mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-10 19:03:03 +02:00
feat(web): React charts/maps/QR — Phase 3
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 <script>/<link> tags from spa.html and their copy steps from build.js (fonts stay vendored). Deleted charts.js. - Moved the Vite CSS bundle (asset_app_css) into <head> before app.css so app.css dark-mode Leaflet overrides win over the bundled leaflet.css. - Dropped the chart globals from the Window type declaration. Tests/docs: - test_caching.py: removed charts.js-specific tests; generic JS-cache tests now target spa/app.js. - Updated charts.js cross-references in collector/routes.py + test_routes.py to point at spa-react/utils/charts.ts. Verified: tsc --noEmit clean, npm run build (153 modules), pytest tests/test_web (255 passed), pre-commit (passed).
This commit is contained in:
+41
-20
@@ -8,16 +8,25 @@ 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) | Not started |
|
||||
| 3 | Chart & map components (react-chartjs-2, react-leaflet) | **Complete** |
|
||||
| 4 | Cleanup (remove lit-html, old spa/, build.js esbuild remnants) | Not started |
|
||||
| 5 | Optional enhancements (tests, react-query, Storybook) | Not started |
|
||||
|
||||
> **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
|
||||
> `spa-react/utils/charts.ts` + components in `spa-react/components/charts/Charts.tsx`),
|
||||
> maps use **react-leaflet** (`MapPage.tsx`, `NodeDetail.tsx`), and QR codes use
|
||||
> **react-qr-code** (`Channels.tsx`, `NodeDetail.tsx`). Chart.js, Leaflet (+ its CSS), and
|
||||
> react-qr-code are bundled by Vite — the vendor `<script>`/`<link>` tags and the
|
||||
> `build.js` vendor copy for leaflet/chart.js/qrcodejs were removed (fonts stay vendored).
|
||||
> `spa-react/utils/charts.ts` imports `leaflet/dist/leaflet.css`; that CSS ships in the
|
||||
> Vite bundle (`asset_app_css`), which is now loaded in `<head>` **before** `app.css` so the
|
||||
> dark-mode map popup overrides in `app.css` still win.
|
||||
>
|
||||
> **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
|
||||
|
||||
@@ -26,7 +35,7 @@ Migration from lit-html (functional templates) to React 19 + TypeScript + Vite.
|
||||
- **Jinja2 shell preserved** — server renders navbar, SEO meta, config JSON; React owns `<main id="app">`
|
||||
- **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
|
||||
- **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/
|
||||
|
||||
## File Structure
|
||||
@@ -91,18 +100,19 @@ src/meshcore_hub/web/static/js/spa/ # OLD lit-html pages (still used via LitB
|
||||
```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/)
|
||||
# 2. Copy vendor fonts (chart/map/QR libs are bundled by Vite, not vendored)
|
||||
# 3. npx vite build (bundles React + chart.js + leaflet + react-qr-code → 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:
|
||||
The Jinja2 template (`spa.html`) reads `assets.json` for the entry JS/CSS filenames:
|
||||
```json
|
||||
{ "app.js": "assets/index-XXXX.js", "vendor": {...}, "locale_version": "..." }
|
||||
{ "app.js": "assets/index-XXXX.js", "app.css": "assets/index-XXXX.css", "vendor": {}, "locale_version": "..." }
|
||||
```
|
||||
|
||||
Python (`app.py`) loads this manifest at startup and passes `asset_app_js` / `asset_app_css` to the template.
|
||||
`asset_app_css` (which contains the bundled `leaflet.css`) is loaded in `<head>` before `app.css` so theme overrides win.
|
||||
|
||||
## Phase 2: Page Conversion
|
||||
|
||||
@@ -158,17 +168,28 @@ Python (`app.py`) loads this manifest at startup and passes `asset_app_js` / `as
|
||||
| `renderFilterForm({ fields, basePath, navigate })` | `<FilterForm basePath={...}>...</FilterForm>` |
|
||||
| `renderStatCard({ icon, color, title, value })` | `<StatCard 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 |
|
||||
| `window.createActivityChart(...)` | `<ActivityChart>` / `buildActivityChart` (react-chartjs-2) |
|
||||
| `window.L.map(...)` (Leaflet) | `<MapContainer>` + `useMap` controller (react-leaflet) |
|
||||
| `window.QRCode(...)` | `<QRCode>` from `react-qr-code` |
|
||||
|
||||
## Phase 3: Charts & Maps
|
||||
## Phase 3: Charts & Maps — Complete
|
||||
|
||||
- Install `react-chartjs-2` (already in package.json) — create typed wrapper components
|
||||
- Install `react-leaflet` (already in package.json) — create `<MeshMap>` component
|
||||
- Port `charts.js` global functions into React chart components
|
||||
- Remove leaflet/chart.js vendor `<script>` tags from `spa.html`
|
||||
- Remove `charts.js` global script
|
||||
- **react-chartjs-2**: typed config builders in `utils/charts.ts` (`buildLineChart`,
|
||||
`buildActivityChart`, `buildStackedBar`, `buildRoutesTrend`, `buildRouteDetailStrip`,
|
||||
plus `ChartColors`, `averageRouteTier`, `routeQualityToTier`); React wrappers in
|
||||
`components/charts/Charts.tsx` (`ActivityChart`, `TrendLineChart`, `StackedBarChart`,
|
||||
`RoutesTrendChart`, `RouteDetailStrip`). `utils/charts.ts` imports `chart.js/auto` (registers
|
||||
everything) — replaces the old global `charts.js`.
|
||||
- **react-leaflet**: `MapPage.tsx` rewritten with `<MapContainer>/<TileLayer>/<Marker>/<Popup>`
|
||||
+ a `MapController` (useMap) for fit-bounds and a memoized marker list; `NodeDetail.tsx`
|
||||
static hero map with `divIcon` marker + `OffsetCenter` (useMap). Both `import "leaflet/dist/leaflet.css"`.
|
||||
- **react-qr-code**: replaces `window.QRCode` in `Channels.tsx` and `NodeDetail.tsx`.
|
||||
- Removed leaflet/chart.js/qrcodejs `@script`/`@link` tags and `charts.js` from `spa.html`;
|
||||
deleted `charts.js`; removed their copy steps from `build.js` (fonts still vendored).
|
||||
- Moved the Vite CSS bundle (`asset_app_css`) into `<head>` **before** `app.css` so app.css's
|
||||
dark-mode Leaflet overrides win over the now-bundled leaflet.css.
|
||||
- Updated `tests/test_web/test_caching.py` (charts.js-specific tests removed; generic JS-cache
|
||||
tests point at `spa/app.js`).
|
||||
|
||||
## Phase 4: Cleanup
|
||||
|
||||
@@ -209,9 +230,9 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core bu
|
||||
## Important Notes
|
||||
|
||||
- The old `spa/app.js` is NO LONGER LOADED. The Jinja2 template now loads the Vite-built React bundle.
|
||||
- The Jinja2 template still renders the navbar, footer, banners, theme toggle, and vendor scripts.
|
||||
- The Jinja2 template still renders the navbar, footer, banners, and theme toggle. Vendor chart/map/QR scripts are gone (bundled by Vite); only fonts remain vendored.
|
||||
- `window.__APP_CONFIG__` is still injected by Jinja2 and read by React on bootstrap.
|
||||
- The theme toggle in the navbar is still vanilla JS (in spa.html). React doesn't manage it.
|
||||
- Old lit-html pages loaded via LitBridge still use `window.Chart`, `window.L`, `window.QRCode` globals.
|
||||
- No more `window.Chart` / `window.L` / `window.QRCode` globals — charts, maps, and QR codes are bundled React components (Phase 3).
|
||||
- Tailwind scans `static/js/` recursively — both `spa/` and `spa-react/` classes are included.
|
||||
- The `dist/assets.json` format is unchanged from the esbuild era — Python code didn't need changes.
|
||||
- The `dist/assets.json` format is unchanged from the esbuild era — Python code didn't need changes (its `vendor` map is now empty).
|
||||
|
||||
@@ -35,16 +35,6 @@ execSync(
|
||||
|
||||
console.log("Copying vendor files...");
|
||||
|
||||
vendor("leaflet", ["dist/leaflet.css", "dist/leaflet.js", "dist/leaflet.js.map"], "leaflet");
|
||||
mkdirSync(join(VENDOR, "leaflet", "images"), { recursive: true });
|
||||
cpSync(
|
||||
join("node_modules", "leaflet", "dist", "images"),
|
||||
join(VENDOR, "leaflet", "images"),
|
||||
{ recursive: true },
|
||||
);
|
||||
|
||||
vendor("chart.js", ["dist/chart.umd.min.js"], "chart.js");
|
||||
vendor("qrcodejs", ["qrcode.min.js"], "qrcodejs");
|
||||
vendor(
|
||||
"@fontsource-variable/ibm-plex-sans",
|
||||
[
|
||||
@@ -90,12 +80,7 @@ if (existsSync(viteManifestPath)) {
|
||||
}
|
||||
}
|
||||
|
||||
const vendorFiles = {
|
||||
"leaflet.css": join(VENDOR, "leaflet", "leaflet.css"),
|
||||
"leaflet.js": join(VENDOR, "leaflet", "leaflet.js"),
|
||||
"chart.umd.min.js": join(VENDOR, "chart.js", "chart.umd.min.js"),
|
||||
"qrcode.min.js": join(VENDOR, "qrcodejs", "qrcode.min.js"),
|
||||
};
|
||||
const vendorFiles = {};
|
||||
|
||||
const vendorHashes = {};
|
||||
for (const [name, path] of Object.entries(vendorFiles)) {
|
||||
|
||||
Generated
+58
-1
@@ -20,6 +20,7 @@
|
||||
"react-dom": "^19",
|
||||
"react-i18next": "^15",
|
||||
"react-leaflet": "^5",
|
||||
"react-qr-code": "^2.2.0",
|
||||
"react-router": "^7",
|
||||
"tailwindcss": "^4"
|
||||
},
|
||||
@@ -1911,7 +1912,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
@@ -2204,6 +2204,18 @@
|
||||
"@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",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -2299,6 +2311,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2347,6 +2368,23 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-generator": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
|
||||
"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",
|
||||
@@ -2409,6 +2447,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
@@ -2423,6 +2467,19 @@
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-qr-code": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.2.0.tgz",
|
||||
"integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.8.1",
|
||||
"qrcode-generator": "^2.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"react-dom": "^19",
|
||||
"react-i18next": "^15",
|
||||
"react-leaflet": "^5",
|
||||
"react-qr-code": "^2.2.0",
|
||||
"react-router": "^7",
|
||||
"tailwindcss": "^4"
|
||||
},
|
||||
|
||||
@@ -786,8 +786,8 @@ def evaluate_route_history(
|
||||
|
||||
|
||||
# Thresholds for ``compute_average_quality`` — kept in sync with the
|
||||
# ``averageTier`` helper in ``web/static/js/charts.js`` so the server-side
|
||||
# rolling-average badge matches the chart's per-route line color.
|
||||
# ``averageRouteTier`` helper in ``web/static/js/spa-react/utils/charts.ts`` so
|
||||
# the server-side rolling-average badge matches the chart's per-route line color.
|
||||
AVERAGE_QUALITY_CLEAR_AT = 1.5
|
||||
AVERAGE_QUALITY_MARGINAL_AT = 0.75
|
||||
|
||||
|
||||
@@ -1,617 +0,0 @@
|
||||
/**
|
||||
* MeshCore Hub - Chart.js Helpers
|
||||
*
|
||||
* Provides common chart configuration and initialization helpers
|
||||
* for activity charts used on home and dashboard pages.
|
||||
*/
|
||||
|
||||
// Match app typography (IBM Plex Sans); Chart.js defaults to Helvetica/Arial.
|
||||
if (typeof Chart !== 'undefined') {
|
||||
Chart.defaults.font.family = '"IBM Plex Sans", ui-sans-serif, system-ui, sans-serif';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number with locale-appropriate grouping separators.
|
||||
* Uses the visitor's browser locale (no explicit locale argument).
|
||||
* @param {number} v
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatNumber(v) {
|
||||
return new Intl.NumberFormat().format(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read page colors from CSS custom properties (defined in app.css :root).
|
||||
* Falls back to hardcoded values if CSS vars are unavailable.
|
||||
*/
|
||||
function getCSSColor(varName, fallback) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || fallback;
|
||||
}
|
||||
|
||||
function withAlpha(color, alpha) {
|
||||
// oklch(0.65 0.24 265) -> oklch(0.65 0.24 265 / 0.1)
|
||||
return color.replace(')', ' / ' + alpha + ')');
|
||||
}
|
||||
|
||||
const ChartColors = {
|
||||
get nodes() { return getCSSColor('--color-nodes', 'oklch(0.65 0.24 265)'); },
|
||||
get nodesFill() { return withAlpha(this.nodes, 0.1); },
|
||||
get adverts() { return getCSSColor('--color-adverts', 'oklch(0.7 0.17 330)'); },
|
||||
get advertsFill() { return withAlpha(this.adverts, 0.1); },
|
||||
get messages() { return getCSSColor('--color-messages', 'oklch(0.75 0.18 180)'); },
|
||||
get messagesFill() { return withAlpha(this.messages, 0.1); },
|
||||
get packets() { return getCSSColor('--color-packets', 'oklch(0.72 0.17 145)'); },
|
||||
get packetsFill() { return withAlpha(this.packets, 0.1); },
|
||||
get routes() { return getCSSColor('--color-routes', 'oklch(0.72 0.17 30)'); },
|
||||
get routesFill() { return withAlpha(this.routes, 0.1); },
|
||||
|
||||
// Neutral grays (not page-specific)
|
||||
grid: 'oklch(0.4 0 0 / 0.2)',
|
||||
text: 'oklch(0.7 0 0)',
|
||||
tooltipBg: 'oklch(0.25 0 0)',
|
||||
tooltipText: 'oklch(0.9 0 0)',
|
||||
tooltipBorder: 'oklch(0.4 0 0)',
|
||||
|
||||
// Qualitative palette for stacked breakdown bars (6 hues + neutral grey
|
||||
// for "other"). Hardcoded oklch values render consistently across light
|
||||
// and dark themes without extra CSS tokens.
|
||||
breakdown: [
|
||||
'oklch(0.65 0.24 265)', // blue
|
||||
'oklch(0.7 0.17 330)', // magenta
|
||||
'oklch(0.75 0.18 180)', // teal
|
||||
'oklch(0.72 0.17 145)', // green
|
||||
'oklch(0.7 0.19 80)', // yellow-green
|
||||
'oklch(0.65 0.22 25)', // orange
|
||||
'oklch(0.55 0 0)' // neutral grey (for "other")
|
||||
],
|
||||
|
||||
// Semantic quality palette for route health charts. Hardcoded oklch
|
||||
// values (same approach as `breakdown`) — app.css defines no semantic
|
||||
// status colors.
|
||||
quality: {
|
||||
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)'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create common chart options with optional legend
|
||||
* @param {boolean} showLegend - Whether to show the legend
|
||||
* @returns {Object} Chart.js options object
|
||||
*/
|
||||
function createChartOptions(showLegend) {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: showLegend,
|
||||
position: 'top',
|
||||
align: 'end',
|
||||
labels: {
|
||||
color: ChartColors.text,
|
||||
boxWidth: 12,
|
||||
padding: 8
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
backgroundColor: ChartColors.tooltipBg,
|
||||
titleColor: ChartColors.tooltipText,
|
||||
bodyColor: ChartColors.tooltipText,
|
||||
borderColor: ChartColors.tooltipBorder,
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
const label = ctx.dataset.label || '';
|
||||
const value = formatNumber(ctx.parsed.y);
|
||||
return label ? label + ': ' + value : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: ChartColors.grid },
|
||||
ticks: {
|
||||
color: ChartColors.text,
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 10
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: ChartColors.grid },
|
||||
ticks: {
|
||||
color: ChartColors.text,
|
||||
precision: 0,
|
||||
callback: function(value) { return formatNumber(value); }
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date labels for chart display (e.g., "8 Feb")
|
||||
* @param {Array} data - Array of objects with 'date' property
|
||||
* @returns {Array} Formatted date strings
|
||||
*/
|
||||
function formatDateLabels(data) {
|
||||
return data.map(function(d) {
|
||||
var date = new Date(d.date);
|
||||
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single-dataset line chart
|
||||
* @param {string} canvasId - ID of the canvas element
|
||||
* @param {Object} data - Data object with 'data' array containing {date, count} objects
|
||||
* @param {string} label - Dataset label
|
||||
* @param {string} borderColor - Line color
|
||||
* @param {string} backgroundColor - Fill color
|
||||
* @param {boolean} fill - Whether to fill under the line
|
||||
*/
|
||||
function createLineChart(canvasId, data, label, borderColor, backgroundColor, fill) {
|
||||
var ctx = document.getElementById(canvasId);
|
||||
if (!ctx || !data || !data.data || data.data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: formatDateLabels(data.data),
|
||||
datasets: [{
|
||||
label: label,
|
||||
data: data.data.map(function(d) { return d.count; }),
|
||||
borderColor: borderColor,
|
||||
backgroundColor: backgroundColor,
|
||||
fill: fill,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: createChartOptions(false)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a multi-dataset activity chart (for home page).
|
||||
* Pass null for advertData or messageData to omit that series.
|
||||
* @param {string} canvasId - ID of the canvas element
|
||||
* @param {Object|null} advertData - Advertisement data with 'data' array, or null to omit
|
||||
* @param {Object|null} messageData - Message data with 'data' array, or null to omit
|
||||
*/
|
||||
function createActivityChart(canvasId, advertData, messageData) {
|
||||
var ctx = document.getElementById(canvasId);
|
||||
if (!ctx) return null;
|
||||
|
||||
// Build datasets from whichever series are provided
|
||||
var datasets = [];
|
||||
var labels = null;
|
||||
|
||||
if (advertData && advertData.data && advertData.data.length > 0) {
|
||||
if (!labels) labels = formatDateLabels(advertData.data);
|
||||
datasets.push({
|
||||
label: (window.t && window.t('entities.advertisements')) || 'Advertisements',
|
||||
data: advertData.data.map(function(d) { return d.count; }),
|
||||
borderColor: ChartColors.adverts,
|
||||
backgroundColor: ChartColors.advertsFill,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
});
|
||||
}
|
||||
|
||||
if (messageData && messageData.data && messageData.data.length > 0) {
|
||||
if (!labels) labels = formatDateLabels(messageData.data);
|
||||
datasets.push({
|
||||
label: (window.t && window.t('entities.messages')) || 'Messages',
|
||||
data: messageData.data.map(function(d) { return d.count; }),
|
||||
borderColor: ChartColors.messages,
|
||||
backgroundColor: ChartColors.messagesFill,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
});
|
||||
}
|
||||
|
||||
if (datasets.length === 0 || !labels) return null;
|
||||
|
||||
return new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels: labels, datasets: datasets },
|
||||
options: createChartOptions(true)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a horizontal 100% stacked bar chart from labeled buckets.
|
||||
*
|
||||
* Each bucket becomes one dataset sized proportionally to its count. The
|
||||
* x-axis is fixed at 0-100% and tooltips show the raw count and percentage.
|
||||
* Returns null when buckets is empty or the total is zero (matching
|
||||
* createLineChart's empty-data idiom).
|
||||
*
|
||||
* @param {string} canvasId - ID of the canvas element
|
||||
* @param {Array|null} buckets - Array of {label, count} objects
|
||||
* @param {Array<string>} colors - Ordered color strings (one per bucket)
|
||||
* @returns {Chart|null}
|
||||
*/
|
||||
function createStackedBarChart(canvasId, buckets, colors) {
|
||||
var ctx = document.getElementById(canvasId);
|
||||
if (!ctx || !buckets || buckets.length === 0) return null;
|
||||
|
||||
var total = buckets.reduce(function(sum, b) { return sum + b.count; }, 0);
|
||||
if (total === 0) return null;
|
||||
|
||||
var datasets = buckets.map(function(bucket, i) {
|
||||
var pct = (bucket.count / total) * 100;
|
||||
return {
|
||||
label: bucket.label,
|
||||
data: [pct],
|
||||
backgroundColor: colors[i % colors.length],
|
||||
borderColor: colors[i % colors.length],
|
||||
borderWidth: 1,
|
||||
rawCount: bucket.count
|
||||
};
|
||||
});
|
||||
|
||||
return new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [''],
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: ChartColors.tooltipBg,
|
||||
titleColor: ChartColors.tooltipText,
|
||||
bodyColor: ChartColors.tooltipText,
|
||||
borderColor: ChartColors.tooltipBorder,
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
var label = ctx.dataset.label || '';
|
||||
var count = formatNumber(ctx.dataset.rawCount);
|
||||
var pct = ctx.parsed.x.toFixed(1);
|
||||
return label + ': ' + count + ' (' + pct + '%)';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
max: 100,
|
||||
stacked: true,
|
||||
grid: { color: ChartColors.grid },
|
||||
ticks: {
|
||||
color: ChartColors.text,
|
||||
callback: function(value) { return value + '%'; }
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
grid: { display: false },
|
||||
ticks: { display: false }
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
intersect: false
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a route-quality enum value to the merged 3-tier space used by the
|
||||
* dashboard trend chart and Route Health widget.
|
||||
*
|
||||
* ``clear`` → clear
|
||||
* ``marginal`` → marginal
|
||||
* anything else → failing (covers ``failing``, ``unknown``,
|
||||
* ``no_coverage``, ``disabled``, null)
|
||||
*/
|
||||
function routeQualityToTier(q) {
|
||||
if (q === 'clear') return 'clear';
|
||||
if (q === 'marginal') return 'marginal';
|
||||
return 'failing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mean tier over the displayed window. Maps the 3-tier space onto a
|
||||
* 0/1/2 numeric scale (failing < marginal < clear), averages, then
|
||||
* buckets back: >=1.5 → clear, >=0.75 → marginal, else failing.
|
||||
* Empty history falls through to failing (matches routeQualityToTier's
|
||||
* default for unknown / null quality).
|
||||
*
|
||||
* Kept in sync with ``compute_average_quality`` in
|
||||
* ``src/meshcore_hub/collector/routes.py`` so the server-side rolling
|
||||
* badge matches the client-side chart line color.
|
||||
*
|
||||
* @param {Array<{quality: string}>|null} history
|
||||
* @returns {string} tier name (``clear`` / ``marginal`` / ``failing``)
|
||||
*/
|
||||
function averageRouteTier(history) {
|
||||
if (!history || history.length === 0) return 'failing';
|
||||
var sum = 0;
|
||||
for (var i = 0; i < history.length; i++) {
|
||||
var tier = routeQualityToTier(history[i].quality);
|
||||
sum += (tier === 'clear' ? 2 : tier === 'marginal' ? 1 : 0);
|
||||
}
|
||||
var mean = sum / history.length;
|
||||
if (mean >= 1.5) return 'clear';
|
||||
if (mean >= 0.75) return 'marginal';
|
||||
return 'failing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a multi-line route-status trend chart for the dashboard.
|
||||
*
|
||||
* Each route becomes one line plotted on a 3-tier categorical Y axis
|
||||
* (``failing`` → ``marginal`` → ``clear``, bottom to top). The line's
|
||||
* color reflects the route's CURRENT quality (its latest evaluation),
|
||||
* so multiple routes in the same health band share a color — the chart
|
||||
* reads as a fleet-health overview rather than per-route identity.
|
||||
* Hover tooltips still show the route label, tier, and matched_count.
|
||||
*
|
||||
* Input is the ``routes`` array from ``GET /dashboard/routes-overview``.
|
||||
* The top ``maxRoutes`` routes by current ``matched_count`` are drawn;
|
||||
* the rest are dropped silently (summing quality tiers is meaningless).
|
||||
*
|
||||
* Quality → tier mapping (per the merged-3-tier design):
|
||||
* ``clear`` → clear
|
||||
* ``marginal`` → marginal
|
||||
* anything else → failing (covers ``failing``, ``unknown``,
|
||||
* ``no_coverage``, ``disabled``, null)
|
||||
*
|
||||
* @param {string} canvasId - ID of the canvas element
|
||||
* @param {Array|null} routes - Array of RouteOverviewEntry objects
|
||||
* @param {number} [maxRoutes=6] - Top-N routes drawn distinctly
|
||||
* @returns {Chart|null}
|
||||
*/
|
||||
function createRoutesTrendChart(canvasId, routes, maxRoutes) {
|
||||
var ctx = document.getElementById(canvasId);
|
||||
if (!ctx || !routes || routes.length === 0) return null;
|
||||
|
||||
maxRoutes = maxRoutes || 6;
|
||||
|
||||
// Bottom-to-top tier order on the categorical Y axis.
|
||||
var tierOrder = ['failing', 'marginal', 'clear'];
|
||||
|
||||
function tierColor(tier) {
|
||||
return ChartColors.quality[tier] || ChartColors.quality.failing;
|
||||
}
|
||||
|
||||
// Sort by current matched_count desc; routes with null matched_count
|
||||
// (disabled / never evaluated) sort to the end.
|
||||
var sorted = routes.slice().sort(function(a, b) {
|
||||
var am = a.matched_count || 0;
|
||||
var bm = b.matched_count || 0;
|
||||
return bm - am;
|
||||
});
|
||||
var top = sorted.slice(0, maxRoutes);
|
||||
|
||||
// Use the longest history as the X-axis label source (all routes
|
||||
// share the same window in practice, but be defensive).
|
||||
var labels = [];
|
||||
for (var i = 0; i < top.length; i++) {
|
||||
if (top[i].history && top[i].history.length > labels.length) {
|
||||
labels = formatDateLabels(top[i].history);
|
||||
}
|
||||
}
|
||||
if (labels.length === 0) return null;
|
||||
|
||||
var datasets = top.map(function(entry) {
|
||||
var history = entry.history || [];
|
||||
var avgTier = averageRouteTier(history);
|
||||
return {
|
||||
label: entry.from_label + ' \u2192 ' + entry.to_label,
|
||||
data: history.map(function(d) { return routeQualityToTier(d.quality); }),
|
||||
borderColor: tierColor(avgTier),
|
||||
backgroundColor: 'transparent',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5,
|
||||
spanGaps: true,
|
||||
_matched: history.map(function(d) { return d.matched_count || 0; })
|
||||
};
|
||||
});
|
||||
|
||||
var opts = createChartOptions(false);
|
||||
// Replace the default numeric Y axis with a 3-tier categorical axis.
|
||||
opts.scales.y = {
|
||||
type: 'category',
|
||||
labels: tierOrder,
|
||||
reverse: true,
|
||||
grid: { color: ChartColors.grid },
|
||||
ticks: {
|
||||
color: ChartColors.text,
|
||||
callback: function(_value, index) {
|
||||
var tier = tierOrder[index];
|
||||
return (window.t && window.t('routes.quality_' + tier)) || tier;
|
||||
}
|
||||
}
|
||||
};
|
||||
// The default tooltip formatter calls formatNumber(ctx.parsed.y),
|
||||
// which is wrong for categorical string values; emit tier + matched.
|
||||
opts.plugins.tooltip.callbacks = {
|
||||
title: function(items) { return items[0].label; },
|
||||
label: function(ctx) {
|
||||
var tier = tierOrder[ctx.parsed.y] || 'failing';
|
||||
var tierLabel = (window.t && window.t('routes.quality_' + tier)) || tier;
|
||||
var matched = (ctx.dataset._matched && ctx.dataset._matched[ctx.dataIndex]) || 0;
|
||||
return ctx.dataset.label + ': ' + tierLabel + ' (' + matched + ')';
|
||||
}
|
||||
};
|
||||
|
||||
return new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels: labels, datasets: datasets },
|
||||
options: opts
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize dashboard charts (nodes, advertisements, messages, packets,
|
||||
* plus optional packet-breakdown stacked bars and routes overview).
|
||||
* Pass null for any data parameter to skip that chart.
|
||||
* @param {Object|null} nodeData - Node count data, or null to skip
|
||||
* @param {Object|null} advertData - Advertisement data, or null to skip
|
||||
* @param {Object|null} messageData - Message data, or null to skip
|
||||
* @param {Object|null} packetData - Raw-packet trend data, or null to skip
|
||||
* @param {Array|null} [eventTypeData] - Packet event-type breakdown buckets
|
||||
* @param {Array|null} [pathWidthData] - Packet path-width breakdown buckets
|
||||
* @param {Array|null} [routesData] - Routes overview ``routes`` array
|
||||
*/
|
||||
function initDashboardCharts(nodeData, advertData, messageData, packetData, eventTypeData, pathWidthData, routesData) {
|
||||
if (nodeData) {
|
||||
createLineChart(
|
||||
'nodeChart',
|
||||
nodeData,
|
||||
(window.t && window.t('common.total_entity', { entity: t('entities.nodes') })) || 'Total Nodes',
|
||||
ChartColors.nodes,
|
||||
ChartColors.nodesFill,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (advertData) {
|
||||
createLineChart(
|
||||
'advertChart',
|
||||
advertData,
|
||||
(window.t && window.t('entities.advertisements')) || 'Advertisements',
|
||||
ChartColors.adverts,
|
||||
ChartColors.advertsFill,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (messageData) {
|
||||
createLineChart(
|
||||
'messageChart',
|
||||
messageData,
|
||||
(window.t && window.t('entities.messages')) || 'Messages',
|
||||
ChartColors.messages,
|
||||
ChartColors.messagesFill,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (packetData) {
|
||||
createLineChart(
|
||||
'packetChart',
|
||||
packetData,
|
||||
(window.t && window.t('entities.packets')) || 'Packets',
|
||||
ChartColors.packets,
|
||||
ChartColors.packetsFill,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (eventTypeData && eventTypeData.length > 0) {
|
||||
createStackedBarChart(
|
||||
'packetEventTypeChart',
|
||||
eventTypeData,
|
||||
ChartColors.breakdown
|
||||
);
|
||||
}
|
||||
|
||||
if (pathWidthData && pathWidthData.length > 0) {
|
||||
createStackedBarChart(
|
||||
'packetPathWidthChart',
|
||||
pathWidthData,
|
||||
ChartColors.breakdown.slice(0, 3)
|
||||
);
|
||||
}
|
||||
|
||||
if (routesData && routesData.length > 0) {
|
||||
createRoutesTrendChart('routesTrendChart', routesData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a per-route health status strip — single horizontal bar of N equal
|
||||
* colored day-segments.
|
||||
*
|
||||
* @param {string} canvasId - ID of the canvas element
|
||||
* @param {Object} routeData - RouteHistory payload with `data` array
|
||||
* @returns {Chart|null}
|
||||
*/
|
||||
function createRouteDetailStrip(canvasId, routeData) {
|
||||
var ctx = document.getElementById(canvasId);
|
||||
if (!ctx || !routeData || !routeData.data || routeData.data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var existing = Chart.getChart(ctx);
|
||||
if (existing) existing.destroy();
|
||||
|
||||
var datasets = routeData.data.map(function(day) {
|
||||
return {
|
||||
label: day.date,
|
||||
data: [1],
|
||||
backgroundColor: ChartColors.quality[day.quality] || ChartColors.quality.no_coverage,
|
||||
borderColor: ChartColors.quality[day.quality] || ChartColors.quality.no_coverage,
|
||||
borderWidth: 1,
|
||||
_quality: day.quality,
|
||||
_matched_count: day.matched_count || 0
|
||||
};
|
||||
});
|
||||
|
||||
return new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: { labels: [''], datasets: datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: ChartColors.tooltipBg,
|
||||
titleColor: ChartColors.tooltipText,
|
||||
bodyColor: ChartColors.tooltipText,
|
||||
borderColor: ChartColors.tooltipBorder,
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
title: function(ctx) { return ctx[0].dataset.label; },
|
||||
label: function(ctx) {
|
||||
var q = ctx.dataset._quality || 'unknown';
|
||||
var label = (window.t && window.t('routes.quality_' + q)) || q;
|
||||
return label + ' (' + ctx.dataset._matched_count + ')';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false }, ticks: { display: false } },
|
||||
y: { stacked: true, grid: { display: false }, ticks: { display: false } }
|
||||
},
|
||||
interaction: { mode: 'nearest', intersect: true }
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Bar, Line } from "react-chartjs-2";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
buildActivityChart,
|
||||
buildLineChart,
|
||||
buildRouteDetailStrip,
|
||||
buildRoutesTrend,
|
||||
buildStackedBar,
|
||||
type ActivitySeries,
|
||||
type BreakdownBucket,
|
||||
type RouteHistory,
|
||||
type RouteOverviewEntry,
|
||||
} from "@/utils/charts";
|
||||
|
||||
function ChartFrame({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
export function ActivityChart({
|
||||
advertData,
|
||||
messageData,
|
||||
}: {
|
||||
advertData: ActivitySeries | null;
|
||||
messageData: ActivitySeries | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const cfg = buildActivityChart(advertData, messageData, t);
|
||||
return (
|
||||
<ChartFrame className="h-48">
|
||||
{cfg && <Line data={cfg.data} options={cfg.options} />}
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrendLineChart({
|
||||
data,
|
||||
label,
|
||||
borderColor,
|
||||
backgroundColor,
|
||||
fill = true,
|
||||
}: {
|
||||
data: ActivitySeries | null;
|
||||
label: string;
|
||||
borderColor: string;
|
||||
backgroundColor: string;
|
||||
fill?: boolean;
|
||||
}) {
|
||||
const cfg = buildLineChart(data, label, borderColor, backgroundColor, fill);
|
||||
return (
|
||||
<ChartFrame className="h-32">
|
||||
{cfg && <Line data={cfg.data} options={cfg.options} />}
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function StackedBarChart({
|
||||
buckets,
|
||||
colors,
|
||||
}: {
|
||||
buckets: BreakdownBucket[] | null;
|
||||
colors: string[];
|
||||
}) {
|
||||
const cfg = buildStackedBar(buckets, colors);
|
||||
return (
|
||||
<ChartFrame className="h-32">
|
||||
{cfg && <Bar data={cfg.data} options={cfg.options} />}
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoutesTrendChart({
|
||||
routes,
|
||||
}: {
|
||||
routes: RouteOverviewEntry[] | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const cfg = buildRoutesTrend(routes, t);
|
||||
return (
|
||||
<ChartFrame className="h-32">
|
||||
{cfg && <Line data={cfg.data} options={cfg.options} />}
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function RouteDetailStrip({ data }: { data: RouteHistory | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
const cfg = buildRouteDetailStrip(data, t);
|
||||
return (
|
||||
<div style={{ height: "40px" }}>
|
||||
{cfg && <Bar data={cfg.data} options={cfg.options} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
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";
|
||||
@@ -8,24 +9,6 @@ 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;
|
||||
@@ -51,21 +34,13 @@ type ModalState =
|
||||
| { type: "delete"; channel: Channel };
|
||||
|
||||
function ChannelQrCode({ channel }: { channel: Channel }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || !channel.key_hex || el.hasChildNodes()) return;
|
||||
const qrUrl = `meshcore://channel/add?name=${encodeURIComponent(channel.name)}&secret=${channel.key_hex.toLowerCase()}`;
|
||||
new window.QRCode(el, {
|
||||
text: qrUrl,
|
||||
width: 128,
|
||||
height: 128,
|
||||
correctLevel: window.QRCode.CorrectLevel.M,
|
||||
});
|
||||
}, [channel]);
|
||||
|
||||
return <div ref={ref} className="qr-container" />;
|
||||
if (!channel.key_hex) return null;
|
||||
const qrUrl = `meshcore://channel/add?name=${encodeURIComponent(channel.name)}&secret=${channel.key_hex.toLowerCase()}`;
|
||||
return (
|
||||
<div className="qr-container">
|
||||
<QRCode value={qrUrl} size={128} level="M" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChannelCardProps {
|
||||
|
||||
@@ -9,6 +9,11 @@ import { Link } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ErrorAlert, Loading } from "@/components/Alerts";
|
||||
import {
|
||||
RoutesTrendChart,
|
||||
StackedBarChart,
|
||||
TrendLineChart,
|
||||
} from "@/components/charts/Charts";
|
||||
import { ObserverIcons } from "@/components/ObserverBadges";
|
||||
import { RouteTypeBadge } from "@/components/RouteTypeBadge";
|
||||
import {
|
||||
@@ -25,6 +30,12 @@ import {
|
||||
} from "@/context/AppConfigContext";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
import { apiGet, isAbortError } from "@/utils/api";
|
||||
import {
|
||||
averageRouteTier,
|
||||
ChartColors,
|
||||
type ActivitySeries,
|
||||
type BreakdownBucket,
|
||||
} from "@/utils/charts";
|
||||
import { formatNumber, useFormatDateTime } from "@/utils/format";
|
||||
|
||||
interface DashboardStats {
|
||||
@@ -35,8 +46,8 @@ interface DashboardStats {
|
||||
}
|
||||
|
||||
interface PacketBreakdown {
|
||||
by_event_type: { count: number }[];
|
||||
by_path_width: { count: number }[];
|
||||
by_event_type: BreakdownBucket[];
|
||||
by_path_width: BreakdownBucket[];
|
||||
}
|
||||
|
||||
interface RouteHealthEntry {
|
||||
@@ -92,25 +103,15 @@ interface ChannelsResponse {
|
||||
interface DashboardData {
|
||||
stats: DashboardStats;
|
||||
recentActivity: RecentActivity;
|
||||
advertActivity: unknown;
|
||||
messageActivity: unknown;
|
||||
nodeCount: unknown;
|
||||
packetActivity: unknown;
|
||||
advertActivity: ActivitySeries | null;
|
||||
messageActivity: ActivitySeries | null;
|
||||
nodeCount: ActivitySeries | null;
|
||||
packetActivity: ActivitySeries | null;
|
||||
packetBreakdown: PacketBreakdown;
|
||||
routesOverview: RoutesOverview | null;
|
||||
channelsData: ChannelsResponse;
|
||||
}
|
||||
|
||||
const CHART_IDS = [
|
||||
"nodeChart",
|
||||
"advertChart",
|
||||
"messageChart",
|
||||
"packetChart",
|
||||
"packetEventTypeChart",
|
||||
"packetPathWidthChart",
|
||||
"routesTrendChart",
|
||||
];
|
||||
|
||||
const QUALITY_COLORS: Record<string, string> = {
|
||||
clear: "oklch(0.72 0.17 145)",
|
||||
marginal: "oklch(0.75 0.18 85)",
|
||||
@@ -140,7 +141,6 @@ function ChartCard({
|
||||
title,
|
||||
subtitle,
|
||||
value,
|
||||
canvasId,
|
||||
children,
|
||||
}: {
|
||||
colorVar: string;
|
||||
@@ -148,7 +148,6 @@ function ChartCard({
|
||||
title: string;
|
||||
subtitle: string;
|
||||
value?: number;
|
||||
canvasId?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -174,11 +173,6 @@ function ChartCard({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canvasId && (
|
||||
<div className="h-32">
|
||||
<canvas id={canvasId}></canvas>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,10 +198,7 @@ function RoutesHealth({ routes }: { routes: RouteOverviewItem[] }) {
|
||||
{visible.map((route, i) => {
|
||||
const history = route.history || [];
|
||||
const averageTier =
|
||||
history.length > 0
|
||||
? ((window as any).averageRouteTier?.(history) as string | null) ??
|
||||
null
|
||||
: null;
|
||||
history.length > 0 ? averageRouteTier(history) : null;
|
||||
const current =
|
||||
averageTier ||
|
||||
(route.enabled ? route.quality || "no_coverage" : "disabled");
|
||||
@@ -289,14 +280,22 @@ export function DashboardPage() {
|
||||
{},
|
||||
{ signal },
|
||||
),
|
||||
apiGet<unknown>("/api/v1/dashboard/activity", { days: 7 }, { signal }),
|
||||
apiGet<unknown>(
|
||||
apiGet<ActivitySeries>(
|
||||
"/api/v1/dashboard/activity",
|
||||
{ days: 7 },
|
||||
{ signal },
|
||||
),
|
||||
apiGet<ActivitySeries>(
|
||||
"/api/v1/dashboard/message-activity",
|
||||
{ days: 7 },
|
||||
{ signal },
|
||||
),
|
||||
apiGet<unknown>("/api/v1/dashboard/node-count", { days: 7 }, { signal }),
|
||||
apiGet<unknown>(
|
||||
apiGet<ActivitySeries>(
|
||||
"/api/v1/dashboard/node-count",
|
||||
{ days: 7 },
|
||||
{ signal },
|
||||
),
|
||||
apiGet<ActivitySeries>(
|
||||
"/api/v1/dashboard/packet-activity",
|
||||
{ days: 7 },
|
||||
{ signal },
|
||||
@@ -341,27 +340,6 @@ export function DashboardPage() {
|
||||
return () => controller.abort();
|
||||
}, [showRoutes, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
window.initDashboardCharts(
|
||||
showNodes ? data.nodeCount : null,
|
||||
showAdverts ? data.advertActivity : null,
|
||||
showMessages ? data.messageActivity : null,
|
||||
showPackets ? data.packetActivity : null,
|
||||
showPackets ? data.packetBreakdown.by_event_type : null,
|
||||
showPackets ? data.packetBreakdown.by_path_width : null,
|
||||
showRoutes && data.routesOverview?.routes
|
||||
? data.routesOverview.routes
|
||||
: null,
|
||||
);
|
||||
return () => {
|
||||
for (const id of CHART_IDS) {
|
||||
const canvas = document.getElementById(id);
|
||||
if (canvas) (window as any).Chart?.getChart(canvas)?.destroy();
|
||||
}
|
||||
};
|
||||
}, [data, showNodes, showAdverts, showMessages, showPackets, showRoutes]);
|
||||
|
||||
const channelLabels = useMemo(() => {
|
||||
if (!data) return new Map<number, string>();
|
||||
return new Map<number, string>([
|
||||
@@ -438,8 +416,16 @@ export function DashboardPage() {
|
||||
title={t("entities.nodes")}
|
||||
subtitle={t("time.over_time_last_7_days")}
|
||||
value={stats.total_nodes}
|
||||
canvasId="nodeChart"
|
||||
/>
|
||||
>
|
||||
<TrendLineChart
|
||||
data={data.nodeCount}
|
||||
label={t("common.total_entity", {
|
||||
entity: t("entities.nodes"),
|
||||
})}
|
||||
borderColor={ChartColors.nodes}
|
||||
backgroundColor={ChartColors.nodesFill}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
{showAdverts && (
|
||||
<ChartCard
|
||||
@@ -448,8 +434,14 @@ export function DashboardPage() {
|
||||
title={t("entities.advertisements")}
|
||||
subtitle={t("time.per_day_last_7_days")}
|
||||
value={stats.advertisements_7d}
|
||||
canvasId="advertChart"
|
||||
/>
|
||||
>
|
||||
<TrendLineChart
|
||||
data={data.advertActivity}
|
||||
label={t("entities.advertisements")}
|
||||
borderColor={ChartColors.adverts}
|
||||
backgroundColor={ChartColors.advertsFill}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
{showMessages && (
|
||||
<ChartCard
|
||||
@@ -458,8 +450,14 @@ export function DashboardPage() {
|
||||
title={t("entities.messages")}
|
||||
subtitle={t("time.per_day_last_7_days")}
|
||||
value={stats.messages_7d}
|
||||
canvasId="messageChart"
|
||||
/>
|
||||
>
|
||||
<TrendLineChart
|
||||
data={data.messageActivity}
|
||||
label={t("entities.messages")}
|
||||
borderColor={ChartColors.messages}
|
||||
backgroundColor={ChartColors.messagesFill}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
{showPackets && (
|
||||
<ChartCard
|
||||
@@ -468,8 +466,14 @@ export function DashboardPage() {
|
||||
title={t("entities.packets")}
|
||||
subtitle={t("time.per_day_last_7_days")}
|
||||
value={stats.packets_7d}
|
||||
canvasId="packetChart"
|
||||
/>
|
||||
>
|
||||
<TrendLineChart
|
||||
data={data.packetActivity}
|
||||
label={t("entities.packets")}
|
||||
borderColor={ChartColors.packets}
|
||||
backgroundColor={ChartColors.packetsFill}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -482,8 +486,12 @@ export function DashboardPage() {
|
||||
title={t("entities.packet_event_types")}
|
||||
subtitle={t("time.last_7_days")}
|
||||
value={eventTypeTotal}
|
||||
canvasId="packetEventTypeChart"
|
||||
/>
|
||||
>
|
||||
<StackedBarChart
|
||||
buckets={packetBreakdown.by_event_type}
|
||||
colors={ChartColors.breakdown}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
{showPackets && (
|
||||
<ChartCard
|
||||
@@ -492,8 +500,12 @@ export function DashboardPage() {
|
||||
title={t("entities.path_hash_width")}
|
||||
subtitle={t("time.last_7_days")}
|
||||
value={pathWidthTotal}
|
||||
canvasId="packetPathWidthChart"
|
||||
/>
|
||||
>
|
||||
<StackedBarChart
|
||||
buckets={packetBreakdown.by_path_width}
|
||||
colors={ChartColors.breakdown.slice(0, 3)}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
{showRoutes && hasRoutes && (
|
||||
<ChartCard
|
||||
@@ -511,8 +523,9 @@ export function DashboardPage() {
|
||||
subtitle={t("time.routes_over_last_n_days", {
|
||||
n: routesOverview!.days,
|
||||
})}
|
||||
canvasId="routesTrendChart"
|
||||
/>
|
||||
>
|
||||
<RoutesTrendChart routes={routesOverview!.routes} />
|
||||
</ChartCard>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Link } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ErrorAlert, Loading } from "@/components/Alerts";
|
||||
import { ActivityChart } from "@/components/charts/Charts";
|
||||
import { StatCard } from "@/components/StatCard";
|
||||
import {
|
||||
IconAdvertisements,
|
||||
@@ -53,10 +54,6 @@ interface ActivitySeries {
|
||||
data: { date: string; count: number }[];
|
||||
}
|
||||
|
||||
interface ChartInstance {
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
|
||||
|
||||
function NavCard({
|
||||
@@ -153,7 +150,6 @@ export function HomePage() {
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const chartCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const hasDataRef = useRef(false);
|
||||
|
||||
const networkName = config.network_name || "MeshCore Network";
|
||||
@@ -217,24 +213,6 @@ export function HomePage() {
|
||||
|
||||
useAutoRefresh({ onRefresh: load });
|
||||
|
||||
useEffect(() => {
|
||||
if (!showActivityChart || !chartCanvasRef.current) return;
|
||||
const chart = window.createActivityChart(
|
||||
"activityChart",
|
||||
showAdvertSeries ? advertActivity : null,
|
||||
showMessageSeries ? messageActivity : null,
|
||||
) as ChartInstance | null;
|
||||
return () => {
|
||||
chart?.destroy();
|
||||
};
|
||||
}, [
|
||||
showActivityChart,
|
||||
showAdvertSeries,
|
||||
showMessageSeries,
|
||||
advertActivity,
|
||||
messageActivity,
|
||||
]);
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (error) return <ErrorAlert message={error} />;
|
||||
if (!stats) return null;
|
||||
@@ -470,9 +448,10 @@ export function HomePage() {
|
||||
<p className="text-sm opacity-70 mb-2">
|
||||
{t("time.activity_per_day_last_7_days")}
|
||||
</p>
|
||||
<div className="h-48">
|
||||
<canvas ref={chartCanvasRef} id="activityChart"></canvas>
|
||||
</div>
|
||||
<ActivityChart
|
||||
advertData={showAdvertSeries ? advertActivity : null}
|
||||
messageData={showMessageSeries ? messageActivity : null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
|
||||
import {
|
||||
divIcon,
|
||||
latLngBounds,
|
||||
type DivIcon,
|
||||
type Map as LeafletMap,
|
||||
} from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
@@ -120,7 +128,7 @@ function getTypeDisplay(node: MapNode, t: TFunction): string {
|
||||
: t("node_types.unknown");
|
||||
}
|
||||
|
||||
function createNodeIcon(L: any, node: MapNode, oidcEnabled: boolean): any {
|
||||
function createNodeIcon(node: MapNode, oidcEnabled: boolean): DivIcon {
|
||||
const displayName = node.name || "";
|
||||
const relativeTime = formatRelativeTime(node.last_seen);
|
||||
const timeDisplay = relativeTime ? " (" + relativeTime + ")" : "";
|
||||
@@ -130,7 +138,7 @@ function createNodeIcon(L: any, node: MapNode, oidcEnabled: boolean): any {
|
||||
? '<div style="width: 12px; height: 12px; background: var(--color-marker-infra); border: 2px solid var(--color-marker-infra-border); border-radius: 50%; box-shadow: 0 0 4px rgba(59,130,246,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>'
|
||||
: '<div style="width: 12px; height: 12px; background: var(--color-marker-public); border: 2px solid var(--color-marker-public-border); border-radius: 50%; box-shadow: 0 0 4px rgba(34,197,94,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>';
|
||||
|
||||
return L.divIcon({
|
||||
return divIcon({
|
||||
className: "custom-div-icon",
|
||||
html:
|
||||
'<div class="map-marker" style="display: flex; flex-direction: column; align-items: center; gap: 2px;">' +
|
||||
@@ -145,117 +153,92 @@ function createNodeIcon(L: any, node: MapNode, oidcEnabled: boolean): any {
|
||||
});
|
||||
}
|
||||
|
||||
function createPopupContent(
|
||||
node: MapNode,
|
||||
oidcEnabled: boolean,
|
||||
t: TFunction,
|
||||
): string {
|
||||
function NodePopup({
|
||||
node,
|
||||
oidcEnabled,
|
||||
}: {
|
||||
node: MapNode;
|
||||
oidcEnabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const typeDisplay = getTypeDisplay(node, t);
|
||||
const nodeTypeEmoji = typeEmoji(node.adv_type);
|
||||
|
||||
let infraIndicatorHtml = "";
|
||||
if (oidcEnabled && typeof node.is_adopted !== "undefined") {
|
||||
const dotColor = node.is_adopted
|
||||
? "var(--color-marker-infra)"
|
||||
: "var(--color-marker-public)";
|
||||
const borderColor = node.is_adopted
|
||||
? "var(--color-marker-infra-border)"
|
||||
: "var(--color-marker-public-border)";
|
||||
const title = node.is_adopted ? t("map.infrastructure") : t("map.public");
|
||||
infraIndicatorHtml =
|
||||
' <span style="display: inline-block; width: 10px; height: 10px; background: ' +
|
||||
dotColor +
|
||||
"; border: 2px solid " +
|
||||
borderColor +
|
||||
'; border-radius: 50%; vertical-align: middle;" title="' +
|
||||
escapeHtml(title) +
|
||||
'"></span>';
|
||||
}
|
||||
|
||||
const typeLabel = t("common.type");
|
||||
const keyLabel = t("common.key");
|
||||
const locationLabel = t("common.location");
|
||||
const lastSeenLabel = t("common.last_seen_label");
|
||||
const unknownLabel = t("node_types.unknown");
|
||||
const viewDetailsLabel = t("common.view_details");
|
||||
|
||||
let rows = "";
|
||||
rows +=
|
||||
'<div class="opacity-70">' +
|
||||
typeLabel +
|
||||
"</div><div>" +
|
||||
escapeHtml(typeDisplay) +
|
||||
"</div>";
|
||||
|
||||
if (node.role) {
|
||||
const roleLabel = t("map.role");
|
||||
rows +=
|
||||
'<div class="opacity-70">' +
|
||||
roleLabel +
|
||||
'</div><div><span class="badge badge-xs badge-ghost">' +
|
||||
escapeHtml(node.role) +
|
||||
"</span></div>";
|
||||
}
|
||||
|
||||
if (node.owner) {
|
||||
const ownerLabel = t("map.owner");
|
||||
const ownerDisplay = node.owner.callsign
|
||||
? escapeHtml(node.owner.name) +
|
||||
" (" +
|
||||
escapeHtml(node.owner.callsign) +
|
||||
")"
|
||||
: escapeHtml(node.owner.name);
|
||||
rows +=
|
||||
'<div class="opacity-70">' + ownerLabel + "</div><div>" + ownerDisplay + "</div>";
|
||||
}
|
||||
|
||||
rows +=
|
||||
'<div class="opacity-70">' +
|
||||
keyLabel +
|
||||
'</div><div><code class="text-xs">' +
|
||||
escapeHtml(node.public_key.substring(0, 16)) +
|
||||
"...</code></div>";
|
||||
rows +=
|
||||
'<div class="opacity-70">' +
|
||||
locationLabel +
|
||||
"</div><div>" +
|
||||
node.lat.toFixed(4) +
|
||||
", " +
|
||||
node.lon.toFixed(4) +
|
||||
"</div>";
|
||||
|
||||
if (node.last_seen) {
|
||||
rows +=
|
||||
'<div class="opacity-70">' +
|
||||
lastSeenLabel +
|
||||
"</div><div>" +
|
||||
node.last_seen.substring(0, 19).replace("T", " ") +
|
||||
"</div>";
|
||||
}
|
||||
const showInfra = oidcEnabled && typeof node.is_adopted !== "undefined";
|
||||
|
||||
return (
|
||||
'<div class="p-2">' +
|
||||
'<h3 class="font-bold text-lg mb-2">' +
|
||||
nodeTypeEmoji +
|
||||
" " +
|
||||
escapeHtml(node.name || unknownLabel) +
|
||||
infraIndicatorHtml +
|
||||
"</h3>" +
|
||||
'<div class="text-sm grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">' +
|
||||
rows +
|
||||
"</div>" +
|
||||
'<a href="/nodes/' +
|
||||
encodeURIComponent(node.public_key) +
|
||||
'" class="btn btn-outline btn-xs mt-3">' +
|
||||
viewDetailsLabel +
|
||||
"</a>" +
|
||||
"</div>"
|
||||
<div className="p-2">
|
||||
<h3 className="font-bold text-lg mb-2">
|
||||
{nodeTypeEmoji} {node.name || unknownLabel}
|
||||
{showInfra && (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "10px",
|
||||
height: "10px",
|
||||
background: node.is_adopted
|
||||
? "var(--color-marker-infra)"
|
||||
: "var(--color-marker-public)",
|
||||
border: `2px solid ${
|
||||
node.is_adopted
|
||||
? "var(--color-marker-infra-border)"
|
||||
: "var(--color-marker-public-border)"
|
||||
}`,
|
||||
borderRadius: "50%",
|
||||
verticalAlign: "middle",
|
||||
}}
|
||||
title={node.is_adopted ? t("map.infrastructure") : t("map.public")}
|
||||
/>
|
||||
)}
|
||||
</h3>
|
||||
<div className="text-sm grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">
|
||||
<div className="opacity-70">{t("common.type")}</div>
|
||||
<div>{typeDisplay}</div>
|
||||
{node.role && (
|
||||
<>
|
||||
<div className="opacity-70">{t("map.role")}</div>
|
||||
<div>
|
||||
<span className="badge badge-xs badge-ghost">{node.role}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{node.owner && (
|
||||
<>
|
||||
<div className="opacity-70">{t("map.owner")}</div>
|
||||
<div>
|
||||
{node.owner.callsign
|
||||
? `${node.owner.name} (${node.owner.callsign})`
|
||||
: node.owner.name}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="opacity-70">{t("common.key")}</div>
|
||||
<div>
|
||||
<code className="text-xs">{node.public_key.substring(0, 16)}...</code>
|
||||
</div>
|
||||
<div className="opacity-70">{t("common.location")}</div>
|
||||
<div>
|
||||
{node.lat.toFixed(4)}, {node.lon.toFixed(4)}
|
||||
</div>
|
||||
{node.last_seen && (
|
||||
<>
|
||||
<div className="opacity-70">{t("common.last_seen_label")}</div>
|
||||
<div>{node.last_seen.substring(0, 19).replace("T", " ")}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={`/nodes/${encodeURIComponent(node.public_key)}`}
|
||||
className="btn btn-outline btn-xs mt-3"
|
||||
>
|
||||
{t("common.view_details")}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fitInitialBounds(
|
||||
map: any,
|
||||
L: any,
|
||||
map: LeafletMap,
|
||||
data: MapData,
|
||||
oidcEnabled: boolean,
|
||||
): void {
|
||||
@@ -265,7 +248,7 @@ function fitInitialBounds(
|
||||
const adoptedNodes = allNodes.filter((n) => n.is_adopted);
|
||||
if (adoptedNodes.length > 0) {
|
||||
map.fitBounds(
|
||||
L.latLngBounds(adoptedNodes.map((n) => [n.lat, n.lon])),
|
||||
latLngBounds(adoptedNodes.map((n) => [n.lat, n.lon])),
|
||||
{ padding },
|
||||
);
|
||||
return;
|
||||
@@ -284,11 +267,59 @@ function fitInitialBounds(
|
||||
);
|
||||
const nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes;
|
||||
map.fitBounds(
|
||||
L.latLngBounds(nodesToFit.map((n) => [n.lat, n.lon])),
|
||||
latLngBounds(nodesToFit.map((n) => [n.lat, n.lon])),
|
||||
{ padding },
|
||||
);
|
||||
}
|
||||
|
||||
function MapController({
|
||||
mapData,
|
||||
filteredNodes,
|
||||
category,
|
||||
oidcEnabled,
|
||||
}: {
|
||||
mapData: MapData;
|
||||
filteredNodes: MapNode[];
|
||||
category: string;
|
||||
oidcEnabled: boolean;
|
||||
}) {
|
||||
const map = useMap();
|
||||
const initialFitRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapData) return;
|
||||
if (!initialFitRef.current) {
|
||||
initialFitRef.current = true;
|
||||
fitInitialBounds(map, mapData, oidcEnabled);
|
||||
return;
|
||||
}
|
||||
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(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}, [map, mapData, filteredNodes, category, oidcEnabled]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MapPage() {
|
||||
const { t } = useTranslation();
|
||||
const config = useAppConfig();
|
||||
@@ -298,11 +329,6 @@ export function MapPage() {
|
||||
const tz = config.timezone || "";
|
||||
const operatorRole = config.role_names?.operator || "operator";
|
||||
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<any>(null);
|
||||
const markersRef = useRef<any[]>([]);
|
||||
const initialFitRef = useRef(false);
|
||||
|
||||
const [mapData, setMapData] = useState<MapData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -311,8 +337,6 @@ export function MapPage() {
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [operatorFilter, setOperatorFilter] = useState("");
|
||||
const [showLabels, setShowLabels] = useState(false);
|
||||
const [nodeCount, setNodeCount] = useState(0);
|
||||
const [filteredCount, setFilteredCount] = useState<number | null>(null);
|
||||
|
||||
const operatorProfiles = useMemo(
|
||||
() =>
|
||||
@@ -343,99 +367,34 @@ export function MapPage() {
|
||||
return () => ac.abort();
|
||||
}, [operatorFilter, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || mapRef.current) return;
|
||||
const L = (window as any).L;
|
||||
const el = mapContainerRef.current;
|
||||
if (!L || !el) return;
|
||||
const map = L.map(el).setView([0, 0], 2);
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
mapRef.current = map;
|
||||
return () => {
|
||||
mapRef.current = null;
|
||||
markersRef.current = [];
|
||||
map.remove();
|
||||
};
|
||||
}, [loading]);
|
||||
const allNodes = useMemo(() => mapData?.nodes ?? [], [mapData]);
|
||||
|
||||
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 filteredNodes = useMemo(
|
||||
() =>
|
||||
allNodes.filter((node) => {
|
||||
if (category === "infra" && !node.is_adopted) return false;
|
||||
if (typeFilter && normalizeType(node.adv_type) !== typeFilter)
|
||||
return false;
|
||||
return true;
|
||||
}),
|
||||
[allNodes, category, typeFilter],
|
||||
);
|
||||
|
||||
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 markers = useMemo(
|
||||
() =>
|
||||
filteredNodes.map((node) => (
|
||||
<Marker
|
||||
key={node.public_key}
|
||||
position={[node.lat, node.lon]}
|
||||
icon={createNodeIcon(node, oidcEnabled)}
|
||||
>
|
||||
<Popup>
|
||||
<NodePopup node={node} oidcEnabled={oidcEnabled} />
|
||||
</Popup>
|
||||
</Marker>
|
||||
)),
|
||||
[filteredNodes, oidcEnabled],
|
||||
);
|
||||
|
||||
const clearFilters = () => {
|
||||
setCategory("");
|
||||
@@ -445,6 +404,8 @@ export function MapPage() {
|
||||
};
|
||||
|
||||
const debug = mapData?.debug ?? null;
|
||||
const nodeCount = allNodes.length;
|
||||
const filteredCount = filteredNodes.length;
|
||||
let countBadgeText: string;
|
||||
if (debug?.error) {
|
||||
countBadgeText = "Error: " + debug.error;
|
||||
@@ -456,14 +417,14 @@ export function MapPage() {
|
||||
countBadgeText = t("map.nodes_none_have_coordinates", {
|
||||
count: formatNumber(debug.total_nodes),
|
||||
});
|
||||
} else if (filteredCount === null || filteredCount === nodeCount) {
|
||||
} else if (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;
|
||||
const showFilteredBadge = filteredCount !== nodeCount;
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (error) return <ErrorAlert message={error} />;
|
||||
@@ -560,9 +521,29 @@ export function MapPage() {
|
||||
<div className="card bg-base-100 shadow-xl">
|
||||
<div className="card-body p-2">
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
className={showLabels ? "show-labels" : undefined}
|
||||
style={{ height: "calc(100vh - 300px)", minHeight: "400px" }}
|
||||
/>
|
||||
>
|
||||
<MapContainer
|
||||
center={[0, 0]}
|
||||
zoom={2}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{markers}
|
||||
{mapData && (
|
||||
<MapController
|
||||
mapData={mapData}
|
||||
filteredNodes={filteredNodes}
|
||||
category={category}
|
||||
oidcEnabled={oidcEnabled}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
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 { hasRole, useAppConfig } from "@/context/AppConfigContext";
|
||||
@@ -65,6 +69,33 @@ function errorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
function OffsetCenter({ lat, lon }: { lat: number; lon: number }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.setView([lat, lon], 14);
|
||||
const point = map.latLngToContainerPoint([lat, lon]);
|
||||
const size = map.getSize();
|
||||
const newPoint = leafletPoint(point.x + size.x * 0.17, point.y);
|
||||
const newLatLng = map.containerPointToLatLng(newPoint);
|
||||
map.setView(newLatLng, 14, { animate: false });
|
||||
}, [map, lat, lon]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function NodeQrCode({ url, className }: { url: string; className: string }) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<QRCode
|
||||
value={url}
|
||||
size={140}
|
||||
level="L"
|
||||
fgColor="#000000"
|
||||
bgColor="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const config = useAppConfig();
|
||||
@@ -102,11 +133,6 @@ export function NodeDetailPage() {
|
||||
const [deleteKey, setDeleteKey] = useState<string | null>(null);
|
||||
const [deleteSaving, setDeleteSaving] = useState(false);
|
||||
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const qrRef = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<unknown>(null);
|
||||
const qrInitRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!publicKey || publicKey.length === 64) return;
|
||||
const ac = new AbortController();
|
||||
@@ -201,48 +227,22 @@ export function NodeDetailPage() {
|
||||
const displayName = tagName || node?.name || t("common.unnamed_node");
|
||||
const emoji = typeEmoji(node?.adv_type ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node || !hasCoords || lat == null || lon == null) return;
|
||||
const L = (window as any).L;
|
||||
const mapEl = mapContainerRef.current;
|
||||
if (!L || !mapEl) return;
|
||||
const map = L.map(mapEl, {
|
||||
zoomControl: false,
|
||||
dragging: false,
|
||||
scrollWheelZoom: false,
|
||||
doubleClickZoom: false,
|
||||
boxZoom: false,
|
||||
keyboard: false,
|
||||
attributionControl: false,
|
||||
});
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png").addTo(
|
||||
map,
|
||||
);
|
||||
map.setView([lat, lon], 14);
|
||||
const point = map.latLngToContainerPoint([lat, lon]);
|
||||
const newPoint = L.point(point.x + map.getSize().x * 0.17, point.y);
|
||||
const newLatLng = map.containerPointToLatLng(newPoint);
|
||||
map.setView(newLatLng, 14, { animate: false });
|
||||
const mapIcon = L.divIcon({
|
||||
html:
|
||||
'<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">' +
|
||||
emoji +
|
||||
"</span>",
|
||||
className: "",
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
});
|
||||
L.marker([lat, lon], { icon: mapIcon }).addTo(map);
|
||||
mapRef.current = map;
|
||||
return () => {
|
||||
mapRef.current = null;
|
||||
map.remove();
|
||||
};
|
||||
}, [node, hasCoords, lat, lon, emoji]);
|
||||
const nodeMapIcon = useMemo(
|
||||
() =>
|
||||
divIcon({
|
||||
html:
|
||||
'<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">' +
|
||||
emoji +
|
||||
"</span>",
|
||||
className: "",
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
}),
|
||||
[emoji],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
qrInitRef.current = false;
|
||||
const qrUrl = useMemo(() => {
|
||||
if (!node) return "";
|
||||
const typeMap: Record<string, number> = {
|
||||
chat: 1,
|
||||
repeater: 2,
|
||||
@@ -251,36 +251,15 @@ export function NodeDetailPage() {
|
||||
sensor: 4,
|
||||
};
|
||||
const typeNum = typeMap[(node.adv_type || "").toLowerCase()] || 1;
|
||||
const url =
|
||||
return (
|
||||
"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]);
|
||||
typeNum
|
||||
);
|
||||
}, [node, displayName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!flash) return;
|
||||
@@ -683,20 +662,40 @@ export function NodeDetailPage() {
|
||||
<ErrorAlert message={flash.message} />
|
||||
))}
|
||||
|
||||
{hasCoords ? (
|
||||
{hasCoords && lat != null && lon != null ? (
|
||||
<div
|
||||
className="relative rounded-box overflow-hidden mb-6 shadow-xl"
|
||||
style={{ height: 180 }}
|
||||
>
|
||||
<div ref={mapContainerRef} className="absolute inset-0 z-0" />
|
||||
<div className="absolute inset-0 z-0">
|
||||
<MapContainer
|
||||
center={[lat, lon]}
|
||||
zoom={14}
|
||||
zoomControl={false}
|
||||
dragging={false}
|
||||
scrollWheelZoom={false}
|
||||
doubleClickZoom={false}
|
||||
boxZoom={false}
|
||||
keyboard={false}
|
||||
attributionControl={false}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<Marker position={[lat, lon]} icon={nodeMapIcon} />
|
||||
<OffsetCenter lat={lat} lon={lon} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
<div className="relative z-20 h-full p-3 flex items-center justify-end">
|
||||
<div ref={qrRef} className="bg-white p-2 rounded-box shadow-lg" />
|
||||
<NodeQrCode
|
||||
url={qrUrl}
|
||||
className="bg-white p-2 rounded-box shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card bg-base-100 shadow-xl mb-6">
|
||||
<div className="card-body flex-row items-center gap-4">
|
||||
<div ref={qrRef} className="bg-white p-2 rounded-box" />
|
||||
<NodeQrCode url={qrUrl} className="bg-white p-2 rounded-box" />
|
||||
<p className="text-sm opacity-70">{t("nodes.scan_to_add")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ 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 { RouteDetailStrip } from "@/components/charts/Charts";
|
||||
import {
|
||||
IconClock,
|
||||
IconEdit,
|
||||
@@ -108,9 +109,6 @@ interface SelectedNode {
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
interface ChartInstance {
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
interface ModalState {
|
||||
type: "add" | "edit" | "delete";
|
||||
@@ -477,9 +475,7 @@ function DetailContent({
|
||||
<div className="mt-2 space-y-3 text-sm">
|
||||
{history && (
|
||||
<div className="mb-3">
|
||||
<div style={{ height: "40px" }}>
|
||||
<canvas id={`routeStripChart-${route.id}`}></canvas>
|
||||
</div>
|
||||
<RouteDetailStrip data={history} />
|
||||
{historyData.length > 0 && (
|
||||
<div className="flex text-xs opacity-50 mt-0.5">
|
||||
{historyData.map((d, i) => (
|
||||
@@ -1169,21 +1165,11 @@ export function RoutesPage() {
|
||||
|
||||
const detailCacheRef = useRef<Record<string, RouteDetail>>({});
|
||||
const historyCacheRef = useRef<Record<string, RouteHistory>>({});
|
||||
const chartsRef = useRef<ChartInstance[]>([]);
|
||||
const pathTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const obsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pathSearchIdRef = useRef(0);
|
||||
const obsSearchIdRef = useRef(0);
|
||||
|
||||
const destroyCharts = useCallback(() => {
|
||||
chartsRef.current.forEach((c) => {
|
||||
try {
|
||||
c.destroy();
|
||||
} catch (_) {}
|
||||
});
|
||||
chartsRef.current = [];
|
||||
}, []);
|
||||
|
||||
const loadAllDetails = useCallback(async (routesList: RouteItem[]) => {
|
||||
const newDetails: Record<string, RouteDetail> = {};
|
||||
const newHistories: Record<string, RouteHistory> = {};
|
||||
@@ -1251,27 +1237,12 @@ export function RoutesPage() {
|
||||
};
|
||||
}, [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({
|
||||
|
||||
@@ -57,13 +57,6 @@ declare global {
|
||||
interface Window {
|
||||
__APP_CONFIG__: AppConfig;
|
||||
t: (key: string, params?: Record<string, unknown>) => 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
import Chart from "chart.js/auto";
|
||||
import type {
|
||||
ChartData,
|
||||
ChartDataset,
|
||||
ChartOptions,
|
||||
TooltipItem,
|
||||
} from "chart.js";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
Chart.defaults.font.family =
|
||||
'"IBM Plex Sans", ui-sans-serif, system-ui, sans-serif';
|
||||
|
||||
export interface ActivityPoint {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ActivitySeries {
|
||||
data: ActivityPoint[];
|
||||
}
|
||||
|
||||
export interface BreakdownBucket {
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RouteHistoryDay {
|
||||
date: string;
|
||||
quality?: string | null;
|
||||
matched_count?: number | null;
|
||||
}
|
||||
|
||||
export interface RouteHistory {
|
||||
data?: RouteHistoryDay[];
|
||||
}
|
||||
|
||||
export interface RouteOverviewEntry {
|
||||
from_label: string;
|
||||
to_label: string;
|
||||
matched_count?: number | null;
|
||||
history?: RouteHistoryDay[];
|
||||
}
|
||||
|
||||
export interface ChartConfig<T extends "line" | "bar"> {
|
||||
data: ChartData<T>;
|
||||
options: ChartOptions<T>;
|
||||
}
|
||||
|
||||
function formatNumber(v: number): string {
|
||||
return new Intl.NumberFormat().format(v);
|
||||
}
|
||||
|
||||
function getCSSColor(varName: string, fallback: string): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim() || fallback
|
||||
);
|
||||
}
|
||||
|
||||
function withAlpha(color: string, alpha: number): string {
|
||||
return color.replace(")", " / " + alpha + ")");
|
||||
}
|
||||
|
||||
export const ChartColors = {
|
||||
get nodes() {
|
||||
return getCSSColor("--color-nodes", "oklch(0.65 0.24 265)");
|
||||
},
|
||||
get nodesFill() {
|
||||
return withAlpha(this.nodes, 0.1);
|
||||
},
|
||||
get adverts() {
|
||||
return getCSSColor("--color-adverts", "oklch(0.7 0.17 330)");
|
||||
},
|
||||
get advertsFill() {
|
||||
return withAlpha(this.adverts, 0.1);
|
||||
},
|
||||
get messages() {
|
||||
return getCSSColor("--color-messages", "oklch(0.75 0.18 180)");
|
||||
},
|
||||
get messagesFill() {
|
||||
return withAlpha(this.messages, 0.1);
|
||||
},
|
||||
get packets() {
|
||||
return getCSSColor("--color-packets", "oklch(0.72 0.17 145)");
|
||||
},
|
||||
get packetsFill() {
|
||||
return withAlpha(this.packets, 0.1);
|
||||
},
|
||||
get routes() {
|
||||
return getCSSColor("--color-routes", "oklch(0.72 0.17 30)");
|
||||
},
|
||||
get routesFill() {
|
||||
return withAlpha(this.routes, 0.1);
|
||||
},
|
||||
|
||||
grid: "oklch(0.4 0 0 / 0.2)",
|
||||
text: "oklch(0.7 0 0)",
|
||||
tooltipBg: "oklch(0.25 0 0)",
|
||||
tooltipText: "oklch(0.9 0 0)",
|
||||
tooltipBorder: "oklch(0.4 0 0)",
|
||||
|
||||
breakdown: [
|
||||
"oklch(0.65 0.24 265)",
|
||||
"oklch(0.7 0.17 330)",
|
||||
"oklch(0.75 0.18 180)",
|
||||
"oklch(0.72 0.17 145)",
|
||||
"oklch(0.7 0.19 80)",
|
||||
"oklch(0.65 0.22 25)",
|
||||
"oklch(0.55 0 0)",
|
||||
],
|
||||
|
||||
quality: {
|
||||
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)",
|
||||
} as Record<string, string>,
|
||||
};
|
||||
|
||||
function createChartOptions(showLegend: boolean): ChartOptions<"line"> {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: showLegend,
|
||||
position: "top",
|
||||
align: "end",
|
||||
labels: {
|
||||
color: ChartColors.text,
|
||||
boxWidth: 12,
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
backgroundColor: ChartColors.tooltipBg,
|
||||
titleColor: ChartColors.tooltipText,
|
||||
bodyColor: ChartColors.tooltipText,
|
||||
borderColor: ChartColors.tooltipBorder,
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: (ctx: TooltipItem<"line">) => {
|
||||
const label = ctx.dataset.label || "";
|
||||
const value = formatNumber(ctx.parsed.y ?? 0);
|
||||
return label ? label + ": " + value : value;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: ChartColors.grid },
|
||||
ticks: {
|
||||
color: ChartColors.text,
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 10,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: ChartColors.grid },
|
||||
ticks: {
|
||||
color: ChartColors.text,
|
||||
precision: 0,
|
||||
callback: (value) => formatNumber(Number(value)),
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
mode: "nearest",
|
||||
axis: "x",
|
||||
intersect: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatDateLabels(data: { date: string }[]): string[] {
|
||||
return data.map((d) => {
|
||||
const date = new Date(d.date);
|
||||
return date.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function routeQualityToTier(q: string | null | undefined): string {
|
||||
if (q === "clear") return "clear";
|
||||
if (q === "marginal") return "marginal";
|
||||
return "failing";
|
||||
}
|
||||
|
||||
export function averageRouteTier(
|
||||
history: { quality?: string | null }[] | null | undefined,
|
||||
): string {
|
||||
if (!history || history.length === 0) return "failing";
|
||||
let sum = 0;
|
||||
for (const entry of history) {
|
||||
const tier = routeQualityToTier(entry.quality);
|
||||
sum += tier === "clear" ? 2 : tier === "marginal" ? 1 : 0;
|
||||
}
|
||||
const mean = sum / history.length;
|
||||
if (mean >= 1.5) return "clear";
|
||||
if (mean >= 0.75) return "marginal";
|
||||
return "failing";
|
||||
}
|
||||
|
||||
export function buildLineChart(
|
||||
data: ActivitySeries | null | undefined,
|
||||
label: string,
|
||||
borderColor: string,
|
||||
backgroundColor: string,
|
||||
fill: boolean,
|
||||
): ChartConfig<"line"> | null {
|
||||
if (!data || !data.data || data.data.length === 0) return null;
|
||||
return {
|
||||
data: {
|
||||
labels: formatDateLabels(data.data),
|
||||
datasets: [
|
||||
{
|
||||
label,
|
||||
data: data.data.map((d) => d.count),
|
||||
borderColor,
|
||||
backgroundColor,
|
||||
fill,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: createChartOptions(false),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildActivityChart(
|
||||
advertData: ActivitySeries | null | undefined,
|
||||
messageData: ActivitySeries | null | undefined,
|
||||
t: TFunction,
|
||||
): ChartConfig<"line"> | null {
|
||||
const datasets: ChartDataset<"line">[] = [];
|
||||
let labels: string[] | null = null;
|
||||
|
||||
if (advertData && advertData.data && advertData.data.length > 0) {
|
||||
if (!labels) labels = formatDateLabels(advertData.data);
|
||||
datasets.push({
|
||||
label: t("entities.advertisements"),
|
||||
data: advertData.data.map((d) => d.count),
|
||||
borderColor: ChartColors.adverts,
|
||||
backgroundColor: ChartColors.advertsFill,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5,
|
||||
});
|
||||
}
|
||||
|
||||
if (messageData && messageData.data && messageData.data.length > 0) {
|
||||
if (!labels) labels = formatDateLabels(messageData.data);
|
||||
datasets.push({
|
||||
label: t("entities.messages"),
|
||||
data: messageData.data.map((d) => d.count),
|
||||
borderColor: ChartColors.messages,
|
||||
backgroundColor: ChartColors.messagesFill,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5,
|
||||
});
|
||||
}
|
||||
|
||||
if (datasets.length === 0 || !labels) return null;
|
||||
return { data: { labels, datasets }, options: createChartOptions(true) };
|
||||
}
|
||||
|
||||
type StackedBarDataset = ChartDataset<"bar"> & { rawCount?: number };
|
||||
|
||||
export function buildStackedBar(
|
||||
buckets: BreakdownBucket[] | null | undefined,
|
||||
colors: string[],
|
||||
): ChartConfig<"bar"> | null {
|
||||
if (!buckets || buckets.length === 0) return null;
|
||||
const total = buckets.reduce((sum, b) => sum + b.count, 0);
|
||||
if (total === 0) return null;
|
||||
|
||||
const datasets: StackedBarDataset[] = buckets.map((bucket, i) => {
|
||||
const pct = (bucket.count / total) * 100;
|
||||
return {
|
||||
label: bucket.label,
|
||||
data: [pct],
|
||||
backgroundColor: colors[i % colors.length],
|
||||
borderColor: colors[i % colors.length],
|
||||
borderWidth: 1,
|
||||
rawCount: bucket.count,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
data: { labels: [""], datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: "y",
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: ChartColors.tooltipBg,
|
||||
titleColor: ChartColors.tooltipText,
|
||||
bodyColor: ChartColors.tooltipText,
|
||||
borderColor: ChartColors.tooltipBorder,
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: (ctx: TooltipItem<"bar">) => {
|
||||
const ds = ctx.dataset as StackedBarDataset;
|
||||
const label = ds.label || "";
|
||||
const count = formatNumber(ds.rawCount ?? 0);
|
||||
const pct = (ctx.parsed.x ?? 0).toFixed(1);
|
||||
return label + ": " + count + " (" + pct + "%)";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
max: 100,
|
||||
stacked: true,
|
||||
grid: { color: ChartColors.grid },
|
||||
ticks: {
|
||||
color: ChartColors.text,
|
||||
callback: (value) => value + "%",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
grid: { display: false },
|
||||
ticks: { display: false },
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
mode: "nearest",
|
||||
intersect: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type RouteTrendDataset = ChartDataset<"line"> & { _matched?: number[] };
|
||||
|
||||
export function buildRoutesTrend(
|
||||
routes: RouteOverviewEntry[] | null | undefined,
|
||||
t: TFunction,
|
||||
maxRoutes = 6,
|
||||
): ChartConfig<"line"> | null {
|
||||
if (!routes || routes.length === 0) return null;
|
||||
|
||||
const tierOrder = ["failing", "marginal", "clear"];
|
||||
const tierColor = (tier: string): string =>
|
||||
ChartColors.quality[tier] || ChartColors.quality.failing;
|
||||
|
||||
const sorted = routes
|
||||
.slice()
|
||||
.sort((a, b) => (b.matched_count || 0) - (a.matched_count || 0));
|
||||
const top = sorted.slice(0, maxRoutes);
|
||||
|
||||
let labels: string[] = [];
|
||||
for (const entry of top) {
|
||||
if (entry.history && entry.history.length > labels.length) {
|
||||
labels = formatDateLabels(entry.history);
|
||||
}
|
||||
}
|
||||
if (labels.length === 0) return null;
|
||||
|
||||
const datasets: RouteTrendDataset[] = top.map((entry) => {
|
||||
const history = entry.history || [];
|
||||
const avgTier = averageRouteTier(history);
|
||||
return {
|
||||
label: entry.from_label + " \u2192 " + entry.to_label,
|
||||
data: history.map((d) => routeQualityToTier(d.quality)),
|
||||
borderColor: tierColor(avgTier),
|
||||
backgroundColor: "transparent",
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
cubicInterpolationMode: "monotone",
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5,
|
||||
spanGaps: true,
|
||||
_matched: history.map((d) => d.matched_count || 0),
|
||||
} as unknown as RouteTrendDataset;
|
||||
});
|
||||
|
||||
const opts = createChartOptions(false);
|
||||
opts.scales = {
|
||||
...opts.scales,
|
||||
y: {
|
||||
type: "category",
|
||||
labels: tierOrder,
|
||||
reverse: true,
|
||||
grid: { color: ChartColors.grid },
|
||||
ticks: {
|
||||
color: ChartColors.text,
|
||||
callback: (_value, index) => {
|
||||
const tier = tierOrder[index];
|
||||
return t("routes.quality_" + tier);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
opts.plugins = {
|
||||
...opts.plugins,
|
||||
tooltip: {
|
||||
...opts.plugins?.tooltip,
|
||||
callbacks: {
|
||||
title: (items: TooltipItem<"line">[]) => items[0].label,
|
||||
label: (ctx: TooltipItem<"line">) => {
|
||||
const ds = ctx.dataset as RouteTrendDataset;
|
||||
const tier = tierOrder[ctx.parsed.y as number] || "failing";
|
||||
const tierLabel = t("routes.quality_" + tier);
|
||||
const matched = ds._matched?.[ctx.dataIndex] ?? 0;
|
||||
return (ds.label || "") + ": " + tierLabel + " (" + matched + ")";
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
data: { labels, datasets },
|
||||
options: opts as ChartOptions<"line">,
|
||||
};
|
||||
}
|
||||
|
||||
type StripDataset = ChartDataset<"bar"> & {
|
||||
_quality?: string;
|
||||
_matched_count?: number;
|
||||
};
|
||||
|
||||
export function buildRouteDetailStrip(
|
||||
routeData: RouteHistory | null | undefined,
|
||||
t: TFunction,
|
||||
): ChartConfig<"bar"> | null {
|
||||
if (!routeData || !routeData.data || routeData.data.length === 0) return null;
|
||||
|
||||
const datasets: StripDataset[] = routeData.data.map((day) => {
|
||||
const color =
|
||||
ChartColors.quality[day.quality ?? ""] || ChartColors.quality.no_coverage;
|
||||
return {
|
||||
label: day.date,
|
||||
data: [1],
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
borderWidth: 1,
|
||||
_quality: day.quality ?? "unknown",
|
||||
_matched_count: day.matched_count || 0,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
data: { labels: [""], datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: "y",
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: ChartColors.tooltipBg,
|
||||
titleColor: ChartColors.tooltipText,
|
||||
bodyColor: ChartColors.tooltipText,
|
||||
borderColor: ChartColors.tooltipBorder,
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
title: (ctx: TooltipItem<"bar">[]) => ctx[0].dataset.label || "",
|
||||
label: (ctx: TooltipItem<"bar">) => {
|
||||
const ds = ctx.dataset as StripDataset;
|
||||
const q = ds._quality || "unknown";
|
||||
const label = t("routes.quality_" + q);
|
||||
return label + " (" + (ds._matched_count ?? 0) + ")";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false }, ticks: { display: false } },
|
||||
y: { stacked: true, grid: { display: false }, ticks: { display: false } },
|
||||
},
|
||||
interaction: { mode: "nearest", intersect: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -43,8 +43,11 @@
|
||||
<!-- Tailwind CSS with DaisyUI (built from source) -->
|
||||
<link rel="stylesheet" href="/static/css/tailwind.css?v={{ version }}" />
|
||||
|
||||
<!-- Leaflet CSS for maps -->
|
||||
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css?v={{ vendor_hashes['leaflet.css'] }}" />
|
||||
<!-- Vite-bundled CSS (React components + Leaflet). Loaded before app.css
|
||||
so app.css theme overrides (e.g. dark-mode map popups) still win. -->
|
||||
{% if asset_app_css %}
|
||||
<link rel="stylesheet" href="/static/dist/{{ asset_app_css }}">
|
||||
{% endif %}
|
||||
|
||||
<!-- Custom application styles -->
|
||||
<link rel="stylesheet" href="/static/css/app.css?v={{ version }}">
|
||||
@@ -183,18 +186,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Leaflet JS for maps -->
|
||||
<script src="/static/vendor/leaflet/leaflet.js?v={{ vendor_hashes['leaflet.js'] }}"></script>
|
||||
|
||||
<!-- Chart.js for charts -->
|
||||
<script src="/static/vendor/chart.js/chart.umd.min.js?v={{ vendor_hashes['chart.umd.min.js'] }}"></script>
|
||||
|
||||
<!-- QR Code library -->
|
||||
<script src="/static/vendor/qrcodejs/qrcode.min.js?v={{ vendor_hashes['qrcode.min.js'] }}"></script>
|
||||
|
||||
<!-- Chart helper functions -->
|
||||
<script src="/static/js/charts.js?v={{ version }}"></script>
|
||||
|
||||
<!-- Embedded app configuration -->
|
||||
<script>
|
||||
window.__APP_CONFIG__ = {{ config_json|safe }};
|
||||
@@ -217,9 +208,6 @@
|
||||
</script>
|
||||
|
||||
<!-- SPA Application (ES Module) -->
|
||||
{% if asset_app_css %}
|
||||
<link rel="stylesheet" href="/static/dist/{{ asset_app_css }}">
|
||||
{% endif %}
|
||||
{% if asset_app_js %}
|
||||
<script type="module" src="/static/dist/{{ asset_app_js }}"></script>
|
||||
{% else %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
Reference in New Issue
Block a user