From 6cd14a58aaa2f31468604a6c3e62f321fea5308d Mon Sep 17 00:00:00 2001 From: Louis King Date: Tue, 21 Jul 2026 10:21:39 +0100 Subject: [PATCH 01/11] =?UTF-8?q?feat(web):=20React=20frontend=20scaffoldi?= =?UTF-8?q?ng=20=E2=80=94=20Phase=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Vite 6 + TypeScript build replacing esbuild, React 19, React Router 7 - LitBridge wraps unconverted lit-html pages inside React app lifecycle - Shared components: SortableTable, Pagination, FilterForm, StatCard, JsonTree, NodeDisplay, ObserverBadges, RouteTypeBadge, icons, ErrorBoundary, Alerts - Hooks: useAutoRefresh, usePageTitle; utils: api, format, clipboard - i18n via react-i18next mirroring existing translation keys - Native React pages: NotFound, Maintenance; all other routes via LitBridge - Jinja2 shell (spa.html) preserved for navbar, SEO, vendor globals, theme - build.js generates legacy-compatible assets.json from Vite manifest - REACT_MIGRATION.md documents full plan and conversion patterns --- Dockerfile | 2 +- REACT_MIGRATION.md | 210 ++ build.js | 38 +- package-lock.json | 2247 ++++++++++++++--- package.json | 22 +- src/meshcore_hub/web/app.py | 8 +- .../web/static/js/spa-react/App.tsx | 276 ++ .../static/js/spa-react/components/Alerts.tsx | 47 + .../js/spa-react/components/AuthSection.tsx | 85 + .../js/spa-react/components/ErrorBoundary.tsx | 47 + .../js/spa-react/components/FilterForm.tsx | 76 + .../js/spa-react/components/JsonTree.tsx | 156 ++ .../js/spa-react/components/LitBridge.tsx | 80 + .../js/spa-react/components/MobileNav.tsx | 63 + .../js/spa-react/components/NodeDisplay.tsx | 47 + .../spa-react/components/ObserverBadges.tsx | 108 + .../js/spa-react/components/Pagination.tsx | 93 + .../spa-react/components/RouteTypeBadge.tsx | 18 + .../js/spa-react/components/SortableTable.tsx | 121 + .../js/spa-react/components/StatCard.tsx | 32 + .../components/TimezoneIndicator.tsx | 7 + .../js/spa-react/components/icons/index.tsx | 463 ++++ .../js/spa-react/context/AppConfigContext.tsx | 59 + .../js/spa-react/hooks/useAutoRefresh.ts | 58 + .../static/js/spa-react/hooks/usePageTitle.ts | 18 + .../web/static/js/spa-react/i18n/index.ts | 57 + .../web/static/js/spa-react/index.html | 8 + .../web/static/js/spa-react/legacy.d.ts | 7 + .../web/static/js/spa-react/main.tsx | 43 + .../static/js/spa-react/pages/Maintenance.tsx | 21 + .../static/js/spa-react/pages/NotFound.tsx | 33 + .../web/static/js/spa-react/types/config.ts | 70 + .../web/static/js/spa-react/utils/api.ts | 100 + .../static/js/spa-react/utils/clipboard.ts | 59 + .../web/static/js/spa-react/utils/format.ts | 157 ++ .../web/static/js/spa-react/vite-env.d.ts | 1 + src/meshcore_hub/web/templates/spa.html | 3 + tsconfig.json | 23 + vite.config.ts | 38 + 39 files changed, 4598 insertions(+), 403 deletions(-) create mode 100644 REACT_MIGRATION.md create mode 100644 src/meshcore_hub/web/static/js/spa-react/App.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Alerts.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/AuthSection.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/ErrorBoundary.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/FilterForm.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/JsonTree.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/LitBridge.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/ObserverBadges.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Pagination.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/RouteTypeBadge.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/SortableTable.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/StatCard.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/TimezoneIndicator.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/icons/index.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/context/AppConfigContext.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/hooks/useAutoRefresh.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/hooks/usePageTitle.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/i18n/index.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/index.html create mode 100644 src/meshcore_hub/web/static/js/spa-react/legacy.d.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/main.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Maintenance.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/NotFound.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/types/config.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/api.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/clipboard.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/format.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/vite-env.d.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/Dockerfile b/Dockerfile index 3b453e8..613e62c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci -COPY build.js ./ +COPY build.js vite.config.ts tsconfig.json ./ COPY src/meshcore_hub/web/static/css/input.css ./src/meshcore_hub/web/static/css/input.css COPY src/meshcore_hub/web/templates/ ./src/meshcore_hub/web/templates/ COPY src/meshcore_hub/web/static/js/ ./src/meshcore_hub/web/static/js/ diff --git a/REACT_MIGRATION.md b/REACT_MIGRATION.md new file mode 100644 index 0000000..c18ac19 --- /dev/null +++ b/REACT_MIGRATION.md @@ -0,0 +1,210 @@ +# React Migration Plan + +Migration from lit-html (functional templates) to React 19 + TypeScript + Vite. + +## Status + +| 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 | +| 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 | + +## Architecture Decisions + +- **TypeScript** strict mode, `@/` alias → `spa-react/`, `@legacy/` alias → `spa/` +- **Vite 6** replaces esbuild; outputs to `static/dist/` with content-hashed filenames +- **Jinja2 shell preserved** — server renders navbar, SEO meta, config JSON; React owns `
` +- **LitBridge** wraps unconverted pages: dynamic import → `render(container, params, router)` → cleanup +- **react-i18next** loads same locale JSONs from `/static/locales/`; exposes `window.t` for legacy scripts +- **Vendor scripts kept** (leaflet, chart.js, qrcodejs as globals) until Phase 3 converts map/charts +- **DaisyUI + Tailwind v4** unchanged; `@source "../js/"` in input.css scans both spa/ and spa-react/ + +## File Structure + +``` +vite.config.ts # Vite config (root=project, input=spa-react/index.html) +tsconfig.json # Strict TS, path aliases +build.js # Tailwind → vendor copy → vite build → assets.json +package.json # React 19, react-router 7, react-i18next, vite, typescript + +src/meshcore_hub/web/static/js/spa-react/ +├── index.html # Vite HTML entry (not served; Jinja2 is the real shell) +├── main.tsx # Bootstrap: initI18n → render App, AuthSection, MobileNav +├── App.tsx # BrowserRouter, all routes, feature flags, LitBridge wiring +├── vite-env.d.ts +├── legacy.d.ts # TS declarations for @legacy/*.js modules +├── types/config.ts # AppConfig interface, window.__APP_CONFIG__ declaration +├── context/AppConfigContext.tsx # useAppConfig(), useFeatures(), hasRole(), channel labels +├── i18n/index.ts # initI18n() with i18next + language detector +├── hooks/ +│ ├── useAutoRefresh.ts # Timer-based refresh with pause/play +│ └── usePageTitle.ts # Set document.title from entity key +├── utils/ +│ ├── api.ts # Typed apiGet, apiPost, apiPut, apiDelete, apiPostForm +│ ├── format.ts # parseAppDate, formatDateTime, formatRelativeTime, emojis +│ └── clipboard.ts # copyToClipboard with fallback +├── components/ +│ ├── icons/index.tsx # 30+ SVG icon components (IconDashboard, IconNodes, etc.) +│ ├── Alerts.tsx # Loading, ErrorAlert, InfoAlert, SuccessAlert, WarningBadge +│ ├── AuthSection.tsx # Navbar auth dropdown (login button or user menu) +│ ├── MobileNav.tsx # Mobile hamburger nav items +│ ├── ErrorBoundary.tsx # React error boundary with fallback UI +│ ├── LitBridge.tsx # Wraps old lit-html page modules in React lifecycle +│ ├── Pagination.tsx # URL-driven pagination (page param) +│ ├── StatCard.tsx # Dashboard stat card with icon/color +│ ├── NodeDisplay.tsx # Node emoji + name + description +│ ├── FilterForm.tsx # FilterForm + FilterToggle (URL query driven) +│ ├── SortableTable.tsx # SortableTableHeader + MobileSortSelect +│ ├── TimezoneIndicator.tsx # Timezone abbreviation badge +│ ├── ObserverBadges.tsx # Observer filter badges + localStorage helpers +│ ├── RouteTypeBadge.tsx # Flood/Relay/Zero-hop badge +│ └── JsonTree.tsx # Expandable JSON viewer +└── pages/ + ├── NotFound.tsx # ✅ Converted (native React) + └── Maintenance.tsx # ✅ Converted (native React) + +src/meshcore_hub/web/static/js/spa/ # OLD lit-html pages (still used via LitBridge) +├── app.js # Old entry (NO LONGER LOADED — replaced by spa-react/main.tsx) +├── router.js # Old router (replaced by react-router) +├── api.js # Old API client (replaced by utils/api.ts) +├── components.js # Old shared components (replaced by React components) +├── i18n.js # Old i18n (replaced by react-i18next) +├── icons.js # Old icons (replaced by components/icons/) +├── auto-refresh.js # Old auto-refresh (replaced by hooks/useAutoRefresh.ts) +├── json-tree.js # Old JSON tree (replaced by components/JsonTree.tsx) +└── pages/ # Old page modules (loaded via LitBridge until converted) + ├── home.js, dashboard.js, nodes.js, node-detail.js, ... +``` + +## Build Pipeline + +```bash +npm run build +# 1. npx @tailwindcss/cli build (input.css → tailwind.css) +# 2. Copy vendor files (leaflet, chart.js, qrcodejs, fonts) +# 3. npx vite build (bundles React + legacy lit-html pages → dist/assets/) +# 4. Remove stale dist/src/ artifact +# 5. Generate dist/assets.json (compatible format for Jinja2 template) +``` + +The Jinja2 template (`spa.html`) reads `assets.json` for the entry JS filename: +```json +{ "app.js": "assets/index-XXXX.js", "vendor": {...}, "locale_version": "..." } +``` + +Python (`app.py`) loads this manifest at startup and passes `asset_app_js` / `asset_app_css` to the template. + +## Phase 2: Page Conversion + +### Pattern for each page + +1. Create `pages/PageName.tsx`: + - Use `useSearchParams()` for filters/pagination/sort + - Use typed `apiGet()` with `useEffect` + `AbortController` + - Replace lit-html `html\`...\`` with JSX + - Use shared components (Pagination, FilterForm, StatCard, etc.) + - Call `usePageTitle('entities.xxx')` for document title +2. In `App.tsx`: replace ` import("@legacy/pages/xxx.js")} />` with `` +3. Delete `src/meshcore_hub/web/static/js/spa/pages/xxx.js` +4. Run `npm run build` to verify bundle compiles +5. Run `pytest --no-cov tests/test_web/` to verify server tests still pass + +### Conversion order (simplest → most complex) + +| # | 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 | + +### Key patterns in old pages → React equivalents + +| Old pattern | React equivalent | +|-------------|-----------------| +| `render(container, params, router)` | Component with hooks | +| `params.query` | `useSearchParams()` | +| `params.signal` (AbortController) | `useEffect` cleanup + `AbortController` | +| `router.navigate(url)` | `useNavigate()(url)` | +| `litRender(html\`...\`, container)` | JSX return | +| `apiGet(path, params, { signal })` | `apiGet(path, params, { signal })` | +| `getConfig()` | `useAppConfig()` | +| `t('key')` | `useTranslation().t('key')` or `window.t('key')` | +| `createAutoRefresh({ fetchAndRender, toggleContainer })` | `useAutoRefresh({ onRefresh })` | +| `pagination(page, totalPages, basePath, params)` | `` | +| `renderFilterForm({ fields, basePath, navigate })` | `...` | +| `renderStatCard({ icon, color, title, value })` | `` | +| `return () => { chart.destroy(); }` (cleanup) | `useEffect` return cleanup | +| `window.createActivityChart(...)` | Keep as-is until Phase 3 (react-chartjs-2) | +| `window.L.map(...)` (Leaflet) | Keep as-is until Phase 3 (react-leaflet) | +| `window.QRCode(...)` | Keep as-is or use `react-qr-code` package | + +## Phase 3: Charts & Maps + +- Install `react-chartjs-2` (already in package.json) — create typed wrapper components +- Install `react-leaflet` (already in package.json) — create `` component +- Port `charts.js` global functions into React chart components +- Remove leaflet/chart.js vendor ` + + diff --git a/src/meshcore_hub/web/static/js/spa-react/legacy.d.ts b/src/meshcore_hub/web/static/js/spa-react/legacy.d.ts new file mode 100644 index 0000000..c3a7768 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/legacy.d.ts @@ -0,0 +1,7 @@ +declare module "@legacy/pages/*.js" { + export function render( + container: HTMLElement, + params: Record, + router: { navigate: (url: string, replace?: boolean) => void }, + ): Promise<(() => void) | void>; +} diff --git a/src/meshcore_hub/web/static/js/spa-react/main.tsx b/src/meshcore_hub/web/static/js/spa-react/main.tsx new file mode 100644 index 0000000..d784dd2 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/main.tsx @@ -0,0 +1,43 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { AppConfigProvider } from "@/context/AppConfigContext"; +import { initI18n } from "@/i18n"; +import { App } from "@/App"; +import { AuthSection } from "@/components/AuthSection"; +import { MobileNav } from "@/components/MobileNav"; +import type { AppConfig } from "@/types/config"; + +async function bootstrap() { + await initI18n(); + + const config: AppConfig = window.__APP_CONFIG__; + + try { + localStorage.removeItem("meshcore-observers-disabled"); + } catch { + // ignore + } + + const appContainer = document.getElementById("app"); + if (!appContainer) return; + + const wrap = (ui: React.ReactNode) => ( + + {ui} + + ); + + createRoot(appContainer).render(wrap()); + + const authContainer = document.getElementById("auth-section"); + if (authContainer) { + createRoot(authContainer).render(wrap()); + } + + const mobileNavContainer = document.getElementById("mobile-nav"); + if (mobileNavContainer) { + createRoot(mobileNavContainer).render(wrap()); + } +} + +bootstrap(); diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Maintenance.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Maintenance.tsx new file mode 100644 index 0000000..0075793 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Maintenance.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from "react-i18next"; +import { usePageTitle } from "@/hooks/usePageTitle"; + +export function Maintenance() { + const { t } = useTranslation(); + usePageTitle(); + + return ( +
+
+
+
🔧
+

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

+

{t("maintenance.description")}

+
+
+
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/NotFound.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/NotFound.tsx new file mode 100644 index 0000000..f169fe5 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/NotFound.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { IconHome, IconNodes } from "@/components/icons"; +import { usePageTitle } from "@/hooks/usePageTitle"; + +export function NotFound() { + const { t } = useTranslation(); + usePageTitle(); + + return ( +
+
+
+
404
+

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

+

{t("not_found.description")}

+
+ + + {t("common.go_home")} + + + + {t("common.view_entity", { entity: t("entities.nodes") })} + +
+
+
+
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/types/config.ts b/src/meshcore_hub/web/static/js/spa-react/types/config.ts new file mode 100644 index 0000000..5a40cbb --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/types/config.ts @@ -0,0 +1,70 @@ +export interface RadioConfigDisplay { + profile?: string; + frequency?: string; + bandwidth?: string; + spreading_factor?: string; + coding_rate?: string; + tx_power?: string; +} + +export interface CustomPage { + slug: string; + title: string; + url: string; + menu_order: number; +} + +export interface OidcUser { + sub: string; + name?: string; + email?: string; + picture?: string; +} + +export interface AppConfig { + network_name: string; + network_city?: string; + network_country?: string; + network_radio_config?: RadioConfigDisplay; + network_contact_email?: string; + network_contact_discord?: string; + network_contact_github?: string; + network_contact_youtube?: string; + network_welcome_text?: string; + features: Record; + custom_pages: CustomPage[]; + logo_url: string; + version: string; + timezone: string; + timezone_iana: string; + default_theme: string; + locale: string; + datetime_locale: string; + auto_refresh_seconds: number; + channel_labels: Record; + logo_invert_light: boolean; + debug: boolean; + locale_version: string; + system_maintenance: boolean; + spam_score_threshold: number; + oidc_enabled: boolean; + user: OidcUser | null; + roles: string[]; + role_names: Record; +} + +declare global { + interface Window { + __APP_CONFIG__: AppConfig; + t: (key: string, params?: Record) => string; + formatNumber: (value: number | string | null | undefined) => string; + createActivityChart: (...args: unknown[]) => unknown; + createLineChart: (...args: unknown[]) => unknown; + createStackedBarChart: (...args: unknown[]) => unknown; + createRoutesTrendChart: (...args: unknown[]) => unknown; + createRouteDetailStrip: (...args: unknown[]) => unknown; + initDashboardCharts: (...args: unknown[]) => unknown; + } +} + +export {}; diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/api.ts b/src/meshcore_hub/web/static/js/spa-react/utils/api.ts new file mode 100644 index 0000000..1b89017 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/api.ts @@ -0,0 +1,100 @@ +import type { AppConfig } from "@/types/config"; + +export function isAbortError(e: unknown): boolean { + return e instanceof DOMException && e.name === "AbortError"; +} + +function checkAuthResponse(response: Response): void { + const config: AppConfig | undefined = window.__APP_CONFIG__; + if (config?.oidc_enabled && response.status === 401) { + const next = encodeURIComponent( + window.location.pathname + window.location.search, + ); + window.location.href = `/auth/login?next=${next}`; + } +} + +export async function apiGet( + path: string, + params: Record = {}, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + const url = new URL(path, window.location.origin); + for (const [k, v] of Object.entries(params)) { + if (v !== null && v !== undefined && v !== "") { + if (Array.isArray(v)) { + v.forEach((item) => url.searchParams.append(k, String(item))); + } else { + url.searchParams.set(k, String(v)); + } + } + } + const response = await fetch(url, { signal }); + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`); + } + return response.json(); +} + +export async function apiPost( + path: string, + body: unknown, +): Promise { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + checkAuthResponse(response); + if (!response.ok) { + const text = await response.text(); + throw new Error(`API error: ${response.status} - ${text}`); + } + if (response.status === 204) return null; + return response.json(); +} + +export async function apiPut( + path: string, + body: unknown, +): Promise { + const response = await fetch(path, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + checkAuthResponse(response); + if (!response.ok) { + const text = await response.text(); + throw new Error(`API error: ${response.status} - ${text}`); + } + if (response.status === 204) return null; + return response.json(); +} + +export async function apiDelete(path: string): Promise { + const response = await fetch(path, { method: "DELETE" }); + checkAuthResponse(response); + if (!response.ok) { + const text = await response.text(); + throw new Error(`API error: ${response.status} - ${text}`); + } +} + +export async function apiPostForm( + path: string, + data: Record, +): Promise { + const body = new URLSearchParams(data); + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`API error: ${response.status} - ${text}`); + } + if (response.status === 204) return null; + return response.json(); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/clipboard.ts b/src/meshcore_hub/web/static/js/spa-react/utils/clipboard.ts new file mode 100644 index 0000000..fa2cb9b --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/clipboard.ts @@ -0,0 +1,59 @@ +export function copyToClipboard( + e: React.MouseEvent, + text: string, +): void { + e.preventDefault(); + e.stopPropagation(); + + const targetElement = e.currentTarget as HTMLElement; + + const showSuccess = (target: HTMLElement) => { + const originalText = target.textContent; + target.textContent = "Copied!"; + target.classList.add("text-success"); + setTimeout(() => { + target.textContent = originalText; + target.classList.remove("text-success"); + }, 1500); + }; + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard + .writeText(text) + .then(() => showSuccess(targetElement)) + .catch((err) => { + console.error("Clipboard API failed:", err); + fallbackCopy(text, targetElement); + }); + } else { + fallbackCopy(text, targetElement); + } +} + +function fallbackCopy(text: string, target: HTMLElement): void { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + textArea.style.top = "-999999px"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand("copy"); + showSuccess(target); + } catch (err) { + console.error("Fallback copy failed:", err); + } + document.body.removeChild(textArea); +} + +function showSuccess(target: HTMLElement): void { + const originalText = target.textContent; + target.textContent = "Copied!"; + target.classList.add("text-success"); + setTimeout(() => { + target.textContent = originalText; + target.classList.remove("text-success"); + }, 1500); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/format.ts b/src/meshcore_hub/web/static/js/spa-react/utils/format.ts new file mode 100644 index 0000000..79845fe --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/format.ts @@ -0,0 +1,157 @@ +import { useAppConfig } from "@/context/AppConfigContext"; + +export function parseAppDate(isoString: string | null): Date | null { + if (!isoString || typeof isoString !== "string") return null; + + let value = isoString.trim(); + if (!value) return null; + + if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}/.test(value)) { + value = value.replace(/\s+/, "T"); + } + + const hasTimePart = /T\d{2}:\d{2}/.test(value); + const hasTimezoneSuffix = /(Z|[+-]\d{2}:\d{2}|[+-]\d{4})$/i.test(value); + if (hasTimePart && !hasTimezoneSuffix) { + value += "Z"; + } + + const parsed = new Date(value); + if (isNaN(parsed.getTime())) return null; + return parsed; +} + +export function formatNumber( + value: number | string | null | undefined, +): string { + if (value === null || value === undefined || value === "") return ""; + const n = Number(value); + if (!Number.isFinite(n)) return String(value); + return new Intl.NumberFormat().format(n); +} + +export function useFormatDateTime() { + const config = useAppConfig(); + const tz = config.timezone_iana || "UTC"; + const locale = config.datetime_locale || "en-US"; + + return { + formatDateTime( + isoString: string | null, + options?: Intl.DateTimeFormatOptions, + ): string { + if (!isoString) return "-"; + try { + const date = parseAppDate(isoString); + if (!date) return "-"; + const opts = options ?? { + timeZone: tz, + year: "numeric" as const, + month: "2-digit" as const, + day: "2-digit" as const, + hour: "2-digit" as const, + minute: "2-digit" as const, + second: "2-digit" as const, + hour12: false, + }; + if (!opts.timeZone) opts.timeZone = tz; + return date.toLocaleString(locale, opts); + } catch { + return isoString ? isoString.slice(0, 19).replace("T", " ") : "-"; + } + }, + + formatDateTimeShort(isoString: string | null): string { + if (!isoString) return "-"; + try { + const date = parseAppDate(isoString); + if (!date) return "-"; + return date.toLocaleString(locale, { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + } catch { + return isoString ? isoString.slice(0, 16).replace("T", " ") : "-"; + } + }, + }; +} + +export function formatRelativeTime(isoString: string | null): string { + if (!isoString) return ""; + const date = parseAppDate(isoString); + if (!date) return ""; + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + const t = window.t; + if (diffDay > 0) return t("time.days_ago", { count: diffDay }); + if (diffHour > 0) return t("time.hours_ago", { count: diffHour }); + if (diffMin > 0) return t("time.minutes_ago", { count: diffMin }); + return t("time.less_than_minute"); +} + +export function truncateKey(key: string | null, length = 12): string { + if (!key) return "-"; + if (key.length <= length) return key; + return key.slice(0, length) + "..."; +} + +function inferNodeType(value: string | null): string | null { + const normalized = (value ?? "").toLowerCase(); + if (!normalized) return null; + if (normalized.includes("room")) return "room"; + if (normalized.includes("repeater") || normalized.includes("relay")) + return "repeater"; + if (normalized.includes("companion") || normalized.includes("observer")) + return "companion"; + if (normalized.includes("chat")) return "chat"; + return null; +} + +export function typeEmoji(advType: string | null): string { + switch (inferNodeType(advType) ?? (advType ?? "").toLowerCase()) { + case "chat": + return "\u{1F4AC}"; + case "repeater": + return "\u{1F4E1}"; + case "companion": + return "\u{1F4F1}"; + case "room": + return "\u{1FAA7}"; + default: + return "\u{1F4CD}"; + } +} + +export function extractFirstEmoji(str: string | null): string | null { + if (!str) return null; + const emojiRegex = + /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{231A}-\u{231B}\u{23E9}-\u{23FA}\u{25AA}-\u{25AB}\u{25B6}\u{25C0}\u{25FB}-\u{25FE}\u{2B50}\u{2B55}\u{3030}\u{303D}\u{3297}\u{3299}](?:\u{FE0F})?(?:\u{200D}[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}](?:\u{FE0F})?)*|\u{00A9}|\u{00AE}|\u{203C}|\u{2049}|\u{2122}|\u{2139}|\u{2194}-\u{2199}|\u{21A9}-\u{21AA}|\u{24C2}|\u{2934}-\u{2935}|\u{2B05}-\u{2B07}|\u{2B1B}-\u{2B1C}/u; + const match = str.match(emojiRegex); + return match ? match[0] : null; +} + +export function getNodeEmoji( + nodeName: string | null, + advType: string | null, +): string { + const nameEmoji = extractFirstEmoji(nodeName); + if (nameEmoji) return nameEmoji; + const inferred = inferNodeType(advType) ?? inferNodeType(nodeName); + return typeEmoji(inferred ?? advType); +} + +export function getPageColor(name: string): string { + return getComputedStyle(document.documentElement) + .getPropertyValue(`--color-${name}`) + .trim(); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/vite-env.d.ts b/src/meshcore_hub/web/static/js/spa-react/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html index 2cb5655..19364da 100644 --- a/src/meshcore_hub/web/templates/spa.html +++ b/src/meshcore_hub/web/templates/spa.html @@ -217,6 +217,9 @@ + {% if asset_app_css %} + + {% endif %} {% if asset_app_js %} {% else %} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..de5256f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowJs": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/meshcore_hub/web/static/js/spa-react/*"], + "@legacy/*": ["src/meshcore_hub/web/static/js/spa/*"] + } + }, + "include": ["src/meshcore_hub/web/static/js/spa-react"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..1486c80 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; + +const SPA_REACT = resolve( + __dirname, + "src/meshcore_hub/web/static/js/spa-react", +); +const SPA_LEGACY = resolve( + __dirname, + "src/meshcore_hub/web/static/js/spa", +); +const DIST = resolve(__dirname, "src/meshcore_hub/web/static/dist"); + +export default defineConfig({ + base: "/static/dist/", + plugins: [react()], + resolve: { + alias: { + "@": SPA_REACT, + "@legacy": SPA_LEGACY, + }, + }, + build: { + outDir: DIST, + emptyOutDir: true, + manifest: true, + rollupOptions: { + input: resolve(SPA_REACT, "index.html"), + output: { + manualChunks: { + vendor: ["react", "react-dom", "react-router"], + i18n: ["i18next", "react-i18next", "i18next-browser-languagedetector"], + }, + }, + }, + }, +}); From 8322b5cf9feb3d451375adfd1509945698bd87df Mon Sep 17 00:00:00 2001 From: Louis King Date: Tue, 21 Jul 2026 16:50:33 +0100 Subject: [PATCH 02/11] =?UTF-8?q?feat(web):=20convert=20all=2015=20SPA=20p?= =?UTF-8?q?ages=20to=20native=20React=20=E2=80=94=20Phase=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert every remaining lit-html page to React 19 + TypeScript and wire them directly into the router, removing all LitBridge usage from App.tsx: - Home, CustomPage, Profile, Members, Channels - Advertisements, Messages, Routes, Nodes, NodeDetail - Packets, PacketDetail, PacketGroupDetail, Dashboard, MapPage Pages use the shared React infrastructure (apiGet, useAutoRefresh, usePageTitle, useFormatDateTime, Pagination, FilterForm, SortableTable, NodeDisplay, ObserverBadges, RouteTypeBadge, JsonTree, StatCard, icons). Charts/maps still call window.Chart / window.L / window.QRCode / charts.js globals — these move to react-chartjs-2 / react-leaflet in Phase 3. The old lit-html code in spa/ is intentionally kept as the spa.html fallback (rendered only when the Vite bundle is absent) and is still referenced by 5 web tests; it will be removed in Phase 4. Added IconSatelliteDish, IconRuler, IconHopSpan, IconPathLength icons. Verified: tsc --noEmit clean, npm run build (94 modules), pytest tests/test_web/ (256 passed), pre-commit (passed). --- REACT_MIGRATION.md | 43 +- .../web/static/js/spa-react/App.tsx | 129 +- .../js/spa-react/components/icons/index.tsx | 68 + .../js/spa-react/pages/Advertisements.tsx | 565 ++++++ .../static/js/spa-react/pages/Channels.tsx | 478 +++++ .../static/js/spa-react/pages/CustomPage.tsx | 70 + .../static/js/spa-react/pages/Dashboard.tsx | 649 +++++++ .../web/static/js/spa-react/pages/Home.tsx | 482 +++++ .../web/static/js/spa-react/pages/MapPage.tsx | 604 ++++++ .../web/static/js/spa-react/pages/Members.tsx | 245 +++ .../static/js/spa-react/pages/Messages.tsx | 781 ++++++++ .../static/js/spa-react/pages/NodeDetail.tsx | 957 ++++++++++ .../web/static/js/spa-react/pages/Nodes.tsx | 433 +++++ .../js/spa-react/pages/PacketDetail.tsx | 247 +++ .../js/spa-react/pages/PacketGroupDetail.tsx | 702 +++++++ .../web/static/js/spa-react/pages/Packets.tsx | 527 ++++++ .../web/static/js/spa-react/pages/Profile.tsx | 360 ++++ .../web/static/js/spa-react/pages/Routes.tsx | 1636 +++++++++++++++++ 18 files changed, 8893 insertions(+), 83 deletions(-) create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx diff --git a/REACT_MIGRATION.md b/REACT_MIGRATION.md index c18ac19..d38678b 100644 --- a/REACT_MIGRATION.md +++ b/REACT_MIGRATION.md @@ -7,11 +7,18 @@ Migration from lit-html (functional templates) to React 19 + TypeScript + Vite. | Phase | Description | Status | |-------|-------------|--------| | 1 | Infrastructure (Vite, React shell, router, LitBridge, build pipeline, shared components) | **Complete** | -| 2 | Convert pages one-by-one from LitBridge to native React | Not started | +| 2 | Convert pages one-by-one from LitBridge to native React | **Complete** | | 3 | Chart & map components (react-chartjs-2, react-leaflet) | Not started | | 4 | Cleanup (remove lit-html, old spa/, build.js esbuild remnants) | Not started | | 5 | Optional enhancements (tests, react-query, Storybook) | Not started | +> **Phase 2 status:** All 15 pages are converted to native React and wired into `App.tsx`. +> The old lit-html code in `spa/` is intentionally **kept** as the `spa.html` fallback +> (rendered only when the Vite bundle/manifest is absent) and is still referenced by +> 5 web tests. It will be removed in Phase 4, after those tests are updated. +> Charts/maps still use `window.Chart`, `window.L`, `window.QRCode`, and the `charts.js` +> globals — these move to `react-chartjs-2` / `react-leaflet` in Phase 3. + ## Architecture Decisions - **TypeScript** strict mode, `@/` alias → `spa-react/`, `@legacy/` alias → `spa/` @@ -116,23 +123,23 @@ Python (`app.py`) loads this manifest at startup and passes `asset_app_js` / `as | # | Page | File | Complexity | Notes | |---|------|------|-----------|-------| -| 1 | NotFound | `not-found.js` | Done | Already native React | -| 2 | Maintenance | `maintenance.js` | Done | Already native React | -| 3 | Home | `home.js` | Low | Stats + nav cards + activity chart (uses `window.createActivityChart`) | -| 4 | CustomPage | `custom-page.js` | Low | Fetches markdown HTML → `dangerouslySetInnerHTML` | -| 5 | Profile | `profile.js` | Medium | Form + PUT + QR code (uses `window.QRCode`) | -| 6 | Members | `members.js` | Medium | Table + filters + pagination | -| 7 | Channels | `channels.js` | Medium | Table + admin CRUD modals | -| 8 | Advertisements | `advertisements.js` | Medium | Table + filters + auto-refresh + observer badges | -| 9 | Messages | `messages.js` | Medium | Table + filters + auto-refresh + observer badges | -| 10 | Routes | `routes.js` | Med-High | Table + filters + history + chart strips | -| 11 | Nodes | `nodes.js` | Med-High | Table + filters + pagination + auto-refresh + observer filter | -| 12 | NodeDetail | `node-detail.js` | High | Tabs, charts, tables, QR, adopt/tags CRUD | -| 13 | Packets | `packets.js` | Medium | Table + filters + auto-refresh | -| 14 | PacketDetail | `packet-detail.js` | Med-High | JSON tree + raw data | -| 15 | PacketGroupDetail | `packet-group-detail.js` | Medium | Grouped packet list | -| 16 | Dashboard | `dashboard.js` | High | Multiple charts, stat cards, auto-refresh | -| 17 | Map | `map.js` | High | Leaflet map, markers, popups, layers | +| 1 | NotFound | `not-found.js` | Done | Native React | +| 2 | Maintenance | `maintenance.js` | Done | Native React | +| 3 | Home | `home.js` | Done | Stats + nav cards + activity chart (still uses `window.createActivityChart`) | +| 4 | CustomPage | `custom-page.js` | Done | Fetches markdown HTML → `dangerouslySetInnerHTML` | +| 5 | Profile | `profile.js` | Done | Form + PUT + adopted nodes | +| 6 | Members | `members.js` | Done | Profile tiles grouped by role | +| 7 | Channels | `channels.js` | Done | Cards + admin CRUD modals + QR (`window.QRCode`) | +| 8 | Advertisements | `advertisements.js` | Done | Table + filters + auto-refresh + observer badges | +| 9 | Messages | `messages.js` | Done | Table + filters + auto-refresh + observer badges + dedupe | +| 10 | Routes | `routes.js` | Done | Cards + quality + history strips (`window.createRouteDetailStrip`) + admin CRUD | +| 11 | Nodes | `nodes.js` | Done | Table + filters + pagination + auto-refresh | +| 12 | NodeDetail | `node-detail.js` | Done | Map (`window.L`), QR, adopt/tags CRUD | +| 13 | Packets | `packets.js` | Done | Table + filters + auto-refresh | +| 14 | PacketDetail | `packet-detail.js` | Done | JSON tree + raw data | +| 15 | PacketGroupDetail | `packet-group-detail.js` | Done | Grouped receptions + path popover | +| 16 | Dashboard | `dashboard.js` | Done | Charts (`window.initDashboardCharts`), stat cards, route health | +| 17 | Map | `map.js` | Done | Leaflet map (`window.L`), markers, popups, filters | ### Key patterns in old pages → React equivalents diff --git a/src/meshcore_hub/web/static/js/spa-react/App.tsx b/src/meshcore_hub/web/static/js/spa-react/App.tsx index 994f30f..52f70ad 100644 --- a/src/meshcore_hub/web/static/js/spa-react/App.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from "react"; +import { useEffect } from "react"; import { BrowserRouter, Routes, @@ -7,10 +7,23 @@ import { useLocation, useParams, } from "react-router"; -import { useTranslation } from "react-i18next"; import { useAppConfig } from "@/context/AppConfigContext"; import { ErrorBoundary } from "@/components/ErrorBoundary"; -import { LitBridge } from "@/components/LitBridge"; +import { HomePage } from "@/pages/Home"; +import { DashboardPage } from "@/pages/Dashboard"; +import { Nodes } from "@/pages/Nodes"; +import { NodeDetailPage } from "@/pages/NodeDetail"; +import { Channels } from "@/pages/Channels"; +import { RoutesPage } from "@/pages/Routes"; +import { Messages } from "@/pages/Messages"; +import { Advertisements } from "@/pages/Advertisements"; +import { Packets } from "@/pages/Packets"; +import { PacketDetail } from "@/pages/PacketDetail"; +import { PacketGroupDetail } from "@/pages/PacketGroupDetail"; +import { MapPage } from "@/pages/MapPage"; +import { Members } from "@/pages/Members"; +import { CustomPagePage } from "@/pages/CustomPage"; +import { Profile } from "@/pages/Profile"; import { NotFound } from "@/pages/NotFound"; import { Maintenance } from "@/pages/Maintenance"; @@ -74,24 +87,6 @@ function ShortLinkRedirect() { return ; } -function LitPage({ - loader, -}: { - loader: () => Promise<{ - render: ( - container: HTMLElement, - params: Record, - router: { navigate: (url: string, replace?: boolean) => void }, - ) => Promise<(() => void) | void>; - }>; -}) { - return ( - - - - ); -} - function AppRoutes() { const config = useAppConfig(); const features = config.features ?? {}; @@ -112,16 +107,18 @@ function AppRoutes() { import("@legacy/pages/home.js")} /> + + + } /> {features.dashboard !== false && ( import("@legacy/pages/dashboard.js")} - /> + + + } /> )} @@ -130,15 +127,17 @@ function AppRoutes() { import("@legacy/pages/nodes.js")} /> + + + } /> import("@legacy/pages/node-detail.js")} - /> + + + } /> } /> @@ -148,9 +147,9 @@ function AppRoutes() { import("@legacy/pages/channels.js")} - /> + + + } /> )} @@ -158,7 +157,9 @@ function AppRoutes() { import("@legacy/pages/routes.js")} /> + + + } /> )} @@ -166,9 +167,9 @@ function AppRoutes() { import("@legacy/pages/messages.js")} - /> + + + } /> )} @@ -176,9 +177,9 @@ function AppRoutes() { import("@legacy/pages/advertisements.js")} - /> + + + } /> )} @@ -187,29 +188,25 @@ function AppRoutes() { import("@legacy/pages/packets.js")} - /> + + + } /> - import("@legacy/pages/packet-group-detail.js") - } - /> + + + } /> - import("@legacy/pages/packet-detail.js") - } - /> + + + } /> @@ -218,7 +215,9 @@ function AppRoutes() { import("@legacy/pages/map.js")} /> + + + } /> )} @@ -226,9 +225,9 @@ function AppRoutes() { import("@legacy/pages/members.js")} - /> + + + } /> )} @@ -236,9 +235,9 @@ function AppRoutes() { import("@legacy/pages/custom-page.js")} - /> + + + } /> )} @@ -247,17 +246,17 @@ function AppRoutes() { import("@legacy/pages/profile.js")} - /> + + + } /> import("@legacy/pages/profile.js")} - /> + + + } /> diff --git a/src/meshcore_hub/web/static/js/spa-react/components/icons/index.tsx b/src/meshcore_hub/web/static/js/spa-react/components/icons/index.tsx index 4735de7..d723bb2 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/icons/index.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/icons/index.tsx @@ -435,6 +435,74 @@ export function IconTxPower(props: IconProps) { ); } +export function IconSatelliteDish(props: IconProps) { + return ( + + + + + + + ); +} + +export function IconRuler(props: IconProps) { + return ( + + + + + + + + ); +} + export function IconClock(props: IconProps) { return ( diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx new file mode 100644 index 0000000..ff2c333 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx @@ -0,0 +1,565 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useAppConfig } from "@/context/AppConfigContext"; +import { apiGet, isAbortError } from "@/utils/api"; +import { formatNumber, useFormatDateTime } from "@/utils/format"; +import { copyToClipboard } from "@/utils/clipboard"; +import { usePageTitle } from "@/hooks/usePageTitle"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { Pagination } from "@/components/Pagination"; +import { FilterForm, FilterToggle } from "@/components/FilterForm"; +import { MobileSortSelect, SortableTableHeader } from "@/components/SortableTable"; +import { NodeDisplay } from "@/components/NodeDisplay"; +import { + ObserverFilterBadges, + ObserverIcons, + getDisabledObserverAreas, + toggleObserverArea, +} from "@/components/ObserverBadges"; +import { RouteTypeBadge } from "@/components/RouteTypeBadge"; +import { Loading, WarningBadge } from "@/components/Alerts"; +import { IconRefresh } from "@/components/icons"; + +interface ObserverInfo { + node_id?: string; + public_key: string; + name?: string; + tag_name?: string; + snr?: number | null; + observed_at?: string; +} + +interface Advertisement { + public_key: string; + name?: string | null; + node_name?: string | null; + node_tag_name?: string | null; + node_tag_description?: string | null; + adv_type?: string | null; + route_type?: string | null; + received_at: string; + packet_hash?: string | null; + observed_by?: string | null; + observers?: ObserverInfo[]; +} + +interface NodeItem { + public_key: string; + tags?: { key: string; value: string | null }[]; +} + +interface OperatorProfile { + id: string; + user_id: string; + name?: string | null; + callsign?: string | null; + roles: string[]; +} + +interface ListResponse { + items?: T[]; + total?: number; +} + +function submitOnEnter(e: React.KeyboardEvent) { + if (e.key === "Enter") e.currentTarget.form?.requestSubmit(); +} + +function autoSubmit(e: React.ChangeEvent) { + e.currentTarget.form?.requestSubmit(); +} + +export function Advertisements() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const config = useAppConfig(); + const { formatDateTime, formatDateTimeShort } = useFormatDateTime(); + usePageTitle("entities.advertisements"); + + const search = searchParams.get("search") ?? ""; + const adoptedBy = searchParams.get("adopted_by") ?? ""; + const routeType = searchParams.get("route_type") ?? "flood,transport_flood"; + const page = parseInt(searchParams.get("page") ?? "", 10) || 1; + const limit = parseInt(searchParams.get("limit") ?? "", 10) || 20; + const sort = searchParams.get("sort") ?? "time"; + const order = searchParams.get("order") ?? "desc"; + const offset = (page - 1) * limit; + + const features = config.features ?? {}; + const packetsEnabled = features.packets !== false; + const tz = config.timezone || ""; + + const [items, setItems] = useState(null); + const [total, setTotal] = useState(null); + const [error, setError] = useState(null); + const [sortedAreas, setSortedAreas] = useState([]); + const [operators, setOperators] = useState([]); + const [disabledAreas, setDisabledAreas] = useState>(() => + getDisabledObserverAreas(), + ); + const [filterOpen, setFilterOpen] = useState( + search !== "" || + (config.oidc_enabled && adoptedBy !== "") || + routeType !== "flood,transport_flood", + ); + + const disabledAreasRef = useRef(disabledAreas); + disabledAreasRef.current = disabledAreas; + const abortRef = useRef(null); + + const fetchData = useCallback(async () => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + const { signal } = controller; + try { + const nodesPromise = apiGet>( + "/api/v1/nodes", + { limit: 500, observer: true }, + { signal }, + ); + const profilesPromise = config.oidc_enabled + ? apiGet>( + "/api/v1/user/profiles", + { limit: 500 }, + { signal }, + ) + : Promise.resolve(null); + const [nodesData, profilesData] = await Promise.all([ + nodesPromise, + profilesPromise, + ]); + + const operatorRole = config.role_names?.operator || "operator"; + const profiles = (profilesData?.items ?? []) + .filter((p) => p.roles?.includes(operatorRole)) + .sort((a, b) => + (a.name || a.callsign || "").localeCompare( + b.name || b.callsign || "", + ), + ); + setOperators(profiles); + + const areaMap = new Map(); + for (const n of nodesData.items ?? []) { + const area = n.tags?.find((tg) => tg.key === "area")?.value; + if (!area || !area.trim()) continue; + const key = area.trim(); + if (!areaMap.has(key)) areaMap.set(key, []); + areaMap.get(key)!.push(n.public_key); + } + const areas = [...areaMap.keys()].sort((a, b) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ); + setSortedAreas(areas); + + const disabled = disabledAreasRef.current; + const observerFilterActive = areas.some((a) => disabled.has(a)); + const apiParams: Record = { + limit, + offset, + search, + sort, + order, + route_type: routeType, + }; + if (observerFilterActive) { + apiParams.observed_by = areas + .filter((a) => !disabled.has(a)) + .flatMap((a) => areaMap.get(a) ?? []); + } + if (adoptedBy) apiParams.adopted_by = adoptedBy; + + const data = await apiGet>( + "/api/v1/advertisements", + apiParams, + { signal }, + ); + setItems(data.items ?? []); + setTotal(data.total ?? 0); + setError(null); + } catch (e) { + if (isAbortError(e)) return; + setError(e instanceof Error ? e.message : String(e)); + } + }, [limit, offset, search, sort, order, routeType, adoptedBy, config]); + + useEffect(() => { + fetchData(); + return () => abortRef.current?.abort(); + }, [fetchData, disabledAreas]); + + const { paused, toggle, intervalSeconds } = useAutoRefresh({ + onRefresh: fetchData, + }); + + const handleObserverToggle = (area: string) => { + const updated = toggleObserverArea(area, sortedAreas.length); + setDisabledAreas(new Set(updated)); + if (page > 1) { + const sp = new URLSearchParams(searchParams); + sp.delete("page"); + const qs = sp.toString(); + navigate(qs ? `/advertisements?${qs}` : "/advertisements"); + } + }; + + const totalPages = total !== null ? Math.ceil(total / limit) : 0; + const headerParams = { + search, + adopted_by: adoptedBy, + route_type: routeType, + limit: String(limit), + }; + const paginationParams = { ...headerParams, sort, order }; + const emptyMessage = t("common.no_entity_found", { + entity: t("entities.advertisements").toLowerCase(), + }); + + const renderReceivers = (ad: Advertisement, variant: "mobile" | "desktop") => { + if (ad.observers && ad.observers.length >= 1) { + return ; + } + if (ad.observed_by) { + return ( + + {"\u{1F4E1}"} + + ); + } + return variant === "desktop" ? - : null; + }; + + return ( + <> +
+

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

+ {tz && tz !== "UTC" && ( + {tz} + )} +
+ +
+ {total !== null && ( + + {t("common.total", { count: formatNumber(total) })} + + )} + {error && } +
+ {intervalSeconds > 0 && ( + + )} +
+
+ setFilterOpen((o) => !o)} + /> +
+
+ + {filterOpen && ( +
+ +
+ + +
+
+ + +
+ {config.oidc_enabled && operators.length > 0 && ( +
+ + +
+ )} +
+
+ )} + + {items === null ? ( + + ) : ( + <> + + + + + + +
+ {items.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + items.map((ad, idx) => { + const adName = + ad.node_tag_name || ad.node_name || ad.name || null; + const detailUrl = + packetsEnabled && ad.packet_hash + ? `/packets/hash/${ad.packet_hash}` + : null; + return ( +
navigate(detailUrl) : undefined + } + > +
+
+ e.stopPropagation()} + > + + +
+
+ {formatDateTimeShort(ad.received_at)} +
+
+ + {renderReceivers(ad, "mobile")} +
+
+
+
+
+ ); + }) + )} +
+ +
+ + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((ad, idx) => { + const adName = + ad.node_tag_name || ad.node_name || ad.name || null; + const detailUrl = + packetsEnabled && ad.packet_hash + ? `/packets/hash/${ad.packet_hash}` + : null; + return ( + navigate(detailUrl) : undefined + } + > + + + + + + + ); + }) + )} + +
{t("advertisements.col_route_type")}{t("common.observers")}
+ {emptyMessage} +
+ e.stopPropagation()} + > + + + + + copyToClipboard(e, ad.public_key) + } + title="Click to copy" + > + {ad.public_key} + + + + + {formatDateTime(ad.received_at)} + {renderReceivers(ad, "desktop")}
+
+ + + + )} + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx new file mode 100644 index 0000000..18ab93e --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx @@ -0,0 +1,478 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router"; + +import { useAppConfig, hasRole } from "@/context/AppConfigContext"; +import { apiGet, apiPost, apiPut, apiDelete } from "@/utils/api"; +import { usePageTitle } from "@/hooks/usePageTitle"; +import { Loading, ErrorAlert } from "@/components/Alerts"; +import { IconChannel, IconPlus, IconEdit, IconTrash } from "@/components/icons"; + +interface QRCodeOptions { + text: string; + width: number; + height: number; + correctLevel: number; +} + +interface QRCodeConstructor { + new (el: HTMLElement, options: QRCodeOptions): unknown; + CorrectLevel: { L: number; M: number; Q: number; H: number }; +} + +declare global { + interface Window { + QRCode: QRCodeConstructor; + } +} + +interface Channel { + id: string; + name: string; + channel_hash: string; + visibility: string; + enabled: boolean; + masked_key: string; + key_hex: string | null; + created_at: string; + updated_at: string; +} + +interface ChannelListResponse { + items: Channel[]; + total: number; +} + +const VISIBILITY_ORDER = ["community", "member", "operator", "admin"]; + +type ModalState = + | { type: "add" } + | { type: "edit"; channel: Channel } + | { type: "delete"; channel: Channel }; + +function ChannelQrCode({ channel }: { channel: Channel }) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el || !channel.key_hex || el.hasChildNodes()) return; + const qrUrl = `meshcore://channel/add?name=${encodeURIComponent(channel.name)}&secret=${channel.key_hex.toLowerCase()}`; + new window.QRCode(el, { + text: qrUrl, + width: 128, + height: 128, + correctLevel: window.QRCode.CorrectLevel.M, + }); + }, [channel]); + + return
; +} + +interface ChannelCardProps { + channel: Channel; + oidcEnabled: boolean; + isAdmin: boolean; + onEdit: () => void; + onDelete: () => void; + onNavigate: (channelIdx: number) => void; +} + +function ChannelCard({ + channel, + oidcEnabled, + isAdmin, + onEdit, + onDelete, + onNavigate, +}: ChannelCardProps) { + const { t } = useTranslation(); + const channelIdx = parseInt(channel.channel_hash, 16); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onNavigate(channelIdx); + } + }; + + return ( +
onNavigate(channelIdx)} + onKeyDown={handleKeyDown} + > +
+
+

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

+ {channel.key_hex && ( +
+ {channel.key_hex.toLowerCase()} +
+ )} + {isAdmin && ( +
+ + +
+ )} +
+
+ {channel.key_hex && } +
+
+
+ ); +} + +interface ChannelModalProps { + isEdit: boolean; + channel: Channel | null; + saving: boolean; + onSave: (body: Record) => void; + onCancel: () => void; +} + +function ChannelModal({ + isEdit, + channel, + saving, + onSave, + onCancel, +}: ChannelModalProps) { + const { t } = useTranslation(); + const [name, setName] = useState(channel?.name ?? ""); + const [keyHex, setKeyHex] = useState(""); + const [visibility, setVisibility] = useState( + channel?.visibility ?? "community", + ); + const [enabled, setEnabled] = useState(channel?.enabled !== false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const body: Record = { visibility, enabled }; + if (!isEdit) { + body.name = name.trim(); + body.key_hex = keyHex.trim().toUpperCase(); + } + onSave(body); + }; + + const title = isEdit + ? t("channels.edit_channel") + : t("channels.add_channel"); + + return ( + +
+

{title}

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

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

+

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

+
+ + +
+
+
+ +
+
+ ); +} + +export function Channels() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const config = useAppConfig(); + const oidcEnabled = config.oidc_enabled; + const isAdmin = hasRole("admin"); + usePageTitle("channels.title"); + + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modal, setModal] = useState(null); + const [saving, setSaving] = useState(false); + + const fetchChannels = useCallback(async () => { + try { + const data = await apiGet("/api/v1/channels"); + setChannels(data.items || []); + setError(null); + } catch (e) { + setError((e as Error).message || t("common.failed_to_load_page")); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + fetchChannels(); + }, [fetchChannels]); + + const handleSave = async (body: Record) => { + setSaving(true); + try { + if (modal?.type === "edit") { + await apiPut(`/api/v1/channels/${modal.channel.id}`, body); + } else { + await apiPost("/api/v1/channels", body); + } + setModal(null); + await fetchChannels(); + } catch (e) { + alert((e as Error).message || "Failed to save channel"); + } finally { + setSaving(false); + } + }; + + const handleDeleteConfirm = async () => { + if (modal?.type !== "delete") return; + setSaving(true); + try { + await apiDelete(`/api/v1/channels/${modal.channel.id}`); + setModal(null); + await fetchChannels(); + } catch (e) { + alert((e as Error).message || "Failed to delete channel"); + } finally { + setSaving(false); + } + }; + + const handleNavigate = (channelIdx: number) => { + navigate(`/messages?channel_idx=${channelIdx}`); + }; + + const groups = new Map(); + for (const vis of VISIBILITY_ORDER) { + groups.set(vis, []); + } + for (const ch of channels) { + const vis = ch.visibility || "community"; + if (!groups.has(vis)) groups.set(vis, []); + groups.get(vis)!.push(ch); + } + + if (loading) return ; + + return ( +
+
+

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

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

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

+
+ {group.map((ch) => ( + setModal({ type: "edit", channel: ch })} + onDelete={() => setModal({ type: "delete", channel: ch })} + onNavigate={handleNavigate} + /> + ))} +
+
+ ); + })} + + {modal && (modal.type === "add" || modal.type === "edit") && ( + setModal(null)} + /> + )} + + {modal?.type === "delete" && ( + setModal(null)} + /> + )} +
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx new file mode 100644 index 0000000..ea359b2 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router"; +import { useTranslation } from "react-i18next"; + +import { ErrorAlert, Loading } from "@/components/Alerts"; +import { useAppConfig } from "@/context/AppConfigContext"; +import { apiGet, isAbortError } from "@/utils/api"; + +interface CustomPageData { + slug: string; + title: string; + content_html: string; +} + +export function CustomPagePage() { + const { slug = "" } = useParams(); + const { t } = useTranslation(); + const config = useAppConfig(); + + const [page, setPage] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(null); + apiGet( + `/spa/pages/${encodeURIComponent(slug)}`, + {}, + { signal: controller.signal }, + ) + .then((data) => { + setPage(data); + setLoading(false); + }) + .catch((e) => { + if (isAbortError(e)) return; + const message = e instanceof Error ? e.message : ""; + setError( + message.includes("404") + ? t("common.page_not_found") + : message || t("custom_page.failed_to_load"), + ); + setLoading(false); + }); + return () => controller.abort(); + }, [slug, t]); + + useEffect(() => { + if (!page) return; + const networkName = config.network_name || "MeshCore Network"; + document.title = `${page.title} - ${networkName}`; + }, [page, config.network_name]); + + if (loading) return ; + if (error) return ; + if (!page) return null; + + return ( +
+
+
+
+
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx new file mode 100644 index 0000000..f06543b --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx @@ -0,0 +1,649 @@ +import { + useEffect, + useMemo, + useState, + type CSSProperties, + type ReactNode, +} from "react"; +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; + +import { ErrorAlert, Loading } from "@/components/Alerts"; +import { ObserverIcons } from "@/components/ObserverBadges"; +import { RouteTypeBadge } from "@/components/RouteTypeBadge"; +import { + IconAdvertisements, + IconChannel, + IconMessages, + IconNodes, + IconPackets, +} from "@/components/icons"; +import { + getChannelLabelsMap, + resolveChannelLabel, + useAppConfig, +} from "@/context/AppConfigContext"; +import { usePageTitle } from "@/hooks/usePageTitle"; +import { apiGet, isAbortError } from "@/utils/api"; +import { formatNumber, useFormatDateTime } from "@/utils/format"; + +interface DashboardStats { + total_nodes: number; + advertisements_7d: number; + messages_7d: number; + packets_7d: number; +} + +interface PacketBreakdown { + by_event_type: { count: number }[]; + by_path_width: { count: number }[]; +} + +interface RouteHealthEntry { + date: string; + quality: string | null; + matched_count: number; +} + +interface RouteOverviewItem { + from_label: string; + to_label: string; + enabled: boolean; + quality?: string | null; + matched_count?: number; + history?: RouteHealthEntry[]; +} + +interface RoutesOverview { + days: number; + routes: RouteOverviewItem[]; +} + +interface ObserverInfo { + public_key: string; + name?: string; + tag_name?: string; +} + +interface RecentAdvertisement { + public_key: string; + name?: string | null; + tag_name?: string | null; + route_type?: string | null; + received_at: string; + observed_by?: string | null; + observers?: ObserverInfo[]; +} + +interface ChannelMessage { + received_at: string; + text?: string | null; +} + +interface RecentActivity { + recent_advertisements: RecentAdvertisement[]; + channel_messages: Record; +} + +interface ChannelsResponse { + items?: { channel_hash: string; name: string }[]; +} + +interface DashboardData { + stats: DashboardStats; + recentActivity: RecentActivity; + advertActivity: unknown; + messageActivity: unknown; + nodeCount: unknown; + packetActivity: unknown; + packetBreakdown: PacketBreakdown; + routesOverview: RoutesOverview | null; + channelsData: ChannelsResponse; +} + +const CHART_IDS = [ + "nodeChart", + "advertChart", + "messageChart", + "packetChart", + "packetEventTypeChart", + "packetPathWidthChart", + "routesTrendChart", +]; + +const QUALITY_COLORS: Record = { + clear: "oklch(0.72 0.17 145)", + marginal: "oklch(0.75 0.18 85)", + failing: "oklch(0.62 0.24 25)", + no_coverage: "oklch(0.65 0.15 250)", + disabled: "oklch(0.55 0 0)", +}; + +function qualityColor(quality: string | null): string { + return QUALITY_COLORS[quality ?? ""] ?? QUALITY_COLORS.no_coverage; +} + +function gridCols(count: number): string { + if (count === 2) return "sm:grid-cols-2"; + if (count === 3) return "sm:grid-cols-2 lg:grid-cols-3"; + if (count === 4) return "sm:grid-cols-2 lg:grid-cols-4"; + return ""; +} + +function panelStyle(colorVar: string): CSSProperties { + return { "--panel-color": `var(${colorVar})` } as CSSProperties; +} + +function ChartCard({ + colorVar, + icon, + title, + subtitle, + value, + canvasId, + children, +}: { + colorVar: string; + icon?: ReactNode; + title: string; + subtitle: string; + value?: number; + canvasId?: string; + children?: ReactNode; +}) { + return ( +
+
+
+
+

+ {icon} + {title} +

+

{subtitle}

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

{t("dashboard.routes_empty")}

; + } + + const labelFor = (quality: string | null) => + t("routes.quality_" + (quality || "unknown")); + const sorted = routes + .slice() + .sort((a, b) => (b.matched_count || 0) - (a.matched_count || 0)); + const visible = sorted.slice(0, 6); + const hidden = sorted.length - visible.length; + + return ( +
+ {visible.map((route, i) => { + const history = route.history || []; + const averageTier = + history.length > 0 + ? ((window as any).averageRouteTier?.(history) as string | null) ?? + null + : null; + const current = + averageTier || + (route.enabled ? route.quality || "no_coverage" : "disabled"); + return ( +
${route.to_label}-${i}`} + className="flex items-center gap-2" + > + + {route.from_label} {" "} + {route.to_label} + +
+ {history.map((entry) => ( +
+ ))} +
+ +
+ ); + })} + {hidden > 0 && ( +

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

+ )} +
+ ); +} + +export function DashboardPage() { + const { t } = useTranslation(); + const config = useAppConfig(); + const { formatDateTime } = useFormatDateTime(); + usePageTitle("entities.dashboard"); + + const features = config.features ?? {}; + const showNodes = features.nodes !== false; + const showAdverts = features.advertisements !== false; + const showMessages = features.messages !== false; + const showPackets = features.packets !== false; + const showRoutes = features.routes !== false; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + const { signal } = controller; + (async () => { + try { + const [ + stats, + recentActivity, + advertActivity, + messageActivity, + nodeCount, + packetActivity, + packetBreakdown, + routesOverview, + channelsData, + ] = await Promise.all([ + apiGet("/api/v1/dashboard/stats", {}, { signal }), + apiGet( + "/api/v1/dashboard/recent-activity", + {}, + { signal }, + ), + apiGet("/api/v1/dashboard/activity", { days: 7 }, { signal }), + apiGet( + "/api/v1/dashboard/message-activity", + { days: 7 }, + { signal }, + ), + apiGet("/api/v1/dashboard/node-count", { days: 7 }, { signal }), + apiGet( + "/api/v1/dashboard/packet-activity", + { days: 7 }, + { signal }, + ), + apiGet( + "/api/v1/dashboard/packet-breakdown", + { days: 7 }, + { signal }, + ), + showRoutes + ? apiGet( + "/api/v1/dashboard/routes-overview", + { days: 7 }, + { signal }, + ) + : Promise.resolve(null), + apiGet("/api/v1/channels", {}, { signal }), + ]); + setData({ + stats, + recentActivity, + advertActivity, + messageActivity, + nodeCount, + packetActivity, + packetBreakdown, + routesOverview, + channelsData, + }); + setError(null); + } catch (e) { + if (isAbortError(e)) return; + setError( + e instanceof Error && e.message + ? e.message + : t("common.failed_to_load_page"), + ); + } finally { + setLoading(false); + } + })(); + return () => controller.abort(); + }, [showRoutes, t]); + + useEffect(() => { + if (!data) return; + window.initDashboardCharts( + showNodes ? data.nodeCount : null, + showAdverts ? data.advertActivity : null, + showMessages ? data.messageActivity : null, + showPackets ? data.packetActivity : null, + showPackets ? data.packetBreakdown.by_event_type : null, + showPackets ? data.packetBreakdown.by_path_width : null, + showRoutes && data.routesOverview?.routes + ? data.routesOverview.routes + : null, + ); + return () => { + for (const id of CHART_IDS) { + const canvas = document.getElementById(id); + if (canvas) (window as any).Chart?.getChart(canvas)?.destroy(); + } + }; + }, [data, showNodes, showAdverts, showMessages, showPackets, showRoutes]); + + const channelLabels = useMemo(() => { + if (!data) return new Map(); + return new Map([ + ...getChannelLabelsMap(config), + ...(data.channelsData.items || []) + .map((ch) => [parseInt(ch.channel_hash, 16), ch.name] as [number, string]) + .filter(([idx]) => Number.isInteger(idx)), + ]); + }, [config, data]); + + if (loading) return ; + if (error) return ; + if (!data) return null; + + const { stats, recentActivity, packetBreakdown, routesOverview } = data; + + const formatTimeOnly = (iso: string | null) => + formatDateTime(iso, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + const formatTimeShort = (iso: string | null) => + formatDateTime(iso, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + const labelForChannel = (channel: string): string => { + const idx = parseInt(String(channel), 10); + if (Number.isInteger(idx)) { + return resolveChannelLabel(idx, channelLabels) || `Ch ${idx}`; + } + return String(channel); + }; + + const eventTypeTotal = + packetBreakdown?.by_event_type?.reduce((sum, b) => sum + b.count, 0) ?? 0; + const pathWidthTotal = + packetBreakdown?.by_path_width?.reduce((sum, b) => sum + b.count, 0) ?? 0; + const hasRoutes = !!( + routesOverview && + routesOverview.routes && + routesOverview.routes.length + ); + const visibleChartCount = + (showNodes ? 1 : 0) + + (showAdverts ? 1 : 0) + + (showMessages ? 1 : 0) + + (showPackets ? 1 : 0); + const bottomCount = (showAdverts ? 1 : 0) + (showMessages ? 1 : 0); + + const ads = recentActivity.recent_advertisements ?? []; + const channelEntries = Object.entries(recentActivity.channel_messages ?? {}); + + return ( + <> +
+

{t("entities.dashboard")}

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

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

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

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

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

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

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

+ + {labelForChannel(channel)} + +

+
+ {messages.map((msg, i) => ( +
+ + {formatTimeShort(msg.received_at)} + {" "} + + {msg.text || ""} + +
+ ))} +
+
+ ))} +
+
+
+ )} +
+ )} + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx new file mode 100644 index 0000000..af1a09d --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx @@ -0,0 +1,482 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type ComponentType, + type SVGProps, +} from "react"; +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; + +import { ErrorAlert, Loading } from "@/components/Alerts"; +import { StatCard } from "@/components/StatCard"; +import { + IconAdvertisements, + IconAntenna, + IconBandwidth, + IconChannel, + IconChart, + IconCodingRate, + IconDashboard, + IconFrequency, + IconInfo, + IconMap, + IconMembers, + IconMessages, + IconNodes, + IconPage, + IconPackets, + IconPath, + IconSettings, + IconSpreadingFactor, + IconTxPower, + IconUsers, +} from "@/components/icons"; +import { useAppConfig, useFeatures } from "@/context/AppConfigContext"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { usePageTitle } from "@/hooks/usePageTitle"; +import type { RadioConfigDisplay } from "@/types/config"; +import { apiGet, isAbortError } from "@/utils/api"; +import { getPageColor } from "@/utils/format"; + +interface DashboardStats { + total_nodes: number; + advertisements_7d: number; + messages_7d: number; + packets_7d: number; + total_operators: number; + total_members: number; +} + +interface ActivitySeries { + data: { date: string; count: number }[]; +} + +interface ChartInstance { + destroy: () => void; +} + +type IconComponent = ComponentType>; + +function NavCard({ + href, + icon: Icon, + label, + colorVar, +}: { + href: string; + icon: IconComponent; + label: string; + colorVar: string; +}) { + return ( + + + + + + {label} + + + ); +} + +function RadioTiles({ rc }: { rc?: RadioConfigDisplay }) { + const { t } = useTranslation(); + if (!rc) return null; + + const tiles = [ + { icon: IconSettings, label: t("links.profile"), value: rc.profile }, + { icon: IconFrequency, label: t("home.frequency"), value: rc.frequency }, + { icon: IconBandwidth, label: t("home.bandwidth"), value: rc.bandwidth }, + { + icon: IconSpreadingFactor, + label: t("home.spreading_factor"), + value: rc.spreading_factor, + }, + { + icon: IconCodingRate, + label: t("home.coding_rate"), + value: rc.coding_rate, + }, + { icon: IconTxPower, label: t("home.tx_power"), value: rc.tx_power }, + ].filter((tile) => tile.value); + + if (tiles.length === 0) return null; + + return ( +
+ {tiles.map(({ icon: Icon, label, value }) => ( +
+ + + + {label} + + {String(value)} + +
+ ))} +
+ ); +} + +export function HomePage() { + const { t } = useTranslation(); + const config = useAppConfig(); + const features = useFeatures(); + usePageTitle(); + + const [stats, setStats] = useState(null); + const [advertActivity, setAdvertActivity] = useState( + null, + ); + const [messageActivity, setMessageActivity] = useState( + null, + ); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const chartCanvasRef = useRef(null); + const hasDataRef = useRef(false); + + const networkName = config.network_name || "MeshCore Network"; + const logoUrl = config.logo_url || "/static/img/logo.svg"; + const logoInvertLight = config.logo_invert_light !== false; + const customPages = config.custom_pages || []; + + const showStats = + features.nodes !== false || + features.advertisements !== false || + features.messages !== false || + features.packets !== false; + const showAdvertSeries = features.advertisements !== false; + const showMessageSeries = features.messages !== false; + const showActivityChart = showAdvertSeries || showMessageSeries; + const showMembersPanel = features.members !== false; + const showRadioPanel = features.radio_config !== false; + + const load = useCallback( + async (signal?: AbortSignal) => { + try { + const [statsData, advertData, messageData] = await Promise.all([ + apiGet("/api/v1/dashboard/stats", {}, { signal }), + apiGet( + "/api/v1/dashboard/activity", + { days: 7 }, + { signal }, + ), + apiGet( + "/api/v1/dashboard/message-activity", + { days: 7 }, + { signal }, + ), + ]); + setStats(statsData); + setAdvertActivity(advertData); + setMessageActivity(messageData); + hasDataRef.current = true; + setError(null); + } catch (e) { + if (isAbortError(e)) return; + if (!hasDataRef.current) { + setError( + e instanceof Error && e.message + ? e.message + : t("common.failed_to_load_page"), + ); + } + } finally { + setLoading(false); + } + }, + [t], + ); + + useEffect(() => { + const controller = new AbortController(); + void load(controller.signal); + return () => controller.abort(); + }, [load]); + + useAutoRefresh({ onRefresh: load }); + + useEffect(() => { + if (!showActivityChart || !chartCanvasRef.current) return; + const chart = window.createActivityChart( + "activityChart", + showAdvertSeries ? advertActivity : null, + showMessageSeries ? messageActivity : null, + ) as ChartInstance | null; + return () => { + chart?.destroy(); + }; + }, [ + showActivityChart, + showAdvertSeries, + showMessageSeries, + advertActivity, + messageActivity, + ]); + + if (loading) return ; + if (error) return ; + if (!stats) return null; + + const navItems: { + feature: string; + href: string; + icon: IconComponent; + label: string; + colorVar: string; + }[] = [ + { + feature: "dashboard", + href: "/dashboard", + icon: IconDashboard, + label: t("entities.dashboard"), + colorVar: "--color-dashboard", + }, + { + feature: "nodes", + href: "/nodes", + icon: IconNodes, + label: t("entities.nodes"), + colorVar: "--color-nodes", + }, + { + feature: "advertisements", + href: "/advertisements", + icon: IconAdvertisements, + label: t("entities.advertisements"), + colorVar: "--color-adverts", + }, + { + feature: "routes", + href: "/routes", + icon: IconPath, + label: t("entities.routes"), + colorVar: "--color-routes", + }, + { + feature: "channels", + href: "/channels", + icon: IconChannel, + label: t("entities.channels"), + colorVar: "--color-channels", + }, + { + feature: "messages", + href: "/messages", + icon: IconMessages, + label: t("entities.messages"), + colorVar: "--color-messages", + }, + { + feature: "packets", + href: "/packets", + icon: IconPackets, + label: t("entities.packets"), + colorVar: "--color-packets", + }, + { + feature: "map", + href: "/map", + icon: IconMap, + label: t("entities.map"), + colorVar: "--color-map", + }, + { + feature: "members", + href: "/members", + icon: IconMembers, + label: t("entities.members"), + colorVar: "--color-members", + }, + ]; + + return ( + <> +
+
+
+
+ {networkName} +
+

+ {networkName} +

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

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

+ )} +
+
+
+

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

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

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

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

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

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

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

+

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

+
+ +
+
+
+ )} +
+ + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx new file mode 100644 index 0000000..7a978cf --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx @@ -0,0 +1,604 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; + +import { useAppConfig } from "@/context/AppConfigContext"; +import { usePageTitle } from "@/hooks/usePageTitle"; +import { apiGet, isAbortError } from "@/utils/api"; +import { formatNumber, formatRelativeTime, typeEmoji } from "@/utils/format"; +import { FilterToggle } from "@/components/FilterForm"; +import { ErrorAlert, Loading } from "@/components/Alerts"; + +const MAX_BOUNDS_RADIUS_KM = 20; + +interface LatLng { + lat: number; + lon: number; +} + +interface MapNodeOwner { + name: string; + callsign: string | null; +} + +interface MapNode { + public_key: string; + name: string | null; + adv_type: string | null; + lat: number; + lon: number; + last_seen: string | null; + is_adopted?: boolean; + role: string | null; + owner: MapNodeOwner | null; +} + +interface Profile { + id: string; + name: string | null; + callsign: string | null; + roles: string[] | null; +} + +interface MapDebug { + total_nodes: number; + nodes_with_coords: number; + error: string | null; +} + +interface MapData { + nodes: MapNode[]; + center: LatLng | null; + adopted_center: LatLng | null; + debug: MapDebug | null; + profiles: Profile[]; +} + +function escapeHtml(str: string | null | undefined): string { + if (!str) return ""; + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; +} + +function getDistanceKm( + lat1: number, + lon1: number, + lat2: number, + lon2: number, +): number { + const R = 6371; + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +function getNodesWithinRadius( + nodes: MapNode[], + anchorLat: number, + anchorLon: number, + radiusKm: number, +): MapNode[] { + return nodes.filter( + (n) => getDistanceKm(anchorLat, anchorLon, n.lat, n.lon) <= radiusKm, + ); +} + +function getAnchorPoint(nodes: MapNode[], adoptedCenter: LatLng | null): LatLng { + if (adoptedCenter) return adoptedCenter; + if (nodes.length === 0) return { lat: 0, lon: 0 }; + return { + lat: nodes.reduce((sum, n) => sum + n.lat, 0) / nodes.length, + lon: nodes.reduce((sum, n) => sum + n.lon, 0) / nodes.length, + }; +} + +function getBoundsPadding(): [number, number] { + if (window.innerWidth < 480) return [50, 50]; + if (window.innerWidth < 768) return [75, 75]; + return [100, 100]; +} + +function normalizeType(type: string | null): string | null { + return type ? type.toLowerCase() : null; +} + +function getTypeDisplay(node: MapNode, t: TFunction): string { + const type = normalizeType(node.adv_type); + if (type === "chat") return t("node_types.chat"); + if (type === "repeater") return t("node_types.repeater"); + if (type === "room") return t("node_types.room"); + return type + ? type.charAt(0).toUpperCase() + type.slice(1) + : t("node_types.unknown"); +} + +function createNodeIcon(L: any, node: MapNode, oidcEnabled: boolean): any { + const displayName = node.name || ""; + const relativeTime = formatRelativeTime(node.last_seen); + const timeDisplay = relativeTime ? " (" + relativeTime + ")" : ""; + + const iconHtml = + oidcEnabled && node.is_adopted + ? '
' + : '
'; + + return L.divIcon({ + className: "custom-div-icon", + html: + '
' + + iconHtml + + '' + + escapeHtml(displayName) + + escapeHtml(timeDisplay) + + "" + + "
", + iconSize: [120, 50], + iconAnchor: [60, 12], + }); +} + +function createPopupContent( + node: MapNode, + oidcEnabled: boolean, + t: TFunction, +): string { + const typeDisplay = getTypeDisplay(node, t); + const nodeTypeEmoji = typeEmoji(node.adv_type); + + let infraIndicatorHtml = ""; + if (oidcEnabled && typeof node.is_adopted !== "undefined") { + const dotColor = node.is_adopted + ? "var(--color-marker-infra)" + : "var(--color-marker-public)"; + const borderColor = node.is_adopted + ? "var(--color-marker-infra-border)" + : "var(--color-marker-public-border)"; + const title = node.is_adopted ? t("map.infrastructure") : t("map.public"); + infraIndicatorHtml = + ' '; + } + + const typeLabel = t("common.type"); + const keyLabel = t("common.key"); + const locationLabel = t("common.location"); + const lastSeenLabel = t("common.last_seen_label"); + const unknownLabel = t("node_types.unknown"); + const viewDetailsLabel = t("common.view_details"); + + let rows = ""; + rows += + '
' + + typeLabel + + "
" + + escapeHtml(typeDisplay) + + "
"; + + if (node.role) { + const roleLabel = t("map.role"); + rows += + '
' + + roleLabel + + '
' + + escapeHtml(node.role) + + "
"; + } + + if (node.owner) { + const ownerLabel = t("map.owner"); + const ownerDisplay = node.owner.callsign + ? escapeHtml(node.owner.name) + + " (" + + escapeHtml(node.owner.callsign) + + ")" + : escapeHtml(node.owner.name); + rows += + '
' + ownerLabel + "
" + ownerDisplay + "
"; + } + + rows += + '
' + + keyLabel + + '
' + + escapeHtml(node.public_key.substring(0, 16)) + + "...
"; + rows += + '
' + + locationLabel + + "
" + + node.lat.toFixed(4) + + ", " + + node.lon.toFixed(4) + + "
"; + + if (node.last_seen) { + rows += + '
' + + lastSeenLabel + + "
" + + node.last_seen.substring(0, 19).replace("T", " ") + + "
"; + } + + return ( + '
' + + '

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

" + + '
' + + rows + + "
" + + '' + + viewDetailsLabel + + "" + + "
" + ); +} + +function fitInitialBounds( + map: any, + L: any, + data: MapData, + oidcEnabled: boolean, +): void { + const allNodes = data.nodes || []; + const padding = getBoundsPadding(); + if (oidcEnabled) { + const adoptedNodes = allNodes.filter((n) => n.is_adopted); + if (adoptedNodes.length > 0) { + map.fitBounds( + L.latLngBounds(adoptedNodes.map((n) => [n.lat, n.lon])), + { padding }, + ); + return; + } + } + if (allNodes.length === 0) return; + const anchor = getAnchorPoint( + allNodes, + oidcEnabled ? data.adopted_center : null, + ); + const nearbyNodes = getNodesWithinRadius( + allNodes, + anchor.lat, + anchor.lon, + MAX_BOUNDS_RADIUS_KM, + ); + const nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes; + map.fitBounds( + L.latLngBounds(nodesToFit.map((n) => [n.lat, n.lon])), + { padding }, + ); +} + +export function MapPage() { + const { t } = useTranslation(); + const config = useAppConfig(); + usePageTitle("entities.map"); + + const oidcEnabled = config.oidc_enabled; + const tz = config.timezone || ""; + const operatorRole = config.role_names?.operator || "operator"; + + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + const markersRef = useRef([]); + const initialFitRef = useRef(false); + + const [mapData, setMapData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filterOpen, setFilterOpen] = useState(false); + const [category, setCategory] = useState(""); + const [typeFilter, setTypeFilter] = useState(""); + const [operatorFilter, setOperatorFilter] = useState(""); + const [showLabels, setShowLabels] = useState(false); + const [nodeCount, setNodeCount] = useState(0); + const [filteredCount, setFilteredCount] = useState(null); + + const operatorProfiles = useMemo( + () => + (mapData?.profiles || []) + .filter((p) => p.roles && p.roles.includes(operatorRole)) + .sort((a, b) => { + const na = a.name || a.callsign || ""; + const nb = b.name || b.callsign || ""; + return na.localeCompare(nb); + }), + [mapData, operatorRole], + ); + + useEffect(() => { + const ac = new AbortController(); + const params: Record = {}; + if (operatorFilter) params.adopted_by = operatorFilter; + apiGet("/map/data", params, { signal: ac.signal }) + .then((data) => { + setMapData(data); + setError(null); + }) + .catch((e) => { + if (isAbortError(e)) return; + setError((e as Error).message || t("common.failed_to_load_page")); + }) + .finally(() => setLoading(false)); + return () => ac.abort(); + }, [operatorFilter, t]); + + useEffect(() => { + if (loading || mapRef.current) return; + const L = (window as any).L; + const el = mapContainerRef.current; + if (!L || !el) return; + const map = L.map(el).setView([0, 0], 2); + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: + '© OpenStreetMap contributors', + }).addTo(map); + mapRef.current = map; + return () => { + mapRef.current = null; + markersRef.current = []; + map.remove(); + }; + }, [loading]); + + const updateMarkers = useCallback( + (map: any, L: any, nodes: MapNode[]) => { + markersRef.current.forEach((m) => map.removeLayer(m)); + markersRef.current = []; + nodes.forEach((node) => { + const marker = L.marker([node.lat, node.lon], { + icon: createNodeIcon(L, node, oidcEnabled), + }).addTo(map); + marker.bindPopup(createPopupContent(node, oidcEnabled, t)); + markersRef.current.push(marker); + }); + }, + [oidcEnabled, t], + ); + + const applyFilters = useCallback(() => { + const map = mapRef.current; + const L = (window as any).L; + if (!map || !L || !mapData) return; + const allNodes = mapData.nodes || []; + const filteredNodes = allNodes.filter((node) => { + if (category === "infra" && !node.is_adopted) return false; + if (typeFilter && normalizeType(node.adv_type) !== typeFilter) + return false; + return true; + }); + + updateMarkers(map, L, filteredNodes); + setNodeCount(allNodes.length); + setFilteredCount(filteredNodes.length); + + if (filteredNodes.length > 0) { + let nodesToFit = filteredNodes; + if (category !== "infra") { + const anchor = getAnchorPoint(filteredNodes, mapData.adopted_center); + const nearbyNodes = getNodesWithinRadius( + filteredNodes, + anchor.lat, + anchor.lon, + MAX_BOUNDS_RADIUS_KM, + ); + if (nearbyNodes.length > 0) nodesToFit = nearbyNodes; + } + map.fitBounds( + L.latLngBounds(nodesToFit.map((n) => [n.lat, n.lon])), + { padding: getBoundsPadding() }, + ); + } else { + const center = mapData.center; + if (center && (center.lat !== 0 || center.lon !== 0)) { + map.setView([center.lat, center.lon], 10); + } + } + }, [mapData, category, typeFilter, updateMarkers]); + + useEffect(() => { + const map = mapRef.current; + const L = (window as any).L; + if (!map || !L || !mapData || mapData.debug?.error) return; + if (!initialFitRef.current) { + initialFitRef.current = true; + fitInitialBounds(map, L, mapData, oidcEnabled); + const allNodes = mapData.nodes || []; + updateMarkers(map, L, allNodes); + setNodeCount(allNodes.length); + setFilteredCount(allNodes.length); + return; + } + applyFilters(); + }, [mapData, oidcEnabled, applyFilters, updateMarkers]); + + useEffect(() => { + const el = mapContainerRef.current; + if (el) el.classList.toggle("show-labels", showLabels); + }, [showLabels]); + + const clearFilters = () => { + setCategory(""); + setTypeFilter(""); + setOperatorFilter(""); + setShowLabels(false); + }; + + const debug = mapData?.debug ?? null; + let countBadgeText: string; + if (debug?.error) { + countBadgeText = "Error: " + debug.error; + } else if (debug && debug.total_nodes === 0) { + countBadgeText = t("common.no_entity_in_database", { + entity: t("entities.nodes").toLowerCase(), + }); + } else if (debug && debug.nodes_with_coords === 0) { + countBadgeText = t("map.nodes_none_have_coordinates", { + count: formatNumber(debug.total_nodes), + }); + } else if (filteredCount === null || filteredCount === nodeCount) { + countBadgeText = t("map.nodes_on_map", { + count: formatNumber(nodeCount), + }); + } else { + countBadgeText = t("common.total", { count: formatNumber(nodeCount) }); + } + const showFilteredBadge = filteredCount !== null && filteredCount !== nodeCount; + + if (loading) return ; + if (error) return ; + + return ( +
+
+

{t("entities.map")}

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

{t("map.gps_description")}

+
+
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx new file mode 100644 index 0000000..16f3ac1 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx @@ -0,0 +1,245 @@ +import { + useEffect, + useState, + type KeyboardEvent, + type MouseEvent, + type ReactNode, +} from "react"; +import { Link, useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useAppConfig } from "@/context/AppConfigContext"; +import { apiGet, isAbortError } from "@/utils/api"; +import { formatNumber } from "@/utils/format"; +import { Loading, ErrorAlert } from "@/components/Alerts"; +import { IconAntenna, IconUsers } from "@/components/icons"; +import { usePageTitle } from "@/hooks/usePageTitle"; + +interface MemberNode { + public_key: string; + name?: string | null; +} + +interface MemberProfile { + id: string; + name?: string | null; + callsign?: string | null; + description?: string | null; + url?: string | null; + roles?: string[] | null; + node_count?: number | null; + adopted_nodes?: MemberNode[] | null; +} + +interface ProfilesResponse { + items?: MemberProfile[] | null; +} + +function ProfileTile({ profile }: { profile: MemberProfile }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const openNode = (e: MouseEvent | KeyboardEvent, publicKey: string) => { + e.preventDefault(); + e.stopPropagation(); + navigate(`/nodes/${publicKey}`); + }; + + const openUrl = (e: MouseEvent | KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + window.open(profile.url ?? undefined, "_blank", "noopener,noreferrer"); + }; + + return ( + +
+

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

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

+ {profile.description} +

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

+ {icon} + {title} +

+
+ {profiles.map((profile) => ( + + ))} +
+ + ); +} + +export function Members() { + const { t } = useTranslation(); + const config = useAppConfig(); + usePageTitle("entities.members"); + const [profiles, setProfiles] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + apiGet( + "/api/v1/user/profiles", + { limit: 500 }, + { signal: controller.signal }, + ) + .then((resp) => setProfiles(resp.items || [])) + .catch((e) => { + if (!isAbortError(e)) { + setError((e as Error).message || t("common.failed_to_load_page")); + } + }); + return () => controller.abort(); + }, [t]); + + if (error) return ; + if (profiles === null) return ; + + const roleNames = config.role_names || {}; + const operatorRole = roleNames.operator || "operator"; + const memberRole = roleNames.member || "member"; + const testRole = roleNames.test || "test"; + + const visible = profiles.filter((p) => !p.roles || !p.roles.includes(testRole)); + + if (visible.length === 0) { + return ( + <> +
+

{t("entities.members")}

+
+
+

{t("members_page.empty_state")}

+

{t("members_page.empty_description")}

+
+ + ); + } + + const byName = (a: MemberProfile, b: MemberProfile) => + (a.name || "").localeCompare(b.name || ""); + const operators = visible + .filter((p) => !!p.roles && p.roles.includes(operatorRole)) + .sort(byName); + const members = visible + .filter( + (p) => + !!p.roles && p.roles.includes(memberRole) && !p.roles.includes(operatorRole), + ) + .sort(byName); + + return ( + <> +
+

{t("entities.members")}

+ + {t("common.count_entity", { + count: formatNumber(operators.length + members.length), + entity: t("entities.members").toLowerCase(), + })} + +
+ + + + + } + profiles={operators} + /> + + + + } + profiles={members} + /> + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx new file mode 100644 index 0000000..973db76 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx @@ -0,0 +1,781 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useNavigate, useSearchParams } from "react-router"; +import { useTranslation } from "react-i18next"; +import { + getChannelLabelsMap, + resolveChannelLabel, + useAppConfig, +} from "@/context/AppConfigContext"; +import { apiGet, isAbortError } from "@/utils/api"; +import { formatNumber, useFormatDateTime } from "@/utils/format"; +import { usePageTitle } from "@/hooks/usePageTitle"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { Pagination } from "@/components/Pagination"; +import { FilterForm, FilterToggle } from "@/components/FilterForm"; +import { MobileSortSelect, SortableTableHeader } from "@/components/SortableTable"; +import { + ObserverFilterBadges, + ObserverIcons, + getDisabledObserverAreas, + toggleObserverArea, +} from "@/components/ObserverBadges"; +import { Loading, WarningBadge } from "@/components/Alerts"; +import { IconRefresh } from "@/components/icons"; + +interface ObserverInfo { + node_id?: string; + public_key: string; + name?: string; + tag_name?: string; + snr?: number | null; + observed_at?: string; +} + +interface Message { + message_type: string; + text: string; + channel_idx?: number | null; + channel_name?: string | null; + signature?: string | null; + pubkey_prefix?: string | null; + sender_name?: string | null; + sender_tag_name?: string | null; + observed_by?: string | null; + observer_name?: string | null; + observer_tag_name?: string | null; + received_at: string; + packet_hash?: string | null; + spam_score?: number | null; + observers?: ObserverInfo[]; +} + +interface NodeItem { + public_key: string; + tags?: { key: string; value: string | null }[]; +} + +interface ChannelItem { + channel_hash: string; + name: string; +} + +interface ListResponse { + items?: T[]; + total?: number; +} + +function autoSubmit(e: React.ChangeEvent) { + e.currentTarget.form?.requestSubmit(); +} + +function parseSenderFromText(text: string | null): { + sender: string | null; + text: string; +} { + if (!text || typeof text !== "string") { + return { sender: null, text: text || "-" }; + } + const patterns = [ + /^\s*ack\s+@\[(.+?)\]\s*:\s*([\s\S]+)$/i, + /^\s*@\[(.+?)\]\s*:\s*([\s\S]+)$/i, + /^\s*ack\s+([^:|\n]{1,80})\s*:\s*([\s\S]+)$/i, + ]; + for (const pattern of patterns) { + const match = text.match(pattern); + if (!match) continue; + const sender = (match[1] || "").trim(); + const remaining = (match[2] || "").trim(); + if (!sender) continue; + return { sender, text: remaining || text }; + } + return { sender: null, text }; +} + +function collapseNewlines(text: string | null): string | null { + if (!text || typeof text !== "string") return text; + return text.replace(/\s*\n\s*/g, " "); +} + +function channelInfo( + msg: Message, + channelLabels: Map, + fallbackLabel: string, +): { label: string | null; text: string } { + if (msg.message_type !== "channel") { + return { label: null, text: msg.text || "-" }; + } + const rawText = msg.text || ""; + const match = rawText.match(/^\[([^\]]+)\]\s+([\s\S]*)$/); + if (msg.channel_idx !== null && msg.channel_idx !== undefined) { + const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels); + if (knownLabel) { + return { + label: knownLabel, + text: match ? match[2] || "-" : rawText || "-", + }; + } + } + if (msg.channel_name) { + return { label: msg.channel_name, text: msg.text || "-" }; + } + if (match) { + return { label: match[1], text: match[2] || "-" }; + } + if (msg.channel_idx !== null && msg.channel_idx !== undefined) { + const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels); + return { label: knownLabel || `Ch ${msg.channel_idx}`, text: rawText || "-" }; + } + return { label: fallbackLabel, text: rawText || "-" }; +} + +function messageTextWithSender(msg: Message, text: string): string { + const parsed = parseSenderFromText(text || "-"); + const explicitSender = + msg.sender_tag_name || + msg.sender_name || + (msg.pubkey_prefix || "").slice(0, 12) || + null; + const sender = explicitSender || parsed.sender; + const body = collapseNewlines((parsed.text || text || "-").trim()) || "-"; + if (!sender) return body; + if (body.toLowerCase().startsWith(`${sender.toLowerCase()}:`)) return body; + return `${sender}: ${body}`; +} + +function dedupeBySignature(items: Message[]): Message[] { + const deduped: Message[] = []; + const bySignature = new Map(); + + for (const msg of items) { + const signature = + typeof msg.signature === "string" + ? msg.signature.trim().toUpperCase() + : ""; + const canDedupe = msg.message_type === "channel" && signature.length >= 8; + if (!canDedupe) { + deduped.push(msg); + continue; + } + + const existing = bySignature.get(signature); + if (!existing) { + const clone: Message = { + ...msg, + observers: [...(msg.observers ?? [])], + }; + bySignature.set(signature, clone); + deduped.push(clone); + continue; + } + + const combined = [...(existing.observers ?? []), ...(msg.observers ?? [])]; + const seenReceivers = new Set(); + existing.observers = combined.filter((recv) => { + const key = + recv?.public_key || + recv?.node_id || + `${recv?.observed_at ?? ""}:${recv?.snr ?? ""}`; + if (seenReceivers.has(key)) return false; + seenReceivers.add(key); + return true; + }); + + if (!existing.observed_by && msg.observed_by) + existing.observed_by = msg.observed_by; + if (!existing.observer_name && msg.observer_name) + existing.observer_name = msg.observer_name; + if (!existing.observer_tag_name && msg.observer_tag_name) + existing.observer_tag_name = msg.observer_tag_name; + if (!existing.pubkey_prefix && msg.pubkey_prefix) + existing.pubkey_prefix = msg.pubkey_prefix; + if (!existing.sender_name && msg.sender_name) + existing.sender_name = msg.sender_name; + if (!existing.sender_tag_name && msg.sender_tag_name) + existing.sender_tag_name = msg.sender_tag_name; + if (!existing.channel_name && msg.channel_name) + existing.channel_name = msg.channel_name; + if ( + existing.channel_name === "Public" && + msg.channel_name && + msg.channel_name !== "Public" + ) { + existing.channel_name = msg.channel_name; + } + if (existing.channel_idx === null || existing.channel_idx === undefined) { + if (msg.channel_idx !== null && msg.channel_idx !== undefined) { + existing.channel_idx = msg.channel_idx; + } + } else if ( + existing.channel_idx === 17 && + msg.channel_idx !== null && + msg.channel_idx !== undefined && + msg.channel_idx !== 17 + ) { + existing.channel_idx = msg.channel_idx; + } + } + + return deduped; +} + +export function Messages() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const config = useAppConfig(); + const { formatDateTime, formatDateTimeShort } = useFormatDateTime(); + usePageTitle("entities.messages"); + + const messageType = searchParams.get("message_type") ?? ""; + const channelIdx = searchParams.get("channel_idx") ?? ""; + const includeSpamParam = searchParams.get("include_spam") === "true"; + const page = parseInt(searchParams.get("page") ?? "", 10) || 1; + const limit = parseInt(searchParams.get("limit") ?? "", 10) || 50; + const sort = searchParams.get("sort") ?? "time"; + const order = searchParams.get("order") ?? "desc"; + const offset = (page - 1) * limit; + + const features = config.features ?? {}; + const packetsEnabled = features.packets !== false; + const spamEnabled = features.spam === true; + const includeSpam = spamEnabled && includeSpamParam; + const spamThreshold = + typeof config.spam_score_threshold === "number" + ? config.spam_score_threshold + : 0.65; + const tz = config.timezone || ""; + + const [items, setItems] = useState(null); + const [total, setTotal] = useState(null); + const [error, setError] = useState(null); + const [sortedAreas, setSortedAreas] = useState([]); + const [builtinLabels, setBuiltinLabels] = useState>( + () => new Map(), + ); + const [customLabels, setCustomLabels] = useState>( + () => new Map(), + ); + const [channelLabels, setChannelLabels] = useState>( + () => new Map(), + ); + const [disabledAreas, setDisabledAreas] = useState>(() => + getDisabledObserverAreas(), + ); + const [filterOpen, setFilterOpen] = useState( + messageType !== "" || channelIdx !== "" || includeSpam, + ); + + const disabledAreasRef = useRef(disabledAreas); + disabledAreasRef.current = disabledAreas; + const abortRef = useRef(null); + + const fetchData = useCallback(async () => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + const { signal } = controller; + try { + const [nodesData, channelsData] = await Promise.all([ + apiGet>( + "/api/v1/nodes", + { limit: 500, observer: true }, + { signal }, + ), + apiGet>("/api/v1/channels", {}, { signal }), + ]); + + const builtin = getChannelLabelsMap(config); + const custom = new Map( + (channelsData.items ?? []) + .map((ch): [number, string] => [ + parseInt(ch.channel_hash, 16), + ch.name, + ]) + .filter(([idx]) => Number.isInteger(idx)), + ); + setBuiltinLabels(builtin); + setCustomLabels(custom); + setChannelLabels(new Map([...builtin, ...custom])); + + const areaMap = new Map(); + for (const n of nodesData.items ?? []) { + const area = n.tags?.find((tg) => tg.key === "area")?.value; + if (!area || !area.trim()) continue; + const key = area.trim(); + if (!areaMap.has(key)) areaMap.set(key, []); + areaMap.get(key)!.push(n.public_key); + } + const areas = [...areaMap.keys()].sort((a, b) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ); + setSortedAreas(areas); + + const disabled = disabledAreasRef.current; + const observerFilterActive = areas.some((a) => disabled.has(a)); + const apiParams: Record = { + limit, + offset, + message_type: messageType, + channel_idx: channelIdx, + sort, + order, + }; + if (observerFilterActive) { + apiParams.observed_by = areas + .filter((a) => !disabled.has(a)) + .flatMap((a) => areaMap.get(a) ?? []); + } + if (includeSpam) apiParams.include_spam = true; + + const data = await apiGet>( + "/api/v1/messages", + apiParams, + { signal }, + ); + setItems(dedupeBySignature(data.items ?? [])); + setTotal(data.total ?? 0); + setError(null); + } catch (e) { + if (isAbortError(e)) return; + setError(e instanceof Error ? e.message : String(e)); + } + }, [limit, offset, messageType, channelIdx, includeSpam, sort, order, config]); + + useEffect(() => { + fetchData(); + return () => abortRef.current?.abort(); + }, [fetchData, disabledAreas]); + + const { paused, toggle, intervalSeconds } = useAutoRefresh({ + onRefresh: fetchData, + }); + + const handleObserverToggle = (area: string) => { + const updated = toggleObserverArea(area, sortedAreas.length); + setDisabledAreas(new Set(updated)); + if (page > 1) { + const sp = new URLSearchParams(searchParams); + sp.delete("page"); + const qs = sp.toString(); + navigate(qs ? `/messages?${qs}` : "/messages"); + } + }; + + const senderBlock = (msg: Message, emphasize = false): ReactNode => { + const senderName = msg.sender_tag_name || msg.sender_name; + if (senderName) { + return emphasize ? ( + {senderName} + ) : ( + <>{senderName} + ); + } + const prefix = (msg.pubkey_prefix || "").slice(0, 12); + if (prefix) return {prefix}; + return -; + }; + + const spamBadge = (msg: Message): ReactNode => { + if ( + !spamEnabled || + msg.spam_score == null || + msg.spam_score < spamThreshold + ) { + return null; + } + return ( + + {t("messages.spam.badge")} + + ); + }; + + const renderReceivers = (msg: Message, variant: "mobile" | "desktop") => { + if (msg.observers && msg.observers.length >= 1) { + return ; + } + if (msg.observed_by) { + return ( + + {"\u{1F4E1}"} + + ); + } + return variant === "desktop" ? - : null; + }; + + const totalPages = total !== null ? Math.ceil(total / limit) : 0; + const headerParams: Record = { + message_type: messageType, + channel_idx: channelIdx, + limit: String(limit), + }; + if (includeSpam) headerParams.include_spam = "true"; + const paginationParams: Record = { + ...headerParams, + sort, + order, + }; + const emptyMessage = t("common.no_entity_found", { + entity: t("entities.messages").toLowerCase(), + }); + + return ( + <> +
+

{t("entities.messages")}

+ {tz && tz !== "UTC" && ( + {tz} + )} +
+ +
+ {total !== null && ( + + {t("common.total", { count: formatNumber(total) })} + + )} + {error && } +
+ {intervalSeconds > 0 && ( + + )} +
+
+ setFilterOpen((o) => !o)} + /> +
+
+ + {filterOpen && ( +
+ +
+ + +
+
+ + +
+ {spamEnabled && ( +
+ + +
+ )} +
+
+ )} + + {items === null ? ( + + ) : ( + <> + + + + + + +
+ {items.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + items.map((msg, idx) => { + const isChannel = msg.message_type === "channel"; + const typeIcon = isChannel ? "\u{1F4FB}" : "\u{1F464}"; + const typeTitle = isChannel + ? t("messages.type_channel") + : t("messages.type_contact"); + const chInfo = channelInfo( + msg, + channelLabels, + t("messages.type_channel"), + ); + const displayMessage = messageTextWithSender( + msg, + chInfo.text, + ); + const fromPrimary = isChannel ? ( + + {chInfo.label || t("messages.type_channel")} + + ) : ( + senderBlock(msg) + ); + const detailUrl = + packetsEnabled && msg.packet_hash + ? `/packets/hash/${msg.packet_hash}` + : null; + return ( +
navigate(detailUrl) : undefined + } + > +
+
+
+ + {typeIcon} + +
+
+ {fromPrimary} +
+
+ {formatDateTimeShort(msg.received_at)} + {spamBadge(msg)} +
+
+
+
+ {renderReceivers(msg, "mobile")} +
+
+

+ {displayMessage} +

+
+
+ ); + }) + )} +
+ +
+ + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((msg, idx) => { + const isChannel = msg.message_type === "channel"; + const typeIcon = isChannel ? "\u{1F4FB}" : "\u{1F464}"; + const typeTitle = isChannel + ? t("messages.type_channel") + : t("messages.type_contact"); + const chInfo = channelInfo( + msg, + channelLabels, + t("messages.type_channel"), + ); + const displayMessage = messageTextWithSender( + msg, + chInfo.text, + ); + const fromPrimary = isChannel ? ( + + {chInfo.label || t("messages.type_channel")} + + ) : ( + senderBlock(msg, true) + ); + const detailUrl = + packetsEnabled && msg.packet_hash + ? `/packets/hash/${msg.packet_hash}` + : null; + return ( + navigate(detailUrl) : undefined + } + > + + + + + + + ); + }) + )} + +
{t("common.observers")}
+ {emptyMessage} +
+ {typeIcon} + + {formatDateTime(msg.received_at)} + +
{fromPrimary}
+
+
+ + {displayMessage} + + {spamBadge(msg)} +
+
{renderReceivers(msg, "desktop")}
+
+ + + + )} + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx new file mode 100644 index 0000000..0c7362e --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx @@ -0,0 +1,957 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type FormEvent, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router"; +import { ErrorAlert, Loading, SuccessAlert } from "@/components/Alerts"; +import { IconEdit, IconError, IconPlus, IconTrash } from "@/components/icons"; +import { hasRole, useAppConfig } from "@/context/AppConfigContext"; +import { usePageTitle } from "@/hooks/usePageTitle"; +import { apiDelete, apiGet, apiPost, apiPut, isAbortError } from "@/utils/api"; +import { copyToClipboard } from "@/utils/clipboard"; +import { typeEmoji, truncateKey, useFormatDateTime } from "@/utils/format"; + +interface NodeTag { + key: string; + value: string | null; + value_type: string | null; +} + +interface AdoptionInfo { + user_id: string; + name: string | null; + profile_id: string; +} + +interface NodeDetailData { + public_key: string; + name: string | null; + adv_type: string | null; + lat: number | null; + lon: number | null; + first_seen: string | null; + last_seen: string | null; + tags: NodeTag[] | null; + adopted_by: AdoptionInfo | null; +} + +interface AdvertisementItem { + received_at: string | null; + adv_type: string | null; + observed_by: string | null; + observer_name: string | null; + observer_tag_name: string | null; +} + +interface AdvertisementListResponse { + items: AdvertisementItem[]; +} + +interface PrefixResolution { + public_key: string; +} + +interface FlashState { + type: "success" | "error"; + message: string; +} + +function errorMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +export function NodeDetailPage() { + const { t } = useTranslation(); + const config = useAppConfig(); + const navigate = useNavigate(); + const { publicKey: publicKeyParam } = useParams(); + const [searchParams] = useSearchParams(); + const { formatDateTime } = useFormatDateTime(); + usePageTitle("entities.node_detail"); + + const publicKey = publicKeyParam ?? ""; + const searchKey = searchParams.toString(); + const flashMessage = searchParams.get("message") || ""; + const flashError = searchParams.get("error") || ""; + + const [node, setNode] = useState(null); + const [advertisements, setAdvertisements] = useState( + [], + ); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notFound, setNotFound] = useState(false); + const [flash, setFlash] = useState(null); + + const [addKey, setAddKey] = useState(""); + const [addValue, setAddValue] = useState(""); + const [addType, setAddType] = useState("string"); + const [addError, setAddError] = useState(""); + + const [editTag, setEditTag] = useState(null); + const [editValue, setEditValue] = useState(""); + const [editType, setEditType] = useState("string"); + const [editError, setEditError] = useState(""); + const [editSaving, setEditSaving] = useState(false); + + const [deleteKey, setDeleteKey] = useState(null); + const [deleteSaving, setDeleteSaving] = useState(false); + + const mapContainerRef = useRef(null); + const qrRef = useRef(null); + const mapRef = useRef(null); + const qrInitRef = useRef(false); + + useEffect(() => { + if (!publicKey || publicKey.length === 64) return; + const ac = new AbortController(); + (async () => { + try { + const resolved = await apiGet( + `/api/v1/nodes/prefix/${encodeURIComponent(publicKey)}`, + {}, + { signal: ac.signal }, + ); + navigate(`/nodes/${resolved.public_key}`, { replace: true }); + } catch (e) { + if (isAbortError(e)) return; + if (errorMessage(e).includes("404")) { + setNotFound(true); + } else { + setError(errorMessage(e)); + } + setLoading(false); + } + })(); + return () => ac.abort(); + }, [publicKey, navigate]); + + const loadData = useCallback( + async (signal: AbortSignal) => { + try { + const [nodeData, adsData] = await Promise.all([ + apiGet( + `/api/v1/nodes/${publicKey}`, + {}, + { signal }, + ), + apiGet( + "/api/v1/advertisements", + { public_key: publicKey, limit: 10 }, + { signal }, + ), + apiGet( + "/api/v1/telemetry", + { node_public_key: publicKey, limit: 10 }, + { signal }, + ), + ]); + if (!nodeData) { + setNotFound(true); + return; + } + setNode(nodeData); + setAdvertisements(adsData.items || []); + setNotFound(false); + setError(null); + } catch (e) { + if (isAbortError(e)) return; + if (errorMessage(e).includes("404")) { + setNotFound(true); + } else { + setError(errorMessage(e)); + } + } finally { + if (!signal.aborted) setLoading(false); + } + }, + [publicKey], + ); + + useEffect(() => { + if (!publicKey || publicKey.length !== 64) return; + const ac = new AbortController(); + loadData(ac.signal); + return () => ac.abort(); + }, [loadData, searchKey]); + + let lat: number | null = node?.lat ?? null; + let lon: number | null = node?.lon ?? null; + if (node) { + for (const tag of node.tags || []) { + if (tag.key === "lat" && !lat) lat = parseFloat(tag.value ?? ""); + if (tag.key === "lon" && !lon) lon = parseFloat(tag.value ?? ""); + } + } + const hasCoords = + lat != null && + lon != null && + !Number.isNaN(lat) && + !Number.isNaN(lon) && + !(lat === 0 && lon === 0); + + const tagName = node?.tags?.find((tag) => tag.key === "name")?.value ?? null; + const tagDescription = + node?.tags?.find((tag) => tag.key === "description")?.value ?? null; + const displayName = tagName || node?.name || t("common.unnamed_node"); + const emoji = typeEmoji(node?.adv_type ?? null); + + useEffect(() => { + if (!node || !hasCoords || lat == null || lon == null) return; + const L = (window as any).L; + const mapEl = mapContainerRef.current; + if (!L || !mapEl) return; + const map = L.map(mapEl, { + zoomControl: false, + dragging: false, + scrollWheelZoom: false, + doubleClickZoom: false, + boxZoom: false, + keyboard: false, + attributionControl: false, + }); + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png").addTo( + map, + ); + map.setView([lat, lon], 14); + const point = map.latLngToContainerPoint([lat, lon]); + const newPoint = L.point(point.x + map.getSize().x * 0.17, point.y); + const newLatLng = map.containerPointToLatLng(newPoint); + map.setView(newLatLng, 14, { animate: false }); + const mapIcon = L.divIcon({ + html: + '' + + emoji + + "", + className: "", + iconSize: [32, 32], + iconAnchor: [16, 16], + }); + L.marker([lat, lon], { icon: mapIcon }).addTo(map); + mapRef.current = map; + return () => { + mapRef.current = null; + map.remove(); + }; + }, [node, hasCoords, lat, lon, emoji]); + + useEffect(() => { + if (!node) return; + qrInitRef.current = false; + const typeMap: Record = { + chat: 1, + repeater: 2, + room: 3, + companion: 1, + sensor: 4, + }; + const typeNum = typeMap[(node.adv_type || "").toLowerCase()] || 1; + const url = + "meshcore://contact/add?name=" + + encodeURIComponent(displayName) + + "&public_key=" + + node.public_key + + "&type=" + + typeNum; + const initQr = (): boolean => { + const QRCode = (window as any).QRCode; + const el = qrRef.current; + if (!QRCode || !el || qrInitRef.current) return false; + el.innerHTML = ""; + new QRCode(el, { + text: url, + width: 140, + height: 140, + colorDark: "#000000", + colorLight: "#ffffff", + correctLevel: QRCode.CorrectLevel.L, + }); + qrInitRef.current = true; + return true; + }; + if (initQr()) return; + let attempts = 0; + const interval = setInterval(() => { + if (initQr() || ++attempts >= 20) clearInterval(interval); + }, 100); + return () => clearInterval(interval); + }, [node, displayName, hasCoords]); + + useEffect(() => { + if (!flash) return; + const timer = setTimeout(() => setFlash(null), 3000); + return () => clearTimeout(timer); + }, [flash]); + + const showFlash = (type: "success" | "error", message: string) => { + setFlash({ type, message }); + }; + + const reloadNode = () => { + navigate(`/nodes/${publicKey}?refresh=${Date.now()}`, { replace: true }); + }; + + const validateTagValue = (value: string, type: string): string | null => { + if (!value || !type) return null; + if (type === "number" && isNaN(Number(value))) { + return t("common.validation_invalid_number"); + } + if (type === "boolean") { + const normalized = value.toLowerCase().trim(); + if (!["true", "false", "yes", "no", "1", "0"].includes(normalized)) { + return t("common.validation_invalid_boolean"); + } + } + return null; + }; + + const handleAdopt = async () => { + if (!node) return; + try { + await apiPost("/api/v1/adoptions", { public_key: node.public_key }); + navigate( + `/nodes/${node.public_key}?message=${encodeURIComponent(t("nodes.adopt_success"))}`, + { replace: true }, + ); + } catch (e) { + navigate( + `/nodes/${node.public_key}?error=${encodeURIComponent(errorMessage(e))}`, + { replace: true }, + ); + } + }; + + const handleRelease = async () => { + if (!node) return; + if (!confirm(t("nodes.release_confirm"))) return; + try { + await apiDelete(`/api/v1/adoptions/${node.public_key}`); + navigate( + `/nodes/${node.public_key}?message=${encodeURIComponent(t("nodes.release_success"))}`, + { replace: true }, + ); + } catch (e) { + navigate( + `/nodes/${node.public_key}?error=${encodeURIComponent(errorMessage(e))}`, + { replace: true }, + ); + } + }; + + const handleAddTag = async (e: FormEvent) => { + e.preventDefault(); + if (!node) return; + const validationError = validateTagValue(addValue, addType); + if (validationError) { + setAddError(validationError); + return; + } + setAddError(""); + try { + await apiPost(`/api/v1/nodes/${node.public_key}/tags`, { + key: addKey, + value: addValue, + value_type: addType, + }); + setAddKey(""); + setAddValue(""); + setAddType("string"); + showFlash( + "success", + t("common.entity_added_success", { entity: t("entities.tag") }), + ); + reloadNode(); + } catch (e) { + showFlash("error", errorMessage(e)); + } + }; + + const openEditTag = (tag: NodeTag) => { + setEditTag(tag); + setEditValue(tag.value ?? ""); + setEditType(tag.value_type || "string"); + setEditError(""); + }; + + const handleEditTag = async (e: FormEvent) => { + e.preventDefault(); + if (!node || !editTag) return; + const validationError = validateTagValue(editValue, editType); + if (validationError) { + setEditError(validationError); + return; + } + setEditError(""); + setEditSaving(true); + try { + await apiPut( + `/api/v1/nodes/${node.public_key}/tags/${encodeURIComponent(editTag.key)}`, + { value: editValue, value_type: editType }, + ); + setEditTag(null); + showFlash( + "success", + t("common.entity_updated_success", { entity: t("entities.tag") }), + ); + reloadNode(); + } catch (e) { + setEditError(errorMessage(e)); + } finally { + setEditSaving(false); + } + }; + + const handleDeleteTag = async () => { + if (!node || deleteKey === null) return; + setDeleteSaving(true); + try { + await apiDelete( + `/api/v1/nodes/${node.public_key}/tags/${encodeURIComponent(deleteKey)}`, + ); + setDeleteKey(null); + showFlash( + "success", + t("common.entity_deleted_success", { entity: t("entities.tag") }), + ); + reloadNode(); + } catch (e) { + setDeleteKey(null); + showFlash("error", errorMessage(e)); + } finally { + setDeleteSaving(false); + } + }; + + if (!node) { + if (notFound) { + return ( + <> +
+
    +
  • + {t("entities.home")} +
  • +
  • + {t("entities.nodes")} +
  • +
  • {t("common.page_not_found")}
  • +
+
+
+ + + {t("common.entity_not_found_details", { + entity: t("entities.node"), + details: publicKey, + })} + +
+ + {t("common.view_entity", { entity: t("entities.nodes") })} + + + ); + } + if (error) { + return ; + } + return ; + } + + const canEditTags = + config.oidc_enabled && + !!config.user && + (hasRole("admin") || + (hasRole("operator") && node.adopted_by?.user_id === config.user.sub)); + + const isOperator = hasRole("operator"); + const isAdmin = hasRole("admin"); + + let adoptionCard: ReactNode = null; + if (config.oidc_enabled && config.user) { + if (node.adopted_by) { + const adoptedBy = node.adopted_by; + const ownerName = adoptedBy.name || adoptedBy.user_id; + const canRelease = + (isOperator || isAdmin) && + (adoptedBy.user_id === config.user.sub || isAdmin); + adoptionCard = ( +
+
+

{t("nodes.ownership")}

+
+

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

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

{t("nodes.ownership")}

+

{t("nodes.not_adopted")}

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

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

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

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

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

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

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

{displayName}

+ {tagDescription && ( +

{tagDescription}

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

{t("nodes.scan_to_add")}

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

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

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

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

+ )} +
+
+ +
+
+

{t("entities.tags")}

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

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

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

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

+

+

+ {t("common.cannot_be_undone")} +
+
+ + +
+
+
!deleteSaving && setDeleteKey(null)} + /> +
+ )} + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx new file mode 100644 index 0000000..87bdecf --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx @@ -0,0 +1,433 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useSearchParams } from "react-router"; + +import { useAppConfig } from "@/context/AppConfigContext"; +import { apiGet } from "@/utils/api"; +import { useFormatDateTime, formatNumber } from "@/utils/format"; +import { copyToClipboard } from "@/utils/clipboard"; +import { usePageTitle } from "@/hooks/usePageTitle"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { Pagination } from "@/components/Pagination"; +import { FilterForm, FilterToggle } from "@/components/FilterForm"; +import { + SortableTableHeader, + MobileSortSelect, +} from "@/components/SortableTable"; +import { NodeDisplay } from "@/components/NodeDisplay"; +import { Loading, WarningBadge } from "@/components/Alerts"; +import { IconRefresh } from "@/components/icons"; + +interface NodeTag { + key: string; + value: string | null; +} + +interface NodeItem { + public_key: string; + name: string | null; + adv_type: string | null; + last_seen: string | null; + tags: NodeTag[]; +} + +interface NodeListResponse { + items: NodeItem[]; + total: number; + limit: number; + offset: number; +} + +interface Profile { + id: string; + name: string | null; + callsign: string | null; + roles: string[]; + user_id?: string; +} + +interface ProfileListResponse { + items: Profile[]; + total: number; +} + +function tagValue(tags: NodeTag[] | undefined, key: string): string | null { + return tags?.find((tag) => tag.key === key)?.value ?? null; +} + +export function Nodes() { + const { t } = useTranslation(); + const config = useAppConfig(); + const [searchParams] = useSearchParams(); + const { formatDateTime, formatDateTimeShort } = useFormatDateTime(); + usePageTitle("entities.nodes"); + + const search = searchParams.get("search") || ""; + const advType = searchParams.get("adv_type") || ""; + const adoptedBy = searchParams.get("adopted_by") || ""; + const pubkeyPrefix = searchParams.get("pubkey_prefix") || ""; + const page = parseInt(searchParams.get("page") || "", 10) || 1; + const limit = parseInt(searchParams.get("limit") || "", 10) || 20; + const offset = (page - 1) * limit; + const sort = searchParams.get("sort") || "last_seen"; + const order = searchParams.get("order") || "desc"; + + const tz = config.timezone || ""; + const hasActiveFilters = + search !== "" || + advType !== "" || + pubkeyPrefix !== "" || + (config.oidc_enabled && adoptedBy !== ""); + + const [filterOpen, setFilterOpen] = useState(hasActiveFilters); + const [nodes, setNodes] = useState([]); + const [total, setTotal] = useState(null); + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const oidcEnabled = config.oidc_enabled; + const operatorRole = config.role_names?.operator || "operator"; + + const fetchData = useCallback(async () => { + try { + const apiParams: Record = { + limit, + offset, + search, + adv_type: advType, + sort, + order, + }; + if (adoptedBy) apiParams.adopted_by = adoptedBy; + if (pubkeyPrefix) apiParams.pubkey_prefix = pubkeyPrefix; + + const fetches: Promise[] = [ + apiGet("/api/v1/nodes", apiParams), + ]; + if (oidcEnabled) { + fetches.push( + apiGet("/api/v1/user/profiles", { limit: 500 }), + ); + } + const results = await Promise.all(fetches); + const data = results[0] as NodeListResponse; + const profs = oidcEnabled + ? ((results[1] as ProfileListResponse)?.items || []).filter( + (p) => p.roles && p.roles.includes(operatorRole), + ) + : []; + + setNodes(data.items || []); + setTotal(data.total || 0); + setProfiles(profs); + setError(null); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, [ + limit, + offset, + search, + advType, + sort, + order, + adoptedBy, + pubkeyPrefix, + oidcEnabled, + operatorRole, + ]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const { paused, toggle, intervalSeconds } = useAutoRefresh({ + onRefresh: fetchData, + }); + + const sortedProfiles = useMemo( + () => + [...profiles].sort((a, b) => { + const na = a.name || a.callsign || ""; + const nb = b.name || b.callsign || ""; + return na.localeCompare(nb); + }), + [profiles], + ); + + const totalPages = total !== null ? Math.ceil(total / limit) : 0; + const headerParams: Record = { + search, + adv_type: advType, + adopted_by: adoptedBy, + pubkey_prefix: pubkeyPrefix, + limit: String(limit), + }; + + const autoSubmit = (e: React.ChangeEvent) => { + e.currentTarget.form?.requestSubmit(); + }; + + const noEntity = t("common.no_entity_found", { + entity: t("entities.nodes").toLowerCase(), + }); + + const mobileCards = + nodes.length === 0 ? ( +
{noEntity}
+ ) : ( + nodes.map((node) => { + const displayName = tagValue(node.tags, "name") || node.name; + const tagDescription = tagValue(node.tags, "description"); + const lastSeen = node.last_seen + ? formatDateTimeShort(node.last_seen) + : "-"; + return ( + +
+
+ +
+
{lastSeen}
+
+
+
+ + ); + }) + ); + + const tableRows = + nodes.length === 0 ? ( + + + {noEntity} + + + ) : ( + nodes.map((node) => { + const displayName = tagValue(node.tags, "name") || node.name; + const tagDescription = tagValue(node.tags, "description"); + const lastSeen = node.last_seen ? formatDateTime(node.last_seen) : "-"; + return ( + + + + + + + + copyToClipboard(e, node.public_key)} + title="Click to copy" + > + {node.public_key} + + + {lastSeen} + + ); + }) + ); + + if (loading) return ; + + return ( +
+
+

{t("entities.nodes")}

+ {tz && tz !== "UTC" && ( + {tz} + )} +
+ +
+ {total !== null && ( + + {t("common.total", { count: formatNumber(total) })} + + )} + {error && } +
+ {intervalSeconds > 0 && ( + + )} +
+
+ setFilterOpen((open) => !open)} + /> +
+
+ + {filterOpen && ( +
+ +
+ + +
+
+ + +
+ {oidcEnabled && sortedProfiles.length > 0 && ( +
+ + +
+ )} +
+
+ )} + + + +
{mobileCards}
+ +
+ + + + + + + + + {tableRows} +
+
+ + +
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx new file mode 100644 index 0000000..b18bd7f --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx @@ -0,0 +1,247 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useParams } from "react-router"; +import { useAppConfig } from "@/context/AppConfigContext"; +import { usePageTitle } from "@/hooks/usePageTitle"; +import { apiGet, isAbortError } from "@/utils/api"; +import { useFormatDateTime } from "@/utils/format"; +import { copyToClipboard } from "@/utils/clipboard"; +import { Loading, WarningBadge } from "@/components/Alerts"; +import { JsonTree } from "@/components/JsonTree"; + +interface PacketDetailData { + packet_hash: string | null; + event_type: string | null; + channel_idx: number | null; + observed_by: string | null; + observer_name: string | null; + observer_tag_name: string | null; + source_pubkey_prefix: string | null; + packet_type: number | null; + payload_type: number | null; + route_type: string | null; + snr: number | null; + path_len: number | null; + received_at: string | null; + redacted: boolean; + raw_hex: string | null; + decoded: unknown; +} + +interface ChannelItem { + name: string; + channel_hash: string; +} + +interface ChannelsResponse { + items: ChannelItem[]; +} + +function buildChannelNames(items: ChannelItem[]): Map { + const names = new Map(); + for (const c of items) { + const idx = parseInt(c.channel_hash, 16); + if (!Number.isNaN(idx)) names.set(idx, c.name); + } + return names; +} + +function isNotFoundError(e: unknown): boolean { + return e instanceof Error && e.message.includes("404"); +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +export function PacketDetail() { + const { t } = useTranslation(); + usePageTitle("packets.detail_title"); + const { id } = useParams(); + const config = useAppConfig(); + const { formatDateTime } = useFormatDateTime(); + + const [packet, setPacket] = useState(null); + const [channelNames, setChannelNames] = useState>( + new Map(), + ); + const [notFound, setNotFound] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setPacket(null); + setNotFound(false); + setError(null); + Promise.all([ + apiGet(`/api/v1/packets/${id}`, {}, { + signal: controller.signal, + }), + apiGet("/api/v1/channels", { limit: 200 }, { + signal: controller.signal, + }).catch(() => ({ items: [] as ChannelItem[] })), + ]) + .then(([p, channelsData]) => { + setPacket(p); + setChannelNames(buildChannelNames(channelsData.items || [])); + }) + .catch((e) => { + if (isAbortError(e)) return; + if (isNotFoundError(e)) { + setNotFound(true); + } else { + setError(e instanceof Error ? e.message : String(e)); + } + }); + return () => controller.abort(); + }, [id]); + + const tz = config.timezone || ""; + const leaf = packet?.packet_hash || packet?.event_type || ""; + + let channelDisplay: ReactNode = ; + if (packet && packet.channel_idx != null) { + const name = channelNames.get(packet.channel_idx); + channelDisplay = name + ? `${name} (${packet.channel_idx})` + : `${packet.channel_idx}`; + } + + return ( +
+
+
    +
  • + {t("entities.home")} +
  • +
  • + {t("entities.packets")} +
  • +
  • {leaf || t("packets.detail_title")}
  • +
+
+ +
+

{t("packets.detail_title")}

+ {tz && tz !== "UTC" && {tz}} +
+ + {notFound && ( +
+ {t("common.entity_not_found_details", { + entity: t("entities.packet").toLowerCase(), + })} +
+ )} + {error && } + {!packet && !notFound && !error && } + + {packet && ( + <> + {packet.redacted && ( +
+ {"\u{1F512}"} {t("packets.redacted_notice")} +
+ )} +
+
+
+ + {formatDateTime(packet.received_at)} + + + {packet.observed_by ? ( + + {packet.observer_tag_name || + packet.observer_name || + packet.observed_by} + + ) : ( + + )} + + + {packet.event_type || "—"} + + {channelDisplay} + + {packet.source_pubkey_prefix ? ( + + {packet.source_pubkey_prefix} + + ) : ( + + )} + + + {packet.packet_hash ? ( + + {packet.packet_hash} + + ) : ( + + )} + + + {packet.packet_type != null ? packet.packet_type : "—"} + + + {packet.payload_type != null ? packet.payload_type : "—"} + + + {packet.route_type || "—"} + + + {packet.snr != null ? Number(packet.snr).toFixed(1) : "—"} + + + {packet.path_len != null ? packet.path_len : "—"} + +
+ + {!packet.redacted && ( +
+
+ + {t("packets.col_raw")} + + {packet.raw_hex && ( + + )} +
+
+                    {packet.raw_hex || "—"}
+                  
+
+ )} + + {!packet.redacted && packet.decoded != null && ( +
+ + {t("packets.decoded")} + +
+ +
+
+ )} +
+
+ + )} +
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx new file mode 100644 index 0000000..e2cbbb0 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx @@ -0,0 +1,702 @@ +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useParams } from "react-router"; +import { useAppConfig } from "@/context/AppConfigContext"; +import { usePageTitle } from "@/hooks/usePageTitle"; +import { apiGet, isAbortError } from "@/utils/api"; +import { + formatNumber, + formatRelativeTime, + truncateKey, + useFormatDateTime, +} from "@/utils/format"; +import { copyToClipboard } from "@/utils/clipboard"; +import { Loading, WarningBadge } from "@/components/Alerts"; +import { JsonTree } from "@/components/JsonTree"; +import { IconSatelliteDish } from "@/components/icons"; + +const PATH_MAX_BADGES = 16; +const PATH_HEAD = 7; +const PATH_TAIL = 7; +const PATH_POPOVER_NODE_CAP = 8; + +interface Reception { + packet_id: string; + observed_by: string | null; + observer_name: string | null; + observer_tag_name: string | null; + path_hashes: string[] | null; + path_len: number | null; + snr: number | null; + received_at: string | null; +} + +interface PacketGroupData { + packet_hash: string | null; + event_type: string | null; + channel_idx: number | null; + source_pubkey_prefix: string | null; + packet_type: number | null; + payload_type: number | null; + route_type: string | null; + reception_count: number; + observer_count: number; + first_seen: string | null; + redacted: boolean; + raw_hex: string | null; + decoded: unknown; + receptions: Reception[]; +} + +interface ChannelItem { + name: string; + channel_hash: string; +} + +interface ChannelsResponse { + items: ChannelItem[]; +} + +interface NodeItem { + public_key: string; + name: string | null; + tags?: { key: string; value: string }[]; +} + +interface NodesResponse { + items: NodeItem[]; + total: number; +} + +interface PopoverAnchor { + hash: string; + left: number; + bottom: number; + top: number; +} + +function buildChannelNames(items: ChannelItem[]): Map { + const names = new Map(); + for (const c of items) { + const idx = parseInt(c.channel_hash, 16); + if (!Number.isNaN(idx)) names.set(idx, c.name); + } + return names; +} + +function isNotFoundError(e: unknown): boolean { + return e instanceof Error && e.message.includes("404"); +} + +function groupByObserver(receptions: Reception[]): Map { + const groups = new Map(); + for (const r of receptions) { + const key = r.observed_by || "__unknown__"; + const list = groups.get(key); + if (list) { + list.push(r); + } else { + groups.set(key, [r]); + } + } + return groups; +} + +function nodeDisplayName(n: NodeItem): string { + const tagName = n.tags?.find((tag) => tag.key === "name")?.value; + return tagName || n.name || truncateKey(n.public_key, 12); +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function Stat({ label, value }: { label: string; value: ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +function PathBadge({ + hash, + onOpen, +}: { + hash: string; + onOpen: (e: React.MouseEvent, hash: string) => void; +}) { + return ( + onOpen(e, hash)} + > + {hash} + + ); +} + +function PathFlow({ + reception, + sourcePrefix, + onBadgeOpen, +}: { + reception: Reception; + sourcePrefix: string | null; + onBadgeOpen: (e: React.MouseEvent, hash: string) => void; +}) { + const { t } = useTranslation(); + const hashes = reception.path_hashes ?? []; + + let middle: ReactNode[]; + if (hashes.length > 0) { + if (hashes.length <= PATH_MAX_BADGES) { + middle = hashes.map((h, i) => ( + + )); + } else { + const hidden = hashes.length - PATH_HEAD - PATH_TAIL; + middle = [ + ...hashes + .slice(0, PATH_HEAD) + .map((h, i) => ( + + )), + , + ...hashes + .slice(-PATH_TAIL) + .map((h, i) => ( + + )), + ]; + } + } else if (reception.path_len != null) { + middle = [ + + {reception.path_len} {t("common.hops").toLowerCase()} + , + ]; + } else { + middle = [ + + — + , + ]; + } + + const parts: ReactNode[] = [ + , + ...middle, + + + , + ]; + + const joined: ReactNode[] = []; + parts.forEach((part, i) => { + if (i > 0) { + joined.push( + + → + , + ); + } + joined.push(part); + }); + + return {joined}; +} + +export function PacketGroupDetail() { + const { t } = useTranslation(); + usePageTitle("packets.detail_title"); + const { hash } = useParams(); + const config = useAppConfig(); + const { formatDateTime } = useFormatDateTime(); + + const [group, setGroup] = useState(null); + const [channelNames, setChannelNames] = useState>( + new Map(), + ); + const [notFound, setNotFound] = useState(false); + const [error, setError] = useState(null); + + const [popover, setPopover] = useState(null); + const [popoverPos, setPopoverPos] = useState<{ + left: number; + top: number; + } | null>(null); + const [popoverNodes, setPopoverNodes] = useState(null); + const [popoverTotal, setPopoverTotal] = useState(0); + const [popoverError, setPopoverError] = useState(null); + const popoverRef = useRef(null); + + useEffect(() => { + const controller = new AbortController(); + setGroup(null); + setNotFound(false); + setError(null); + Promise.all([ + apiGet(`/api/v1/packet-groups/${hash}`, {}, { + signal: controller.signal, + }), + apiGet("/api/v1/channels", { limit: 200 }, { + signal: controller.signal, + }).catch(() => ({ items: [] as ChannelItem[] })), + ]) + .then(([g, channelsData]) => { + setGroup(g); + setChannelNames(buildChannelNames(channelsData.items || [])); + }) + .catch((e) => { + if (isAbortError(e)) return; + if (isNotFoundError(e)) { + setNotFound(true); + } else { + setError(e instanceof Error ? e.message : String(e)); + } + }); + return () => controller.abort(); + }, [hash]); + + useEffect(() => { + const onDocClick = (ev: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(ev.target as Node) + ) { + setPopover(null); + } + }; + const onKey = (ev: KeyboardEvent) => { + if (ev.key === "Escape") setPopover(null); + }; + document.addEventListener("click", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("click", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, []); + + const popoverHash = popover?.hash ?? null; + useEffect(() => { + if (!popoverHash) return; + let cancelled = false; + setPopoverNodes(null); + setPopoverError(null); + apiGet("/api/v1/nodes", { + pubkey_prefix: popoverHash, + sort: "name", + order: "asc", + limit: PATH_POPOVER_NODE_CAP, + }) + .then((data) => { + if (cancelled) return; + const items = (data.items || []) + .slice() + .sort((a, b) => + nodeDisplayName(a).localeCompare(nodeDisplayName(b)), + ); + setPopoverNodes(items); + setPopoverTotal(data.total || 0); + }) + .catch((e) => { + if (cancelled || isAbortError(e)) return; + setPopoverError(e instanceof Error ? e.message : String(e)); + }); + return () => { + cancelled = true; + }; + }, [popoverHash]); + + useLayoutEffect(() => { + if (!popover || !popoverRef.current) return; + const el = popoverRef.current; + const margin = 8; + const pw = el.offsetWidth || 256; + const ph = el.offsetHeight || 0; + let left = Math.min(popover.left, window.innerWidth - pw - margin); + if (left < margin) left = margin; + let top = popover.bottom + 4; + if ( + top + ph + margin > window.innerHeight && + popover.top - ph - 4 > margin + ) { + top = popover.top - ph - 4; + } + setPopoverPos({ left, top }); + }, [popover, popoverNodes, popoverError]); + + const openPathPopover = (e: React.MouseEvent, pathHash: string) => { + e.preventDefault(); + e.stopPropagation(); + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setPopoverPos(null); + setPopover({ + hash: pathHash, + left: rect.left, + bottom: rect.bottom, + top: rect.top, + }); + }; + + const tz = config.timezone || ""; + const leaf = group?.packet_hash || group?.event_type || ""; + const receptions = group?.receptions ?? []; + const sourcePrefix = group?.source_pubkey_prefix ?? null; + const observerGroups = groupByObserver(receptions); + const moreCount = popoverTotal - (popoverNodes?.length ?? 0); + + let channelDisplay: ReactNode = ; + if (group && group.channel_idx != null) { + const name = channelNames.get(group.channel_idx); + channelDisplay = name + ? `${name} (${group.channel_idx})` + : `${group.channel_idx}`; + } + + const receptionTime = (r: Reception) => ( + + {formatRelativeTime(r.received_at)} + + ); + + return ( +
+
+
    +
  • + {t("entities.home")} +
  • +
  • + {t("entities.packets")} +
  • +
  • {leaf || t("packets.detail_title")}
  • +
+
+ +
+

{t("packets.detail_title")}

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

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

+ {[...observerGroups.entries()].map(([key, recs]) => { + const first = recs[0]; + const displayName = + first.observer_tag_name || + first.observer_name || + (first.observed_by + ? first.observed_by.slice(0, 12) + "…" + : "—"); + return ( +
+
+ {"\u{1F4E1}"}{" "} + {first.observed_by ? ( + + {displayName} + + ) : ( + displayName + )} + {recs.length > 1 && ( + + ({formatNumber(recs.length)}{" "} + {t("packets.reception_plural")}) + + )} +
+ +
+ {recs.map((r) => ( +
+
+ +
+
+ + + +
+
+ ))} +
+ +
+ + + + + + + + + + + {recs.map((r) => ( + + + + + + + ))} + +
{t("packets.col_path")} + {t("common.hops")} + + {t("common.snr_db")} + + {t("common.time")} +
+ + + {r.path_len != null ? r.path_len : "—"} + + {r.snr != null + ? Number(r.snr).toFixed(1) + : "—"} + + {receptionTime(r)} +
+
+
+ ); + })} +
+ )} + + {!group.redacted && group.raw_hex && ( +
+
+ + {t("packets.col_raw")} + + +
+
+                    {group.raw_hex}
+                  
+
+ )} + + {!group.redacted && group.decoded != null && ( +
+ + {t("packets.decoded")} + +
+ +
+
+ )} +
+
+ + )} + + {popover && ( +
+
+ + {t("packets.path_nodes_title", { hash: popover.hash })} + + +
+
+ {popoverError ? ( +
+ +
+ ) : popoverNodes === null ? ( +
+ +
+ ) : popoverNodes.length === 0 ? ( +
+ {t("packets.path_no_nodes")} +
+ ) : ( +
    + {popoverNodes.map((n) => ( +
  • + setPopover(null)} + className="flex flex-col items-start gap-0" + > + {nodeDisplayName(n)} + + {truncateKey(n.public_key, 16)} + + +
  • + ))} + {moreCount > 0 && ( +
  • + setPopover(null)} + className="text-xs opacity-70" + > + {t("packets.path_nodes_more", { + count: formatNumber(moreCount), + })} + +
  • + )} +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx new file mode 100644 index 0000000..38f7ea2 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx @@ -0,0 +1,527 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate, useSearchParams } from "react-router"; +import { useAppConfig } from "@/context/AppConfigContext"; +import { usePageTitle } from "@/hooks/usePageTitle"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { apiGet, isAbortError } from "@/utils/api"; +import { formatNumber, useFormatDateTime } from "@/utils/format"; +import { Pagination } from "@/components/Pagination"; +import { FilterForm, FilterToggle } from "@/components/FilterForm"; +import { + MobileSortSelect, + SortableTableHeader, +} from "@/components/SortableTable"; +import { Loading, WarningBadge } from "@/components/Alerts"; +import { + IconPath, + IconRefresh, + IconRuler, + IconSatelliteDish, +} from "@/components/icons"; + +const EVENT_TYPES = [ + "advertisement", + "channel_msg_recv", + "contact_msg_recv", + "trace_data", + "telemetry_response", + "path_updated", + "status_response", + "req", + "response", + "ack", + "encrypted_direct", + "encrypted_channel", + "grp_data", + "anon_req", + "multipart", + "control", + "raw_custom", + "advert", + "path", + "trace", + "letsmesh_packet", +]; + +interface PacketGroupItem { + packet_hash: string | null; + event_type: string | null; + channel_idx: number | null; + path_hash_bytes: number | null; + reception_count: number | null; + observer_count: number | null; + first_seen: string | null; + redacted?: boolean; + receptions?: { packet_id: string }[]; +} + +interface PacketGroupsResponse { + items: PacketGroupItem[]; + total: number; +} + +interface ChannelItem { + name: string; + channel_hash: string; +} + +interface ChannelsResponse { + items: ChannelItem[]; +} + +interface ChannelEntry { + idx: number; + name: string; +} + +function buildChannelList(items: ChannelItem[]): ChannelEntry[] { + return items + .map((c) => ({ name: c.name, idx: parseInt(c.channel_hash, 16) })) + .filter((c) => !Number.isNaN(c.idx)); +} + +function packetUrl(p: PacketGroupItem): string { + if (p.packet_hash) return `/packets/hash/${p.packet_hash}`; + if (p.receptions && p.receptions.length > 0) + return `/packets/${p.receptions[0].packet_id}`; + return "/packets"; +} + +function ChannelLabel({ + packet, + channelNames, +}: { + packet: PacketGroupItem; + channelNames: Map; +}) { + const { t } = useTranslation(); + if (packet.channel_idx == null) return ; + const name = channelNames.get(packet.channel_idx); + const text = name ? `${name} (${packet.channel_idx})` : `${packet.channel_idx}`; + return ( + <> + {text} + {packet.redacted && ( + <> + {" "} + + {"\u{1F512}"} + + + )} + + ); +} + +function ReceptionBadge({ packet }: { packet: PacketGroupItem }) { + const { t } = useTranslation(); + const rc = packet.reception_count ?? 1; + const oc = packet.observer_count ?? 1; + const pb = packet.path_hash_bytes; + const knownWidth = pb != null && pb > 0; + const widthLabel = knownWidth + ? t("packets.path_width_bytes", { count: pb }) + : t("packets.path_width_unknown"); + return ( + + + + {formatNumber(oc)} + + + + + {formatNumber(rc)} + + + + + {widthLabel} + + + ); +} + +export function Packets() { + const { t } = useTranslation(); + usePageTitle("entities.packets"); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const config = useAppConfig(); + const { formatDateTime, formatDateTimeShort } = useFormatDateTime(); + + const search = searchParams.get("search") ?? ""; + const eventType = searchParams.get("event_type") ?? ""; + const channelIdx = searchParams.get("channel_idx") ?? ""; + const pathHashBytes = searchParams.get("path_hash_bytes") ?? ""; + const page = parseInt(searchParams.get("page") ?? "", 10) || 1; + const limit = parseInt(searchParams.get("limit") ?? "", 10) || 20; + const sort = searchParams.get("sort") ?? "time"; + const order = searchParams.get("order") ?? "desc"; + const offset = (page - 1) * limit; + + const [packets, setPackets] = useState(null); + const [total, setTotal] = useState(0); + const [channels, setChannels] = useState([]); + const [error, setError] = useState(null); + const hasActiveFilters = + search !== "" || + eventType !== "" || + channelIdx !== "" || + pathHashBytes !== ""; + const [filterOpen, setFilterOpen] = useState(hasActiveFilters); + + const channelNames = useMemo( + () => new Map(channels.map((c) => [c.idx, c.name])), + [channels], + ); + + const fetchData = useCallback( + async (signal?: AbortSignal) => { + try { + const apiParams: Record = { + limit, + offset, + search, + sort, + order, + }; + if (eventType) apiParams.event_type = eventType; + if (channelIdx !== "") apiParams.channel_idx = channelIdx; + if (pathHashBytes !== "") apiParams.path_hash_bytes = pathHashBytes; + + const [data, channelsData] = await Promise.all([ + apiGet("/api/v1/packet-groups", apiParams, { + signal, + }), + apiGet("/api/v1/channels", { limit: 200 }, { + signal, + }).catch(() => ({ items: [] as ChannelItem[] })), + ]); + + setPackets(data.items || []); + setTotal(data.total || 0); + setChannels(buildChannelList(channelsData.items || [])); + setError(null); + } catch (e) { + if (isAbortError(e)) return; + setError(e instanceof Error ? e.message : String(e)); + } + }, + [search, eventType, channelIdx, pathHashBytes, limit, offset, sort, order], + ); + + useEffect(() => { + const controller = new AbortController(); + fetchData(controller.signal); + return () => controller.abort(); + }, [fetchData]); + + const autoRefresh = useAutoRefresh({ + onRefresh: () => fetchData(), + }); + + const applyFilters = (overrides: Record) => { + const next = { + search, + event_type: eventType, + channel_idx: channelIdx, + path_hash_bytes: pathHashBytes, + ...overrides, + }; + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(next)) { + if (v) params.set(k, v); + } + const qs = params.toString(); + navigate(qs ? `/packets?${qs}` : "/packets"); + }; + + const tz = config.timezone || ""; + const totalPages = Math.ceil(total / limit); + const filterParams: Record = { + search, + event_type: eventType, + channel_idx: channelIdx, + path_hash_bytes: pathHashBytes, + limit: String(limit), + }; + const noneFound = t("common.no_entity_found", { + entity: t("entities.packets").toLowerCase(), + }); + + return ( +
+
+

{t("entities.packets")}

+ {tz && tz !== "UTC" && {tz}} +
+ +
+ {packets !== null && ( + + {t("common.total", { count: formatNumber(total) })} + + )} + {error && } +
+ {autoRefresh.intervalSeconds > 0 && ( + + )} +
+
+ setFilterOpen((o) => !o)} + /> +
+
+ + {filterOpen && ( +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ )} + + {packets === null ? ( + + ) : ( + <> + + +
+ {packets.length === 0 ? ( +
{noneFound}
+ ) : ( + packets.map((p, i) => ( + +
+
+
+
+ {p.event_type || "—"} +
+
+ +
+
+
+
+ {formatDateTimeShort(p.first_seen)} +
+
+ +
+
+
+
+ + )) + )} +
+ +
+ + + + + + + + + + + + {packets.length === 0 ? ( + + + + ) : ( + packets.map((p, i) => ( + navigate(packetUrl(p))} + > + + + + + + + )) + )} + +
{t("packets.packet_hash")} + {t("packets.col_receptions")} + {t("entities.channel")}
+ {noneFound} +
+ {formatDateTime(p.first_seen)} + + {p.packet_hash ? ( + + {p.packet_hash} + + ) : ( + + )} + + + + {p.event_type || "—"} + + +
+
+ + + + )} +
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx new file mode 100644 index 0000000..359b710 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx @@ -0,0 +1,360 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useAppConfig } from "@/context/AppConfigContext"; +import type { AppConfig } from "@/types/config"; +import { apiGet, apiPut, isAbortError } from "@/utils/api"; +import { formatRelativeTime, useFormatDateTime } from "@/utils/format"; +import { Loading, ErrorAlert, SuccessAlert } from "@/components/Alerts"; +import { usePageTitle } from "@/hooks/usePageTitle"; + +interface ProfileNode { + public_key: string; + name?: string | null; + last_seen?: string | null; +} + +interface UserProfileData { + id: string; + user_id?: string | null; + name?: string | null; + callsign?: string | null; + description?: string | null; + url?: string | null; + roles?: string[] | null; + created_at?: string | null; + nodes?: ProfileNode[] | null; +} + +function hasOperatorOrAdmin( + roles: string[] | null | undefined, + config: AppConfig, +): boolean { + const roleNames = config.role_names || {}; + const operatorRole = roleNames.operator || "operator"; + const adminRole = roleNames.admin || "admin"; + return !!roles && (roles.includes(operatorRole) || roles.includes(adminRole)); +} + +function RoleBadges({ roles }: { roles?: string[] | null }) { + if (!roles || roles.length === 0) return null; + return ( +
+ {roles.map((role) => ( + + {role} + + ))} +
+ ); +} + +function MemberSince({ createdAt }: { createdAt?: string | null }) { + const { t } = useTranslation(); + const { formatDateTime } = useFormatDateTime(); + if (!createdAt) return null; + return ( +

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

+ ); +} + +function AdoptedNodeLink({ node }: { node: ProfileNode }) { + const { formatDateTime } = useFormatDateTime(); + const displayName = node.name || node.public_key.slice(0, 12) + "..."; + const relTime = node.last_seen ? formatRelativeTime(node.last_seen) : "-"; + const fullTime = node.last_seen ? formatDateTime(node.last_seen) : "-"; + + return ( + +
+
{displayName}
+
+ {node.public_key} +
+
+ + + ); +} + +function AdoptedNodesCard({ + profile, + className, +}: { + profile: UserProfileData; + className?: string; +}) { + const { t } = useTranslation(); + return ( +
+
+

{t("user_profile.adopted_nodes")}

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

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

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

{t("user_profile.title")}

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

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

+ + {profile.description && ( +

{profile.description}

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

{t("user_profile.title")}

+

{t("user_profile.login_to_view")}

+ + {t("auth.login")} + +
+ ); + } + + if (error) return ; + if (!profile) return ; + + const flashMessage = searchParams.get("message") || ""; + const flashError = searchParams.get("error") || ""; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + const data = new FormData(e.currentTarget); + const body = { + name: String(data.get("name") ?? "").trim() || null, + callsign: String(data.get("callsign") ?? "").trim() || null, + description: String(data.get("description") ?? "").trim() || null, + url: String(data.get("url") ?? "").trim() || null, + }; + try { + await apiPut(`/api/v1/user/profile/${profile.id}`, body); + navigate( + "/profile?message=" + encodeURIComponent(t("user_profile.profile_updated")), + { replace: true }, + ); + } catch (err) { + navigate( + "/profile?error=" + encodeURIComponent((err as Error).message), + { replace: true }, + ); + } + }; + + return ( + <> +
+

{t("user_profile.title")}

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

{t("user_profile.your_profile")}

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

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

+ {route.description && ( +

{route.description}

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

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

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

    {t("routes.path_help")}

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

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

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

    {t("routes.delete_route")}

    +

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

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

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

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

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

    +
    + {group.map((r) => ( + openEditModal(r)} + onDelete={() => openDeleteModal(r)} + onNavigate={navigate} + /> + ))} +
    +
    + ); + })} + + {modal && (modal.type === "add" || modal.type === "edit") && ( + setModal(null)} + /> + )} + + {modal?.type === "delete" && modal.route && ( + setModal(null)} + /> + )} +
    + ); +} From 715659607a7deb57eec38d877e53895a1b4cc3f0 Mon Sep 17 00:00:00 2001 From: Louis King Date: Tue, 21 Jul 2026 19:03:04 +0100 Subject: [PATCH 03/11] =?UTF-8?q?feat(web):=20React=20charts/maps/QR=20?= =?UTF-8?q?=E2=80=94=20Phase=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all window.Chart / window.L / window.QRCode globals and the charts.js helper script with bundled React components: - react-chartjs-2: typed config builders in utils/charts.ts (buildLineChart, buildActivityChart, buildStackedBar, buildRoutesTrend, buildRouteDetailStrip + ChartColors, averageRouteTier, routeQualityToTier) and wrappers in components/charts/Charts.tsx (ActivityChart, TrendLineChart, StackedBarChart, RoutesTrendChart, RouteDetailStrip). utils/charts.ts imports chart.js/auto. Wired into Home, Dashboard, Routes. - react-leaflet: MapPage rewritten with MapContainer/TileLayer/Marker/Popup + a useMap MapController for fit-bounds and memoized markers; NodeDetail static hero map with divIcon marker + OffsetCenter. Both import leaflet/dist/leaflet.css. - react-qr-code: replaces window.QRCode in Channels and NodeDetail. Bundling & shell: - Chart.js, Leaflet (+CSS), react-qr-code now bundled by Vite; removed the leaflet/chart.js/qrcodejs vendor - - - - - - - - - - - {% if asset_app_css %} - - {% endif %} {% if asset_app_js %} {% else %} diff --git a/tests/test_collector/test_routes.py b/tests/test_collector/test_routes.py index 3351095..76157f8 100644 --- a/tests/test_collector/test_routes.py +++ b/tests/test_collector/test_routes.py @@ -1125,8 +1125,8 @@ class TestComputeAverageQuality: """Rolling-average tier over a history window (server-side badge source). Mirrors the ``averageRouteTier`` JS helper in - ``web/static/js/charts.js`` so the route card badge matches the chart - line color when both render the same window. + ``web/static/js/spa-react/utils/charts.ts`` so the route card badge matches + the chart line color when both render the same window. """ @staticmethod diff --git a/tests/test_web/test_caching.py b/tests/test_web/test_caching.py index 93f5892..280d789 100644 --- a/tests/test_web/test_caching.py +++ b/tests/test_web/test_caching.py @@ -19,7 +19,7 @@ class TestCacheControlHeaders: def test_static_js_with_version(self, client): """Static JS with version parameter should have long-term cache.""" - response = client.get(f"/static/js/charts.js?v={__version__}") + response = client.get(f"/static/js/spa/app.js?v={__version__}") assert response.status_code == 200 assert "cache-control" in response.headers assert ( @@ -59,7 +59,7 @@ class TestCacheControlHeaders: def test_static_js_without_version(self, client): """Static JS without version should have short fallback cache.""" - response = client.get("/static/js/charts.js") + response = client.get("/static/js/spa/app.js") assert response.status_code == 200 assert "cache-control" in response.headers assert response.headers["cache-control"] == "public, max-age=3600" @@ -152,19 +152,6 @@ class TestVersionParameterInHTML: assert css_link is not None assert f"?v={__version__}" in css_link["href"] - def test_charts_js_has_version(self, client): - """Charts.js script should include version parameter.""" - response = client.get("/") - assert response.status_code == 200 - - soup = BeautifulSoup(response.text, "html.parser") - charts_script = soup.find( - "script", {"src": lambda x: x and "/static/js/charts.js" in x} - ) - - assert charts_script is not None - assert f"?v={__version__}" in charts_script["src"] - def test_app_js_has_version(self, client): """SPA app.js script should include version or content hash.""" response = client.get("/") From a5fabf7d465ef6aab638acba31f69d6883babe69 Mon Sep 17 00:00:00 2001 From: Louis King Date: Tue, 21 Jul 2026 19:13:01 +0100 Subject: [PATCH 04/11] =?UTF-8?q?chore(web):=20remove=20lit-html=20fallbac?= =?UTF-8?q?k=20&=20legacy=20code=20=E2=80=94=20Phase=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The React frontend is complete (Phases 1-3); the lit-html fallback is dead (its vendor globals were removed in Phase 3). Delete it and the scaffolding: - Delete the entire src/meshcore_hub/web/static/js/spa/ lit-html tree, LitBridge.tsx, and legacy.d.ts. - Remove the @legacy alias from vite.config.ts and tsconfig.json. - Remove lit-html and qrcodejs from package.json (both unused now). - Remove the lit-html fallback {% else %} branch from spa.html — the Vite build is now required to serve the UI (no fallback bundle). Tests (fallback no longer exists): - test_home/advertisements/nodes/messages.py: assert the React mount point (id="app") instead of a bundled-or-fallback script tag. - test_caching.py: JS-cache tests are header-only (static JS is bundled into dist/, absent in test env; the middleware sets headers on 404 too); the dist-bundle HTML test drops its fallback branch. Docs: - AGENTS.md: new Frontend (React) section (host-run npm/vite/tsc toolchain, react-chartjs-2/react-leaflet/react-qr-code, CSS load order); clarified the compose-stack rule to exempt frontend tooling. - REACT_MIGRATION.md: Phase 4 complete, final file structure, decisions. Verified: tsc --noEmit clean, npm run build, full pytest (1463 passed, 22 skipped), pre-commit (passed). --- AGENTS.md | 29 +- REACT_MIGRATION.md | 69 +- package-lock.json | 22 - package.json | 2 - .../js/spa-react/components/LitBridge.tsx | 80 -- .../web/static/js/spa-react/legacy.d.ts | 7 - src/meshcore_hub/web/static/js/spa/api.js | 130 --- src/meshcore_hub/web/static/js/spa/app.js | 277 ------ .../web/static/js/spa/auto-refresh.js | 87 -- .../web/static/js/spa/components.js | 838 ----------------- src/meshcore_hub/web/static/js/spa/i18n.js | 78 -- src/meshcore_hub/web/static/js/spa/icons.js | 199 ---- .../web/static/js/spa/json-tree.js | 111 --- .../web/static/js/spa/pages/advertisements.js | 330 ------- .../web/static/js/spa/pages/channels.js | 300 ------ .../web/static/js/spa/pages/custom-page.js | 30 - .../web/static/js/spa/pages/dashboard.js | 435 --------- .../web/static/js/spa/pages/home.js | 305 ------ .../web/static/js/spa/pages/maintenance.js | 27 - .../web/static/js/spa/pages/map.js | 361 -------- .../web/static/js/spa/pages/members.js | 117 --- .../web/static/js/spa/pages/messages.js | 508 ---------- .../web/static/js/spa/pages/node-detail.js | 657 ------------- .../web/static/js/spa/pages/nodes.js | 246 ----- .../web/static/js/spa/pages/not-found.js | 27 - .../web/static/js/spa/pages/packet-detail.js | 120 --- .../js/spa/pages/packet-group-detail.js | 389 -------- .../web/static/js/spa/pages/packets.js | 263 ------ .../web/static/js/spa/pages/profile.js | 194 ---- .../web/static/js/spa/pages/routes.js | 876 ------------------ src/meshcore_hub/web/static/js/spa/router.js | 189 ---- src/meshcore_hub/web/templates/spa.html | 4 +- tests/test_web/test_advertisements.py | 8 +- tests/test_web/test_caching.py | 40 +- tests/test_web/test_home.py | 8 +- tests/test_web/test_messages.py | 8 +- tests/test_web/test_nodes.py | 8 +- tsconfig.json | 3 +- vite.config.ts | 5 - 39 files changed, 99 insertions(+), 7288 deletions(-) delete mode 100644 src/meshcore_hub/web/static/js/spa-react/components/LitBridge.tsx delete mode 100644 src/meshcore_hub/web/static/js/spa-react/legacy.d.ts delete mode 100644 src/meshcore_hub/web/static/js/spa/api.js delete mode 100644 src/meshcore_hub/web/static/js/spa/app.js delete mode 100644 src/meshcore_hub/web/static/js/spa/auto-refresh.js delete mode 100644 src/meshcore_hub/web/static/js/spa/components.js delete mode 100644 src/meshcore_hub/web/static/js/spa/i18n.js delete mode 100644 src/meshcore_hub/web/static/js/spa/icons.js delete mode 100644 src/meshcore_hub/web/static/js/spa/json-tree.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/advertisements.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/channels.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/custom-page.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/dashboard.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/home.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/maintenance.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/map.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/members.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/messages.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/node-detail.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/nodes.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/not-found.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/packet-detail.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/packet-group-detail.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/packets.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/profile.js delete mode 100644 src/meshcore_hub/web/static/js/spa/pages/routes.js delete mode 100644 src/meshcore_hub/web/static/js/spa/router.js diff --git a/AGENTS.md b/AGENTS.md index acb911b..cd2bdd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ ``` `--no-cov` skips coverage for speed; the pipe surfaces only the pass/fail summary. - Use Python (version in `.python-version`); activate a venv in `.venv` before running pytest, pre-commit, or alembic locally. -- **All other operations run inside the compose stack** — never invoke `meshcore-hub` or `npm` directly on the host; build/run/exec via `docker compose` (see Development). +- **Application operations run inside the compose stack** — never invoke `meshcore-hub` directly on the host; build/run/exec via `docker compose` (see Development). The frontend `npm`/`vite`/`tsc` toolchain is the exception — it runs on the host (see Frontend). - **Never `git push` without explicit confirmation** — staging and committing discrete changes is fine. - **Never build the Docker images or run `make build` / `make up`** — the user builds manually to test. Stop after code changes + tests + pre-commit pass. - **Always generate random Alembic revision IDs** — use `python -c "import secrets; print(secrets.token_hex(6))"` or let `alembic revision` auto-generate. Never hand-pick sequential or guessable IDs like `a1b2c3d4e5f6` — they collide with existing migrations and cause cycle errors at upgrade time. @@ -40,6 +40,33 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core ex # Shorthands (Makefile, mqtt+core profiles): make build | make up | make down | make logs ``` +## Frontend (React) + +The web UI is a **React 19 + TypeScript + Vite** SPA in +`src/meshcore_hub/web/static/js/spa-react/` (alias `@/` → that dir). The Jinja2 shell +(`web/templates/spa.html`) renders the navbar/SEO/`window.__APP_CONFIG__`; React mounts into +`
    `. **Frontend tooling runs on the host** (not in Docker): `npm install`, +`npm run build` (Tailwind → vendor fonts → `vite build` → `static/dist/` + `assets.json`), and +`npx tsc --noEmit` (the TS gate — there is no JS linter in pre-commit). The Vite build is +required to serve the UI; there is no fallback bundle. + +```bash +npm install # host: install frontend deps +npm run build # host: produce static/dist/ + assets.json +npx tsc --noEmit # host: typecheck (must be clean) +``` + +- Charts: **react-chartjs-2** — typed config builders in `utils/charts.ts`, wrappers in + `components/charts/Charts.tsx` (imports `chart.js/auto`). +- Maps: **react-leaflet** (`MapPage.tsx`, `NodeDetail.tsx`); both `import "leaflet/dist/leaflet.css"`. + That CSS ships in the Vite bundle, which `spa.html` loads in `` **before** `app.css` so + the dark-mode map overrides win — don't reorder those ``s. +- QR codes: **react-qr-code**. +- Page conventions: `useSearchParams()` for filters/pagination/sort, typed `apiGet()` with an + `AbortController` in `useEffect`, `usePageTitle('entities.x')`, shared components + (`Pagination`, `FilterForm`, `StatCard`, `NodeDisplay`, etc.). +- Only **fonts** are vendored (`build.js` copies them); chart/map/QR libs are bundled by Vite. + ## Tests & Quality Coverage is **opt-in**; add `--cov=meshcore_hub` (or `make test-cov`) when you want it. The dev loop defaults to no coverage and parallel across CPU cores. diff --git a/REACT_MIGRATION.md b/REACT_MIGRATION.md index bddfe0a..3eecf59 100644 --- a/REACT_MIGRATION.md +++ b/REACT_MIGRATION.md @@ -9,7 +9,7 @@ Migration from lit-html (functional templates) to React 19 + TypeScript + Vite. | 1 | Infrastructure (Vite, React shell, router, LitBridge, build pipeline, shared components) | **Complete** | | 2 | Convert pages one-by-one from LitBridge to native React | **Complete** | | 3 | Chart & map components (react-chartjs-2, react-leaflet) | **Complete** | -| 4 | Cleanup (remove lit-html, old spa/, build.js esbuild remnants) | Not started | +| 4 | Cleanup (remove lit-html, old spa/, LitBridge, @legacy alias) | **Complete** | | 5 | Optional enhancements (tests, react-query, Storybook) | Not started | > **Phase 3 status:** All `window.Chart` / `window.L` / `window.QRCode` globals and the @@ -30,29 +30,29 @@ Migration from lit-html (functional templates) to React 19 + TypeScript + Vite. ## Architecture Decisions -- **TypeScript** strict mode, `@/` alias → `spa-react/`, `@legacy/` alias → `spa/` +- **TypeScript** strict mode, `@/` alias → `spa-react/` (the `@legacy/` alias was removed in Phase 4) - **Vite 6** replaces esbuild; outputs to `static/dist/` with content-hashed filenames - **Jinja2 shell preserved** — server renders navbar, SEO meta, config JSON; React owns `
    ` -- **LitBridge** wraps unconverted pages: dynamic import → `render(container, params, router)` → cleanup -- **react-i18next** loads same locale JSONs from `/static/locales/`; exposes `window.t` for legacy scripts +- **All pages native React** — LitBridge (the temporary wrapper for unconverted lit-html pages) was removed in Phase 4 +- **react-i18next** loads same locale JSONs from `/static/locales/`; still exposes `window.t` - **Vendor scripts removed** (Phase 3): chart.js, leaflet (+ CSS), and react-qr-code are bundled by Vite; only fonts remain vendored -- **DaisyUI + Tailwind v4** unchanged; `@source "../js/"` in input.css scans both spa/ and spa-react/ +- **DaisyUI + Tailwind v4** unchanged; `@source "../js/"` in input.css scans the spa-react/ source ## File Structure ``` vite.config.ts # Vite config (root=project, input=spa-react/index.html) -tsconfig.json # Strict TS, path aliases -build.js # Tailwind → vendor copy → vite build → assets.json -package.json # React 19, react-router 7, react-i18next, vite, typescript +tsconfig.json # Strict TS, path alias @/ → spa-react/ +build.js # Tailwind → vendor fonts copy → vite build → assets.json +package.json # React 19, react-router 7, react-i18next, react-chartjs-2, + # react-leaflet, react-qr-code, chart.js, leaflet, vite, typescript src/meshcore_hub/web/static/js/spa-react/ ├── index.html # Vite HTML entry (not served; Jinja2 is the real shell) ├── main.tsx # Bootstrap: initI18n → render App, AuthSection, MobileNav -├── App.tsx # BrowserRouter, all routes, feature flags, LitBridge wiring +├── App.tsx # BrowserRouter, all routes, feature flags (native React pages) ├── vite-env.d.ts -├── legacy.d.ts # TS declarations for @legacy/*.js modules -├── types/config.ts # AppConfig interface, window.__APP_CONFIG__ declaration +├── types/config.ts # AppConfig interface, window.__APP_CONFIG__ + window.t declarations ├── context/AppConfigContext.tsx # useAppConfig(), useFeatures(), hasRole(), channel labels ├── i18n/index.ts # initI18n() with i18next + language detector ├── hooks/ @@ -61,14 +61,15 @@ src/meshcore_hub/web/static/js/spa-react/ ├── utils/ │ ├── api.ts # Typed apiGet, apiPost, apiPut, apiDelete, apiPostForm │ ├── format.ts # parseAppDate, formatDateTime, formatRelativeTime, emojis +│ ├── charts.ts # Chart.js config builders, ChartColors, averageRouteTier (imports chart.js/auto) │ └── clipboard.ts # copyToClipboard with fallback ├── components/ │ ├── icons/index.tsx # 30+ SVG icon components (IconDashboard, IconNodes, etc.) +│ ├── charts/Charts.tsx # react-chartjs-2 wrappers (ActivityChart, TrendLineChart, StackedBarChart, RoutesTrendChart, RouteDetailStrip) │ ├── Alerts.tsx # Loading, ErrorAlert, InfoAlert, SuccessAlert, WarningBadge │ ├── AuthSection.tsx # Navbar auth dropdown (login button or user menu) │ ├── MobileNav.tsx # Mobile hamburger nav items │ ├── ErrorBoundary.tsx # React error boundary with fallback UI -│ ├── LitBridge.tsx # Wraps old lit-html page modules in React lifecycle │ ├── Pagination.tsx # URL-driven pagination (page param) │ ├── StatCard.tsx # Dashboard stat card with icon/color │ ├── NodeDisplay.tsx # Node emoji + name + description @@ -78,23 +79,16 @@ src/meshcore_hub/web/static/js/spa-react/ │ ├── ObserverBadges.tsx # Observer filter badges + localStorage helpers │ ├── RouteTypeBadge.tsx # Flood/Relay/Zero-hop badge │ └── JsonTree.tsx # Expandable JSON viewer -└── pages/ - ├── NotFound.tsx # ✅ Converted (native React) - └── Maintenance.tsx # ✅ Converted (native React) - -src/meshcore_hub/web/static/js/spa/ # OLD lit-html pages (still used via LitBridge) -├── app.js # Old entry (NO LONGER LOADED — replaced by spa-react/main.tsx) -├── router.js # Old router (replaced by react-router) -├── api.js # Old API client (replaced by utils/api.ts) -├── components.js # Old shared components (replaced by React components) -├── i18n.js # Old i18n (replaced by react-i18next) -├── icons.js # Old icons (replaced by components/icons/) -├── auto-refresh.js # Old auto-refresh (replaced by hooks/useAutoRefresh.ts) -├── json-tree.js # Old JSON tree (replaced by components/JsonTree.tsx) -└── pages/ # Old page modules (loaded via LitBridge until converted) - ├── home.js, dashboard.js, nodes.js, node-detail.js, ... +└── pages/ # All native React pages + ├── Home.tsx, Dashboard.tsx, Nodes.tsx, NodeDetail.tsx, Advertisements.tsx, + ├── Messages.tsx, Routes.tsx, Packets.tsx, PacketDetail.tsx, PacketGroupDetail.tsx, + ├── Channels.tsx, MapPage.tsx, Members.tsx, Profile.tsx, CustomPage.tsx, + └── NotFound.tsx, Maintenance.tsx ``` +> The old `src/meshcore_hub/web/static/js/spa/` lit-html tree, `LitBridge.tsx`, and +> `legacy.d.ts` were deleted in Phase 4. There is no fallback bundle — the Vite build is required. + ## Build Pipeline ```bash @@ -191,16 +185,19 @@ Python (`app.py`) loads this manifest at startup and passes `asset_app_js` / `as - Updated `tests/test_web/test_caching.py` (charts.js-specific tests removed; generic JS-cache tests point at `spa/app.js`). -## Phase 4: Cleanup +## Phase 4: Cleanup — Complete -- Remove `lit-html` from package.json -- Delete `LitBridge.tsx` -- Delete entire `src/meshcore_hub/web/static/js/spa/` directory -- Remove `@legacy` alias from vite.config.ts and tsconfig.json -- Delete `legacy.d.ts` -- Remove vendor script tags from `spa.html` (leaflet, chart.js, qrcodejs, charts.js) -- Remove `build.js` vendor copy for leaflet/chart.js/qrcodejs (now bundled by Vite) -- Update `AGENTS.md` with new frontend conventions +- Removed `lit-html` **and** `qrcodejs` from package.json (both unused after Phases 2–3). +- Deleted `LitBridge.tsx`, `legacy.d.ts`, and the entire `src/meshcore_hub/web/static/js/spa/` tree. +- Removed the `@legacy` alias from `vite.config.ts` and `tsconfig.json`. +- Removed the lit-html fallback `{% else %}` branch from `spa.html` — the Vite build is now + required (no fallback bundle). +- Updated the web tests that referenced the fallback: `test_home/advertisements/nodes/messages.py` + now assert the React mount point (`id="app"`); `test_caching.py` JS-cache tests are header-only + (static JS is bundled into `dist/`, absent in test env) and the dist-bundle test drops the + fallback branch. +- (Vendor script tags / `charts.js` / `build.js` vendor copy were already removed in Phase 3.) +- Updated `AGENTS.md` with the React frontend conventions. ## Phase 5: Optional Enhancements diff --git a/package-lock.json b/package-lock.json index 98652fa..7fe1b30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,6 @@ "i18next": "^25", "i18next-browser-languagedetector": "^8", "leaflet": "^1.9.4", - "lit-html": "^3", - "qrcodejs": "^1.0.0", "react": "^19", "react-chartjs-2": "^5", "react-dom": "^19", @@ -1547,12 +1545,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT" - }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2195,15 +2187,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lit-html": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz", - "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==", - "license": "BSD-3-Clause", - "dependencies": { - "@types/trusted-types": "^2.0.2" - } - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2385,11 +2368,6 @@ "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", "license": "MIT" }, - "node_modules/qrcodejs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/qrcodejs/-/qrcodejs-1.0.0.tgz", - "integrity": "sha512-67rj3mMBhSBepaD57qENnltO+r8rSYlqM7HGThks/BiyDAkc86sLvkKqjkqPS5v13f7tvnt6dbEf3qt7zq+BCg==" - }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", diff --git a/package.json b/package.json index 29182b6..d04bd44 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,6 @@ "i18next": "^25", "i18next-browser-languagedetector": "^8", "leaflet": "^1.9.4", - "lit-html": "^3", - "qrcodejs": "^1.0.0", "react": "^19", "react-chartjs-2": "^5", "react-dom": "^19", diff --git a/src/meshcore_hub/web/static/js/spa-react/components/LitBridge.tsx b/src/meshcore_hub/web/static/js/spa-react/components/LitBridge.tsx deleted file mode 100644 index 65caca3..0000000 --- a/src/meshcore_hub/web/static/js/spa-react/components/LitBridge.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { useEffect, useRef } from "react"; -import { useParams, useSearchParams, useNavigate } from "react-router"; - -interface LitBridgeProps { - loader: () => Promise<{ - render: ( - container: HTMLElement, - params: Record, - router: { navigate: (url: string, replace?: boolean) => void }, - ) => Promise<(() => void) | void>; - }>; -} - -export function LitBridge({ loader }: LitBridgeProps) { - const containerRef = useRef(null); - const params = useParams(); - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - const cleanupRef = useRef<(() => void) | null>(null); - const abortRef = useRef(null); - - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - - const query: Record = {}; - for (const [k, v] of searchParams.entries()) { - if (k in query) { - const existing = query[k]; - query[k] = Array.isArray(existing) ? [...existing, v] : [existing, v]; - } else { - query[k] = v; - } - } - - const routerAdapter = { - navigate(url: string, replace = false) { - navigate(url, { replace }); - }, - }; - - let cancelled = false; - - loader() - .then((module) => { - if (cancelled) return; - return module.render( - container, - { ...params, query, signal: controller.signal }, - routerAdapter, - ); - }) - .then((cleanup) => { - if (cancelled) { - if (typeof cleanup === "function") cleanup(); - return; - } - if (typeof cleanup === "function") { - cleanupRef.current = cleanup; - } - }) - .catch((e) => { - if (e?.name === "AbortError") return; - console.error("LitBridge page load error:", e); - }); - - return () => { - cancelled = true; - controller.abort(); - cleanupRef.current?.(); - cleanupRef.current = null; - }; - }, [loader, params, searchParams, navigate]); - - return
    ; -} diff --git a/src/meshcore_hub/web/static/js/spa-react/legacy.d.ts b/src/meshcore_hub/web/static/js/spa-react/legacy.d.ts deleted file mode 100644 index c3a7768..0000000 --- a/src/meshcore_hub/web/static/js/spa-react/legacy.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -declare module "@legacy/pages/*.js" { - export function render( - container: HTMLElement, - params: Record, - router: { navigate: (url: string, replace?: boolean) => void }, - ): Promise<(() => void) | void>; -} diff --git a/src/meshcore_hub/web/static/js/spa/api.js b/src/meshcore_hub/web/static/js/spa/api.js deleted file mode 100644 index f572a53..0000000 --- a/src/meshcore_hub/web/static/js/spa/api.js +++ /dev/null @@ -1,130 +0,0 @@ -/** - * MeshCore Hub SPA - API Client - * - * Wrapper around fetch() for making API calls to the proxied backend. - */ - -/** - * Returns true if the error is a fetch abort (e.g. the request was cancelled - * because the user navigated to another page). - * @param {*} e - * @returns {boolean} - */ -export function isAbortError(e) { - return !!e && e.name === 'AbortError'; -} - -/** - * Make a GET request and return parsed JSON. - * @param {string} path - URL path (e.g., '/api/v1/nodes') - * @param {Object} [params] - Query parameters - * @param {Object} [options] - Extra options - * @param {AbortSignal} [options.signal] - Signal to cancel the request (e.g. on navigation) - * @returns {Promise} Parsed JSON response - */ -export async function apiGet(path, params = {}, { signal } = {}) { - const url = new URL(path, window.location.origin); - for (const [k, v] of Object.entries(params)) { - if (v !== null && v !== undefined && v !== '') { - if (Array.isArray(v)) { - v.forEach(item => url.searchParams.append(k, String(item))); - } else { - url.searchParams.set(k, String(v)); - } - } - } - const response = await fetch(url, { signal }); - if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`); - } - return response.json(); -} - -/** - * Check response for auth errors and redirect to login if needed. - * @param {Response} response - */ -function checkAuthResponse(response) { - const config = window.__APP_CONFIG__ || {}; - if (config.oidc_enabled && response.status === 401) { - const next = encodeURIComponent(window.location.pathname + window.location.search); - window.location.href = `/auth/login?next=${next}`; - } -} - -/** - * Make a POST request with JSON body. - * @param {string} path - URL path - * @param {Object} body - Request body - * @returns {Promise} Parsed JSON response - */ -export async function apiPost(path, body) { - const response = await fetch(path, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - checkAuthResponse(response); - if (!response.ok) { - const text = await response.text(); - throw new Error(`API error: ${response.status} - ${text}`); - } - if (response.status === 204) return null; - return response.json(); -} - -/** - * Make a PUT request with JSON body. - * @param {string} path - URL path - * @param {Object} body - Request body - * @returns {Promise} Parsed JSON response - */ -export async function apiPut(path, body) { - const response = await fetch(path, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - checkAuthResponse(response); - if (!response.ok) { - const text = await response.text(); - throw new Error(`API error: ${response.status} - ${text}`); - } - if (response.status === 204) return null; - return response.json(); -} - -/** - * Make a DELETE request. - * @param {string} path - URL path - * @returns {Promise} - */ -export async function apiDelete(path) { - const response = await fetch(path, { method: 'DELETE' }); - checkAuthResponse(response); - if (!response.ok) { - const text = await response.text(); - throw new Error(`API error: ${response.status} - ${text}`); - } -} - -/** - * Make a POST request with form-encoded body. - * @param {string} path - URL path - * @param {Object} data - Form data as key-value pairs - * @returns {Promise} Parsed JSON response - */ -export async function apiPostForm(path, data) { - const body = new URLSearchParams(data); - const response = await fetch(path, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: body.toString(), - }); - if (!response.ok) { - const text = await response.text(); - throw new Error(`API error: ${response.status} - ${text}`); - } - if (response.status === 204) return null; - return response.json(); -} diff --git a/src/meshcore_hub/web/static/js/spa/app.js b/src/meshcore_hub/web/static/js/spa/app.js deleted file mode 100644 index d617122..0000000 --- a/src/meshcore_hub/web/static/js/spa/app.js +++ /dev/null @@ -1,277 +0,0 @@ -/** - * MeshCore Hub SPA - Main Application Entry Point - * - * Initializes i18n, the router, registers all page routes, - * and handles navigation. - */ - -import { Router } from './router.js'; -import { isAbortError } from './api.js'; -import { html, litRender, getConfig, hasRole, renderAuthSection } from './components.js'; -import { loadLocale, t } from './i18n.js'; -import { iconHome, iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconPackets, iconMap, iconMembers, iconPage, iconChannel, iconPath } from './icons.js'; - -// Page modules (lazy-loaded) -const pages = { - home: () => import('./pages/home.js'), - dashboard: () => import('./pages/dashboard.js'), - nodes: () => import('./pages/nodes.js'), - nodeDetail: () => import('./pages/node-detail.js'), - messages: () => import('./pages/messages.js'), - advertisements: () => import('./pages/advertisements.js'), - packets: () => import('./pages/packets.js'), - packetDetail: () => import('./pages/packet-detail.js'), - packetGroupDetail: () => import('./pages/packet-group-detail.js'), - map: () => import('./pages/map.js'), - members: () => import('./pages/members.js'), - channels: () => import('./pages/channels.js'), - routes: () => import('./pages/routes.js'), - customPage: () => import('./pages/custom-page.js'), - notFound: () => import('./pages/not-found.js'), - profile: () => import('./pages/profile.js'), - maintenance: () => import('./pages/maintenance.js'), -}; - -// Main app container -const appContainer = document.getElementById('app'); -const router = new Router(); - -// Read feature flags from config -const config = getConfig(); -const features = config.features || {}; - -/** - * Create a route handler that lazy-loads a page module and calls its render function. - * @param {Function} loader - Module loader function - * @returns {Function} Route handler - */ -function pageHandler(loader) { - return async (params) => { - try { - const module = await loader(); - return await module.render(appContainer, params, router); - } catch (e) { - // Navigating away cancels in-flight requests — not a real error. - if (isAbortError(e)) return; - console.error('Page load error:', e); - appContainer.innerHTML = ` -
    -

    ${t('common.error')}

    -

    ${t('common.failed_to_load_page')}

    -

    ${e.message || 'Unknown error'}

    - ${t('common.go_home')} -
    `; - } - }; -} - -// Maintenance mode: every route renders the maintenance page and no -// API-backed page module is ever loaded. -const maintenanceMode = config.system_maintenance === true; - -// Register routes (conditionally based on feature flags) -if (maintenanceMode) { - const maintenanceHandler = pageHandler(pages.maintenance); - router.addRoute('/', maintenanceHandler); - router.setNotFound(maintenanceHandler); -} else { -router.addRoute('/', pageHandler(pages.home)); - -if (features.dashboard !== false) { - router.addRoute('/dashboard', pageHandler(pages.dashboard)); -} -if (features.nodes !== false) { - router.addRoute('/nodes', pageHandler(pages.nodes)); - router.addRoute('/nodes/:publicKey', pageHandler(pages.nodeDetail)); - router.addRoute('/n/:prefix', async (params) => { - // Short link redirect - router.navigate(`/nodes/${params.prefix}`, true); - }); -} -if (features.channels !== false) { - router.addRoute('/channels', pageHandler(pages.channels)); -} -if (features.routes !== false) { - router.addRoute('/routes', pageHandler(pages.routes)); -} -if (features.messages !== false) { - router.addRoute('/messages', pageHandler(pages.messages)); -} -if (features.advertisements !== false) { - router.addRoute('/advertisements', pageHandler(pages.advertisements)); -} -if (features.packets !== false) { - router.addRoute('/packets', pageHandler(pages.packets)); - router.addRoute('/packets/hash/:hash', pageHandler(pages.packetGroupDetail)); - router.addRoute('/packets/:id', pageHandler(pages.packetDetail)); -} -if (features.map !== false) { - router.addRoute('/map', pageHandler(pages.map)); -} -if (features.members !== false) { - router.addRoute('/members', pageHandler(pages.members)); -} -if (features.pages !== false) { - router.addRoute('/pages/:slug', pageHandler(pages.customPage)); -} - -// Profile route (only register when OIDC enabled) -if (config.oidc_enabled) { - router.addRoute('/profile', pageHandler(pages.profile)); - router.addRoute('/profile/:id', pageHandler(pages.profile)); -} - -// 404 handler -router.setNotFound(pageHandler(pages.notFound)); -} - -/** - * Update the active state of navigation links. - * @param {string} pathname - Current URL path - */ -function updateNavActiveState(pathname) { - document.querySelectorAll('[data-nav-link]').forEach(link => { - const href = link.getAttribute('href'); - let isActive = false; - - if (href === '/') { - isActive = pathname === '/'; - } else if (href === '/nodes') { - isActive = pathname.startsWith('/nodes'); - } else { - isActive = pathname === href || pathname.startsWith(href + '/'); - } - - if (isActive) { - link.classList.add('active'); - } else { - link.classList.remove('active'); - } - }); - - // Close mobile dropdown if open (DaisyUI dropdowns stay open while focused) - if (document.activeElement?.closest('.dropdown')) { - document.activeElement.blur(); - } -} - -/** - * Compose a page title from entity name and network name. - * @param {string} entityKey - Translation key for entity (e.g., 'entities.dashboard') - * @returns {string} - */ -function composePageTitle(entityKey) { - const networkName = config.network_name || 'MeshCore Network'; - const entity = t(entityKey); - return `${entity} - ${networkName}`; -} - -/** - * Update the page title based on the current route. - * @param {string} pathname - */ -function updatePageTitle(pathname) { - const networkName = config.network_name || 'MeshCore Network'; - const titles = { - '/': networkName, - }; - - // Add feature-dependent titles - if (features.dashboard !== false) titles['/dashboard'] = composePageTitle('entities.dashboard'); - if (features.nodes !== false) titles['/nodes'] = composePageTitle('entities.nodes'); - if (features.channels !== false) titles['/channels'] = composePageTitle('entities.channels'); - if (features.routes !== false) titles['/routes'] = composePageTitle('entities.routes'); - if (features.messages !== false) titles['/messages'] = composePageTitle('entities.messages'); - if (features.advertisements !== false) titles['/advertisements'] = composePageTitle('entities.advertisements'); - if (features.packets !== false) titles['/packets'] = composePageTitle('entities.packets'); - if (features.map !== false) titles['/map'] = composePageTitle('entities.map'); - if (features.members !== false) titles['/members'] = composePageTitle('entities.members'); - titles['/profile'] = composePageTitle('links.profile'); - - if (titles[pathname]) { - document.title = titles[pathname]; - } else if (pathname.startsWith('/nodes/')) { - document.title = composePageTitle('entities.node_detail'); - } else if (pathname.startsWith('/pages/')) { - // Custom pages set their own title in the page module - document.title = networkName; - } else { - document.title = networkName; - } -} - -// Set up navigation callback -router.onNavigate((pathname) => { - updateNavActiveState(pathname); - updatePageTitle(pathname); -}); - -/** - * Render the mobile navigation dropdown. - * Populates the #mobile-nav container with nav items based on config features. - * @param {Object} config - App configuration object - */ -function renderMobileNav(config) { - const container = document.getElementById('mobile-nav'); - if (!container) return; - - const features = config.features || {}; - const customPages = config.custom_pages || []; - - const items = []; - - items.push(html`
  • ${iconHome('h-5 w-5')} ${t('entities.home')}
  • `); - - if (features.dashboard !== false) { - items.push(html`
  • ${iconDashboard('h-5 w-5 nav-icon-dashboard')} ${t('entities.dashboard')}
  • `); - } - if (features.nodes !== false) { - items.push(html`
  • ${iconNodes('h-5 w-5 nav-icon-nodes')} ${t('entities.nodes')}
  • `); - } - if (features.advertisements !== false) { - items.push(html`
  • ${iconAdvertisements('h-5 w-5 nav-icon-adverts')} ${t('entities.advertisements')}
  • `); - } - if (features.routes !== false) { - items.push(html`
  • ${iconPath('h-5 w-5 nav-icon-routes')} ${t('entities.routes')}
  • `); - } - if (features.channels !== false) { - items.push(html`
  • ${iconChannel('h-5 w-5 nav-icon-channels')} ${t('entities.channels')}
  • `); - } - if (features.messages !== false) { - items.push(html`
  • ${iconMessages('h-5 w-5 nav-icon-messages')} ${t('entities.messages')}
  • `); - } - if (features.packets !== false) { - items.push(html`
  • ${iconPackets('h-5 w-5 nav-icon-packets')} ${t('entities.packets')}
  • `); - } - if (features.map !== false) { - items.push(html`
  • ${iconMap('h-5 w-5 nav-icon-map')} ${t('entities.map')}
  • `); - } - if (features.members !== false) { - items.push(html`
  • ${iconMembers('h-5 w-5 nav-icon-members')} ${t('entities.members')}
  • `); - } - - if (features.pages !== false && customPages.length > 0) { - for (const page of customPages) { - items.push(html`
  • ${iconPage('h-5 w-5')} ${page.title}
  • `); - } - } - - litRender(html`${items}`, container); -} - -// Load locale then start the router -const locale = localStorage.getItem('meshcore-locale') || config.locale || 'en'; -await loadLocale(locale); - -// Legacy cleanup: remove the old per-observer localStorage key so stale public -// keys are never misread as area codes by the new area-based filter. -try { localStorage.removeItem('meshcore-observers-disabled'); } catch {} - -// Render auth section in navbar (after translations are loaded) -const authSection = document.getElementById('auth-section'); -renderAuthSection(authSection, config); - -// Render mobile nav (after translations are loaded) -renderMobileNav(config); - -router.start(); diff --git a/src/meshcore_hub/web/static/js/spa/auto-refresh.js b/src/meshcore_hub/web/static/js/spa/auto-refresh.js deleted file mode 100644 index d358a79..0000000 --- a/src/meshcore_hub/web/static/js/spa/auto-refresh.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Auto-refresh utility for list pages. - * - * Reads `auto_refresh_seconds` from the app config. When the interval is > 0 - * it sets up a periodic timer that calls the provided `fetchAndRender` callback - * and renders a pause/play toggle button into the given container element. - */ - -import { html, litRender, getConfig, t } from './components.js'; -import { iconRefresh } from './icons.js'; - -/** - * Create an auto-refresh controller. - * - * @param {Object} options - * @param {Function} options.fetchAndRender - Async function that fetches data and re-renders the page. - * @param {HTMLElement} options.toggleContainer - Element to render the pause/play toggle into. - * @returns {{ cleanup: Function }} cleanup function to stop the timer. - */ -export function createAutoRefresh({ fetchAndRender, toggleContainer }) { - const config = getConfig(); - const intervalSeconds = config.auto_refresh_seconds || 0; - - if (!intervalSeconds || !toggleContainer) { - return { cleanup() {} }; - } - - let paused = false; - let isPending = false; - let timerId = null; - - function renderToggle() { - const tooltip = paused ? t('auto_refresh.resume') : t('auto_refresh.pause'); - - litRender(html` - - `, toggleContainer); - } - - function onToggle(e) { - paused = !e.target.checked; - if (paused) { - clearInterval(timerId); - timerId = null; - } else { - startTimer(); - } - renderToggle(); - } - - async function tick() { - if (isPending || paused) return; - isPending = true; - try { - await fetchAndRender(); - } catch (_e) { - // Errors are handled inside fetchAndRender; don't stop the timer. - } finally { - isPending = false; - } - } - - function startTimer() { - timerId = setInterval(tick, intervalSeconds * 1000); - } - - // Initial render and start - renderToggle(); - startTimer(); - - return { - cleanup() { - if (timerId) { - clearInterval(timerId); - timerId = null; - } - }, - }; -} diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js deleted file mode 100644 index 1b82690..0000000 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ /dev/null @@ -1,838 +0,0 @@ -/** - * MeshCore Hub SPA - Shared UI Components - * - * Reusable rendering functions using lit-html. - * - * Styling conventions: - * - Page

    : `text-3xl font-bold`; header row wrapper: `mb-6`. - * - Content/detail cards: `card bg-base-100 shadow-xl`; stat cards and - * table wrappers: `shadow-sm`; mobile list cards: `shadow-sm`. - * - Muted text: `opacity-70` (primary), `opacity-60` (secondary), - * `opacity-50` (timestamps/tertiary) — not `text-base-content/N`. - * - Empty states: `text-center py-8 opacity-70`. - * - Panel-level surfaces: `rounded-box`; small inline chips: `rounded`. - * - Buttons: table-row icon actions `btn btn-xs btn-ghost`; card-level - * labeled actions `btn btn-xs btn-outline` (+ `btn-error` on delete); - * modal submits full-size `btn btn-primary`, inline/header actions `btn-sm`. - */ - -import { html, nothing } from 'lit-html'; -import { render } from 'lit-html'; -import { unsafeHTML } from 'lit-html/directives/unsafe-html.js'; -import { t } from './i18n.js'; -import { iconAlert, iconError, iconInfo, iconSuccess, iconUser, iconLogout, iconFilter } from './icons.js'; - -// Re-export lit-html utilities for page modules -export { html, nothing, unsafeHTML }; -export { render as litRender } from 'lit-html'; -export { t } from './i18n.js'; - -function buildSortUrl(basePath, params, nextSort, nextOrder) { - const sp = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - if (value !== null && value !== undefined && value !== '') { - if (Array.isArray(value)) { - value.forEach(item => sp.append(key, String(item))); - } else { - sp.set(key, String(value)); - } - } - } - if (nextSort && nextOrder) { - sp.set('sort', nextSort); - sp.set('order', nextOrder); - } - const qs = sp.toString(); - return qs ? `${basePath}?${qs}` : basePath; -} - -export function sortableTableHeader(label, { sortKey, currentSort, currentOrder, navigate, basePath, params }) { - let indicator = ''; - let nextOrder; - - if (currentSort !== sortKey) { - nextOrder = 'asc'; - } else if (currentOrder === 'asc') { - nextOrder = 'desc'; - indicator = ' \u25B4'; - } else { - nextOrder = 'asc'; - indicator = ' \u25BE'; - } - - const url = buildSortUrl(basePath, params, sortKey, nextOrder); - - return html` - { e.preventDefault(); e.stopPropagation(); navigate(url); }}> - ${label}${indicator} - - `; -} - -export function mobileSortSelect({ currentSort, currentOrder, navigate, basePath, params, options }) { - const currentValue = `${currentSort}:${currentOrder}`; - - const sortOptions = options.map(opt => - html`` - ); - - const onChange = (e) => { - const [sort, order] = e.target.value.split(':'); - const url = buildSortUrl(basePath, params, sort, order); - navigate(url); - }; - - return html`
    -
    - ${t('common.sort_by')} - -
    -
    `; -} - -/** - * Get app config from the embedded window object. - * @returns {Object} App configuration - */ -export function getConfig() { - return window.__APP_CONFIG__ || {}; -} - -/** - * Check if the current session has a specific role. - * Returns true when OIDC is disabled (open access). - * Translates symbolic role names (e.g. "admin") to actual IdP role names - * via the role_names config mapping. - * @param {string} roleName - Symbolic role to check - * @returns {boolean} - */ -export function hasRole(roleName) { - const config = getConfig(); - if (!config.oidc_enabled) return false; - const actualRole = (config.role_names || {})[roleName] || roleName; - return (config.roles || []).includes(actualRole); -} - -/** - * Build channel label map from app config. - * Keys are numeric channel indexes and values are non-empty labels. - * - * @param {Object} [config] - * @returns {Map} - */ -export function getChannelLabelsMap(config = getConfig()) { - return new Map( - Object.entries(config.channel_labels || {}) - .map(([idx, label]) => [parseInt(idx, 10), typeof label === 'string' ? label.trim() : '']) - .filter(([idx, label]) => Number.isInteger(idx) && label.length > 0), - ); -} - -/** - * Resolve a channel label from a numeric index. - * - * @param {number|string} channelIdx - * @param {Map} [channelLabels] - * @returns {string|null} - */ -export function resolveChannelLabel(channelIdx, channelLabels = getChannelLabelsMap()) { - const parsed = parseInt(String(channelIdx), 10); - if (!Number.isInteger(parsed)) return null; - return channelLabels.get(parsed) || null; -} - -/** - * Parse API datetime strings reliably. - * MeshCore API often returns UTC timestamps without an explicit timezone suffix. - * In that case, treat them as UTC by appending 'Z' before Date parsing. - * - * @param {string|null} isoString - * @returns {Date|null} - */ -export function parseAppDate(isoString) { - if (!isoString || typeof isoString !== 'string') return null; - - let value = isoString.trim(); - if (!value) return null; - - // Normalize "YYYY-MM-DD HH:MM:SS" to ISO separator. - if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}/.test(value)) { - value = value.replace(/\s+/, 'T'); - } - - // If no timezone suffix is present, treat as UTC. - const hasTimePart = /T\d{2}:\d{2}/.test(value); - const hasTimezoneSuffix = /(Z|[+-]\d{2}:\d{2}|[+-]\d{4})$/i.test(value); - if (hasTimePart && !hasTimezoneSuffix) { - value += 'Z'; - } - - const parsed = new Date(value); - if (isNaN(parsed.getTime())) return null; - return parsed; -} - -/** - * Page color palette - reads from CSS custom properties (defined in app.css :root). - * Use for inline styles or dynamic coloring in page modules. - */ -export const pageColors = { - get dashboard() { return getComputedStyle(document.documentElement).getPropertyValue('--color-dashboard').trim(); }, - get nodes() { return getComputedStyle(document.documentElement).getPropertyValue('--color-nodes').trim(); }, - get adverts() { return getComputedStyle(document.documentElement).getPropertyValue('--color-adverts').trim(); }, - get messages() { return getComputedStyle(document.documentElement).getPropertyValue('--color-messages').trim(); }, - get packets() { return getComputedStyle(document.documentElement).getPropertyValue('--color-packets').trim(); }, - get map() { return getComputedStyle(document.documentElement).getPropertyValue('--color-map').trim(); }, - get members() { return getComputedStyle(document.documentElement).getPropertyValue('--color-members').trim(); }, -}; - -// --- Formatting Helpers (return strings) --- - -/** - * Format a number with locale-appropriate grouping separators. - * Uses the visitor's browser locale (no explicit locale argument). - * @param {number|string|null|undefined} value - * @returns {string} Grouped number string, or '' for missing values - */ -export function formatNumber(value) { - if (value === null || value === undefined || value === '') return ''; - const n = Number(value); - if (!Number.isFinite(n)) return String(value); - return new Intl.NumberFormat().format(n); -} -window.formatNumber = formatNumber; - -/** - * Get the type emoji for a node advertisement type. - * @param {string|null} advType - * @returns {string} Emoji character - */ -function inferNodeType(value) { - const normalized = (value || '').toLowerCase(); - if (!normalized) return null; - if (normalized.includes('room')) return 'room'; - if (normalized.includes('repeater') || normalized.includes('relay')) return 'repeater'; - if (normalized.includes('companion') || normalized.includes('observer')) return 'companion'; - if (normalized.includes('chat')) return 'chat'; - return null; -} - -export function typeEmoji(advType) { - switch (inferNodeType(advType) || (advType || '').toLowerCase()) { - case 'chat': return '\u{1F4AC}'; // 💬 - case 'repeater': return '\u{1F4E1}'; // 📡 - case 'companion': return '\u{1F4F1}'; // 📱 - case 'room': return '\u{1FAA7}'; // 🪧 - default: return '\u{1F4CD}'; // 📍 - } -} - -/** - * Extract the first emoji from a string. - * Uses a regex pattern that matches emoji characters including compound emojis. - * @param {string|null} str - * @returns {string|null} First emoji found, or null if none - */ -export function extractFirstEmoji(str) { - if (!str) return null; - // Match emoji using Unicode ranges and zero-width joiners - const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{231A}-\u{231B}\u{23E9}-\u{23FA}\u{25AA}-\u{25AB}\u{25B6}\u{25C0}\u{25FB}-\u{25FE}\u{2B50}\u{2B55}\u{3030}\u{303D}\u{3297}\u{3299}](?:\u{FE0F})?(?:\u{200D}[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}](?:\u{FE0F})?)*|\u{00A9}|\u{00AE}|\u{203C}|\u{2049}|\u{2122}|\u{2139}|\u{2194}-\u{2199}|\u{21A9}-\u{21AA}|\u{24C2}|\u{2934}-\u{2935}|\u{2B05}-\u{2B07}|\u{2B1B}-\u{2B1C}/u; - const match = str.match(emojiRegex); - return match ? match[0] : null; -} - -/** - * Get the display emoji for a node. - * Prefers the first emoji from the node name, falls back to type emoji. - * @param {string|null} nodeName - Node's display name - * @param {string|null} advType - Advertisement type - * @returns {string} Emoji character to display - */ -export function getNodeEmoji(nodeName, advType) { - const nameEmoji = extractFirstEmoji(nodeName); - if (nameEmoji) return nameEmoji; - const inferred = inferNodeType(advType) || inferNodeType(nodeName); - return typeEmoji(inferred || advType); -} - -/** - * Format an ISO datetime string to the configured timezone. - * @param {string|null} isoString - * @param {Object} [options] - Intl.DateTimeFormat options override - * @returns {string} Formatted datetime string - */ -export function formatDateTime(isoString, options) { - if (!isoString) return '-'; - try { - const config = getConfig(); - const tz = config.timezone_iana || 'UTC'; - const locale = config.datetime_locale || 'en-US'; - const date = parseAppDate(isoString); - if (!date) return '-'; - const opts = options || { - timeZone: tz, - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', second: '2-digit', - hour12: false, - }; - if (!opts.timeZone) opts.timeZone = tz; - return date.toLocaleString(locale, opts); - } catch { - return isoString ? isoString.slice(0, 19).replace('T', ' ') : '-'; - } -} - -/** - * Format an ISO datetime string to short format (date + HH:MM). - * @param {string|null} isoString - * @returns {string} - */ -export function formatDateTimeShort(isoString) { - if (!isoString) return '-'; - try { - const config = getConfig(); - const tz = config.timezone_iana || 'UTC'; - const locale = config.datetime_locale || 'en-US'; - const date = parseAppDate(isoString); - if (!date) return '-'; - return date.toLocaleString(locale, { - timeZone: tz, - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', - hour12: false, - }); - } catch { - return isoString ? isoString.slice(0, 16).replace('T', ' ') : '-'; - } -} - -/** - * Format an ISO datetime as relative time (e.g., "2m ago", "1h ago"). - * @param {string|null} isoString - * @returns {string} - */ -export function formatRelativeTime(isoString) { - if (!isoString) return ''; - const date = parseAppDate(isoString); - if (!date) return ''; - const now = new Date(); - const diffMs = now - date; - const diffSec = Math.floor(diffMs / 1000); - const diffMin = Math.floor(diffSec / 60); - const diffHour = Math.floor(diffMin / 60); - const diffDay = Math.floor(diffHour / 24); - if (diffDay > 0) return t('time.days_ago', { count: diffDay }); - if (diffHour > 0) return t('time.hours_ago', { count: diffHour }); - if (diffMin > 0) return t('time.minutes_ago', { count: diffMin }); - return t('time.less_than_minute'); -} - -/** - * Truncate a public key for display. - * @param {string} key - Full public key - * @param {number} [length=12] - Characters to show - * @returns {string} Truncated key with ellipsis - */ -export function truncateKey(key, length = 12) { - if (!key) return '-'; - if (key.length <= length) return key; - return key.slice(0, length) + '...'; -} - -/** - * Escape HTML special characters. Rarely needed with lit-html - * since template interpolation auto-escapes, but kept for edge cases. - * @param {string} str - * @returns {string} - */ -export function escapeHtml(str) { - if (!str) return ''; - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; -} - -/** - * Copy text to clipboard with visual feedback. - * Updates the target element to show "Copied!" temporarily. - * Falls back to execCommand for browsers without Clipboard API. - * @param {Event} e - Click event - * @param {string} text - Text to copy to clipboard - */ -export function copyToClipboard(e, text) { - e.preventDefault(); - e.stopPropagation(); - - // Capture target element synchronously before async operations - const targetElement = e.currentTarget; - - const showSuccess = (target) => { - const originalText = target.textContent; - target.textContent = 'Copied!'; - target.classList.add('text-success'); - setTimeout(() => { - target.textContent = originalText; - target.classList.remove('text-success'); - }, 1500); - }; - - // Try modern Clipboard API first - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(text).then(() => { - showSuccess(targetElement); - }).catch(err => { - console.error('Clipboard API failed:', err); - fallbackCopy(text, targetElement); - }); - } else { - // Fallback for older browsers or non-secure contexts - fallbackCopy(text, targetElement); - } - - function fallbackCopy(text, target) { - const textArea = document.createElement('textarea'); - textArea.value = text; - textArea.style.position = 'fixed'; - textArea.style.left = '-999999px'; - textArea.style.top = '-999999px'; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - try { - document.execCommand('copy'); - showSuccess(target); - } catch (err) { - console.error('Fallback copy failed:', err); - } - document.body.removeChild(textArea); - } -} - -// --- UI Components (return lit-html TemplateResult) --- - -/** - * Render a node display with emoji, name, and optional description. - * Used for consistent node representation across lists (nodes, advertisements, messages, etc.). - * - * @param {Object} options - Node display options - * @param {string|null} options.name - Node display name (from tag or advertised name) - * @param {string|null} options.description - Node description from tags - * @param {string} options.publicKey - Node public key (for fallback display) - * @param {string|null} options.advType - Advertisement type (chat, repeater, room) - * @param {string} [options.size='base'] - Size variant: 'sm' (small lists) or 'base' (normal) - * @returns {TemplateResult} lit-html template - */ -export function renderNodeDisplay({ name, description, publicKey, advType, size = 'base' }) { - const displayName = name || null; - const emoji = getNodeEmoji(name, advType); - const emojiSize = 'text-lg'; - const nameSize = size === 'sm' ? 'text-sm' : 'text-base'; - const descSize = size === 'sm' ? 'text-xs' : 'text-xs'; - - const nameBlock = displayName - ? html`
    ${displayName}
    - ${description ? html`
    ${description}
    ` : nothing}` - : html`
    ${publicKey.slice(0, 16)}...
    `; - - return html` -
    - ${emoji} -
    - ${nameBlock} -
    -
    `; -} - -/** - * Render a loading spinner. - * @returns {TemplateResult} - */ -export function loading() { - return html`
    `; -} - -/** - * Render an error alert. - * @param {string} message - * @returns {TemplateResult} - */ -export function errorAlert(message) { - return html``; -} - -/** - * Render an info alert. Use unsafeHTML for HTML content. - * @param {string} message - Plain text message - * @returns {TemplateResult} - */ -export function infoAlert(message) { - return html``; -} - -/** - * Render a success alert. - * @param {string} message - * @returns {TemplateResult} - */ -export function successAlert(message) { - return html``; -} - -/** - * Render a warning badge with tooltip for transient API errors. - * @param {string} message - Error message to display as tooltip - * @returns {TemplateResult} - */ -export function warningBadge(message) { - return html` - ${iconAlert('h-4 w-4')} - `; -} - -/** - * Render pagination controls. - * @param {number} page - Current page (1-based) - * @param {number} totalPages - Total number of pages - * @param {string} basePath - Base URL path (e.g., '/nodes') - * @param {Object} [params={}] - Extra query parameters to preserve - * @returns {TemplateResult|nothing} - */ -export function pagination(page, totalPages, basePath, params = {}) { - if (totalPages <= 1) return nothing; - - const queryParts = []; - for (const [k, v] of Object.entries(params)) { - if (k === 'page' || v === null || v === undefined || v === '') continue; - if (Array.isArray(v)) { - v.forEach(item => queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(item)}`)); - } else { - queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`); - } - } - const extraQuery = queryParts.length > 0 ? '&' + queryParts.join('&') : ''; - - function pageUrl(p) { - return `${basePath}?page=${p}${extraQuery}`; - } - - const pageNumbers = []; - for (let p = 1; p <= totalPages; p++) { - if (p === page) { - pageNumbers.push(html``); - } else if (p === 1 || p === totalPages || (p >= page - 2 && p <= page + 2)) { - pageNumbers.push(html`${p}`); - } else if (p === 2 || p === totalPages - 1) { - pageNumbers.push(html``); - } - } - - return html`
    - ${page > 1 - ? html`${t('common.previous')}` - : html``} - ${pageNumbers} - ${page < totalPages - ? html`${t('common.next')}` - : html``} -
    `; -} - -/** - * Render a timezone indicator for page headers. - * @returns {TemplateResult|nothing} - */ -export function timezoneIndicator() { - const config = getConfig(); - const tz = config.timezone || 'UTC'; - return html`(${tz})`; -} - -/** - * Render an observer count badge with tooltip listing observer names. - * @param {Array} observers - Array of observer objects - * @returns {TemplateResult|nothing} - */ -export function observerIcons(observers) { - if (!observers || observers.length === 0) return nothing; - const names = observers.map(o => o.tag_name || o.name || truncateKey(o.public_key, 8)); - const tooltip = names.join(', '); - return html`${formatNumber(observers.length)}`; -} - -export function routeTypeBadge(routeType) { - if (!routeType) { - return nothing; - } - if (routeType === 'flood' || routeType === 'transport_flood') { - return html`${routeType === 'flood' ? 'Flood' : 'Relay'}`; - } - if (routeType === 'direct' || routeType === 'transport_direct') { - return html`${routeType === 'direct' ? 'Zero-hop' : 'Direct relay'}`; - } - return nothing; -} - -// --- Observer filter (localStorage-backed toggle badges) --- - -// Shared across the Adverts and Messages pages. We persist the *disabled* set -// of area codes so any newly-discovered area defaults to enabled automatically. -const OBSERVER_FILTER_KEY = 'meshcore-observer-areas-disabled'; - -/** - * Read the set of disabled (deselected) observer area codes from localStorage. - * @returns {Set} - */ -export function getDisabledObserverAreas() { - try { - const raw = localStorage.getItem(OBSERVER_FILTER_KEY); - if (!raw) return new Set(); - const arr = JSON.parse(raw); - return Array.isArray(arr) ? new Set(arr) : new Set(); - } catch { - return new Set(); - } -} - -/** - * Persist the set of disabled observer area codes to localStorage. - * @param {Set} disabled - */ -export function setDisabledObserverAreas(disabled) { - try { - localStorage.setItem(OBSERVER_FILTER_KEY, JSON.stringify([...disabled])); - } catch { - // Ignore quota/availability errors — filtering still works in-memory. - } -} - -/** - * Toggle an observer area's enabled state, enforcing that at least one area - * stays enabled. Returns the updated disabled set (persisted). - * @param {string} area - Observer area code to toggle - * @param {number} totalAreaCount - Total number of observer areas - * @returns {Set} - */ -export function toggleObserverArea(area, totalAreaCount) { - const disabled = getDisabledObserverAreas(); - if (disabled.has(area)) { - disabled.delete(area); - } else { - // Block disabling the last enabled area. - if (totalAreaCount - disabled.size <= 1) { - return disabled; - } - disabled.add(area); - } - setDisabledObserverAreas(disabled); - return disabled; -} - -/** - * Render a row of clickable observer filter badges, one per area code. - * @param {Array} options.areas - Observer area codes (already sorted) - * @param {Set} options.disabled - Currently disabled area codes - * @param {Function} options.onToggle - Called with an area code when a badge is clicked - * @param {string} [options.extraClass] - Wrapper classes; must set the display - * (e.g. 'hidden lg:flex' or 'flex lg:hidden') since the base omits it to avoid conflicts - * @returns {TemplateResult|nothing} - */ -export function observerFilterBadges({ areas, disabled, onToggle, extraClass = 'flex' }) { - if (!areas || areas.length === 0) return nothing; - return html`
    - ${t('common.filter_observer_label')}: - ${areas.map(area => { - const enabled = !disabled.has(area); - const cls = enabled ? 'badge badge-primary' : 'badge badge-ghost opacity-50'; - const title = enabled - ? t('common.filter_observer_disable') - : t('common.filter_observer_enable'); - const emoji = extractFirstEmoji(area); - const label = emoji ? (area.replace(emoji, '').trim() || area) : area; - return html``; - })} -
    `; -} - -// --- Form Helpers --- - -/** - * Create a submit handler for filter forms that uses SPA navigation. - * Use as: @submit=${createFilterHandler('/nodes', navigate)} - * @param {string} basePath - Base URL path for the page - * @param {Function} navigate - Router navigate function - * @returns {Function} Event handler - */ -export function createFilterHandler(basePath, navigate) { - return (e) => { - e.preventDefault(); - const formData = new FormData(e.target); - const params = new URLSearchParams(); - const keys = new Set(formData.keys()); - for (const k of keys) { - for (const v of formData.getAll(k)) { - if (v) params.append(k, v); - } - } - const queryStr = params.toString(); - navigate(queryStr ? `${basePath}?${queryStr}` : basePath); - }; -} - -/** - * Auto-submit handler for select/checkbox elements. - * Use as: @change=${autoSubmit} - * @param {Event} e - */ -export function autoSubmit(e) { - e.target.closest('form').requestSubmit(); -} - -/** - * Submit form on Enter key in text inputs. - * Use as: @keydown=${submitOnEnter} - * @param {KeyboardEvent} e - */ -export function submitOnEnter(e) { - if (e.key === 'Enter') { - e.preventDefault(); - e.target.closest('form').requestSubmit(); - } -} - -/** - * Render the auth section in the navbar. - * Shows a login button when not authenticated, or a user dropdown when logged in. - * @param {HTMLElement} container - The #auth-section element - * @param {Object} config - App configuration object - */ -export function renderAuthSection(container, config) { - if (!container) return; - if (!config.oidc_enabled) { - render(nothing, container); - return; - } - - const user = config.user; - if (!user) { - render(html` - ${t('auth.login')} - `, container); - return; - } - - const displayName = user.name || user.email || 'User'; - const initials = displayName.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase(); - const pictureHtml = user.picture - ? html`${displayName}` - : html`${initials}`; - - const roleBadges = (config.roles || []).map(r => { - const key = `auth.role_${r}`; - const label = t(key); - const name = label !== key ? label : r; - return html`${name}`; - }); - - const profileItem = html`
  • ${iconUser('h-5 w-5')} ${t('links.profile')}
  • `; - - const debugId = config.debug && user.sub - ? html`${user.sub}` - : nothing; - - render(html` - - `, container); -} - -/** - * Render a bare filter form (fields + submit/clear buttons). - * No surrounding card, border, or collapse wrapper — the caller controls visibility. - * @param {Array} options.fields - Array of render functions returning lit-html form controls - * @param {string} options.basePath - Base URL path for the page (e.g., '/nodes') - * @param {Function} options.navigate - Router navigate function - * @param {string} [options.submitLabel] - Text for submit button (default: translated "Filter") - * @param {string} [options.clearLabel] - Text for clear button (default: translated "Clear") - * @returns {TemplateResult} - */ -export function renderFilterForm({ fields, basePath, navigate, submitLabel, clearLabel }) { - return html` -
    -
    - ${fields.map(f => f())} -
    -
    - - ${clearLabel || t('common.clear')} -
    -
    `; -} - -/** - * Render the filter toggle control (DaisyUI slider switch + label). - * Placed at the right of the control row; the native checkbox holds open-state. - * @param {boolean} options.open - Whether the toggle is checked - * @param {Function} options.onChange - @change handler on the checkbox - * @returns {TemplateResult} - */ -export function renderFilterToggle({ open, onChange }) { - return html` - `; -} - -/** - * Render a single stat card for dashboard/home pages. - * @param {TemplateResult} options.icon - lit-html icon (from icons.js) - * @param {string} options.color - CSS color value for glow (e.g., pageColors.dashboard) - * @param {string} options.title - Stat title - * @param {string|number} options.value - Stat value - * @param {string} [options.description] - Optional description - * @returns {TemplateResult} - */ -export function renderStatCard({ icon, color, title, value, description }) { - return html` -
    -
    ${icon}
    -
    ${title}
    -
    ${formatNumber(value)}
    - ${description ? html`
    ${description}
    ` : nothing} -
    `; -} diff --git a/src/meshcore_hub/web/static/js/spa/i18n.js b/src/meshcore_hub/web/static/js/spa/i18n.js deleted file mode 100644 index b46a1e7..0000000 --- a/src/meshcore_hub/web/static/js/spa/i18n.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * MeshCore Hub SPA - Lightweight i18n Module - * - * Loads a JSON translation file and provides a t() lookup function. - * Shares the same locale JSON files with the Python/Jinja2 server side. - * - * Usage: - * import { t, loadLocale } from './i18n.js'; - * await loadLocale('en'); - * t('entities.home'); // "Home" - * t('common.total', { count: 42 }); // "42 total" - */ - -let _translations = {}; -let _locale = 'en'; - -/** - * Load a locale JSON file from the server. - * @param {string} locale - Language code (e.g. 'en') - */ -export async function loadLocale(locale) { - try { - const config = window.__APP_CONFIG__ || {}; - const v = config.locale_version || ''; - const res = await fetch(`/static/locales/${locale}.json${v ? '?v=' + v : ''}`); - if (res.ok) { - _translations = await res.json(); - _locale = locale; - } else { - console.warn(`Failed to load locale '${locale}', status ${res.status}`); - } - } catch (e) { - console.warn(`Failed to load locale '${locale}':`, e); - } -} - -/** - * Resolve a dot-separated key in the translations object. - * @param {string} key - * @returns {*} - */ -function resolve(key) { - return key.split('.').reduce( - (obj, k) => (obj && typeof obj === 'object' ? obj[k] : undefined), - _translations, - ); -} - -/** - * Translate a key with optional {{var}} interpolation. - * Falls back to the key itself if not found. - * @param {string} key - Dot-separated translation key - * @param {Object} [params={}] - Interpolation values - * @returns {string} - */ -export function t(key, params = {}) { - let val = resolve(key); - - if (typeof val !== 'string') return key; - - // Replace {{var}} placeholders - if (Object.keys(params).length > 0) { - val = val.replace(/\{\{(\w+)\}\}/g, (_, k) => (k in params ? String(params[k]) : '')); - } - - return val; -} - -/** - * Get the currently loaded locale code. - * @returns {string} - */ -export function getLocale() { - return _locale; -} - -// Also expose t() globally for non-module scripts (e.g. charts.js) -window.t = t; diff --git a/src/meshcore_hub/web/static/js/spa/icons.js b/src/meshcore_hub/web/static/js/spa/icons.js deleted file mode 100644 index 76cb3b0..0000000 --- a/src/meshcore_hub/web/static/js/spa/icons.js +++ /dev/null @@ -1,199 +0,0 @@ -/** - * MeshCore Hub SPA - SVG Icon Functions - * - * Each function returns a lit-html TemplateResult. Pass a CSS class string to customize size. - */ - -import { html } from 'lit-html'; - -export function iconDashboard(cls = 'h-5 w-5') { - return html``; -} - -export function iconMap(cls = 'h-5 w-5') { - return html``; -} - -export function iconNodes(cls = 'h-5 w-5') { - return html``; -} - -export function iconAdvertisements(cls = 'h-5 w-5') { - return html``; -} - -export function iconMessages(cls = 'h-5 w-5') { - return html``; -} - -export function iconPackets(cls = 'h-5 w-5') { - return html``; -} - -export function iconHome(cls = 'h-5 w-5') { - return html``; -} - -export function iconMembers(cls = 'h-5 w-5') { - return html``; -} - -export function iconPage(cls = 'h-5 w-5') { - return html``; -} - -export function iconInfo(cls = 'h-5 w-5') { - return html``; -} - -export function iconAlert(cls = 'h-5 w-5') { - return html``; -} - -export function iconChart(cls = 'h-5 w-5') { - return html``; -} - -export function iconRefresh(cls = 'h-5 w-5') { - return html``; -} - -export function iconMenu(cls = 'h-5 w-5') { - return html``; -} - -export function iconGithub(cls = 'h-5 w-5') { - return html``; -} - -export function iconExternalLink(cls = 'h-5 w-5') { - return html``; -} - -export function iconGlobe(cls = 'h-5 w-5') { - return html``; -} - -export function iconError(cls = 'h-5 w-5') { - return html``; -} - -export function iconChannel(cls = 'h-5 w-5') { - return html``; -} - -export function iconSuccess(cls = 'h-5 w-5') { - return html``; -} - -export function iconLock(cls = 'h-5 w-5') { - return html``; -} - -export function iconUser(cls = 'h-5 w-5') { - return html``; -} - -export function iconEmail(cls = 'h-5 w-5') { - return html``; -} - -export function iconTag(cls = 'h-5 w-5') { - return html``; -} - -export function iconUsers(cls = 'h-5 w-5') { - return html``; -} - -export function iconAntenna(cls = 'h-5 w-5') { - return html``; -} - -export function iconSatelliteDish(cls = 'h-5 w-5') { - return html``; -} - -export function iconPath(cls = 'h-5 w-5') { - return html``; -} - -export function iconSettings(cls = 'h-5 w-5') { - return html``; -} - -export function iconLogout(cls = 'h-5 w-5') { - return html``; -} - -export function iconPlus(cls = 'h-5 w-5') { - return html``; -} - -export function iconChevronRight(cls = 'h-5 w-5') { - return html``; -} - -export function iconEdit(cls = 'h-5 w-5') { - return html``; -} - -export function iconTrash(cls = 'h-5 w-5') { - return html``; -} - -export function iconPause(cls = 'w-4 h-4') { - return html``; -} - -export function iconPlay(cls = 'w-4 h-4') { - return html``; -} - -export function iconFrequency(cls = 'h-5 w-5') { - return html``; -} - -export function iconBandwidth(cls = 'h-5 w-5') { - return html``; -} - -export function iconSpreadingFactor(cls = 'h-5 w-5') { - return html``; -} - -export function iconCodingRate(cls = 'h-5 w-5') { - return html``; -} - -export function iconTxPower(cls = 'h-5 w-5') { - return html``; -} - -export function iconRuler(cls = 'h-5 w-5') { - return html``; -} - -export function iconClock(cls = 'h-5 w-5') { - return html``; -} - -export function iconFilter(cls = 'h-5 w-5') { - return html``; -} - -export function iconRouteFrom(cls = 'h-5 w-5') { - return html``; -} - -export function iconRouteTo(cls = 'h-5 w-5') { - return html``; -} - -export function iconHopSpan(cls = 'h-5 w-5') { - return html``; -} - -export function iconPathLength(cls = 'h-5 w-5') { - return html``; -} diff --git a/src/meshcore_hub/web/static/js/spa/json-tree.js b/src/meshcore_hub/web/static/js/spa/json-tree.js deleted file mode 100644 index 637154b..0000000 --- a/src/meshcore_hub/web/static/js/spa/json-tree.js +++ /dev/null @@ -1,111 +0,0 @@ -/** - * MeshCore Hub SPA - JSON Tree - * - * Renders an arbitrary JSON value as an expandable/collapsible tree. - * Imperative toggling (class flips): the host page renders once after load, - * so no re-render loop is required. - */ -import { html, nothing } from 'lit-html'; -import { t } from './components.js'; -import { iconChevronRight } from './icons.js'; - -function toggleNode(e) { - const btn = e.currentTarget; - const children = btn.nextElementSibling; - if (!children) return; - const nowHidden = children.classList.toggle('hidden'); - btn.querySelector('.json-caret').classList.toggle('rotate-90', !nowHidden); -} - -function expandAll(e) { - const root = e.currentTarget.closest('.json-tree-root'); - if (!root) return; - root.querySelectorAll('.json-children').forEach((el) => el.classList.remove('hidden')); - root.querySelectorAll('.json-caret').forEach((el) => el.classList.add('rotate-90')); -} - -function collapseAll(e) { - const root = e.currentTarget.closest('.json-tree-root'); - if (!root) return; - root.querySelectorAll('.json-children').forEach((el) => el.classList.add('hidden')); - root.querySelectorAll('.json-caret').forEach((el) => el.classList.remove('rotate-90')); -} - -function primitiveClass(val) { - if (val === null) return 'italic opacity-50'; - switch (typeof val) { - case 'string': return 'text-success'; - case 'number': return 'text-warning'; - case 'boolean': return 'text-info'; - default: return ''; - } -} - -function formatPrimitive(val) { - if (val === null) return 'null'; - if (typeof val === 'string') return `"${val}"`; - return String(val); -} - -function keyLabel(key) { - if (key == null) return nothing; - if (typeof key === 'number') { - return html`${key}:`; - } - return html`"${key}":`; -} - -function renderNode(value, key, depth, openDepth) { - const isContainer = value !== null && typeof value === 'object'; - - if (!isContainer) { - return html` -
    - ${keyLabel(key)} - ${formatPrimitive(value)} -
    `; - } - - const isArray = Array.isArray(value); - const entries = isArray - ? value.map((v, i) => [i, v]) - : Object.entries(value); - const open = isArray ? '[' : '{'; - const close = isArray ? ']' : '}'; - const hint = isArray ? `${entries.length}` : `${entries.length}`; - - if (entries.length === 0) { - return html` -
    - ${keyLabel(key)} - ${open}${close} -
    `; - } - - const isExpanded = depth < openDepth; - - return html` -
    - -
    - ${entries.map(([k, v]) => renderNode(v, k, depth + 1, openDepth))} -
    -
    `; -} - -export function jsonTree(value, { openDepth = 1 } = {}) { - return html` -
    -
    - - -
    -
    - ${renderNode(value, null, 0, openDepth)} -
    -
    `; -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js deleted file mode 100644 index c4f718a..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ /dev/null @@ -1,330 +0,0 @@ -import { apiGet, isAbortError } from '../api.js'; -import { - html, litRender, nothing, t, - getConfig, formatDateTime, formatDateTimeShort, formatNumber, - warningBadge, - pagination, sortableTableHeader, mobileSortSelect, - renderFilterForm, renderFilterToggle, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, - observerIcons, getDisabledObserverAreas, toggleObserverArea, observerFilterBadges, routeTypeBadge -} from '../components.js'; -import { createAutoRefresh } from '../auto-refresh.js'; - -export async function render(container, params, router) { - const { signal } = params || {}; - const query = params.query || {}; - const search = query.search || ''; - const adopted_by = query.adopted_by || ''; - const route_type = query.route_type || 'flood,transport_flood'; - const page = parseInt(query.page, 10) || 1; - const limit = parseInt(query.limit, 10) || 20; - const offset = (page - 1) * limit; - const sort = query.sort || 'time'; - const order = query.order || 'desc'; - - // Observer filter is sourced from localStorage (shared toggle badges), not the URL. - let disabledObserverAreas = getDisabledObserverAreas(); - - const config = getConfig(); - const features = config.features || {}; - const packetsEnabled = features.packets !== false; - const tz = config.timezone || ''; - const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing; - const navigate = (url) => router.navigate(url); - // For links nested inside a row/card whose own @click navigates elsewhere: - // suppress the row handler and drive SPA navigation explicitly (the router - // listens on document, so stopPropagation alone would force a full reload). - const stopAndNavigate = (url) => (e) => { - e.preventDefault(); - e.stopPropagation(); - navigate(url); - }; - // Packet-detail target for a row/card, or null when not navigable. - const packetDetailUrl = (packetHash) => - (packetsEnabled && packetHash) ? `/packets/hash/${packetHash}` : null; - - let lastContent = nothing; - let lastTotal = null; - let currentFilterFields = []; - const hasActiveFilters = search !== '' || (config.oidc_enabled && adopted_by !== '') || route_type !== 'flood,transport_flood'; - - function onFilterToggle() { renderPage(lastContent, { total: lastTotal }); } - - function renderPage(content, { total = null, error = null } = {}) { - if (!error) { - lastContent = content; - lastTotal = total; - } - const displayContent = error ? lastContent : content; - const displayTotal = error ? lastTotal : total; - const existingToggle = container.querySelector('#filter-toggle'); - const filterOpen = existingToggle ? existingToggle.checked : hasActiveFilters; - litRender(html` -
    -

    ${t('entities.advertisements')}

    - ${tzBadge} -
    -
    - ${displayTotal !== null - ? html`${t('common.total', { count: formatNumber(displayTotal) })}` - : nothing} - ${error ? warningBadge(error) : nothing} -
    - -
    -
    ${renderFilterToggle({ open: filterOpen, onChange: onFilterToggle })}
    -
    -${(filterOpen && currentFilterFields.length > 0) - ? html`
    ${renderFilterForm({ fields: currentFilterFields, basePath: '/advertisements', navigate })}
    ` - : nothing} -${displayContent}`, container); - } - - renderPage(nothing); - - async function fetchAndRenderData() { - try { - // Phase 1: fetch the observer node list (and operator profiles) first. - // The advertisements API filters observers by inclusion only, so we need - // the full observer list to translate the stored "disabled" set into an - // explicit include-list before fetching the data. - const metaFetches = [ - apiGet('/api/v1/nodes', { limit: 500, observer: true }, { signal }), - ]; - if (config.oidc_enabled) { - metaFetches.push(apiGet('/api/v1/user/profiles', { limit: 500 }, { signal })); - } - const metaResults = await Promise.all(metaFetches); - const nodesData = metaResults[0]; - const operatorRole = config.role_names?.operator || 'operator'; - const profiles = config.oidc_enabled - ? (metaResults[1]?.items || []).filter(p => p.roles && p.roles.includes(operatorRole)) - : []; - const allNodes = nodesData.items || []; - - const areaMap = new Map(); // area -> public_key[] - for (const n of allNodes) { - 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 sortedAreas = [...areaMap.keys()] - .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); - const enabledObserverKeys = sortedAreas - .filter(a => !disabledObserverAreas.has(a)) - .flatMap(a => areaMap.get(a)); - // Only constrain when some current area is actually hidden. - const observerFilterActive = sortedAreas.some(a => disabledObserverAreas.has(a)); - - const onObserverToggle = (area) => { - disabledObserverAreas = toggleObserverArea(area, sortedAreas.length); - if (page > 1) { - // Re-scoping the data invalidates the current page; reset to page 1. - const sp = new URLSearchParams(window.location.search); - sp.delete('page'); - const qs = sp.toString(); - navigate(qs ? `/advertisements?${qs}` : '/advertisements'); - } else { - fetchAndRenderData(); - } - }; - - // Phase 2: fetch the advertisements with the resolved observer filter. - const apiParams = { limit, offset, search, sort, order, route_type }; - if (observerFilterActive) apiParams.observed_by = enabledObserverKeys; - if (adopted_by) apiParams.adopted_by = adopted_by; - const data = await apiGet('/api/v1/advertisements', apiParams, { signal }); - - const advertisements = data.items || []; - const total = data.total || 0; - const totalPages = Math.ceil(total / limit); - - const observerBadges = (extraClass) => observerFilterBadges({ - areas: sortedAreas, disabled: disabledObserverAreas, onToggle: onObserverToggle, extraClass, - }); - - const mobileCards = advertisements.length === 0 - ? html`
    ${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}
    ` - : advertisements.map(ad => { - const adName = ad.node_tag_name || ad.node_name || ad.name; - const adDescription = ad.node_tag_description; - let receiversBlock = nothing; - if (ad.observers && ad.observers.length >= 1) { - receiversBlock = observerIcons(ad.observers); - } else if (ad.observed_by) { - receiversBlock = html`\u{1F4E1}`; - } - const detailUrl = packetDetailUrl(ad.packet_hash); - return html`
    navigate(detailUrl) : undefined}> -
    -
    - - ${renderNodeDisplay({ - name: adName, - description: adDescription, - publicKey: ad.public_key, - advType: ad.adv_type, - size: 'sm' - })} - -
    -
    ${formatDateTimeShort(ad.received_at)}
    -
    - ${routeTypeBadge(ad.route_type)} - ${receiversBlock} -
    -
    -
    -
    -
    `; - }); - - const tableRows = advertisements.length === 0 - ? html`${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}` - : advertisements.map(ad => { - const adName = ad.node_tag_name || ad.node_name || ad.name; - const adDescription = ad.node_tag_description; - let receiversBlock; - if (ad.observers && ad.observers.length >= 1) { - receiversBlock = html`${observerIcons(ad.observers)}`; - } else if (ad.observed_by) { - receiversBlock = html`\u{1F4E1}`; - } else { - receiversBlock = html`-`; - } - const detailUrl = packetDetailUrl(ad.packet_hash); - return html` navigate(detailUrl) : undefined}> - - - ${renderNodeDisplay({ - name: adName, - description: adDescription, - publicKey: ad.public_key, - advType: ad.adv_type, - size: 'base' - })} - - - - copyToClipboard(e, ad.public_key)} - title="Click to copy">${ad.public_key} - - ${routeTypeBadge(ad.route_type)} - ${formatDateTime(ad.received_at)} - ${receiversBlock} - `; - }); - - const paginationBlock = pagination(page, totalPages, '/advertisements', { - search, adopted_by, route_type, limit, sort, order, - }); - - const filterFields = [ - () => html` -
    - - -
    `, - () => html` -
    - - -
    `, - ]; - if (config.oidc_enabled && profiles.length > 0) { - filterFields.push(() => html` -
    - - -
    `); - } - const headerParams = { search, adopted_by, route_type, limit }; - const sortable = (label, sortKey) => sortableTableHeader(label, { - sortKey, currentSort: sort, currentOrder: order, - navigate, basePath: '/advertisements', params: headerParams, - }); - - currentFilterFields = filterFields; - - renderPage(html` - -${observerBadges('hidden lg:flex mb-4')} - -${mobileSortSelect({ - currentSort: sort, currentOrder: order, - navigate, 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') }, - ], -})} - -${observerBadges('flex lg:hidden mb-4')} - -
    - ${mobileCards} -
    - - - -${paginationBlock}`, { total }); - - } catch (e) { - if (isAbortError(e)) return; - renderPage(nothing, { error: e.message }); - } - } - - await fetchAndRenderData(); - - const toggleEl = container.querySelector('#auto-refresh-toggle'); - const { cleanup } = createAutoRefresh({ - fetchAndRender: fetchAndRenderData, - toggleContainer: toggleEl, - }); - return cleanup; -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/channels.js b/src/meshcore_hub/web/static/js/spa/pages/channels.js deleted file mode 100644 index d1ec654..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/channels.js +++ /dev/null @@ -1,300 +0,0 @@ -import { apiGet, apiPost, apiPut, apiDelete, isAbortError } from '../api.js'; -import { html, litRender, nothing, t, errorAlert, getConfig, hasRole } from '../components.js'; -import { iconChannel, iconPlus, iconEdit, iconTrash } from '../icons.js'; - -const VISIBILITY_ORDER = ['community', 'member', 'operator', 'admin']; - -function renderVisibilityBadge(visibility, oidcEnabled) { - if (!oidcEnabled) return nothing; - return html`${visibility}`; -} - -function renderChannelCard(channel, { oidcEnabled, isAdmin, onDelete, onEdit, onNavigate }) { - const visibilityBadge = renderVisibilityBadge(channel.visibility, oidcEnabled); - const enabledBadge = !channel.enabled - ? html`${t('channels.disabled')}` - : nothing; - - const channelIdx = parseInt(channel.channel_hash, 16); - const qrId = `qr-${channel.id}`; - - const adminButtons = isAdmin - ? html`
    - - -
    ` - : nothing; - - const keyDisplay = channel.key_hex - ? html`
    ${channel.key_hex.toLowerCase()}
    ` - : nothing; - - const qrPlaceholder = channel.key_hex - ? html`
    ` - : nothing; - - return html`
    onNavigate(channelIdx)} - @keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onNavigate(channelIdx); } }}> -
    -
    -

    - ${channel.name} - ${visibilityBadge} - ${enabledBadge} -

    - ${keyDisplay} - ${adminButtons} -
    -
    - ${qrPlaceholder} -
    -
    -
    `; -} - -function renderAddButton(onAdd) { - return html``; -} - -function renderChannelModal({ channel, isEdit, onSave, onCancel, saving }) { - const title = isEdit ? t('channels.edit_channel') : t('channels.add_channel'); - - return html` - - - `; -} - -function renderDeleteModal({ channel, onConfirm, onCancel, saving }) { - return html` - - - `; -} - -export async function render(container, params, router) { - const { signal } = params || {}; - try { - const config = getConfig(); - const oidcEnabled = config.oidc_enabled; - const isAdmin = hasRole('admin'); - - const data = await apiGet('/api/v1/channels', {}, { signal }); - const channels = data.items || []; - - let modalState = null; - - async function refresh() { - const newData = await apiGet('/api/v1/channels'); - renderPage(newData.items || []); - } - - function renderPage(channelsList) { - const adminHeader = isAdmin - ? html`
    ${renderAddButton(handleAdd)}
    ` - : nothing; - - const emptyMessage = channelsList.length === 0 - ? html`
    - ${t('common.no_entity_found', { entity: t('entities.channels').toLowerCase() })} -
    ` - : nothing; - - const groups = new Map(); - for (const vis of VISIBILITY_ORDER) { - groups.set(vis, []); - } - for (const ch of channelsList) { - const vis = ch.visibility || 'community'; - if (!groups.has(vis)) groups.set(vis, []); - groups.get(vis).push(ch); - } - - const cardOpts = { - oidcEnabled, - isAdmin, - onDelete: handleDeleteClick, - onEdit: handleEditClick, - onNavigate: (idx) => router.navigate(`/messages?channel_idx=${idx}`), - }; - - const groupedSections = []; - for (const vis of VISIBILITY_ORDER) { - const group = groups.get(vis); - if (!group || group.length === 0) continue; - groupedSections.push(html` -

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

    -
    - ${group.map(ch => renderChannelCard(ch, cardOpts))} -
    - `); - } - - let modalHtml = nothing; - if (modalState?.type === 'add' || modalState?.type === 'edit') { - modalHtml = renderChannelModal({ - channel: modalState.channel, - isEdit: modalState.type === 'edit', - onSave: handleSave, - onCancel: () => { modalState = null; renderPage(channelsList); }, - saving: !!modalState.saving, - }); - } else if (modalState?.type === 'delete') { - modalHtml = renderDeleteModal({ - channel: modalState.channel, - onConfirm: handleDeleteConfirm, - onCancel: () => { modalState = null; renderPage(channelsList); }, - saving: !!modalState.saving, - }); - } - - litRender(html` -
    -

    - ${iconChannel('h-8 w-8')} - ${t('channels.title')} -

    -
    - ${adminHeader} - ${emptyMessage} - ${groupedSections} - ${modalHtml} - `, container); - - channelsList.forEach(ch => { - const qrEl = document.getElementById(`qr-${ch.id}`); - if (qrEl && !qrEl.hasChildNodes() && ch.key_hex) { - const qrUrl = `meshcore://channel/add?name=${encodeURIComponent(ch.name)}&secret=${ch.key_hex.toLowerCase()}`; - new QRCode(qrEl, { - text: qrUrl, - width: 128, - height: 128, - correctLevel: QRCode.CorrectLevel.M, - }); - } - }); - } - - function handleAdd() { - modalState = { type: 'add', channel: { visibility: 'community', enabled: true } }; - renderPage(channels); - } - - function handleEditClick(channel) { - modalState = { type: 'edit', channel }; - renderPage(channels); - } - - function handleDeleteClick(channel) { - modalState = { type: 'delete', channel }; - renderPage(channels); - } - - async function handleSave() { - const nameEl = document.getElementById('channel-modal-name'); - const keyEl = document.getElementById('channel-modal-key'); - const visEl = document.getElementById('channel-modal-visibility'); - const enabledEl = document.getElementById('channel-modal-enabled'); - - const isEdit = modalState.type === 'edit'; - const body = { - visibility: visEl.value, - enabled: enabledEl.checked, - }; - - if (!isEdit) { - body.name = nameEl.value.trim(); - body.key_hex = keyEl.value.trim().toUpperCase(); - } else { - if (keyEl && keyEl.value) { - body.key_hex = keyEl.value.trim().toUpperCase(); - } - } - - modalState = { ...modalState, saving: true }; - renderPage(channels); - try { - if (isEdit) { - await apiPut(`/api/v1/channels/${modalState.channel.id}`, body); - } else { - await apiPost('/api/v1/channels', body); - } - modalState = null; - await refresh(); - } catch (e) { - modalState = { ...modalState, saving: false }; - renderPage(channels); - alert(e.message || 'Failed to save channel'); - } - } - - async function handleDeleteConfirm() { - modalState = { ...modalState, saving: true }; - renderPage(channels); - try { - await apiDelete(`/api/v1/channels/${modalState.channel.id}`); - modalState = null; - await refresh(); - } catch (e) { - modalState = { ...modalState, saving: false }; - renderPage(channels); - alert(e.message || 'Failed to delete channel'); - } - } - - renderPage(channels); - - } catch (e) { - if (isAbortError(e)) return; - litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); - } -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/custom-page.js b/src/meshcore_hub/web/static/js/spa/pages/custom-page.js deleted file mode 100644 index 30ab615..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/custom-page.js +++ /dev/null @@ -1,30 +0,0 @@ -import { apiGet, isAbortError } from '../api.js'; -import { html, litRender, unsafeHTML, getConfig, errorAlert, t } from '../components.js'; - -export async function render(container, params, router) { - const { signal } = params || {}; - try { - const page = await apiGet('/spa/pages/' + encodeURIComponent(params.slug), {}, { signal }); - - const config = getConfig(); - const networkName = config.network_name || 'MeshCore Network'; - document.title = `${page.title} - ${networkName}`; - - litRender(html` -
    -
    -
    - ${unsafeHTML(page.content_html)} -
    -
    -
    `, container); - - } catch (e) { - if (isAbortError(e)) return; - if (e.message && e.message.includes('404')) { - litRender(errorAlert(t('common.page_not_found')), container); - } else { - litRender(errorAlert(e.message || t('custom_page.failed_to_load')), container); - } - } -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js deleted file mode 100644 index 11da706..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js +++ /dev/null @@ -1,435 +0,0 @@ -import { apiGet, isAbortError } from '../api.js'; -import { - html, litRender, nothing, - getConfig, getChannelLabelsMap, resolveChannelLabel, - observerIcons, routeTypeBadge, errorAlert, t, formatDateTime, formatNumber, -} from '../components.js'; -import { - iconNodes, iconAdvertisements, iconMessages, iconPackets, iconChannel, -} from '../icons.js'; - -function channelLabel(channel, channelLabels) { - const idx = parseInt(String(channel), 10); - if (Number.isInteger(idx)) { - return resolveChannelLabel(idx, channelLabels) || `Ch ${idx}`; - } - return String(channel); -} - -function formatTimeOnly(isoString) { - return formatDateTime(isoString, { - hour: '2-digit', minute: '2-digit', second: '2-digit', - hour12: false, - }); -} - -function formatTimeShort(isoString) { - return formatDateTime(isoString, { - month: 'short', day: 'numeric', - hour: '2-digit', minute: '2-digit', - hour12: false, - }); -} - -function renderRecentAds(ads) { - if (!ads || ads.length === 0) { - return html`

    ${t('common.no_entity_yet', { entity: t('entities.advertisements').toLowerCase() })}

    `; - } - const rows = ads.map(ad => { - const friendlyName = ad.tag_name || ad.name; - const displayName = friendlyName || (ad.public_key.slice(0, 12) + '...'); - const keyLine = friendlyName - ? html`
    ${ad.public_key.slice(0, 12)}...
    ` - : nothing; - let observersBlock; - if (ad.observers && ad.observers.length >= 1) { - observersBlock = html`${observerIcons(ad.observers)}`; - } else if (ad.observed_by) { - observersBlock = html`\u{1F4E1}`; - } else { - observersBlock = html`-`; - } - return html` - - -
    ${displayName}
    -
    - ${keyLine} - - ${routeTypeBadge(ad.route_type)} - ${formatTimeOnly(ad.received_at)} - ${observersBlock} - `; - }); - - return html`
    - - - - - - - - - - ${rows} -
    ${t('entities.node')}${t('common.received')}${t('common.observers')}
    -
    `; -} - -function renderChannelMessages(channelMessages, channelLabels) { - if (!channelMessages || Object.keys(channelMessages).length === 0) return nothing; - - const channels = Object.entries(channelMessages).map(([channel, messages]) => { - const label = channelLabel(channel, channelLabels); - const msgLines = messages.map(msg => html` -
    - ${formatTimeShort(msg.received_at)} - ${msg.text || ''} -
    `); - - return html`
    -

    - ${label} -

    -
    - ${msgLines} -
    -
    `; - }); - - return html`
    -
    -

    - ${iconChannel('h-6 w-6')} - ${t('dashboard.recent_channel_messages')} -

    -
    - ${channels} -
    -
    -
    `; -} - -/** Return responsive Tailwind grid-cols classes for the given visible column count. */ -function gridCols(count) { - 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 renderRoutesHealth(routes) { - if (!routes || routes.length === 0) { - return html`

    ${t('dashboard.routes_empty')}

    `; - } - // Top 6 by current matched_count, mirroring the trend chart cap so the - // two widgets surface the same routes. - const sorted = routes.slice().sort((a, b) => (b.matched_count || 0) - (a.matched_count || 0)); - const maxRows = 6; - const visible = sorted.slice(0, maxRows); - const hidden = sorted.length - visible.length; - - const colorFor = (q) => { - // Mirrors ChartColors.quality in charts.js but reads CSS vars so the - // strips stay in sync with the legend. - const map = { - 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)', - }; - return map[q] || map.no_coverage; - }; - const labelFor = (q) => t('routes.quality_' + (q || 'unknown')); - - const rows = visible.map(r => { - const cells = (r.history || []).map(d => html` -
    `); - // Right-most dot = rolling 7-day average tier (same computation as - // the chart line color and the route-card badge on /routes). Falls - // back to the snapshot if history is missing (e.g. backend degraded). - const hist = r.history || []; - const avgTier = (window.averageRouteTier && hist.length > 0) - ? window.averageRouteTier(hist) - : null; - const current = avgTier - || (r.enabled ? (r.quality || 'no_coverage') : 'disabled'); - return html`
    - - ${r.from_label} \u2192 ${r.to_label} - -
    ${cells}
    - -
    `; - }); - - return html`
    - ${rows} - ${hidden > 0 ? html`

    ${t('dashboard.routes_more', { count: hidden })}

    ` : nothing} -
    `; -} - -function renderChartCards({ showNodes, showAdverts, showMessages, showPackets, showRoutes, stats, packetBreakdown, routesOverview }) { - const visibleCount = (showNodes ? 1 : 0) + (showAdverts ? 1 : 0) + (showMessages ? 1 : 0) + (showPackets ? 1 : 0); - if (visibleCount === 0) return nothing; - - const eventTypeTotal = packetBreakdown?.by_event_type?.reduce((s, b) => s + b.count, 0) ?? 0; - const pathWidthTotal = packetBreakdown?.by_path_width?.reduce((s, b) => s + b.count, 0) ?? 0; - const hasRoutes = !!(routesOverview && routesOverview.routes && routesOverview.routes.length); - - return html` -
    - ${showNodes ? html` -
    -
    -
    -
    -

    - ${iconNodes('h-5 w-5')} - ${t('entities.nodes')} -

    -

    ${t('time.over_time_last_7_days')}

    -
    -
    - ${formatNumber(stats.total_nodes)} -
    -
    -
    - -
    -
    -
    ` : nothing} - - ${showAdverts ? html` -
    -
    -
    -
    -

    - ${iconAdvertisements('h-5 w-5')} - ${t('entities.advertisements')} -

    -

    ${t('time.per_day_last_7_days')}

    -
    -
    - ${formatNumber(stats.advertisements_7d)} -
    -
    -
    - -
    -
    -
    ` : nothing} - - ${showMessages ? html` -
    -
    -
    -
    -

    - ${iconMessages('h-5 w-5')} - ${t('entities.messages')} -

    -

    ${t('time.per_day_last_7_days')}

    -
    -
    - ${formatNumber(stats.messages_7d)} -
    -
    -
    - -
    -
    -
    ` : nothing} - - ${showPackets ? html` -
    -
    -
    -
    -

    - ${iconPackets('h-5 w-5')} - ${t('entities.packets')} -

    -

    ${t('time.per_day_last_7_days')}

    -
    -
    - ${formatNumber(stats.packets_7d)} -
    -
    -
    - -
    -
    -
    ` : nothing} -
    - -${(showPackets || (showRoutes && hasRoutes)) ? html` -
    - ${showPackets ? html` -
    -
    -
    -
    -

    - ${iconPackets('h-5 w-5')} - ${t('entities.packet_event_types')} -

    -

    ${t('time.last_7_days')}

    -
    -
    - ${formatNumber(eventTypeTotal)} -
    -
    -
    - -
    -
    -
    ` : nothing} - - ${showPackets ? html` -
    -
    -
    -
    -

    - ${iconPackets('h-5 w-5')} - ${t('entities.path_hash_width')} -

    -

    ${t('time.last_7_days')}

    -
    -
    - ${formatNumber(pathWidthTotal)} -
    -
    -
    - -
    -
    -
    ` : nothing} - - ${(showRoutes && hasRoutes) ? html` -
    -
    -
    -
    -

    - ${t('dashboard.route_health')} -

    -

    ${t('time.last_7_days')}

    -
    -
    - ${renderRoutesHealth(routesOverview.routes)} -
    -
    ` : nothing} - - ${(showRoutes && hasRoutes) ? html` -
    -
    -
    -
    -

    - ${t('dashboard.routes_trend')} -

    -

    ${t('time.routes_over_last_n_days', { n: routesOverview.days })}

    -
    -
    -
    - -
    -
    -
    ` : nothing} -
    ` : nothing}`; -} - -export async function render(container, params, router) { - const { signal } = params || {}; - try { - const config = getConfig(); - let channelLabels = new Map(); - 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 [stats, recentActivity, advertActivity, messageActivity, nodeCount, packetActivity, packetBreakdown, routesOverview, channelsData] = await Promise.all([ - apiGet('/api/v1/dashboard/stats', {}, { signal }), - apiGet('/api/v1/dashboard/recent-activity', {}, { signal }), - apiGet('/api/v1/dashboard/activity', { days: 7 }, { signal }), - apiGet('/api/v1/dashboard/message-activity', { days: 7 }, { signal }), - apiGet('/api/v1/dashboard/node-count', { days: 7 }, { signal }), - apiGet('/api/v1/dashboard/packet-activity', { days: 7 }, { signal }), - apiGet('/api/v1/dashboard/packet-breakdown', { days: 7 }, { signal }), - showRoutes ? apiGet('/api/v1/dashboard/routes-overview', { days: 7 }, { signal }) : Promise.resolve(null), - apiGet('/api/v1/channels', {}, { signal }), - ]); - channelLabels = new Map([ - ...getChannelLabelsMap(config), - ...(channelsData.items || []) - .map(ch => [parseInt(ch.channel_hash, 16), ch.name]) - .filter(([idx]) => Number.isInteger(idx)), - ]); - - // Bottom section: recent adverts + recent channel messages - const bottomCount = (showAdverts ? 1 : 0) + (showMessages ? 1 : 0); - const bottomGrid = gridCols(bottomCount); - - litRender(html` -
    -

    ${t('entities.dashboard')}

    -
    - -${(showNodes || showAdverts || showMessages || showPackets) ? html` -${renderChartCards({ showNodes, showAdverts, showMessages, showPackets, showRoutes, stats, packetBreakdown, routesOverview })}` : nothing} - -${bottomCount > 0 ? html` -
    - ${showAdverts ? html` -
    -
    -

    - ${iconAdvertisements('h-6 w-6')} - ${t('common.recent_entity', { entity: t('entities.advertisements') })} -

    - ${renderRecentAds(recentActivity.recent_advertisements)} -
    -
    ` : nothing} - - ${showMessages ? renderChannelMessages(recentActivity.channel_messages, channelLabels) : nothing} -
    ` : nothing}`, container); - - window.initDashboardCharts( - showNodes ? nodeCount : null, - showAdverts ? advertActivity : null, - showMessages ? messageActivity : null, - showPackets ? packetActivity : null, - showPackets ? packetBreakdown.by_event_type : null, - showPackets ? packetBreakdown.by_path_width : null, - (showRoutes && routesOverview && routesOverview.routes) ? routesOverview.routes : null, - ); - - const chartIds = ['nodeChart', 'advertChart', 'messageChart', 'packetChart', 'packetEventTypeChart', 'packetPathWidthChart', 'routesTrendChart']; - return () => { - chartIds.forEach(id => { - const canvas = document.getElementById(id); - if (canvas) { - const instance = window.Chart.getChart(canvas); - if (instance) instance.destroy(); - } - }); - }; - - } catch (e) { - if (isAbortError(e)) return; - litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); - } -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/home.js b/src/meshcore_hub/web/static/js/spa/pages/home.js deleted file mode 100644 index 17cdfc1..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/home.js +++ /dev/null @@ -1,305 +0,0 @@ -import { apiGet, isAbortError } from '../api.js'; -import { - html, litRender, nothing, - getConfig, errorAlert, pageColors, renderStatCard, t, -} from '../components.js'; -import { - iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconPackets, iconMembers, iconMap, - iconPage, iconInfo, iconChart, iconAntenna, iconUsers, iconChannel, iconPath, - iconSettings, iconFrequency, iconBandwidth, iconSpreadingFactor, iconCodingRate, iconTxPower, -} from '../icons.js'; - -function renderRadioTiles(rc) { - if (!rc) return nothing; - 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 }, - ]; - const visible = tiles.filter(t => t.value); - if (visible.length === 0) return nothing; - return html` -
    - ${visible.map(({ icon, label, value }) => html` -
    - ${icon('w-full h-full')} - ${label} - ${String(value)} -
    `)} -
    `; -} - -function renderNavCard({ href, icon, label, colorVar }) { - return html` - - - ${icon} - - - ${label} - - `; -} - -function renderHeroSection({ networkName, logoUrl, logoInvertLight, networkCity, networkCountry, networkWelcomeText, features, customPages }) { - const cityCountry = (networkCity && networkCountry) - ? html`

    ${networkCity}, ${networkCountry}

    ` - : nothing; - - const welcomeText = networkWelcomeText - ? html`

    ${networkWelcomeText}

    ` - : html`

    - ${t('home.welcome_default', { network_name: networkName })} -

    `; - - return html` -
    -
    - -
    -

    ${networkName}

    - ${cityCountry} -
    -
    -
    - ${welcomeText} -
    -
    - ${features.dashboard !== false ? renderNavCard({ - href: '/dashboard', - icon: iconDashboard('w-full h-full'), - label: t('entities.dashboard'), - colorVar: '--color-dashboard', - }) : nothing} - ${features.nodes !== false ? renderNavCard({ - href: '/nodes', - icon: iconNodes('w-full h-full'), - label: t('entities.nodes'), - colorVar: '--color-nodes', - }) : nothing} - ${features.advertisements !== false ? renderNavCard({ - href: '/advertisements', - icon: iconAdvertisements('w-full h-full'), - label: t('entities.advertisements'), - colorVar: '--color-adverts', - }) : nothing} - ${features.routes !== false ? renderNavCard({ - href: '/routes', - icon: iconPath('w-full h-full'), - label: t('entities.routes'), - colorVar: '--color-routes', - }) : nothing} - ${features.channels !== false ? renderNavCard({ - href: '/channels', - icon: iconChannel('w-full h-full'), - label: t('entities.channels'), - colorVar: '--color-channels', - }) : nothing} - ${features.messages !== false ? renderNavCard({ - href: '/messages', - icon: iconMessages('w-full h-full'), - label: t('entities.messages'), - colorVar: '--color-messages', - }) : nothing} - ${features.packets !== false ? renderNavCard({ - href: '/packets', - icon: iconPackets('w-full h-full'), - label: t('entities.packets'), - colorVar: '--color-packets', - }) : nothing} - ${features.map !== false ? renderNavCard({ - href: '/map', - icon: iconMap('w-full h-full'), - label: t('entities.map'), - colorVar: '--color-map', - }) : nothing} - ${features.members !== false ? renderNavCard({ - href: '/members', - icon: iconMembers('w-full h-full'), - label: t('entities.members'), - colorVar: '--color-members', - }) : nothing} -
    - ${features.pages !== false && customPages.length > 0 ? html` -
    - ${customPages.slice(0, 3).map(page => html` - - ${iconPage('h-5 w-5 mr-2')} - ${page.title} - `)} -
    ` : nothing} -
    `; -} - -function renderStatsPanel({ features, stats }) { - return html` -
    - ${features.nodes !== false ? renderStatCard({ - icon: iconNodes('h-8 w-8'), - color: pageColors.nodes, - title: t('entities.nodes'), - value: stats.total_nodes, - description: t('home.all_discovered_nodes'), - }) : nothing} - ${features.advertisements !== false ? renderStatCard({ - icon: iconAdvertisements('h-8 w-8'), - color: pageColors.adverts, - title: t('entities.advertisements'), - value: stats.advertisements_7d, - description: t('time.last_7_days'), - }) : nothing} - ${features.messages !== false ? renderStatCard({ - icon: iconMessages('h-8 w-8'), - color: pageColors.messages, - title: t('entities.messages'), - value: stats.messages_7d, - description: t('time.last_7_days'), - }) : nothing} - ${features.packets !== false ? renderStatCard({ - icon: iconPackets('h-8 w-8'), - color: pageColors.packets, - title: t('entities.packets'), - value: stats.packets_7d, - description: t('time.last_7_days'), - }) : nothing} -
    `; -} - -function renderActivityChartCard({ showAdvertSeries, showMessageSeries }) { - return html` -
    -
    -

    - ${iconChart('h-6 w-6')} - ${t('home.network_activity')} -

    -

    ${t('time.activity_per_day_last_7_days')}

    -
    - -
    -
    -
    `; -} - -function renderMembersPanel({ features, stats }) { - if (features.members === false) return nothing; - return html` -
    -
    -

    - ${iconMembers('h-6 w-6')} - ${t('entities.members')} -

    -
    - ${renderStatCard({ - icon: iconAntenna('h-6 w-6'), - color: pageColors.members, - title: t('members_page.operators'), - value: stats.total_operators ?? 0, - })} - ${renderStatCard({ - icon: iconUsers('h-6 w-6'), - color: pageColors.members, - title: t('members_page.members'), - value: stats.total_members ?? 0, - })} -
    -
    -
    `; -} - -export async function render(container, params, router) { - const { signal } = params || {}; - try { - const config = getConfig(); - const features = config.features || {}; - 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 rc = config.network_radio_config; - - const [stats, advertActivity, messageActivity] = await Promise.all([ - apiGet('/api/v1/dashboard/stats', {}, { signal }), - apiGet('/api/v1/dashboard/activity', { days: 7 }, { signal }), - apiGet('/api/v1/dashboard/message-activity', { days: 7 }, { signal }), - ]); - - 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 heroSection = renderHeroSection({ - networkName, logoUrl, logoInvertLight, - networkCity: config.network_city, - networkCountry: config.network_country, - networkWelcomeText: config.network_welcome_text, - features, customPages, - }); - - const statsPanel = renderStatsPanel({ features, stats }); - - const activityChartCard = renderActivityChartCard({ showAdvertSeries, showMessageSeries }); - - litRender(html` -
    -
    - ${heroSection} -
    - ${showStats ? statsPanel : nothing} -
    - -
    - ${showRadioPanel ? html` -
    -
    -

    - ${iconInfo('h-6 w-6')} - ${t('home.network_info')} -

    -
    - ${renderRadioTiles(rc)} -
    -
    -
    - ` : nothing} - - ${renderMembersPanel({ features, stats })} - - ${showActivityChart ? activityChartCard : nothing} -
    `, container); - - let chart = null; - if (showActivityChart) { - chart = window.createActivityChart( - 'activityChart', - showAdvertSeries ? advertActivity : null, - showMessageSeries ? messageActivity : null, - ); - } - - return () => { - if (chart) chart.destroy(); - }; - - } catch (e) { - if (isAbortError(e)) return; - litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); - } -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/maintenance.js b/src/meshcore_hub/web/static/js/spa/pages/maintenance.js deleted file mode 100644 index c3401c7..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/maintenance.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Maintenance page. - * - * Rendered for every route when SYSTEM_MAINTENANCE is enabled. This page makes - * NO backend API calls — the API service / database may be offline while the - * web component stays up. Keep it dependency-free (no api.js import, no fetch). - */ -import { html, litRender, t, getConfig } from '../components.js'; - -export async function render(container, params, router) { - const config = getConfig(); - const logoClass = config.logo_invert_light - ? 'theme-logo theme-logo--invert-light' - : 'theme-logo'; - - litRender(html` -
    -
    -
    - ${config.network_name} -

    ${config.network_name}

    -

    ${t('maintenance.title')}

    -

    ${t('maintenance.message')}

    -
    -
    -
    `, container); -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/map.js b/src/meshcore_hub/web/static/js/spa/pages/map.js deleted file mode 100644 index 5d86e02..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/map.js +++ /dev/null @@ -1,361 +0,0 @@ -import { apiGet, isAbortError } from '../api.js'; -import { - html, litRender, nothing, t, - getConfig, typeEmoji, formatRelativeTime, escapeHtml, errorAlert, - timezoneIndicator, formatNumber, renderFilterToggle, -} from '../components.js'; - -const MAX_BOUNDS_RADIUS_KM = 20; - -function getDistanceKm(lat1, lon1, lat2, lon2) { - 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, anchorLat, anchorLon, radiusKm) { - return nodes.filter(n => getDistanceKm(anchorLat, anchorLon, n.lat, n.lon) <= radiusKm); -} - -function getAnchorPoint(nodes, adoptedCenter) { - 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 normalizeType(type) { - return type ? type.toLowerCase() : null; -} - -function getTypeDisplay(node) { - const type = normalizeType(node.adv_type); - if (type === 'chat') return (window.t && window.t('node_types.chat')) || 'Chat'; - if (type === 'repeater') return (window.t && window.t('node_types.repeater')) || 'Repeater'; - if (type === 'room') return (window.t && window.t('node_types.room')) || 'Room'; - return type ? type.charAt(0).toUpperCase() + type.slice(1) : (window.t && window.t('node_types.unknown')) || 'Unknown'; -} - -// Leaflet DivIcon requires plain HTML strings, so keep escapeHtml here -function createNodeIcon(node, oidcEnabled) { - const displayName = node.name || ''; - const relativeTime = formatRelativeTime(node.last_seen); - const timeDisplay = relativeTime ? ' (' + relativeTime + ')' : ''; - - const iconHtml = (oidcEnabled && node.is_adopted) - ? '
    ' - : '
    '; - - return L.divIcon({ - className: 'custom-div-icon', - html: '
    ' + - iconHtml + - '' + - escapeHtml(displayName) + timeDisplay + '' + - '
    ', - iconSize: [120, 50], - iconAnchor: [60, 12], - }); -} - -// Leaflet popup requires plain HTML strings, so keep escapeHtml here -function createPopupContent(node, oidcEnabled) { - const typeDisplay = getTypeDisplay(node); - 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 ? ((window.t && window.t('map.infrastructure')) || 'Infrastructure') : ((window.t && window.t('map.public')) || 'Public'); - infraIndicatorHtml = ' '; - } - - const typeLabel = (window.t && window.t('common.type')) || 'Type:'; - const keyLabel = (window.t && window.t('common.key')) || 'Key:'; - const locationLabel = (window.t && window.t('common.location')) || 'Location:'; - const lastSeenLabel = (window.t && window.t('common.last_seen_label')) || 'Last seen:'; - const unknownLabel = (window.t && window.t('node_types.unknown')) || 'Unknown'; - const viewDetailsLabel = (window.t && window.t('common.view_details')) || 'View Details'; - - let rows = ''; - rows += '
    ' + typeLabel + '
    ' + escapeHtml(typeDisplay) + '
    '; - - if (node.role) { - const roleLabel = (window.t && window.t('map.role')) || 'Role:'; - rows += '
    ' + roleLabel + '
    ' + escapeHtml(node.role) + '
    '; - } - - if (node.owner) { - const ownerLabel = (window.t && window.t('map.owner')) || 'Owner:'; - const ownerDisplay = node.owner.callsign - ? escapeHtml(node.owner.name) + ' (' + escapeHtml(node.owner.callsign) + ')' - : escapeHtml(node.owner.name); - rows += '
    ' + ownerLabel + '
    ' + ownerDisplay + '
    '; - } - - rows += '
    ' + keyLabel + '
    ' + escapeHtml(node.public_key.substring(0, 16)) + '...
    '; - rows += '
    ' + locationLabel + '
    ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '
    '; - - if (node.last_seen) { - rows += '
    ' + lastSeenLabel + '
    ' + node.last_seen.substring(0, 19).replace('T', ' ') + '
    '; - } - - return '
    ' + - '

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

    ' + - '
    ' + rows + '
    ' + - '' + viewDetailsLabel + '' + - '
    '; -} - -export async function render(container, params, router) { - const { signal } = params || {}; - try { - const config = getConfig(); - const data = await apiGet('/map/data', {}, { signal }); - let allNodes = data.nodes || []; - const mapCenter = data.center || { lat: 0, lon: 0 }; - const adoptedCenter = data.adopted_center || null; - const debug = data.debug || {}; - const profiles = data.profiles || []; - const operatorRole = config.role_names?.operator || 'operator'; - const operatorProfiles = profiles.filter(p => p.roles && p.roles.includes(operatorRole)); - - const isMobilePortrait = window.innerWidth < 480; - const isMobile = window.innerWidth < 768; - const BOUNDS_PADDING = isMobilePortrait ? [50, 50] : (isMobile ? [75, 75] : [100, 100]); - - let lastMemberFilter = ''; - - async function applyFilters() { - const memberFilter = document.getElementById('member-filter')?.value || ''; - - if (memberFilter !== lastMemberFilter) { - lastMemberFilter = memberFilter; - const params = {}; - if (memberFilter) params.adopted_by = memberFilter; - const newData = await apiGet('/map/data', params, { signal }); - allNodes = newData.nodes || []; - } - const filteredNodes = applyFiltersCore(); - const categoryFilter = container.querySelector('#filter-category').value; - - if (filteredNodes.length > 0) { - let nodesToFit = filteredNodes; - - if (categoryFilter !== 'infra') { - const anchor = getAnchorPoint(filteredNodes, adoptedCenter); - const nearbyNodes = getNodesWithinRadius(filteredNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM); - if (nearbyNodes.length > 0) { - nodesToFit = nearbyNodes; - } - } - - const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon])); - map.fitBounds(bounds, { padding: BOUNDS_PADDING }); - } else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) { - map.setView([mapCenter.lat, mapCenter.lon], 10); - } - } - - function updateLabelVisibility() { - const showLabels = container.querySelector('#show-labels').checked; - if (showLabels) { - mapEl.classList.add('show-labels'); - } else { - mapEl.classList.remove('show-labels'); - } - } - - function clearFiltersHandler() { - container.querySelector('#filter-category').value = ''; - container.querySelector('#filter-type').value = ''; - container.querySelector('#show-labels').checked = false; - const memberEl = container.querySelector('#member-filter'); - if (memberEl) memberEl.value = ''; - updateLabelVisibility(); - applyFilters(); - } - - function onMapFilterToggle() { - const filterDiv = container.querySelector('#map-filter-fields'); - if (filterDiv) filterDiv.classList.toggle('hidden'); - } - - const existingToggle = container.querySelector('#filter-toggle'); - const isFilterOpen = existingToggle ? existingToggle.checked : false; - - litRender(html` -
    -

    ${t('entities.map')}

    -
    - ${timezoneIndicator()} - ${t('common.loading')} - - ${renderFilterToggle({ open: isFilterOpen, onChange: onMapFilterToggle })} -
    -
    - -
    -
    - - -
    -
    - - -
    - ${config.oidc_enabled && operatorProfiles.length > 0 ? html` -
    - - -
    - ` : nothing} -
    - -
    - -
    - -
    -
    -
    -
    -
    - -${config.oidc_enabled ? html` -
    - ${t('map.legend')} -
    -
    - ${t('map.infrastructure')} -
    -
    -
    - ${t('map.public')} -
    -
    -` : nothing} - -
    -

    ${t('map.gps_description')}

    -
    `, container); - - const mapEl = container.querySelector('#spa-map'); - const map = L.map(mapEl).setView([0, 0], 2); - - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors', - }).addTo(map); - - let markers = []; - - function clearMarkers() { - markers.forEach(m => map.removeLayer(m)); - markers = []; - } - - function applyFiltersCore() { - const categoryFilter = container.querySelector('#filter-category').value; - const typeFilter = container.querySelector('#filter-type').value; - - const filteredNodes = allNodes.filter(node => { - if (categoryFilter === 'infra' && !node.is_adopted) return false; - const nodeType = normalizeType(node.adv_type); - if (typeFilter && nodeType !== typeFilter) return false; - return true; - }); - - clearMarkers(); - - filteredNodes.forEach(node => { - const marker = L.marker([node.lat, node.lon], { icon: createNodeIcon(node, config.oidc_enabled) }).addTo(map); - marker.bindPopup(createPopupContent(node, config.oidc_enabled)); - markers.push(marker); - }); - - const countEl = container.querySelector('#node-count'); - const filteredEl = container.querySelector('#filtered-count'); - - if (filteredNodes.length === allNodes.length) { - countEl.textContent = t('map.nodes_on_map', { count: formatNumber(allNodes.length) }); - filteredEl.classList.add('hidden'); - } else { - countEl.textContent = t('common.total', { count: formatNumber(allNodes.length) }); - filteredEl.textContent = t('common.shown', { count: formatNumber(filteredNodes.length) }); - filteredEl.classList.remove('hidden'); - } - - return filteredNodes; - } - - if (debug.error) { - container.querySelector('#node-count').textContent = 'Error: ' + debug.error; - return () => map.remove(); - } - - if (debug.total_nodes === 0) { - container.querySelector('#node-count').textContent = t('common.no_entity_in_database', { entity: t('entities.nodes').toLowerCase() }); - return () => map.remove(); - } - - if (debug.nodes_with_coords === 0) { - container.querySelector('#node-count').textContent = t('map.nodes_none_have_coordinates', { count: formatNumber(debug.total_nodes) }); - return () => map.remove(); - } - - if (config.oidc_enabled) { - const adoptedNodes = allNodes.filter(n => n.is_adopted); - if (adoptedNodes.length > 0) { - const bounds = L.latLngBounds(adoptedNodes.map(n => [n.lat, n.lon])); - map.fitBounds(bounds, { padding: BOUNDS_PADDING }); - } else if (allNodes.length > 0) { - const anchor = getAnchorPoint(allNodes, adoptedCenter); - const nearbyNodes = getNodesWithinRadius(allNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM); - const nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes; - const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon])); - map.fitBounds(bounds, { padding: BOUNDS_PADDING }); - } - } else if (allNodes.length > 0) { - const anchor = getAnchorPoint(allNodes, null); - const nearbyNodes = getNodesWithinRadius(allNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM); - const nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes; - const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon])); - map.fitBounds(bounds, { padding: BOUNDS_PADDING }); - } - - applyFiltersCore(); - - return () => map.remove(); - - } catch (e) { - if (isAbortError(e)) return; - litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); - } -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/members.js b/src/meshcore_hub/web/static/js/spa/pages/members.js deleted file mode 100644 index 0522cd7..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/members.js +++ /dev/null @@ -1,117 +0,0 @@ -import { apiGet, isAbortError } from '../api.js'; -import { html, litRender, nothing, t, errorAlert, getConfig, formatNumber } from '../components.js'; -import { iconAntenna, iconUsers } from '../icons.js'; - -function renderProfileTile(profile, router) { - const callsignBadge = profile.callsign - ? html`${profile.callsign}` - : nothing; - - const roleBadges = profile.roles && profile.roles.length > 0 - ? html`
    ${profile.roles.map(role => - html`${role}` - )}
    ` - : nothing; - - const nodeCountLabel = profile.node_count > 0 - ? html`${t('members_page.node_count', { count: formatNumber(profile.node_count) })}` - : nothing; - - const nodeBadges = profile.adopted_nodes && profile.adopted_nodes.length > 0 - ? html`
    ${profile.adopted_nodes.map(node => { - const label = node.name || node.public_key.slice(0, 12) + '...'; - const handleClick = (e) => { - e.preventDefault(); - e.stopPropagation(); - router.navigate('/nodes/' + node.public_key); - }; - return html` { if (e.key === 'Enter' || e.key === ' ') handleClick(e); }}>${label}`; - })}
    ` - : nothing; - - const descriptionText = profile.description - ? html`

    ${profile.description}

    ` - : nothing; - - const openUrl = (e) => { e.preventDefault(); e.stopPropagation(); window.open(profile.url, '_blank', 'noopener,noreferrer'); }; - const urlLink = profile.url - ? html` { if (e.key === 'Enter') openUrl(e); }}>${profile.url}` - : nothing; - - return html` -
    -

    - ${profile.name || t('common.unnamed')} - ${callsignBadge} -

    - ${roleBadges} - ${descriptionText} - ${urlLink} - ${nodeCountLabel} - ${nodeBadges} -
    -
    `; -} - -function renderGroup(title, profiles, icon, router) { - if (profiles.length === 0) return nothing; - return html` -

    - ${icon}${title} -

    -
    - ${profiles.sort((a, b) => (a.name || '').localeCompare(b.name || '')).map(p => renderProfileTile(p, router))} -
    `; -} - -export async function render(container, params, router) { - const { signal } = params || {}; - try { - const config = getConfig(); - const roleNames = config.role_names || {}; - const operatorRole = roleNames.operator || 'operator'; - const memberRole = roleNames.member || 'member'; - const testRole = roleNames.test || 'test'; - - const resp = await apiGet('/api/v1/user/profiles', { limit: 500 }, { signal }); - const allProfiles = resp.items || []; - - const profiles = allProfiles.filter(p => !p.roles || !p.roles.includes(testRole)); - - if (profiles.length === 0) { - litRender(html` -
    -

    ${t('entities.members')}

    -
    - -
    -

    ${t('members_page.empty_state')}

    -

    ${t('members_page.empty_description')}

    -
    `, container); - return; - } - - const operators = profiles.filter(p => p.roles && p.roles.includes(operatorRole)); - const members = profiles.filter(p => - p.roles && p.roles.includes(memberRole) && !p.roles.includes(operatorRole) - ); - - litRender(html` -
    -

    ${t('entities.members')}

    - ${t('common.count_entity', { count: formatNumber(operators.length + members.length), entity: t('entities.members').toLowerCase() })} -
    - -${renderGroup(t('members_page.operators'), operators, html`${iconAntenna('h-6 w-6')}`, router)} -${renderGroup(t('members_page.members'), members, html`${iconUsers('h-6 w-6')}`, router)} -`, container); - - } catch (e) { - if (isAbortError(e)) return; - litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); - } -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js deleted file mode 100644 index 45948b4..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/messages.js +++ /dev/null @@ -1,508 +0,0 @@ -import { apiGet, isAbortError } from '../api.js'; -import { - html, litRender, nothing, t, - getConfig, formatDateTime, formatDateTimeShort, formatNumber, - getChannelLabelsMap, resolveChannelLabel, - warningBadge, - pagination, sortableTableHeader, mobileSortSelect, - renderFilterForm, renderFilterToggle, autoSubmit, - observerIcons, getDisabledObserverAreas, toggleObserverArea, observerFilterBadges -} from '../components.js'; -import { createAutoRefresh } from '../auto-refresh.js'; - -export async function render(container, params, router) { - const { signal } = params || {}; - const query = params.query || {}; - const message_type = query.message_type || ''; - const channel_idx = query.channel_idx || ''; - const includeSpamParam = query.include_spam === 'true' || query.include_spam === true; - const page = parseInt(query.page, 10) || 1; - const limit = parseInt(query.limit, 10) || 50; - const offset = (page - 1) * limit; - const sort = query.sort || 'time'; - const order = query.order || 'desc'; - - // Observer filter is sourced from localStorage (shared toggle badges), not the URL. - let disabledObserverAreas = getDisabledObserverAreas(); - - const config = getConfig(); - const features = config.features || {}; - const packetsEnabled = features.packets !== false; - // Spam toggle is only shown when the feature is enabled; when off the API - // returns everything anyway, so the include_spam param is a no-op. - const spamEnabled = features.spam === true; - const includeSpam = spamEnabled && includeSpamParam; - // Threshold the API hides on; the badge must use the same value so a row is - // badged exactly when it would be hidden (see web app config). - const spamThreshold = typeof config.spam_score_threshold === 'number' - ? config.spam_score_threshold - : 0.65; - let channelLabels = new Map(); - const tz = config.timezone || ''; - const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing; - const navigate = (url) => router.navigate(url); - // Packet-detail target for a row/card, or null when not navigable. - const packetDetailUrl = (packetHash) => - (packetsEnabled && packetHash) ? `/packets/hash/${packetHash}` : null; - - function channelInfo(msg) { - 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: t('messages.type_channel'), text: rawText || '-' }; - } - - function senderBlock(msg, emphasize = false) { - const senderName = msg.sender_tag_name || msg.sender_name; - if (senderName) { - return emphasize - ? html`${senderName}` - : html`${senderName}`; - } - const prefix = (msg.pubkey_prefix || '').slice(0, 12); - if (prefix) { - return html`${prefix}`; - } - return html`-`; - } - - function parseSenderFromText(text) { - 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 }; - } - - // Collapse any run of newlines (and the whitespace around them) into a - // single space so multi-line messages don't blow up the table/card layout. - function collapseNewlines(text) { - if (!text || typeof text !== 'string') return text; - return text.replace(/\s*\n\s*/g, ' '); - } - - function messageTextWithSender(msg, text) { - 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}`; - } - - // Small badge for rows the scorer flagged as likely spam. Only meaningful - // when the spam feature is on (otherwise spam_score is null on every row). - function spamBadge(msg) { - if (!spamEnabled || msg.spam_score == null || msg.spam_score < spamThreshold) { - return nothing; - } - return html`${t('messages.spam.badge')}`; - } - - function dedupeBySignature(items) { - const deduped = []; - const bySignature = new Map(); - - for (const msg of items) { - const signature = typeof msg.signature === 'string' ? msg.signature.trim().toUpperCase() : ''; - const canDedupe = msg.message_type === 'channel' && signature.length >= 8; - if (!canDedupe) { - deduped.push(msg); - continue; - } - - const existing = bySignature.get(signature); - if (!existing) { - const clone = { - ...msg, - observers: [...(msg.observers || [])], - }; - bySignature.set(signature, clone); - deduped.push(clone); - continue; - } - - const combined = [...(existing.observers || []), ...(msg.observers || [])]; - const seenReceivers = new Set(); - existing.observers = combined.filter((recv) => { - const key = recv?.public_key || recv?.node_id || `${recv?.observed_at || ''}:${recv?.snr || ''}`; - if (seenReceivers.has(key)) return false; - seenReceivers.add(key); - return true; - }); - - if (!existing.observed_by && msg.observed_by) existing.observed_by = msg.observed_by; - if (!existing.observer_name && msg.observer_name) existing.observer_name = msg.observer_name; - if (!existing.observer_tag_name && msg.observer_tag_name) existing.observer_tag_name = msg.observer_tag_name; - if (!existing.pubkey_prefix && msg.pubkey_prefix) existing.pubkey_prefix = msg.pubkey_prefix; - if (!existing.sender_name && msg.sender_name) existing.sender_name = msg.sender_name; - if (!existing.sender_tag_name && msg.sender_tag_name) existing.sender_tag_name = msg.sender_tag_name; - if (!existing.channel_name && msg.channel_name) existing.channel_name = msg.channel_name; - if ( - existing.channel_name === 'Public' - && msg.channel_name - && msg.channel_name !== 'Public' - ) { - existing.channel_name = msg.channel_name; - } - if (existing.channel_idx === null || existing.channel_idx === undefined) { - if (msg.channel_idx !== null && msg.channel_idx !== undefined) { - existing.channel_idx = msg.channel_idx; - } - } else if ( - existing.channel_idx === 17 - && msg.channel_idx !== null - && msg.channel_idx !== undefined - && msg.channel_idx !== 17 - ) { - existing.channel_idx = msg.channel_idx; - } - } - - return deduped; - } - - let lastContent = nothing; - let lastTotal = null; - let currentFilterFields = []; - const hasActiveFilters = message_type !== '' || channel_idx !== '' || includeSpam; - - function onFilterToggle() { renderPage(lastContent, { total: lastTotal }); } - - function renderPage(content, { total = null, error = null } = {}) { - if (!error) { - lastContent = content; - lastTotal = total; - } - const displayContent = error ? lastContent : content; - const displayTotal = error ? lastTotal : total; - const existingToggle = container.querySelector('#filter-toggle'); - const filterOpen = existingToggle ? existingToggle.checked : hasActiveFilters; - litRender(html` -
    -

    ${t('entities.messages')}

    - ${tzBadge} -
    -
    - ${displayTotal !== null - ? html`${t('common.total', { count: formatNumber(displayTotal) })}` - : nothing} - ${error ? warningBadge(error) : nothing} -
    - -
    -
    ${renderFilterToggle({ open: filterOpen, onChange: onFilterToggle })}
    -
    -${(filterOpen && currentFilterFields.length > 0) - ? html`
    ${renderFilterForm({ fields: currentFilterFields, basePath: '/messages', navigate })}
    ` - : nothing} -${displayContent}`, container); - } - - // Render page header immediately (old content stays visible until data loads) - renderPage(nothing); - - async function fetchAndRenderData() { - try { - // Phase 1: fetch the observer node list (and channels) first. The messages - // API filters observers by inclusion only, so we need the full observer list - // to translate the stored "disabled" set into an explicit include-list. - const [nodesData, channelsData] = await Promise.all([ - apiGet('/api/v1/nodes', { limit: 500, observer: true }, { signal }), - apiGet('/api/v1/channels', {}, { signal }), - ]); - const builtinLabels = getChannelLabelsMap(config); - const customLabels = new Map( - (channelsData.items || []) - .map(ch => [parseInt(ch.channel_hash, 16), ch.name]) - .filter(([idx]) => Number.isInteger(idx)), - ); - channelLabels = new Map([...builtinLabels, ...customLabels]); - const allNodes = nodesData.items || []; - - const areaMap = new Map(); // area -> public_key[] - for (const n of allNodes) { - 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 sortedAreas = [...areaMap.keys()] - .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); - const enabledObserverKeys = sortedAreas - .filter(a => !disabledObserverAreas.has(a)) - .flatMap(a => areaMap.get(a)); - // Only constrain when some current area is actually hidden. - const observerFilterActive = sortedAreas.some(a => disabledObserverAreas.has(a)); - - const onObserverToggle = (area) => { - disabledObserverAreas = toggleObserverArea(area, sortedAreas.length); - if (page > 1) { - // Re-scoping the data invalidates the current page; reset to page 1. - const sp = new URLSearchParams(window.location.search); - sp.delete('page'); - const qs = sp.toString(); - navigate(qs ? `/messages?${qs}` : '/messages'); - } else { - fetchAndRenderData(); - } - }; - - // Phase 2: fetch the messages with the resolved observer filter. - const apiParams = { limit, offset, message_type, channel_idx, sort, order }; - if (observerFilterActive) apiParams.observed_by = enabledObserverKeys; - if (includeSpam) apiParams.include_spam = true; - const data = await apiGet('/api/v1/messages', apiParams, { signal }); - const messages = dedupeBySignature(data.items || []); - const total = data.total || 0; - const totalPages = Math.ceil(total / limit); - - const observerBadges = (extraClass) => observerFilterBadges({ - areas: sortedAreas, disabled: disabledObserverAreas, onToggle: onObserverToggle, extraClass, - }); - - const mobileCards = messages.length === 0 - ? html`
    ${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}
    ` - : messages.map(msg => { - 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); - const sender = senderBlock(msg); - const displayMessage = messageTextWithSender(msg, chInfo.text); - const fromPrimary = isChannel - ? html`${chInfo.label || t('messages.type_channel')}` - : sender; - let receiversBlock = nothing; - if (msg.observers && msg.observers.length >= 1) { - receiversBlock = observerIcons(msg.observers); - } else if (msg.observed_by) { - receiversBlock = html`\u{1F4E1}`; - } - const detailUrl = packetDetailUrl(msg.packet_hash); - return html`
    navigate(detailUrl) : undefined}> -
    -
    -
    - - ${typeIcon} - -
    -
    - ${fromPrimary} -
    -
    - ${formatDateTimeShort(msg.received_at)} - ${spamBadge(msg)} -
    -
    -
    -
    - ${receiversBlock} -
    -
    -

    ${displayMessage}

    -
    -
    `; - }); - - const tableRows = messages.length === 0 - ? html`${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}` - : messages.map(msg => { - 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); - const sender = senderBlock(msg, true); - const displayMessage = messageTextWithSender(msg, chInfo.text); - const fromPrimary = isChannel - ? html`${chInfo.label || t('messages.type_channel')}` - : sender; - let receiversBlock; - if (msg.observers && msg.observers.length >= 1) { - receiversBlock = html`${observerIcons(msg.observers)}`; - } else if (msg.observed_by) { - receiversBlock = html`\u{1F4E1}`; - } else { - receiversBlock = html`-`; - } - const detailUrl = packetDetailUrl(msg.packet_hash); - return html` navigate(detailUrl) : undefined}> - ${typeIcon} - ${formatDateTime(msg.received_at)} - -
    ${fromPrimary}
    - - -
    - ${displayMessage} - ${spamBadge(msg)} -
    - - ${receiversBlock} - `; - }); - - const spamParam = includeSpam ? { include_spam: 'true' } : {}; - const paginationBlock = pagination(page, totalPages, '/messages', { - message_type, channel_idx, limit, sort, order, ...spamParam, - }); - - const filterFields = [ - () => html` -
    - - -
    `, - () => html` -
    - - -
    `, - ]; - if (spamEnabled) { - filterFields.push(() => html` -
    - - -
    `); - } - const headerParams = { message_type, channel_idx, limit, ...spamParam }; - const sortable = (label, sortKey) => sortableTableHeader(label, { - sortKey, currentSort: sort, currentOrder: order, - navigate, basePath: '/messages', params: headerParams, - }); - - currentFilterFields = filterFields; - - renderPage(html` - -${observerBadges('hidden lg:flex mb-4')} - -${mobileSortSelect({ - currentSort: sort, currentOrder: order, - navigate, 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') }, - ], -})} - -${observerBadges('flex lg:hidden mb-4')} - -
    - ${mobileCards} -
    - - - -${paginationBlock}`, { total }); - - } catch (e) { - if (isAbortError(e)) return; - renderPage(nothing, { error: e.message }); - } - } - - await fetchAndRenderData(); - - const toggleEl = container.querySelector('#auto-refresh-toggle'); - const { cleanup } = createAutoRefresh({ - fetchAndRender: fetchAndRenderData, - toggleContainer: toggleEl, - }); - return cleanup; -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js deleted file mode 100644 index bfaed26..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js +++ /dev/null @@ -1,657 +0,0 @@ -import { apiGet, apiPost, apiPut, apiDelete, isAbortError } from '../api.js'; -import { - html, litRender, nothing, - getConfig, hasRole, typeEmoji, formatDateTime, - truncateKey, errorAlert, successAlert, copyToClipboard, t, -} from '../components.js'; -import { iconError, iconPlus, iconEdit, iconTrash } from '../icons.js'; - -let _mapInstance = null; - -function validateTagValue(value, type) { - 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; -} - -function renderDeleteTagModal() { - return html` - - - -`; -} - -function renderEditTagModal() { - return html` - - - -`; -} - -export async function render(container, params, router) { - const { signal } = params || {}; - const cleanupFns = []; - let publicKey = params.publicKey; - - try { - if (publicKey.length !== 64) { - const resolved = await apiGet('/api/v1/nodes/prefix/' + encodeURIComponent(publicKey), {}, { signal }); - router.navigate('/nodes/' + resolved.public_key, true); - return; - } - - const [node, adsData, telemetryData] = await Promise.all([ - apiGet('/api/v1/nodes/' + publicKey, {}, { signal }), - apiGet('/api/v1/advertisements', { public_key: publicKey, limit: 10 }, { signal }), - apiGet('/api/v1/telemetry', { node_public_key: publicKey, limit: 10 }, { signal }), - ]); - - if (!node) { - litRender(renderNotFound(publicKey), container); - return; - } - - const config = getConfig(); - const tagName = node.tags?.find(t => t.key === 'name')?.value; - const tagDescription = node.tags?.find(t => t.key === 'description')?.value; - const displayName = tagName || node.name || t('common.unnamed_node'); - const emoji = typeEmoji(node.adv_type); - - let lat = node.lat; - let lon = node.lon; - if (!lat || !lon) { - 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 && !(lat === 0 && lon === 0); - - const advertisements = adsData.items || []; - - const heroHtml = hasCoords - ? html` -
    -
    -
    -
    -
    -
    ` - : html` -
    -
    -
    -

    ${t('nodes.scan_to_add')}

    -
    -
    `; - - const coordsHtml = hasCoords - ? html`
    ${t('common.location')}: ${lat}, ${lon}
    ` - : nothing; - - const adsTableHtml = advertisements.length > 0 - ? html`
    - - - - - - - - - - ${advertisements.map(adv => { - const advEmoji = adv.adv_type ? typeEmoji(adv.adv_type) : ''; - const advTypeHtml = adv.adv_type - ? html`${advEmoji}` - : html`-`; - const recvName = adv.observed_by ? (adv.observer_tag_name || adv.observer_name) : null; - const receiverHtml = !adv.observed_by - ? html`-` - : recvName - ? html` -
    ${recvName}
    - -
    ` - : html` - ${adv.observed_by.slice(0, 12)}... - `; - return html` - - - - `; - })} - -
    ${t('common.time')}${t('common.type')}${t('common.received_by')}
    ${formatDateTime(adv.received_at)}${advTypeHtml}${receiverHtml}
    -
    ` - : html`

    ${t('common.no_entity_recorded', { entity: t('entities.advertisements').toLowerCase() })}

    `; - - const tags = node.tags || []; - const canEditTags = config.oidc_enabled && config.user && ( - hasRole('admin') || (hasRole('operator') && node.adopted_by?.user_id === config.user.sub) - ); - - const tagsTableHtml = canEditTags - ? (tags.length > 0 - ? html`
    - - - - - - - - - - - ${tags.map(tag => html` - - - - - `)} - -
    ${t('common.key')}${t('common.value')}${t('common.actions')}
    ${tag.key}${tag.value || ''} -
    - - -
    -
    -
    ` - : html`

    ${t('common.no_entity_defined', { entity: t('entities.tags').toLowerCase() })}

    `) - : (tags.length > 0 - ? html`
    - - - - - - - - - - ${tags.map(tag => html` - - - - `)} - -
    ${t('common.key')}${t('common.value')}${t('common.type')}
    ${tag.key}${tag.value || ''}${tag.value_type || 'string'}
    -
    ` - : html`

    ${t('common.no_entity_defined', { entity: t('entities.tags').toLowerCase() })}

    `); - - const addTagFormHtml = canEditTags - ? html`
    -
    -
    - -
    -
    - - -
    - - -
    -
    ` - : nothing; - - const adoptionHtml = renderAdoptionSection(node, config); - - const flashMessage = (params.query && params.query.message) || ''; - const flashError = (params.query && params.query.error) || ''; - const flashHtml = flashMessage ? successAlert(flashMessage) : flashError ? errorAlert(flashError) : nothing; - - const infoGridHtml = adoptionHtml - ? html`
    -
    -
    -
    -

    ${t('common.public_key')}

    - copyToClipboard(e, node.public_key)} - title="Click to copy">${node.public_key} -
    -
    -
    ${t('common.first_seen_label')} ${formatDateTime(node.first_seen)}
    -
    ${t('common.last_seen_label')} ${formatDateTime(node.last_seen)}
    - ${coordsHtml} -
    -
    -
    - ${adoptionHtml} -
    ` - : html`
    -
    -
    -

    ${t('common.public_key')}

    - copyToClipboard(e, node.public_key)} - title="Click to copy">${node.public_key} -
    -
    -
    ${t('common.first_seen_label')} ${formatDateTime(node.first_seen)}
    -
    ${t('common.last_seen_label')} ${formatDateTime(node.last_seen)}
    - ${coordsHtml} -
    -
    -
    `; - - litRender(html` - - -
    - ${emoji} -
    -

    ${displayName}

    - ${tagDescription ? html`

    ${tagDescription}

    ` : nothing} -
    -
    - -${flashHtml} - -
    - -${heroHtml} - -${infoGridHtml} - -
    -
    -
    -

    ${t('common.recent_entity', { entity: t('entities.advertisements') })}

    - ${adsTableHtml} -
    -
    - -
    -
    -

    ${t('entities.tags')}

    - ${tagsTableHtml} - ${addTagFormHtml} -
    -
    -
    - -${canEditTags ? renderDeleteTagModal() : nothing} -${canEditTags ? renderEditTagModal() : nothing}`, container); - if (hasCoords && typeof L !== 'undefined') { - const mapEl = document.getElementById('header-map'); - if (mapEl) { - if (_mapInstance) { - try { _mapInstance.remove(); } catch (e) { /* ignore */ } - _mapInstance = null; - } - if (mapEl._leaflet_id != null) { - delete mapEl._leaflet_id; - } - } - const map = L.map('header-map', { - zoomControl: false, dragging: false, scrollWheelZoom: false, - doubleClickZoom: false, boxZoom: false, keyboard: false, - attributionControl: false, - }); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map); - map.setView([lat, lon], 14); - const point = map.latLngToContainerPoint([lat, lon]); - const newPoint = L.point(point.x + map.getSize().x * 0.17, point.y); - const newLatLng = map.containerPointToLatLng(newPoint); - map.setView(newLatLng, 14, { animate: false }); - const mapIcon = L.divIcon({ - html: '' + emoji + '', - className: '', iconSize: [32, 32], iconAnchor: [16, 16], - }); - L.marker([lat, lon], { icon: mapIcon }).addTo(map); - _mapInstance = map; - cleanupFns.push(() => { _mapInstance = null; try { map.remove(); } catch (e) { /* ignore */ } }); - } - - // Initialize QR code - wait for both DOM element and QRCode library - const initQr = () => { - const qrEl = document.getElementById('qr-code'); - if (!qrEl || typeof QRCode === 'undefined') return false; - const typeMap = { 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; - new QRCode(qrEl, { - text: url, width: 140, height: 140, - colorDark: '#000000', colorLight: '#ffffff', - correctLevel: QRCode.CorrectLevel.L, - }); - return true; - }; - if (!initQr()) { - let attempts = 0; - const qrInterval = setInterval(() => { - if (initQr() || ++attempts >= 20) clearInterval(qrInterval); - }, 100); - cleanupFns.push(() => clearInterval(qrInterval)); - } - - // Wire up adoption buttons - const adoptReleaseAc = new AbortController(); - const adoptReleaseSignal = adoptReleaseAc.signal; - cleanupFns.push(() => adoptReleaseAc.abort()); - - const adoptBtn = container.querySelector('.btn-adopt-node'); - if (adoptBtn) { - adoptBtn.addEventListener('click', async () => { - try { - await apiPost('/api/v1/adoptions', { public_key: node.public_key }); - router.navigate('/nodes/' + node.public_key + '?message=' + encodeURIComponent(t('nodes.adopt_success')), true); - } catch (err) { - router.navigate('/nodes/' + node.public_key + '?error=' + encodeURIComponent(err.message), true); - } - }, { signal: adoptReleaseSignal }); - } - - const releaseBtn = container.querySelector('.btn-release-node'); - if (releaseBtn) { - releaseBtn.addEventListener('click', async () => { - if (!confirm(t('nodes.release_confirm'))) return; - try { - await apiDelete('/api/v1/adoptions/' + node.public_key); - router.navigate('/nodes/' + node.public_key + '?message=' + encodeURIComponent(t('nodes.release_success')), true); - } catch (err) { - router.navigate('/nodes/' + node.public_key + '?error=' + encodeURIComponent(err.message), true); - } - }, { signal: adoptReleaseSignal }); - } - - // Tag editor event handlers - if (canEditTags) { - const ac = new AbortController(); - const { signal } = ac; - cleanupFns.push(() => ac.abort()); - - const refreshNode = async () => { - const fresh = await apiGet('/api/v1/nodes/' + node.public_key); - return fresh; - }; - - const showFlash = (type, message) => { - const flashContainer = container.querySelector('#flash-container'); - if (!flashContainer) return; - litRender(type === 'success' ? successAlert(message) : errorAlert(message), flashContainer); - setTimeout(() => { - if (flashContainer) litRender(nothing, flashContainer); - }, 3000); - }; - - // Add tag form - const addForm = container.querySelector('#tag-add-form'); - if (addForm) { - addForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const formData = new FormData(addForm); - const key = formData.get('key'); - const value = formData.get('value') || ''; - const valueType = formData.get('value_type'); - const errEl = container.querySelector('#tagAddError'); - - const validationError = validateTagValue(value, valueType); - if (validationError) { - if (errEl) { errEl.textContent = validationError; errEl.classList.remove('hidden'); } - return; - } - if (errEl) { errEl.textContent = ''; errEl.classList.add('hidden'); } - - try { - await apiPost('/api/v1/nodes/' + node.public_key + '/tags', { key, value, value_type: valueType }); - showFlash('success', t('common.entity_added_success', { entity: t('entities.tag') })); - addForm.reset(); - router.navigate('/nodes/' + node.public_key, true); - } catch (err) { - showFlash('error', err.message); - } - }, { signal }); - } - - // Edit buttons - container.querySelectorAll('.tag-edit-btn').forEach(btn => { - btn.addEventListener('click', () => { - const modal = container.querySelector('#tagEditModal'); - const keyInput = container.querySelector('#tagEditKey'); - const keyDisplay = container.querySelector('#tagEditKeyDisplay'); - const valueInput = container.querySelector('#tagEditValue'); - const typeSelect = container.querySelector('#tagEditType'); - const errorLabel = container.querySelector('#tagEditError'); - - keyInput.value = btn.dataset.key; - if (keyDisplay) keyDisplay.textContent = btn.dataset.key; - valueInput.value = btn.dataset.value; - typeSelect.value = btn.dataset.type; - if (errorLabel) { errorLabel.textContent = ''; errorLabel.classList.add('hidden'); } - modal.showModal(); - }, { signal }); - }); - - // Edit form submit - const editForm = container.querySelector('#tag-edit-form'); - if (editForm) { - editForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const key = container.querySelector('#tagEditKey').value; - const value = container.querySelector('#tagEditValue').value; - const valueType = container.querySelector('#tagEditType').value; - const errorLabel = container.querySelector('#tagEditError'); - - const validationError = validateTagValue(value, valueType); - if (validationError) { - if (errorLabel) { errorLabel.textContent = validationError; errorLabel.classList.remove('hidden'); } - return; - } - if (errorLabel) { errorLabel.textContent = ''; errorLabel.classList.add('hidden'); } - - const submitBtn = container.querySelector('#tagEditSubmit'); - const cancelBtn = container.querySelector('#tagEditCancel'); - const orig = submitBtn.innerHTML; - submitBtn.disabled = true; - cancelBtn.disabled = true; - submitBtn.innerHTML = ` ${orig}`; - try { - await apiPut('/api/v1/nodes/' + node.public_key + '/tags/' + encodeURIComponent(key), { value, value_type: valueType }); - container.querySelector('#tagEditModal').close(); - showFlash('success', t('common.entity_updated_success', { entity: t('entities.tag') })); - router.navigate('/nodes/' + node.public_key, true); - } catch (err) { - if (errorLabel) { errorLabel.textContent = err.message; errorLabel.classList.remove('hidden'); } - } finally { - submitBtn.disabled = false; - cancelBtn.disabled = false; - submitBtn.innerHTML = orig; - } - }, { signal }); - } - - // Edit cancel - const editCancel = container.querySelector('#tagEditCancel'); - if (editCancel) { - editCancel.addEventListener('click', () => { - container.querySelector('#tagEditModal').close(); - }, { signal }); - } - - // Delete buttons - container.querySelectorAll('.tag-delete-btn').forEach(btn => { - btn.addEventListener('click', () => { - const modal = container.querySelector('#tagDeleteModal'); - const msg = container.querySelector('#tag-delete-msg'); - msg.innerHTML = t('common.delete_entity_confirm', { entity: t('entities.tag'), name: btn.dataset.key }); - modal._tagKey = btn.dataset.key; - modal.showModal(); - }, { signal }); - }); - - // Delete confirm - const deleteConfirm = container.querySelector('#tagDeleteConfirm'); - if (deleteConfirm) { - deleteConfirm.addEventListener('click', async () => { - const modal = container.querySelector('#tagDeleteModal'); - const key = modal._tagKey; - const cancelBtn = container.querySelector('#tagDeleteCancel'); - const orig = deleteConfirm.innerHTML; - deleteConfirm.disabled = true; - cancelBtn.disabled = true; - deleteConfirm.innerHTML = ` ${orig}`; - try { - await apiDelete('/api/v1/nodes/' + node.public_key + '/tags/' + encodeURIComponent(key)); - modal.close(); - showFlash('success', t('common.entity_deleted_success', { entity: t('entities.tag') })); - router.navigate('/nodes/' + node.public_key, true); - } catch (err) { - modal.close(); - showFlash('error', err.message); - } finally { - deleteConfirm.disabled = false; - cancelBtn.disabled = false; - deleteConfirm.innerHTML = orig; - } - }, { signal }); - } - - // Delete cancel - const deleteCancel = container.querySelector('#tagDeleteCancel'); - if (deleteCancel) { - deleteCancel.addEventListener('click', () => { - container.querySelector('#tagDeleteModal').close(); - }, { signal }); - } - } - - return () => { - cleanupFns.forEach(fn => fn()); - }; - } catch (e) { - if (isAbortError(e)) return; - if (e.message && e.message.includes('404')) { - litRender(renderNotFound(publicKey), container); - } else { - litRender(errorAlert(e.message), container); - } - } -} - -function renderAdoptionSection(node, config) { - if (!config.oidc_enabled || !config.user) return nothing; - - const isOperator = hasRole('operator'); - const isAdmin = hasRole('admin'); - if (!isOperator && !isAdmin) { - if (node.adopted_by) { - const ownerName = node.adopted_by.name || node.adopted_by.user_id; - return html`
    -
    -

    ${t('nodes.ownership')}

    -

    - ${t('nodes.adopted_by_prefix')} - ${ownerName} -

    -
    -
    `; - } - return nothing; - } - - if (node.adopted_by) { - const ownerName = node.adopted_by.name || node.adopted_by.user_id; - const isOwner = node.adopted_by.user_id === config.user.sub; - const canRelease = isOwner || isAdmin; - - const releaseBtnHtml = canRelease - ? html`` - : nothing; - - return html`
    -
    -

    ${t('nodes.ownership')}

    -
    -

    - ${t('nodes.adopted_by_prefix')} - ${ownerName} -

    - ${releaseBtnHtml} -
    -
    -
    `; - } - - return html`
    -
    -

    ${t('nodes.ownership')}

    -

    ${t('nodes.not_adopted')}

    -
    - -
    -
    -
    `; -} - -function renderNotFound(publicKey) { - return html` - -
    - ${iconError('stroke-current shrink-0 h-6 w-6')} - ${t('common.entity_not_found_details', { entity: t('entities.node'), details: publicKey })} -
    -${t('common.view_entity', { entity: t('entities.nodes') })}`; -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/nodes.js b/src/meshcore_hub/web/static/js/spa/pages/nodes.js deleted file mode 100644 index 8dcbdc0..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/nodes.js +++ /dev/null @@ -1,246 +0,0 @@ -import { apiGet, isAbortError } from '../api.js'; -import { - html, litRender, nothing, - getConfig, formatDateTime, formatDateTimeShort, formatNumber, - warningBadge, - pagination, sortableTableHeader, mobileSortSelect, - renderFilterForm, renderFilterToggle, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t -} from '../components.js'; -import { createAutoRefresh } from '../auto-refresh.js'; - -export async function render(container, params, router) { - const { signal } = params || {}; - const query = params.query || {}; - const search = query.search || ''; - const adv_type = query.adv_type || ''; - const adopted_by = query.adopted_by || ''; - const pubkey_prefix = query.pubkey_prefix || ''; - const page = parseInt(query.page, 10) || 1; - const limit = parseInt(query.limit, 10) || 20; - const offset = (page - 1) * limit; - const sort = query.sort || 'last_seen'; - const order = query.order || 'desc'; - - const config = getConfig(); - const tz = config.timezone || ''; - const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing; - const navigate = (url) => router.navigate(url); - - let lastContent = nothing; - let lastTotal = null; - let currentFilterFields = []; - const hasActiveFilters = search !== '' || adv_type !== '' || pubkey_prefix !== '' || (config.oidc_enabled && adopted_by !== ''); - - function onFilterToggle() { renderPage(lastContent, { total: lastTotal }); } - - function renderPage(content, { total = null, error = null } = {}) { - if (!error) { - lastContent = content; - lastTotal = total; - } - const displayContent = error ? lastContent : content; - const displayTotal = error ? lastTotal : total; - const existingToggle = container.querySelector('#filter-toggle'); - const filterOpen = existingToggle ? existingToggle.checked : hasActiveFilters; - litRender(html` -
    -

    ${t('entities.nodes')}

    - ${tzBadge} -
    -
    - ${displayTotal !== null - ? html`${t('common.total', { count: formatNumber(displayTotal) })}` - : nothing} - ${error ? warningBadge(error) : nothing} -
    - -
    -
    ${renderFilterToggle({ open: filterOpen, onChange: onFilterToggle })}
    -
    -${(filterOpen && currentFilterFields.length > 0) - ? html`
    ${renderFilterForm({ fields: currentFilterFields, basePath: '/nodes', navigate })}
    ` - : nothing} -${displayContent}`, container); - } - - renderPage(nothing); - - async function fetchAndRenderData() { - try { - const apiParams = { limit, offset, search, adv_type, sort, order }; - if (adopted_by) apiParams.adopted_by = adopted_by; - if (pubkey_prefix) apiParams.pubkey_prefix = pubkey_prefix; - const fetches = [apiGet('/api/v1/nodes', apiParams, { signal })]; - if (config.oidc_enabled) { - fetches.push(apiGet('/api/v1/user/profiles', { limit: 500 }, { signal })); - } - const results = await Promise.all(fetches); - const data = results[0]; - const operatorRole = config.role_names?.operator || 'operator'; - const profiles = config.oidc_enabled - ? (results[1]?.items || []).filter(p => p.roles && p.roles.includes(operatorRole)) - : []; - - const nodes = data.items || []; - const total = data.total || 0; - const totalPages = Math.ceil(total / limit); - - const mobileCards = nodes.length === 0 - ? html`
    ${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}
    ` - : nodes.map(node => { - const tagName = node.tags?.find(tag => tag.key === 'name')?.value; - const tagDescription = node.tags?.find(tag => tag.key === 'description')?.value; - const displayName = tagName || node.name; - const lastSeen = node.last_seen ? formatDateTimeShort(node.last_seen) : '-'; - return html` -
    -
    - ${renderNodeDisplay({ - name: displayName, - description: tagDescription, - publicKey: node.public_key, - advType: node.adv_type, - size: 'sm' - })} -
    -
    ${lastSeen}
    -
    -
    -
    -
    `; - }); - - const tableRows = nodes.length === 0 - ? html`${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}` - : nodes.map(node => { - const tagName = node.tags?.find(tag => tag.key === 'name')?.value; - const tagDescription = node.tags?.find(tag => tag.key === 'description')?.value; - const displayName = tagName || node.name; - const lastSeen = node.last_seen ? formatDateTime(node.last_seen) : '-'; - return html` - - - ${renderNodeDisplay({ - name: displayName, - description: tagDescription, - publicKey: node.public_key, - advType: node.adv_type, - size: 'base' - })} - - - - copyToClipboard(e, node.public_key)} - title="Click to copy">${node.public_key} - - ${lastSeen} - `; - }); - - const paginationBlock = pagination(page, totalPages, '/nodes', { - search, adv_type, adopted_by, pubkey_prefix, limit, sort, order, - }); - - const filterFields = [ - () => html` -
    - - -
    `, - () => html` -
    - - -
    `, - ]; - if (config.oidc_enabled && profiles.length > 0) { - filterFields.push(() => html` -
    - - -
    `); - } - - const headerParams = { search, adv_type, adopted_by, pubkey_prefix, limit }; - const sortable = (label, sortKey) => sortableTableHeader(label, { - sortKey, currentSort: sort, currentOrder: order, - navigate, basePath: '/nodes', params: headerParams, - }); - - currentFilterFields = filterFields; - - renderPage(html` - -${mobileSortSelect({ - currentSort: sort, currentOrder: order, - navigate, 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') }, - ], -})} - -
    - ${mobileCards} -
    - - - -${paginationBlock}`, { total }); - - } catch (e) { - if (isAbortError(e)) return; - renderPage(nothing, { error: e.message }); - } - } - - await fetchAndRenderData(); - - const toggleEl = container.querySelector('#auto-refresh-toggle'); - const { cleanup } = createAutoRefresh({ - fetchAndRender: fetchAndRenderData, - toggleContainer: toggleEl, - }); - return cleanup; -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/not-found.js b/src/meshcore_hub/web/static/js/spa/pages/not-found.js deleted file mode 100644 index dcfe612..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/not-found.js +++ /dev/null @@ -1,27 +0,0 @@ -import { html, litRender, t } from '../components.js'; -import { iconHome, iconNodes } from '../icons.js'; - -export async function render(container, params, router) { - litRender(html` -`, container); -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/packet-detail.js b/src/meshcore_hub/web/static/js/spa/pages/packet-detail.js deleted file mode 100644 index 09b9535..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/packet-detail.js +++ /dev/null @@ -1,120 +0,0 @@ -import { apiGet, isAbortError } from '../api.js'; -import { - html, litRender, nothing, t, - getConfig, formatDateTime, warningBadge, copyToClipboard -} from '../components.js'; -import { jsonTree } from '../json-tree.js'; - -function field(label, value) { - return html` -
    - ${label} - ${value} -
    `; -} - -export async function render(container, params, router) { - const { signal } = params || {}; - const id = params.id; - const config = getConfig(); - const tz = config.timezone || ''; - const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing; - - function shell(content, leaf) { - litRender(html` - - -
    -

    ${t('packets.detail_title')}

    - ${tzBadge} -
    -${content}`, container); - } - - shell(html`
    ${t('common.loading')}
    `); - - try { - const [p, channelsData] = await Promise.all([ - apiGet(`/api/v1/packets/${id}`, {}, { signal }), - apiGet('/api/v1/channels', { limit: 200 }, { signal }).catch(() => ({ items: [] })), - ]); - - const channelNames = new Map( - (channelsData.items || []) - .map(c => [parseInt(c.channel_hash, 16), c.name]) - .filter(([idx]) => !Number.isNaN(idx)) - ); - - let channelDisplay = html``; - if (p.channel_idx != null) { - const name = channelNames.get(p.channel_idx); - channelDisplay = html`${name ? `${name} (${p.channel_idx})` : `${p.channel_idx}`}`; - } - - const observerDisplay = p.observed_by - ? html`${p.observer_tag_name || p.observer_name || p.observed_by}` - : html``; - - const redactedNotice = p.redacted - ? html`
    \u{1F512} ${t('packets.redacted_notice')}
    ` - : nothing; - - const rawBlock = p.redacted - ? nothing - : html` -
    -
    - ${t('packets.col_raw')} - ${p.raw_hex ? html`` : nothing} -
    -
    ${p.raw_hex || '—'}
    -
    `; - - const decodedBlock = (!p.redacted && p.decoded) - ? html` -
    - ${t('packets.decoded')} -
    ${jsonTree(p.decoded, { openDepth: 1 })}
    -
    ` - : nothing; - - shell(html` -${redactedNotice} -
    -
    -
    - ${field(t('common.time'), formatDateTime(p.received_at))} - ${field(t('common.observers'), observerDisplay)} - ${field(t('packets.col_event_type'), p.event_type || '—')} - ${field(t('entities.channel'), channelDisplay)} - ${field(t('packets.col_source'), p.source_pubkey_prefix - ? html`${p.source_pubkey_prefix}` - : html``)} - ${field(t('packets.packet_hash'), p.packet_hash - ? html`${p.packet_hash}` - : html``)} - ${field(t('packets.packet_type'), p.packet_type != null ? p.packet_type : '—')} - ${field(t('packets.payload_type'), p.payload_type != null ? p.payload_type : '—')} - ${field(t('packets.col_route_type'), p.route_type || '—')} - ${field(t('common.snr_db'), p.snr != null ? Number(p.snr).toFixed(1) : '—')} - ${field(t('common.hops'), p.path_len != null ? p.path_len : '—')} -
    - ${rawBlock} - ${decodedBlock} -
    -
    `, p.packet_hash || p.event_type); - } catch (e) { - if (isAbortError(e)) return; - if (e.status === 404) { - shell(html`
    ${t('common.entity_not_found_details', { entity: t('entities.packet').toLowerCase() })}
    `); - return; - } - shell(warningBadge(e.message)); - } -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/packet-group-detail.js b/src/meshcore_hub/web/static/js/spa/pages/packet-group-detail.js deleted file mode 100644 index 2bb76f2..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/packet-group-detail.js +++ /dev/null @@ -1,389 +0,0 @@ -import { apiGet, isAbortError } from '../api.js'; -import { iconSatelliteDish } from '../icons.js'; -import { - html, litRender, nothing, t, - getConfig, formatDateTime, formatRelativeTime, formatNumber, warningBadge, copyToClipboard, - loading, truncateKey -} from '../components.js'; -import { jsonTree } from '../json-tree.js'; - -function field(label, value) { - return html` -
    - ${label} - ${value} -
    `; -} - -// Centre-truncation thresholds for long paths. Counts hops, not characters, -// so variable-length hashes (1–3 bytes) truncate predictably. -const PATH_MAX_BADGES = 16; -const PATH_HEAD = 7; -const PATH_TAIL = 7; - -// Max nodes listed in the path-hash lookup popover before linking out to the -// full (prefix-filtered) Nodes page. -const PATH_POPOVER_NODE_CAP = 8; - -// Render a single path-hash as a badge. Clicking it opens a popover listing the -// node(s) whose public key starts with this hash (see openPathPopover in render). -function pathBadge(hash, onClick) { - return html` onClick(e, hash)}>${hash}`; -} - -const pathArrow = html``; - -// Join badges with arrow separators into a flex-wrap container so long paths -// wrap onto multiple lines (growing in height, not width) on narrow screens. -function pathRow(badges) { - const parts = []; - badges.forEach((b, i) => { - if (i > 0) parts.push(pathArrow); - parts.push(b); - }); - return html`${parts}`; -} - -// Static start-of-path marker: a filled green dot signifying the origin node. -function senderMarker(prefix) { - const title = prefix - ? `${t('packets.col_source')}: ${prefix}` - : t('packets.col_source'); - return html``; -} - -// Static end-of-path marker: a satellite-dish icon signifying the observer. -function observerEndMarker() { - return html`${iconSatelliteDish('h-4 w-4 opacity-70')}`; -} - -// Render the full path as a flow: sender -> [hops] -> observer. The endpoints are -// static markers; the intermediate hops keep their existing hash badges (with the -// same truncation + popover-click behaviour), joined together by pathRow. -function formatPathFlow(pathHashes, pathLen, sourcePrefix, onBadgeClick) { - const badge = (h) => pathBadge(h, onBadgeClick); - let middleParts; - if (pathHashes && pathHashes.length > 0) { - if (pathHashes.length <= PATH_MAX_BADGES) { - middleParts = pathHashes.map(badge); - } else { - const hidden = pathHashes.length - PATH_HEAD - PATH_TAIL; - const ellipsis = html``; - middleParts = [ - ...pathHashes.slice(0, PATH_HEAD).map(badge), - ellipsis, - ...pathHashes.slice(-PATH_TAIL).map(badge), - ]; - } - } else if (pathLen != null) { - middleParts = [html`${pathLen} ${t('common.hops').toLowerCase()}`]; - } else { - middleParts = [html``]; - } - - return pathRow([senderMarker(sourcePrefix), ...middleParts, observerEndMarker()]); -} - -function groupByObserver(receptions) { - const groups = new Map(); - for (const r of receptions) { - const key = r.observed_by || '__unknown__'; - if (!groups.has(key)) groups.set(key, []); - groups.get(key).push(r); - } - return groups; -} - -export async function render(container, params, router) { - const { signal } = params || {}; - const hash = params.hash; - const config = getConfig(); - const tz = config.timezone || ''; - const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing; - - // ── Path-hash → node lookup popover ─────────────────────────────────────── - // Clicking a path badge opens a single floating panel (appended to ) - // that lists the node(s) whose public key starts with that hex prefix. A - // prefix may match zero or more nodes. - let popoverEl = null; - let popoverListeners = null; - - function closePopover() { - if (popoverListeners) { - document.removeEventListener('click', popoverListeners.onDocClick); - document.removeEventListener('keydown', popoverListeners.onKey); - popoverListeners = null; - } - if (popoverEl) { - popoverEl.remove(); - popoverEl = null; - } - } - - function nodeDisplayName(n) { - const tagName = n.tags?.find(tag => tag.key === 'name')?.value; - return tagName || n.name || truncateKey(n.public_key, 12); - } - - function positionPopover(badgeEl) { - if (!popoverEl) return; - const rect = badgeEl.getBoundingClientRect(); - const margin = 8; - const pw = popoverEl.offsetWidth || 256; - const ph = popoverEl.offsetHeight || 0; - let left = Math.min(rect.left, window.innerWidth - pw - margin); - if (left < margin) left = margin; - let top = rect.bottom + 4; - if (top + ph + margin > window.innerHeight && rect.top - ph - 4 > margin) { - top = rect.top - ph - 4; - } - // Anchor to the document (position: absolute) so the popover scrolls with the page. - popoverEl.style.left = `${left + window.scrollX}px`; - popoverEl.style.top = `${top + window.scrollY}px`; - } - - function popoverShell(hashLabel, body) { - return html` -
    - ${t('packets.path_nodes_title', { hash: hashLabel })} - -
    -
    ${body}
    `; - } - - async function openPathPopover(e, ph) { - e.preventDefault(); - e.stopPropagation(); - const badgeEl = e.currentTarget; - closePopover(); - - popoverEl = document.createElement('div'); - popoverEl.className = 'path-node-popover absolute z-[1000] w-64 max-w-[90vw] max-h-[60vh] overflow-y-auto bg-base-100 rounded-box shadow-lg border border-base-300'; - document.body.appendChild(popoverEl); - - litRender(popoverShell(ph, html`
    ${loading()}
    `), popoverEl); - positionPopover(badgeEl); - - const onDocClick = (ev) => { if (popoverEl && !popoverEl.contains(ev.target)) closePopover(); }; - const onKey = (ev) => { if (ev.key === 'Escape') closePopover(); }; - popoverListeners = { onDocClick, onKey }; - // Defer so the click that opened the popover doesn't immediately close it. - setTimeout(() => { - document.addEventListener('click', onDocClick); - document.addEventListener('keydown', onKey); - }, 0); - - try { - const data = await apiGet('/api/v1/nodes', - { pubkey_prefix: ph, sort: 'name', order: 'asc', limit: PATH_POPOVER_NODE_CAP }, - { signal }); - if (!popoverEl) return; // closed while loading - const items = (data.items || []).slice() - .sort((a, b) => nodeDisplayName(a).localeCompare(nodeDisplayName(b))); - const more = (data.total || 0) - items.length; - const body = items.length === 0 - ? html`
    ${t('packets.path_no_nodes')}
    ` - : html``; - litRender(popoverShell(ph, body), popoverEl); - positionPopover(badgeEl); - } catch (err) { - if (isAbortError(err) || !popoverEl) return; - litRender(popoverShell(ph, html`
    ${warningBadge(err.message)}
    `), popoverEl); - positionPopover(badgeEl); - } - } - - // ── Per-observer reception rendering ────────────────────────────────────── - const hopsValue = (r) => (r.path_len != null ? r.path_len : '—'); - const snrValue = (r) => (r.snr != null ? Number(r.snr).toFixed(1) : '—'); - const timeValue = (r) => html`${formatRelativeTime(r.received_at)}`; - - function stat(label, value) { - return html`
    - ${label} - ${value} -
    `; - } - - // Mobile (< lg): one card per reception, path full-width on top, stats below. - function receptionCards(recs, sourcePrefix) { - return html`
    - ${recs.map(r => html` -
    -
    ${formatPathFlow(r.path_hashes, r.path_len, sourcePrefix, openPathPopover)}
    -
    - ${stat(t('common.time'), timeValue(r))} - ${stat(t('common.hops'), hopsValue(r))} - ${stat(t('common.snr_db'), snrValue(r))} -
    -
    `)} -
    `; - } - - // Desktop (lg+): table-fixed so the right-aligned stat columns line up across - // every observer block regardless of path length. - function receptionTable(recs, sourcePrefix) { - return html``; - } - - function shell(content, leaf) { - litRender(html` - -
    -

    ${t('packets.detail_title')}

    - ${tzBadge} -
    -${content}`, container); - } - - shell(html`
    ${t('common.loading')}
    `); - - try { - const [g, channelsData] = await Promise.all([ - apiGet(`/api/v1/packet-groups/${hash}`, {}, { signal }), - apiGet('/api/v1/channels', { limit: 200 }, { signal }).catch(() => ({ items: [] })), - ]); - - const channelNames = new Map( - (channelsData.items || []) - .map(c => [parseInt(c.channel_hash, 16), c.name]) - .filter(([idx]) => !Number.isNaN(idx)) - ); - - let channelDisplay = html``; - if (g.channel_idx != null) { - const name = channelNames.get(g.channel_idx); - channelDisplay = html`${name ? `${name} (${g.channel_idx})` : `${g.channel_idx}`}`; - } - - const redactedNotice = g.redacted - ? html`
    \u{1F512} ${t('packets.redacted_notice')}
    ` - : nothing; - - const rawBlock = (!g.redacted && g.raw_hex) - ? html` -
    -
    - ${t('packets.col_raw')} - -
    -
    ${g.raw_hex}
    -
    ` - : nothing; - - const decodedBlock = (!g.redacted && g.decoded) - ? html` -
    - ${t('packets.decoded')} -
    ${jsonTree(g.decoded, { openDepth: 1 })}
    -
    ` - : nothing; - - const receptions = g.receptions || []; - const sourcePrefix = g.source_pubkey_prefix || null; - const observerGroups = groupByObserver(receptions); - - const receptionsSection = receptions.length > 0 - ? html` -
    -

    - ${t('packets.receptions_title')} - (${formatNumber(g.reception_count)} ${g.reception_count === 1 ? t('packets.reception_singular') : t('packets.reception_plural')}, ${formatNumber(g.observer_count)} ${t('common.observers').toLowerCase()}) -

    - ${[...observerGroups.entries()].map(([_key, recs]) => { - const first = recs[0]; - const displayName = first.observer_tag_name || first.observer_name - || (first.observed_by ? first.observed_by.slice(0, 12) + '…' : '—'); - return html` -
    -
    - \u{1F4E1} - ${first.observed_by - ? html`${displayName}` - : html`${displayName}`} - ${recs.length > 1 - ? html`(${formatNumber(recs.length)} ${t('packets.reception_plural')})` - : nothing} -
    - ${receptionCards(recs, sourcePrefix)} - ${receptionTable(recs, sourcePrefix)} -
    `; - })} -
    ` - : nothing; - - shell(html` -${redactedNotice} -
    -
    -
    - ${field(t('common.time'), formatDateTime(g.first_seen))} - ${field(t('packets.col_event_type'), g.event_type || '—')} - ${field(t('entities.channel'), channelDisplay)} - ${field(t('packets.col_source'), g.source_pubkey_prefix - ? html`${g.source_pubkey_prefix}` - : html``)} - ${field(t('packets.packet_hash'), g.packet_hash - ? html`${g.packet_hash}` - : html``)} - ${field(t('packets.packet_type'), g.packet_type != null ? g.packet_type : '—')} - ${field(t('packets.payload_type'), g.payload_type != null ? g.payload_type : '—')} - ${field(t('packets.col_route_type'), g.route_type || '—')} - ${field(t('packets.receptions_count'), - html`${formatNumber(g.reception_count)} ${g.reception_count === 1 ? t('packets.reception_singular') : t('packets.reception_plural')} · ${formatNumber(g.observer_count)} ${t('common.observers').toLowerCase()}`)} -
    - ${receptionsSection} - ${rawBlock} - ${decodedBlock} -
    -
    `, g.packet_hash || g.event_type); - - } catch (e) { - if (isAbortError(e)) return closePopover; - if (e.status === 404) { - shell(html`
    ${t('packets.not_found_retention')}
    `, t('entities.packet')); - return closePopover; - } - shell(warningBadge(e.message)); - } - - // Tear down any open popover (and its global listeners) on navigation. - return closePopover; -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/packets.js b/src/meshcore_hub/web/static/js/spa/pages/packets.js deleted file mode 100644 index 25ec70b..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/packets.js +++ /dev/null @@ -1,263 +0,0 @@ -import { apiGet, isAbortError } from '../api.js'; -import { - html, litRender, nothing, t, - getConfig, formatDateTime, formatDateTimeShort, formatNumber, - warningBadge, - pagination, sortableTableHeader, mobileSortSelect, - renderFilterForm, renderFilterToggle, autoSubmit, submitOnEnter -} from '../components.js'; -import { createAutoRefresh } from '../auto-refresh.js'; -import { iconSatelliteDish, iconPath, iconRuler } from '../icons.js'; - -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', -]; - -function lockBadge() { - return html`\u{1F512}`; -} - -function channelLabel(packet, channelNames) { - if (packet.channel_idx == null) { - return html``; - } - const name = channelNames.get(packet.channel_idx); - const text = name ? `${name} (${packet.channel_idx})` : `${packet.channel_idx}`; - return html`${text}${packet.redacted ? html` ${lockBadge()}` : nothing}`; -} - -function receptionBadge(packet) { - 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 html` - ${iconSatelliteDish('h-4 w-4 opacity-70')} - ${formatNumber(oc)} - - ${iconPath('h-4 w-4 opacity-70')} - ${formatNumber(rc)} - - ${iconRuler('h-4 w-4 opacity-70')} - ${widthLabel} - `; -} - -export async function render(container, params, router) { - const { signal } = params || {}; - const query = params.query || {}; - const search = query.search || ''; - const event_type = query.event_type || ''; - const channel_idx = query.channel_idx || ''; - const path_hash_bytes = query.path_hash_bytes || ''; - const page = parseInt(query.page, 10) || 1; - const limit = parseInt(query.limit, 10) || 20; - const offset = (page - 1) * limit; - const sort = query.sort || 'time'; - const order = query.order || 'desc'; - - const config = getConfig(); - const tz = config.timezone || ''; - const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing; - const navigate = (url) => router.navigate(url); - - let lastContent = nothing; - let lastTotal = null; - let currentFilterFields = []; - const hasActiveFilters = search !== '' || event_type !== '' || channel_idx !== '' || path_hash_bytes !== ''; - - function onFilterToggle() { renderPage(lastContent, { total: lastTotal }); } - - function renderPage(content, { total = null, error = null } = {}) { - if (!error) { - lastContent = content; - lastTotal = total; - } - const displayContent = error ? lastContent : content; - const displayTotal = error ? lastTotal : total; - const existingToggle = container.querySelector('#filter-toggle'); - const filterOpen = existingToggle ? existingToggle.checked : hasActiveFilters; - litRender(html` -
    -

    ${t('entities.packets')}

    - ${tzBadge} -
    -
    - ${displayTotal !== null - ? html`${t('common.total', { count: formatNumber(displayTotal) })}` - : nothing} - ${error ? warningBadge(error) : nothing} -
    - -
    -
    ${renderFilterToggle({ open: filterOpen, onChange: onFilterToggle })}
    -
    -${(filterOpen && currentFilterFields.length > 0) - ? html`
    ${renderFilterForm({ fields: currentFilterFields, basePath: '/packets', navigate })}
    ` - : nothing} -${displayContent}`, container); - } - - renderPage(nothing); - - async function fetchAndRenderData() { - try { - const apiParams = { limit, offset, search, sort, order }; - if (event_type) apiParams.event_type = event_type; - if (channel_idx !== '') apiParams.channel_idx = channel_idx; - if (path_hash_bytes !== '') apiParams.path_hash_bytes = path_hash_bytes; - - const [data, channelsData] = await Promise.all([ - apiGet('/api/v1/packet-groups', apiParams, { signal }), - apiGet('/api/v1/channels', { limit: 200 }, { signal }).catch(() => ({ items: [] })), - ]); - - const packets = data.items || []; - const total = data.total || 0; - const totalPages = Math.ceil(total / limit); - - const channelList = (channelsData.items || []).map(c => ({ - name: c.name, - idx: parseInt(c.channel_hash, 16), - })).filter(c => !Number.isNaN(c.idx)); - const channelNames = new Map(channelList.map(c => [c.idx, c.name])); - - const noneFound = html`
    ${t('common.no_entity_found', { entity: t('entities.packets').toLowerCase() })}
    `; - - function packetUrl(p) { - 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'; - } - - const mobileCards = packets.length === 0 - ? noneFound - : packets.map(p => html` - -
    -
    -
    -
    ${p.event_type || '—'}
    -
    ${channelLabel(p, channelNames)}
    -
    -
    -
    ${formatDateTimeShort(p.first_seen)}
    -
    ${receptionBadge(p)}
    -
    -
    -
    -
    `); - - const tableRows = packets.length === 0 - ? html`${t('common.no_entity_found', { entity: t('entities.packets').toLowerCase() })}` - : packets.map(p => html` navigate(packetUrl(p))}> - ${formatDateTime(p.first_seen)} - ${p.packet_hash ? html`${p.packet_hash}` : html``} - ${receptionBadge(p)} - ${p.event_type || '—'} - ${channelLabel(p, channelNames)} - `); - - const paginationBlock = pagination(page, totalPages, '/packets', { - search, event_type, channel_idx, path_hash_bytes, limit, sort, order, - }); - - const filterFields = [ - () => html` -
    - - -
    `, - () => html` -
    - - -
    `, - () => html` -
    - - -
    `, - () => html` -
    - - -
    `, - ]; - - const headerParams = { search, event_type, channel_idx, path_hash_bytes, limit }; - const sortable = (label, sortKey) => sortableTableHeader(label, { - sortKey, currentSort: sort, currentOrder: order, - navigate, basePath: '/packets', params: headerParams, - }); - - currentFilterFields = filterFields; - - renderPage(html` - -${mobileSortSelect({ - currentSort: sort, currentOrder: order, - navigate, basePath: '/packets', - params: headerParams, - 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') }, - ], -})} - -
    - ${mobileCards} -
    - - - -${paginationBlock}`, { total }); - - } catch (e) { - if (isAbortError(e)) return; - renderPage(nothing, { error: e.message }); - } - } - - await fetchAndRenderData(); - - const toggleEl = container.querySelector('#auto-refresh-toggle'); - const { cleanup } = createAutoRefresh({ - fetchAndRender: fetchAndRenderData, - toggleContainer: toggleEl, - }); - return cleanup; -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/profile.js b/src/meshcore_hub/web/static/js/spa/pages/profile.js deleted file mode 100644 index 12c4f93..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/profile.js +++ /dev/null @@ -1,194 +0,0 @@ -import { apiGet, apiPut, isAbortError } from '../api.js'; -import { - html, litRender, nothing, - getConfig, t, errorAlert, successAlert, - formatRelativeTime, formatDateTime, -} from '../components.js'; - -function renderAdoptedNode(node) { - 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 html` -
    -
    ${displayName}
    -
    ${node.public_key}
    -
    - -
    `; -} - -function renderMemberSince(profile) { - return profile.created_at - ? html`

    ${t('user_profile.member_since', { date: formatDateTime(profile.created_at, { year: 'numeric', month: 'long', day: 'numeric' }) })}

    ` - : nothing; -} - -function renderRoleBadges(roles) { - if (!roles || roles.length === 0) return nothing; - return html`
    ${roles.map(role => html`${role}`)}
    `; -} - -function hasOperatorOrAdmin(roles, config) { - const roleNames = config.role_names || {}; - const operatorRole = roleNames.operator || 'operator'; - const adminRole = roleNames.admin || 'admin'; - return roles && (roles.includes(operatorRole) || roles.includes(adminRole)); -} - -function renderProfileDetails(profile, config) { - const adoptedSection = hasOperatorOrAdmin(profile.roles, config) - ? html`
    -
    -

    ${t('user_profile.adopted_nodes')}

    - ${profile.nodes && profile.nodes.length > 0 - ? html`
    ${profile.nodes.map(n => renderAdoptedNode(n))}
    ` - : html`

    ${t('user_profile.no_adopted_nodes')}

    `} -
    -
    ` - : nothing; - - return html`${renderMemberSince(profile)}${adoptedSection}`; -} - -function renderPublicProfile(profile, config, target) { - const isOwner = config.user && profile.user_id && config.user.sub === profile.user_id; - - litRender(html` -
    -

    ${t('user_profile.title')}

    - ${isOwner ? html`${t('user_profile.edit_profile')}` : nothing} -
    - -
    -
    -

    - ${profile.name || t('common.unnamed')} - ${profile.callsign ? html`${profile.callsign}` : nothing} -

    - ${renderRoleBadges(profile.roles)} - ${profile.description ? html`

    ${profile.description}

    ` : nothing} - ${profile.url ? html`${profile.url}` : nothing} - ${renderProfileDetails(profile, config)} -
    -
    `, target); -} - -export async function render(container, params, router) { - const { signal } = params || {}; - const config = getConfig(); - - if (params.id) { - try { - const profile = await apiGet(`/api/v1/user/profile/${params.id}`, {}, { signal }); - renderPublicProfile(profile, config, container); - } catch (e) { - if (isAbortError(e)) return; - litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); - } - return; - } - - if (!config.oidc_enabled || !config.user) { - litRender(html` -
    -

    ${t('user_profile.title')}

    -

    ${t('user_profile.login_to_view')}

    - ${t('auth.login')} -
    `, container); - return; - } - - try { - const profile = await apiGet('/api/v1/user/profile/me', {}, { signal }); - const profilePath = `/api/v1/user/profile/${profile.id}`; - - const flashMessage = (params.query && params.query.message) || ''; - const flashError = (params.query && params.query.error) || ''; - const flashHtml = flashMessage ? successAlert(flashMessage) : flashError ? errorAlert(flashError) : nothing; - - litRender(html` -
    -

    ${t('user_profile.title')}

    -
    - -${flashHtml} - -
    - -
    -
    -
    -

    ${t('user_profile.your_profile')}

    - ${renderRoleBadges(profile.roles)} -
    - - - - - -
    - ${renderMemberSince(profile)} -
    -
    -
    - - ${hasOperatorOrAdmin(profile.roles, config) ? html` -
    -
    -

    ${t('user_profile.adopted_nodes')}

    - ${profile.nodes && profile.nodes.length > 0 - ? html`
    ${profile.nodes.map(n => renderAdoptedNode(n))}
    ` - : html`

    ${t('user_profile.no_adopted_nodes')}

    `} -
    -
    ` : nothing} - -
    `, container); - - const ac = new AbortController(); - - container.querySelector('#profile-form').addEventListener('submit', async (e) => { - e.preventDefault(); - const form = e.target; - const body = { - name: form.name.value.trim() || null, - callsign: form.callsign.value.trim() || null, - description: form.description.value.trim() || null, - url: form.url.value.trim() || null, - }; - try { - await apiPut(profilePath, body); - router.navigate('/profile?message=' + encodeURIComponent(t('user_profile.profile_updated')), true); - } catch (err) { - router.navigate('/profile?error=' + encodeURIComponent(err.message), true); - } - }, { signal: ac.signal }); - - return () => ac.abort(); - - } catch (e) { - if (isAbortError(e)) return; - litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); - } -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/routes.js b/src/meshcore_hub/web/static/js/spa/pages/routes.js deleted file mode 100644 index 90e7765..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/routes.js +++ /dev/null @@ -1,876 +0,0 @@ -import { apiGet, apiPost, apiPut, apiDelete, isAbortError } from '../api.js'; -import { html, litRender, nothing, t, errorAlert, getConfig, hasRole } from '../components.js'; -import { iconPath, iconPlus, iconEdit, iconTrash, iconPackets, iconClock, iconRuler, iconNodes, iconSatelliteDish, iconRouteFrom, iconRouteTo, iconHopSpan, iconPathLength } from '../icons.js'; - -const VISIBILITY_ORDER = ['community', 'member', 'operator', 'admin']; - -let _pathSearchTimer = null; -let _pathSearchId = 0; -let _obsSearchTimer = null; -let _obsSearchId = 0; - -function qualityBadgeClass(quality, enabled) { - if (!enabled) return 'badge-neutral'; - const map = { - clear: 'badge-success', - marginal: 'badge-warning', - failing: 'badge-error', - no_coverage: 'badge-info', - unknown: 'badge-info', - }; - return map[quality] || 'badge-ghost'; -} - -function qualityLabel(quality, enabled) { - if (!enabled) return t('routes.disabled'); - const map = { - clear: t('routes.quality_clear'), - marginal: t('routes.quality_marginal'), - failing: t('routes.quality_failing'), - no_coverage: t('routes.quality_no_coverage'), - unknown: t('routes.quality_unknown'), - }; - return map[quality] || quality || t('routes.quality_unknown'); -} - -function qualityDot(quality, enabled) { - if (!enabled) return '\u25CC'; - const dots = { clear: '\u25CF', marginal: '\u25CF', failing: '\u25CF', no_coverage: '\u25D0', unknown: '\u25D0' }; - return dots[quality] || '\u25D0'; -} - -function diagnosisText(route) { - const result = route.route_result; - if (!result || !route.enabled) return ''; - if (result.state === 'healthy') return t('routes.diagnosis_healthy'); - if (result.state === 'unhealthy') return t('routes.diagnosis_unhealthy'); - if (result.state === 'no_coverage') return t('routes.diagnosis_no_coverage'); - return ''; -} - -function renderSummaryStrip(routes) { - const counts = { clear: 0, marginal: 0, failing: 0, no_coverage: 0, disabled: 0 }; - for (const r of routes) { - if (!r.enabled) { counts.disabled++; continue; } - // Prefer the 7-day rolling average so the strip matches the card - // badges (which also display ``quality_avg``). Falls back to the - // latest snapshot for brand-new routes that have no history yet. - const q = r.quality_avg || r.route_result?.quality || 'unknown'; - if (q === 'clear') counts.clear++; - else if (q === 'marginal') counts.marginal++; - else if (q === 'failing') counts.failing++; - else counts.no_coverage++; - } - return html`
    - \u25CF ${counts.clear} ${t('routes.quality_clear')} - \u25CF ${counts.marginal} ${t('routes.quality_marginal')} - \u25CF ${counts.failing} ${t('routes.quality_failing')} - \u25D0 ${counts.no_coverage} ${t('routes.quality_no_coverage')} - \u25CC ${counts.disabled} ${t('routes.disabled')} -
    `; -} - -function renderPathChips(route) { - const nodes = route.route_nodes || []; - const arrow = route.reversible !== false ? '\u2194' : '\u2192'; - const prefixLen = 2 * (route.match_width || 1); - return html`
    - ${nodes.map((rn, i) => html` - ${i > 0 ? html`${arrow}` : nothing} - ${rn.name ? html`${rn.name} (${rn.public_key?.slice(0, prefixLen)})` : (rn.public_key?.slice(0, prefixLen) || rn.node_id.slice(0, 8))} - `)} -
    `; -} - -function renderStatsRow(route) { - const result = route.route_result; - const matched = result?.matched_count ?? '?'; - const threshold = result?.threshold ?? '?'; - const degraded = result?.effective_clear ?? '?'; - const nodeCount = (route.route_nodes || []).length; - const obsCount = (route.route_observers || []).length; - - return html`
    - - ${iconPackets('h-3.5 w-3.5')} - ${matched}/${threshold}\u2192${degraded} - - - ${iconClock('h-3.5 w-3.5')} - ${route.window_hours}h - - - ${iconRuler('h-3.5 w-3.5')} - ${route.match_width}B - - - ${iconNodes('h-3.5 w-3.5')} - ${nodeCount} - - - ${iconHopSpan('h-3.5 w-3.5')} - ${route.max_hop_span || '\u221E'} - - - ${iconPathLength('h-3.5 w-3.5')} - ${route.max_path_length || '\u221E'} - - - ${iconSatelliteDish('h-3.5 w-3.5')} - ${obsCount || '\u221E'} - -
    `; -} - -function renderRouteCard(route, { isAdmin, onDelete, onEdit, detail, navigate, packetsEnabled, history }) { - // Badge reflects the 7-day rolling average (``quality_avg``) rather - // than the latest snapshot, so a flapping route that's currently up - // still shows as marginal/failing if it's been mostly down. Falls - // back to the snapshot for brand-new routes with no history. - const q = route.quality_avg || route.route_result?.quality || 'unknown'; - const badgeCls = qualityBadgeClass(q, route.enabled); - const label = qualityLabel(q, route.enabled); - const dot = qualityDot(q, route.enabled); - const tip = diagnosisText(route); - const badge = tip - ? html`${dot} ${label}` - : html`${dot} ${label}`; - - const adminButtons = isAdmin - ? html`
    - - -
    ` - : nothing; - - const expandContent = detail - ? renderDetailContent(route, detail, { navigate, packetsEnabled, history }) - : html`
    - -
    `; - - return html`
    -
    -
    -
    -

    -
    - ${iconRouteFrom('h-5 w-5')} - ${route.from_label} - ${iconRouteTo('h-5 w-5')} - ${route.to_label} -
    -

    - ${route.description ? html`

    ${route.description}

    ` : nothing} -
    -
    - ${badge} -
    -
    -
    ${renderPathChips(route)}
    - ${renderStatsRow(route)} - ${expandContent} - ${adminButtons} -
    -
    `; -} - -function renderDetailContent(route, detail, { navigate, packetsEnabled, history }) { - const matches = detail.recent_matches || []; - const packetDetailUrl = (packetHash) => - (packetsEnabled && packetHash) ? `/packets/hash/${packetHash}` : null; - - const historySection = history - ? html`
    -
    - -
    - ${history.data && history.data.length > 0 ? html`
    - ${history.data.map((d, i) => html`${i === history.data.length - 1 ? t('routes.last_n_hours', { n: route.window_hours }) : new Date(d.date + 'T00:00:00').toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}`)} -
    ` : nothing} -
    ` - : nothing; - - return html`
    - ${historySection} - ${matches.length > 0 ? html`
    - ${t('routes.recent_packets')} -
    - ${matches.map(m => { - const prefixLen = 2 * (route.match_width || 1); - const pathLookup = new Map( - (route.route_nodes || []).map(rn => - [rn.expected_hash?.toLowerCase(), rn]) - ); - const detailUrl = packetDetailUrl(m.packet_hash); - return html`
    { e.stopPropagation(); navigate(detailUrl); } : undefined}> - ${(() => { - const hops = m.hops || []; - const PATH_MAX = 5; - const PATH_HEAD = 2; - const PATH_TAIL = 2; - let entries; - if (hops.length > PATH_MAX) { - const hidden = hops.length - PATH_HEAD - PATH_TAIL; - const head = hops.slice(0, PATH_HEAD); - const tail = hops.slice(-PATH_TAIL); - const ellipsis = html``; - entries = [ - ...head.map(h => [h, true]), - [ellipsis, false], - ...tail.map(h => [h, true]), - ]; - } else { - entries = hops.map(h => [h, true]); - } - return entries.map(([h, isHop], i) => { - if (!isHop) { - return html` - ${i > 0 ? html`\u2192` : nothing} - ${h} - `; - } - const rn = pathLookup.get((h.node_hash || '').toLowerCase().slice(0, prefixLen)); - return html` - ${i > 0 ? html`\u2192` : nothing} - ${rn - ? html`${(h.node_hash || '').toLowerCase()}` - : html`${(h.node_hash || '').toLowerCase()}`} - `; - }); - })()} - ${m.received_at ? html`${new Date(m.received_at).toLocaleString()}` : nothing} -
    `; - })} -
    -
    ` : nothing} -
    `; -} - -function renderNodeSearchResult(node, onSelect) { - const name = node.name || `${node.public_key.slice(0, 12)}\u2026`; - return html` -
  • - -
  • `; -} - -function renderRouteModal({ modalState, onSave, onCancel, saving }) { - const route = modalState.route; - const isEdit = modalState.isEdit; - const title = isEdit ? t('routes.edit_route') : t('routes.add_route'); - - const pathNodes = modalState.pathNodes; - const observerNodes = modalState.observerNodes; - const pathResults = modalState.pathResults; - const obsResults = modalState.obsResults; - - const selectedPathKeys = new Set(pathNodes.map(n => n.public_key)); - const selectedObsKeys = new Set(observerNodes.map(n => n.public_key)); - const availPathResults = pathResults.filter(n => !selectedPathKeys.has(n.public_key)); - const availObsResults = obsResults.filter(n => !selectedObsKeys.has(n.public_key)); - - return html` - - - `; -} - -function renderDeleteModal({ route, onConfirm, onCancel, saving }) { - const arrow = route.reversible !== false ? '\u2194' : '\u2192'; - const label = `${route.from_label} ${arrow} ${route.to_label}`; - return html` - - - `; -} - -export async function render(container, params, router) { - const { signal } = params || {}; - try { - const config = getConfig(); - const isAdmin = hasRole('admin'); - const navigate = (url) => router.navigate(url); - const packetsEnabled = config.features?.packets !== false; - - const data = await apiGet('/api/v1/routes', {}, { signal }); - const routes = data.items || []; - - let modalState = null; - const detailCache = new Map(); - const historyCache = new Map(); - const chartRegistry = []; - - function destroyCharts() { - chartRegistry.forEach(c => { try { c.destroy(); } catch (_) {} }); - chartRegistry.length = 0; - } - - async function refresh() { - const newData = await apiGet('/api/v1/routes'); - routes.splice(0, routes.length, ...(newData.items || [])); - renderPage(routes); - loadAllDetails(routes); - } - - async function loadAllDetails(routesList) { - const promises = []; - for (const r of routesList) { - if (!detailCache.has(r.id)) { - promises.push( - apiGet(`/api/v1/routes/${r.id}`, {}, { signal }) - .then(d => detailCache.set(r.id, d)) - .catch(() => {}) - ); - } - if (!historyCache.has(r.id)) { - promises.push( - apiGet(`/api/v1/routes/${r.id}/history`, { days: 6 }, { signal }) - .then(h => historyCache.set(r.id, h)) - .catch(() => {}) - ); - } - } - if (promises.length > 0) { - await Promise.allSettled(promises); - renderPage(routes); - } - } - - function renderPage(routesList) { - const adminHeader = isAdmin - ? html`
    - -
    ` - : nothing; - - const emptyMessage = routesList.length === 0 - ? html`
    - ${t('common.no_entity_found', { entity: t('entities.routes').toLowerCase() })} -
    ` - : nothing; - - const groups = new Map(); - for (const vis of VISIBILITY_ORDER) groups.set(vis, []); - for (const r of routesList) { - const vis = r.visibility || 'community'; - if (!groups.has(vis)) groups.set(vis, []); - groups.get(vis).push(r); - } - - const cardOpts = { - isAdmin, - onDelete: handleDeleteClick, - onEdit: handleEditClick, - detail: (r) => detailCache.get(r.id), - navigate, - packetsEnabled, - history: (r) => historyCache.get(r.id), - }; - - const groupedSections = []; - for (const vis of VISIBILITY_ORDER) { - const group = groups.get(vis); - if (!group || group.length === 0) continue; - group.sort((a, b) => { - const cmp = (a.from_label || '').localeCompare(b.from_label || ''); - return cmp !== 0 ? cmp : (a.to_label || '').localeCompare(b.to_label || ''); - }); - groupedSections.push(html` -

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

    -
    - ${group.map(r => renderRouteCard(r, { - ...cardOpts, - detail: cardOpts.detail(r), - history: cardOpts.history(r), - }))} -
    - `); - } - - let modalHtml = nothing; - if (modalState?.type === 'add' || modalState?.type === 'edit') { - modalHtml = renderRouteModal({ - modalState, - onSave: handleSave, - onCancel: () => { modalState = null; renderPage(routesList); }, - saving: !!modalState.saving, - }); - } else if (modalState?.type === 'delete') { - modalHtml = renderDeleteModal({ - route: modalState.route, - onConfirm: handleDeleteConfirm, - onCancel: () => { modalState = null; renderPage(routesList); }, - saving: !!modalState.saving, - }); - } - - destroyCharts(); - - litRender(html` -
    -

    - ${iconPath('h-8 w-8')} - ${t('routes.title')} -

    -
    - ${renderSummaryStrip(routesList)} - ${adminHeader} - ${emptyMessage} - ${groupedSections} - ${modalHtml} - `, container); - - for (const r of routesList) { - if (historyCache.has(r.id)) { - const chart = window.createRouteDetailStrip(`routeStripChart-${r.id}`, historyCache.get(r.id)); - if (chart) chartRegistry.push(chart); - } - } - } - - function _newModalState(type, route) { - return { - type, - route, - isEdit: type === 'edit', - pathNodes: (route.route_nodes || []).map(rn => ({ - public_key: rn.public_key, - name: rn.name, - })), - observerNodes: (route.route_observers || []).map(ro => ({ - public_key: ro.public_key, - name: ro.name, - })), - pathResults: [], - obsResults: [], - handlePathSearch, - handlePathSelect, - handlePathRemove, - handlePathMove, - handlePathKeydown, - handleObsSearch, - handleObsSelect, - handleObsRemove, - handleObsKeydown, - }; - } - - function handleAdd() { - modalState = _newModalState('add', { visibility: 'community', enabled: true, match_width: 1, window_hours: 48, max_hop_span: 8, packet_count_threshold: 5 }); - renderPage(routes); - } - - function handleEditClick(route) { - modalState = _newModalState('edit', route); - renderPage(routes); - } - - function handleDeleteClick(route) { - modalState = { type: 'delete', route }; - renderPage(routes); - } - - function handlePathSearch(query) { - clearTimeout(_pathSearchTimer); - const q = query.trim(); - if (q.length < 2) { - modalState.pathResults = []; - renderPage(routes); - return; - } - _pathSearchTimer = setTimeout(async () => { - const myId = ++_pathSearchId; - try { - const data = await apiGet('/api/v1/nodes', { search: q, limit: 10 }); - if (myId !== _pathSearchId) return; - modalState.pathResults = data.items || []; - renderPage(routes); - } catch (_) { /* ignore */ } - }, 300); - } - - function handlePathSelect(node) { - if (modalState.pathNodes.some(n => n.public_key === node.public_key)) return; - modalState.pathNodes.push({ public_key: node.public_key, name: node.name }); - modalState.pathResults = []; - renderPage(routes); - const el = document.getElementById('route-modal-path-search'); - if (el) el.value = ''; - } - - function handlePathRemove(index) { - modalState.pathNodes.splice(index, 1); - renderPage(routes); - } - - function handlePathMove(index, dir) { - const newIndex = index + dir; - if (newIndex < 0 || newIndex >= modalState.pathNodes.length) return; - const nodes = modalState.pathNodes; - [nodes[index], nodes[newIndex]] = [nodes[newIndex], nodes[index]]; - renderPage(routes); - } - - async function handlePathKeydown(e, availResults) { - if (e.key !== 'Enter') return; - e.preventDefault(); - if (availResults.length === 1) { - handlePathSelect(availResults[0]); - return; - } - if (availResults.length > 1) { - handlePathSelect(availResults[0]); - return; - } - const query = e.target.value.trim(); - if (query.length < 2) return; - clearTimeout(_pathSearchTimer); - const myId = ++_pathSearchId; - try { - const data = await apiGet('/api/v1/nodes', { search: query, limit: 10 }); - if (myId !== _pathSearchId) return; - modalState.pathResults = data.items || []; - renderPage(routes); - const filtered = modalState.pathResults.filter( - n => !modalState.pathNodes.some(pn => pn.public_key === n.public_key) - ); - if (filtered.length >= 1) { - handlePathSelect(filtered[0]); - } - } catch (_) { /* ignore */ } - } - - function handleObsSearch(query) { - clearTimeout(_obsSearchTimer); - const q = query.trim(); - if (q.length < 2) { - modalState.obsResults = []; - renderPage(routes); - return; - } - _obsSearchTimer = setTimeout(async () => { - const myId = ++_obsSearchId; - try { - const data = await apiGet('/api/v1/nodes', { search: q, limit: 10, observer: true }); - if (myId !== _obsSearchId) return; - modalState.obsResults = data.items || []; - renderPage(routes); - } catch (_) { /* ignore */ } - }, 300); - } - - function handleObsSelect(node) { - if (modalState.observerNodes.some(n => n.public_key === node.public_key)) return; - modalState.observerNodes.push({ public_key: node.public_key, name: node.name }); - modalState.obsResults = []; - renderPage(routes); - const el = document.getElementById('route-modal-obs-search'); - if (el) el.value = ''; - } - - function handleObsRemove(index) { - modalState.observerNodes.splice(index, 1); - renderPage(routes); - } - - async function handleObsKeydown(e, availResults) { - if (e.key !== 'Enter') return; - e.preventDefault(); - if (availResults.length >= 1) { - handleObsSelect(availResults[0]); - return; - } - const query = e.target.value.trim(); - if (query.length < 2) return; - clearTimeout(_obsSearchTimer); - const myId = ++_obsSearchId; - try { - const data = await apiGet('/api/v1/nodes', { search: query, limit: 10, observer: true }); - if (myId !== _obsSearchId) return; - modalState.obsResults = data.items || []; - renderPage(routes); - const filtered = modalState.obsResults.filter( - n => !modalState.observerNodes.some(on => on.public_key === n.public_key) - ); - if (filtered.length >= 1) { - handleObsSelect(filtered[0]); - } - } catch (_) { /* ignore */ } - } - - async function handleSave() { - const fromEl = document.getElementById('route-modal-from'); - const toEl = document.getElementById('route-modal-to'); - const descEl = document.getElementById('route-modal-description'); - const visEl = document.getElementById('route-modal-visibility'); - const widthEl = document.getElementById('route-modal-width'); - const windowEl = document.getElementById('route-modal-window'); - const thresholdEl = document.getElementById('route-modal-threshold'); - const clearEl = document.getElementById('route-modal-clear'); - const spanEl = document.getElementById('route-modal-span'); - const pathLengthEl = document.getElementById('route-modal-path-length'); - const enabledEl = document.getElementById('route-modal-enabled'); - const reversibleEl = document.getElementById('route-modal-reversible'); - - const isEdit = modalState.isEdit; - const nodePublicKeys = modalState.pathNodes.map(n => n.public_key); - const observerPublicKeys = modalState.observerNodes.map(n => n.public_key); - - if (nodePublicKeys.length < 2) { - alert(t('routes.min_nodes_error')); - return; - } - - const body = { - from_label: fromEl.value.trim(), - to_label: toEl.value.trim(), - description: descEl.value.trim() || null, - visibility: visEl.value, - match_width: parseInt(widthEl.value, 10) || 1, - window_hours: parseInt(windowEl.value, 10) || 48, - packet_count_threshold: parseInt(thresholdEl.value, 10) || 5, - max_hop_span: spanEl.value ? parseInt(spanEl.value, 10) : null, - max_path_length: pathLengthEl.value ? parseInt(pathLengthEl.value, 10) : null, - enabled: enabledEl.checked, - reversible: reversibleEl.checked, - node_public_keys: nodePublicKeys, - observer_public_keys: observerPublicKeys, - }; - - const clearVal = clearEl.value.trim(); - if (clearVal) { - body.clear_threshold = parseInt(clearVal, 10); - } - - modalState = { ...modalState, saving: true }; - renderPage(routes); - try { - if (isEdit) { - await apiPut(`/api/v1/routes/${modalState.route.id}`, body); - detailCache.delete(modalState.route.id); - historyCache.delete(modalState.route.id); - } else { - await apiPost('/api/v1/routes', body); - } - modalState = null; - await refresh(); - } catch (e) { - modalState = { ...modalState, saving: false }; - renderPage(routes); - alert(e.message || 'Failed to save route'); - } - } - - async function handleDeleteConfirm() { - modalState = { ...modalState, saving: true }; - renderPage(routes); - try { - await apiDelete(`/api/v1/routes/${modalState.route.id}`); - modalState = null; - await refresh(); - } catch (e) { - modalState = { ...modalState, saving: false }; - renderPage(routes); - alert(e.message || 'Failed to delete route'); - } - } - - renderPage(routes); - loadAllDetails(routes); - - return () => { - destroyCharts(); - }; - - } catch (e) { - if (isAbortError(e)) return; - litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); - } -} diff --git a/src/meshcore_hub/web/static/js/spa/router.js b/src/meshcore_hub/web/static/js/spa/router.js deleted file mode 100644 index f58b668..0000000 --- a/src/meshcore_hub/web/static/js/spa/router.js +++ /dev/null @@ -1,189 +0,0 @@ -/** - * MeshCore Hub SPA - Client-Side Router - * - * Simple History API based router with parameterized routes. - */ - -export class Router { - constructor() { - this._routes = []; - this._notFoundHandler = null; - this._currentCleanup = null; - this._onNavigate = null; - this._navAbort = null; - this._navGen = 0; - } - - /** - * Register a route. - * @param {string} path - URL pattern (e.g., '/nodes/:publicKey') - * @param {Function} handler - async function(params) where params includes route params and query - */ - addRoute(path, handler) { - const paramNames = []; - const regexStr = path - .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex chars - .replace(/:([a-zA-Z_]+)/g, (_, name) => { - paramNames.push(name); - return '([^/]+)'; - }); - this._routes.push({ - pattern: new RegExp('^' + regexStr + '$'), - paramNames, - handler, - path, - }); - } - - /** - * Set the 404 handler. - * @param {Function} handler - async function(params) - */ - setNotFound(handler) { - this._notFoundHandler = handler; - } - - /** - * Set a callback to run on every navigation (for updating navbar, etc.) - * @param {Function} fn - function(pathname) - */ - onNavigate(fn) { - this._onNavigate = fn; - } - - /** - * Navigate to a URL. - * @param {string} url - URL path with optional query string - * @param {boolean} [replace=false] - Use replaceState instead of pushState - */ - navigate(url, replace = false) { - // Skip if already on this exact URL - const current = window.location.pathname + window.location.search; - if (url === current && !replace) return; - - if (replace) { - history.replaceState(null, '', url); - } else { - history.pushState(null, '', url); - } - this._handleRoute(); - } - - /** - * Match a pathname against registered routes. - * @param {string} pathname - * @returns {{ handler: Function, params: Object } | null} - */ - _match(pathname) { - for (const route of this._routes) { - const match = pathname.match(route.pattern); - if (match) { - const params = {}; - route.paramNames.forEach((name, i) => { - params[name] = decodeURIComponent(match[i + 1]); - }); - return { handler: route.handler, params }; - } - } - return null; - } - - /** - * Handle the current URL. - */ - async _handleRoute() { - // Cancel any in-flight requests from the page we're leaving so they - // don't hold connections / server resources behind the new page. - if (this._navAbort) { - this._navAbort.abort(); - } - this._navAbort = new AbortController(); - const signal = this._navAbort.signal; - - // Track this navigation so a stale (superseded) route can't toggle the - // shared loading indicator for the navigation that replaced it. - const navGen = ++this._navGen; - - // Clean up previous page - if (this._currentCleanup) { - try { this._currentCleanup(); } catch (e) { /* ignore */ } - this._currentCleanup = null; - } - - const pathname = window.location.pathname; - const sp = new URLSearchParams(window.location.search); - const query = {}; - for (const [k, v] of sp.entries()) { - if (k in query) { - query[k] = Array.isArray(query[k]) ? [...query[k], v] : [query[k], v]; - } else { - query[k] = v; - } - } - - // Notify navigation listener - if (this._onNavigate) { - this._onNavigate(pathname); - } - - // Show navbar loading indicator - const loader = document.getElementById('nav-loading'); - if (loader) loader.classList.remove('hidden'); - - try { - const result = this._match(pathname); - if (result) { - const cleanup = await result.handler({ ...result.params, query, signal }); - if (typeof cleanup === 'function') { - this._currentCleanup = cleanup; - } - } else if (this._notFoundHandler) { - await this._notFoundHandler({ query, signal }); - } - } finally { - // Only hide the loader if a newer navigation hasn't started. - if (loader && navGen === this._navGen) loader.classList.add('hidden'); - } - - // Reset focus to dismiss any open dropdown after navigation - document.activeElement?.blur(); - - // Scroll to top on navigation - window.scrollTo(0, 0); - } - - /** - * Start the router - listen for events and handle initial route. - */ - start() { - // Handle browser back/forward - window.addEventListener('popstate', () => this._handleRoute()); - - // Intercept link clicks for SPA navigation - document.addEventListener('click', (e) => { - const link = e.target.closest('a[href]'); - if (!link) return; - - const href = link.getAttribute('href'); - - // Skip external links, anchors, downloads, new tabs - if (!href || !href.startsWith('/') || href.startsWith('//')) return; - if (link.hasAttribute('download') || link.target === '_blank') return; - - // Skip non-SPA paths (static files, API, media, OAuth, SEO) - if (href.startsWith('/static/') || href.startsWith('/media/') || - href.startsWith('/api/') || href.startsWith('/auth/') || - href.startsWith('/health') || href === '/robots.txt' || - href === '/sitemap.xml') return; - - // Skip mailto and tel links - if (href.startsWith('mailto:') || href.startsWith('tel:')) return; - - e.preventDefault(); - this.navigate(href); - }); - - // Handle initial route - this._handleRoute(); - } -} diff --git a/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html index bc31291..496612e 100644 --- a/src/meshcore_hub/web/templates/spa.html +++ b/src/meshcore_hub/web/templates/spa.html @@ -207,11 +207,9 @@ })(); - + {% if asset_app_js %} - {% else %} - {% endif %} diff --git a/tests/test_web/test_advertisements.py b/tests/test_web/test_advertisements.py index b8ac12f..70ac438 100644 --- a/tests/test_web/test_advertisements.py +++ b/tests/test_web/test_advertisements.py @@ -28,12 +28,10 @@ class TestAdvertisementsPage: response = client.get("/advertisements") assert "window.__APP_CONFIG__" in response.text - def test_advertisements_contains_spa_script(self, client: TestClient) -> None: - """Test that advertisements page includes SPA application script.""" + def test_advertisements_contains_spa_mount(self, client: TestClient) -> None: + """Test that advertisements page renders the React SPA mount point.""" response = client.get("/advertisements") - has_bundled = "/static/dist/" in response.text - has_fallback = "/static/js/spa/app.js" in response.text - assert has_bundled or has_fallback + assert 'id="app"' in response.text class TestAdvertisementsPageFilters: diff --git a/tests/test_web/test_caching.py b/tests/test_web/test_caching.py index 280d789..7723709 100644 --- a/tests/test_web/test_caching.py +++ b/tests/test_web/test_caching.py @@ -18,18 +18,26 @@ class TestCacheControlHeaders: ) def test_static_js_with_version(self, client): - """Static JS with version parameter should have long-term cache.""" - response = client.get(f"/static/js/spa/app.js?v={__version__}") - assert response.status_code == 200 + """Static JS with version parameter should have long-term cache. + + Only the header is asserted (not status): JS source is bundled into + static/dist/ and absent from host checkouts, and the middleware sets + headers on 404 responses too. + """ + response = client.get(f"/static/js/app.js?v={__version__}") assert "cache-control" in response.headers assert ( response.headers["cache-control"] == "public, max-age=31536000, immutable" ) def test_static_module_with_version(self, client): - """Static ES module with version parameter should have long-term cache.""" - response = client.get(f"/static/js/spa/app.js?v={__version__}") - assert response.status_code == 200 + """Bundled ES modules in static/dist/ use content-hashed immutable cache. + + Only the header is asserted (not status): static/dist/ is a build + artifact absent from host checkouts, and the middleware sets headers on + 404 responses too. + """ + response = client.get("/static/dist/assets/app.js") assert "cache-control" in response.headers assert ( response.headers["cache-control"] == "public, max-age=31536000, immutable" @@ -58,9 +66,11 @@ class TestCacheControlHeaders: assert response.headers["cache-control"] == "public, max-age=3600" def test_static_js_without_version(self, client): - """Static JS without version should have short fallback cache.""" - response = client.get("/static/js/spa/app.js") - assert response.status_code == 200 + """Static JS without version should have short fallback cache. + + Only the header is asserted (not status): see test_static_js_with_version. + """ + response = client.get("/static/js/app.js") assert "cache-control" in response.headers assert response.headers["cache-control"] == "public, max-age=3600" @@ -153,7 +163,11 @@ class TestVersionParameterInHTML: assert f"?v={__version__}" in css_link["href"] def test_app_js_has_version(self, client): - """SPA app.js script should include version or content hash.""" + """SPA bundle script should be served content-hashed from static/dist/. + + The bundle only exists after a frontend build; without one there is no + script tag, so the dist/ origin is only asserted when present. + """ response = client.get("/") assert response.status_code == 200 @@ -162,15 +176,9 @@ class TestVersionParameterInHTML: "script", {"src": lambda x: x and "/static/dist/" in x and x.endswith(".js")}, ) - fallback_script = soup.find( - "script", {"src": lambda x: x and "/static/js/spa/app.js" in x} - ) if bundled_script: assert "/static/dist/" in bundled_script["src"] - else: - assert fallback_script is not None - assert f"?v={__version__}" in fallback_script["src"] def test_cdn_resources_unchanged(self, client): """CDN resources should not have version parameters.""" diff --git a/tests/test_web/test_home.py b/tests/test_web/test_home.py index 676c319..c309d91 100644 --- a/tests/test_web/test_home.py +++ b/tests/test_web/test_home.py @@ -83,9 +83,7 @@ class TestHomePage: assert 'href="/nodes"' in response.text assert 'href="/messages"' in response.text - def test_home_contains_spa_app_script(self, client: TestClient) -> None: - """Test that home page includes the SPA application script.""" + def test_home_contains_spa_mount(self, client: TestClient) -> None: + """Test that home page renders the React SPA mount point.""" response = client.get("/") - has_bundled = "/static/dist/" in response.text - has_fallback = "/static/js/spa/app.js" in response.text - assert has_bundled or has_fallback + assert 'id="app"' in response.text diff --git a/tests/test_web/test_messages.py b/tests/test_web/test_messages.py index 008f7f3..4352d68 100644 --- a/tests/test_web/test_messages.py +++ b/tests/test_web/test_messages.py @@ -28,12 +28,10 @@ class TestMessagesPage: response = client.get("/messages") assert "window.__APP_CONFIG__" in response.text - def test_messages_contains_spa_script(self, client: TestClient) -> None: - """Test that messages page includes SPA application script.""" + def test_messages_contains_spa_mount(self, client: TestClient) -> None: + """Test that messages page renders the React SPA mount point.""" response = client.get("/messages") - has_bundled = "/static/dist/" in response.text - has_fallback = "/static/js/spa/app.js" in response.text - assert has_bundled or has_fallback + assert 'id="app"' in response.text class TestMessagesPageFilters: diff --git a/tests/test_web/test_nodes.py b/tests/test_web/test_nodes.py index 1577d23..ec8dd5f 100644 --- a/tests/test_web/test_nodes.py +++ b/tests/test_web/test_nodes.py @@ -28,12 +28,10 @@ class TestNodesListPage: response = client.get("/nodes") assert "window.__APP_CONFIG__" in response.text - def test_nodes_contains_spa_script(self, client: TestClient) -> None: - """Test that nodes page includes SPA application script.""" + def test_nodes_contains_spa_mount(self, client: TestClient) -> None: + """Test that nodes page renders the React SPA mount point.""" response = client.get("/nodes") - has_bundled = "/static/dist/" in response.text - has_fallback = "/static/js/spa/app.js" in response.text - assert has_bundled or has_fallback + assert 'id="app"' in response.text def test_nodes_with_search_param(self, client: TestClient) -> None: """Test nodes page with search parameter returns SPA shell.""" diff --git a/tsconfig.json b/tsconfig.json index de5256f..32770a1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,8 +15,7 @@ "allowJs": true, "baseUrl": ".", "paths": { - "@/*": ["src/meshcore_hub/web/static/js/spa-react/*"], - "@legacy/*": ["src/meshcore_hub/web/static/js/spa/*"] + "@/*": ["src/meshcore_hub/web/static/js/spa-react/*"] } }, "include": ["src/meshcore_hub/web/static/js/spa-react"] diff --git a/vite.config.ts b/vite.config.ts index 1486c80..974ad22 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,10 +6,6 @@ const SPA_REACT = resolve( __dirname, "src/meshcore_hub/web/static/js/spa-react", ); -const SPA_LEGACY = resolve( - __dirname, - "src/meshcore_hub/web/static/js/spa", -); const DIST = resolve(__dirname, "src/meshcore_hub/web/static/dist"); export default defineConfig({ @@ -18,7 +14,6 @@ export default defineConfig({ resolve: { alias: { "@": SPA_REACT, - "@legacy": SPA_LEGACY, }, }, build: { From 527c860bf81dbcca7b4dd8bbf6be37f28d60e9e6 Mon Sep 17 00:00:00 2001 From: Louis King Date: Tue, 21 Jul 2026 20:06:52 +0100 Subject: [PATCH 05/11] =?UTF-8?q?feat(web):=20frontend=20CI,=20vitest=20su?= =?UTF-8?q?ite,=20navbar=E2=86=92React=20SPA=20shell=20=E2=80=94=20Phase?= =?UTF-8?q?=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend CI (closes the no-coverage gap for the TSX): - ci.yml: new 'frontend' job — npm ci, tsc --noEmit, test:frontend, build. - package.json: engines.node>=20, test:frontend/typecheck scripts. vitest unit + component tests (jsdom, @testing-library/react): - utils/charts.test.ts — tier math + every chart builder. - utils/format.test.ts — parseAppDate, formatNumber, truncateKey, emoji helpers, formatRelativeTime. - components/Navbar.test.tsx — feature-gated nav, custom pages, OIDC/maintenance auth gating. - components/Announcements.test.tsx — banner rendering, ordering, dismiss + sessionStorage (covers behaviour moved out of the Python suite). Navbar → React (full SPA shell): - New Navbar/ThemeToggle/Announcements components + useNavItems hook (shared feature-gated nav for desktop + mobile); nav uses react-router NavLink (client-side nav + auto active) — drops the imperative data-nav-link bridge. - main.tsx single root; App.tsx renders Navbar + Announcements above routed
    . - spa.html slimmed to SEO/config/footer shell (Jinja2 navbar/banners/theme script removed; #app is a plain div React fills). - Backend: _build_config_json exposes system_announcement/network_announcement. Tests (server-rendered nav/banner assertions → config): - conftest.py: get_app_config() helper (robust __APP_CONFIG__ extraction). - test_features/app/home/pages/dashboard rewritten to assert __APP_CONFIG__; dashboard client-rendered-stats tests replaced with a shell assertion. Docs: REACT_MIGRATION.md Phase 5 (incl. deliberately-skipped react-query/ Storybook/Playwright), AGENTS.md Frontend section. Verified: tsc clean, npm run build, vitest (49 passed), pytest tests/test_web (251 passed), full suite (1459 passed), pre-commit (passed). --- .github/workflows/ci.yml | 25 + AGENTS.md | 18 +- REACT_MIGRATION.md | 40 +- package-lock.json | 1128 ++++++++++++++++- package.json | 14 +- src/meshcore_hub/web/app.py | 2 + .../web/static/js/spa-react/App.tsx | 31 +- .../components/Announcements.test.tsx | 86 ++ .../js/spa-react/components/Announcements.tsx | 62 + .../js/spa-react/components/MobileNav.tsx | 60 +- .../js/spa-react/components/Navbar.test.tsx | 119 ++ .../static/js/spa-react/components/Navbar.tsx | 69 + .../js/spa-react/components/ThemeToggle.tsx | 41 + .../static/js/spa-react/hooks/useNavItems.tsx | 107 ++ .../web/static/js/spa-react/main.tsx | 22 +- .../static/js/spa-react/test/makeConfig.ts | 28 + .../web/static/js/spa-react/test/setup.ts | 7 + .../web/static/js/spa-react/types/config.ts | 2 + .../static/js/spa-react/utils/charts.test.ts | 255 ++++ .../static/js/spa-react/utils/format.test.ts | 138 ++ src/meshcore_hub/web/templates/spa.html | 106 +- tests/test_web/conftest.py | 19 +- tests/test_web/test_app.py | 122 +- tests/test_web/test_dashboard.py | 24 +- tests/test_web/test_features.py | 117 +- tests/test_web/test_home.py | 7 - tests/test_web/test_pages.py | 28 +- vitest.config.ts | 22 + 28 files changed, 2305 insertions(+), 394 deletions(-) create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Announcements.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Announcements.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Navbar.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Navbar.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/hooks/useNavItems.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/test/makeConfig.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/test/setup.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/charts.test.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18d3eb8..260d7ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,31 @@ jobs: - name: Run pre-commit uses: pre-commit/action@v3.0.1 + frontend: + name: Frontend + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + + - name: Set up Node + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npx tsc --noEmit + + - name: Unit tests + run: npm run test:frontend + + - name: Build + run: npm run build + test: name: Test runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index cd2bdd6..5502435 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,16 +44,18 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core ex The web UI is a **React 19 + TypeScript + Vite** SPA in `src/meshcore_hub/web/static/js/spa-react/` (alias `@/` → that dir). The Jinja2 shell -(`web/templates/spa.html`) renders the navbar/SEO/`window.__APP_CONFIG__`; React mounts into -`
    `. **Frontend tooling runs on the host** (not in Docker): `npm install`, -`npm run build` (Tailwind → vendor fonts → `vite build` → `static/dist/` + `assets.json`), and -`npx tsc --noEmit` (the TS gate — there is no JS linter in pre-commit). The Vite build is -required to serve the UI; there is no fallback bundle. +(`web/templates/spa.html`) renders only SEO/`window.__APP_CONFIG__`/footer; React renders the +navbar, banners, and routed pages into `
    `. **Frontend tooling runs on the host** +(not in Docker): `npm install`, `npm run build` (Tailwind → vendor fonts → `vite build` → +`static/dist/` + `assets.json`), `npx tsc --noEmit` (the TS gate — there is no JS linter in +pre-commit), and `npm run test:frontend` (vitest). The Vite build is required to serve the UI; +there is no fallback bundle. ```bash npm install # host: install frontend deps npm run build # host: produce static/dist/ + assets.json npx tsc --noEmit # host: typecheck (must be clean) +npm run test:frontend # host: vitest unit + component tests ``` - Charts: **react-chartjs-2** — typed config builders in `utils/charts.ts`, wrappers in @@ -62,9 +64,15 @@ npx tsc --noEmit # host: typecheck (must be clean) That CSS ships in the Vite bundle, which `spa.html` loads in `` **before** `app.css` so the dark-mode map overrides win — don't reorder those ``s. - QR codes: **react-qr-code**. +- Navbar/shell: React (`components/Navbar.tsx`, `ThemeToggle.tsx`, `Announcements.tsx`, + `hooks/useNavItems.tsx`); nav uses react-router `NavLink` (client-side nav). Feature flags, + custom pages, and announcements all come from `window.__APP_CONFIG__`. - Page conventions: `useSearchParams()` for filters/pagination/sort, typed `apiGet()` with an `AbortController` in `useEffect`, `usePageTitle('entities.x')`, shared components (`Pagination`, `FilterForm`, `StatCard`, `NodeDisplay`, etc.). +- Tests: **vitest** + `@testing-library/react` (`*.test.ts(x)` next to code; setup in + `spa-react/test/`). Python web tests assert the embedded `__APP_CONFIG__` + (`tests/test_web/conftest.py::get_app_config`), not server-rendered nav HTML. - Only **fonts** are vendored (`build.js` copies them); chart/map/QR libs are bundled by Vite. ## Tests & Quality diff --git a/REACT_MIGRATION.md b/REACT_MIGRATION.md index 3eecf59..a4bf4be 100644 --- a/REACT_MIGRATION.md +++ b/REACT_MIGRATION.md @@ -10,7 +10,7 @@ Migration from lit-html (functional templates) to React 19 + TypeScript + Vite. | 2 | Convert pages one-by-one from LitBridge to native React | **Complete** | | 3 | Chart & map components (react-chartjs-2, react-leaflet) | **Complete** | | 4 | Cleanup (remove lit-html, old spa/, LitBridge, @legacy alias) | **Complete** | -| 5 | Optional enhancements (tests, react-query, Storybook) | Not started | +| 5 | Frontend CI + vitest unit/component tests + navbar → React (SPA shell) | **Complete** | > **Phase 3 status:** All `window.Chart` / `window.L` / `window.QRCode` globals and the > `charts.js` helper script are gone. Charts now use **react-chartjs-2** (typed builders in @@ -199,13 +199,39 @@ Python (`app.py`) loads this manifest at startup and passes `asset_app_js` / `as - (Vendor script tags / `charts.js` / `build.js` vendor copy were already removed in Phase 3.) - Updated `AGENTS.md` with the React frontend conventions. -## Phase 5: Optional Enhancements +## Phase 5: Frontend CI, Tests & SPA Shell — Complete -- Add `vitest` + `@testing-library/react` for component tests -- Add Playwright for E2E browser tests -- Consider `@tanstack/react-query` for data fetching -- Consider moving navbar from Jinja2 to React (full SPA shell) -- Add Storybook for component development +- **Frontend CI job** (`.github/workflows/ci.yml`): `npm ci` → `tsc --noEmit` → + `npm run test:frontend` → `npm run build` on every push/PR. Closes the gap where the + ~9k lines of TSX had no CI coverage (pre-commit is Python-only). +- **vitest** (`vitest.config.ts`, jsdom env, `npm run test:frontend`): + - `utils/charts.test.ts` — tier math (`routeQualityToTier`, `averageRouteTier`) and every + chart builder (empty → null, dataset counts/labels/colors, stacked %, route-strip segments). + - `utils/format.test.ts` — `parseAppDate`, `formatNumber`, `truncateKey`, `typeEmoji`, + `extractFirstEmoji`, `getNodeEmoji`, `formatRelativeTime`. + - `components/Navbar.test.tsx` — feature-gated nav links, custom pages, OIDC/maintenance + auth gating (rendered with `MemoryRouter` + `AppConfigProvider`). + - `components/Announcements.test.tsx` — system/network banner rendering, ordering, dismiss + + sessionStorage persistence (covers behaviour that moved out of the Python suite). +- **Navbar → React (full SPA shell)**: + - New `components/Navbar.tsx`, `components/ThemeToggle.tsx`, `components/Announcements.tsx`, + and `hooks/useNavItems.tsx` (shared feature-gated nav list used by desktop + mobile). + - `main.tsx` now renders a single root; `App.tsx` renders `` + `` + above the routed `
    `. Nav uses react-router `NavLink` (client-side nav + auto active + class) — the imperative `data-nav-link` active-toggle and `#nav-loading` DOM bridge are gone. + - `spa.html` slimmed to a thin shell: the Jinja2 navbar, banners, and vanilla theme-toggle + script were removed; `
    ` became a plain `
    ` that React fills. + SEO ``, footer, and the early theme-init script stay server-rendered. + - Backend: `_build_config_json` now exposes `system_announcement` / `network_announcement` + (pre-rendered Markdown) for the React banners. + - Python tests that asserted the server-rendered navbar/banners were rewritten to assert the + embedded `__APP_CONFIG__` (new `get_app_config()` helper in `tests/test_web/conftest.py`); + the flag→render path is now covered by the Navbar component test. + +**Deliberately not done** (low ROI / high risk for this codebase): `@tanstack/react-query` +(conflicts with the deliberate `private, no-cache` + server-side Redis invalidation design and +is a 15-page refactor), Storybook (single-app component set), and Playwright E2E (needs the full +stack in CI; revisit if real browser coverage is wanted). ## Running & Testing diff --git a/package-lock.json b/package-lock.json index 7fe1b30..c5a9d8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,14 +23,77 @@ "tailwindcss": "^4" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", "@types/leaflet": "^1.9.17", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^4", + "jsdom": "^29.1.1", "typescript": "^5.8", - "vite": "^6" + "vite": "^6", + "vitest": "^4.1.10" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -322,6 +385,177 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fontsource-variable/ibm-plex-sans": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource-variable/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", @@ -1123,6 +1357,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/cli": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz", @@ -1456,6 +1697,91 @@ "node": ">= 20" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1501,6 +1827,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1566,6 +1910,162 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.44", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz", @@ -1579,6 +2079,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1646,6 +2156,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chart.js": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", @@ -1678,6 +2198,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1694,6 +2235,20 @@ "url": "https://github.com/saadeghi/daisyui?sponsor=1" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1712,6 +2267,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1721,6 +2293,13 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.394", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.394.tgz", @@ -1741,6 +2320,26 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1751,6 +2350,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1812,6 +2431,19 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -1861,6 +2493,16 @@ "@babel/runtime": "^7.23.2" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1891,6 +2533,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1906,6 +2555,57 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2209,6 +2909,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2218,6 +2928,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -2243,6 +2960,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -2303,6 +3030,40 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2351,6 +3112,28 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -2362,6 +3145,16 @@ "react-is": "^16.13.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode-generator": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", @@ -2490,6 +3283,30 @@ } } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -2535,6 +3352,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2557,6 +3387,13 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2566,6 +3403,40 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -2585,6 +3456,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2602,6 +3490,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2614,6 +3532,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2628,6 +3572,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3218,6 +4172,96 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -3227,6 +4271,88 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index d04bd44..725f724 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,27 @@ { "private": true, "type": "module", + "engines": { + "node": ">=20" + }, "scripts": { "build": "node build.js", - "dev": "vite --config vite.config.ts" + "dev": "vite --config vite.config.ts", + "test:frontend": "vitest run", + "typecheck": "tsc --noEmit" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", "@types/leaflet": "^1.9.17", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^4", + "jsdom": "^29.1.1", "typescript": "^5.8", - "vite": "^6" + "vite": "^6", + "vitest": "^4.1.10" }, "dependencies": { "@fontsource-variable/ibm-plex-sans": "^5", diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index a48d38d..01c6104 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -361,6 +361,8 @@ def _build_config_json(app: FastAPI, request: Request) -> str: "locale_version": getattr(app.state, "locale_version", ""), "system_maintenance": app.state.system_maintenance, "spam_score_threshold": app.state.spam_score_threshold, + "system_announcement": app.state.system_announcement, + "network_announcement": app.state.network_announcement, } role_names = { diff --git a/src/meshcore_hub/web/static/js/spa-react/App.tsx b/src/meshcore_hub/web/static/js/spa-react/App.tsx index 52f70ad..d45e67a 100644 --- a/src/meshcore_hub/web/static/js/spa-react/App.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/App.tsx @@ -9,6 +9,8 @@ import { } from "react-router"; import { useAppConfig } from "@/context/AppConfigContext"; import { ErrorBoundary } from "@/components/ErrorBoundary"; +import { Navbar } from "@/components/Navbar"; +import { Announcements } from "@/components/Announcements"; import { HomePage } from "@/pages/Home"; import { DashboardPage } from "@/pages/Dashboard"; import { Nodes } from "@/pages/Nodes"; @@ -33,21 +35,6 @@ function useNavActiveState() { useEffect(() => { const pathname = location.pathname; - document.querySelectorAll("[data-nav-link]").forEach((link) => { - const href = link.getAttribute("href"); - let isActive = false; - if (href === "/") { - isActive = pathname === "/"; - } else if (href === "/nodes") { - isActive = pathname.startsWith("/nodes"); - } else if (href) { - isActive = pathname === href || pathname.startsWith(href + "/"); - } - link.classList.toggle("active", isActive); - }); - - const loader = document.getElementById("nav-loading"); - if (loader) loader.classList.add("hidden"); if (document.activeElement?.closest(".dropdown")) { (document.activeElement as HTMLElement).blur(); @@ -266,10 +253,22 @@ function AppRoutes() { ); } +function Shell() { + return ( + <> + + +
    + +
    + + ); +} + export function App() { return ( - + ); } diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Announcements.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Announcements.test.tsx new file mode 100644 index 0000000..54544eb --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Announcements.test.tsx @@ -0,0 +1,86 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { AppConfigProvider } from "@/context/AppConfigContext"; +import { Announcements } from "@/components/Announcements"; +import { makeConfig } from "@/test/makeConfig"; +import type { AppConfig } from "@/types/config"; + +function renderAnnouncements(config: AppConfig) { + return render( + + + , + ); +} + +beforeEach(() => { + sessionStorage.clear(); +}); + +describe("Announcements", () => { + it("renders nothing when there are no announcements", () => { + const { container } = renderAnnouncements(makeConfig()); + expect(container.firstChild).toBeNull(); + }); + + it("renders the system banner content as HTML", () => { + const { container } = renderAnnouncements( + makeConfig({ system_announcement: "Outage at 22:00" }), + ); + expect(container.querySelector("#system-banner")).not.toBeNull(); + expect(screen.getByText("Outage").tagName).toBe("STRONG"); + }); + + it("renders the network banner with a dismiss button", () => { + const { container } = renderAnnouncements( + makeConfig({ network_announcement: "

    Notice

    " }), + ); + expect(container.querySelector("#flash-banner")).not.toBeNull(); + expect(screen.getByLabelText("Dismiss")).toBeInTheDocument(); + }); + + it("does not render a dismiss control on the system banner", () => { + const { container } = renderAnnouncements( + makeConfig({ system_announcement: "Heads up" }), + ); + const banner = container.querySelector("#system-banner"); + expect(banner).not.toBeNull(); + expect(banner!.querySelector("button")).toBeNull(); + }); + + it("renders the system banner above the network banner", () => { + const { container } = renderAnnouncements( + makeConfig({ + system_announcement: "System notice", + network_announcement: "Network notice", + }), + ); + const system = container.querySelector("#system-banner"); + const network = container.querySelector("#flash-banner"); + expect(system).not.toBeNull(); + expect(network).not.toBeNull(); + // network follows system in document order + expect( + system!.compareDocumentPosition(network!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("dismisses the network banner and persists to sessionStorage", () => { + const { container } = renderAnnouncements( + makeConfig({ network_announcement: "Notice" }), + ); + fireEvent.click(screen.getByLabelText("Dismiss")); + expect(container.querySelector("#flash-banner")).toBeNull(); + expect(sessionStorage.getItem("flash-banner-dismissed")).toBe("1"); + }); + + it("does not render a previously dismissed network banner", () => { + sessionStorage.setItem("flash-banner-dismissed", "1"); + const { container } = renderAnnouncements( + makeConfig({ network_announcement: "Notice" }), + ); + expect(container.querySelector("#flash-banner")).toBeNull(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Announcements.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Announcements.tsx new file mode 100644 index 0000000..55437f8 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Announcements.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; + +import { useAppConfig } from "@/context/AppConfigContext"; + +export function Announcements() { + const config = useAppConfig(); + const [dismissed, setDismissed] = useState(() => { + try { + return sessionStorage.getItem("flash-banner-dismissed") === "1"; + } catch { + return false; + } + }); + + const system = config.system_announcement; + const network = config.network_announcement; + + const dismiss = () => { + setDismissed(true); + try { + sessionStorage.setItem("flash-banner-dismissed", "1"); + } catch { + // ignore + } + }; + + if (!system && (!network || dismissed)) return null; + + return ( + <> + {system && ( +
    +
    +
    + )} + {network && !dismissed && ( +
    +
    + +
    + )} + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx b/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx index be85312..f9a5c48 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx @@ -1,61 +1,21 @@ -import { useTranslation } from "react-i18next"; -import { useAppConfig } from "@/context/AppConfigContext"; -import { - IconHome, - IconDashboard, - IconNodes, - IconAdvertisements, - IconMessages, - IconPackets, - IconMap, - IconMembers, - IconPage, - IconChannel, - IconPath, -} from "@/components/icons"; +import { NavLink } from "react-router"; + +import { useNavItems } from "@/hooks/useNavItems"; export function MobileNav() { - const { t } = useTranslation(); - const config = useAppConfig(); - const features = config.features ?? {}; - const customPages = config.custom_pages ?? []; - - const items: { href: string; icon: React.ReactNode; label: string }[] = [ - { href: "/", icon: , label: t("entities.home") }, - ]; - - if (features.dashboard !== false) - items.push({ href: "/dashboard", icon: , label: t("entities.dashboard") }); - if (features.nodes !== false) - items.push({ href: "/nodes", icon: , label: t("entities.nodes") }); - if (features.advertisements !== false) - items.push({ href: "/advertisements", icon: , label: t("entities.advertisements") }); - if (features.routes !== false) - items.push({ href: "/routes", icon: , label: t("entities.routes") }); - if (features.channels !== false) - items.push({ href: "/channels", icon: , label: t("entities.channels") }); - if (features.messages !== false) - items.push({ href: "/messages", icon: , label: t("entities.messages") }); - if (features.packets !== false) - items.push({ href: "/packets", icon: , label: t("entities.packets") }); - if (features.map !== false) - items.push({ href: "/map", icon: , label: t("entities.map") }); - if (features.members !== false) - items.push({ href: "/members", icon: , label: t("entities.members") }); - - if (features.pages !== false) { - for (const page of customPages) { - items.push({ href: page.url, icon: , label: page.title }); - } - } + const items = useNavItems("h-5 w-5"); return ( <> {items.map((item) => (
  • - + (isActive ? "active" : undefined)} + > {item.icon} {item.label} - +
  • ))} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Navbar.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Navbar.test.tsx new file mode 100644 index 0000000..acd7ba5 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Navbar.test.tsx @@ -0,0 +1,119 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it, vi } from "vitest"; + +import { AppConfigProvider } from "@/context/AppConfigContext"; +import { Navbar } from "@/components/Navbar"; +import { makeConfig } from "@/test/makeConfig"; +import type { AppConfig } from "@/types/config"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +function renderNavbar(config: AppConfig) { + return render( + + + + + , + ); +} + +// Each nav label renders twice (desktop menu + mobile dropdown). +const labelCount = (label: string) => screen.queryAllByText(label).length; + +describe("Navbar feature gating", () => { + it("renders all feature links when every feature is enabled", () => { + renderNavbar(makeConfig()); + expect(labelCount("entities.home")).toBeGreaterThan(0); + expect(labelCount("entities.dashboard")).toBeGreaterThan(0); + expect(labelCount("entities.nodes")).toBeGreaterThan(0); + expect(labelCount("entities.messages")).toBeGreaterThan(0); + expect(labelCount("entities.map")).toBeGreaterThan(0); + }); + + it("hides links for disabled features", () => { + renderNavbar( + makeConfig({ + features: { dashboard: false, nodes: false, map: false }, + }), + ); + expect(labelCount("entities.dashboard")).toBe(0); + expect(labelCount("entities.nodes")).toBe(0); + expect(labelCount("entities.map")).toBe(0); + // Still-enabled features remain + expect(labelCount("entities.messages")).toBeGreaterThan(0); + expect(labelCount("entities.home")).toBeGreaterThan(0); + }); + + it("shows only Home when all features are off (maintenance)", () => { + renderNavbar( + makeConfig({ + system_maintenance: true, + features: { + dashboard: false, + nodes: false, + advertisements: false, + routes: false, + channels: false, + messages: false, + packets: false, + map: false, + members: false, + pages: false, + }, + }), + ); + expect(labelCount("entities.home")).toBeGreaterThan(0); + expect(labelCount("entities.dashboard")).toBe(0); + expect(labelCount("entities.nodes")).toBe(0); + expect(labelCount("entities.messages")).toBe(0); + }); + + it("renders custom pages when the pages feature is enabled", () => { + renderNavbar( + makeConfig({ + custom_pages: [ + { slug: "about", title: "About Us", url: "/pages/about", menu_order: 1 }, + ], + }), + ); + expect(labelCount("About Us")).toBeGreaterThan(0); + }); + + it("hides custom pages when the pages feature is disabled", () => { + renderNavbar( + makeConfig({ + features: { pages: false }, + custom_pages: [ + { slug: "about", title: "About Us", url: "/pages/about", menu_order: 1 }, + ], + }), + ); + expect(labelCount("About Us")).toBe(0); + }); +}); + +describe("Navbar auth gating", () => { + it("shows the login button when OIDC is enabled and not in maintenance", () => { + renderNavbar(makeConfig({ oidc_enabled: true })); + expect(labelCount("auth.login")).toBeGreaterThan(0); + }); + + it("hides auth when OIDC is disabled", () => { + renderNavbar(makeConfig({ oidc_enabled: false })); + expect(labelCount("auth.login")).toBe(0); + }); + + it("hides auth in maintenance mode even when OIDC is enabled", () => { + renderNavbar( + makeConfig({ oidc_enabled: true, system_maintenance: true }), + ); + expect(labelCount("auth.login")).toBe(0); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Navbar.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Navbar.tsx new file mode 100644 index 0000000..6cfef9e --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Navbar.tsx @@ -0,0 +1,69 @@ +import { NavLink } from "react-router"; + +import { useAppConfig } from "@/context/AppConfigContext"; +import { useNavItems } from "@/hooks/useNavItems"; +import { AuthSection } from "@/components/AuthSection"; +import { MobileNav } from "@/components/MobileNav"; +import { ThemeToggle } from "@/components/ThemeToggle"; + +export function Navbar() { + const config = useAppConfig(); + const items = useNavItems("h-4 w-4"); + const logoClass = `theme-logo${ + config.logo_invert_light ? " theme-logo--invert-light" : "" + } h-6 w-6 mr-2`; + + return ( +
    +
    + + {config.network_name} + {config.network_name} + +
    +
    +
      + {items.map((item) => ( +
    • + (isActive ? "active" : undefined)} + > + {item.icon} {item.label} + +
    • + ))} +
    +
    +
    + + {config.oidc_enabled && !config.system_maintenance && } +
    +
    + + + +
    +
      + +
    +
    +
    +
    + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.tsx new file mode 100644 index 0000000..996dfd5 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.tsx @@ -0,0 +1,41 @@ +import { useState, type ChangeEvent } from "react"; + +export function ThemeToggle() { + const [isLight, setIsLight] = useState( + () => document.documentElement.getAttribute("data-theme") === "light", + ); + + const handleChange = (e: ChangeEvent) => { + const light = e.currentTarget.checked; + const theme = light ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", theme); + try { + localStorage.setItem("meshcore-theme", theme); + } catch { + // ignore + } + setIsLight(light); + }; + + return ( + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/hooks/useNavItems.tsx b/src/meshcore_hub/web/static/js/spa-react/hooks/useNavItems.tsx new file mode 100644 index 0000000..1f13888 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/hooks/useNavItems.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { useAppConfig } from "@/context/AppConfigContext"; +import { + IconAdvertisements, + IconChannel, + IconDashboard, + IconHome, + IconMap, + IconMembers, + IconMessages, + IconNodes, + IconPackets, + IconPage, + IconPath, +} from "@/components/icons"; + +export interface NavItem { + href: string; + label: string; + icon: ReactNode; + end?: boolean; +} + +export function useNavItems(sizeClass = "h-5 w-5"): NavItem[] { + const { t } = useTranslation(); + const config = useAppConfig(); + const features = config.features ?? {}; + const customPages = config.custom_pages ?? []; + + const items: NavItem[] = [ + { + href: "/", + label: t("entities.home"), + icon: , + end: true, + }, + ]; + + if (features.dashboard !== false) + items.push({ + href: "/dashboard", + label: t("entities.dashboard"), + icon: , + }); + if (features.nodes !== false) + items.push({ + href: "/nodes", + label: t("entities.nodes"), + icon: , + }); + if (features.advertisements !== false) + items.push({ + href: "/advertisements", + label: t("entities.advertisements"), + icon: , + }); + if (features.routes !== false) + items.push({ + href: "/routes", + label: t("entities.routes"), + icon: , + }); + if (features.channels !== false) + items.push({ + href: "/channels", + label: t("entities.channels"), + icon: , + }); + if (features.messages !== false) + items.push({ + href: "/messages", + label: t("entities.messages"), + icon: , + }); + if (features.packets !== false) + items.push({ + href: "/packets", + label: t("entities.packets"), + icon: , + }); + if (features.map !== false) + items.push({ + href: "/map", + label: t("entities.map"), + icon: , + }); + if (features.members !== false) + items.push({ + href: "/members", + label: t("entities.members"), + icon: , + }); + + if (features.pages !== false) { + for (const page of customPages) { + items.push({ + href: page.url, + label: page.title, + icon: , + }); + } + } + + return items; +} diff --git a/src/meshcore_hub/web/static/js/spa-react/main.tsx b/src/meshcore_hub/web/static/js/spa-react/main.tsx index d784dd2..3aee37a 100644 --- a/src/meshcore_hub/web/static/js/spa-react/main.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/main.tsx @@ -3,8 +3,6 @@ import { createRoot } from "react-dom/client"; import { AppConfigProvider } from "@/context/AppConfigContext"; import { initI18n } from "@/i18n"; import { App } from "@/App"; -import { AuthSection } from "@/components/AuthSection"; -import { MobileNav } from "@/components/MobileNav"; import type { AppConfig } from "@/types/config"; async function bootstrap() { @@ -21,23 +19,13 @@ async function bootstrap() { const appContainer = document.getElementById("app"); if (!appContainer) return; - const wrap = (ui: React.ReactNode) => ( + createRoot(appContainer).render( - {ui} - + + + + , ); - - createRoot(appContainer).render(wrap()); - - const authContainer = document.getElementById("auth-section"); - if (authContainer) { - createRoot(authContainer).render(wrap()); - } - - const mobileNavContainer = document.getElementById("mobile-nav"); - if (mobileNavContainer) { - createRoot(mobileNavContainer).render(wrap()); - } } bootstrap(); diff --git a/src/meshcore_hub/web/static/js/spa-react/test/makeConfig.ts b/src/meshcore_hub/web/static/js/spa-react/test/makeConfig.ts new file mode 100644 index 0000000..e6566e9 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/test/makeConfig.ts @@ -0,0 +1,28 @@ +import type { AppConfig } from "@/types/config"; + +export function makeConfig(overrides: Partial = {}): AppConfig { + return { + network_name: "TestNet", + features: {}, + custom_pages: [], + logo_url: "/logo.svg", + version: "1.0.0", + timezone: "UTC", + timezone_iana: "UTC", + default_theme: "dark", + locale: "en", + datetime_locale: "en-US", + auto_refresh_seconds: 30, + channel_labels: {}, + logo_invert_light: false, + debug: false, + locale_version: "", + system_maintenance: false, + spam_score_threshold: 0, + oidc_enabled: false, + user: null, + roles: [], + role_names: {}, + ...overrides, + }; +} diff --git a/src/meshcore_hub/web/static/js/spa-react/test/setup.ts b/src/meshcore_hub/web/static/js/spa-react/test/setup.ts new file mode 100644 index 0000000..e262193 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/test/setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/types/config.ts b/src/meshcore_hub/web/static/js/spa-react/types/config.ts index 5cd96b5..5dc9db9 100644 --- a/src/meshcore_hub/web/static/js/spa-react/types/config.ts +++ b/src/meshcore_hub/web/static/js/spa-react/types/config.ts @@ -51,6 +51,8 @@ export interface AppConfig { user: OidcUser | null; roles: string[]; role_names: Record; + system_announcement?: string | null; + network_announcement?: string | null; } declare global { diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/charts.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/charts.test.ts new file mode 100644 index 0000000..08e3e37 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/charts.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from "vitest"; +import type { TFunction } from "i18next"; + +import { + averageRouteTier, + buildActivityChart, + buildLineChart, + buildRouteDetailStrip, + buildRoutesTrend, + buildStackedBar, + ChartColors, + routeQualityToTier, + type ActivitySeries, + type BreakdownBucket, + type RouteOverviewEntry, +} from "@/utils/charts"; + +const t = ((key: string) => key) as unknown as TFunction; + +const dayLabel = (date: string) => + new Date(date).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + }); + +const series = (counts: number[]): ActivitySeries => ({ + data: counts.map((count, i) => ({ + date: `2026-02-0${i + 1}`, + count, + })), +}); + +describe("routeQualityToTier", () => { + it("maps clear/marginal through and everything else to failing", () => { + expect(routeQualityToTier("clear")).toBe("clear"); + expect(routeQualityToTier("marginal")).toBe("marginal"); + expect(routeQualityToTier("failing")).toBe("failing"); + expect(routeQualityToTier("unknown")).toBe("failing"); + expect(routeQualityToTier("no_coverage")).toBe("failing"); + expect(routeQualityToTier(null)).toBe("failing"); + expect(routeQualityToTier(undefined)).toBe("failing"); + }); +}); + +describe("averageRouteTier", () => { + it("falls back to failing on empty/absent history", () => { + expect(averageRouteTier(null)).toBe("failing"); + expect(averageRouteTier([])).toBe("failing"); + }); + + it("buckets the mean tier (clear=2, marginal=1, failing=0)", () => { + const q = (quality: string) => [{ quality }]; + expect(averageRouteTier(q("clear"))).toBe("clear"); + expect(averageRouteTier(q("marginal"))).toBe("marginal"); + expect(averageRouteTier(q("failing"))).toBe("failing"); + + // mean (2+1)/2 = 1.5 -> clear + expect(averageRouteTier([{ quality: "clear" }, { quality: "marginal" }])).toBe( + "clear", + ); + // mean (2+0)/2 = 1.0 -> marginal (>= 0.75) + expect(averageRouteTier([{ quality: "clear" }, { quality: "failing" }])).toBe( + "marginal", + ); + // mean (1+0)/2 = 0.5 -> failing + expect( + averageRouteTier([{ quality: "marginal" }, { quality: "failing" }]), + ).toBe("failing"); + }); +}); + +describe("buildLineChart", () => { + it("returns null for missing/empty data", () => { + expect(buildLineChart(null, "L", "b", "bg", true)).toBeNull(); + expect(buildLineChart({ data: [] }, "L", "b", "bg", true)).toBeNull(); + }); + + it("builds a single filled dataset with formatted labels", () => { + const cfg = buildLineChart( + series([5, 10]), + "Nodes", + "border", + "fill", + true, + ); + expect(cfg).not.toBeNull(); + expect(cfg!.data.labels).toEqual([dayLabel("2026-02-01"), dayLabel("2026-02-02")]); + expect(cfg!.data.datasets).toHaveLength(1); + const ds = cfg!.data.datasets[0] as { data: number[]; label: string; fill: boolean }; + expect(ds.label).toBe("Nodes"); + expect(ds.data).toEqual([5, 10]); + expect(ds.fill).toBe(true); + }); +}); + +describe("buildActivityChart", () => { + it("returns null when both series are absent", () => { + expect(buildActivityChart(null, null, t)).toBeNull(); + }); + + it("builds one dataset when only adverts are provided", () => { + const cfg = buildActivityChart(series([3, 4]), null, t); + expect(cfg).not.toBeNull(); + expect(cfg!.data.datasets).toHaveLength(1); + expect((cfg!.data.datasets[0] as { label: string }).label).toBe( + "entities.advertisements", + ); + }); + + it("builds two datasets when both series are provided", () => { + const cfg = buildActivityChart(series([3, 4]), series([1, 2]), t); + expect(cfg).not.toBeNull(); + expect(cfg!.data.datasets).toHaveLength(2); + const labels = cfg!.data.datasets.map((d) => (d as { label: string }).label); + expect(labels).toEqual(["entities.advertisements", "entities.messages"]); + }); +}); + +describe("buildStackedBar", () => { + it("returns null for empty buckets or zero total", () => { + expect(buildStackedBar(null, ["red"])).toBeNull(); + expect(buildStackedBar([], ["red"])).toBeNull(); + const zero: BreakdownBucket[] = [ + { label: "a", count: 0 }, + { label: "b", count: 0 }, + ]; + expect(buildStackedBar(zero, ["red"])).toBeNull(); + }); + + it("produces percentage datasets that sum to 100 with rawCount preserved", () => { + const buckets: BreakdownBucket[] = [ + { label: "a", count: 30 }, + { label: "b", count: 70 }, + ]; + const cfg = buildStackedBar(buckets, ["red", "blue"]); + expect(cfg).not.toBeNull(); + const datasets = cfg!.data.datasets as { + data: number[]; + rawCount: number; + backgroundColor: string; + }[]; + expect(datasets).toHaveLength(2); + expect(datasets[0].data[0] + datasets[1].data[0]).toBeCloseTo(100); + expect(datasets[0].rawCount).toBe(30); + expect(datasets[1].rawCount).toBe(70); + expect(datasets[0].backgroundColor).toBe("red"); + expect(datasets[1].backgroundColor).toBe("blue"); + }); +}); + +describe("buildRoutesTrend", () => { + it("returns null for empty routes or routes without history", () => { + expect(buildRoutesTrend(null, t)).toBeNull(); + expect(buildRoutesTrend([], t)).toBeNull(); + expect( + buildRoutesTrend([{ from_label: "A", to_label: "B", history: [] }], t), + ).toBeNull(); + }); + + it("sorts by matched_count, uses categorical tiers, and colors by average tier", () => { + const routes: RouteOverviewEntry[] = [ + { + from_label: "A", + to_label: "B", + matched_count: 1, + history: [ + { date: "2026-02-01", quality: "clear", matched_count: 1 }, + { date: "2026-02-02", quality: "clear", matched_count: 1 }, + ], + }, + { + from_label: "C", + to_label: "D", + matched_count: 9, + history: [ + { date: "2026-02-01", quality: "failing", matched_count: 9 }, + { date: "2026-02-02", quality: "failing", matched_count: 9 }, + ], + }, + ]; + const cfg = buildRoutesTrend(routes, t); + expect(cfg).not.toBeNull(); + const datasets = cfg!.data.datasets as unknown as { + label: string; + data: string[]; + borderColor: string; + _matched: number[]; + }[]; + // Higher matched_count first + expect(datasets[0].label).toBe("C \u2192 D"); + expect(datasets[0].data).toEqual(["failing", "failing"]); + expect(datasets[0].borderColor).toBe(ChartColors.quality.failing); + expect(datasets[0]._matched).toEqual([9, 9]); + expect(datasets[1].data).toEqual(["clear", "clear"]); + expect(datasets[1].borderColor).toBe(ChartColors.quality.clear); + expect(cfg!.data.labels).toEqual([ + dayLabel("2026-02-01"), + dayLabel("2026-02-02"), + ]); + }); + + it("respects maxRoutes", () => { + const routes: RouteOverviewEntry[] = Array.from({ length: 8 }, (_, i) => ({ + from_label: `A${i}`, + to_label: "B", + matched_count: i, + history: [{ date: "2026-02-01", quality: "clear", matched_count: i }], + })); + const cfg = buildRoutesTrend(routes, t, 6); + expect(cfg!.data.datasets).toHaveLength(6); + }); +}); + +describe("buildRouteDetailStrip", () => { + it("returns null for missing/empty history", () => { + expect(buildRouteDetailStrip(null, t)).toBeNull(); + expect(buildRouteDetailStrip({ data: [] }, t)).toBeNull(); + expect(buildRouteDetailStrip({}, t)).toBeNull(); + }); + + it("produces one colored segment per day", () => { + const cfg = buildRouteDetailStrip( + { + data: [ + { date: "2026-02-01", quality: "clear", matched_count: 5 }, + { date: "2026-02-02", quality: "failing", matched_count: 2 }, + ], + }, + t, + ); + expect(cfg).not.toBeNull(); + const datasets = cfg!.data.datasets as { + data: number[]; + backgroundColor: string; + _quality: string; + _matched_count: number; + }[]; + expect(datasets).toHaveLength(2); + expect(datasets[0].data).toEqual([1]); + expect(datasets[0].backgroundColor).toBe(ChartColors.quality.clear); + expect(datasets[0]._quality).toBe("clear"); + expect(datasets[0]._matched_count).toBe(5); + expect(datasets[1].backgroundColor).toBe(ChartColors.quality.failing); + expect(datasets[1]._matched_count).toBe(2); + }); + + it("falls back to no_coverage color for unknown quality", () => { + const cfg = buildRouteDetailStrip( + { data: [{ date: "2026-02-01", quality: "weird", matched_count: 0 }] }, + t, + ); + const ds = cfg!.data.datasets[0] as { backgroundColor: string }; + expect(ds.backgroundColor).toBe(ChartColors.quality.no_coverage); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts new file mode 100644 index 0000000..9b70179 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + extractFirstEmoji, + formatNumber, + formatRelativeTime, + getNodeEmoji, + parseAppDate, + truncateKey, + typeEmoji, +} from "@/utils/format"; + +const fmt = (n: number) => new Intl.NumberFormat().format(n); + +describe("parseAppDate", () => { + it("returns null for empty/invalid input", () => { + expect(parseAppDate(null)).toBeNull(); + expect(parseAppDate("")).toBeNull(); + expect(parseAppDate(" ")).toBeNull(); + expect(parseAppDate("not a date")).toBeNull(); + }); + + it("treats naive datetimes as UTC", () => { + const d = parseAppDate("2026-02-08 12:30:00"); + expect(d).not.toBeNull(); + expect(d!.getTime()).toBe(Date.parse("2026-02-08T12:30:00Z")); + }); + + it("preserves explicit timezone offsets", () => { + const d = parseAppDate("2026-02-08T12:30:00+02:00"); + expect(d!.getTime()).toBe(Date.parse("2026-02-08T12:30:00+02:00")); + }); + + it("parses date-only strings", () => { + const d = parseAppDate("2026-02-08"); + expect(d).not.toBeNull(); + expect(d!.getTime()).toBe(Date.parse("2026-02-08")); + }); +}); + +describe("formatNumber", () => { + it("returns empty string for null/undefined/empty", () => { + expect(formatNumber(null)).toBe(""); + expect(formatNumber(undefined)).toBe(""); + expect(formatNumber("")).toBe(""); + }); + + it("returns the raw string for non-numeric input", () => { + expect(formatNumber("abc")).toBe("abc"); + }); + + it("formats numbers with locale grouping", () => { + expect(formatNumber(1234)).toBe(fmt(1234)); + expect(formatNumber("1234")).toBe(fmt(1234)); + expect(formatNumber(0)).toBe(fmt(0)); + }); +}); + +describe("truncateKey", () => { + it("returns '-' for empty input", () => { + expect(truncateKey(null)).toBe("-"); + }); + + it("returns short keys unchanged", () => { + expect(truncateKey("short")).toBe("short"); + }); + + it("truncates long keys with an ellipsis", () => { + const key = "abcdefghijklmnopqrst"; + expect(truncateKey(key)).toBe("abcdefghijkl..."); + expect(truncateKey(key, 4)).toBe("abcd..."); + }); +}); + +describe("typeEmoji", () => { + it("maps node types to emoji (incl. inference from substrings)", () => { + expect(typeEmoji("chat")).toBe("\u{1F4AC}"); + expect(typeEmoji("repeater")).toBe("\u{1F4E1}"); + expect(typeEmoji("room")).toBe("\u{1FAA7}"); + expect(typeEmoji("companion")).toBe("\u{1F4F1}"); + expect(typeEmoji("Chat Node")).toBe("\u{1F4AC}"); + expect(typeEmoji("My Repeater")).toBe("\u{1F4E1}"); + }); + + it("falls back to a pin for unknown/null types", () => { + expect(typeEmoji(null)).toBe("\u{1F4CD}"); + expect(typeEmoji("sensor")).toBe("\u{1F4CD}"); + }); +}); + +describe("extractFirstEmoji", () => { + it("returns null when there is no emoji", () => { + expect(extractFirstEmoji(null)).toBeNull(); + expect(extractFirstEmoji("plain text")).toBeNull(); + }); + + it("extracts the first emoji", () => { + expect(extractFirstEmoji("\u{1F525} hot node")).toBe("\u{1F525}"); + }); +}); + +describe("getNodeEmoji", () => { + it("prefers an emoji in the node name", () => { + expect(getNodeEmoji("\u{1F680} Rocket", null)).toBe("\u{1F680}"); + }); + + it("infers from type/name when no name emoji", () => { + expect(getNodeEmoji("Living Room", null)).toBe("\u{1FAA7}"); + expect(getNodeEmoji("X", "repeater")).toBe("\u{1F4E1}"); + }); +}); + +describe("formatRelativeTime", () => { + afterEach(() => { + delete (window as { t?: unknown }).t; + }); + + const withT = () => { + window.t = (key: string) => key; + }; + + const isoAgo = (ms: number) => new Date(Date.now() - ms).toISOString(); + + it("returns empty string for empty/invalid input", () => { + withT(); + expect(formatRelativeTime(null)).toBe(""); + }); + + it("buckets elapsed time into relative labels", () => { + withT(); + expect(formatRelativeTime(isoAgo(10 * 1000))).toBe("time.less_than_minute"); + expect(formatRelativeTime(isoAgo(5 * 60 * 1000))).toBe("time.minutes_ago"); + expect(formatRelativeTime(isoAgo(3 * 60 * 60 * 1000))).toBe("time.hours_ago"); + expect(formatRelativeTime(isoAgo(2 * 24 * 60 * 60 * 1000))).toBe( + "time.days_ago", + ); + }); +}); diff --git a/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html index 496612e..003b2fb 100644 --- a/src/meshcore_hub/web/templates/spa.html +++ b/src/meshcore_hub/web/templates/spa.html @@ -53,94 +53,8 @@ - - - - {% if system_announcement %} -
    -
    {{ system_announcement | safe }}
    -
    - {% endif %} - - {% if network_announcement %} -
    -
    {{ network_announcement | safe }}
    - -
    - - {% endif %} - - -
    -
    + +
    @@ -191,22 +105,6 @@ window.__APP_CONFIG__ = {{ config_json|safe }}; - - - {% if asset_app_js %} diff --git a/tests/test_web/conftest.py b/tests/test_web/conftest.py index 6e340cd..b091806 100644 --- a/tests/test_web/conftest.py +++ b/tests/test_web/conftest.py @@ -1,6 +1,7 @@ """Web dashboard test fixtures.""" -from typing import Any, Generator +import json +from typing import Any, Generator, cast from unittest.mock import MagicMock, patch import pytest @@ -22,6 +23,22 @@ ALL_FEATURES_ENABLED = { } +def get_app_config(html: str) -> dict[str, Any]: + """Extract the embedded ``window.__APP_CONFIG__`` object from SPA shell HTML. + + The navbar, banners, and feature-gated nav are rendered client-side by React + from this config, so web tests assert on the config rather than on + server-rendered nav HTML. + """ + marker = "window.__APP_CONFIG__ = " + start = html.index(marker) + len(marker) + script_end = html.index("", start) + # Use the last ";" before so semicolons inside JSON string values + # (e.g. announcement HTML) don't truncate the object. + end = html.rindex(";", start, script_end) + return cast(dict[str, Any], json.loads(html[start:end])) + + class MockHttpClient: """Mock HTTP client for testing web routes.""" diff --git a/tests/test_web/test_app.py b/tests/test_web/test_app.py index 0fddb1d..c9c1207 100644 --- a/tests/test_web/test_app.py +++ b/tests/test_web/test_app.py @@ -17,7 +17,7 @@ from meshcore_hub.web.app import ( create_app, ) -from .conftest import ALL_FEATURES_ENABLED, MockHttpClient +from .conftest import ALL_FEATURES_ENABLED, MockHttpClient, get_app_config @pytest.fixture @@ -330,7 +330,7 @@ class TestFlashBannerVisibility: def test_banner_present_when_announcement_set( self, mock_http_client: MockHttpClient ) -> None: - """Banner HTML is present when network_announcement is set.""" + """Banner content is exposed in the SPA config when network_announcement is set.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -340,23 +340,19 @@ class TestFlashBannerVisibility: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - assert response.status_code == 200 - html = response.text - assert 'id="flash-banner"' in html - assert "Scheduled maintenance at 22:00" in html + config = get_app_config(client.get("/").text) + assert config["network_announcement"] + assert "Scheduled maintenance at 22:00" in config["network_announcement"] def test_banner_absent_when_announcement_none(self, client: TestClient) -> None: - """Banner HTML is absent when network_announcement is not set.""" - response = client.get("/") - assert response.status_code == 200 - html = response.text - assert 'id="flash-banner"' not in html + """Banner content is absent from the config when network_announcement is not set.""" + config = get_app_config(client.get("/").text) + assert not config["network_announcement"] def test_banner_absent_for_empty_string( self, mock_http_client: MockHttpClient ) -> None: - """Banner is not shown when announcement is an empty string.""" + """Banner is not exposed when announcement is an empty string.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -366,14 +362,13 @@ class TestFlashBannerVisibility: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - assert response.status_code == 200 - assert 'id="flash-banner"' not in response.text + config = get_app_config(client.get("/").text) + assert not config["network_announcement"] def test_banner_absent_for_whitespace_only( self, mock_http_client: MockHttpClient ) -> None: - """Banner is not shown when announcement is whitespace-only.""" + """Banner is not exposed when announcement is whitespace-only.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -383,16 +378,15 @@ class TestFlashBannerVisibility: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - assert response.status_code == 200 - assert 'id="flash-banner"' not in response.text + config = get_app_config(client.get("/").text) + assert not config["network_announcement"] class TestFlashBannerMarkdown: """Tests for Markdown rendering in the flash banner.""" def test_bold_rendered(self, mock_http_client: MockHttpClient) -> None: - """Markdown bold is rendered to .""" + """Markdown bold is rendered to in the config content.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -402,12 +396,11 @@ class TestFlashBannerMarkdown: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - assert response.status_code == 200 - assert "important" in response.text + config = get_app_config(client.get("/").text) + assert "important" in config["network_announcement"] def test_link_rendered(self, mock_http_client: MockHttpClient) -> None: - """Markdown link is rendered to tag.""" + """Markdown link is rendered to tag in the config content.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -417,16 +410,18 @@ class TestFlashBannerMarkdown: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - assert response.status_code == 200 - assert 'click here' in response.text + config = get_app_config(client.get("/").text) + assert ( + 'click here' + in config["network_announcement"] + ) def test_raw_html_passed_through(self, mock_http_client: MockHttpClient) -> None: """Raw HTML in announcement is passed through by the Markdown library. This is safe because the announcement source is an operator-controlled environment variable, not user input — same trust model as custom pages - in pages.py. + in pages.py. The React banner renders it via dangerouslySetInnerHTML. """ app = create_app( api_url="http://localhost:8000", @@ -437,9 +432,8 @@ class TestFlashBannerMarkdown: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - assert response.status_code == 200 - assert "bold" in response.text + config = get_app_config(client.get("/").text) + assert "bold" in config["network_announcement"] class TestSystemAnnouncementBanner: @@ -448,7 +442,7 @@ class TestSystemAnnouncementBanner: def test_system_banner_present_when_set( self, mock_http_client: MockHttpClient ) -> None: - """System banner HTML is present and Markdown-rendered when set.""" + """System banner content is exposed and Markdown-rendered in the config.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -458,18 +452,19 @@ class TestSystemAnnouncementBanner: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - html = client.get("/").text - assert 'id="system-banner"' in html - assert "Outage at 22:00" in html + config = get_app_config(client.get("/").text) + assert config["system_announcement"] + assert "Outage at 22:00" in config["system_announcement"] def test_system_banner_absent_when_none(self, client: TestClient) -> None: - """System banner HTML is absent when not set.""" - assert 'id="system-banner"' not in client.get("/").text + """System banner content is absent from the config when not set.""" + config = get_app_config(client.get("/").text) + assert not config["system_announcement"] def test_system_banner_absent_for_empty_string( self, mock_http_client: MockHttpClient ) -> None: - """System banner is not shown for an empty string.""" + """System banner is not exposed for an empty string.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -479,43 +474,12 @@ class TestSystemAnnouncementBanner: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - assert 'id="system-banner"' not in client.get("/").text + config = get_app_config(client.get("/").text) + assert not config["system_announcement"] - def test_system_banner_not_dismissable( - self, mock_http_client: MockHttpClient - ) -> None: - """System banner has no dismiss button or sessionStorage script.""" - app = create_app( - api_url="http://localhost:8000", - api_key="test-api-key", - system_announcement="Heads up", - features=ALL_FEATURES_ENABLED, - ) - app.state.http_client = mock_http_client - client = TestClient(app, raise_server_exceptions=True) - - html = client.get("/").text - banner = html[html.index('id="system-banner"') :] - banner = banner[: banner.index("
    ")] - assert "Dismiss" not in banner - assert "sessionStorage" not in banner - - def test_system_banner_stacked_above_network_banner( - self, mock_http_client: MockHttpClient - ) -> None: - """System banner is rendered above the network announcement banner.""" - app = create_app( - api_url="http://localhost:8000", - api_key="test-api-key", - system_announcement="System notice", - network_announcement="Network notice", - features=ALL_FEATURES_ENABLED, - ) - app.state.http_client = mock_http_client - client = TestClient(app, raise_server_exceptions=True) - - html = client.get("/").text - assert html.index('id="system-banner"') < html.index('id="flash-banner"') + # NOTE: system-banner stacking order and the absence of a dismiss control are + # now rendering behaviour of the React component, covered by + # the frontend test suite (components/Announcements.test.tsx). class TestSystemMaintenance: @@ -532,7 +496,7 @@ class TestSystemMaintenance: assert all(value is False for value in app.state.features.values()) def test_maintenance_nav_only_home(self, mock_http_client: MockHttpClient) -> None: - """Desktop nav contains only Home (no feature links) in maintenance.""" + """Config exposes all features off in maintenance, so the React nav shows only Home.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -542,10 +506,8 @@ class TestSystemMaintenance: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - html = client.get("/dashboard").text - assert 'href="/dashboard"' not in html - assert 'href="/nodes"' not in html - assert 'href="/messages"' not in html + config = get_app_config(client.get("/dashboard").text) + assert not any(config["features"].values()) def test_maintenance_flag_in_config_json( self, mock_http_client: MockHttpClient diff --git a/tests/test_web/test_dashboard.py b/tests/test_web/test_dashboard.py index 572155f..403a91f 100644 --- a/tests/test_web/test_dashboard.py +++ b/tests/test_web/test_dashboard.py @@ -25,27 +25,19 @@ class TestDashboardPage: response = client.get("/dashboard") assert "Test Network" in response.text - def test_dashboard_displays_stats( + def test_dashboard_serves_spa_shell( self, client: TestClient, mock_http_client: MockHttpClient ) -> None: - """Test that dashboard page displays statistics.""" - response = client.get("/dashboard") - # Check for stats from mock response - assert response.status_code == 200 - # The mock returns total_nodes: 10, active_nodes: 5, etc. - # These should be displayed in the page - assert "10" in response.text # total_nodes - assert "5" in response.text # active_nodes + """The dashboard route serves the SPA shell. - def test_dashboard_displays_message_counts( - self, client: TestClient, mock_http_client: MockHttpClient - ) -> None: - """Test that dashboard page displays message counts.""" + Dashboard statistics are fetched and rendered client-side by React from + the API, so they are not present in the server-rendered shell; we assert + the mount point and embedded config instead. + """ response = client.get("/dashboard") assert response.status_code == 200 - # Mock returns total_messages: 100, messages_today: 15 - assert "100" in response.text - assert "15" in response.text + assert 'id="app"' in response.text + assert "window.__APP_CONFIG__" in response.text class TestDashboardPageAPIErrors: diff --git a/tests/test_web/test_features.py b/tests/test_web/test_features.py index 9fc29bf..4e20bab 100644 --- a/tests/test_web/test_features.py +++ b/tests/test_web/test_features.py @@ -1,12 +1,14 @@ """Tests for feature flags functionality.""" -import json - import pytest from fastapi.testclient import TestClient from meshcore_hub.web.app import create_app -from tests.test_web.conftest import ALL_FEATURES_ENABLED, MockHttpClient +from tests.test_web.conftest import ( + ALL_FEATURES_ENABLED, + MockHttpClient, + get_app_config, +) class TestFeatureFlagsConfig: @@ -16,11 +18,7 @@ class TestFeatureFlagsConfig: """All non-OIDC features should be enabled by default in config JSON.""" response = client.get("/") assert response.status_code == 200 - html = response.text - # Extract config JSON from script tag - start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ") - end = html.index(";", start) - config = json.loads(html[start:end]) + config = get_app_config(response.text) features = config["features"] non_oidc_features = {k: v for k, v in features.items() if k != "members"} assert all( @@ -30,10 +28,7 @@ class TestFeatureFlagsConfig: def test_features_dict_has_all_keys(self, client: TestClient) -> None: """Features dict should have all 7 expected keys.""" response = client.get("/") - html = response.text - start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ") - end = html.index(";", start) - config = json.loads(html[start:end]) + config = get_app_config(response.text) features = config["features"] expected_keys = { "dashboard", @@ -49,45 +44,41 @@ class TestFeatureFlagsConfig: def test_disabled_features_in_config(self, client_no_features: TestClient) -> None: """Disabled features should be false in config JSON.""" response = client_no_features.get("/") - html = response.text - start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ") - end = html.index(";", start) - config = json.loads(html[start:end]) + config = get_app_config(response.text) features = config["features"] assert all(not v for v in features.values()), "All features should be disabled" class TestFeatureFlagsNav: - """Test feature flags affect navigation.""" + """Test feature flags affect navigation (via the SPA config the nav reads).""" def test_enabled_features_show_nav_links(self, client: TestClient) -> None: - """Enabled features should show nav links.""" - response = client.get("/") - html = response.text - assert 'href="/dashboard"' in html - assert 'href="/nodes"' in html - assert 'href="/advertisements"' in html - assert 'href="/messages"' in html - assert 'href="/map"' in html + """Enabled features should be true in config so the React nav shows them.""" + config = get_app_config(client.get("/").text) + features = config["features"] + for key in ("dashboard", "nodes", "advertisements", "messages", "map"): + assert features[key] is True def test_disabled_features_hide_nav_links( self, client_no_features: TestClient ) -> None: - """Disabled features should not show nav links.""" - response = client_no_features.get("/") - html = response.text - assert 'href="/dashboard"' not in html - assert 'href="/nodes"' not in html - assert 'href="/advertisements"' not in html - assert 'href="/messages"' not in html - assert 'href="/map"' not in html - assert 'href="/members"' not in html + """Disabled features should be false in config so the React nav hides them.""" + config = get_app_config(client_no_features.get("/").text) + features = config["features"] + for key in ( + "dashboard", + "nodes", + "advertisements", + "messages", + "map", + "members", + ): + assert features[key] is False def test_home_link_always_present(self, client_no_features: TestClient) -> None: - """Home link should always be present.""" + """The SPA mount (where the always-present Home nav renders) is in the shell.""" response = client_no_features.get("/") - html = response.text - assert 'href="/"' in html + assert 'id="app"' in response.text class TestFeatureFlagsEndpoints: @@ -118,11 +109,7 @@ class TestFeatureFlagsEndpoints: self, client_no_features: TestClient ) -> None: """Custom pages should be empty in config when pages feature is disabled.""" - response = client_no_features.get("/") - html = response.text - start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ") - end = html.index(";", start) - config = json.loads(html[start:end]) + config = get_app_config(client_no_features.get("/").text) assert config["custom_pages"] == [] @@ -213,18 +200,18 @@ class TestPacketsFeatureFlag: ) -> None: """The packets nav link is absent when the feature is off.""" client = self._make_app(mock_http_client, packets=False) - html = client.get("/").text - assert 'href="/packets"' not in html + config = get_app_config(client.get("/").text) + assert config["features"]["packets"] is False # Messages still shows (ordering sanity) - assert 'href="/messages"' in html + assert config["features"]["messages"] is True def test_packets_nav_shown_when_enabled( self, mock_http_client: MockHttpClient ) -> None: """The packets nav link appears when the feature is on.""" client = self._make_app(mock_http_client, packets=True) - html = client.get("/").text - assert 'href="/packets"' in html + config = get_app_config(client.get("/").text) + assert config["features"]["packets"] is True def test_packets_enabled_by_default_in_settings(self) -> None: """The declared default for feature_packets is True (env-independent).""" @@ -266,11 +253,10 @@ class TestFeatureFlagsIndividual: def test_disable_map_only(self, _make_client) -> None: """Disabling only map should hide map but show others.""" client = _make_client("map") - response = client.get("/") - html = response.text - assert 'href="/map"' not in html - assert 'href="/dashboard"' in html - assert 'href="/nodes"' in html + config = get_app_config(client.get("/").text) + assert config["features"]["map"] is False + assert config["features"]["dashboard"] is True + assert config["features"]["nodes"] is True # Map data endpoint should 404 response = client.get("/map/data") @@ -279,11 +265,10 @@ class TestFeatureFlagsIndividual: def test_disable_dashboard_only(self, _make_client) -> None: """Disabling only dashboard should hide dashboard but show others.""" client = _make_client("dashboard") - response = client.get("/") - html = response.text - assert 'href="/dashboard"' not in html - assert 'href="/nodes"' in html - assert 'href="/map"' in html + config = get_app_config(client.get("/").text) + assert config["features"]["dashboard"] is False + assert config["features"]["nodes"] is True + assert config["features"]["map"] is True class TestDashboardAutoDisable: @@ -310,12 +295,7 @@ class TestDashboardAutoDisable: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - html = response.text - assert 'href="/dashboard"' not in html - - # Check config JSON also reflects it - config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0]) + config = get_app_config(client.get("/").text) assert config["features"]["dashboard"] is False def test_map_auto_disabled_when_nodes_off( @@ -339,12 +319,7 @@ class TestDashboardAutoDisable: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - html = response.text - assert 'href="/map"' not in html - - # Check config JSON also reflects it - config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0]) + config = get_app_config(client.get("/").text) assert config["features"]["map"] is False # Map data endpoint should 404 @@ -372,5 +347,5 @@ class TestDashboardAutoDisable: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - assert 'href="/dashboard"' in response.text + config = get_app_config(client.get("/").text) + assert config["features"]["dashboard"] is True diff --git a/tests/test_web/test_home.py b/tests/test_web/test_home.py index c309d91..1eaeb28 100644 --- a/tests/test_web/test_home.py +++ b/tests/test_web/test_home.py @@ -76,13 +76,6 @@ class TestHomePage: response = client.get("/") assert "discord.gg/test" in response.text - def test_home_contains_navigation(self, client: TestClient) -> None: - """Test that home page contains navigation links.""" - response = client.get("/") - assert 'href="/"' in response.text - assert 'href="/nodes"' in response.text - assert 'href="/messages"' in response.text - def test_home_contains_spa_mount(self, client: TestClient) -> None: """Test that home page renders the React SPA mount point.""" response = client.get("/") diff --git a/tests/test_web/test_pages.py b/tests/test_web/test_pages.py index 7ab265a..692e34a 100644 --- a/tests/test_web/test_pages.py +++ b/tests/test_web/test_pages.py @@ -1,6 +1,5 @@ """Tests for custom pages functionality (SPA).""" -import json import tempfile from collections.abc import Generator from pathlib import Path @@ -10,6 +9,7 @@ import pytest from fastapi.testclient import TestClient from meshcore_hub.web.pages import CustomPage, PageLoader +from tests.test_web.conftest import get_app_config class TestCustomPage: @@ -484,32 +484,26 @@ Here are some answers. assert "Frequently Asked Questions" in data["content_html"] def test_pages_in_navigation(self, client_with_pages: TestClient) -> None: - """Test that custom pages appear in navigation.""" + """Test that custom pages are exposed for the React navigation.""" response = client_with_pages.get("/") assert response.status_code == 200 - # Check for navigation links - assert 'href="/pages/about"' in response.text - assert 'href="/pages/faq"' in response.text + config = get_app_config(response.text) + urls = [p["url"] for p in config["custom_pages"]] + assert "/pages/about" in urls + assert "/pages/faq" in urls def test_pages_sorted_in_navigation(self, client_with_pages: TestClient) -> None: - """Test that pages are sorted by menu_order in navigation.""" + """Test that pages are sorted by menu_order for the React navigation.""" response = client_with_pages.get("/") assert response.status_code == 200 + config = get_app_config(response.text) + urls = [p["url"] for p in config["custom_pages"]] # About (order 10) should appear before FAQ (order 20) - about_pos = response.text.find('href="/pages/about"') - faq_pos = response.text.find('href="/pages/faq"') - assert about_pos < faq_pos + assert urls.index("/pages/about") < urls.index("/pages/faq") def test_pages_in_config(self, client_with_pages: TestClient) -> None: """Test that custom pages are included in SPA config.""" - response = client_with_pages.get("/") - text = response.text - config_start = text.find("window.__APP_CONFIG__ = ") + len( - "window.__APP_CONFIG__ = " - ) - config_end = text.find(";", config_start) - config = json.loads(text[config_start:config_end]) - + config = get_app_config(client_with_pages.get("/").text) custom_pages = config["custom_pages"] assert len(custom_pages) == 2 slugs = [p["slug"] for p in custom_pages] diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fa4b433 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; + +const SPA_REACT = resolve( + __dirname, + "src/meshcore_hub/web/static/js/spa-react", +); + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": SPA_REACT, + }, + }, + test: { + environment: "jsdom", + include: ["src/meshcore_hub/web/static/js/spa-react/**/*.test.{ts,tsx}"], + setupFiles: ["src/meshcore_hub/web/static/js/spa-react/test/setup.ts"], + }, +}); From bf8e0620a56f4c1386321894a23f9391cf5a21a3 Mon Sep 17 00:00:00 2001 From: Louis King Date: Tue, 21 Jul 2026 20:25:50 +0100 Subject: [PATCH 06/11] fix(web): path-node popup scrolls with page; wire frontend tests/typecheck into make + pre-commit - PacketGroupDetail: render the path-node popover via createPortal(document.body) with position:absolute + document coordinates (rect + scrollX/scrollY) instead of position:fixed with one-shot viewport coords, so it scrolls with the page rather than staying pinned to the viewport. Outside-click/Escape close unchanged (DOM-based). - Makefile: 'make test' now runs pytest then the frontend vitest suite via a new 'test-frontend' target (npm run test:frontend). - pre-commit: add a local 'frontend-typecheck' hook (language: system) running 'npm run typecheck' (tsc --noEmit) on TS/TSX + tsconfig/package(-lock).json changes. - AGENTS.md: document the pre-commit TS gate and that 'make test' includes the frontend. Verified: tsc clean, vitest 49 passed, pre-commit --all-files green, make test (1459 backend passed + 49 frontend passed). --- .pre-commit-config.yaml | 12 ++ AGENTS.md | 12 +- Makefile | 7 +- .../js/spa-react/pages/PacketGroupDetail.tsx | 144 +++++++++--------- 4 files changed, 100 insertions(+), 75 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1f49c18..c6b1462 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,3 +41,15 @@ repos: - alembic>=1.7.0 - types-paho-mqtt>=1.6.0 - types-PyYAML>=6.0.0 + + # Frontend TypeScript gate. Runs in the host node toolchain (language: system) + # so it resolves types from the project's node_modules — requires `npm install`. + # `pass_filenames: false`: tsc typechecks the whole project (tsconfig include). + - repo: local + hooks: + - id: frontend-typecheck + name: frontend typecheck (tsc --noEmit) + entry: npm run typecheck + language: system + pass_filenames: false + files: '(spa-react/.*\.tsx?$|^tsconfig\.json$|^package(-lock)?\.json$)' diff --git a/AGENTS.md b/AGENTS.md index 5502435..b9449df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,8 +47,9 @@ The web UI is a **React 19 + TypeScript + Vite** SPA in (`web/templates/spa.html`) renders only SEO/`window.__APP_CONFIG__`/footer; React renders the navbar, banners, and routed pages into `
    `. **Frontend tooling runs on the host** (not in Docker): `npm install`, `npm run build` (Tailwind → vendor fonts → `vite build` → -`static/dist/` + `assets.json`), `npx tsc --noEmit` (the TS gate — there is no JS linter in -pre-commit), and `npm run test:frontend` (vitest). The Vite build is required to serve the UI; +`static/dist/` + `assets.json`), `npx tsc --noEmit` (the TS gate — also run by the +`frontend-typecheck` pre-commit hook; there is no JS *linter* in pre-commit), and +`npm run test:frontend` (vitest). The Vite build is required to serve the UI; there is no fallback bundle. ```bash @@ -84,9 +85,10 @@ Coverage is **opt-in**; add `--cov=meshcore_hub` (or `make test-cov`) when you w pytest -nauto --no-cov 2>&1 | grep -iE "passed|failed" | tail -3 # Makefile shorthands -make test # pytest -nauto --no-cov (parallel dev loop) -make test-cov # full run with coverage report -make test-unit # parallel, fast unit suites only (skips e2e) +make test # backend (pytest -nauto --no-cov) then frontend vitest +make test-cov # full backend run with coverage report +make test-unit # parallel, fast unit suites only (skips e2e) +make test-frontend # frontend vitest only (npm run test:frontend) # Targeted by component (run only what you changed) pytest --no-cov tests/test_web/ # templates, static JS, web routes diff --git a/Makefile b/Makefile index a54cc4b..74dae20 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ COMPOSE_FILES = -f docker-compose.yml -f docker-compose.dev.yml VOLUMES = $(COMPOSE_PROJECT_NAME)_data $(COMPOSE_PROJECT_NAME)_mqtt_data \ $(COMPOSE_PROJECT_NAME)_observer_data -.PHONY: build up down logs backup restore test test-cov test-unit +.PHONY: build up down logs backup restore test test-cov test-unit test-frontend build: docker compose $(COMPOSE_FILES) --profile all build --no-cache @@ -36,11 +36,16 @@ restore: # --- Tests --------------------------------------------------------------- # Coverage is opt-in (use test-cov). Dev loop runs in parallel across cores. +# `test` runs the backend suite then the frontend (vitest) suite. test: pytest -nauto --no-cov + $(MAKE) test-frontend test-cov: pytest --cov=meshcore_hub --cov-report=term-missing test-unit: pytest -nauto --no-cov tests/test_common/ tests/test_api/ tests/test_collector/ tests/test_web/ + +test-frontend: + npm run test:frontend diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx index e2cbbb0..56d2423 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx @@ -5,6 +5,7 @@ import { useState, type ReactNode, } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Link, useParams } from "react-router"; import { useAppConfig } from "@/context/AppConfigContext"; @@ -351,7 +352,10 @@ export function PacketGroupDetail() { ) { top = popover.top - ph - 4; } - setPopoverPos({ left, top }); + setPopoverPos({ + left: left + window.scrollX, + top: top + window.scrollY, + }); }, [popover, popoverNodes, popoverError]); const openPathPopover = (e: React.MouseEvent, pathHash: string) => { @@ -629,74 +633,76 @@ export function PacketGroupDetail() { )} - {popover && ( -
    -
    - - {t("packets.path_nodes_title", { hash: popover.hash })} - - -
    -
    - {popoverError ? ( -
    - -
    - ) : popoverNodes === null ? ( -
    - -
    - ) : popoverNodes.length === 0 ? ( -
    - {t("packets.path_no_nodes")} -
    - ) : ( -
      - {popoverNodes.map((n) => ( -
    • - setPopover(null)} - className="flex flex-col items-start gap-0" - > - {nodeDisplayName(n)} - - {truncateKey(n.public_key, 16)} - - -
    • - ))} - {moreCount > 0 && ( -
    • - setPopover(null)} - className="text-xs opacity-70" - > - {t("packets.path_nodes_more", { - count: formatNumber(moreCount), - })} - -
    • - )} -
    - )} -
    -
    - )} + {popover && + createPortal( +
    +
    + + {t("packets.path_nodes_title", { hash: popover.hash })} + + +
    +
    + {popoverError ? ( +
    + +
    + ) : popoverNodes === null ? ( +
    + +
    + ) : popoverNodes.length === 0 ? ( +
    + {t("packets.path_no_nodes")} +
    + ) : ( +
      + {popoverNodes.map((n) => ( +
    • + setPopover(null)} + className="flex flex-col items-start gap-0" + > + {nodeDisplayName(n)} + + {truncateKey(n.public_key, 16)} + + +
    • + ))} + {moreCount > 0 && ( +
    • + setPopover(null)} + className="text-xs opacity-70" + > + {t("packets.path_nodes_more", { + count: formatNumber(moreCount), + })} + +
    • + )} +
    + )} +
    +
    , + document.body, + )}
    ); } From de9784211d1ab94ac47f9dce46bd314fc12f002b Mon Sep 17 00:00:00 2001 From: Louis King Date: Tue, 21 Jul 2026 23:24:58 +0100 Subject: [PATCH 07/11] feat(web): adopt TanStack Query for SPA data layer; consolidate shared UI Migrate the React SPA off the bespoke useApiFetch hook and raw useEffect fetches to TanStack Query: useQuery/useQueries for reads, useMutation + invalidateQueries for writes; a central query-key factory and invalidation helpers mirroring the backend cache_invalidation prefixes (channels/routes/nodes/messages/profiles/dashboard/adoptions); polling via refetchInterval (useAutoRefresh now returns pause state only); RouteCard self-fetches its detail/history, dropping the hand-rolled caches. Adds QueryClientProvider in App, a renderWithProviders test helper, and deletes useApiFetch. Also consolidates recurring UI into reusable components (Breadcrumbs, ListToolbar, Modal/ConfirmDialog, NotFoundState, TimeAgo, Definition, CopyableValue, MeshQrCode, Badges, SectionGroup, PageHeader) and fixes the FilterForm clear button, merged toggle wrappers, and role-aware channels/messages cache keys so client invalidation matches the server. Verified: tsc --noEmit clean, vitest 113 passed, pytest test_web 251 + test_cache 119 passed, pre-commit --all-files green. --- package-lock.json | 30 ++ package.json | 1 + src/meshcore_hub/api/routes/channels.py | 2 +- src/meshcore_hub/api/routes/messages.py | 2 +- .../web/static/js/spa-react/App.tsx | 13 +- .../components/AutoRefreshToggle.test.tsx | 47 +++ .../components/AutoRefreshToggle.tsx | 34 ++ .../js/spa-react/components/Badges.test.tsx | 27 ++ .../static/js/spa-react/components/Badges.tsx | 13 + .../spa-react/components/Breadcrumbs.test.tsx | 63 ++++ .../js/spa-react/components/Breadcrumbs.tsx | 30 ++ .../components/ConfirmDialog.test.tsx | 86 +++++ .../js/spa-react/components/ConfirmDialog.tsx | 55 +++ .../components/CopyableValue.test.tsx | 27 ++ .../js/spa-react/components/CopyableValue.tsx | 23 ++ .../spa-react/components/Definition.test.tsx | 34 ++ .../js/spa-react/components/Definition.tsx | 30 ++ .../spa-react/components/EmptyState.test.tsx | 27 ++ .../js/spa-react/components/EmptyState.tsx | 21 ++ .../spa-react/components/FilterForm.test.tsx | 108 ++++++ .../js/spa-react/components/FilterForm.tsx | 116 ++++++- .../spa-react/components/ListToolbar.test.tsx | 72 ++++ .../js/spa-react/components/ListToolbar.tsx | 43 +++ .../spa-react/components/MeshQrCode.test.tsx | 20 ++ .../js/spa-react/components/MeshQrCode.tsx | 25 ++ .../js/spa-react/components/Modal.test.tsx | 48 +++ .../static/js/spa-react/components/Modal.tsx | 30 ++ .../js/spa-react/components/NodeDisplay.tsx | 16 + .../components/NotFoundState.test.tsx | 24 ++ .../js/spa-react/components/NotFoundState.tsx | 20 ++ .../spa-react/components/PacketParts.test.tsx | 48 +++ .../js/spa-react/components/PacketParts.tsx | 58 ++++ .../spa-react/components/PageHeader.test.tsx | 44 +++ .../js/spa-react/components/PageHeader.tsx | 24 ++ .../components/SectionGroup.test.tsx | 29 ++ .../js/spa-react/components/SectionGroup.tsx | 24 ++ .../js/spa-react/components/TimeAgo.test.tsx | 39 +++ .../js/spa-react/components/TimeAgo.tsx | 17 + .../components/TimezoneIndicator.tsx | 7 - .../js/spa-react/hooks/useAutoRefresh.ts | 49 +-- .../js/spa-react/pages/Advertisements.tsx | 260 +++++--------- .../static/js/spa-react/pages/Channels.tsx | 167 +++++---- .../static/js/spa-react/pages/CustomPage.tsx | 4 + .../static/js/spa-react/pages/Dashboard.tsx | 154 +++++---- .../web/static/js/spa-react/pages/Home.tsx | 104 +++--- .../web/static/js/spa-react/pages/MapPage.tsx | 87 ++--- .../web/static/js/spa-react/pages/Members.tsx | 66 ++-- .../static/js/spa-react/pages/Messages.tsx | 217 +++++------- .../static/js/spa-react/pages/NodeDetail.tsx | 323 ++++++++---------- .../web/static/js/spa-react/pages/Nodes.tsx | 252 ++++++-------- .../js/spa-react/pages/PacketDetail.tsx | 185 ++++------ .../js/spa-react/pages/PacketGroupDetail.tsx | 196 ++++------- .../web/static/js/spa-react/pages/Packets.tsx | 218 +++++------- .../web/static/js/spa-react/pages/Profile.tsx | 130 ++++--- .../web/static/js/spa-react/pages/Routes.tsx | 237 +++++-------- .../js/spa-react/test/renderWithProviders.tsx | 48 +++ .../static/js/spa-react/utils/format.test.ts | 33 ++ .../web/static/js/spa-react/utils/format.ts | 15 + .../static/js/spa-react/utils/packets.test.ts | 41 +++ .../web/static/js/spa-react/utils/packets.ts | 17 + .../static/js/spa-react/utils/queryClient.ts | 15 + .../static/js/spa-react/utils/queryKeys.ts | 78 +++++ tests/test_api/test_cache.py | 46 ++- 63 files changed, 2703 insertions(+), 1616 deletions(-) create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Badges.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Badges.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Definition.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Definition.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/EmptyState.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/EmptyState.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/FilterForm.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Modal.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Modal.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/PacketParts.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/PacketParts.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.tsx delete mode 100644 src/meshcore_hub/web/static/js/spa-react/components/TimezoneIndicator.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/test/renderWithProviders.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/packets.test.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/packets.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/queryClient.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts diff --git a/package-lock.json b/package-lock.json index c5a9d8c..1c33fa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "@fontsource-variable/ibm-plex-sans": "^5", "@fontsource/ibm-plex-mono": "^5", "@tailwindcss/cli": "^4", + "@tanstack/react-query": "^5.101.4", "chart.js": "^4", "daisyui": "^5", "i18next": "^25", @@ -34,6 +35,9 @@ "typescript": "^5.8", "vite": "^6", "vitest": "^4.1.10" + }, + "engines": { + "node": ">=20" } }, "node_modules/@adobe/css-tools": { @@ -1697,6 +1701,32 @@ "node": ">= 20" } }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index 725f724..963694e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@fontsource-variable/ibm-plex-sans": "^5", "@fontsource/ibm-plex-mono": "^5", "@tailwindcss/cli": "^4", + "@tanstack/react-query": "^5.101.4", "chart.js": "^4", "daisyui": "^5", "i18next": "^25", diff --git a/src/meshcore_hub/api/routes/channels.py b/src/meshcore_hub/api/routes/channels.py index a9d40d6..3d3e794 100644 --- a/src/meshcore_hub/api/routes/channels.py +++ b/src/meshcore_hub/api/routes/channels.py @@ -25,7 +25,7 @@ router = APIRouter() def _channels_key_builder(request: Request) -> str: role = resolve_user_role(request) or "anonymous" - return f"channels:role={role}:{sorted_query_string(request)}" + return f"{request.url.path}:role={role}:{sorted_query_string(request)}" def _channel_to_read(channel: Channel, include_key: bool = False) -> ChannelRead: diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py index cd1ad76..86f06e0 100644 --- a/src/meshcore_hub/api/routes/messages.py +++ b/src/meshcore_hub/api/routes/messages.py @@ -30,7 +30,7 @@ VALID_MSG_SORT_COLUMNS = {"time", "type", "from", "message"} def _messages_key_builder(request: Request) -> str: role = resolve_user_role(request) or "anonymous" - return f"messages:role={role}:{sorted_query_string(request)}" + return f"{request.url.path}:role={role}:{sorted_query_string(request)}" def _get_tag_name(node: Optional[Node]) -> Optional[str]: diff --git a/src/meshcore_hub/web/static/js/spa-react/App.tsx b/src/meshcore_hub/web/static/js/spa-react/App.tsx index d45e67a..63989c9 100644 --- a/src/meshcore_hub/web/static/js/spa-react/App.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/App.tsx @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, @@ -7,6 +8,7 @@ import { useLocation, useParams, } from "react-router"; +import { createQueryClient } from "@/utils/queryClient"; import { useAppConfig } from "@/context/AppConfigContext"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { Navbar } from "@/components/Navbar"; @@ -266,9 +268,12 @@ function Shell() { } export function App() { + const [queryClient] = useState(createQueryClient); return ( - - - + + + + + ); } diff --git a/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.test.tsx new file mode 100644 index 0000000..ce9b4bd --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.test.tsx @@ -0,0 +1,47 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { AutoRefreshToggle } from "@/components/AutoRefreshToggle"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +describe("AutoRefreshToggle", () => { + it("renders nothing when the interval is 0", () => { + const { container } = render( + {}} + intervalSeconds={0} + />, + ); + expect(container.firstChild).toBeNull(); + }); + + it("shows the interval and a checked toggle while running", () => { + const onToggle = vi.fn(); + render( + , + ); + expect(screen.getByText("30s")).toBeInTheDocument(); + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toBeChecked(); + fireEvent.click(checkbox); + expect(onToggle).toHaveBeenCalledOnce(); + }); + + it("shows an unchecked toggle while paused", () => { + render( + {}} intervalSeconds={30} />, + ); + expect(screen.getByRole("checkbox")).not.toBeChecked(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.tsx b/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.tsx new file mode 100644 index 0000000..a2a0d11 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from "react-i18next"; +import { IconRefresh } from "@/components/icons"; + +interface AutoRefreshToggleProps { + paused: boolean; + onToggle: () => void; + intervalSeconds: number; +} + +export function AutoRefreshToggle({ + paused, + onToggle, + intervalSeconds, +}: AutoRefreshToggleProps) { + const { t } = useTranslation(); + if (intervalSeconds <= 0) return null; + return ( + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Badges.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Badges.test.tsx new file mode 100644 index 0000000..185ddd8 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Badges.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { CallsignBadge, CountBadge, RoleBadge } from "@/components/Badges"; + +describe("badge recipes", () => { + it("CountBadge renders a large badge", () => { + render(42 things); + const el = screen.getByText("42 things"); + expect(el).toHaveClass("badge"); + expect(el).toHaveClass("badge-lg"); + }); + + it("RoleBadge renders a primary small badge", () => { + render(); + const el = screen.getByText("operator"); + expect(el).toHaveClass("badge-primary"); + expect(el).toHaveClass("badge-sm"); + }); + + it("CallsignBadge renders a neutral small badge", () => { + render(); + const el = screen.getByText("AB1CDE"); + expect(el).toHaveClass("badge-neutral"); + expect(el).toHaveClass("badge-sm"); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Badges.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Badges.tsx new file mode 100644 index 0000000..74c5b8d --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Badges.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; + +export function CountBadge({ children }: { children: ReactNode }) { + return {children}; +} + +export function RoleBadge({ role }: { role: string }) { + return {role}; +} + +export function CallsignBadge({ callsign }: { callsign: string }) { + return {callsign}; +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.test.tsx new file mode 100644 index 0000000..b1382d9 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it } from "vitest"; + +import { Breadcrumbs, type Crumb } from "@/components/Breadcrumbs"; + +function renderCrumbs(items: Crumb[]) { + return render( + + + , + ); +} + +const items: Crumb[] = [ + { label: "Home", to: "/" }, + { label: "Nodes", to: "/nodes" }, + { label: "AB1234" }, +]; + +describe("Breadcrumbs", () => { + it("renders a nav landmark labelled Breadcrumb", () => { + renderCrumbs(items); + expect( + screen.getByRole("navigation", { name: "Breadcrumb" }), + ).toBeInTheDocument(); + }); + + it("links non-final crumbs to their targets", () => { + renderCrumbs(items); + expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute( + "href", + "/", + ); + expect(screen.getByRole("link", { name: "Nodes" })).toHaveAttribute( + "href", + "/nodes", + ); + }); + + it("renders the final crumb as plain text with aria-current=page", () => { + renderCrumbs(items); + expect( + screen.queryByRole("link", { name: "AB1234" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("AB1234").closest("li")).toHaveAttribute( + "aria-current", + "page", + ); + }); + + it("renders a crumb without a target as plain text even mid-trail", () => { + renderCrumbs([ + { label: "Home", to: "/" }, + { label: "Static" }, + { label: "Leaf" }, + ]); + expect( + screen.queryByRole("link", { name: "Static" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("Static")).toBeInTheDocument(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.tsx new file mode 100644 index 0000000..1ee7bcf --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; +import { Link } from "react-router"; + +export interface Crumb { + label: ReactNode; + to?: string; +} + +export function Breadcrumbs({ items }: { items: Crumb[] }) { + return ( + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.test.tsx new file mode 100644 index 0000000..4c3f143 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.test.tsx @@ -0,0 +1,86 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ConfirmDialog } from "@/components/ConfirmDialog"; + +function renderDialog(props: Partial[0]> = {}) { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + render( + , + ); + return { onConfirm, onCancel }; +} + +describe("ConfirmDialog", () => { + it("renders the title and message", () => { + renderDialog(); + expect( + screen.getByRole("heading", { name: "Delete thing" }), + ).toBeInTheDocument(); + expect(screen.getByText("Are you sure?")).toBeInTheDocument(); + }); + + it("calls onConfirm and onCancel from the respective buttons", () => { + const { onConfirm, onCancel } = renderDialog(); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + expect(onConfirm).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it("uses the error tone by default and primary when requested", () => { + const { rerender } = render( + {}} + onCancel={() => {}} + />, + ); + expect(screen.getByRole("button", { name: "Go" })).toHaveClass( + "btn-error", + ); + rerender( + {}} + onCancel={() => {}} + />, + ); + expect(screen.getByRole("button", { name: "Go" })).toHaveClass( + "btn-primary", + ); + }); + + it("disables both buttons and shows a spinner while saving", () => { + const { container } = render( + {}} + onCancel={() => {}} + />, + ); + expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); + expect(container.querySelector(".loading-spinner")).not.toBeNull(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.tsx new file mode 100644 index 0000000..80568a3 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from "react"; + +import { Modal } from "@/components/Modal"; + +export function ConfirmDialog({ + title, + message, + confirmLabel, + cancelLabel, + saving = false, + tone = "error", + onConfirm, + onCancel, +}: { + title: ReactNode; + message: ReactNode; + confirmLabel: ReactNode; + cancelLabel: ReactNode; + saving?: boolean; + tone?: "error" | "primary"; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( + + + + + } + > + {message} + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.test.tsx new file mode 100644 index 0000000..9f5818a --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.test.tsx @@ -0,0 +1,27 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { CopyableValue } from "@/components/CopyableValue"; +import { copyToClipboard } from "@/utils/clipboard"; + +vi.mock("@/utils/clipboard", () => ({ + copyToClipboard: vi.fn(), +})); + +describe("CopyableValue", () => { + it("copies the value on click (inline variant)", () => { + render(); + const el = screen.getByText("abc123"); + expect(el).toHaveClass("font-mono"); + fireEvent.click(el); + expect(copyToClipboard).toHaveBeenCalledWith(expect.anything(), "abc123"); + }); + + it("renders the block variant with block classes", () => { + render(); + const el = screen.getByText("deadbeef"); + expect(el).toHaveClass("block"); + expect(el).toHaveClass("break-all"); + expect(el).not.toHaveClass("font-mono"); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.tsx b/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.tsx new file mode 100644 index 0000000..8852d55 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.tsx @@ -0,0 +1,23 @@ +import { copyToClipboard } from "@/utils/clipboard"; + +export function CopyableValue({ + value, + variant = "inline", +}: { + value: string; + variant?: "inline" | "block"; +}) { + return ( + copyToClipboard(e, value)} + title="Click to copy" + > + {value} + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Definition.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Definition.test.tsx new file mode 100644 index 0000000..2cf8b32 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Definition.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { DefinitionField, DefinitionGrid } from "@/components/Definition"; + +describe("DefinitionField", () => { + it("renders the label above the value", () => { + render(5 (chan)); + expect(screen.getByText("Channel")).toBeInTheDocument(); + expect(screen.getByText("5 (chan)")).toBeInTheDocument(); + }); +}); + +describe("DefinitionGrid", () => { + it("uses the default two-column grid classes", () => { + const { container } = render( + + x + , + ); + expect(container.firstChild).toHaveClass("grid"); + expect(container.firstChild).toHaveClass("md:grid-cols-2"); + }); + + it("allows a custom className override", () => { + const { container } = render( + + x + , + ); + expect(container.firstChild).toHaveClass("grid-cols-3"); + expect(container.firstChild).not.toHaveClass("md:grid-cols-2"); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Definition.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Definition.tsx new file mode 100644 index 0000000..5484da1 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Definition.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; + +export function DefinitionField({ + label, + children, +}: { + label: ReactNode; + children: ReactNode; +}) { + return ( +
    + {label} + {children} +
    + ); +} + +export function DefinitionGrid({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
    + {children} +
    + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.test.tsx new file mode 100644 index 0000000..62c7f7b --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { EmptyState, EmptyRow } from "@/components/EmptyState"; + +describe("EmptyState", () => { + it("renders its children", () => { + render(No nodes found); + expect(screen.getByText("No nodes found")).toBeInTheDocument(); + }); +}); + +describe("EmptyRow", () => { + it("renders a table cell spanning the given columns", () => { + const { container } = render( + + + Nothing here + +
    , + ); + const td = container.querySelector("td"); + expect(td).not.toBeNull(); + expect(td!.getAttribute("colspan")).toBe("5"); + expect(screen.getByText("Nothing here")).toBeInTheDocument(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.tsx b/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.tsx new file mode 100644 index 0000000..9470f46 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; + +export function EmptyState({ children }: { children: ReactNode }) { + return
    {children}
    ; +} + +export function EmptyRow({ + colSpan, + children, +}: { + colSpan: number; + children: ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.test.tsx new file mode 100644 index 0000000..b6963e0 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.test.tsx @@ -0,0 +1,108 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router"; +import { describe, expect, it, vi } from "vitest"; + +import { + FilterField, + FilterForm, + OperatorSelect, + submitOnEnter, +} from "@/components/FilterForm"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +const profiles = [ + { id: "1", name: "Alice", callsign: "AL", user_id: "u1" }, + { id: "2", name: "Bob", callsign: null, user_id: "u2" }, +]; + +describe("OperatorSelect", () => { + it("renders an all-operators option plus formatted profile options", () => { + render(); + expect(screen.getByText("common.all_operators")).toBeInTheDocument(); + expect(screen.getByText("Alice (AL)")).toBeInTheDocument(); + // No callsign -> falls back to the plain name + expect(screen.getByText("Bob")).toBeInTheDocument(); + }); + + it("supports controlled value and onChange", () => { + const onChange = vi.fn(); + render( + , + ); + const select = screen.getByRole("combobox") as HTMLSelectElement; + expect(select.value).toBe("1"); + fireEvent.change(select, { target: { value: "2" } }); + expect(onChange).toHaveBeenCalledOnce(); + }); +}); + +describe("FilterField", () => { + it("renders a label wrapping the control", () => { + render( + + + , + ); + expect(screen.getByText("Search")).toBeInTheDocument(); + expect(screen.getByTestId("control")).toBeInTheDocument(); + }); +}); + +describe("submitOnEnter", () => { + it("submits the form on Enter", () => { + const requestSubmit = vi + .spyOn(HTMLFormElement.prototype, "requestSubmit") + .mockImplementation(() => {}); + render( +
    + +
    , + ); + fireEvent.keyDown(screen.getByTestId("inp"), { key: "Enter" }); + expect(requestSubmit).toHaveBeenCalledOnce(); + requestSubmit.mockRestore(); + }); + + it("does nothing for other keys", () => { + const requestSubmit = vi + .spyOn(HTMLFormElement.prototype, "requestSubmit") + .mockImplementation(() => {}); + render( +
    + +
    , + ); + fireEvent.keyDown(screen.getByTestId("inp"), { key: "a" }); + expect(requestSubmit).not.toHaveBeenCalled(); + requestSubmit.mockRestore(); + }); +}); + +describe("FilterForm clear navigation", () => { + function LocationProbe() { + const location = useLocation(); + return ( +
    {location.pathname + location.search}
    + ); + } + + it("clears filters via client-side navigation (no full reload)", () => { + render( + + + + + + , + ); + expect(screen.getByTestId("loc").textContent).toBe("/nodes?search=foo"); + fireEvent.click(screen.getByText("common.clear")); + expect(screen.getByTestId("loc").textContent).toBe("/nodes"); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.tsx b/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.tsx index 8c65c75..869fcf4 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; +import { Link, useNavigate } from "react-router"; import { IconFilter } from "@/components/icons"; interface FilterFormProps { @@ -44,9 +44,9 @@ export function FilterForm({ - + {clearLabel || t("common.clear")} - +
    ); @@ -74,3 +74,113 @@ export function FilterToggle({ open, onChange }: FilterToggleProps) { ); } + +export function autoSubmit( + e: React.ChangeEvent, +) { + e.currentTarget.form?.requestSubmit(); +} + +export function submitOnEnter(e: React.KeyboardEvent) { + if (e.key === "Enter") e.currentTarget.form?.requestSubmit(); +} + +export function FilterField({ + label, + children, + className, +}: { + label: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
    + + {children} +
    + ); +} + +interface FilterSelectOption { + value: string; + label: string; +} + +interface FilterSelectProps { + name: string; + options: FilterSelectOption[]; + defaultValue?: string; + onChange?: (e: React.ChangeEvent) => void; + className?: string; +} + +export function FilterSelect({ + name, + options, + defaultValue, + onChange, + className, +}: FilterSelectProps) { + return ( + + ); +} + +export interface OperatorOption { + id: string; + name?: string | null; + callsign?: string | null; + user_id?: string; +} + +interface OperatorSelectProps { + name?: string; + profiles: OperatorOption[]; + value?: string; + defaultValue?: string; + onChange?: (e: React.ChangeEvent) => void; + className?: string; +} + +export function OperatorSelect({ + name, + profiles, + value, + defaultValue, + onChange, + className, +}: OperatorSelectProps) { + const { t } = useTranslation(); + const controlled = value !== undefined; + return ( + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.test.tsx new file mode 100644 index 0000000..8cbdba3 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ListToolbar } from "@/components/ListToolbar"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +const autoRefresh = { + paused: false, + onToggle: () => {}, + intervalSeconds: 30, +}; + +describe("ListToolbar", () => { + it("renders the total badge when total is provided", () => { + render(); + expect(screen.getByText("common.total")).toBeInTheDocument(); + }); + + it("hides the total badge when total is null", () => { + render(); + expect(screen.queryByText("common.total")).not.toBeInTheDocument(); + }); + + it("renders a warning badge only when there is an error", () => { + const { container, rerender } = render( + , + ); + expect(container.querySelector(".badge-warning")).toBeNull(); + rerender( + , + ); + expect(container.querySelector(".badge-warning")).not.toBeNull(); + }); + + it("renders the auto-refresh toggle when interval is positive", () => { + const { container } = render( + , + ); + expect(container.querySelector('input[type="checkbox"]')).not.toBeNull(); + }); + + it("omits the auto-refresh toggle when interval is not positive", () => { + const { container } = render( + , + ); + expect(container.querySelector('input[type="checkbox"]')).toBeNull(); + }); + + it("renders the filter toggle only when provided", () => { + const { container, rerender } = render( + , + ); + expect(container.querySelector("#filter-toggle")).toBeNull(); + rerender( + {} }} + />, + ); + expect(container.querySelector("#filter-toggle")).not.toBeNull(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.tsx new file mode 100644 index 0000000..d515917 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from "react-i18next"; + +import { WarningBadge } from "@/components/Alerts"; +import { AutoRefreshToggle } from "@/components/AutoRefreshToggle"; +import { CountBadge } from "@/components/Badges"; +import { FilterToggle } from "@/components/FilterForm"; +import { formatNumber } from "@/utils/format"; + +export interface ListToolbarAutoRefresh { + paused: boolean; + onToggle: () => void; + intervalSeconds: number; +} + +export function ListToolbar({ + total, + error, + autoRefresh, + filterToggle, +}: { + total: number | null; + error?: string | null; + autoRefresh: ListToolbarAutoRefresh; + filterToggle?: { open: boolean; onChange: () => void }; +}) { + const { t } = useTranslation(); + return ( +
    + {total !== null && ( + {t("common.total", { count: formatNumber(total) })} + )} + {error && } +
    + + {filterToggle && } +
    +
    + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.test.tsx new file mode 100644 index 0000000..5094b91 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.test.tsx @@ -0,0 +1,20 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { MeshQrCode } from "@/components/MeshQrCode"; + +describe("MeshQrCode", () => { + it("renders an svg QR code inside the white padded wrapper by default", () => { + const { container } = render(); + expect(container.querySelector("svg")).not.toBeNull(); + expect(container.firstChild).toHaveClass("bg-white"); + expect(container.firstChild).toHaveClass("rounded-box"); + }); + + it("accepts a custom className override", () => { + const { container } = render( + , + ); + expect(container.firstChild).toHaveClass("shadow-lg"); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.tsx b/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.tsx new file mode 100644 index 0000000..3a387dd --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.tsx @@ -0,0 +1,25 @@ +import QRCode from "react-qr-code"; + +export function MeshQrCode({ + value, + size = 140, + level = "L", + className = "bg-white p-2 rounded-box", +}: { + value: string; + size?: number; + level?: "L" | "M" | "Q" | "H"; + className?: string; +}) { + return ( +
    + +
    + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Modal.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Modal.test.tsx new file mode 100644 index 0000000..00581b0 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Modal.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { Modal } from "@/components/Modal"; + +describe("Modal", () => { + it("renders the title, children and footer", () => { + render( + {}} footer={foot}> +

    body content

    +
    , + ); + expect( + screen.getByRole("heading", { name: "My Title" }), + ).toBeInTheDocument(); + expect(screen.getByText("body content")).toBeInTheDocument(); + expect(screen.getByText("foot")).toBeInTheDocument(); + }); + + it("omits the footer action row when no footer is given", () => { + const { container } = render( + {}}> +

    body

    +
    , + ); + expect(container.querySelector(".modal-action")).toBeNull(); + }); + + it("applies the large size class", () => { + const { container } = render( + {}}> +

    body

    +
    , + ); + expect(container.querySelector(".modal-box-lg")).not.toBeNull(); + }); + + it("calls onClose when the backdrop button is clicked", () => { + const onClose = vi.fn(); + render( + +

    body

    +
    , + ); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Modal.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Modal.tsx new file mode 100644 index 0000000..a55d9a3 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Modal.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; + +export function Modal({ + title, + children, + footer, + size = "md", + onClose, +}: { + title: ReactNode; + children: ReactNode; + footer?: ReactNode; + size?: "md" | "lg"; + onClose: () => void; +}) { + return ( + +
    +

    {title}

    + {children} + {footer &&
    {footer}
    } +
    +
    +
    + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.tsx b/src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.tsx index 4c4dc64..d75abb7 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; import { getNodeEmoji } from "@/utils/format"; interface NodeDisplayProps { @@ -45,3 +46,18 @@ export function NodeDisplay({
    ); } + +interface NodeLinkProps extends NodeDisplayProps { + className?: string; +} + +export function NodeLink({ className, ...display }: NodeLinkProps) { + return ( + + + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.test.tsx new file mode 100644 index 0000000..8155a55 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { NotFoundState } from "@/components/NotFoundState"; + +describe("NotFoundState", () => { + it("renders an error alert with the message by default", () => { + const { container } = render(); + const alert = screen.getByRole("alert"); + expect(alert).toHaveClass("alert-error"); + expect(alert).toHaveTextContent("No such node"); + expect(container.querySelector("svg")).not.toBeNull(); + }); + + it("renders a warning alert without an icon when tone is warning", () => { + const { container } = render( + , + ); + const alert = screen.getByRole("alert"); + expect(alert).toHaveClass("alert-warning"); + expect(alert).toHaveTextContent("Gone after retention"); + expect(container.querySelector("svg")).toBeNull(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.tsx b/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.tsx new file mode 100644 index 0000000..b977487 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; + +import { IconError } from "@/components/icons"; + +export function NotFoundState({ + message, + tone = "error", +}: { + message: ReactNode; + tone?: "error" | "warning"; +}) { + return ( +
    + {tone === "error" && ( + + )} + {message} +
    + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.test.tsx new file mode 100644 index 0000000..f666e34 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + Field, + RedactedNotice, + channelNameDisplay, +} from "@/components/PacketParts"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +describe("Field", () => { + it("renders a label and its value", () => { + render(12:00); + expect(screen.getByText("Time")).toBeInTheDocument(); + expect(screen.getByText("12:00")).toBeInTheDocument(); + }); +}); + +describe("channelNameDisplay", () => { + it("renders an em dash for a null channel index", () => { + render(<>{channelNameDisplay(new Map(), null)}); + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("renders 'name (idx)' for a known channel", () => { + render(<>{channelNameDisplay(new Map([[3, "General"]]), 3)}); + expect(screen.getByText("General (3)")).toBeInTheDocument(); + }); + + it("renders just the index for an unknown channel", () => { + render(<>{channelNameDisplay(new Map(), 7)}); + expect(screen.getByText("7")).toBeInTheDocument(); + }); +}); + +describe("RedactedNotice", () => { + it("renders a warning notice", () => { + const { container } = render(); + expect(container.querySelector(".alert-warning")).not.toBeNull(); + expect(container.textContent).toContain("packets.redacted_notice"); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.tsx new file mode 100644 index 0000000..0819b80 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { copyToClipboard } from "@/utils/clipboard"; +import { JsonTree } from "@/components/JsonTree"; + +export { DefinitionField as Field } from "@/components/Definition"; + +export function RedactedNotice() { + const { t } = useTranslation(); + return ( +
    + {"\u{1F512}"} {t("packets.redacted_notice")} +
    + ); +} + +export function channelNameDisplay( + names: Map, + channelIdx: number | null, +): ReactNode { + if (channelIdx == null) return ; + const name = names.get(channelIdx); + return name ? `${name} (${channelIdx})` : `${channelIdx}`; +} + +export function RawHexBlock({ hex }: { hex: string | null }) { + const { t } = useTranslation(); + return ( +
    +
    + {t("packets.col_raw")} + {hex && ( + + )} +
    +
    +        {hex || "—"}
    +      
    +
    + ); +} + +export function DecodedJsonBlock({ value }: { value: unknown }) { + const { t } = useTranslation(); + return ( +
    + {t("packets.decoded")} +
    + +
    +
    + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx new file mode 100644 index 0000000..2fa187e --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it } from "vitest"; + +import { AppConfigProvider } from "@/context/AppConfigContext"; +import { PageHeader } from "@/components/PageHeader"; +import { makeConfig } from "@/test/makeConfig"; +import type { AppConfig } from "@/types/config"; + +function renderHeader(config: AppConfig = makeConfig(), children?: ReactNode) { + return render( + + {children} + , + ); +} + +describe("PageHeader", () => { + it("renders the title", () => { + renderHeader(); + expect( + screen.getByRole("heading", { name: "Nodes" }), + ).toBeInTheDocument(); + }); + + it("hides the timezone indicator for UTC", () => { + const { container } = renderHeader(makeConfig({ timezone: "UTC" })); + expect(container.textContent).not.toContain("UTC"); + }); + + it("shows a non-UTC timezone", () => { + renderHeader(makeConfig({ timezone: "America/New_York" })); + expect(screen.getByText("America/New_York")).toBeInTheDocument(); + }); + + it("renders right-side children alongside the timezone", () => { + renderHeader( + makeConfig({ timezone: "EST" }), + extra badge, + ); + expect(screen.getByText("EST")).toBeInTheDocument(); + expect(screen.getByText("extra badge")).toBeInTheDocument(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx new file mode 100644 index 0000000..68f376c --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; +import { useAppConfig } from "@/context/AppConfigContext"; + +export function PageHeader({ + title, + children, +}: { + title: ReactNode; + children?: ReactNode; +}) { + const config = useAppConfig(); + const tz = config.timezone || ""; + return ( +
    +

    {title}

    +
    + {tz && tz !== "UTC" && ( + {tz} + )} + {children} +
    +
    + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.test.tsx new file mode 100644 index 0000000..ba27375 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { SectionGroup } from "@/components/SectionGroup"; + +describe("SectionGroup", () => { + it("renders the title heading and children in the default grid", () => { + const { container } = render( + + card + , + ); + expect( + screen.getByRole("heading", { name: "Community" }), + ).toBeInTheDocument(); + expect(screen.getByText("card")).toBeInTheDocument(); + expect(container.querySelector("div")).toHaveClass("lg:grid-cols-3"); + }); + + it("allows a custom grid className", () => { + const { container } = render( + + c + , + ); + expect(container.querySelector(".grid-cols-2")).not.toBeNull(); + expect(container.querySelector(".lg\\:grid-cols-3")).toBeNull(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.tsx b/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.tsx new file mode 100644 index 0000000..2d381e1 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; + +export function SectionGroup({ + title, + className, + children, +}: { + title: ReactNode; + className?: string; + children: ReactNode; +}) { + return ( + <> +

    {title}

    +
    + {children} +
    + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.test.tsx new file mode 100644 index 0000000..d6b4910 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.test.tsx @@ -0,0 +1,39 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { TimeAgo } from "@/components/TimeAgo"; + +vi.mock("@/utils/format", async () => { + const actual = + await vi.importActual>("@/utils/format"); + return { + ...actual, + formatRelativeTime: () => "2 hours ago", + useFormatDateTime: () => ({ + formatDateTime: () => "Jan 1, 2026 12:00", + }), + }; +}); + +describe("TimeAgo", () => { + it("renders relative text with the full time as title and datetime", () => { + const { container } = render(); + const time = container.querySelector("time"); + expect(time).not.toBeNull(); + expect(time).toHaveAttribute("datetime", "2026-01-01T12:00:00Z"); + expect(time).toHaveAttribute("title", "Jan 1, 2026 12:00"); + expect(time).toHaveTextContent("2 hours ago"); + }); + + it("applies a custom className", () => { + const { container } = render( + , + ); + expect(container.querySelector("time")).toHaveClass("text-xs"); + }); + + it("renders nothing when iso is null", () => { + const { container } = render(); + expect(container.querySelector("time")).toBeNull(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.tsx b/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.tsx new file mode 100644 index 0000000..287c444 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.tsx @@ -0,0 +1,17 @@ +import { formatRelativeTime, useFormatDateTime } from "@/utils/format"; + +export function TimeAgo({ + iso, + className, +}: { + iso: string | null; + className?: string; +}) { + const { formatDateTime } = useFormatDateTime(); + if (!iso) return null; + return ( + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/TimezoneIndicator.tsx b/src/meshcore_hub/web/static/js/spa-react/components/TimezoneIndicator.tsx deleted file mode 100644 index 356fc36..0000000 --- a/src/meshcore_hub/web/static/js/spa-react/components/TimezoneIndicator.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { useAppConfig } from "@/context/AppConfigContext"; - -export function TimezoneIndicator() { - const config = useAppConfig(); - const tz = config.timezone || "UTC"; - return ({tz}); -} diff --git a/src/meshcore_hub/web/static/js/spa-react/hooks/useAutoRefresh.ts b/src/meshcore_hub/web/static/js/spa-react/hooks/useAutoRefresh.ts index a3cd130..0095966 100644 --- a/src/meshcore_hub/web/static/js/spa-react/hooks/useAutoRefresh.ts +++ b/src/meshcore_hub/web/static/js/spa-react/hooks/useAutoRefresh.ts @@ -1,58 +1,21 @@ -import { useEffect, useRef, useState, useCallback } from "react"; +import { useState, useCallback } from "react"; import { useAppConfig } from "@/context/AppConfigContext"; -interface UseAutoRefreshOptions { - onRefresh: () => Promise; -} - interface UseAutoRefreshReturn { paused: boolean; toggle: () => void; intervalSeconds: number; + refetchInterval: number | false; } -export function useAutoRefresh({ - onRefresh, -}: UseAutoRefreshOptions): UseAutoRefreshReturn { +export function useAutoRefresh(): UseAutoRefreshReturn { const config = useAppConfig(); const intervalSeconds = config.auto_refresh_seconds || 0; const [paused, setPaused] = useState(false); - const isPendingRef = useRef(false); - const timerRef = useRef | null>(null); - const onRefreshRef = useRef(onRefresh); - onRefreshRef.current = onRefresh; - const toggle = useCallback(() => setPaused((p) => !p), []); - useEffect(() => { - if (!intervalSeconds || paused) { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - return; - } + const refetchInterval = + !intervalSeconds || paused ? false : intervalSeconds * 1000; - const tick = async () => { - if (isPendingRef.current) return; - isPendingRef.current = true; - try { - await onRefreshRef.current(); - } catch { - // handled by caller - } finally { - isPendingRef.current = false; - } - }; - - timerRef.current = setInterval(tick, intervalSeconds * 1000); - return () => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - }; - }, [intervalSeconds, paused]); - - return { paused, toggle, intervalSeconds }; + return { paused, toggle, intervalSeconds, refetchInterval }; } diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx index ff2c333..3ee3fed 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx @@ -1,16 +1,25 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Link, useNavigate, useSearchParams } from "react-router"; import { useTranslation } from "react-i18next"; import { useAppConfig } from "@/context/AppConfigContext"; -import { apiGet, isAbortError } from "@/utils/api"; -import { formatNumber, useFormatDateTime } from "@/utils/format"; -import { copyToClipboard } from "@/utils/clipboard"; +import { apiGet } from "@/utils/api"; +import { qk } from "@/utils/queryKeys"; +import { useFormatDateTime } from "@/utils/format"; import { usePageTitle } from "@/hooks/usePageTitle"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { Pagination } from "@/components/Pagination"; -import { FilterForm, FilterToggle } from "@/components/FilterForm"; +import { + FilterForm, + FilterField, + FilterSelect, + OperatorSelect, + autoSubmit, + submitOnEnter, +} from "@/components/FilterForm"; import { MobileSortSelect, SortableTableHeader } from "@/components/SortableTable"; import { NodeDisplay } from "@/components/NodeDisplay"; +import { CopyableValue } from "@/components/CopyableValue"; import { ObserverFilterBadges, ObserverIcons, @@ -18,8 +27,10 @@ import { toggleObserverArea, } from "@/components/ObserverBadges"; import { RouteTypeBadge } from "@/components/RouteTypeBadge"; -import { Loading, WarningBadge } from "@/components/Alerts"; -import { IconRefresh } from "@/components/icons"; +import { Loading } from "@/components/Alerts"; +import { ListToolbar } from "@/components/ListToolbar"; +import { PageHeader } from "@/components/PageHeader"; +import { EmptyState, EmptyRow } from "@/components/EmptyState"; interface ObserverInfo { node_id?: string; @@ -62,14 +73,6 @@ interface ListResponse { total?: number; } -function submitOnEnter(e: React.KeyboardEvent) { - if (e.key === "Enter") e.currentTarget.form?.requestSubmit(); -} - -function autoSubmit(e: React.ChangeEvent) { - e.currentTarget.form?.requestSubmit(); -} - export function Advertisements() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -89,13 +92,7 @@ export function Advertisements() { const features = config.features ?? {}; const packetsEnabled = features.packets !== false; - const tz = config.timezone || ""; - const [items, setItems] = useState(null); - const [total, setTotal] = useState(null); - const [error, setError] = useState(null); - const [sortedAreas, setSortedAreas] = useState([]); - const [operators, setOperators] = useState([]); const [disabledAreas, setDisabledAreas] = useState>(() => getDisabledObserverAreas(), ); @@ -105,16 +102,24 @@ export function Advertisements() { routeType !== "flood,transport_flood", ); - const disabledAreasRef = useRef(disabledAreas); - disabledAreasRef.current = disabledAreas; - const abortRef = useRef(null); + const { paused, toggle, intervalSeconds, refetchInterval } = + useAutoRefresh(); - const fetchData = useCallback(async () => { - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - const { signal } = controller; - try { + const { data, error: queryError } = useQuery({ + queryKey: qk.advertisements.list({ + limit, + offset, + search, + sort, + order, + routeType, + adoptedBy, + oidcEnabled: config.oidc_enabled, + operatorRole: config.role_names?.operator || "operator", + disabledAreas: [...disabledAreas].sort(), + }), + refetchInterval, + queryFn: async ({ signal }) => { const nodesPromise = apiGet>( "/api/v1/nodes", { limit: 500, observer: true }, @@ -133,14 +138,13 @@ export function Advertisements() { ]); const operatorRole = config.role_names?.operator || "operator"; - const profiles = (profilesData?.items ?? []) + const operators = (profilesData?.items ?? []) .filter((p) => p.roles?.includes(operatorRole)) .sort((a, b) => (a.name || a.callsign || "").localeCompare( b.name || b.callsign || "", ), ); - setOperators(profiles); const areaMap = new Map(); for (const n of nodesData.items ?? []) { @@ -150,13 +154,13 @@ export function Advertisements() { if (!areaMap.has(key)) areaMap.set(key, []); areaMap.get(key)!.push(n.public_key); } - const areas = [...areaMap.keys()].sort((a, b) => + const sortedAreas = [...areaMap.keys()].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()), ); - setSortedAreas(areas); - const disabled = disabledAreasRef.current; - const observerFilterActive = areas.some((a) => disabled.has(a)); + const observerFilterActive = sortedAreas.some((a) => + disabledAreas.has(a), + ); const apiParams: Record = { limit, offset, @@ -166,34 +170,31 @@ export function Advertisements() { route_type: routeType, }; if (observerFilterActive) { - apiParams.observed_by = areas - .filter((a) => !disabled.has(a)) + apiParams.observed_by = sortedAreas + .filter((a) => !disabledAreas.has(a)) .flatMap((a) => areaMap.get(a) ?? []); } if (adoptedBy) apiParams.adopted_by = adoptedBy; - const data = await apiGet>( + const adData = await apiGet>( "/api/v1/advertisements", apiParams, { signal }, ); - setItems(data.items ?? []); - setTotal(data.total ?? 0); - setError(null); - } catch (e) { - if (isAbortError(e)) return; - setError(e instanceof Error ? e.message : String(e)); - } - }, [limit, offset, search, sort, order, routeType, adoptedBy, config]); - - useEffect(() => { - fetchData(); - return () => abortRef.current?.abort(); - }, [fetchData, disabledAreas]); - - const { paused, toggle, intervalSeconds } = useAutoRefresh({ - onRefresh: fetchData, + return { + items: adData.items ?? [], + total: adData.total ?? 0, + operators, + sortedAreas, + }; + }, }); + const error = queryError ? queryError.message : null; + + const items = data?.items ?? null; + const total = data?.total ?? null; + const operators = data?.operators ?? []; + const sortedAreas = data?.sortedAreas ?? []; const handleObserverToggle = (area: string) => { const updated = toggleObserverArea(area, sortedAreas.length); @@ -234,62 +235,19 @@ export function Advertisements() { return ( <> -
    -

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

    - {tz && tz !== "UTC" && ( - {tz} - )} -
    + -
    - {total !== null && ( - - {t("common.total", { count: formatNumber(total) })} - - )} - {error && } -
    - {intervalSeconds > 0 && ( - - )} -
    -
    - setFilterOpen((o) => !o)} - /> -
    -
    + setFilterOpen((o) => !o) }} + /> {filterOpen && (
    -
    - + -
    -
    - - -
    + options={[ + { + value: "flood,transport_flood", + label: t("advertisements.route_type_flood"), + }, + { value: "all", label: t("advertisements.route_type_all") }, + { + value: "direct", + label: t("advertisements.route_type_direct"), + }, + ]} + /> + {config.oidc_enabled && operators.length > 0 && ( -
    - - -
    + profiles={operators} + /> + )}
    @@ -400,9 +345,7 @@ export function Advertisements() {
    {items.length === 0 ? ( -
    - {emptyMessage} -
    + {emptyMessage} ) : ( items.map((ad, idx) => { const adName = @@ -487,14 +430,7 @@ export function Advertisements() { {items.length === 0 ? ( - - - {emptyMessage} - - + {emptyMessage} ) : ( items.map((ad, idx) => { const adName = @@ -527,15 +463,7 @@ export function Advertisements() { - - copyToClipboard(e, ad.public_key) - } - title="Click to copy" - > - {ad.public_key} - + diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx index ef7a327..18dff39 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx @@ -1,12 +1,19 @@ -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; -import QRCode from "react-qr-code"; import { useAppConfig, hasRole } from "@/context/AppConfigContext"; import { apiGet, apiPost, apiPut, apiDelete } from "@/utils/api"; +import { qk, invalidate } from "@/utils/queryKeys"; import { usePageTitle } from "@/hooks/usePageTitle"; import { Loading, ErrorAlert } from "@/components/Alerts"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { EmptyState } from "@/components/EmptyState"; +import { MeshQrCode } from "@/components/MeshQrCode"; +import { Modal } from "@/components/Modal"; +import { PageHeader } from "@/components/PageHeader"; +import { SectionGroup } from "@/components/SectionGroup"; import { IconChannel, IconPlus, IconEdit, IconTrash } from "@/components/icons"; interface Channel { @@ -36,11 +43,7 @@ type ModalState = function ChannelQrCode({ channel }: { channel: Channel }) { if (!channel.key_hex) return null; const qrUrl = `meshcore://channel/add?name=${encodeURIComponent(channel.name)}&secret=${channel.key_hex.toLowerCase()}`; - return ( -
    - -
    - ); + return ; } interface ChannelCardProps { @@ -167,9 +170,7 @@ function ChannelModal({ : t("channels.add_channel"); return ( - -
    -

    {title}

    +
    -
    -
    - -
    -
    + ); } @@ -268,32 +265,15 @@ function DeleteChannelModal({ const { t } = useTranslation(); return ( - -
    -

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

    -

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

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

    } + confirmLabel={t("common.delete")} + cancelLabel={t("common.cancel")} + saving={saving} + onConfirm={onConfirm} + onCancel={onCancel} + /> ); } @@ -305,56 +285,70 @@ export function Channels() { const isAdmin = hasRole("admin"); usePageTitle("channels.title"); - const [channels, setChannels] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); + + const { + data, + isLoading: loading, + error: queryError, + } = useQuery({ + queryKey: qk.channels.list({}), + queryFn: async ({ signal }) => { + const resp = await apiGet( + "/api/v1/channels", + {}, + { signal }, + ); + return resp.items || []; + }, + }); + const channels = data ?? []; + const error = queryError ? queryError.message : null; const [modal, setModal] = useState(null); - const [saving, setSaving] = useState(false); - const fetchChannels = useCallback(async () => { - try { - const data = await apiGet("/api/v1/channels"); - setChannels(data.items || []); - setError(null); - } catch (e) { - setError((e as Error).message || t("common.failed_to_load_page")); - } finally { - setLoading(false); - } - }, [t]); - - useEffect(() => { - fetchChannels(); - }, [fetchChannels]); - - const handleSave = async (body: Record) => { - setSaving(true); - try { - if (modal?.type === "edit") { - await apiPut(`/api/v1/channels/${modal.channel.id}`, body); + const saveMutation = useMutation({ + mutationFn: async ({ + id, + body, + }: { + id?: string; + body: Record; + }) => { + if (id) { + await apiPut(`/api/v1/channels/${id}`, body); } else { await apiPost("/api/v1/channels", body); } + }, + onSuccess: () => invalidate.channels(queryClient), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiDelete(`/api/v1/channels/${id}`), + onSuccess: () => invalidate.channels(queryClient), + }); + + const saving = saveMutation.isPending || deleteMutation.isPending; + + const handleSave = async (body: Record) => { + try { + await saveMutation.mutateAsync({ + id: modal?.type === "edit" ? modal.channel.id : undefined, + body, + }); setModal(null); - await fetchChannels(); } catch (e) { alert((e as Error).message || "Failed to save channel"); - } finally { - setSaving(false); } }; const handleDeleteConfirm = async () => { if (modal?.type !== "delete") return; - setSaving(true); try { - await apiDelete(`/api/v1/channels/${modal.channel.id}`); + await deleteMutation.mutateAsync(modal.channel.id); setModal(null); - await fetchChannels(); } catch (e) { alert((e as Error).message || "Failed to delete channel"); - } finally { - setSaving(false); } }; @@ -376,12 +370,14 @@ export function Channels() { return (
    -
    -

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

    -
    + + + {t("channels.title")} + + } + /> {error && } @@ -397,11 +393,11 @@ export function Channels() { )} {channels.length === 0 && ( -
    + {t("common.no_entity_found", { entity: t("entities.channels").toLowerCase(), })} -
    + )} {VISIBILITY_ORDER.map((vis) => { @@ -409,10 +405,7 @@ export function Channels() { if (!group || group.length === 0) return null; return (
    -

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

    -
    + {group.map((ch) => ( ))} -
    +
    ); })} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx index ea359b2..eebee13 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx @@ -3,6 +3,7 @@ import { useParams } from "react-router"; import { useTranslation } from "react-i18next"; import { ErrorAlert, Loading } from "@/components/Alerts"; +import { Breadcrumbs } from "@/components/Breadcrumbs"; import { useAppConfig } from "@/context/AppConfigContext"; import { apiGet, isAbortError } from "@/utils/api"; @@ -59,6 +60,9 @@ export function CustomPagePage() { return (
    +
    (null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const controller = new AbortController(); - const { signal } = controller; - (async () => { - try { - const [ - stats, - recentActivity, - advertActivity, - messageActivity, - nodeCount, - packetActivity, - packetBreakdown, - routesOverview, - channelsData, - ] = await Promise.all([ + const queries = useQueries({ + queries: [ + { + queryKey: qk.dashboard.stats(), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet("/api/v1/dashboard/stats", {}, { signal }), + }, + { + queryKey: qk.dashboard.recent({}), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet( "/api/v1/dashboard/recent-activity", {}, { signal }, ), + }, + { + queryKey: qk.dashboard.series("activity", { days: 7 }), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet( "/api/v1/dashboard/activity", { days: 7 }, { signal }, ), + }, + { + queryKey: qk.dashboard.series("message-activity", { days: 7 }), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet( "/api/v1/dashboard/message-activity", { days: 7 }, { signal }, ), + }, + { + queryKey: qk.dashboard.series("node-count", { days: 7 }), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet( "/api/v1/dashboard/node-count", { days: 7 }, { signal }, ), + }, + { + queryKey: qk.dashboard.series("packet-activity", { days: 7 }), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet( "/api/v1/dashboard/packet-activity", { days: 7 }, { signal }, ), + }, + { + queryKey: qk.dashboard.series("packet-breakdown", { days: 7 }), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet( "/api/v1/dashboard/packet-breakdown", { days: 7 }, { signal }, ), - showRoutes - ? apiGet( - "/api/v1/dashboard/routes-overview", - { days: 7 }, - { signal }, - ) - : Promise.resolve(null), + }, + { + queryKey: qk.dashboard.routesOverview(), + queryFn: ({ signal }: { signal: AbortSignal }) => + apiGet( + "/api/v1/dashboard/routes-overview", + { days: 7 }, + { signal }, + ), + enabled: showRoutes, + }, + { + queryKey: qk.channels.list({}), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet("/api/v1/channels", {}, { signal }), - ]); - setData({ - stats, - recentActivity, - advertActivity, - messageActivity, - nodeCount, - packetActivity, - packetBreakdown, - routesOverview, - channelsData, - }); - setError(null); - } catch (e) { - if (isAbortError(e)) return; - setError( - e instanceof Error && e.message - ? e.message - : t("common.failed_to_load_page"), - ); - } finally { - setLoading(false); - } - })(); - return () => controller.abort(); - }, [showRoutes, t]); + }, + ], + }); + + const [ + statsQ, + recentQ, + advertQ, + messageQ, + nodeCountQ, + packetActivityQ, + packetBreakdownQ, + routesOverviewQ, + channelsQ, + ] = queries; + + const loading = queries.some((q) => q.isLoading); + const firstError = queries.find((q) => q.error)?.error ?? null; + const error = firstError + ? firstError instanceof Error && firstError.message + ? firstError.message + : t("common.failed_to_load_page") + : null; + + const data: DashboardData | null = + !loading && !error + ? { + stats: statsQ.data as DashboardStats, + recentActivity: recentQ.data as RecentActivity, + advertActivity: (advertQ.data as ActivitySeries | undefined) ?? null, + messageActivity: + (messageQ.data as ActivitySeries | undefined) ?? null, + nodeCount: (nodeCountQ.data as ActivitySeries | undefined) ?? null, + packetActivity: + (packetActivityQ.data as ActivitySeries | undefined) ?? null, + packetBreakdown: packetBreakdownQ.data as PacketBreakdown, + routesOverview: + (routesOverviewQ.data as RoutesOverview | undefined) ?? null, + channelsData: channelsQ.data as ChannelsResponse, + } + : null; const channelLabels = useMemo(() => { if (!data) return new Map(); @@ -400,9 +428,7 @@ export function DashboardPage() { return ( <> -
    -

    {t("entities.dashboard")}

    -
    + {visibleChartCount > 0 && ( <> diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx index b08cc0a..942c78b 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx @@ -1,11 +1,5 @@ -import { - useCallback, - useEffect, - useRef, - useState, - type ComponentType, - type SVGProps, -} from "react"; +import { type ComponentType, type SVGProps } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Link } from "react-router"; import { useTranslation } from "react-i18next"; @@ -38,7 +32,8 @@ import { useAppConfig, useFeatures } from "@/context/AppConfigContext"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { usePageTitle } from "@/hooks/usePageTitle"; import type { RadioConfigDisplay } from "@/types/config"; -import { apiGet, isAbortError } from "@/utils/api"; +import { apiGet } from "@/utils/api"; +import { qk } from "@/utils/queryKeys"; import { getPageColor } from "@/utils/format"; interface DashboardStats { @@ -141,17 +136,6 @@ export function HomePage() { const features = useFeatures(); usePageTitle(); - const [stats, setStats] = useState(null); - const [advertActivity, setAdvertActivity] = useState( - null, - ); - const [messageActivity, setMessageActivity] = useState( - null, - ); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const hasDataRef = useRef(false); - const networkName = config.network_name || "MeshCore Network"; const logoUrl = config.logo_url || "/static/img/logo.svg"; const logoInvertLight = config.logo_invert_light !== false; @@ -168,50 +152,46 @@ export function HomePage() { const showMembersPanel = features.members !== false; const showRadioPanel = features.radio_config !== false; - const load = useCallback( - async (signal?: AbortSignal) => { - try { - const [statsData, advertData, messageData] = await Promise.all([ - apiGet("/api/v1/dashboard/stats", {}, { signal }), - apiGet( - "/api/v1/dashboard/activity", - { days: 7 }, - { signal }, - ), - apiGet( - "/api/v1/dashboard/message-activity", - { days: 7 }, - { signal }, - ), - ]); - setStats(statsData); - setAdvertActivity(advertData); - setMessageActivity(messageData); - hasDataRef.current = true; - setError(null); - } catch (e) { - if (isAbortError(e)) return; - if (!hasDataRef.current) { - setError( - e instanceof Error && e.message - ? e.message - : t("common.failed_to_load_page"), - ); - } - } finally { - setLoading(false); - } - }, - [t], - ); + const { refetchInterval } = useAutoRefresh(); - useEffect(() => { - const controller = new AbortController(); - void load(controller.signal); - return () => controller.abort(); - }, [load]); + const statsQuery = useQuery({ + queryKey: qk.dashboard.stats(), + queryFn: ({ signal }) => + apiGet("/api/v1/dashboard/stats", {}, { signal }), + refetchInterval, + }); + const advertQuery = useQuery({ + queryKey: qk.dashboard.series("activity", { days: 7 }), + queryFn: ({ signal }) => + apiGet( + "/api/v1/dashboard/activity", + { days: 7 }, + { signal }, + ), + refetchInterval, + }); + const messageQuery = useQuery({ + queryKey: qk.dashboard.series("message-activity", { days: 7 }), + queryFn: ({ signal }) => + apiGet( + "/api/v1/dashboard/message-activity", + { days: 7 }, + { signal }, + ), + refetchInterval, + }); - useAutoRefresh({ onRefresh: load }); + const stats = statsQuery.data ?? null; + const advertActivity = advertQuery.data ?? null; + const messageActivity = messageQuery.data ?? null; + const loading = + statsQuery.isLoading || advertQuery.isLoading || messageQuery.isLoading; + const firstError = + statsQuery.error ?? advertQuery.error ?? messageQuery.error; + const error = + !stats && firstError + ? firstError.message || t("common.failed_to_load_page") + : null; if (loading) return ; if (error) return ; diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx index 585c9b5..6baaafa 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet"; @@ -12,10 +13,12 @@ import "leaflet/dist/leaflet.css"; import { useAppConfig } from "@/context/AppConfigContext"; import { usePageTitle } from "@/hooks/usePageTitle"; -import { apiGet, isAbortError } from "@/utils/api"; +import { apiGet } from "@/utils/api"; +import { qk } from "@/utils/queryKeys"; import { formatNumber, formatRelativeTime, typeEmoji } from "@/utils/format"; -import { FilterToggle } from "@/components/FilterForm"; +import { FilterToggle, OperatorSelect } from "@/components/FilterForm"; import { ErrorAlert, Loading } from "@/components/Alerts"; +import { PageHeader } from "@/components/PageHeader"; const MAX_BOUNDS_RADIUS_KM = 20; @@ -326,18 +329,28 @@ export function MapPage() { usePageTitle("entities.map"); const oidcEnabled = config.oidc_enabled; - const tz = config.timezone || ""; const operatorRole = config.role_names?.operator || "operator"; - const [mapData, setMapData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [filterOpen, setFilterOpen] = useState(false); const [category, setCategory] = useState(""); const [typeFilter, setTypeFilter] = useState(""); const [operatorFilter, setOperatorFilter] = useState(""); const [showLabels, setShowLabels] = useState(false); + const mapQuery = useQuery({ + queryKey: qk.map.data({ adopted_by: operatorFilter || undefined }), + queryFn: ({ signal }) => { + const params: Record = {}; + if (operatorFilter) params.adopted_by = operatorFilter; + return apiGet("/map/data", params, { signal }); + }, + }); + const mapData = mapQuery.data ?? null; + const loading = mapQuery.isLoading; + const error = mapQuery.error + ? mapQuery.error.message || t("common.failed_to_load_page") + : null; + const operatorProfiles = useMemo( () => (mapData?.profiles || []) @@ -350,23 +363,6 @@ export function MapPage() { [mapData, operatorRole], ); - useEffect(() => { - const ac = new AbortController(); - const params: Record = {}; - if (operatorFilter) params.adopted_by = operatorFilter; - apiGet("/map/data", params, { signal: ac.signal }) - .then((data) => { - setMapData(data); - setError(null); - }) - .catch((e) => { - if (isAbortError(e)) return; - setError((e as Error).message || t("common.failed_to_load_page")); - }) - .finally(() => setLoading(false)); - return () => ac.abort(); - }, [operatorFilter, t]); - const allNodes = useMemo(() => mapData?.nodes ?? [], [mapData]); const filteredNodes = useMemo( @@ -431,24 +427,18 @@ export function MapPage() { return (
    -
    -

    {t("entities.map")}

    -
    - {tz && tz !== "UTC" && ( - {tz} - )} - {countBadgeText} - {showFilteredBadge && ( - - {t("common.shown", { count: formatNumber(filteredCount) })} - - )} - setFilterOpen((open) => !open)} - /> -
    -
    + + {countBadgeText} + {showFilteredBadge && ( + + {t("common.shown", { count: formatNumber(filteredCount) })} + + )} + setFilterOpen((open) => !open)} + /> + {filterOpen && (
    @@ -485,20 +475,11 @@ export function MapPage() { - + profiles={operatorProfiles} + />
    )}
    diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx index 16f3ac1..f6ff0a2 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx @@ -1,16 +1,19 @@ import { - useEffect, - useState, type KeyboardEvent, type MouseEvent, type ReactNode, } from "react"; import { Link, useNavigate } from "react-router"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useAppConfig } from "@/context/AppConfigContext"; -import { apiGet, isAbortError } from "@/utils/api"; -import { formatNumber } from "@/utils/format"; +import { apiGet } from "@/utils/api"; +import { qk } from "@/utils/queryKeys"; +import { formatNumber, resolveNodeName } from "@/utils/format"; import { Loading, ErrorAlert } from "@/components/Alerts"; +import { CallsignBadge, RoleBadge } from "@/components/Badges"; +import { EmptyState } from "@/components/EmptyState"; +import { PageHeader } from "@/components/PageHeader"; import { IconAntenna, IconUsers } from "@/components/icons"; import { usePageTitle } from "@/hooks/usePageTitle"; @@ -58,18 +61,12 @@ function ProfileTile({ profile }: { profile: MemberProfile }) {

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

    {profile.roles && profile.roles.length > 0 && (
    {profile.roles.map((role) => ( - - {role} - + ))}
    )} @@ -101,7 +98,7 @@ function ProfileTile({ profile }: { profile: MemberProfile }) { {profile.adopted_nodes && profile.adopted_nodes.length > 0 && (
    {profile.adopted_nodes.map((node) => { - const label = node.name || node.public_key.slice(0, 12) + "..."; + const label = resolveNodeName(node); return ( (null); - const [error, setError] = useState(null); - useEffect(() => { - const controller = new AbortController(); - apiGet( - "/api/v1/user/profiles", - { limit: 500 }, - { signal: controller.signal }, - ) - .then((resp) => setProfiles(resp.items || [])) - .catch((e) => { - if (!isAbortError(e)) { - setError((e as Error).message || t("common.failed_to_load_page")); - } - }); - return () => controller.abort(); - }, [t]); + const { data, error: queryError } = useQuery({ + queryKey: qk.profiles.list({ limit: 500 }), + queryFn: async ({ signal }) => { + const resp = await apiGet( + "/api/v1/user/profiles", + { limit: 500 }, + { signal }, + ); + return resp.items || []; + }, + }); + const profiles = data ?? null; + const error = queryError ? queryError.message : null; if (error) return ; if (profiles === null) return ; @@ -187,13 +180,11 @@ export function Members() { if (visible.length === 0) { return ( <> -
    -

    {t("entities.members")}

    -
    -
    + +

    {t("members_page.empty_state")}

    {t("members_page.empty_description")}

    -
    + ); } @@ -212,15 +203,14 @@ export function Members() { return ( <> -
    -

    {t("entities.members")}

    + {t("common.count_entity", { count: formatNumber(operators.length + members.length), entity: t("entities.members").toLowerCase(), })} -
    + { total?: number; } -function autoSubmit(e: React.ChangeEvent) { - e.currentTarget.form?.requestSubmit(); -} - function parseSenderFromText(text: string | null): { sender: string | null; text: string; @@ -243,21 +248,7 @@ export function Messages() { typeof config.spam_score_threshold === "number" ? config.spam_score_threshold : 0.65; - const tz = config.timezone || ""; - const [items, setItems] = useState(null); - const [total, setTotal] = useState(null); - const [error, setError] = useState(null); - const [sortedAreas, setSortedAreas] = useState([]); - const [builtinLabels, setBuiltinLabels] = useState>( - () => new Map(), - ); - const [customLabels, setCustomLabels] = useState>( - () => new Map(), - ); - const [channelLabels, setChannelLabels] = useState>( - () => new Map(), - ); const [disabledAreas, setDisabledAreas] = useState>(() => getDisabledObserverAreas(), ); @@ -265,16 +256,23 @@ export function Messages() { messageType !== "" || channelIdx !== "" || includeSpam, ); - const disabledAreasRef = useRef(disabledAreas); - disabledAreasRef.current = disabledAreas; - const abortRef = useRef(null); + const { paused, toggle, intervalSeconds, refetchInterval } = + useAutoRefresh(); - const fetchData = useCallback(async () => { - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - const { signal } = controller; - try { + const { data, error: queryError } = useQuery({ + queryKey: qk.messages.list({ + limit, + offset, + messageType, + channelIdx, + includeSpam, + sort, + order, + channelLabels: config.channel_labels, + disabledAreas: [...disabledAreas].sort(), + }), + refetchInterval, + queryFn: async ({ signal }) => { const [nodesData, channelsData] = await Promise.all([ apiGet>( "/api/v1/nodes", @@ -293,9 +291,7 @@ export function Messages() { ]) .filter(([idx]) => Number.isInteger(idx)), ); - setBuiltinLabels(builtin); - setCustomLabels(custom); - setChannelLabels(new Map([...builtin, ...custom])); + const channelLabels = new Map([...builtin, ...custom]); const areaMap = new Map(); for (const n of nodesData.items ?? []) { @@ -305,13 +301,13 @@ export function Messages() { if (!areaMap.has(key)) areaMap.set(key, []); areaMap.get(key)!.push(n.public_key); } - const areas = [...areaMap.keys()].sort((a, b) => + const sortedAreas = [...areaMap.keys()].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()), ); - setSortedAreas(areas); - const disabled = disabledAreasRef.current; - const observerFilterActive = areas.some((a) => disabled.has(a)); + const observerFilterActive = sortedAreas.some((a) => + disabledAreas.has(a), + ); const apiParams: Record = { limit, offset, @@ -321,34 +317,35 @@ export function Messages() { order, }; if (observerFilterActive) { - apiParams.observed_by = areas - .filter((a) => !disabled.has(a)) + apiParams.observed_by = sortedAreas + .filter((a) => !disabledAreas.has(a)) .flatMap((a) => areaMap.get(a) ?? []); } if (includeSpam) apiParams.include_spam = true; - const data = await apiGet>( + const messagesData = await apiGet>( "/api/v1/messages", apiParams, { signal }, ); - setItems(dedupeBySignature(data.items ?? [])); - setTotal(data.total ?? 0); - setError(null); - } catch (e) { - if (isAbortError(e)) return; - setError(e instanceof Error ? e.message : String(e)); - } - }, [limit, offset, messageType, channelIdx, includeSpam, sort, order, config]); - - useEffect(() => { - fetchData(); - return () => abortRef.current?.abort(); - }, [fetchData, disabledAreas]); - - const { paused, toggle, intervalSeconds } = useAutoRefresh({ - onRefresh: fetchData, + return { + items: dedupeBySignature(messagesData.items ?? []), + total: messagesData.total ?? 0, + sortedAreas, + builtinLabels: builtin, + customLabels: custom, + channelLabels, + }; + }, }); + const error = queryError ? queryError.message : null; + + const items = data?.items ?? null; + const total = data?.total ?? null; + const sortedAreas = data?.sortedAreas ?? []; + const builtinLabels = data?.builtinLabels ?? new Map(); + const customLabels = data?.customLabels ?? new Map(); + const channelLabels = data?.channelLabels ?? new Map(); const handleObserverToggle = (area: string) => { const updated = toggleObserverArea(area, sortedAreas.length); @@ -425,76 +422,32 @@ export function Messages() { return ( <> -
    -

    {t("entities.messages")}

    - {tz && tz !== "UTC" && ( - {tz} - )} -
    + -
    - {total !== null && ( - - {t("common.total", { count: formatNumber(total) })} - - )} - {error && } -
    - {intervalSeconds > 0 && ( - - )} -
    -
    - setFilterOpen((o) => !o)} - /> -
    -
    + setFilterOpen((o) => !o) }} + /> {filterOpen && (
    -
    - - -
    -
    - + options={[ + { value: "", label: t("common.all_types") }, + { value: "contact", label: t("messages.type_direct") }, + { value: "channel", label: t("messages.type_channel") }, + ]} + /> + + -
    + {spamEnabled && ( -
    - + -
    + )}
    @@ -587,9 +535,7 @@ export function Messages() {
    {items.length === 0 ? ( -
    - {emptyMessage} -
    + {emptyMessage} ) : ( items.map((msg, idx) => { const isChannel = msg.message_type === "channel"; @@ -699,14 +645,7 @@ export function Messages() { {items.length === 0 ? ( - - - {emptyMessage} - - + {emptyMessage} ) : ( items.map((msg, idx) => { const isChannel = msg.message_type === "channel"; diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx index eeef57b..fdda2d4 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx @@ -1,23 +1,28 @@ import { - useCallback, useEffect, useMemo, useState, type FormEvent, type ReactNode, } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { Link, useNavigate, useParams, useSearchParams } from "react-router"; import { MapContainer, Marker, TileLayer, useMap } from "react-leaflet"; import { divIcon, point as leafletPoint } from "leaflet"; import "leaflet/dist/leaflet.css"; -import QRCode from "react-qr-code"; import { ErrorAlert, Loading, SuccessAlert } from "@/components/Alerts"; -import { IconEdit, IconError, IconPlus, IconTrash } from "@/components/icons"; +import { Breadcrumbs } from "@/components/Breadcrumbs"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { CopyableValue } from "@/components/CopyableValue"; +import { IconEdit, IconPlus, IconTrash } from "@/components/icons"; +import { MeshQrCode } from "@/components/MeshQrCode"; +import { Modal } from "@/components/Modal"; +import { NotFoundState } from "@/components/NotFoundState"; import { hasRole, useAppConfig } from "@/context/AppConfigContext"; import { usePageTitle } from "@/hooks/usePageTitle"; import { apiDelete, apiGet, apiPost, apiPut, isAbortError } from "@/utils/api"; -import { copyToClipboard } from "@/utils/clipboard"; +import { qk, invalidate } from "@/utils/queryKeys"; import { typeEmoji, truncateKey, useFormatDateTime } from "@/utils/format"; interface NodeTag { @@ -82,20 +87,6 @@ function OffsetCenter({ lat, lon }: { lat: number; lon: number }) { return null; } -function NodeQrCode({ url, className }: { url: string; className: string }) { - return ( -
    - -
    - ); -} - export function NodeDetailPage() { const { t } = useTranslation(); const config = useAppConfig(); @@ -106,18 +97,14 @@ export function NodeDetailPage() { usePageTitle("entities.node_detail"); const publicKey = publicKeyParam ?? ""; - const searchKey = searchParams.toString(); + const isFullKey = publicKey.length === 64; const flashMessage = searchParams.get("message") || ""; const flashError = searchParams.get("error") || ""; - const [node, setNode] = useState(null); - const [advertisements, setAdvertisements] = useState( - [], - ); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [notFound, setNotFound] = useState(false); + const queryClient = useQueryClient(); const [flash, setFlash] = useState(null); + const [prefixNotFound, setPrefixNotFound] = useState(false); + const [prefixError, setPrefixError] = useState(null); const [addKey, setAddKey] = useState(""); const [addValue, setAddValue] = useState(""); @@ -132,9 +119,10 @@ export function NodeDetailPage() { const [deleteKey, setDeleteKey] = useState(null); const [deleteSaving, setDeleteSaving] = useState(false); + const [confirmRelease, setConfirmRelease] = useState(false); useEffect(() => { - if (!publicKey || publicKey.length === 64) return; + if (!publicKey || isFullKey) return; const ac = new AbortController(); (async () => { try { @@ -147,64 +135,57 @@ export function NodeDetailPage() { } catch (e) { if (isAbortError(e)) return; if (errorMessage(e).includes("404")) { - setNotFound(true); + setPrefixNotFound(true); } else { - setError(errorMessage(e)); + setPrefixError(errorMessage(e)); } - setLoading(false); } })(); return () => ac.abort(); - }, [publicKey, navigate]); + }, [publicKey, isFullKey, navigate]); - const loadData = useCallback( - async (signal: AbortSignal) => { - try { - const [nodeData, adsData] = await Promise.all([ - apiGet( - `/api/v1/nodes/${publicKey}`, - {}, - { signal }, - ), - apiGet( - "/api/v1/advertisements", - { public_key: publicKey, limit: 10 }, - { signal }, - ), - apiGet( - "/api/v1/telemetry", - { node_public_key: publicKey, limit: 10 }, - { signal }, - ), - ]); - if (!nodeData) { - setNotFound(true); - return; - } - setNode(nodeData); - setAdvertisements(adsData.items || []); - setNotFound(false); - setError(null); - } catch (e) { - if (isAbortError(e)) return; - if (errorMessage(e).includes("404")) { - setNotFound(true); - } else { - setError(errorMessage(e)); - } - } finally { - if (!signal.aborted) setLoading(false); - } - }, - [publicKey], - ); + const nodeQuery = useQuery({ + queryKey: qk.nodes.detail(publicKey), + queryFn: ({ signal }) => + apiGet( + `/api/v1/nodes/${publicKey}`, + {}, + { signal }, + ), + enabled: isFullKey, + }); + const advertisementsQuery = useQuery({ + queryKey: qk.advertisements.list({ public_key: publicKey, limit: 10 }), + queryFn: ({ signal }) => + apiGet( + "/api/v1/advertisements", + { public_key: publicKey, limit: 10 }, + { signal }, + ), + enabled: isFullKey, + }); - useEffect(() => { - if (!publicKey || publicKey.length !== 64) return; - const ac = new AbortController(); - loadData(ac.signal); - return () => ac.abort(); - }, [loadData, searchKey]); + const node = nodeQuery.data ?? null; + const advertisements = advertisementsQuery.data?.items ?? []; + const nodeErrorMsg = nodeQuery.error ? errorMessage(nodeQuery.error) : null; + const notFound = + prefixNotFound || + (nodeErrorMsg?.includes("404") ?? false) || + (isFullKey && !nodeQuery.isPending && nodeQuery.data === null); + const error = + prefixError || + (nodeErrorMsg && !nodeErrorMsg.includes("404") ? nodeErrorMsg : null); + const loading = isFullKey ? nodeQuery.isLoading : !prefixNotFound && !prefixError; + + const adoptMutation = useMutation({ + mutationFn: (key: string) => + apiPost("/api/v1/adoptions", { public_key: key }), + onSuccess: () => invalidate.adoptions(queryClient), + }); + const releaseMutation = useMutation({ + mutationFn: (key: string) => apiDelete(`/api/v1/adoptions/${key}`), + onSuccess: () => invalidate.adoptions(queryClient), + }); let lat: number | null = node?.lat ?? null; let lon: number | null = node?.lon ?? null; @@ -271,8 +252,8 @@ export function NodeDetailPage() { setFlash({ type, message }); }; - const reloadNode = () => { - navigate(`/nodes/${publicKey}?refresh=${Date.now()}`, { replace: true }); + const invalidateNodeData = () => { + invalidate.nodeTags(queryClient); }; const validateTagValue = (value: string, type: string): string | null => { @@ -292,7 +273,7 @@ export function NodeDetailPage() { const handleAdopt = async () => { if (!node) return; try { - await apiPost("/api/v1/adoptions", { public_key: node.public_key }); + await adoptMutation.mutateAsync(node.public_key); navigate( `/nodes/${node.public_key}?message=${encodeURIComponent(t("nodes.adopt_success"))}`, { replace: true }, @@ -307,9 +288,9 @@ export function NodeDetailPage() { const handleRelease = async () => { if (!node) return; - if (!confirm(t("nodes.release_confirm"))) return; + setConfirmRelease(false); try { - await apiDelete(`/api/v1/adoptions/${node.public_key}`); + await releaseMutation.mutateAsync(node.public_key); navigate( `/nodes/${node.public_key}?message=${encodeURIComponent(t("nodes.release_success"))}`, { replace: true }, @@ -344,7 +325,7 @@ export function NodeDetailPage() { "success", t("common.entity_added_success", { entity: t("entities.tag") }), ); - reloadNode(); + invalidateNodeData(); } catch (e) { showFlash("error", errorMessage(e)); } @@ -377,7 +358,7 @@ export function NodeDetailPage() { "success", t("common.entity_updated_success", { entity: t("entities.tag") }), ); - reloadNode(); + invalidateNodeData(); } catch (e) { setEditError(errorMessage(e)); } finally { @@ -397,7 +378,7 @@ export function NodeDetailPage() { "success", t("common.entity_deleted_success", { entity: t("entities.tag") }), ); - reloadNode(); + invalidateNodeData(); } catch (e) { setDeleteKey(null); showFlash("error", errorMessage(e)); @@ -410,26 +391,19 @@ export function NodeDetailPage() { if (notFound) { return ( <> -
    -
      -
    • - {t("entities.home")} -
    • -
    • - {t("entities.nodes")} -
    • -
    • {t("common.page_not_found")}
    • -
    -
    -
    - - - {t("common.entity_not_found_details", { - entity: t("entities.node"), - details: publicKey, - })} - -
    + + {t("common.view_entity", { entity: t("entities.nodes") })} @@ -476,7 +450,7 @@ export function NodeDetailPage() { {canRelease && ( @@ -509,13 +483,7 @@ export function NodeDetailPage() {

    {t("common.public_key")}

    - copyToClipboard(e, node.public_key)} - title="Click to copy" - > - {node.public_key} - +
    @@ -622,17 +590,13 @@ export function NodeDetailPage() { return ( <> -
    -
      -
    • - {t("entities.home")} -
    • -
    • - {t("entities.nodes")} -
    • -
    • {tagName || node.name || truncateKey(node.public_key)}
    • -
    -
    +
    -
    @@ -695,7 +659,7 @@ export function NodeDetailPage() { ) : (
    - +

    {t("nodes.scan_to_add")}

    @@ -843,15 +807,20 @@ export function NodeDetailPage() {
    {canEditTags && editTag && ( -
    -
    -

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

    -
    + + } + onClose={() => { + if (!editSaving) setEditTag(null); + }} + > +
    -
    -
    !editSaving && setEditTag(null)} - /> -
    + )} {canEditTags && deleteKey !== null && ( -
    -
    -

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

    -

    -

    - {t("common.cannot_be_undone")} -
    -
    - - -
    -
    -
    !deleteSaving && setDeleteKey(null)} - /> -
    + +

    +

    + {t("common.cannot_be_undone")} +
    + + } + confirmLabel={t("common.delete")} + cancelLabel={t("common.cancel")} + saving={deleteSaving} + onConfirm={handleDeleteTag} + onCancel={() => { + if (!deleteSaving) setDeleteKey(null); + }} + /> + )} + + {confirmRelease && ( + setConfirmRelease(false)} + /> )} ); diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx index 87bdecf..f3aa3a5 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx @@ -1,22 +1,32 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { Link, useSearchParams } from "react-router"; import { useAppConfig } from "@/context/AppConfigContext"; import { apiGet } from "@/utils/api"; -import { useFormatDateTime, formatNumber } from "@/utils/format"; -import { copyToClipboard } from "@/utils/clipboard"; +import { qk } from "@/utils/queryKeys"; +import { useFormatDateTime } from "@/utils/format"; import { usePageTitle } from "@/hooks/usePageTitle"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { Pagination } from "@/components/Pagination"; -import { FilterForm, FilterToggle } from "@/components/FilterForm"; +import { + FilterForm, + FilterField, + FilterSelect, + OperatorSelect, + autoSubmit, +} from "@/components/FilterForm"; import { SortableTableHeader, MobileSortSelect, } from "@/components/SortableTable"; -import { NodeDisplay } from "@/components/NodeDisplay"; -import { Loading, WarningBadge } from "@/components/Alerts"; -import { IconRefresh } from "@/components/icons"; +import { NodeDisplay, NodeLink } from "@/components/NodeDisplay"; +import { CopyableValue } from "@/components/CopyableValue"; +import { Loading } from "@/components/Alerts"; +import { ListToolbar } from "@/components/ListToolbar"; +import { PageHeader } from "@/components/PageHeader"; +import { EmptyState, EmptyRow } from "@/components/EmptyState"; interface NodeTag { key: string; @@ -72,7 +82,6 @@ export function Nodes() { const sort = searchParams.get("sort") || "last_seen"; const order = searchParams.get("order") || "desc"; - const tz = config.timezone || ""; const hasActiveFilters = search !== "" || advType !== "" || @@ -80,17 +89,32 @@ export function Nodes() { (config.oidc_enabled && adoptedBy !== ""); const [filterOpen, setFilterOpen] = useState(hasActiveFilters); - const [nodes, setNodes] = useState([]); - const [total, setTotal] = useState(null); - const [profiles, setProfiles] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const oidcEnabled = config.oidc_enabled; const operatorRole = config.role_names?.operator || "operator"; - const fetchData = useCallback(async () => { - try { + const { paused, toggle, intervalSeconds, refetchInterval } = + useAutoRefresh(); + + const { + data, + isLoading: loading, + error: queryError, + } = useQuery({ + queryKey: qk.nodes.list({ + limit, + offset, + search, + advType, + sort, + order, + adoptedBy, + pubkeyPrefix, + oidcEnabled, + operatorRole, + }), + refetchInterval, + queryFn: async ({ signal }) => { const apiParams: Record = { limit, offset, @@ -103,50 +127,37 @@ export function Nodes() { if (pubkeyPrefix) apiParams.pubkey_prefix = pubkeyPrefix; const fetches: Promise[] = [ - apiGet("/api/v1/nodes", apiParams), + apiGet("/api/v1/nodes", apiParams, { signal }), ]; if (oidcEnabled) { fetches.push( - apiGet("/api/v1/user/profiles", { limit: 500 }), + apiGet( + "/api/v1/user/profiles", + { limit: 500 }, + { signal }, + ), ); } const results = await Promise.all(fetches); - const data = results[0] as NodeListResponse; + const nodeData = results[0] as NodeListResponse; const profs = oidcEnabled ? ((results[1] as ProfileListResponse)?.items || []).filter( (p) => p.roles && p.roles.includes(operatorRole), ) : []; - setNodes(data.items || []); - setTotal(data.total || 0); - setProfiles(profs); - setError(null); - } catch (e) { - setError((e as Error).message); - } finally { - setLoading(false); - } - }, [ - limit, - offset, - search, - advType, - sort, - order, - adoptedBy, - pubkeyPrefix, - oidcEnabled, - operatorRole, - ]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - const { paused, toggle, intervalSeconds } = useAutoRefresh({ - onRefresh: fetchData, + return { + nodes: nodeData.items || [], + total: nodeData.total || 0, + profiles: profs, + }; + }, }); + const error = queryError ? queryError.message : null; + + const nodes = data?.nodes ?? []; + const total = data?.total ?? null; + const profiles = data?.profiles ?? []; const sortedProfiles = useMemo( () => @@ -167,17 +178,13 @@ export function Nodes() { limit: String(limit), }; - const autoSubmit = (e: React.ChangeEvent) => { - e.currentTarget.form?.requestSubmit(); - }; - const noEntity = t("common.no_entity_found", { entity: t("entities.nodes").toLowerCase(), }); const mobileCards = nodes.length === 0 ? ( -
    {noEntity}
    + {noEntity} ) : ( nodes.map((node) => { const displayName = tagValue(node.tags, "name") || node.name; @@ -212,11 +219,7 @@ export function Nodes() { const tableRows = nodes.length === 0 ? ( - - - {noEntity} - - + {noEntity} ) : ( nodes.map((node) => { const displayName = tagValue(node.tags, "name") || node.name; @@ -225,27 +228,16 @@ export function Nodes() { return ( - - - + - copyToClipboard(e, node.public_key)} - title="Click to copy" - > - {node.public_key} - + {lastSeen} @@ -257,48 +249,17 @@ export function Nodes() { return (
    -
    -

    {t("entities.nodes")}

    - {tz && tz !== "UTC" && ( - {tz} - )} -
    + -
    - {total !== null && ( - - {t("common.total", { count: formatNumber(total) })} - - )} - {error && } -
    - {intervalSeconds > 0 && ( - - )} -
    -
    - setFilterOpen((open) => !open)} - /> -
    -
    + setFilterOpen((open) => !open), + }} + /> {filterOpen && (
    @@ -306,10 +267,7 @@ export function Nodes() { key={`filters-${search}-${advType}-${adoptedBy}-${pubkeyPrefix}`} basePath="/nodes" > -
    - + -
    -
    - - -
    + options={[ + { value: "", label: t("common.all_types") }, + { value: "chat", label: t("node_types.chat") }, + { value: "repeater", label: t("node_types.repeater") }, + { value: "companion", label: t("node_types.companion") }, + { value: "room", label: t("node_types.room") }, + ]} + /> + {oidcEnabled && sortedProfiles.length > 0 && ( -
    - - -
    + profiles={sortedProfiles} + /> + )}
    diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx index b18bd7f..717e299 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx @@ -1,13 +1,26 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { Link, useParams } from "react-router"; -import { useAppConfig } from "@/context/AppConfigContext"; import { usePageTitle } from "@/hooks/usePageTitle"; -import { apiGet, isAbortError } from "@/utils/api"; +import { apiGet } from "@/utils/api"; +import { qk } from "@/utils/queryKeys"; import { useFormatDateTime } from "@/utils/format"; -import { copyToClipboard } from "@/utils/clipboard"; import { Loading, WarningBadge } from "@/components/Alerts"; -import { JsonTree } from "@/components/JsonTree"; +import { Breadcrumbs } from "@/components/Breadcrumbs"; +import { NotFoundState } from "@/components/NotFoundState"; +import { DefinitionGrid } from "@/components/Definition"; +import { + buildChannelNames, + isNotFoundError, + type ChannelItem, +} from "@/utils/packets"; +import { + Field, + RedactedNotice, + RawHexBlock, + DecodedJsonBlock, + channelNameDisplay, +} from "@/components/PacketParts"; interface PacketDetailData { packet_hash: string | null; @@ -28,129 +41,71 @@ interface PacketDetailData { decoded: unknown; } -interface ChannelItem { - name: string; - channel_hash: string; -} - interface ChannelsResponse { items: ChannelItem[]; } -function buildChannelNames(items: ChannelItem[]): Map { - const names = new Map(); - for (const c of items) { - const idx = parseInt(c.channel_hash, 16); - if (!Number.isNaN(idx)) names.set(idx, c.name); - } - return names; -} - -function isNotFoundError(e: unknown): boolean { - return e instanceof Error && e.message.includes("404"); -} - -function Field({ label, children }: { label: string; children: ReactNode }) { - return ( -
    - {label} - {children} -
    - ); -} - export function PacketDetail() { const { t } = useTranslation(); usePageTitle("packets.detail_title"); const { id } = useParams(); - const config = useAppConfig(); const { formatDateTime } = useFormatDateTime(); - const [packet, setPacket] = useState(null); - const [channelNames, setChannelNames] = useState>( - new Map(), - ); - const [notFound, setNotFound] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - const controller = new AbortController(); - setPacket(null); - setNotFound(false); - setError(null); - Promise.all([ - apiGet(`/api/v1/packets/${id}`, {}, { - signal: controller.signal, - }), + const packetQuery = useQuery({ + queryKey: qk.packets.detail(id ?? ""), + queryFn: ({ signal }) => + apiGet(`/api/v1/packets/${id}`, {}, { signal }), + enabled: !!id, + }); + const channelsQuery = useQuery({ + queryKey: qk.channels.list({ limit: 200 }), + queryFn: ({ signal }) => apiGet("/api/v1/channels", { limit: 200 }, { - signal: controller.signal, + signal, }).catch(() => ({ items: [] as ChannelItem[] })), - ]) - .then(([p, channelsData]) => { - setPacket(p); - setChannelNames(buildChannelNames(channelsData.items || [])); - }) - .catch((e) => { - if (isAbortError(e)) return; - if (isNotFoundError(e)) { - setNotFound(true); - } else { - setError(e instanceof Error ? e.message : String(e)); - } - }); - return () => controller.abort(); - }, [id]); + }); + + const packet = packetQuery.data ?? null; + const channelNames = buildChannelNames(channelsQuery.data?.items || []); + const notFound = packetQuery.error + ? isNotFoundError(packetQuery.error) + : false; + const error = + packetQuery.error && !isNotFoundError(packetQuery.error) + ? packetQuery.error instanceof Error + ? packetQuery.error.message + : String(packetQuery.error) + : null; - const tz = config.timezone || ""; const leaf = packet?.packet_hash || packet?.event_type || ""; - - let channelDisplay: ReactNode = ; - if (packet && packet.channel_idx != null) { - const name = channelNames.get(packet.channel_idx); - channelDisplay = name - ? `${name} (${packet.channel_idx})` - : `${packet.channel_idx}`; - } + const channelDisplay = channelNameDisplay(channelNames, packet?.channel_idx ?? null); return (
    -
    -
      -
    • - {t("entities.home")} -
    • -
    • - {t("entities.packets")} -
    • -
    • {leaf || t("packets.detail_title")}
    • -
    -
    - -
    -

    {t("packets.detail_title")}

    - {tz && tz !== "UTC" && {tz}} -
    + {notFound && ( -
    - {t("common.entity_not_found_details", { + + /> )} {error && } {!packet && !notFound && !error && } {packet && ( <> - {packet.redacted && ( -
    - {"\u{1F512}"} {t("packets.redacted_notice")} -
    - )} + {packet.redacted && }
    -
    + {formatDateTime(packet.received_at)} @@ -205,38 +160,12 @@ export function PacketDetail() { {packet.path_len != null ? packet.path_len : "—"} -
    + - {!packet.redacted && ( -
    -
    - - {t("packets.col_raw")} - - {packet.raw_hex && ( - - )} -
    -
    -                    {packet.raw_hex || "—"}
    -                  
    -
    - )} + {!packet.redacted && } {!packet.redacted && packet.decoded != null && ( -
    - - {t("packets.decoded")} - -
    - -
    -
    + )}
    diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx index 56d2423..bede1a2 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx @@ -6,21 +6,36 @@ import { type ReactNode, } from "react"; import { createPortal } from "react-dom"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { Link, useParams } from "react-router"; -import { useAppConfig } from "@/context/AppConfigContext"; import { usePageTitle } from "@/hooks/usePageTitle"; import { apiGet, isAbortError } from "@/utils/api"; +import { qk } from "@/utils/queryKeys"; import { formatNumber, - formatRelativeTime, + resolveNodeName, truncateKey, useFormatDateTime, } from "@/utils/format"; -import { copyToClipboard } from "@/utils/clipboard"; import { Loading, WarningBadge } from "@/components/Alerts"; -import { JsonTree } from "@/components/JsonTree"; +import { Breadcrumbs } from "@/components/Breadcrumbs"; import { IconSatelliteDish } from "@/components/icons"; +import { NotFoundState } from "@/components/NotFoundState"; +import { TimeAgo } from "@/components/TimeAgo"; +import { DefinitionGrid } from "@/components/Definition"; +import { + buildChannelNames, + isNotFoundError, + type ChannelItem, +} from "@/utils/packets"; +import { + Field, + RedactedNotice, + RawHexBlock, + DecodedJsonBlock, + channelNameDisplay, +} from "@/components/PacketParts"; const PATH_MAX_BADGES = 16; const PATH_HEAD = 7; @@ -55,11 +70,6 @@ interface PacketGroupData { receptions: Reception[]; } -interface ChannelItem { - name: string; - channel_hash: string; -} - interface ChannelsResponse { items: ChannelItem[]; } @@ -82,19 +92,6 @@ interface PopoverAnchor { top: number; } -function buildChannelNames(items: ChannelItem[]): Map { - const names = new Map(); - for (const c of items) { - const idx = parseInt(c.channel_hash, 16); - if (!Number.isNaN(idx)) names.set(idx, c.name); - } - return names; -} - -function isNotFoundError(e: unknown): boolean { - return e instanceof Error && e.message.includes("404"); -} - function groupByObserver(receptions: Reception[]): Map { const groups = new Map(); for (const r of receptions) { @@ -109,20 +106,6 @@ function groupByObserver(receptions: Reception[]): Map { return groups; } -function nodeDisplayName(n: NodeItem): string { - const tagName = n.tags?.find((tag) => tag.key === "name")?.value; - return tagName || n.name || truncateKey(n.public_key, 12); -} - -function Field({ label, children }: { label: string; children: ReactNode }) { - return ( -
    - {label} - {children} -
    - ); -} - function Stat({ label, value }: { label: string; value: ReactNode }) { return (
    @@ -238,16 +221,8 @@ export function PacketGroupDetail() { const { t } = useTranslation(); usePageTitle("packets.detail_title"); const { hash } = useParams(); - const config = useAppConfig(); const { formatDateTime } = useFormatDateTime(); - const [group, setGroup] = useState(null); - const [channelNames, setChannelNames] = useState>( - new Map(), - ); - const [notFound, setNotFound] = useState(false); - const [error, setError] = useState(null); - const [popover, setPopover] = useState(null); const [popoverPos, setPopoverPos] = useState<{ left: number; @@ -258,33 +233,31 @@ export function PacketGroupDetail() { const [popoverError, setPopoverError] = useState(null); const popoverRef = useRef(null); - useEffect(() => { - const controller = new AbortController(); - setGroup(null); - setNotFound(false); - setError(null); - Promise.all([ - apiGet(`/api/v1/packet-groups/${hash}`, {}, { - signal: controller.signal, - }), + const groupQuery = useQuery({ + queryKey: qk.packets.group(hash ?? ""), + queryFn: ({ signal }) => + apiGet(`/api/v1/packet-groups/${hash}`, {}, { signal }), + enabled: !!hash, + }); + const channelsQuery = useQuery({ + queryKey: qk.channels.list({ limit: 200 }), + queryFn: ({ signal }) => apiGet("/api/v1/channels", { limit: 200 }, { - signal: controller.signal, + signal, }).catch(() => ({ items: [] as ChannelItem[] })), - ]) - .then(([g, channelsData]) => { - setGroup(g); - setChannelNames(buildChannelNames(channelsData.items || [])); - }) - .catch((e) => { - if (isAbortError(e)) return; - if (isNotFoundError(e)) { - setNotFound(true); - } else { - setError(e instanceof Error ? e.message : String(e)); - } - }); - return () => controller.abort(); - }, [hash]); + }); + + const group = groupQuery.data ?? null; + const channelNames = buildChannelNames(channelsQuery.data?.items || []); + const notFound = groupQuery.error + ? isNotFoundError(groupQuery.error) + : false; + const error = + groupQuery.error && !isNotFoundError(groupQuery.error) + ? groupQuery.error instanceof Error + ? groupQuery.error.message + : String(groupQuery.error) + : null; useEffect(() => { const onDocClick = (ev: MouseEvent) => { @@ -323,7 +296,7 @@ export function PacketGroupDetail() { const items = (data.items || []) .slice() .sort((a, b) => - nodeDisplayName(a).localeCompare(nodeDisplayName(b)), + resolveNodeName(a).localeCompare(resolveNodeName(b)), ); setPopoverNodes(items); setPopoverTotal(data.total || 0); @@ -371,64 +344,41 @@ export function PacketGroupDetail() { }); }; - const tz = config.timezone || ""; const leaf = group?.packet_hash || group?.event_type || ""; const receptions = group?.receptions ?? []; const sourcePrefix = group?.source_pubkey_prefix ?? null; const observerGroups = groupByObserver(receptions); const moreCount = popoverTotal - (popoverNodes?.length ?? 0); - let channelDisplay: ReactNode = ; - if (group && group.channel_idx != null) { - const name = channelNames.get(group.channel_idx); - channelDisplay = name - ? `${name} (${group.channel_idx})` - : `${group.channel_idx}`; - } - - const receptionTime = (r: Reception) => ( - - {formatRelativeTime(r.received_at)} - + const channelDisplay = channelNameDisplay( + channelNames, + group?.channel_idx ?? null, ); + const receptionTime = (r: Reception) => ; + return (
    -
    -
      -
    • - {t("entities.home")} -
    • -
    • - {t("entities.packets")} -
    • -
    • {leaf || t("packets.detail_title")}
    • -
    -
    - -
    -

    {t("packets.detail_title")}

    - {tz && tz !== "UTC" && {tz}} -
    + {notFound && ( -
    - {t("packets.not_found_retention")} -
    + )} {error && } {!group && !notFound && !error && } {group && ( <> - {group.redacted && ( -
    - {"\u{1F512}"} {t("packets.redacted_notice")} -
    - )} + {group.redacted && }
    -
    + {formatDateTime(group.first_seen)} @@ -471,7 +421,7 @@ export function PacketGroupDetail() { · {formatNumber(group.observer_count)}{" "} {t("common.observers").toLowerCase()} -
    + {receptions.length > 0 && (
    @@ -600,33 +550,11 @@ export function PacketGroupDetail() { )} {!group.redacted && group.raw_hex && ( -
    -
    - - {t("packets.col_raw")} - - -
    -
    -                    {group.raw_hex}
    -                  
    -
    + )} {!group.redacted && group.decoded != null && ( -
    - - {t("packets.decoded")} - -
    - -
    -
    + )}
    @@ -677,7 +605,7 @@ export function PacketGroupDetail() { onClick={() => setPopover(null)} className="flex flex-col items-start gap-0" > - {nodeDisplayName(n)} + {resolveNodeName(n)} {truncateKey(n.public_key, 16)} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx index 38f7ea2..8b9e4fd 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx @@ -1,24 +1,24 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { Link, useNavigate, useSearchParams } from "react-router"; import { useAppConfig } from "@/context/AppConfigContext"; import { usePageTitle } from "@/hooks/usePageTitle"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; -import { apiGet, isAbortError } from "@/utils/api"; +import { apiGet } from "@/utils/api"; +import { qk } from "@/utils/queryKeys"; import { formatNumber, useFormatDateTime } from "@/utils/format"; import { Pagination } from "@/components/Pagination"; -import { FilterForm, FilterToggle } from "@/components/FilterForm"; +import { FilterForm, FilterField } from "@/components/FilterForm"; import { MobileSortSelect, SortableTableHeader, } from "@/components/SortableTable"; -import { Loading, WarningBadge } from "@/components/Alerts"; -import { - IconPath, - IconRefresh, - IconRuler, - IconSatelliteDish, -} from "@/components/icons"; +import { Loading } from "@/components/Alerts"; +import { ListToolbar } from "@/components/ListToolbar"; +import { PageHeader } from "@/components/PageHeader"; +import { EmptyState, EmptyRow } from "@/components/EmptyState"; +import { IconPath, IconRuler, IconSatelliteDish } from "@/components/icons"; const EVENT_TYPES = [ "advertisement", @@ -174,10 +174,6 @@ export function Packets() { const order = searchParams.get("order") ?? "desc"; const offset = (page - 1) * limit; - const [packets, setPackets] = useState(null); - const [total, setTotal] = useState(0); - const [channels, setChannels] = useState([]); - const [error, setError] = useState(null); const hasActiveFilters = search !== "" || eventType !== "" || @@ -185,56 +181,59 @@ export function Packets() { pathHashBytes !== ""; const [filterOpen, setFilterOpen] = useState(hasActiveFilters); + const autoRefresh = useAutoRefresh(); + + const { data, error: queryError } = useQuery({ + queryKey: qk.packets.groups({ + search, + eventType, + channelIdx, + pathHashBytes, + limit, + offset, + sort, + order, + }), + refetchInterval: autoRefresh.refetchInterval, + queryFn: async ({ signal }) => { + const apiParams: Record = { + limit, + offset, + search, + sort, + order, + }; + if (eventType) apiParams.event_type = eventType; + if (channelIdx !== "") apiParams.channel_idx = channelIdx; + if (pathHashBytes !== "") apiParams.path_hash_bytes = pathHashBytes; + + const [groupsData, channelsData] = await Promise.all([ + apiGet("/api/v1/packet-groups", apiParams, { + signal, + }), + apiGet("/api/v1/channels", { limit: 200 }, { + signal, + }).catch(() => ({ items: [] as ChannelItem[] })), + ]); + + return { + packets: groupsData.items || [], + total: groupsData.total || 0, + channels: buildChannelList(channelsData.items || []), + }; + }, + }); + const error = queryError ? queryError.message : null; + + const packets = data?.packets ?? null; + const total = data?.total ?? 0; + const channels = data?.channels ?? []; + const channelNames = useMemo( () => new Map(channels.map((c) => [c.idx, c.name])), [channels], ); - const fetchData = useCallback( - async (signal?: AbortSignal) => { - try { - const apiParams: Record = { - limit, - offset, - search, - sort, - order, - }; - if (eventType) apiParams.event_type = eventType; - if (channelIdx !== "") apiParams.channel_idx = channelIdx; - if (pathHashBytes !== "") apiParams.path_hash_bytes = pathHashBytes; - - const [data, channelsData] = await Promise.all([ - apiGet("/api/v1/packet-groups", apiParams, { - signal, - }), - apiGet("/api/v1/channels", { limit: 200 }, { - signal, - }).catch(() => ({ items: [] as ChannelItem[] })), - ]); - - setPackets(data.items || []); - setTotal(data.total || 0); - setChannels(buildChannelList(channelsData.items || [])); - setError(null); - } catch (e) { - if (isAbortError(e)) return; - setError(e instanceof Error ? e.message : String(e)); - } - }, - [search, eventType, channelIdx, pathHashBytes, limit, offset, sort, order], - ); - - useEffect(() => { - const controller = new AbortController(); - fetchData(controller.signal); - return () => controller.abort(); - }, [fetchData]); - - const autoRefresh = useAutoRefresh({ - onRefresh: () => fetchData(), - }); - const applyFilters = (overrides: Record) => { const next = { search, @@ -251,7 +250,6 @@ export function Packets() { navigate(qs ? `/packets?${qs}` : "/packets"); }; - const tz = config.timezone || ""; const totalPages = Math.ceil(total / limit); const filterParams: Record = { search, @@ -266,56 +264,23 @@ export function Packets() { return (
    -
    -

    {t("entities.packets")}

    - {tz && tz !== "UTC" && {tz}} -
    + -
    - {packets !== null && ( - - {t("common.total", { count: formatNumber(total) })} - - )} - {error && } -
    - {autoRefresh.intervalSeconds > 0 && ( - - )} -
    -
    - setFilterOpen((o) => !o)} - /> -
    -
    + setFilterOpen((o) => !o) }} + /> {filterOpen && (
    -
    - + -
    -
    - + + -
    -
    - + + -
    -
    - + + -
    +
    )} @@ -412,7 +368,7 @@ export function Packets() {
    {packets.length === 0 ? ( -
    {noneFound}
    + {noneFound} ) : ( packets.map((p, i) => ( {packets.length === 0 ? ( - - - {noneFound} - - + {noneFound} ) : ( packets.map((p, i) => ( {roles.map((role) => ( - - {role} - + ))}
    ); @@ -67,10 +71,7 @@ function MemberSince({ createdAt }: { createdAt?: string | null }) { } function AdoptedNodeLink({ node }: { node: ProfileNode }) { - const { formatDateTime } = useFormatDateTime(); - const displayName = node.name || node.public_key.slice(0, 12) + "..."; - const relTime = node.last_seen ? formatRelativeTime(node.last_seen) : "-"; - const fullTime = node.last_seen ? formatDateTime(node.last_seen) : "-"; + const displayName = resolveNodeName(node); return (
    - + {node.last_seen ? ( + + ) : ( + + - + + )} ); } @@ -125,26 +129,12 @@ function AdoptedNodesCard({ function PublicProfileView({ id }: { id: string }) { const { t } = useTranslation(); const config = useAppConfig(); - const [profile, setProfile] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - const controller = new AbortController(); - setProfile(null); - setError(null); - apiGet( - `/api/v1/user/profile/${id}`, - {}, - { signal: controller.signal }, - ) - .then(setProfile) - .catch((e) => { - if (!isAbortError(e)) { - setError((e as Error).message || t("common.failed_to_load_page")); - } - }); - return () => controller.abort(); - }, [id, t]); + const { data: profile, error: queryError } = useQuery({ + queryKey: qk.profiles.detail(id), + queryFn: ({ signal }) => + apiGet(`/api/v1/user/profile/${id}`, {}, { signal }), + }); + const error = queryError ? queryError.message : null; if (error) return ; if (!profile) return ; @@ -154,24 +144,26 @@ function PublicProfileView({ id }: { id: string }) { return ( <> -
    -

    {t("user_profile.title")}

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

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

    {profile.description && ( @@ -202,26 +194,24 @@ function OwnProfileView() { const config = useAppConfig(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const [profile, setProfile] = useState(null); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); + const { data: profile, error: queryError } = useQuery({ + queryKey: qk.profiles.me(), + queryFn: ({ signal }) => + apiGet("/api/v1/user/profile/me", {}, { signal }), + }); + const error = queryError ? queryError.message : null; - useEffect(() => { - const controller = new AbortController(); - setProfile(null); - setError(null); - apiGet( - "/api/v1/user/profile/me", - {}, - { signal: controller.signal }, - ) - .then(setProfile) - .catch((e) => { - if (!isAbortError(e)) { - setError((e as Error).message || t("common.failed_to_load_page")); - } - }); - return () => controller.abort(); - }, [searchParams, t]); + const updateMutation = useMutation({ + mutationFn: ({ + id, + body, + }: { + id: string; + body: Record; + }) => apiPut(`/api/v1/user/profile/${id}`, body), + onSuccess: () => invalidate.profiles(queryClient), + }); if (!config.oidc_enabled || !config.user) { return ( @@ -251,7 +241,7 @@ function OwnProfileView() { url: String(data.get("url") ?? "").trim() || null, }; try { - await apiPut(`/api/v1/user/profile/${profile.id}`, body); + await updateMutation.mutateAsync({ id: profile.id, body }); navigate( "/profile?message=" + encodeURIComponent(t("user_profile.profile_updated")), { replace: true }, @@ -266,9 +256,13 @@ function OwnProfileView() { return ( <> -
    -

    {t("user_profile.title")}

    -
    + + {flashMessage ? ( diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx index e30756b..cd70c3e 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx @@ -1,18 +1,24 @@ import { Fragment, - useCallback, useEffect, useRef, useState, type SVGProps, } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; import { useAppConfig, hasRole } from "@/context/AppConfigContext"; import { apiGet, apiPost, apiPut, apiDelete } from "@/utils/api"; +import { qk, invalidate } from "@/utils/queryKeys"; import { usePageTitle } from "@/hooks/usePageTitle"; import { Loading, ErrorAlert } from "@/components/Alerts"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { EmptyState } from "@/components/EmptyState"; +import { Modal } from "@/components/Modal"; +import { PageHeader } from "@/components/PageHeader"; +import { SectionGroup } from "@/components/SectionGroup"; import { RouteDetailStrip } from "@/components/charts/Charts"; import { IconClock, @@ -514,8 +520,6 @@ function DetailContent({ function RouteCard({ route, - detail, - history, isAdmin, packetsEnabled, onEdit, @@ -523,8 +527,6 @@ function RouteCard({ onNavigate, }: { route: RouteItem; - detail: RouteDetail | undefined; - history: RouteHistory | undefined; isAdmin: boolean; packetsEnabled: boolean; onEdit: () => void; @@ -532,6 +534,20 @@ function RouteCard({ onNavigate: (url: string) => void; }) { const { t } = useTranslation(); + const { data: detail } = useQuery({ + queryKey: qk.routes.detail(route.id), + queryFn: ({ signal }) => + apiGet(`/api/v1/routes/${route.id}`, {}, { signal }), + }); + const { data: history } = useQuery({ + queryKey: qk.routes.history(route.id, 6), + queryFn: ({ signal }) => + apiGet( + `/api/v1/routes/${route.id}/history`, + { days: 6 }, + { signal }, + ), + }); const q = qualityOf(route); const badgeCls = qualityBadgeClass(q, route.enabled); const label = qualityLabel(q, route.enabled, t); @@ -765,11 +781,11 @@ function RouteModal({ }; return ( - -
    -

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

    +
    @@ -1093,11 +1109,7 @@ function RouteModal({
    -
    -
    - -
    -
    + ); } @@ -1117,30 +1129,15 @@ function DeleteRouteModal({ const label = `${route.from_label} ${arrow} ${route.to_label}`; return ( - -
    -

    {t("routes.delete_route")}

    -

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

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

    } + confirmLabel={t("common.delete")} + cancelLabel={t("common.cancel")} + saving={saving} + onConfirm={onConfirm} + onCancel={onCancel} + /> ); } @@ -1152,90 +1149,53 @@ export function RoutesPage() { const isAdmin = hasRole("admin"); usePageTitle("routes.title"); - const [routes, setRoutes] = useState([]); - const [detailCache, setDetailCache] = useState>( - {}, - ); - const [historyCache, setHistoryCache] = useState< - Record - >({}); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); + + const { + data: routesData, + isLoading: loading, + error: queryError, + } = useQuery({ + queryKey: qk.routes.list(), + queryFn: async ({ signal }) => { + const data = await apiGet( + "/api/v1/routes", + {}, + { signal }, + ); + return data.items || []; + }, + }); + const routes = routesData ?? []; + const error = queryError ? queryError.message : null; const [modal, setModal] = useState(null); - const detailCacheRef = useRef>({}); - const historyCacheRef = useRef>({}); const pathTimerRef = useRef | null>(null); const obsTimerRef = useRef | null>(null); const pathSearchIdRef = useRef(0); const obsSearchIdRef = useRef(0); - const loadAllDetails = useCallback(async (routesList: RouteItem[]) => { - const newDetails: Record = {}; - const newHistories: Record = {}; - const promises: Promise[] = []; - for (const r of routesList) { - if (!detailCacheRef.current[r.id]) { - promises.push( - apiGet(`/api/v1/routes/${r.id}`) - .then((d) => { - newDetails[r.id] = d; - }) - .catch(() => undefined), - ); + const saveMutation = useMutation({ + mutationFn: async ({ + id, + body, + }: { + id?: string; + body: Record; + }) => { + if (id) { + await apiPut(`/api/v1/routes/${id}`, body); + } else { + await apiPost("/api/v1/routes", body); } - if (!historyCacheRef.current[r.id]) { - promises.push( - apiGet(`/api/v1/routes/${r.id}/history`, { days: 6 }) - .then((h) => { - newHistories[r.id] = h; - }) - .catch(() => undefined), - ); - } - } - if (promises.length === 0) return; - await Promise.allSettled(promises); - if (Object.keys(newDetails).length > 0) { - detailCacheRef.current = { ...detailCacheRef.current, ...newDetails }; - setDetailCache(detailCacheRef.current); - } - if (Object.keys(newHistories).length > 0) { - historyCacheRef.current = { ...historyCacheRef.current, ...newHistories }; - setHistoryCache(historyCacheRef.current); - } - }, []); + }, + onSuccess: () => invalidate.routes(queryClient), + }); - const fetchRoutes = useCallback(async (): Promise => { - try { - const data = await apiGet("/api/v1/routes"); - const items = data.items || []; - setRoutes(items); - setError(null); - return items; - } catch (e) { - setError((e as Error).message || t("common.failed_to_load_page")); - return []; - } finally { - setLoading(false); - } - }, [t]); - - const refresh = useCallback(async () => { - const items = await fetchRoutes(); - await loadAllDetails(items); - }, [fetchRoutes, loadAllDetails]); - - useEffect(() => { - let active = true; - (async () => { - const items = await fetchRoutes(); - if (active) await loadAllDetails(items); - })(); - return () => { - active = false; - }; - }, [fetchRoutes, loadAllDetails]); + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiDelete(`/api/v1/routes/${id}`), + onSuccess: () => invalidate.routes(queryClient), + }); useEffect(() => { return () => { @@ -1461,22 +1421,11 @@ export function RoutesPage() { } setModal((m) => (m ? { ...m, saving: true } : m)); try { - if (isEdit && modal.route) { - const id = modal.route.id; - await apiPut(`/api/v1/routes/${id}`, body); - const nextDetails = { ...detailCacheRef.current }; - delete nextDetails[id]; - detailCacheRef.current = nextDetails; - setDetailCache(nextDetails); - const nextHistories = { ...historyCacheRef.current }; - delete nextHistories[id]; - historyCacheRef.current = nextHistories; - setHistoryCache(nextHistories); - } else { - await apiPost("/api/v1/routes", body); - } + await saveMutation.mutateAsync({ + id: isEdit && modal.route ? modal.route.id : undefined, + body, + }); setModal(null); - await refresh(); } catch (e) { setModal((m) => (m ? { ...m, saving: false } : m)); alert((e as Error).message || "Failed to save route"); @@ -1487,9 +1436,8 @@ export function RoutesPage() { if (!modal || modal.type !== "delete" || !modal.route) return; setModal((m) => (m ? { ...m, saving: true } : m)); try { - await apiDelete(`/api/v1/routes/${modal.route.id}`); + await deleteMutation.mutateAsync(modal.route.id); setModal(null); - await refresh(); } catch (e) { setModal((m) => (m ? { ...m, saving: false } : m)); alert((e as Error).message || "Failed to delete route"); @@ -1508,12 +1456,14 @@ export function RoutesPage() { return (
    -
    -

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

    -
    + + + {t("routes.title")} + + } + /> @@ -1531,11 +1481,11 @@ export function RoutesPage() { )} {routes.length === 0 && ( -
    + {t("common.no_entity_found", { entity: t("entities.routes").toLowerCase(), })} -
    + )} {VISIBILITY_ORDER.map((vis) => { @@ -1548,16 +1498,11 @@ export function RoutesPage() { if (group.length === 0) return null; return (
    -

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

    -
    + {group.map((r) => ( openEditModal(r)} @@ -1565,7 +1510,7 @@ export function RoutesPage() { onNavigate={navigate} /> ))} -
    +
    ); })} diff --git a/src/meshcore_hub/web/static/js/spa-react/test/renderWithProviders.tsx b/src/meshcore_hub/web/static/js/spa-react/test/renderWithProviders.tsx new file mode 100644 index 0000000..a832437 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/test/renderWithProviders.tsx @@ -0,0 +1,48 @@ +import type { ReactElement, ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router"; +import { render, type RenderOptions } from "@testing-library/react"; + +import { AppConfigProvider } from "@/context/AppConfigContext"; +import { makeConfig } from "@/test/makeConfig"; +import type { AppConfig } from "@/types/config"; + +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Infinity, gcTime: Infinity }, + mutations: { retry: false }, + }, + }); +} + +interface ProviderOptions { + config?: AppConfig; + client?: QueryClient; + route?: string; + renderOptions?: Omit; +} + +export function renderWithProviders( + ui: ReactElement, + options: ProviderOptions = {}, +) { + const { + config = makeConfig(), + client = createTestQueryClient(), + route = "/", + renderOptions, + } = options; + + function Wrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); + } + + return { client, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }; +} diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts index 9b70179..663784d 100644 --- a/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts +++ b/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts @@ -6,6 +6,7 @@ import { formatRelativeTime, getNodeEmoji, parseAppDate, + resolveNodeName, truncateKey, typeEmoji, } from "@/utils/format"; @@ -72,6 +73,38 @@ describe("truncateKey", () => { }); }); +describe("resolveNodeName", () => { + const key = "0123456789abcdef0123456789abcdef"; + + it("returns '-' for null/undefined nodes", () => { + expect(resolveNodeName(null)).toBe("-"); + expect(resolveNodeName(undefined)).toBe("-"); + }); + + it("prefers a 'name' tag value", () => { + expect( + resolveNodeName({ + name: "Real Name", + public_key: key, + tags: [{ key: "name", value: "Tag Name" }], + }), + ).toBe("Tag Name"); + }); + + it("falls back to the node name when there is no name tag", () => { + expect(resolveNodeName({ name: "Real Name", public_key: key })).toBe( + "Real Name", + ); + }); + + it("falls back to a truncated public key when there is no name", () => { + expect(resolveNodeName({ name: null, public_key: key })).toBe( + "0123456789ab...", + ); + expect(resolveNodeName({ public_key: key })).toBe("0123456789ab..."); + }); +}); + describe("typeEmoji", () => { it("maps node types to emoji (incl. inference from substrings)", () => { expect(typeEmoji("chat")).toBe("\u{1F4AC}"); diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/format.ts b/src/meshcore_hub/web/static/js/spa-react/utils/format.ts index 79845fe..05eab35 100644 --- a/src/meshcore_hub/web/static/js/spa-react/utils/format.ts +++ b/src/meshcore_hub/web/static/js/spa-react/utils/format.ts @@ -105,6 +105,21 @@ export function truncateKey(key: string | null, length = 12): string { return key.slice(0, length) + "..."; } +export function resolveNodeName( + node: { + name?: string | null; + public_key?: string | null; + tags?: { key: string; value: string | null }[]; + } | null | undefined, + fallbackLength = 12, +): string { + if (!node) return "-"; + const tagName = node.tags?.find((tag) => tag.key === "name")?.value; + return ( + tagName || node.name || truncateKey(node.public_key ?? null, fallbackLength) + ); +} + function inferNodeType(value: string | null): string | null { const normalized = (value ?? "").toLowerCase(); if (!normalized) return null; diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/packets.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/packets.test.ts new file mode 100644 index 0000000..656b5bd --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/packets.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { buildChannelNames, isNotFoundError } from "@/utils/packets"; + +describe("buildChannelNames", () => { + it("maps hex channel_hash to name by parsed index", () => { + const names = buildChannelNames([ + { name: "General", channel_hash: "0" }, + { name: "Lobby", channel_hash: "a" }, + { name: "Ops", channel_hash: "ff" }, + ]); + expect(names.get(0)).toBe("General"); + expect(names.get(10)).toBe("Lobby"); + expect(names.get(255)).toBe("Ops"); + }); + + it("skips entries whose hash is not a number", () => { + const names = buildChannelNames([ + { name: "Bad", channel_hash: "zz" }, + { name: "Good", channel_hash: "1" }, + ]); + expect(names.size).toBe(1); + expect(names.get(1)).toBe("Good"); + }); + + it("returns an empty map for no items", () => { + expect(buildChannelNames([]).size).toBe(0); + }); +}); + +describe("isNotFoundError", () => { + it("detects 404 in the error message", () => { + expect(isNotFoundError(new Error("API error: 404 Not Found"))).toBe(true); + }); + + it("returns false for other errors and non-Error values", () => { + expect(isNotFoundError(new Error("API error: 500"))).toBe(false); + expect(isNotFoundError("404")).toBe(false); + expect(isNotFoundError(null)).toBe(false); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/packets.ts b/src/meshcore_hub/web/static/js/spa-react/utils/packets.ts new file mode 100644 index 0000000..37b972c --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/packets.ts @@ -0,0 +1,17 @@ +export interface ChannelItem { + name: string; + channel_hash: string; +} + +export function buildChannelNames(items: ChannelItem[]): Map { + const names = new Map(); + for (const c of items) { + const idx = parseInt(c.channel_hash, 16); + if (!Number.isNaN(idx)) names.set(idx, c.name); + } + return names; +} + +export function isNotFoundError(e: unknown): boolean { + return e instanceof Error && e.message.includes("404"); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/queryClient.ts b/src/meshcore_hub/web/static/js/spa-react/utils/queryClient.ts new file mode 100644 index 0000000..255796d --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/queryClient.ts @@ -0,0 +1,15 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const DEFAULT_STALE_TIME_MS = 30_000; + +export function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: DEFAULT_STALE_TIME_MS, + refetchOnWindowFocus: true, + retry: 1, + }, + }, + }); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts b/src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts new file mode 100644 index 0000000..7428c8a --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts @@ -0,0 +1,78 @@ +import type { QueryClient } from "@tanstack/react-query"; + +export const qk = { + nodes: { + all: ["nodes"] as const, + list: (params: unknown) => ["nodes", "list", params] as const, + detail: (publicKey: string) => ["nodes", "detail", publicKey] as const, + prefix: (prefix: string) => ["nodes", "prefix", prefix] as const, + }, + messages: { + all: ["messages"] as const, + list: (params: unknown) => ["messages", "list", params] as const, + }, + channels: { + all: ["channels"] as const, + list: (params: unknown) => ["channels", "list", params] as const, + }, + routes: { + all: ["routes"] as const, + list: () => ["routes", "list"] as const, + detail: (id: string) => ["routes", "detail", id] as const, + history: (id: string, days: number) => + ["routes", "history", id, days] as const, + }, + advertisements: { + all: ["advertisements"] as const, + list: (params: unknown) => ["advertisements", "list", params] as const, + }, + profiles: { + all: ["profiles"] as const, + list: (params: unknown) => ["profiles", "list", params] as const, + detail: (id: string) => ["profiles", "detail", id] as const, + me: () => ["profiles", "me"] as const, + }, + dashboard: { + all: ["dashboard"] as const, + stats: () => ["dashboard", "stats"] as const, + series: (kind: string, params: unknown) => + ["dashboard", "series", kind, params] as const, + recent: (params: unknown) => ["dashboard", "recent", params] as const, + routesOverview: () => ["dashboard", "routes-overview"] as const, + }, + packets: { + all: ["packets"] as const, + groups: (params: unknown) => ["packets", "groups", params] as const, + group: (hash: string) => ["packets", "group", hash] as const, + detail: (id: string) => ["packets", "detail", id] as const, + }, + map: { + all: ["map"] as const, + data: (params: unknown) => ["map", "data", params] as const, + }, +}; + +export const invalidate = { + channels: (qc: QueryClient) => + qc.invalidateQueries({ queryKey: qk.channels.all }), + routes: (qc: QueryClient) => { + qc.invalidateQueries({ queryKey: qk.routes.all }); + qc.invalidateQueries({ queryKey: qk.dashboard.all }); + }, + profiles: (qc: QueryClient) => { + qc.invalidateQueries({ queryKey: qk.profiles.all }); + qc.invalidateQueries({ queryKey: qk.dashboard.all }); + }, + nodeTags: (qc: QueryClient) => { + qc.invalidateQueries({ queryKey: qk.nodes.all }); + qc.invalidateQueries({ queryKey: qk.messages.all }); + qc.invalidateQueries({ queryKey: qk.advertisements.all }); + qc.invalidateQueries({ queryKey: qk.dashboard.all }); + }, + adoptions: (qc: QueryClient) => { + qc.invalidateQueries({ queryKey: qk.nodes.all }); + qc.invalidateQueries({ queryKey: qk.profiles.all }); + qc.invalidateQueries({ queryKey: qk.advertisements.all }); + qc.invalidateQueries({ queryKey: qk.dashboard.all }); + }, +}; diff --git a/tests/test_api/test_cache.py b/tests/test_api/test_cache.py index f563d80..09c1ab0 100644 --- a/tests/test_api/test_cache.py +++ b/tests/test_api/test_cache.py @@ -1313,12 +1313,13 @@ class TestKeyBuilders: ): scope = { "type": "http", + "path": "/api/v1/channels", "query_string": b"", "headers": [], } request = Request(scope) key = _channels_key_builder(request) - assert key == "channels:role=operator:" + assert key == "/api/v1/channels:role=operator:" def test_channels_key_builder_anonymous(self): from meshcore_hub.api.routes.channels import _channels_key_builder @@ -1329,6 +1330,7 @@ class TestKeyBuilders: ): scope = { "type": "http", + "path": "/api/v1/channels", "query_string": b"", "headers": [], } @@ -1345,14 +1347,54 @@ class TestKeyBuilders: ): scope = { "type": "http", + "path": "/api/v1/messages", "query_string": b"limit=10&offset=0", "headers": [], } request = Request(scope) key = _messages_key_builder(request) - assert "role=admin" in key + assert key.startswith("/api/v1/messages:role=admin:") assert "limit=10" in key + def test_role_aware_keys_match_invalidation_prefixes(self): + """The GET cache key must live under the prefix the matching + invalidation helper drops, otherwise a mutation never clears the + stale cached list (the store path and delete path must agree). + """ + from meshcore_hub.api.routes.channels import _channels_key_builder + from meshcore_hub.api.routes.messages import _messages_key_builder + + with ( + patch( + "meshcore_hub.api.routes.channels.resolve_user_role", + return_value="admin", + ), + patch( + "meshcore_hub.api.routes.messages.resolve_user_role", + return_value="admin", + ), + ): + channels_req = Request( + { + "type": "http", + "path": "/api/v1/channels", + "query_string": b"", + "headers": [], + } + ) + messages_req = Request( + { + "type": "http", + "path": "/api/v1/messages", + "query_string": b"", + "headers": [], + } + ) + # invalidate_channels drops "/api/v1/channels", + # invalidate_messages drops "/api/v1/messages". + assert _channels_key_builder(channels_req).startswith("/api/v1/channels") + assert _messages_key_builder(messages_req).startswith("/api/v1/messages") + def _make_request_with_cache(cache): """Build a Request whose ``app.state.redis_cache`` is *cache* (or absent).""" From 94dd4c9b4254052a0317df54f7289b51365b387a Mon Sep 17 00:00:00 2001 From: Louis King Date: Wed, 22 Jul 2026 10:41:53 +0100 Subject: [PATCH 08/11] test(e2e): replace Python e2e suite with Playwright (headless Chromium) Replaces the httpx-based smoke tests in tests/e2e/ with a browser E2E suite under e2e/, running against a self-contained throwaway stack that never touches the local development database. Stack & data isolation (e2e/docker-compose.test.yml): - Own ephemeral Postgres 17 (schema via the `migrate` service / Alembic), distinct project name + named volumes, no host DB port, no \${VAR} interpolation. `make e2e-down` destroys everything. - OIDC enabled with a known session secret; WEB_AUTO_REFRESH_SECONDS=2 so polling is assertable; CONTENT_HOME mounts a test markdown page. - Deterministic seeder (e2e/seed_data.py) run via global setup inside the collector container: nodes/observers with `area` tags, adverts, messages on the public (17) + a custom channel, raw packets + path hops keyed to node prefixes, a route + health/history, profiles + adoptions, and event_observers rows so the observer filter/badges resolve. Auth & tests: - Forged signed `meshcore-session` cookies (e2e/mint_session.py, itsdangerous) for admin/member identities -> storageState; no real IdP needed. 31 specs across 12 files cover global nav/theme, profile menu+edit, home hero, dashboard widgets, list filters/auto-refresh/row actions, observer toggles, the path-node overlay, map filters + show-labels, members, markdown pages, and routes add/edit/delete (persistence, validation, confirm dialog). workers:1 / fullyParallel:false against the single shared backend; targeted data-testids added to React components for stable selectors. Fixes surfaced while running the suite on fresh Postgres: - Migration 5e3b712ccf10 aborted on Postgres: its route-health backfill queries the live Route model (which now has max_path_length) before that column exists. The swallowed error left the transaction aborted, killing the subsequent alembic_version stamp. Wrapped the backfill in a SAVEPOINT so a failure rolls back cleanly without blocking the migration. - Restored the full ABUSE_* env set required by the MQTT broker, and set the web service's API_KEY to the admin key (admin writes go through the proxy as a Bearer token). 31/31 passing; tsc (frontend+e2e), pytest (1460), and pre-commit green. --- .gitignore | 6 + AGENTS.md | 32 ++ Makefile | 25 +- README.md | 21 + ..._5e3b712ccf10_route_health_consolidated.py | 11 +- e2e/content/pages/about.md | 13 + e2e/docker-compose.test.yml | 245 +++++++++ e2e/global-setup.ts | 157 ++++++ e2e/mint_session.py | 53 ++ e2e/playwright.config.ts | 27 + e2e/seed_data.py | 491 ++++++++++++++++++ e2e/tests/advertisements.spec.ts | 91 ++++ e2e/tests/custom-page.spec.ts | 27 + e2e/tests/dashboard.spec.ts | 35 ++ e2e/tests/global.spec.ts | 62 +++ e2e/tests/home.spec.ts | 50 ++ e2e/tests/map.spec.ts | 43 ++ e2e/tests/members.spec.ts | 50 ++ e2e/tests/messages.spec.ts | 69 +++ e2e/tests/nodes.spec.ts | 69 +++ e2e/tests/packets.spec.ts | 91 ++++ e2e/tests/routes.spec.ts | 137 +++++ e2e/tests/users.spec.ts | 47 ++ e2e/tsconfig.json | 16 + e2e/utils/helpers.ts | 39 ++ package-lock.json | 82 +++ package.json | 6 +- pyproject.toml | 9 +- .../js/spa-react/components/AuthSection.tsx | 5 +- .../components/AutoRefreshToggle.tsx | 1 + .../js/spa-react/components/MobileNav.tsx | 2 + .../static/js/spa-react/components/Navbar.tsx | 2 + .../spa-react/components/ObserverBadges.tsx | 2 + .../js/spa-react/components/ThemeToggle.tsx | 7 +- .../js/spa-react/pages/Advertisements.tsx | 1 + .../web/static/js/spa-react/pages/Home.tsx | 2 + .../web/static/js/spa-react/pages/Members.tsx | 4 + .../static/js/spa-react/pages/Messages.tsx | 1 + .../web/static/js/spa-react/pages/Nodes.tsx | 2 +- .../js/spa-react/pages/PacketGroupDetail.tsx | 5 + .../web/static/js/spa-react/pages/Packets.tsx | 1 + .../web/static/js/spa-react/pages/Routes.tsx | 45 +- tests/e2e/__init__.py | 1 - tests/e2e/conftest.py | 157 ------ tests/e2e/docker-compose.test.yml | 145 ------ tests/e2e/test_full_flow.py | 206 -------- 46 files changed, 2069 insertions(+), 524 deletions(-) create mode 100644 e2e/content/pages/about.md create mode 100644 e2e/docker-compose.test.yml create mode 100644 e2e/global-setup.ts create mode 100644 e2e/mint_session.py create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/seed_data.py create mode 100644 e2e/tests/advertisements.spec.ts create mode 100644 e2e/tests/custom-page.spec.ts create mode 100644 e2e/tests/dashboard.spec.ts create mode 100644 e2e/tests/global.spec.ts create mode 100644 e2e/tests/home.spec.ts create mode 100644 e2e/tests/map.spec.ts create mode 100644 e2e/tests/members.spec.ts create mode 100644 e2e/tests/messages.spec.ts create mode 100644 e2e/tests/nodes.spec.ts create mode 100644 e2e/tests/packets.spec.ts create mode 100644 e2e/tests/routes.spec.ts create mode 100644 e2e/tests/users.spec.ts create mode 100644 e2e/tsconfig.json create mode 100644 e2e/utils/helpers.ts delete mode 100644 tests/e2e/__init__.py delete mode 100644 tests/e2e/conftest.py delete mode 100644 tests/e2e/docker-compose.test.yml delete mode 100644 tests/e2e/test_full_flow.py diff --git a/.gitignore b/.gitignore index e042944..10ec52c 100644 --- a/.gitignore +++ b/.gitignore @@ -229,3 +229,9 @@ node_modules/ src/meshcore_hub/web/static/vendor/ src/meshcore_hub/web/static/dist/ src/meshcore_hub/web/static/css/tailwind.css + +# Playwright e2e artifacts +/e2e/.auth/ +/e2e/test-results/ +/e2e/playwright-report/ +/e2e/blob-report/ diff --git a/AGENTS.md b/AGENTS.md index b9449df..2057401 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,38 @@ pytest --no-cov pre-commit run --all-files ``` +Browser E2E lives in **`e2e/`** (Playwright, headless Chromium) and replaces the +old Python e2e suite. It runs against a **throwaway stack** (`e2e/docker-compose.test.yml`) +with its own ephemeral Postgres and isolated volumes — it never touches the dev +database. Like the rest of the stack, **the assistant never builds/runs these +images**; the user does. + +```bash +npx playwright install chromium # one-time browser binary (host) +make e2e-build && make e2e-up # user: build + start mqtt/pg/migrate/collector/api/web +make e2e-test # user: seeds via e2e/seed_data.py, then runs the suite +make e2e-down # user: tear down (destroys the throwaway DB) +npm run typecheck:e2e # assistant: typecheck the e2e TS (safe to run) +npx playwright test --config=e2e/playwright.config.ts --list # assistant: verify collection +``` + +Design notes when extending the suite: +- **Auth is forged, not logged in.** No mock IdP exists; the web tier fully trusts + the signed `meshcore-session` cookie. `e2e/mint_session.py` (itsdangerous, run + with `.venv` python) mints admin/member cookies using the stack's + `OIDC_SESSION_SECRET=test-session-secret`; global setup writes them to + `e2e/.auth/*.json` and specs opt in via `test.use({ storageState })`. OIDC is + enabled in the test stack (which also unlocks the Members feature). +- **Data is deterministic.** `e2e/seed_data.py` clears + recreates fixed rows + (nodes/observers with `area` tags, adverts, messages on channel idx 17 + the + "E2E General" custom channel, raw packets + path hops keyed to node prefixes, + a route + health, profiles + adoptions) using recent timestamps (7-day windows). +- **Single shared backend:** `workers: 1`, `fullyParallel: false`; routes/profile + specs are `describe.serial`. `WEB_AUTO_REFRESH_SECONDS=2` makes polling assertable. +- Selectors rely on purposeful `data-testid`s (theme/auto-refresh toggles, observer + area badges, path-hop badge + popover, route modal fields, nav/hero/member/list + rows) added to the React components. + ## Database & Ops The default backend is **SQLite** (zero-config, file at `${DATA_HOME}/collector/meshcore.db`). **PostgreSQL** is also supported via `DATABASE_BACKEND=postgres` — see `docs/database.md` for the full backend reference, production provisioning, and schema-per-instance setup. Migrations are backend-agnostic; the commands below work for both. diff --git a/Makefile b/Makefile index 74dae20..afd09c1 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,8 @@ COMPOSE_FILES = -f docker-compose.yml -f docker-compose.dev.yml VOLUMES = $(COMPOSE_PROJECT_NAME)_data $(COMPOSE_PROJECT_NAME)_mqtt_data \ $(COMPOSE_PROJECT_NAME)_observer_data -.PHONY: build up down logs backup restore test test-cov test-unit test-frontend +.PHONY: build up down logs backup restore test test-cov test-unit test-frontend \ + e2e-build e2e-up e2e-down e2e-seed e2e-test build: docker compose $(COMPOSE_FILES) --profile all build --no-cache @@ -49,3 +50,25 @@ test-unit: test-frontend: npm run test:frontend + +# --- E2E (Playwright) --------------------------------------------------- +# Self-contained throwaway stack (own ephemeral Postgres, isolated volumes). +# make e2e-build && make e2e-up # start the stack (build first time) +# make e2e-test # seeds data, then runs the Playwright suite +# make e2e-down # tears everything down (destroys the DB) +E2E_COMPOSE = docker compose -f e2e/docker-compose.test.yml + +e2e-build: + $(E2E_COMPOSE) build + +e2e-up: + $(E2E_COMPOSE) up -d + +e2e-down: + $(E2E_COMPOSE) down -v --remove-orphans + +e2e-seed: + $(E2E_COMPOSE) exec -T collector python /seed_data.py + +e2e-test: + npm run test:e2e diff --git a/README.md b/README.md index 936a4b3..d4a1300 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,27 @@ pytest tests/test_api/test_nodes.py pytest -k "test_list" ``` +### End-to-End Tests (Playwright) + +Browser E2E tests live in `e2e/` and run against a self-contained throwaway +stack (its own ephemeral Postgres, isolated volumes — never the dev database): + +```bash +npx playwright install chromium # one-time: browser binary + +make e2e-build # build the images (first time / after changes) +make e2e-up # start mqtt + postgres + migrate + collector + api + web +make e2e-test # seeds deterministic data, then runs the suite +make e2e-down # tear down (destroys the throwaway database) + +npm run typecheck:e2e # typecheck the e2e suite +``` + +The Playwright global setup seeds the database (via `e2e/seed_data.py` inside +the collector container), waits for the web service, and forges signed +`meshcore-session` cookies (admin + member) so authenticated/admin flows can be +tested without a real OIDC provider (`e2e/mint_session.py`). + ### Code Quality ```bash diff --git a/alembic/versions/20260719_1951_5e3b712ccf10_route_health_consolidated.py b/alembic/versions/20260719_1951_5e3b712ccf10_route_health_consolidated.py index 8ab3fe0..867df7b 100644 --- a/alembic/versions/20260719_1951_5e3b712ccf10_route_health_consolidated.py +++ b/alembic/versions/20260719_1951_5e3b712ccf10_route_health_consolidated.py @@ -531,8 +531,17 @@ def upgrade() -> None: # there are zero routes, so this is effectively a no-op; the loop is # retained so a restore from a dev backup that DOES have routes # backfills correctly. + # + # Runs inside a SAVEPOINT: the backfill imports the live ORM models, + # which can reference columns added by later migrations (e.g. + # routes.max_path_length). On Postgres a failed statement aborts the + # whole transaction, so without the savepoint the swallowed error would + # still kill the subsequent alembic_version stamp. Rolling back to the + # savepoint leaves the outer migration transaction healthy on both + # backends. try: - _backfill_history() + with conn.begin_nested(): + _backfill_history() except Exception as e: # noqa: BLE001 — never abort the migration print(f"[route health precompute] backfill skipped: {e}") diff --git a/e2e/content/pages/about.md b/e2e/content/pages/about.md new file mode 100644 index 0000000..15a711f --- /dev/null +++ b/e2e/content/pages/about.md @@ -0,0 +1,13 @@ +--- +title: About +slug: about +menu_order: 10 +--- + +# About the E2E Network + +This page is **rendered from markdown** served by the Playwright test stack. + +- Deterministic content mounted via `CONTENT_HOME` +- Sourced from `e2e/content/pages/about.md` +- Fetched by the SPA from `/spa/pages/about` diff --git a/e2e/docker-compose.test.yml b/e2e/docker-compose.test.yml new file mode 100644 index 0000000..3718c81 --- /dev/null +++ b/e2e/docker-compose.test.yml @@ -0,0 +1,245 @@ +# MeshCore Hub - Playwright End-to-End Test Stack +# +# Self-contained, throwaway stack for the Playwright suite in this directory. +# Runs its OWN ephemeral Postgres (schema created by `migrate` via Alembic) and +# NEVER touches the local development database: distinct project name, distinct +# named volumes, no host port for Postgres, and no ${VAR} interpolation (so the +# dev .env is never consulted). +# +# Usage (from the repo root): +# make e2e-build # docker compose -f e2e/docker-compose.test.yml build +# make e2e-up # ... up -d +# make e2e-test # npm run test:e2e (seeds data, then runs Playwright) +# make e2e-down # ... down -v (destroys the throwaway database) + +name: meshcore-e2e + +services: + # ========================================================================== + # PostgreSQL - ephemeral database for the e2e stack (not published to host) + # ========================================================================== + postgres: + image: postgres:17-alpine + container_name: meshcore-test-postgres + environment: + - POSTGRES_USER=meshcorehub + - POSTGRES_PASSWORD=e2epassword + - POSTGRES_DB=meshcorehub + volumes: + - test_pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U meshcorehub -d meshcorehub"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s + + # ========================================================================== + # Database Migrations - create the `meshcorehub` schema + tables (Alembic) + # ========================================================================== + migrate: + build: + context: .. + dockerfile: Dockerfile + container_name: meshcore-test-migrate + restart: "no" + depends_on: + postgres: + condition: service_healthy + volumes: + - test_data:/data + environment: + - DATA_HOME=/data + - DATABASE_BACKEND=postgres + - DATABASE_HOST=postgres + - DATABASE_PORT=5432 + - DATABASE_NAME=meshcorehub + - DATABASE_SCHEMA=meshcorehub + - DATABASE_USER=meshcorehub + - DATABASE_PASSWORD=e2epassword + command: ["db", "upgrade"] + + # ========================================================================== + # MQTT Broker + # ========================================================================== + mqtt: + image: ghcr.io/ipnet-mesh/meshcore-mqtt-broker:latest + container_name: meshcore-test-mqtt + ports: + - "11883:1883" + volumes: + - test_mqtt_data:/data + environment: + - MQTT_WS_PORT=1883 + - MQTT_HOST=0.0.0.0 + - AUTH_EXPECTED_AUDIENCE=mqtt.localhost + - SUBSCRIBER_MAX_CONNECTIONS_DEFAULT=5 + - SUBSCRIBER_1=test-admin:test-password:1 + - ABUSE_ENFORCEMENT_ENABLED=false + - ABUSE_DUPLICATE_WINDOW_SIZE=100 + - ABUSE_DUPLICATE_WINDOW_MS=300000 + - ABUSE_DUPLICATE_THRESHOLD=10 + - ABUSE_MAX_DUPLICATES_PER_PACKET=5 + - ABUSE_DUPLICATE_RATE_THRESHOLD=0.3 + - ABUSE_DUPLICATE_RATE_WINDOW_MS=300000 + - ABUSE_BUCKET_CAPACITY=20 + - ABUSE_BUCKET_REFILL_RATE=3 + - ABUSE_MAX_PACKET_SIZE=255 + - ABUSE_MAX_TOPICS_PER_DAY=3 + - ABUSE_ANOMALY_THRESHOLD=10 + - ABUSE_MAX_IATA_CHANGES_24H=3 + - ABUSE_TOPIC_HISTORY_SIZE=50 + - ABUSE_TOPIC_HISTORY_WINDOW_MS=86400000 + - ABUSE_PERSISTENCE_PATH=/data/abuse-detection.db + - ABUSE_PERSISTENCE_INTERVAL_MS=300000 + healthcheck: + test: ["CMD", "node", "-e", "const net=require('net');const s=net.createConnection(1883,'127.0.0.1',()=>{s.end();process.exit(0)});s.on('error',()=>process.exit(1));setTimeout(()=>process.exit(1),3000)"] + interval: 5s + timeout: 5s + retries: 3 + start_period: 5s + + # ========================================================================== + # Collector - mounts the deterministic e2e seed script (run via `make e2e-seed` + # / the Playwright global setup: `exec -T collector python /seed_data.py`) + # ========================================================================== + collector: + build: + context: .. + dockerfile: Dockerfile + container_name: meshcore-test-collector + depends_on: + migrate: + condition: service_completed_successfully + mqtt: + condition: service_healthy + volumes: + - test_data:/data + - ./seed_data.py:/seed_data.py:ro + environment: + - LOG_LEVEL=INFO + - MQTT_HOST=mqtt + - MQTT_PORT=1883 + - MQTT_PREFIX=test + - MQTT_TRANSPORT=websockets + - MQTT_WS_PATH=/ + - MQTT_USERNAME=test-admin + - MQTT_PASSWORD=test-password + - DATA_HOME=/data + - DATABASE_BACKEND=postgres + - DATABASE_HOST=postgres + - DATABASE_PORT=5432 + - DATABASE_NAME=meshcorehub + - DATABASE_SCHEMA=meshcorehub + - DATABASE_USER=meshcorehub + - DATABASE_PASSWORD=e2epassword + command: ["collector"] + healthcheck: + test: ["CMD", "pgrep", "-f", "meshcore-hub"] + interval: 5s + timeout: 5s + retries: 3 + start_period: 10s + + # ========================================================================== + # API Server + # ========================================================================== + api: + build: + context: .. + dockerfile: Dockerfile + container_name: meshcore-test-api + depends_on: + migrate: + condition: service_completed_successfully + mqtt: + condition: service_healthy + ports: + - "18000:8000" + volumes: + - test_data:/data + environment: + - LOG_LEVEL=INFO + - MQTT_HOST=mqtt + - MQTT_PORT=1883 + - MQTT_PREFIX=test + - MQTT_TRANSPORT=websockets + - MQTT_WS_PATH=/ + - MQTT_USERNAME=test-admin + - MQTT_PASSWORD=test-password + - DATA_HOME=/data + - DATABASE_BACKEND=postgres + - DATABASE_HOST=postgres + - DATABASE_PORT=5432 + - DATABASE_NAME=meshcorehub + - DATABASE_SCHEMA=meshcorehub + - DATABASE_USER=meshcorehub + - DATABASE_PASSWORD=e2epassword + - API_HOST=0.0.0.0 + - API_PORT=8000 + - API_READ_KEY=test-read-key + - API_ADMIN_KEY=test-admin-key + command: ["api"] + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 5s + timeout: 5s + retries: 6 + start_period: 10s + + # ========================================================================== + # Web Dashboard (serves the built SPA + proxies /api/* to the API) + # - OIDC enabled with a known session secret so Playwright can forge the + # signed `meshcore-session` cookie (see e2e/mint_session.py). No real IdP + # is required; discovery failure only logs a warning. + # - WEB_AUTO_REFRESH_SECONDS=2 makes list polling fast enough to assert. + # - CONTENT_HOME provides the custom markdown page (e2e/content/pages). + # ========================================================================== + web: + build: + context: .. + dockerfile: Dockerfile + container_name: meshcore-test-web + depends_on: + api: + condition: service_healthy + ports: + - "18080:8080" + volumes: + - ./content:/content:ro + environment: + - LOG_LEVEL=INFO + - API_BASE_URL=http://api:8000 + # Admin key: the web proxy uses this as its Bearer token to the API, + # and admin-only writes (routes/channels) require it at the API layer. + # Mirrors the root compose (API_KEY=${API_ADMIN_KEY:-${API_READ_KEY}}). + - API_KEY=test-admin-key + - WEB_HOST=0.0.0.0 + - WEB_PORT=8080 + - NETWORK_NAME=Test Network + - WEB_LOCALE=en + - WEB_THEME=dark + - WEB_AUTO_REFRESH_SECONDS=2 + - CONTENT_HOME=/content + - OIDC_ENABLED=true + - OIDC_CLIENT_ID=e2e-client + - OIDC_CLIENT_SECRET=e2e-secret + - OIDC_DISCOVERY_URL=https://idp.invalid/.well-known/openid-configuration + - OIDC_REDIRECT_URI=http://localhost:18080/auth/callback + - OIDC_SESSION_SECRET=test-session-secret + - OIDC_COOKIE_SECURE=false + command: ["web"] + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"] + interval: 5s + timeout: 5s + retries: 6 + start_period: 10s + +volumes: + test_pg_data: + name: meshcore_test_pg_data + test_data: + name: meshcore_test_data + test_mqtt_data: + name: meshcore_test_mqtt_data diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..649017b --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,157 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { chromium } from "@playwright/test"; + +const execFileAsync = promisify(execFile); + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, ".."); +const COMPOSE_FILE = path.join(HERE, "docker-compose.test.yml"); +const BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:18080"; +const SESSION_SECRET = process.env.E2E_SESSION_SECRET ?? "test-session-secret"; +const PYTHON = process.env.E2E_PYTHON ?? path.join(ROOT, ".venv", "bin", "python"); +const AUTH_DIR = path.join(HERE, ".auth"); + +const READY_TIMEOUT_MS = 120_000; +const DATA_TIMEOUT_MS = 60_000; + +async function seedDatabase(): Promise { + try { + await execFileAsync( + "docker", + [ + "compose", + "-f", + COMPOSE_FILE, + "exec", + "-T", + "collector", + "python", + "/seed_data.py", + ], + { cwd: ROOT }, + ); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + "Failed to seed the e2e database. Is the stack running? Start it with " + + "`make e2e-up` (or `docker compose -f e2e/docker-compose.test.yml " + + "up -d`).\n" + + detail, + ); + } +} + +async function poll( + url: string, + predicate: (body: unknown) => boolean, + timeoutMs: number, + description: string, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError = ""; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + if (response.ok) { + const body = (await response.json()) as unknown; + if (predicate(body)) { + return; + } + lastError = `unexpected response body: ${JSON.stringify(body)}`; + } else { + lastError = `HTTP ${response.status}`; + } + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + throw new Error( + `Timed out waiting for ${description} at ${url} (${lastError}). ` + + "Is the e2e stack running? Start it with `make e2e-up`.", + ); +} + +async function waitForStack(): Promise { + await poll( + `${BASE_URL}/health/ready`, + (body) => (body as { status?: string }).status === "ready", + READY_TIMEOUT_MS, + "the web service to become ready", + ); + await poll( + `${BASE_URL}/api/v1/nodes?limit=1`, + (body) => + typeof (body as { total?: number }).total === "number" && + (body as { total: number }).total > 0, + DATA_TIMEOUT_MS, + "seeded data to be visible via the API", + ); +} + +async function mintSessionCookie( + sub: string, + name: string, + email: string, + roles: string, +): Promise { + const { stdout } = await execFileAsync(PYTHON, [ + path.join(HERE, "mint_session.py"), + SESSION_SECRET, + sub, + name, + email, + roles, + ]); + const cookie = stdout.trim(); + if (!cookie) { + throw new Error("mint_session.py produced an empty cookie"); + } + return cookie; +} + +async function writeStorageState(cookie: string, file: string): Promise { + const browser = await chromium.launch(); + try { + const context = await browser.newContext(); + await context.addCookies([ + { + name: "meshcore-session", + value: cookie, + domain: new URL(BASE_URL).hostname, + path: "/", + httpOnly: true, + secure: false, + sameSite: "Lax", + }, + ]); + await context.storageState({ path: file }); + } finally { + await browser.close(); + } +} + +export default async function globalSetup(): Promise { + await seedDatabase(); + await waitForStack(); + + fs.mkdirSync(AUTH_DIR, { recursive: true }); + const adminCookie = await mintSessionCookie( + "pw-admin", + "PW Admin", + "pw-admin@example.com", + "admin,member", + ); + const memberCookie = await mintSessionCookie( + "pw-member", + "PW Member", + "pw-member@example.com", + "member", + ); + await writeStorageState(adminCookie, path.join(AUTH_DIR, "admin.json")); + await writeStorageState(memberCookie, path.join(AUTH_DIR, "member.json")); +} diff --git a/e2e/mint_session.py b/e2e/mint_session.py new file mode 100644 index 0000000..ba76045 --- /dev/null +++ b/e2e/mint_session.py @@ -0,0 +1,53 @@ +"""Mint a signed ``meshcore-session`` cookie for Playwright e2e tests. + +Reproduces Starlette's SessionMiddleware signing scheme (an +``itsdangerous.TimestampSigner`` over the base64-encoded session JSON) so the +forged cookie is accepted by the web tier exactly like a real OIDC login - +populating ``window.__APP_CONFIG__`` (user + roles) and driving the API proxy's +``X-User-Id`` / ``X-User-Roles`` injection. No IdP round-trip is performed. + +The secret must match the stack's ``OIDC_SESSION_SECRET`` +(``test-session-secret`` in ``e2e/docker-compose.test.yml``). + +Usage: + python e2e/mint_session.py + +Prints the cookie value to stdout. +""" + +from __future__ import annotations + +import base64 +import json +import sys + +import itsdangerous + + +def mint(secret: str, sub: str, name: str, email: str, roles_csv: str) -> str: + """Return a signed session-cookie value for the given identity/roles.""" + session = { + "user": { + "sub": sub, + "name": name, + "email": email, + "picture": None, + "roles": [r.strip() for r in roles_csv.split(",") if r.strip()], + } + } + data = base64.b64encode(json.dumps(session).encode("utf-8")) + signed = itsdangerous.TimestampSigner(secret).sign(data).decode("utf-8") + return str(signed) + + +def main() -> None: + if len(sys.argv) != 6: + raise SystemExit( + "usage: mint_session.py " + ) + secret, sub, name, email, roles_csv = sys.argv[1:6] + print(mint(secret, sub, name, email, roles_csv)) + + +if __name__ == "__main__": + main() diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..893f1ff --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "@playwright/test"; + +const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:18080"; + +export default defineConfig({ + testDir: "./tests", + outputDir: "./test-results", + globalSetup: "./global-setup.ts", + // Single throwaway backend: run serially so mutating specs (routes, profile) + // cannot race each other. + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]], + use: { + baseURL, + headless: true, + viewport: { width: 1280, height: 800 }, + actionTimeout: 15_000, + navigationTimeout: 30_000, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + expect: { + timeout: 15_000, + }, +}); diff --git a/e2e/seed_data.py b/e2e/seed_data.py new file mode 100644 index 0000000..7ea0293 --- /dev/null +++ b/e2e/seed_data.py @@ -0,0 +1,491 @@ +"""Deterministic seed data for the Playwright end-to-end test stack. + +Runs inside the e2e collector container (which has the app and the Postgres +driver installed) against the throwaway e2e database: + + docker compose -f e2e/docker-compose.test.yml exec -T collector \ + python /seed_data.py + +Idempotent: clears previously seeded rows and recreates them with fixed public +keys and recent timestamps, so every run yields the same dataset. The e2e +stack uses its own ephemeral Postgres instance - this never touches the local +development database. +""" + +from __future__ import annotations + +import time +from datetime import datetime, timedelta, timezone + +from sqlalchemy import delete +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import Session + +from meshcore_hub.common.config import get_common_settings +from meshcore_hub.common.database import ( + create_database_engine, + create_session_factory, +) +from meshcore_hub.common.models import ( + Advertisement, + Channel, + EventObserver, + Message, + Node, + NodeTag, + PacketPathHop, + RawPacket, + Route, + RouteNode, + RouteObserver, + RouteRecentMatch, + RouteResult, + RouteResultHistory, + UserProfile, + UserProfileNode, +) + +NOW = datetime.now(timezone.utc) + +ALPHA = ("a1fa" + "0" * 64)[:64] +BRAVO = ("b2b0" + "0" * 64)[:64] +CHARLIE = ("c3c0" + "0" * 64)[:64] +DELTA = ("d4d0" + "0" * 64)[:64] +NORTH_1 = ("aa01" + "0" * 64)[:64] +NORTH_2 = ("aa02" + "0" * 64)[:64] +SOUTH_1 = ("bb01" + "0" * 64)[:64] +SOUTH_2 = ("bb02" + "0" * 64)[:64] + +PATH_HOPS = [ALPHA[:4].upper(), BRAVO[:4].upper(), CHARLIE[:4].upper()] + + +def _hash(seed: str) -> str: + return (seed + "0" * 32)[:32] + + +def _event_hash(counter: int) -> str: + return f"{counter:032x}" + + +def _ago(minutes: float = 0.0, hours: float = 0.0, days: float = 0.0) -> datetime: + return NOW - timedelta(minutes=minutes, hours=hours, days=days) + + +def clear(session: Session) -> None: + for model in ( + RouteRecentMatch, + RouteResultHistory, + RouteResult, + RouteObserver, + RouteNode, + Route, + PacketPathHop, + RawPacket, + EventObserver, + Advertisement, + Message, + UserProfileNode, + UserProfile, + NodeTag, + Node, + Channel, + ): + session.execute(delete(model)) + + +def seed_nodes(session: Session) -> dict[str, Node]: + content_specs = [ + (ALPHA, "Alpha Node", "chat", 51.5074, -0.1278), + (BRAVO, "Bravo Node", "repeater", 52.4862, -1.8904), + (CHARLIE, "Charlie Node", "room", 53.4808, -2.2426), + (DELTA, "Delta Node", "chat", None, None), + ] + observer_specs = [ + (NORTH_1, "North Observer 1", "North", 51.6, -0.2), + (NORTH_2, "North Observer 2", "North", 51.7, -0.3), + (SOUTH_1, "South Observer 1", "South", 52.5, -1.9), + (SOUTH_2, "South Observer 2", "South", 52.6, -2.0), + ] + + nodes: dict[str, Node] = {} + for i, (pk, name, adv_type, lat, lon) in enumerate(content_specs): + node = Node( + public_key=pk, + name=name, + adv_type=adv_type, + lat=lat, + lon=lon, + first_seen=_ago(days=30), + last_seen=_ago(minutes=i + 1), + ) + session.add(node) + nodes[pk] = node + + for i, (pk, name, _area, _lat, _lon) in enumerate(observer_specs): + node = Node( + public_key=pk, + name=name, + adv_type="repeater", + lat=lat, + lon=lon, + is_observer=True, + first_seen=_ago(days=30), + last_seen=_ago(minutes=i + 1), + ) + session.add(node) + nodes[pk] = node + + session.flush() + for pk, _name, area, _lat, _lon in observer_specs: + session.add(NodeTag(node_id=nodes[pk].id, key="area", value=area)) + for pk in nodes: + session.add(NodeTag(node_id=nodes[pk].id, key="name", value=nodes[pk].name)) + session.flush() + return nodes + + +def seed_advertisements( + session: Session, nodes: dict[str, Node] +) -> dict[str, tuple[str, float]]: + specs = [ + (ALPHA, NORTH_1, "ad01", "flood", 30.0), + (BRAVO, SOUTH_1, "ad02", "flood", 45.0), + (CHARLIE, NORTH_2, "ad03", "direct", 60.0), + (DELTA, SOUTH_2, "ad04", "transport_flood", 90.0), + (ALPHA, SOUTH_1, "ad05", "flood", 120.0), + (BRAVO, NORTH_1, "ad06", "flood", 150.0), + ] + events: dict[str, tuple[str, float]] = {} + for i, (node_pk, observer_pk, seed, route_type, minutes) in enumerate(specs): + packet_hash = _hash(seed) + event_hash = _event_hash(i + 1) + events[packet_hash] = (event_hash, minutes) + node = nodes[node_pk] + received_at = _ago(minutes=minutes) + session.add( + Advertisement( + observer_node_id=nodes[observer_pk].id, + node_id=node.id, + public_key=node_pk, + name=node.name, + adv_type=node.adv_type, + received_at=received_at, + event_hash=event_hash, + packet_hash=packet_hash, + route_type=route_type, + advert_timestamp=received_at, + ) + ) + session.add( + EventObserver( + event_type="advertisement", + event_hash=event_hash, + observer_node_id=nodes[observer_pk].id, + snr=7.5, + path_len=2, + observed_at=received_at, + ) + ) + session.flush() + return events + + +def seed_messages( + session: Session, nodes: dict[str, Node], custom_channel_idx: int +) -> dict[str, tuple[str, float]]: + specs = [ + ("channel", NORTH_1, "ce01", 10.0, "Hello from the e2e mesh", 17, None), + ("channel", SOUTH_1, "ce02", 20.0, "Channel check from the south", 17, None), + ( + "channel", + NORTH_2, + "ce03", + 25.0, + "Ops channel traffic", + custom_channel_idx, + None, + ), + ( + "contact", + SOUTH_2, + "ce04", + 15.0, + "Direct hello over the mesh", + None, + ALPHA[:12], + ), + ] + events: dict[str, tuple[str, float]] = {} + for i, (mtype, observer_pk, seed, minutes, text, channel_idx, prefix) in enumerate( + specs + ): + packet_hash = _hash(seed) + event_hash = _event_hash(100 + i) + events[packet_hash] = (event_hash, minutes) + received_at = _ago(minutes=minutes) + session.add( + Message( + observer_node_id=nodes[observer_pk].id, + message_type=mtype, + pubkey_prefix=prefix, + channel_idx=channel_idx, + text=text, + path_len=2, + snr=6.5, + received_at=received_at, + event_hash=event_hash, + packet_hash=packet_hash, + ) + ) + session.add( + EventObserver( + event_type="message", + event_hash=event_hash, + observer_node_id=nodes[observer_pk].id, + snr=6.5, + path_len=2, + observed_at=received_at, + ) + ) + session.flush() + return events + + +def seed_raw_packets( + session: Session, + nodes: dict[str, Node], + advert_events: dict[str, tuple[str, float]], + message_events: dict[str, tuple[str, float]], + custom_channel_idx: int, +) -> str: + channel_indices = { + _hash("ce01"): 17, + _hash("ce02"): 17, + _hash("ce03"): custom_channel_idx, + } + events = { + **{h: (e, m, "advertisement") for h, (e, m) in advert_events.items()}, + **{ + h: (e, m, "contact_msg_recv" if h == _hash("ce04") else "channel_msg_recv") + for h, (e, m) in message_events.items() + }, + } + + first_raw_packet_id = "" + for packet_hash, (event_hash, minutes, event_type) in sorted(events.items()): + for j, observer_pk in enumerate((NORTH_1, SOUTH_1)): + received_at = _ago(minutes=minutes) + timedelta(seconds=j * 3) + raw = RawPacket( + observer_node_id=nodes[observer_pk].id, + packet_hash=packet_hash, + event_hash=event_hash, + raw_hex=(packet_hash * 4)[:128], + packet_type=5, + payload_type=4 if event_type == "advertisement" else 5, + event_type=event_type, + channel_idx=channel_indices.get(packet_hash), + source_pubkey_prefix=ALPHA[:12], + route_type="flood", + path_len=3, + path_hash_bytes=2, + snr=8.5 - j * 2.25, + decoded={"e2e": True, "packet_hash": packet_hash}, + received_at=received_at, + ) + session.add(raw) + session.flush() + if not first_raw_packet_id: + first_raw_packet_id = raw.id + for position, node_hash in enumerate(PATH_HOPS): + session.add( + PacketPathHop( + raw_packet_id=raw.id, + position=position, + node_hash=node_hash, + packet_hash=packet_hash, + event_hash=event_hash, + received_at=received_at, + observer_node_id=nodes[observer_pk].id, + ) + ) + session.flush() + return first_raw_packet_id + + +def seed_channels(session: Session) -> int: + keys = [ + ("E2E General", "00112233445566778899aabbccddeeff" * 2), + ("E2E Ops", "ffeeddccbbaa99887766554433221100" * 2), + ] + for name, key_hex in keys: + session.add( + Channel( + name=name, + key_hex=key_hex, + channel_hash=Channel.compute_channel_hash(key_hex), + visibility="community", + enabled=True, + ) + ) + session.flush() + general_hash = Channel.compute_channel_hash(keys[0][1]) + return int(general_hash, 16) + + +def seed_routes( + session: Session, nodes: dict[str, Node], first_raw_packet_id: str +) -> None: + route = Route( + from_label="Alpha Site", + to_label="Bravo Site", + description="Synthetic e2e route", + visibility="community", + match_width=2, + window_hours=48, + packet_count_threshold=3, + clear_threshold=6, + max_hop_span=8, + enabled=True, + reversible=True, + ) + session.add(route) + session.flush() + + for position, pk in enumerate((ALPHA, BRAVO)): + session.add( + RouteNode( + route_id=route.id, + node_id=nodes[pk].id, + position=position, + expected_hash=pk[:4].upper(), + ) + ) + session.add(RouteObserver(route_id=route.id, node_id=nodes[NORTH_1].id)) + + session.add( + RouteResult( + route_id=route.id, + state="healthy", + quality="clear", + matched_count=7, + threshold=3, + effective_clear=6, + evaluated_at=_ago(minutes=5), + quality_avg="clear", + ) + ) + + history = [ + ("clear", 7), + ("clear", 6), + ("marginal", 4), + ("clear", 5), + ("marginal", 3), + ("failing", 1), + ("clear", 6), + ] + for day_offset, (quality, matched) in enumerate(history): + session.add( + RouteResultHistory( + route_id=route.id, + date=(NOW - timedelta(days=day_offset)).date(), + quality=quality, + state="unhealthy" if quality == "failing" else "healthy", + matched_count=matched, + evaluated_at=_ago(days=day_offset), + ) + ) + + if first_raw_packet_id: + session.add( + RouteRecentMatch( + route_id=route.id, + raw_packet_id=first_raw_packet_id, + first_position=0, + last_position=1, + ) + ) + session.flush() + + +def seed_profiles(session: Session, nodes: dict[str, Node]) -> None: + specs = [ + ( + "pw-admin", + "PW Admin", + "E2EADM", + "admin,member", + "Playwright admin user", + "https://example.com/pw-admin", + ), + ( + "pw-member", + "PW Member", + "E2EMBR", + "member", + "Playwright member user", + None, + ), + ("op-north", "Op North", "OPN1", "operator,member", "North operator", None), + ("mem-south", "Mem South", "MEMS1", "member", "South member", None), + ] + profiles: dict[str, UserProfile] = {} + for user_id, name, callsign, roles, description, url in specs: + profile = UserProfile( + user_id=user_id, + name=name, + callsign=callsign, + roles=roles, + description=description, + url=url, + ) + session.add(profile) + profiles[user_id] = profile + session.flush() + + session.add( + UserProfileNode( + user_profile_id=profiles["op-north"].id, node_id=nodes[ALPHA].id + ) + ) + session.add( + UserProfileNode( + user_profile_id=profiles["mem-south"].id, node_id=nodes[BRAVO].id + ) + ) + session.flush() + + +def main() -> None: + settings = get_common_settings() + engine = create_database_engine( + settings.effective_database_url, + schema=settings.effective_database_schema, + ) + session_factory = create_session_factory(engine) + + last_error: OperationalError | None = None + for _ in range(15): + try: + with session_factory() as session: + clear(session) + nodes = seed_nodes(session) + custom_channel_idx = seed_channels(session) + advert_events = seed_advertisements(session, nodes) + message_events = seed_messages(session, nodes, custom_channel_idx) + first_raw_packet_id = seed_raw_packets( + session, nodes, advert_events, message_events, custom_channel_idx + ) + seed_routes(session, nodes, first_raw_packet_id) + seed_profiles(session, nodes) + session.commit() + print("e2e seed data written") + return + except OperationalError as exc: + last_error = exc + time.sleep(2) + raise RuntimeError(f"database not reachable after retries: {last_error}") + + +if __name__ == "__main__": + main() diff --git a/e2e/tests/advertisements.spec.ts b/e2e/tests/advertisements.spec.ts new file mode 100644 index 0000000..f802c76 --- /dev/null +++ b/e2e/tests/advertisements.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from "@playwright/test"; +import { countApiCalls, expectListLoaded, openFilters } from "../utils/helpers"; + +const ALPHA_KEY = "a1fa" + "0".repeat(60); + +test.use({ permissions: ["clipboard-read", "clipboard-write"] }); + +test.describe("advertisements", () => { + test("filter options work", async ({ page }) => { + await page.goto("/advertisements"); + await expectListLoaded(page); + const table = page.locator("table"); + + await openFilters(page); + await page.locator('select[name="route_type"]').selectOption("all"); + await page.getByRole("button", { name: "Filter" }).click(); + await expect(page).toHaveURL(/route_type=all/); + await expect(table.getByText("Charlie Node").first()).toBeVisible(); + + await openFilters(page); + await page.locator('input[name="search"]').fill("Bravo"); + await page.getByRole("button", { name: "Filter" }).click(); + await expect(page).toHaveURL(/search=Bravo/); + // Two Bravo adverts (one per area); toHaveCount auto-waits out the refetch. + await expect(page.getByTestId("list-row")).toHaveCount(2); + await expect(table.getByText("Alpha Node")).toHaveCount(0); + }); + + test("auto-refresh works and can be paused", async ({ page }) => { + await page.goto("/advertisements"); + await expectListLoaded(page); + + const toggle = page.getByTestId("auto-refresh-toggle"); + await expect(toggle).toBeChecked(); + + const active = await countApiCalls(page, "/api/v1/advertisements?", 5000); + expect(active).toBeGreaterThanOrEqual(2); + + await toggle.click(); + await expect(toggle).not.toBeChecked(); + const paused = await countApiCalls(page, "/api/v1/advertisements?", 4500); + expect(paused).toBe(0); + }); + + test("table row actions work", async ({ page }) => { + await page.goto("/advertisements"); + await expectListLoaded(page); + const table = page.locator("table"); + + await table.getByRole("link", { name: "Alpha Node" }).first().click(); + await expect(page).toHaveURL(new RegExp(`/nodes/${ALPHA_KEY}`)); + await page.goBack(); + await expectListLoaded(page); + + // Click a plain-text cell (Time): node links and copyable keys + // stopPropagation / have their own handlers. + await page.getByTestId("list-row").first().locator("td").nth(3).click(); + await expect(page).toHaveURL(/\/packets\/hash\//); + + await page.goto("/advertisements"); + await expectListLoaded(page); + const copyable = table.locator('code[title="Click to copy"]').first(); + await copyable.click(); + await expect(page.getByText("Copied!").first()).toBeVisible(); + }); + + test("observer toggles filter the list", async ({ page }) => { + await page.goto("/advertisements"); + await expectListLoaded(page); + + const north = page.locator('[data-testid="observer-area"][data-area="North"]'); + const south = page.locator('[data-testid="observer-area"][data-area="South"]'); + await expect(north.first()).toBeVisible(); + await expect(south.first()).toBeVisible(); + await expect(page.getByTestId("list-row")).toHaveCount(5); + + await north.first().click(); + await expect(north.first()).toHaveClass(/badge-ghost/); + await expect(page.getByTestId("list-row")).toHaveCount(3); + + await north.first().click(); + await expect(north.first()).toHaveClass(/badge-primary/); + await expect(page.getByTestId("list-row")).toHaveCount(5); + + await south.first().click(); + await expect(page.getByTestId("list-row")).toHaveCount(2); + await north.first().click(); + await expect(north.first()).toHaveClass(/badge-primary/); + await expect(page.getByTestId("list-row")).toHaveCount(2); + }); +}); diff --git a/e2e/tests/custom-page.spec.ts b/e2e/tests/custom-page.spec.ts new file mode 100644 index 0000000..abfe226 --- /dev/null +++ b/e2e/tests/custom-page.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from "@playwright/test"; + +test.describe("custom pages", () => { + test("markdown content is rendered", async ({ page }) => { + await page.goto("/pages/about"); + + const prose = page.locator(".prose"); + await expect(prose).toBeVisible(); + await expect( + prose.getByRole("heading", { name: "About the E2E Network" }), + ).toBeVisible(); + await expect(prose.getByText("rendered from markdown")).toBeVisible(); + await expect( + prose.getByText("Fetched by the SPA from /spa/pages/about"), + ).toBeVisible(); + }); + + test("custom page appears in the navigation", async ({ page }) => { + await page.goto("/"); + + const link = page.locator('[data-testid="nav-link"][data-nav-href="/pages/about"]'); + await expect(link.first()).toBeVisible(); + await link.first().click(); + await expect(page).toHaveURL(/\/pages\/about/); + await expect(page.locator(".prose")).toBeVisible(); + }); +}); diff --git a/e2e/tests/dashboard.spec.ts b/e2e/tests/dashboard.spec.ts new file mode 100644 index 0000000..80894cb --- /dev/null +++ b/e2e/tests/dashboard.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from "@playwright/test"; + +test.describe("dashboard", () => { + test("all widgets render", async ({ page }) => { + await page.goto("/dashboard"); + const main = page.locator("main"); + + await expect(main.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + + for (const title of [ + "Nodes", + "Adverts", + "Messages", + "Packets", + "Packet Types", + "Path Bytes", + "Route Health", + "Route Trends", + "Recent Adverts", + "Recent Channel Messages", + ]) { + await expect(main.getByText(title, { exact: true }).first()).toBeVisible(); + } + + expect(await page.locator("canvas").count()).toBeGreaterThanOrEqual(5); + + await expect(main.getByText("Alpha Site")).toBeVisible(); + await expect(main.getByText("Bravo Site").first()).toBeVisible(); + + await expect(main.getByText("Alpha Node").first()).toBeVisible(); + await expect( + main.locator('a[href^="/nodes/"]').first(), + ).toBeVisible(); + }); +}); diff --git a/e2e/tests/global.spec.ts b/e2e/tests/global.spec.ts new file mode 100644 index 0000000..3e86e58 --- /dev/null +++ b/e2e/tests/global.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "@playwright/test"; + +const NAV_TARGETS = [ + "/", + "/dashboard", + "/nodes", + "/advertisements", + "/routes", + "/channels", + "/messages", + "/packets", + "/map", + "/members", + "/pages/about", +]; + +test.describe("global", () => { + test("all navigation links work", async ({ page }) => { + await page.goto("/"); + // Scope to the desktop menu: MobileNav renders the same links (hidden >= lg). + const navLinks = page.locator(".navbar-center [data-testid='nav-link']"); + await expect(navLinks.first()).toBeVisible(); + + await expect(navLinks).toHaveCount(NAV_TARGETS.length); + for (const href of NAV_TARGETS) { + await expect( + page.locator(`.navbar-center [data-nav-href="${href}"]`), + ).toHaveCount(1); + } + + for (const href of NAV_TARGETS) { + const link = page.locator(`.navbar-center [data-nav-href="${href}"]`); + await link.click(); + await expect(page).toHaveURL(new RegExp(href === "/" ? "/$" : href)); + await expect(link).toHaveClass(/active/); + } + }); + + test("dark/light toggle works and persists", async ({ page }) => { + await page.goto("/"); + // The checkbox itself is visually hidden by daisyUI's swap; click the label. + const toggle = page.getByTestId("theme-toggle"); + const toggleControl = page.locator("label.swap"); + const html = page.locator("html"); + + await expect(html).toHaveAttribute("data-theme", "dark"); + await expect(toggle).not.toBeChecked(); + + await toggleControl.click(); + await expect(html).toHaveAttribute("data-theme", "light"); + await expect(toggle).toBeChecked(); + + await page.reload(); + await expect(html).toHaveAttribute("data-theme", "light"); + + await page.locator("label.swap").click(); + await expect(html).toHaveAttribute("data-theme", "dark"); + + await page.reload(); + await expect(html).toHaveAttribute("data-theme", "dark"); + }); +}); diff --git a/e2e/tests/home.spec.ts b/e2e/tests/home.spec.ts new file mode 100644 index 0000000..ccd53cf --- /dev/null +++ b/e2e/tests/home.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test"; + +const HERO_TARGETS = [ + "/dashboard", + "/nodes", + "/advertisements", + "/routes", + "/channels", + "/messages", + "/packets", + "/map", + "/members", +]; + +test.describe("home", () => { + test("renders hero, stats and activity panels", async ({ page }) => { + await page.goto("/"); + + await expect(page.locator("h1.hero-title")).toHaveText("Test Network"); + await expect( + page.getByText("Welcome to the Test Network mesh network dashboard."), + ).toBeVisible(); + + const stats = page.locator(".stat"); + await expect(stats.first()).toBeVisible(); + expect(await stats.count()).toBeGreaterThanOrEqual(4); + await expect(page.getByText("All discovered nodes")).toBeVisible(); + + await expect( + page.getByRole("heading", { name: "Network Activity" }), + ).toBeVisible(); + expect(await page.locator("canvas").count()).toBeGreaterThanOrEqual(1); + }); + + test("hero navigation links work", async ({ page }) => { + await page.goto("/"); + const cards = page.getByTestId("hero-card"); + await expect(cards.first()).toBeVisible(); + + for (const href of HERO_TARGETS) { + const card = page.locator( + `[data-testid="hero-card"][data-hero-href="${href}"]`, + ); + await expect(card).toBeVisible(); + await card.click(); + await expect(page).toHaveURL(new RegExp(href)); + await page.goto("/"); + } + }); +}); diff --git a/e2e/tests/map.spec.ts b/e2e/tests/map.spec.ts new file mode 100644 index 0000000..98708c4 --- /dev/null +++ b/e2e/tests/map.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test"; +import { openFilters } from "../utils/helpers"; + +test.describe("map", () => { + test("renders markers and filter options work (incl. show labels)", async ({ + page, + }) => { + await page.goto("/map"); + + const markers = page.locator(".map-marker"); + await expect(markers.first()).toBeVisible(); + await expect(markers).toHaveCount(7); + await expect(page.getByText("7 nodes on map")).toBeVisible(); + + await openFilters(page); + + await page + .locator('select:has(option[value="repeater"])') + .selectOption("repeater"); + await expect(markers).toHaveCount(5); + await expect(page.getByText("5 shown")).toBeVisible(); + + await page.getByLabel("Show Labels").check(); + await expect(page.locator(".show-labels").first()).toBeVisible(); + await expect(page.locator(".map-label").first()).toBeVisible(); + + await page.getByRole("button", { name: "Clear Filters" }).click(); + await expect(markers).toHaveCount(7); + await expect(page.locator(".show-labels")).toHaveCount(0); + await expect(page.getByLabel("Show Labels")).not.toBeChecked(); + }); + + test("marker popup links to node detail", async ({ page }) => { + await page.goto("/map"); + await expect(page.locator(".map-marker").first()).toBeVisible(); + + await page.locator(".map-marker").first().click(); + const popup = page.locator(".leaflet-popup"); + await expect(popup).toBeVisible(); + await popup.getByRole("link", { name: "View Details" }).click(); + await expect(page).toHaveURL(/\/nodes\/[0-9a-f]{64}/); + }); +}); diff --git a/e2e/tests/members.spec.ts b/e2e/tests/members.spec.ts new file mode 100644 index 0000000..304d09b --- /dev/null +++ b/e2e/tests/members.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test"; +import { MEMBER_STATE } from "../utils/helpers"; + +const BRAVO_KEY = "b2b0" + "0".repeat(60); + +test.use({ storageState: MEMBER_STATE }); + +test.describe("members", () => { + test("lists operators and members", async ({ page }) => { + await page.goto("/members"); + + // Level 2: the group headings (level 1 is the page title "Members"). + await expect( + page.getByRole("heading", { name: "Operators", level: 2 }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Members", level: 2 }), + ).toBeVisible(); + await expect(page.getByText("Op North")).toBeVisible(); + await expect(page.getByText("Mem South")).toBeVisible(); + expect(await page.getByTestId("member-card").count()).toBeGreaterThanOrEqual(4); + }); + + test("clicking a member shows the profile page", async ({ page }) => { + await page.goto("/members"); + + await page + .getByTestId("member-card") + .filter({ hasText: "Mem South" }) + .first() + .click(); + await expect(page).toHaveURL(/\/profile\/[0-9a-f-]{36}/); + await expect(page.getByText("Mem South").first()).toBeVisible(); + await expect(page.locator('nav[aria-label="Breadcrumb"]')).toBeVisible(); + }); + + test("clicking a member node shows the node detail page", async ({ page }) => { + await page.goto("/members"); + + const card = page + .getByTestId("member-card") + .filter({ hasText: "Mem South" }) + .first(); + await card + .locator(`[data-testid="member-node-badge"][data-node-key="${BRAVO_KEY}"]`) + .click(); + await expect(page).toHaveURL(new RegExp(`/nodes/${BRAVO_KEY}`)); + await expect(page.getByText("Bravo Node").first()).toBeVisible(); + }); +}); diff --git a/e2e/tests/messages.spec.ts b/e2e/tests/messages.spec.ts new file mode 100644 index 0000000..c06dca2 --- /dev/null +++ b/e2e/tests/messages.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from "@playwright/test"; +import { countApiCalls, expectListLoaded, openFilters } from "../utils/helpers"; + +test.describe("messages", () => { + test("filter options work", async ({ page }) => { + await page.goto("/messages"); + await expectListLoaded(page); + const table = page.locator("table"); + await expect(page.getByTestId("list-row")).toHaveCount(4); + + await openFilters(page); + await page.locator('select[name="message_type"]').selectOption("channel"); + await page.getByRole("button", { name: "Filter" }).click(); + await expect(page).toHaveURL(/message_type=channel/); + await expect(page.getByTestId("list-row")).toHaveCount(3); + + // The channel select auto-submits on change. + await openFilters(page); + await page + .locator('select[name="channel_idx"]') + .selectOption({ label: "E2E General" }); + await expect(page).toHaveURL(/channel_idx=\d+/); + await expect(page.getByTestId("list-row")).toHaveCount(1); + await expect(table.getByText("Ops channel traffic")).toBeVisible(); + + await page.goto("/messages"); + await expectListLoaded(page); + await expect(page.getByTestId("list-row")).toHaveCount(4); + }); + + test("auto-refresh works and can be paused", async ({ page }) => { + await page.goto("/messages"); + await expectListLoaded(page); + + const toggle = page.getByTestId("auto-refresh-toggle"); + await expect(toggle).toBeChecked(); + + const active = await countApiCalls(page, "/api/v1/messages?", 5000); + expect(active).toBeGreaterThanOrEqual(2); + + await toggle.click(); + await expect(toggle).not.toBeChecked(); + const paused = await countApiCalls(page, "/api/v1/messages?", 4500); + expect(paused).toBe(0); + }); + + test("table row actions and observer toggle work", async ({ page }) => { + await page.goto("/messages"); + await expectListLoaded(page); + const table = page.locator("table"); + + await expect(table.locator("span.observer-badge").first()).toBeVisible(); + + // Click a plain-text cell (Time) so the row handler navigates. + await page.getByTestId("list-row").first().locator("td").nth(1).click(); + await expect(page).toHaveURL(/\/packets\/hash\//); + + await page.goto("/messages"); + await expectListLoaded(page); + await expect(page.getByTestId("list-row")).toHaveCount(4); + + const north = page + .locator('[data-testid="observer-area"][data-area="North"]') + .first(); + await north.click(); + await expect(north).toHaveClass(/badge-ghost/); + await expect(page.getByTestId("list-row")).toHaveCount(2); + }); +}); diff --git a/e2e/tests/nodes.spec.ts b/e2e/tests/nodes.spec.ts new file mode 100644 index 0000000..3be61bb --- /dev/null +++ b/e2e/tests/nodes.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from "@playwright/test"; +import { countApiCalls, expectListLoaded, openFilters } from "../utils/helpers"; + +const ALPHA_KEY = "a1fa" + "0".repeat(60); + +test.use({ permissions: ["clipboard-read", "clipboard-write"] }); + +test.describe("nodes", () => { + test("filter options work", async ({ page }) => { + await page.goto("/nodes"); + await expectListLoaded(page); + // Scope to the desktop table: rows also exist as hidden mobile cards. + const table = page.locator("table"); + const initialCount = await page.getByTestId("list-row").count(); + expect(initialCount).toBeGreaterThanOrEqual(4); + + await openFilters(page); + + await page.locator('input[name="search"]').fill("Alpha"); + await page.getByRole("button", { name: "Filter" }).click(); + await expect(page).toHaveURL(/search=Alpha/); + await expect(page.getByTestId("list-row")).toHaveCount(1); + await expect(table.getByText("Alpha Node").first()).toBeVisible(); + + await page.getByRole("link", { name: "Clear" }).click(); + await expect(page).not.toHaveURL(/search=/); + await expect(page.getByTestId("list-row")).toHaveCount(initialCount); + + await openFilters(page); + await page.locator('select[name="adv_type"]').selectOption("repeater"); + await page.getByRole("button", { name: "Filter" }).click(); + await expect(page).toHaveURL(/adv_type=repeater/); + await expect(table.getByText("Alpha Node")).toHaveCount(0); + await expect(table.getByText("Bravo Node").first()).toBeVisible(); + }); + + test("auto-refresh works and can be paused", async ({ page }) => { + await page.goto("/nodes"); + await expectListLoaded(page); + + const toggle = page.getByTestId("auto-refresh-toggle"); + await expect(toggle).toBeVisible(); + await expect(toggle).toBeChecked(); + + const active = await countApiCalls(page, "/api/v1/nodes?", 5000); + expect(active).toBeGreaterThanOrEqual(2); + + await toggle.click(); + await expect(toggle).not.toBeChecked(); + const paused = await countApiCalls(page, "/api/v1/nodes?", 4500); + expect(paused).toBe(0); + }); + + test("table row actions work", async ({ page }) => { + await page.goto("/nodes"); + await expectListLoaded(page); + const table = page.locator("table"); + + await table.getByRole("link", { name: "Alpha Node" }).first().click(); + await expect(page).toHaveURL(new RegExp(`/nodes/${ALPHA_KEY}`)); + await expect(page.getByText("Alpha Node").first()).toBeVisible(); + await page.goBack(); + await expectListLoaded(page); + + const copyable = table.locator('code[title="Click to copy"]').first(); + await copyable.click(); + await expect(page.getByText("Copied!").first()).toBeVisible(); + }); +}); diff --git a/e2e/tests/packets.spec.ts b/e2e/tests/packets.spec.ts new file mode 100644 index 0000000..d74f72a --- /dev/null +++ b/e2e/tests/packets.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from "@playwright/test"; +import { countApiCalls, expectListLoaded, openFilters } from "../utils/helpers"; + +const ALPHA_KEY = "a1fa" + "0".repeat(60); +const AD01_HASH = "ad01" + "0".repeat(28); + +test.describe("packets", () => { + test("filter options work", async ({ page }) => { + await page.goto("/packets"); + await expectListLoaded(page); + await expect(page.getByTestId("list-row")).toHaveCount(10); + + await openFilters(page); + await page.locator('select[name="event_type"]').selectOption("advertisement"); + await page.getByRole("button", { name: "Filter" }).click(); + await expect(page).toHaveURL(/event_type=advertisement/); + await expect(page.getByTestId("list-row")).toHaveCount(6); + + await openFilters(page); + await page.locator('select[name="path_hash_bytes"]').selectOption("2"); + await page.getByRole("button", { name: "Filter" }).click(); + await expect(page).toHaveURL(/path_hash_bytes=2/); + await expect(page.getByTestId("list-row")).toHaveCount(6); + }); + + test("auto-refresh works and can be paused", async ({ page }) => { + await page.goto("/packets"); + await expectListLoaded(page); + + const toggle = page.getByTestId("auto-refresh-toggle"); + await expect(toggle).toBeChecked(); + + const active = await countApiCalls(page, "/api/v1/packet-groups?", 5000); + expect(active).toBeGreaterThanOrEqual(2); + + await toggle.click(); + await expect(toggle).not.toBeChecked(); + const paused = await countApiCalls(page, "/api/v1/packet-groups?", 4500); + expect(paused).toBe(0); + }); + + test("row click opens the packet group detail", async ({ page }) => { + await page.goto("/packets"); + await expectListLoaded(page); + + await page.getByTestId("list-row").first().click(); + await expect(page).toHaveURL(/\/packets\/hash\//); + await expect(page.locator('nav[aria-label="Breadcrumb"]')).toBeVisible(); + }); + + test("clicking a path node renders the matching-nodes overlay", async ({ + page, + }) => { + await page.goto(`/packets/hash/${AD01_HASH}`); + + // Badges render twice (desktop table + hidden mobile cards): scope to visible. + const pathHops = page.locator('[data-testid="path-hop"]:visible'); + await expect(pathHops.first()).toBeVisible(); + for (const hash of ["A1FA", "B2B0", "C3C0"]) { + await expect( + page.locator(`[data-testid="path-hop"][data-hash="${hash}"]:visible`).first(), + ).toBeVisible(); + } + + await page + .locator('[data-testid="path-hop"][data-hash="A1FA"]:visible') + .first() + .click(); + const popover = page.getByTestId("path-nodes-popover"); + await expect(popover).toBeVisible(); + await expect(popover.getByText("Nodes matching A1FA")).toBeVisible(); + await expect(popover.getByText("Alpha Node")).toBeVisible(); + + await popover.getByTestId("path-node-link").first().click(); + await expect(page).toHaveURL(new RegExp(`/nodes/${ALPHA_KEY}`)); + await expect(page.getByText("Alpha Node").first()).toBeVisible(); + + await page.goto(`/packets/hash/${AD01_HASH}`); + await page + .locator('[data-testid="path-hop"][data-hash="B2B0"]:visible') + .first() + .click(); + await expect(page.getByTestId("path-nodes-popover")).toBeVisible(); + await expect(page.getByText("Bravo Node").first()).toBeVisible(); + await page + .getByTestId("path-nodes-popover") + .getByRole("button", { name: "close" }) + .click(); + await expect(page.getByTestId("path-nodes-popover")).toHaveCount(0); + }); +}); diff --git a/e2e/tests/routes.spec.ts b/e2e/tests/routes.spec.ts new file mode 100644 index 0000000..cfaa56a --- /dev/null +++ b/e2e/tests/routes.spec.ts @@ -0,0 +1,137 @@ +import { expect, test } from "@playwright/test"; +import { ADMIN_STATE } from "../utils/helpers"; + +test.use({ storageState: ADMIN_STATE }); + +const ROUTE_LABEL = "E2E From \u2192 E2E To"; + +test.describe.serial("routes (admin)", () => { + test("add route displays the modal and all options are persisted", async ({ + page, + }) => { + await page.goto("/routes"); + + await expect( + page.locator('[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]'), + ).toBeVisible(); + + await page.getByTestId("add-route").click(); + const modal = page.locator('[data-testid="route-modal"]'); + await expect(modal).toBeVisible(); + await expect(page.locator("dialog h3")).toHaveText("Add Route"); + + await page.getByTestId("route-from").fill("E2E From"); + await page.getByTestId("route-to").fill("E2E To"); + await page.getByTestId("route-description").fill("Created by Playwright"); + await page.getByTestId("route-visibility").selectOption("operator"); + await page.locator('[data-testid="route-width"][data-width="2"]').click(); + await expect( + page.locator('[data-testid="route-width"][data-width="2"]'), + ).toHaveClass(/btn-primary/); + + await page.getByTestId("route-path-search").fill("Alpha"); + await page.getByTestId("node-search-result").first().click(); + await expect(page.getByTestId("route-path-chip")).toHaveCount(1); + + await page.getByTestId("route-path-search").fill("Bravo"); + await page.getByTestId("node-search-result").first().click(); + await expect(page.getByTestId("route-path-chip")).toHaveCount(2); + await expect(modal.getByText("Alpha Node")).toBeVisible(); + await expect(modal.getByText("Bravo Node")).toBeVisible(); + + await page.getByTestId("route-observer-search").fill("North"); + await page.getByTestId("node-search-result").first().click(); + await expect(modal.getByText(/North Observer/).first()).toBeVisible(); + + await page.getByTestId("route-window").fill("72"); + await page.getByTestId("route-threshold").fill("4"); + await page.getByTestId("route-clear-threshold").fill("8"); + await page.getByTestId("route-max-span").fill("6"); + await page.getByTestId("route-max-path-length").fill("5"); + await page.getByTestId("route-enabled").setChecked(false); + await page.getByTestId("route-reversible").setChecked(false); + + await page.getByTestId("route-save").click(); + await expect(modal).toHaveCount(0); + + const card = page.locator( + `[data-testid="route-card"][data-route-label="${ROUTE_LABEL}"]`, + ); + await expect(card).toBeVisible(); + + await card.getByTestId("edit-route").click(); + await expect(modal).toBeVisible(); + await expect(page.locator("dialog h3")).toHaveText("Edit Route"); + + await expect(page.getByTestId("route-from")).toHaveValue("E2E From"); + await expect(page.getByTestId("route-to")).toHaveValue("E2E To"); + await expect(page.getByTestId("route-description")).toHaveValue( + "Created by Playwright", + ); + await expect(page.getByTestId("route-visibility")).toHaveValue("operator"); + await expect( + page.locator('[data-testid="route-width"][data-width="2"]'), + ).toHaveClass(/btn-primary/); + await expect(page.getByTestId("route-path-chip")).toHaveCount(2); + await expect(modal.getByText("Alpha Node")).toBeVisible(); + await expect(modal.getByText("Bravo Node")).toBeVisible(); + await expect(modal.getByText(/North Observer/).first()).toBeVisible(); + await expect(page.getByTestId("route-window")).toHaveValue("72"); + await expect(page.getByTestId("route-threshold")).toHaveValue("4"); + await expect(page.getByTestId("route-clear-threshold")).toHaveValue("8"); + await expect(page.getByTestId("route-max-span")).toHaveValue("6"); + await expect(page.getByTestId("route-max-path-length")).toHaveValue("5"); + await expect(page.getByTestId("route-enabled")).not.toBeChecked(); + await expect(page.getByTestId("route-reversible")).not.toBeChecked(); + + await page.getByTestId("route-cancel").click(); + await expect(modal).toHaveCount(0); + }); + + test("saving with fewer than 2 path nodes is rejected", async ({ page }) => { + await page.goto("/routes"); + await page.getByTestId("add-route").click(); + + await page.getByTestId("route-from").fill("Bad"); + await page.getByTestId("route-to").fill("Route"); + await page.getByTestId("route-path-search").fill("Alpha"); + await page.getByTestId("node-search-result").first().click(); + await expect(page.getByTestId("route-path-chip")).toHaveCount(1); + + // The alert blocks the page until dismissed, so accept it the moment it + // appears (handling it only after the awaited click would deadlock). + const dialogPromise = page.waitForEvent("dialog"); + void dialogPromise.then((dialog) => dialog.accept()); + await page.getByTestId("route-save").click(); + expect((await dialogPromise).message()).toBe( + "At least 2 path nodes are required.", + ); + + await expect(page.locator('[data-testid="route-modal"]')).toBeVisible(); + await page.getByTestId("route-cancel").click(); + }); + + test("delete route shows a confirm dialog and removes the route", async ({ + page, + }) => { + await page.goto("/routes"); + + const card = page.locator( + `[data-testid="route-card"][data-route-label="${ROUTE_LABEL}"]`, + ); + await expect(card).toBeVisible(); + await card.getByTestId("delete-route").click(); + + const confirm = page.locator("dialog.modal-open"); + await expect(confirm).toBeVisible(); + await expect( + confirm.getByRole("heading", { name: "Delete Route" }), + ).toBeVisible(); + await expect( + confirm.getByText(/Are you sure you want to delete route/), + ).toBeVisible(); + + await confirm.getByRole("button", { name: "Delete" }).click(); + await expect(card).toHaveCount(0); + }); +}); diff --git a/e2e/tests/users.spec.ts b/e2e/tests/users.spec.ts new file mode 100644 index 0000000..42ecd0f --- /dev/null +++ b/e2e/tests/users.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from "@playwright/test"; +import { MEMBER_STATE } from "../utils/helpers"; + +test.use({ storageState: MEMBER_STATE }); + +test.describe("users", () => { + test("user profile menu works", async ({ page }) => { + await page.goto("/"); + + await page.getByTestId("user-menu").click(); + await expect(page.getByText("PW Member")).toBeVisible(); + await expect(page.getByText("member", { exact: true })).toBeVisible(); + await expect(page.getByTestId("user-menu-profile")).toBeVisible(); + await expect(page.getByTestId("user-menu-logout")).toBeVisible(); + }); + + test("profile edit works and persists", async ({ page }) => { + await page.goto("/profile"); + + await expect(page.locator('input[name="name"]')).toBeVisible(); + await page.locator('input[name="name"]').fill("PW Member Edited"); + await page.locator('input[name="callsign"]').fill("E2EEDIT"); + await page + .locator('input[name="description"]') + .fill("Updated by Playwright"); + await page + .locator('input[name="url"]') + .fill("https://example.com/pw-member"); + + await page.getByRole("button", { name: "Save Profile" }).click(); + await expect(page.getByRole("alert")).toContainText( + "Profile updated successfully", + ); + + await page.reload(); + await expect(page.locator('input[name="name"]')).toHaveValue( + "PW Member Edited", + ); + await expect(page.locator('input[name="callsign"]')).toHaveValue("E2EEDIT"); + await expect(page.locator('input[name="description"]')).toHaveValue( + "Updated by Playwright", + ); + await expect(page.locator('input[name="url"]')).toHaveValue( + "https://example.com/pw-member", + ); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..3b1ed25 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/e2e/utils/helpers.ts b/e2e/utils/helpers.ts new file mode 100644 index 0000000..2bd023e --- /dev/null +++ b/e2e/utils/helpers.ts @@ -0,0 +1,39 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, type Page } from "@playwright/test"; + +const AUTH_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + ".auth", +); +export const ADMIN_STATE = path.join(AUTH_DIR, "admin.json"); +export const MEMBER_STATE = path.join(AUTH_DIR, "member.json"); + +export async function expectListLoaded(page: Page): Promise { + await expect(page.getByTestId("list-row").first()).toBeVisible(); +} + +export async function openFilters(page: Page): Promise { + const toggle = page.locator("#filter-toggle"); + if (!(await toggle.isChecked())) { + await toggle.click(); + } +} + +export async function countApiCalls( + page: Page, + urlFragment: string, + durationMs: number, +): Promise { + let count = 0; + const onRequest = (request: { url: () => string }): void => { + if (request.url().includes(urlFragment)) { + count += 1; + } + }; + page.on("request", onRequest); + await page.waitForTimeout(durationMs); + page.off("request", onRequest); + return count; +} diff --git a/package-lock.json b/package-lock.json index 1c33fa2..c5b4a79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,10 +24,12 @@ "tailwindcss": "^4" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@types/leaflet": "^1.9.17", + "@types/node": "^24.0.0", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^4", @@ -954,6 +956,22 @@ "node": ">=0.10" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@react-leaflet/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", @@ -1899,6 +1917,16 @@ "@types/geojson": "*" } }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -3113,6 +3141,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.21", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", @@ -3612,6 +3687,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/package.json b/package.json index 963694e..b4734af 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,17 @@ "build": "node build.js", "dev": "vite --config vite.config.ts", "test:frontend": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test --config=e2e/playwright.config.ts", + "typecheck:e2e": "tsc -p e2e/tsconfig.json --noEmit" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@types/leaflet": "^1.9.17", + "@types/node": "^24.0.0", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^4", diff --git a/pyproject.toml b/pyproject.toml index 9433f84..081ec32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,6 +151,12 @@ module = [ ] ignore_errors = true +# e2e helper script (run with the project venv); itsdangerous stubs are not +# present in the isolated pre-commit mypy environment. +[[tool.mypy.overrides]] +module = ["mint_session"] +ignore_missing_imports = true + [tool.pytest.ini_options] minversion = "7.0" asyncio_mode = "auto" @@ -163,9 +169,6 @@ addopts = [ "-q", "--strict-markers", ] -markers = [ - "e2e: end-to-end tests requiring Docker services (skipped unless --e2e)", -] filterwarnings = [ "ignore::DeprecationWarning", ] diff --git a/src/meshcore_hub/web/static/js/spa-react/components/AuthSection.tsx b/src/meshcore_hub/web/static/js/spa-react/components/AuthSection.tsx index be4a488..7dadba5 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/AuthSection.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/AuthSection.tsx @@ -41,6 +41,7 @@ export function AuthSection() {
    {user.picture ? ( @@ -70,12 +71,12 @@ export function AuthSection() {
  • - + {t("links.profile")}
  • - + {t("auth.logout")}
  • diff --git a/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.tsx b/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.tsx index a2a0d11..1c910a1 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.tsx @@ -26,6 +26,7 @@ export function AutoRefreshToggle({ diff --git a/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx b/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx index f9a5c48..a32683e 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx @@ -12,6 +12,8 @@ export function MobileNav() { (isActive ? "active" : undefined)} > {item.icon} {item.label} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Navbar.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Navbar.tsx index 6cfef9e..b33eed1 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/Navbar.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/Navbar.tsx @@ -28,6 +28,8 @@ export function Navbar() { (isActive ? "active" : undefined)} > {item.icon} {item.label} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ObserverBadges.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ObserverBadges.tsx index f251baa..b9225ee 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/ObserverBadges.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/ObserverBadges.tsx @@ -95,6 +95,8 @@ export function ObserverFilterBadges({ key={area} type="button" className={`${cls} cursor-pointer`} + data-testid="observer-area" + data-area={area} title={title} onClick={() => onToggle(area)} > diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.tsx index 996dfd5..4772db7 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.tsx @@ -19,7 +19,12 @@ export function ThemeToggle() { return (