From 25dcde093a40eff78b7a45fd383cec7d8cc384df Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 2 May 2026 22:02:18 +0200 Subject: [PATCH] feat: task 2.5 MapPositions (clustered + selected sources) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/data/devices.ts: useDevicesById() — TanStack Query (5-min stale) returning Map. deviceLabel() formats human-readable titles ("FMB920 #3458"). - src/map/layers/map-positions.tsx: side-effect-only. Two GeoJSON sources (clustered non-selected + unclustered selected). Five layers: non-selected symbol + direction + cluster-bubble, selected symbol + direction. Click handlers: marker -> selectDevice, cluster -> getClusterExpansionZoom + easeTo. Hover toggles cursor. - src/routes/_authed/monitor.tsx: renders inside . Schema overhaul in src/auth/client.ts: - Made Schema SDK-compatible. Each entry is an *array* of row types (devices: DeviceRow[], not just DeviceRow). RegularCollections filters on array-like values; non-arrays collapse to never which broke readItems('devices', ...) with keyof Schema = never. - Spelled out the composed client type as DirectusClient & RestClient & AuthenticationClient — without the explicit annotation Schema didn't flow through .with(...) chain to request() call sites. - Added DeviceRow + EventRow types; exported via @/auth. useDevicesById uses readItems> — explicit generics because the SDK doesn't infer Schema from the receiver's type at call sites. Deviations: 1. Spec referenced device.kind for category — Phase 1 schema doesn't have it yet; everything maps to 'default'. Refine when kind lands. 2. Cluster bubble uses 'default-neutral' sprite instead of a dedicated 'cluster-background' (not in 2.3's registry). Swap in 3.8. 3. getClusterExpansionZoom is Promise-based in maplibre-gl 5.x (was callback-style); used .then(). Bundle: main bundle 394KB / 120KB gz, ~1KB up from 2.4. /monitor chunk includes the new layer module (~10KB). --- .../04-ws-client-and-position-store.md | 1 + .../phase-2-live-map/05-map-positions.md | 35 +- .planning/phase-2-live-map/README.md | 2 +- src/auth/client.ts | 61 +++- src/auth/index.ts | 2 +- src/data/devices.ts | 66 ++++ src/map/layers/map-positions.tsx | 306 ++++++++++++++++++ src/routes/_authed/monitor.tsx | 2 + 8 files changed, 464 insertions(+), 11 deletions(-) create mode 100644 src/data/devices.ts create mode 100644 src/map/layers/map-positions.tsx diff --git a/.planning/phase-2-live-map/04-ws-client-and-position-store.md b/.planning/phase-2-live-map/04-ws-client-and-position-store.md index efd4e3c..fc1ac8f 100644 --- a/.planning/phase-2-live-map/04-ws-client-and-position-store.md +++ b/.planning/phase-2-live-map/04-ws-client-and-position-store.md @@ -381,6 +381,7 @@ Eight files under `src/live/`: 3. `onPosition` returns an unsubscribe function (Set-add + delete). Spec showed it as a single-handler API; multi-handler support is no extra cost and lets future code (a debug panel, tests) attach without fighting the singleton handler. **Smoke check (local `pnpm dev`):** + - App boots; no WS connection until login. - After login: `useConnectionStore.getState()` shows `status: 'connecting'` then either `'connected'` (if Phase 1.5 stage processor is reachable) or `'reconnecting'` with backoff (if the proxy isn't routing `/ws-live` yet). - `usePositionStore.getState()` shows the empty initial state — `activeEventId: null`, empty Maps. diff --git a/.planning/phase-2-live-map/05-map-positions.md b/.planning/phase-2-live-map/05-map-positions.md index d310541..38a8eb8 100644 --- a/.planning/phase-2-live-map/05-map-positions.md +++ b/.planning/phase-2-live-map/05-map-positions.md @@ -263,4 +263,37 @@ The `mapReady` gate (2.1) ensures these only mount when the style is loaded. ## Done -(Filled in when the task lands.) +Three new files plus Schema work: + +- **`src/data/devices.ts`** — `useDevicesById()` hook returning a `Map` plus loading/error flags. TanStack Query with 5-min stale-time. `deviceLabel(device, deviceId)` produces a human-readable title (e.g. `"FMB920 #3458"` from model + last-4 IMEI). +- **`src/map/layers/map-positions.tsx`** — `` side-effect-only component. Two `useId`-derived (colon-stripped) source ids + five layer ids. Setup effect adds: + - non-selected source (`cluster: true, clusterMaxZoom: 14, clusterRadius: 50`) with symbol + direction-arrow + cluster-bubble layers. + - selected source (no clustering, always on top) with symbol + direction-arrow layers. + - Click handlers for marker click → `selectDevice(deviceId)`, cluster click → `getClusterExpansionZoom` + `easeTo`. Hover handlers swap the cursor. + - Cleanup removes all layers and sources in reverse order. +- Update effect: `buildFeatureCollections({ latestByDevice, selectedDeviceId, devices })` produces the two FCs and `setData`s both sources. Selected goes onto its own source so the always-on-top symbol layers handle the z-ordering without manual layer reordering. +- **`src/routes/_authed/monitor.tsx`** — renders `` as a child of ``. + +**Schema overhaul** in `src/auth/client.ts`: + +- Made `Schema` type SDK-compatible. Each entry is an *array* of row types (e.g. `devices: DeviceRow[]`), not a single row — `RegularCollections` filters on `Schema[K] extends ArrayLike` and a non-array value collapses to `never`, which broke `readItems('devices', ...)` with `keyof Schema = never`. +- Spelled out the composed client type explicitly: `DirectusClient & RestClient & AuthenticationClient` instead of `ReturnType`. Without the explicit annotation Schema didn't reliably flow through the chained `.with(...)` calls to inference at `request()` call sites. +- Added `DeviceRow` and `EventRow` row types to the Schema; barrel-exported via `@/auth`. + +`useDevicesById` calls `readItems>('devices', ...)` — the SDK doesn't infer Schema from the receiver's type, so explicit type parameters are required. + +**Deviations from spec:** + +1. Spec referenced `mapCategoryToSprite(device?.kind ?? 'default')` to pick the sprite category. Phase 1's `devices` schema doesn't have a `kind` column yet, so the feature builder maps everything to `'default'` for v1. When the schema gains a kind/category (Phase 2 of directus or earlier), thread it through here. +2. Cluster-bubble icon: spec sketched `'cluster-background'` as a separate sprite. The 2.3 sprite registry didn't include it; reused `'default-neutral'` as the bubble plate (it's a circle on a black background, which reads fine as a cluster). When 3.8 lands, swap to a dedicated cluster sprite. +3. Cluster click handler: `getClusterExpansionZoom` is a `Promise`-returning method in maplibre-gl 5.x (was callback-style in older versions). Used the Promise form. + +**Smoke check (local `pnpm dev`):** +- `/monitor` loads. `` mounts. Position store is empty until the event picker (2.7) lands. +- In the browser console: `usePositionStore.getState().applyPositions([{deviceId: 'test-1', lat: 41.327, lon: 19.819, ts: Date.now()}])` produces a marker on the map at the seeded coordinates within ~16ms (one rAF flush). +- Clicking the marker triggers `selectDevice('test-1')` — verifiable by reading the store after. +- Switching basemap (2.2) → mapReady gate fires → markers reappear after the swap. + +**Bundle:** `/monitor` route chunk is now ~10KB raw (the rest of MapLibre is in its own chunk). Main bundle 394KB / 120KB gzipped — small bump from 2.4's 393KB. No measurable impact. + +Landed in `PENDING_SHA`. diff --git a/.planning/phase-2-live-map/README.md b/.planning/phase-2-live-map/README.md index 9992e5a..a440a31 100644 --- a/.planning/phase-2-live-map/README.md +++ b/.planning/phase-2-live-map/README.md @@ -52,7 +52,7 @@ When Phase 2 is done: | 2.2 | [Tile-source switcher](./02-tile-source-switcher.md) | 🟩 | | 2.3 | [Sprite preload (racing categories)](./03-sprite-preload.md) | 🟩 | | 2.4 | [WS client + rAF coalescer + Zustand position store](./04-ws-client-and-position-store.md) | 🟩 | -| 2.5 | [MapPositions (clustered + selected sources)](./05-map-positions.md) | ⬜ | +| 2.5 | [MapPositions (clustered + selected sources)](./05-map-positions.md) | 🟩 | | 2.6 | [MapTrails (bounded ring buffer, polyline rendering)](./06-map-trails.md) | ⬜ | | 2.7 | [Event picker (subscription driver)](./07-event-picker.md) | ⬜ | | 2.8 | [Camera control trio](./08-camera-trio.md) | ⬜ | diff --git a/src/auth/client.ts b/src/auth/client.ts index 5a27f8f..1ae9398 100644 --- a/src/auth/client.ts +++ b/src/auth/client.ts @@ -1,4 +1,11 @@ -import { authentication, createDirectus, rest } from '@directus/sdk'; +import { + authentication, + createDirectus, + rest, + type AuthenticationClient, + type DirectusClient as SdkDirectusClient, + type RestClient, +} from '@directus/sdk'; import { useRuntimeConfig } from '@/config/context'; /** @@ -16,14 +23,52 @@ export type DirectusUser = { /** * Typed Directus collection schema for the SDK. * - * Will grow as the SPA starts reading TRM-specific collections in Phase 2+. - * Empty for now — Phase 1 only needs `/auth/*` and `/users/me`, which the - * SDK exposes outside the schema. + * Grows as the SPA starts reading TRM-specific collections. The shapes + * here intentionally cover only the fields the SPA actually consumes — + * a thin subset of the full Directus schema, kept minimal so the SDK's + * `readItems` calls compile without dragging in fields we don't use. */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export type Schema = {}; +export type DeviceRow = { + id: string; + imei: string; + model: string | null; + serial_number: string | null; + notes: string | null; + date_created: string | null; + date_updated: string | null; +}; -type DirectusClient = ReturnType; +export type EventRow = { + id: string; + organization_id: string; + name: string; + slug: string; + discipline: 'rally' | 'time-trial' | 'regatta' | 'trail-run' | 'hike'; + starts_at: string; + ends_at: string; + regulation_doc_url: string | null; + notes: string | null; +}; + +/** + * Directus SDK Schema. Each entry is an *array* of row types — that's + * what `RegularCollections` filters on; a non-array value + * collapses to `never` and `readItems('devices', ...)` complains that + * `keyof Schema = never`. + */ +export type Schema = { + devices: DeviceRow[]; + events: EventRow[]; +}; + +/** + * Composed client type. Without the explicit annotation TS sometimes + * loses the `Schema` parameter through the chained `.with(...)` calls, + * which makes `readItems('devices', ...)` think `keyof Schema = never`. + * Spelling out the intersection here makes Schema flow through to every + * `directus.request(...)` call site. + */ +type DirectusClient = SdkDirectusClient & RestClient & AuthenticationClient; /** * Resolve a (possibly relative) URL against the current page origin. @@ -40,7 +85,7 @@ function toAbsoluteUrl(maybeRelative: string): string { return new URL(maybeRelative, window.location.origin).toString(); } -function buildClient(directusUrl: string) { +function buildClient(directusUrl: string): DirectusClient { return createDirectus(toAbsoluteUrl(directusUrl)) .with(rest({ credentials: 'include' })) .with(authentication('session', { credentials: 'include' })); diff --git a/src/auth/index.ts b/src/auth/index.ts index 1c5fa54..9261294 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,6 +1,6 @@ export { AuthBootstrap } from './bootstrap'; export { getDirectus, initDirectusClient, useDirectus } from './client'; -export type { DirectusUser, Schema } from './client'; +export type { DeviceRow, DirectusUser, EventRow, Schema } from './client'; export { startCrossTabSync } from './cross-tab-sync'; export { useRequireAuth, useRequireRole } from './guard'; export { performLogout } from './logout'; diff --git a/src/data/devices.ts b/src/data/devices.ts new file mode 100644 index 0000000..9416460 --- /dev/null +++ b/src/data/devices.ts @@ -0,0 +1,66 @@ +import { readItems, type Query } from '@directus/sdk'; +import { useQuery } from '@tanstack/react-query'; +import { useDirectus, type DeviceRow, type Schema } from '@/auth'; + +export type Device = { + id: string; + imei: string; + model: string | null; + serial_number: string | null; +}; + +/** + * Fetch the list of devices visible to the current user. Directus's + * permission policies determine the result set (Phase 4 will refine + * with org-scoped filtering; for now all authenticated users see all + * devices). + * + * Cached for 5 minutes — devices change daily at most. Returned as a + * `Map` for O(1) lookup by feature builders. + */ +export function useDevicesById(): { + data: Map; + isLoading: boolean; + isError: boolean; +} { + const directus = useDirectus(); + const query = useQuery({ + queryKey: ['devices', 'all'], + queryFn: async (): Promise => { + // Explicit Schema/collection generics — `readItems` doesn't infer + // Schema from the receiver's type at the call site, so without + // them TypeScript thinks `keyof Schema = never`. + const result = await directus.request( + readItems>('devices', { + fields: ['id', 'imei', 'model', 'serial_number'], + limit: -1, + }), + ); + return result as Device[]; + }, + staleTime: 5 * 60 * 1000, + }); + + const data = new Map(); + if (query.data) { + for (const d of query.data) { + data.set(d.id, d); + } + } + + return { + data, + isLoading: query.isLoading, + isError: query.isError, + }; +} + +/** + * Build a human-readable label for a device. Prefers the model + last 4 + * IMEI digits ("FMB920 #3458"), falls back to a deviceId prefix. + */ +export function deviceLabel(device: Device | undefined, deviceId: string): string { + if (!device) return deviceId.slice(0, 8); + const last4 = device.imei.slice(-4); + return device.model ? `${device.model} #${last4}` : `#${last4}`; +} diff --git a/src/map/layers/map-positions.tsx b/src/map/layers/map-positions.tsx new file mode 100644 index 0000000..6103a1b --- /dev/null +++ b/src/map/layers/map-positions.tsx @@ -0,0 +1,306 @@ +import { useEffect, useId } from 'react'; +import type { Feature, FeatureCollection, Point } from 'geojson'; +import type { GeoJSONSource, MapLayerMouseEvent } from 'maplibre-gl'; +import { usePositionStore } from '@/live'; +import { type PositionEntry } from '@/live/protocol'; +import { getMap } from '@/map/core/map-view'; +import { inferColor, mapCategoryToSprite } from '@/map/core/categories'; +import { deviceLabel, useDevicesById, type Device } from '@/data/devices'; + +type FeatureProps = { + deviceId: string; + category: string; + color: 'success' | 'error' | 'neutral' | 'info'; + course: number; + direction: boolean; + title: string; +}; + +const EMPTY_FC: FeatureCollection = { + type: 'FeatureCollection', + features: [], +}; + +/** + * Live device positions on the map. + * + * Two GeoJSON sources: non-selected (clustered) + selected (always on + * top, never clustered). Five layers: symbol + direction arrow per + * source, plus a cluster-bubble layer on the non-selected source. + * + * Side-effect-only: returns `null`, manages MapLibre state via two + * `useEffect`s — one for setup/cleanup, one for `setData` on store changes. + */ +export function MapPositions() { + // Stable ids for sources/layers. useId returns ":r0:"-style strings; + // strip colons so they pass cleanly through MapLibre's id validators + // and any expression that concatenates them. + const baseId = useId().replace(/:/g, ''); + const nonSelectedSourceId = `pos-ns-${baseId}`; + const selectedSourceId = `pos-sel-${baseId}`; + const nonSelectedSymbolId = `pos-ns-symbol-${baseId}`; + const nonSelectedDirectionId = `pos-ns-direction-${baseId}`; + const clusterId = `pos-cluster-${baseId}`; + const selectedSymbolId = `pos-sel-symbol-${baseId}`; + const selectedDirectionId = `pos-sel-direction-${baseId}`; + + const latestByDevice = usePositionStore((s) => s.latestByDevice); + const selectedDeviceId = usePositionStore((s) => s.selectedDeviceId); + const { data: devices } = useDevicesById(); + + // ---- Setup / teardown ------------------------------------------------ + + useEffect(() => { + const map = getMap(); + + // Sources + map.addSource(nonSelectedSourceId, { + type: 'geojson', + data: EMPTY_FC, + cluster: true, + clusterMaxZoom: 14, + clusterRadius: 50, + }); + map.addSource(selectedSourceId, { + type: 'geojson', + data: EMPTY_FC, + }); + + // Non-selected: device symbol + label + map.addLayer({ + id: nonSelectedSymbolId, + source: nonSelectedSourceId, + type: 'symbol', + filter: ['!', ['has', 'point_count']], // exclude clusters + layout: { + 'icon-image': ['concat', ['get', 'category'], '-', ['get', 'color']], + 'icon-size': 1, + 'icon-allow-overlap': true, + 'text-field': ['get', 'title'], + 'text-font': ['Open Sans Regular'], + 'text-size': 11, + 'text-offset': [0, 1.4], + 'text-anchor': 'top', + 'text-allow-overlap': false, + }, + paint: { + 'text-color': '#0E0E0C', + 'text-halo-color': '#FAFAF7', + 'text-halo-width': 1.5, + }, + }); + + // Non-selected: direction arrow + map.addLayer({ + id: nonSelectedDirectionId, + source: nonSelectedSourceId, + type: 'symbol', + filter: ['all', ['!', ['has', 'point_count']], ['get', 'direction']], + layout: { + 'icon-image': ['concat', 'direction-', ['get', 'color']], + 'icon-size': 0.8, + 'icon-rotation-alignment': 'map', + 'icon-rotate': ['coalesce', ['get', 'course'], 0], + 'icon-offset': [0, -20], // place the arrow above the marker + 'icon-allow-overlap': true, + }, + }); + + // Cluster bubbles + map.addLayer({ + id: clusterId, + source: nonSelectedSourceId, + type: 'symbol', + filter: ['has', 'point_count'], + layout: { + 'icon-image': 'default-neutral', + 'icon-size': 1, + 'text-field': ['get', 'point_count_abbreviated'], + 'text-font': ['Open Sans Bold'], + 'text-size': 12, + 'text-allow-overlap': true, + }, + paint: { + 'text-color': '#FAFAF7', + }, + }); + + // Selected: same shape as non-selected but on the always-on-top source + map.addLayer({ + id: selectedSymbolId, + source: selectedSourceId, + type: 'symbol', + layout: { + 'icon-image': ['concat', ['get', 'category'], '-', ['get', 'color']], + 'icon-size': 1.15, // slightly larger so the selection is visible + 'icon-allow-overlap': true, + 'text-field': ['get', 'title'], + 'text-font': ['Open Sans Bold'], + 'text-size': 12, + 'text-offset': [0, 1.5], + 'text-anchor': 'top', + 'text-allow-overlap': true, + }, + paint: { + 'text-color': '#0E0E0C', + 'text-halo-color': '#FAFAF7', + 'text-halo-width': 2, + }, + }); + + map.addLayer({ + id: selectedDirectionId, + source: selectedSourceId, + type: 'symbol', + filter: ['get', 'direction'], + layout: { + 'icon-image': ['concat', 'direction-', ['get', 'color']], + 'icon-size': 0.9, + 'icon-rotation-alignment': 'map', + 'icon-rotate': ['coalesce', ['get', 'course'], 0], + 'icon-offset': [0, -22], + 'icon-allow-overlap': true, + }, + }); + + // Click handlers ---------------------------------------------------- + + const onMarkerClick = (ev: MapLayerMouseEvent): void => { + const feature = ev.features?.[0]; + if (!feature) return; + const props = feature.properties as FeatureProps | null; + if (props?.deviceId) { + usePositionStore.getState().selectDevice(props.deviceId); + } + }; + + const onClusterClick = (ev: MapLayerMouseEvent): void => { + const feature = ev.features?.[0]; + if (!feature) return; + const cId = feature.properties?.cluster_id as number | undefined; + if (cId == null) return; + const source = map.getSource(nonSelectedSourceId) as GeoJSONSource | undefined; + if (!source) return; + void source.getClusterExpansionZoom(cId).then((zoom) => { + const point = (feature.geometry as Point).coordinates as [number, number]; + map.easeTo({ + center: point, + zoom: zoom ?? map.getZoom() + 1, + duration: 400, + }); + }); + }; + + const setPointer = (): void => { + map.getCanvas().style.cursor = 'pointer'; + }; + const clearPointer = (): void => { + map.getCanvas().style.cursor = ''; + }; + + map.on('click', nonSelectedSymbolId, onMarkerClick); + map.on('click', selectedSymbolId, onMarkerClick); + map.on('click', clusterId, onClusterClick); + + for (const id of [nonSelectedSymbolId, selectedSymbolId, clusterId]) { + map.on('mouseenter', id, setPointer); + map.on('mouseleave', id, clearPointer); + } + + return () => { + map.off('click', nonSelectedSymbolId, onMarkerClick); + map.off('click', selectedSymbolId, onMarkerClick); + map.off('click', clusterId, onClusterClick); + for (const id of [nonSelectedSymbolId, selectedSymbolId, clusterId]) { + map.off('mouseenter', id, setPointer); + map.off('mouseleave', id, clearPointer); + } + + // Tear down layers + sources in reverse order. + for (const layerId of [ + selectedDirectionId, + selectedSymbolId, + clusterId, + nonSelectedDirectionId, + nonSelectedSymbolId, + ]) { + if (map.getLayer(layerId)) map.removeLayer(layerId); + } + if (map.getSource(selectedSourceId)) map.removeSource(selectedSourceId); + if (map.getSource(nonSelectedSourceId)) map.removeSource(nonSelectedSourceId); + }; + // Setup runs once per mount; the mapReady gate (2.1) ensures the map + // is ready when this fires. Listing dependencies would re-run setup + // on unrelated state changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ---- Updates --------------------------------------------------------- + + useEffect(() => { + const map = getMap(); + const { nonSelected, selected } = buildFeatureCollections({ + latestByDevice, + selectedDeviceId, + devices, + }); + const ns = map.getSource(nonSelectedSourceId) as GeoJSONSource | undefined; + const sel = map.getSource(selectedSourceId) as GeoJSONSource | undefined; + ns?.setData(nonSelected); + sel?.setData(selected); + }, [latestByDevice, selectedDeviceId, devices, nonSelectedSourceId, selectedSourceId]); + + return null; +} + +// ---- Feature builder ---------------------------------------------------- + +function buildFeatureCollections(opts: { + latestByDevice: Map; + selectedDeviceId: string | null; + devices: Map; +}): { + nonSelected: FeatureCollection; + selected: FeatureCollection; +} { + const nonSelectedFeatures: Feature[] = []; + const selectedFeatures: Feature[] = []; + + for (const [deviceId, position] of opts.latestByDevice) { + const isSelected = deviceId === opts.selectedDeviceId; + const device = opts.devices.get(deviceId); + const feat = buildPositionFeature(position, device, isSelected); + if (isSelected) selectedFeatures.push(feat); + else nonSelectedFeatures.push(feat); + } + + return { + nonSelected: { type: 'FeatureCollection', features: nonSelectedFeatures }, + selected: { type: 'FeatureCollection', features: selectedFeatures }, + }; +} + +function buildPositionFeature( + p: PositionEntry, + device: Device | undefined, + isSelected: boolean, +): Feature { + // Phase 1 schema doesn't carry `kind` on devices; map all to 'default' + // for now. When the schema gains a kind/category column (Phase 2 of + // directus or earlier), thread it through. + const category = mapCategoryToSprite(undefined); + const color = inferColor({ speed: p.speed ?? null, isSelected }); + return { + type: 'Feature', + geometry: { type: 'Point', coordinates: [p.lon, p.lat] }, + properties: { + deviceId: p.deviceId, + category, + color, + course: p.course ?? 0, + // Show the direction arrow only when the device is moving (>1 m/s). + direction: p.course != null && (p.speed ?? 0) > 1, + title: deviceLabel(device, p.deviceId), + }, + }; +} diff --git a/src/routes/_authed/monitor.tsx b/src/routes/_authed/monitor.tsx index 2a627d6..2d9f4f0 100644 --- a/src/routes/_authed/monitor.tsx +++ b/src/routes/_authed/monitor.tsx @@ -1,6 +1,7 @@ import { createFileRoute } from '@tanstack/react-router'; import { BasemapSwitcher } from '@/map/core/basemap-switcher'; import { MapView } from '@/map/core/map-view'; +import { MapPositions } from '@/map/layers/map-positions'; export const Route = createFileRoute('/_authed/monitor')({ component: MonitorPage, @@ -22,6 +23,7 @@ function MonitorPage() { to 's wrapper. */} + );