From 458d8e49c568ae5c5ea81553d425ddda40b07e39 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 30 Jul 2026 17:19:44 -0700 Subject: [PATCH 1/2] Add config-driven sensor alert dashboard Introduces a unified dashboard for telemetered sensor sources, starting with Van Essen Diver and Wellntel. It serves the two operator questions: is every logger healthy, and what new data is waiting to be ingested. Adding a source is a configuration change only. A source is declared in src/config/sensor-sources/sources/ via defineSensorSource(), which specifies its transport, vendor field mapping, displayed metrics, alert rules, and ingestion target. The alert engine, device grids, and mock provider are all driven off that registry and contain no per-source branching. Alert rules are declarative (stale / threshold / range / gap) so new failure modes are expressed as config rather than code. The config is Zod-validated at import time, and additionally rejects rules pointing at undeclared metrics, duplicate rule ids, and critical bounds that are less severe than their warning bound -- each of which would otherwise produce a rule that silently never fires. The OcotilloAPI sensor-source endpoints do not exist yet. The expected contract is documented in src/interfaces/sensor-dashboard, and VITE_SENSOR_MOCK=true serves fixtures generated from the same configs so the dashboard is fully exercisable until the backend lands. Vendor field paths in both source configs are modelled, not verified against live API responses; they are marked TODO(vendor-docs) and are correctable in one file per source. Viewing is open to AMP viewers; triggering an ingestion run writes to the observation tables and is gated on editor/admin at both the route and the button. --- .env.development.example | 4 + .env.devserver.example | 4 + .env.production.example | 5 + src/components/SensorDashboard/AlertList.tsx | 101 ++++ .../SensorDashboard/AlertSummaryTiles.tsx | 68 +++ src/components/SensorDashboard/DeviceGrid.tsx | 167 ++++++ .../SensorDashboard/PendingIngestionPanel.tsx | 258 +++++++++ .../SensorDashboard/SourceStatusCard.tsx | 105 ++++ .../SensorDashboard/sensorAlerts.test.ts | 538 ++++++++++++++++++ .../SensorDashboard/sensorAlerts.ts | 274 +++++++++ src/components/SensorDashboard/severity.ts | 28 + src/config/navigation.ts | 10 +- src/config/sensor-sources/index.ts | 29 + src/config/sensor-sources/schema.ts | 252 ++++++++ .../sensor-sources/sources/van-essen-diver.ts | 136 +++++ src/config/sensor-sources/sources/wellntel.ts | 138 +++++ src/hooks/useSensorSources.ts | 163 ++++++ src/interfaces/sensor-dashboard/index.ts | 127 +++++ src/pages/ocotillo/sensor-dashboard/index.tsx | 132 +++++ src/providers/sensor-source-mock.ts | 306 ++++++++++ src/providers/sensor-source-provider.ts | 88 +++ src/resources/ocotillo.tsx | 12 +- src/routes/ocotillo.tsx | 111 ++-- src/settings.tsx | 9 + src/test/utils/accessControl.test.ts | 6 +- src/utils/accessControl.ts | 8 + 26 files changed, 3024 insertions(+), 55 deletions(-) create mode 100644 src/components/SensorDashboard/AlertList.tsx create mode 100644 src/components/SensorDashboard/AlertSummaryTiles.tsx create mode 100644 src/components/SensorDashboard/DeviceGrid.tsx create mode 100644 src/components/SensorDashboard/PendingIngestionPanel.tsx create mode 100644 src/components/SensorDashboard/SourceStatusCard.tsx create mode 100644 src/components/SensorDashboard/sensorAlerts.test.ts create mode 100644 src/components/SensorDashboard/sensorAlerts.ts create mode 100644 src/components/SensorDashboard/severity.ts create mode 100644 src/config/sensor-sources/index.ts create mode 100644 src/config/sensor-sources/schema.ts create mode 100644 src/config/sensor-sources/sources/van-essen-diver.ts create mode 100644 src/config/sensor-sources/sources/wellntel.ts create mode 100644 src/hooks/useSensorSources.ts create mode 100644 src/interfaces/sensor-dashboard/index.ts create mode 100644 src/pages/ocotillo/sensor-dashboard/index.tsx create mode 100644 src/providers/sensor-source-mock.ts create mode 100644 src/providers/sensor-source-provider.ts diff --git a/.env.development.example b/.env.development.example index dd40bd54..93b9fb8d 100644 --- a/.env.development.example +++ b/.env.development.example @@ -12,3 +12,7 @@ VITE_AUTHENTIK_CLIENT_ID= VITE_AUTHENTIK_URL= VITE_AUTHENTIK_REDIRECT_URI= VITE_TEST_AUTH=true + +# Serve the sensor dashboard from generated fixtures instead of the +# OcotilloAPI sensor-source endpoints (which are not implemented yet). +VITE_SENSOR_MOCK=true diff --git a/.env.devserver.example b/.env.devserver.example index a3e3abbb..0e80f70e 100644 --- a/.env.devserver.example +++ b/.env.devserver.example @@ -14,3 +14,7 @@ VITE_APP_TIMEZONE=America/Denver VITE_AUTHENTIK_CLIENT_ID= VITE_AUTHENTIK_URL= VITE_AUTHENTIK_REDIRECT_URI= + +# Serve the sensor dashboard from generated fixtures instead of the +# OcotilloAPI sensor-source endpoints (which are not implemented yet). +VITE_SENSOR_MOCK=true diff --git a/.env.production.example b/.env.production.example index 361965e0..5c128eef 100644 --- a/.env.production.example +++ b/.env.production.example @@ -16,3 +16,8 @@ VITE_APP_TIMEZONE=America/Denver VITE_AUTHENTIK_CLIENT_ID= VITE_AUTHENTIK_URL= VITE_AUTHENTIK_REDIRECT_URI= + +# Serve the sensor dashboard from generated fixtures instead of the +# OcotilloAPI sensor-source endpoints (which are not implemented yet). +# Must stay false in production. +VITE_SENSOR_MOCK=false diff --git a/src/components/SensorDashboard/AlertList.tsx b/src/components/SensorDashboard/AlertList.tsx new file mode 100644 index 00000000..f4636f4d --- /dev/null +++ b/src/components/SensorDashboard/AlertList.tsx @@ -0,0 +1,101 @@ +import { + Alert, + Card, + CardContent, + Chip, + Divider, + List, + ListItem, + ListItemText, + Stack, + Typography, +} from '@mui/material' +import { getSensorSource } from '@/config/sensor-sources' +import type { SensorAlert } from '@/interfaces/sensor-dashboard' +import { SEVERITY_COLOR, SEVERITY_LABEL } from './severity' + +type Props = { + alerts: SensorAlert[] + /** Cap the list; the grids below carry the full detail. */ + limit?: number + /** + * Cap per device. One badly broken logger trips most of its source's rules + * at once, which would otherwise fill the whole list and hide every other + * failing device. + */ + perDeviceLimit?: number +} + +/** Worst-first list of everything currently firing, across all sources. */ +export const AlertList = ({ + alerts, + limit = 12, + perDeviceLimit = 2, +}: Props) => { + if (alerts.length === 0) { + return ( + + No active alerts -- every configured sensor is reporting within its + thresholds. + + ) + } + + // `alerts` arrives worst-first, so taking the first N per device keeps each + // device's most severe findings. + const perDevice = new Map() + const shown = alerts + .filter((alert) => { + const key = `${alert.sourceId}:${alert.deviceId}` + const seen = perDevice.get(key) ?? 0 + if (seen >= perDeviceLimit) return false + perDevice.set(key, seen + 1) + return true + }) + .slice(0, limit) + + return ( + + + Active alerts + + + {shown.map((alert, index) => ( + + + {index > 0 && } + + + {alert.deviceLabel} + {/* Device labels are only unique within a source -- two + vendors can both have a "MG-007", so name the source. */} + + {getSensorSource(alert.sourceId)?.label ?? alert.sourceId} + + + + + + ))} + + {alerts.length > shown.length && ( + + + +{alerts.length - shown.length} more -- see the device tables below. + + + )} + + ) +} diff --git a/src/components/SensorDashboard/AlertSummaryTiles.tsx b/src/components/SensorDashboard/AlertSummaryTiles.tsx new file mode 100644 index 00000000..3eaf938d --- /dev/null +++ b/src/components/SensorDashboard/AlertSummaryTiles.tsx @@ -0,0 +1,68 @@ +import { Box, Card, CardActionArea, Typography } from '@mui/material' +import type { AlertSeverity } from '@/config/sensor-sources' +import type { AlertSummary } from './sensorAlerts' +import { + SEVERITY_COLOR, + SEVERITY_DISPLAY_ORDER, + SEVERITY_LABEL, +} from './severity' + +type Props = { + summary: AlertSummary + /** Currently applied severity filter, or null for "no filter". */ + selected: AlertSeverity | null + onSelect: (severity: AlertSeverity | null) => void +} + +const CAPTION: Record = { + critical: 'Need attention now', + warning: 'Degrading', + ok: 'Reporting normally', +} + +/** + * Device counts bucketed by worst severity. Clicking a tile filters the + * device grids below; clicking the active tile clears the filter. + */ +export const AlertSummaryTiles = ({ summary, selected, onSelect }: Props) => ( + + {SEVERITY_DISPLAY_ORDER.map((severity) => { + const color = SEVERITY_COLOR[severity] + const isSelected = selected === severity + return ( + + onSelect(isSelected ? null : severity)} + sx={{ p: 2 }} + > + + {SEVERITY_LABEL[severity]} + + + {summary[severity]} + + + {CAPTION[severity]} + + + + ) + })} + +) diff --git a/src/components/SensorDashboard/DeviceGrid.tsx b/src/components/SensorDashboard/DeviceGrid.tsx new file mode 100644 index 00000000..b77f5fae --- /dev/null +++ b/src/components/SensorDashboard/DeviceGrid.tsx @@ -0,0 +1,167 @@ +import { Chip, Stack, Tooltip, Typography } from '@mui/material' +import type { GridColDef } from '@mui/x-data-grid' +import { DataGrid } from '@mui/x-data-grid' +import { useMemo } from 'react' +import type { AlertSeverity, SensorSourceConfig } from '@/config/sensor-sources' +import { SEVERITY_ORDER } from '@/config/sensor-sources' +import type { SensorAlert, SensorDevice } from '@/interfaces/sensor-dashboard' +import { formatAppDateTime } from '@/utils/Date' +import { formatAge, minutesSince, worstSeverity } from './sensorAlerts' +import { SEVERITY_COLOR, SEVERITY_LABEL } from './severity' + +type Props = { + source: SensorSourceConfig + devices: SensorDevice[] + alerts: SensorAlert[] + isLoading: boolean + /** Show only devices at this severity. Null shows everything. */ + severityFilter: AlertSeverity | null +} + +type Row = SensorDevice & { + id: string + severity: AlertSeverity + deviceAlerts: SensorAlert[] +} + +const relativeCell = (value: string | null | undefined) => { + if (!value) return -- + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) { + return -- + } + return ( + + + {formatAge(minutesSince(parsed, new Date()))} ago + + + ) +} + +/** + * Device table for a single source. Metric columns come from the source + * config, so a new source renders its own columns with no change here. + */ +export const DeviceGrid = ({ + source, + devices, + alerts, + isLoading, + severityFilter, +}: Props) => { + const rows = useMemo(() => { + const byDevice = new Map() + for (const alert of alerts) { + const existing = byDevice.get(alert.deviceId) + if (existing) existing.push(alert) + else byDevice.set(alert.deviceId, [alert]) + } + + return devices + .map((device) => { + const deviceAlerts = byDevice.get(device.deviceId) ?? [] + return { + ...device, + id: device.deviceId, + severity: worstSeverity(deviceAlerts), + deviceAlerts, + } + }) + .filter((row) => !severityFilter || row.severity === severityFilter) + }, [devices, alerts, severityFilter]) + + const columns = useMemo[]>(() => { + const base: GridColDef[] = [ + { + field: 'severity', + headerName: 'Status', + width: 130, + // Severity is a string, so the default comparator would sort it + // alphabetically (critical < ok < warning). Rank it instead. + sortComparator: (a: AlertSeverity, b: AlertSeverity) => + SEVERITY_ORDER.indexOf(a) - SEVERITY_ORDER.indexOf(b), + renderCell: ({ row }) => ( + `${a.label}: ${a.detail}`) + .join('\n') + : 'No alerts' + } + > + + + ), + }, + { field: 'label', headerName: 'Device', flex: 1, minWidth: 180 }, + { field: 'pointId', headerName: 'PointID', width: 120 }, + { field: 'serialNumber', headerName: 'Serial', width: 110 }, + { + field: 'lastCommunicationAt', + headerName: 'Last contact', + width: 130, + renderCell: ({ row }) => relativeCell(row.lastCommunicationAt), + }, + { + field: 'lastObservationAt', + headerName: 'Last reading', + width: 130, + renderCell: ({ row }) => relativeCell(row.lastObservationAt), + }, + ] + + const metricColumns: GridColDef[] = source.metrics + .filter((metric) => !metric.hidden) + .map((metric) => ({ + field: `metric:${metric.key}`, + headerName: metric.unit + ? `${metric.label} (${metric.unit})` + : metric.label, + width: 130, + type: 'number' as const, + // Metrics live in a nested record, so the grid needs an explicit + // accessor to sort and filter on them. + valueGetter: (_value, row: Row) => row.metrics[metric.key] ?? null, + renderCell: ({ row }) => { + const value = row.metrics[metric.key] + return typeof value === 'number' && Number.isFinite(value) + ? value.toFixed(metric.precision) + : '--' + }, + })) + + return [...base, ...metricColumns] + }, [source.metrics]) + + return ( + + + + ) +} diff --git a/src/components/SensorDashboard/PendingIngestionPanel.tsx b/src/components/SensorDashboard/PendingIngestionPanel.tsx new file mode 100644 index 00000000..8073e81b --- /dev/null +++ b/src/components/SensorDashboard/PendingIngestionPanel.tsx @@ -0,0 +1,258 @@ +import CloudDownloadIcon from '@mui/icons-material/CloudDownload' +import { + Alert, + Box, + Button, + Card, + CardContent, + LinearProgress, + Stack, + Tooltip, + Typography, +} from '@mui/material' +import type { GridColDef, GridRowSelectionModel } from '@mui/x-data-grid' +import { DataGrid } from '@mui/x-data-grid' +import { useCan } from '@refinedev/core' +import { useEffect, useMemo, useState } from 'react' +import type { SensorSourceView } from '@/hooks/useSensorSources' +import type { + IngestRun, + IngestRunStatus, + PendingBatch, +} from '@/interfaces/sensor-dashboard' +import { sensorSourceClient } from '@/providers/sensor-source-provider' +import { formatAppDateTime } from '@/utils/Date' +import { getErrorMessage } from '@/utils/getErrorMessage' + +type Props = { + source: SensorSourceView + onIngested: () => void +} + +type Row = PendingBatch & { id: string } + +const EMPTY_SELECTION: GridRowSelectionModel = { + type: 'include', + ids: new Set(), +} + +const TERMINAL_STATUSES: IngestRunStatus[] = ['succeeded', 'failed'] + +const RUN_POLL_INTERVAL_MS = 1500 + +const RUN_ALERT_SEVERITY: Record< + IngestRunStatus, + 'info' | 'success' | 'error' +> = { + queued: 'info', + running: 'info', + succeeded: 'success', + failed: 'error', +} + +/** + * Data waiting in the vendor cloud that Ocotillo has not ingested yet, plus + * the operator action to pull it in. + */ +export const PendingIngestionPanel = ({ source, onIngested }: Props) => { + const [selection, setSelection] = + useState(EMPTY_SELECTION) + const [run, setRun] = useState(null) + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + // Viewing pending data is read-only, but triggering a run writes to the + // observation tables -- so the button needs its own check, not just the + // route guard. + const { data: ingestPermission } = useCan({ + resource: 'ocotillo.sensor-dashboard', + action: 'create', + }) + const canIngest = ingestPermission?.can ?? false + + // Poll the run until it settles so the operator sees the outcome, not just + // an acknowledgement that the request was accepted. Refresh the pending + // list once it succeeds -- the ingested batches should drop off. + const runId = run?.runId + const isRunSettled = run ? TERMINAL_STATUSES.includes(run.status) : true + useEffect(() => { + if (!runId || isRunSettled) return + + let cancelled = false + const timer = setInterval(async () => { + try { + const latest = await sensorSourceClient.getIngestRun( + source.config, + runId + ) + if (cancelled) return + setRun(latest) + if (latest.status === 'succeeded') onIngested() + } catch (err) { + if (!cancelled) setError(getErrorMessage(err)) + } + }, RUN_POLL_INTERVAL_MS) + + return () => { + cancelled = true + clearInterval(timer) + } + }, [runId, isRunSettled, source.config, onIngested]) + + const rows = useMemo( + () => source.pending.map((batch) => ({ ...batch, id: batch.batchId })), + [source.pending] + ) + + // MUI X v8 models selection as include/exclude sets, so an "exclude" model + // means everything not listed rather than the listed rows. + const selectedIds = useMemo(() => { + if (selection.type === 'include') { + return rows + .filter((row) => selection.ids.has(row.id)) + .map((row) => row.id) + } + return rows.filter((row) => !selection.ids.has(row.id)).map((row) => row.id) + }, [selection, rows]) + + const columns = useMemo[]>( + () => [ + { field: 'deviceLabel', headerName: 'Device', flex: 1, minWidth: 180 }, + { field: 'pointId', headerName: 'PointID', width: 120 }, + { + field: 'startDatetime', + headerName: 'From', + width: 180, + renderCell: ({ row }) => formatAppDateTime(row.startDatetime), + }, + { + field: 'endDatetime', + headerName: 'To', + width: 180, + renderCell: ({ row }) => formatAppDateTime(row.endDatetime), + }, + { + field: 'recordCount', + headerName: 'Records', + width: 100, + type: 'number', + }, + { field: 'parameter', headerName: 'Parameter', width: 180 }, + ], + [] + ) + + const totalRecords = rows + .filter((row) => selectedIds.includes(row.id)) + .reduce((sum, row) => sum + row.recordCount, 0) + + const handleIngest = async () => { + setIsSubmitting(true) + setError(null) + try { + const started = await sensorSourceClient.triggerIngest( + source.config, + selectedIds + ) + setRun(started) + setSelection(EMPTY_SELECTION) + } catch (err) { + setError(getErrorMessage(err)) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + + {source.config.label} + + Lands in {source.config.ingestion.targetResource} + + + + + + + + + + + {error && ( + setError(null)}> + {error} + + )} + + {run && ( + setRun(null) : undefined} + > + + Run {run.runId} -- {run.status} -- covering{' '} + {run.batchIds.length} batch + {run.batchIds.length === 1 ? '' : 'es'} + {typeof run.recordsIngested === 'number' && + `, ${run.recordsIngested} records ingested`} + {run.message ? `. ${run.message}` : '.'} + + {!isRunSettled && } + + )} + + {rows.length === 0 ? ( + + Nothing pending -- Ocotillo is caught up with{' '} + {source.config.vendor.name}. + + ) : ( + + )} + + + ) +} diff --git a/src/components/SensorDashboard/SourceStatusCard.tsx b/src/components/SensorDashboard/SourceStatusCard.tsx new file mode 100644 index 00000000..52dc66bb --- /dev/null +++ b/src/components/SensorDashboard/SourceStatusCard.tsx @@ -0,0 +1,105 @@ +import OpenInNewIcon from '@mui/icons-material/OpenInNew' +import { + Alert, + Box, + Card, + CardContent, + Chip, + Link, + Stack, + Typography, +} from '@mui/material' +import type { SensorSourceView } from '@/hooks/useSensorSources' +import { formatAppDateTime } from '@/utils/Date' +import { + SEVERITY_COLOR, + SEVERITY_DISPLAY_ORDER, + SEVERITY_LABEL, +} from './severity' + +type Props = { + source: SensorSourceView +} + +/** Per-vendor health header: is the integration itself up, and its fleet mix. */ +export const SourceStatusCard = ({ source }: Props) => { + const { config, status, summary, pending } = source + + return ( + + + + + {config.label} + + {config.vendor.name} + {config.vendor.consoleUrl && ( + <> + {' -- '} + + vendor console + + + + )} + + + + + {SEVERITY_DISPLAY_ORDER.map((severity) => ( + + ))} + + + + + {source.error ? ( + + Could not reach {config.vendor.name}: {source.error.message} + + ) : ( + + {status.lastPolledAt + ? `Last polled ${formatAppDateTime(status.lastPolledAt)}` + : 'Not yet polled'} + {' -- '} + {status.deviceCount} device{status.deviceCount === 1 ? '' : 's'} + + )} + + + ) +} diff --git a/src/components/SensorDashboard/sensorAlerts.test.ts b/src/components/SensorDashboard/sensorAlerts.test.ts new file mode 100644 index 00000000..a842807c --- /dev/null +++ b/src/components/SensorDashboard/sensorAlerts.test.ts @@ -0,0 +1,538 @@ +import { describe, expect, it } from 'vitest' +import type { SensorSourceConfigInput } from '@/config/sensor-sources' +import { defineSensorSource } from '@/config/sensor-sources' +import type { SensorDevice } from '@/interfaces/sensor-dashboard' +import { + evaluateDevice, + formatAge, + summarizeDevices, + worstSeverity, +} from './sensorAlerts' + +const NOW = new Date('2026-07-30T12:00:00.000Z') + +const minutesAgo = (minutes: number) => + new Date(NOW.getTime() - minutes * 60_000).toISOString() + +const source = (overrides: Partial = {}) => + defineSensorSource({ + id: 'test-source', + label: 'Test Source', + transport: { kind: 'ocotillo-proxy', basePath: 'sensor-source/test' }, + vendor: { name: 'Test Vendor', fieldMap: {} }, + metrics: [ + { key: 'batteryPercent', label: 'Battery', unit: '%', precision: 0 }, + { key: 'depthFeet', label: 'Depth', unit: 'ft', precision: 2 }, + ], + alertRules: [], + ingestion: { + targetResource: 'observation/transducer-groundwater-level', + parameter: 'groundwater-level', + defaultIntervalMinutes: 60, + }, + ...overrides, + }) + +const device = (overrides: Partial = {}): SensorDevice => ({ + sourceId: 'test-source', + deviceId: 'dev-1', + label: 'Device 1', + lastCommunicationAt: minutesAgo(5), + lastObservationAt: minutesAgo(5), + metrics: {}, + ...overrides, +}) + +describe('formatAge', () => { + it('scales units by magnitude', () => { + expect(formatAge(45)).toBe('45 min') + expect(formatAge(60 * 3.2)).toBe('3.2 hr') + expect(formatAge(60 * 24 * 6.1)).toBe('6.1 days') + }) +}) + +describe('stale rules', () => { + const staleSource = source({ + alertRules: [ + { + id: 'offline', + kind: 'stale', + label: 'Offline', + field: 'lastCommunicationAt', + warnAfterMinutes: 60, + criticalAfterMinutes: 240, + }, + ], + }) + + it('stays silent inside the warning window', () => { + const alerts = evaluateDevice( + device({ lastCommunicationAt: minutesAgo(59) }), + staleSource, + NOW + ) + expect(alerts).toEqual([]) + }) + + it('warns at exactly the warning threshold', () => { + const alerts = evaluateDevice( + device({ lastCommunicationAt: minutesAgo(60) }), + staleSource, + NOW + ) + expect(alerts).toHaveLength(1) + expect(alerts[0].severity).toBe('warning') + expect(alerts[0].ruleId).toBe('offline') + }) + + it('escalates at exactly the critical threshold', () => { + const alerts = evaluateDevice( + device({ lastCommunicationAt: minutesAgo(240) }), + staleSource, + NOW + ) + expect(alerts[0].severity).toBe('critical') + }) + + it('treats a never-reporting device as critical', () => { + const alerts = evaluateDevice( + device({ lastCommunicationAt: null }), + staleSource, + NOW + ) + expect(alerts[0].severity).toBe('critical') + expect(alerts[0].detail).toBe('never reported') + }) + + it('treats an unparseable timestamp as never reported', () => { + const alerts = evaluateDevice( + device({ lastCommunicationAt: 'not-a-date' }), + staleSource, + NOW + ) + expect(alerts[0].severity).toBe('critical') + }) + + it('does not fire on a future timestamp', () => { + const alerts = evaluateDevice( + device({ lastCommunicationAt: minutesAgo(-90) }), + staleSource, + NOW + ) + expect(alerts).toEqual([]) + }) + + it('evaluates each timestamp field independently', () => { + const twoField = source({ + alertRules: [ + { + id: 'offline', + kind: 'stale', + label: 'Offline', + field: 'lastCommunicationAt', + warnAfterMinutes: 60, + criticalAfterMinutes: 240, + }, + { + id: 'no-data', + kind: 'stale', + label: 'No data', + field: 'lastObservationAt', + warnAfterMinutes: 60, + criticalAfterMinutes: 240, + }, + ], + }) + // Checking in but not recording: the case a single rule would miss. + const alerts = evaluateDevice( + device({ + lastCommunicationAt: minutesAgo(5), + lastObservationAt: minutesAgo(300), + }), + twoField, + NOW + ) + expect(alerts).toHaveLength(1) + expect(alerts[0].ruleId).toBe('no-data') + expect(alerts[0].severity).toBe('critical') + }) +}) + +describe('threshold rules', () => { + const below = source({ + alertRules: [ + { + id: 'battery', + kind: 'threshold', + label: 'Battery low', + metric: 'batteryPercent', + direction: 'below', + warnAt: 25, + criticalAt: 10, + }, + ], + }) + + it('is silent above the warning bound', () => { + const alerts = evaluateDevice( + device({ metrics: { batteryPercent: 26 } }), + below, + NOW + ) + expect(alerts).toEqual([]) + }) + + it('warns at the bound and reports the value with its unit', () => { + const alerts = evaluateDevice( + device({ metrics: { batteryPercent: 25 } }), + below, + NOW + ) + expect(alerts[0].severity).toBe('warning') + expect(alerts[0].detail).toBe('25% (warn below 25%)') + expect(alerts[0].metricKey).toBe('batteryPercent') + expect(alerts[0].value).toBe(25) + }) + + it('escalates at the critical bound', () => { + const alerts = evaluateDevice( + device({ metrics: { batteryPercent: 10 } }), + below, + NOW + ) + expect(alerts[0].severity).toBe('critical') + }) + + it('supports the above direction', () => { + const above = source({ + metrics: [{ key: 'memoryPercent', label: 'Memory', unit: '%' }], + alertRules: [ + { + id: 'memory', + kind: 'threshold', + label: 'Memory filling', + metric: 'memoryPercent', + direction: 'above', + warnAt: 80, + criticalAt: 95, + }, + ], + }) + expect( + evaluateDevice(device({ metrics: { memoryPercent: 79 } }), above, NOW) + ).toEqual([]) + expect( + evaluateDevice(device({ metrics: { memoryPercent: 96 } }), above, NOW)[0] + .severity + ).toBe('critical') + }) + + it('skips a metric the vendor did not report', () => { + expect(evaluateDevice(device({ metrics: {} }), below, NOW)).toEqual([]) + expect( + evaluateDevice(device({ metrics: { batteryPercent: null } }), below, NOW) + ).toEqual([]) + }) + + it('skips non-finite values rather than treating NaN as low', () => { + expect( + evaluateDevice( + device({ metrics: { batteryPercent: Number.NaN } }), + below, + NOW + ) + ).toEqual([]) + }) +}) + +describe('range rules', () => { + const ranged = source({ + alertRules: [ + { + id: 'depth', + kind: 'range', + label: 'Depth implausible', + metric: 'depthFeet', + min: 0, + max: 1000, + criticalMin: -5, + criticalMax: 2000, + }, + ], + }) + + it('is silent inside the range', () => { + expect( + evaluateDevice(device({ metrics: { depthFeet: 500 } }), ranged, NOW) + ).toEqual([]) + }) + + it('warns just outside the range', () => { + expect( + evaluateDevice(device({ metrics: { depthFeet: 1001 } }), ranged, NOW)[0] + .severity + ).toBe('warning') + expect( + evaluateDevice(device({ metrics: { depthFeet: -1 } }), ranged, NOW)[0] + .severity + ).toBe('warning') + }) + + it('escalates past the critical bounds', () => { + expect( + evaluateDevice(device({ metrics: { depthFeet: 2001 } }), ranged, NOW)[0] + .severity + ).toBe('critical') + expect( + evaluateDevice(device({ metrics: { depthFeet: -6 } }), ranged, NOW)[0] + .severity + ).toBe('critical') + }) + + it('warns without critical bounds configured', () => { + const noCritical = source({ + alertRules: [ + { + id: 'depth', + kind: 'range', + label: 'Depth implausible', + metric: 'depthFeet', + min: 0, + max: 1000, + }, + ], + }) + const alerts = evaluateDevice( + device({ metrics: { depthFeet: 99999 } }), + noCritical, + NOW + ) + expect(alerts[0].severity).toBe('warning') + }) +}) + +describe('gap rules', () => { + const gapped = source({ + alertRules: [ + { + id: 'gap', + kind: 'gap', + label: 'Missing records', + warnMissedIntervals: 4, + criticalMissedIntervals: 12, + }, + ], + }) + + it('is silent when every expected record arrived', () => { + expect( + evaluateDevice( + device({ expectedSampleCount: 24, observedSampleCount: 24 }), + gapped, + NOW + ) + ).toEqual([]) + }) + + it('warns and escalates on missed counts', () => { + expect( + evaluateDevice( + device({ expectedSampleCount: 24, observedSampleCount: 20 }), + gapped, + NOW + )[0].severity + ).toBe('warning') + expect( + evaluateDevice( + device({ expectedSampleCount: 24, observedSampleCount: 12 }), + gapped, + NOW + )[0].severity + ).toBe('critical') + }) + + it('reports the shortfall in the detail', () => { + const alerts = evaluateDevice( + device({ expectedSampleCount: 24, observedSampleCount: 18 }), + gapped, + NOW + ) + expect(alerts[0].detail).toBe('6 of 24 records missing') + }) + + it('skips when accounting is incomplete', () => { + expect( + evaluateDevice(device({ expectedSampleCount: 24 }), gapped, NOW) + ).toEqual([]) + expect( + evaluateDevice(device({ observedSampleCount: 24 }), gapped, NOW) + ).toEqual([]) + expect( + evaluateDevice( + device({ expectedSampleCount: 0, observedSampleCount: 0 }), + gapped, + NOW + ) + ).toEqual([]) + }) + + it('does not fire when more records arrived than expected', () => { + expect( + evaluateDevice( + device({ expectedSampleCount: 24, observedSampleCount: 30 }), + gapped, + NOW + ) + ).toEqual([]) + }) +}) + +describe('worstSeverity', () => { + const alert = (severity: 'warning' | 'critical') => ({ + ruleId: 'r', + sourceId: 's', + deviceId: 'd', + deviceLabel: 'D', + severity, + label: 'L', + detail: '', + }) + + it('is ok with no alerts', () => { + expect(worstSeverity([])).toBe('ok') + }) + + it('picks critical over warning regardless of order', () => { + expect(worstSeverity([alert('warning'), alert('critical')])).toBe( + 'critical' + ) + expect(worstSeverity([alert('critical'), alert('warning')])).toBe( + 'critical' + ) + }) +}) + +describe('summarizeDevices', () => { + it('buckets each device by its worst alert and sums to the total', () => { + const devices = [ + device({ deviceId: 'a' }), + device({ deviceId: 'b' }), + device({ deviceId: 'c' }), + ] + const alerts = [ + { + ruleId: 'r1', + sourceId: 's', + deviceId: 'b', + deviceLabel: 'B', + severity: 'warning' as const, + label: 'L', + detail: '', + }, + { + ruleId: 'r2', + sourceId: 's', + deviceId: 'c', + deviceLabel: 'C', + severity: 'warning' as const, + label: 'L', + detail: '', + }, + { + ruleId: 'r3', + sourceId: 's', + deviceId: 'c', + deviceLabel: 'C', + severity: 'critical' as const, + label: 'L', + detail: '', + }, + ] + + const summary = summarizeDevices(devices, alerts) + // 'c' has both a warning and a critical -- it must count once, as critical. + expect(summary).toEqual({ ok: 1, warning: 1, critical: 1 }) + expect(summary.ok + summary.warning + summary.critical).toBe(devices.length) + }) +}) + +describe('config validation', () => { + it('rejects an alert rule pointing at an undeclared metric', () => { + expect(() => + source({ + alertRules: [ + { + id: 'ghost', + kind: 'threshold', + label: 'Ghost metric', + metric: 'doesNotExist', + direction: 'below', + warnAt: 10, + criticalAt: 5, + }, + ], + }) + ).toThrow(/undeclared metrics/) + }) + + it('rejects duplicate rule ids', () => { + expect(() => + source({ + alertRules: [ + { + id: 'dupe', + kind: 'stale', + label: 'A', + field: 'lastObservationAt', + warnAfterMinutes: 10, + criticalAfterMinutes: 20, + }, + { + id: 'dupe', + kind: 'stale', + label: 'B', + field: 'lastCommunicationAt', + warnAfterMinutes: 10, + criticalAfterMinutes: 20, + }, + ], + }) + ).toThrow(/duplicate alert rule id/) + }) + + it('rejects a critical bound less severe than its warning bound', () => { + expect(() => + source({ + alertRules: [ + { + id: 'backwards', + kind: 'threshold', + label: 'Backwards battery', + metric: 'batteryPercent', + direction: 'below', + warnAt: 10, + criticalAt: 25, + }, + ], + }) + ).toThrow(/not more severe/) + }) + + it('rejects a non-kebab-case source id', () => { + expect(() => source({ id: 'Not Kebab' })).toThrow(/kebab-case/) + }) +}) + +describe('shipped source configs', () => { + it('load and validate at import time', async () => { + const { SENSOR_SOURCES } = await import('@/config/sensor-sources') + expect(SENSOR_SOURCES.map((s) => s.id)).toEqual([ + 'van-essen-diver', + 'wellntel', + ]) + for (const shipped of SENSOR_SOURCES) { + expect(shipped.alertRules.length).toBeGreaterThan(0) + expect(shipped.metrics.length).toBeGreaterThan(0) + } + }) +}) diff --git a/src/components/SensorDashboard/sensorAlerts.ts b/src/components/SensorDashboard/sensorAlerts.ts new file mode 100644 index 00000000..89eb70d5 --- /dev/null +++ b/src/components/SensorDashboard/sensorAlerts.ts @@ -0,0 +1,274 @@ +import type { + AlertRule, + AlertSeverity, + MetricDefinition, + SensorSourceConfig, +} from '@/config/sensor-sources' +import { SEVERITY_ORDER } from '@/config/sensor-sources' +import type { SensorAlert, SensorDevice } from '@/interfaces/sensor-dashboard' + +/** + * Alert engine for the sensor dashboard. + * + * Pure and source-agnostic: every threshold comes from the source config, so + * adding a vendor never touches this file. Deliberately no per-source + * branching -- if you find yourself wanting `if (sourceId === ...)` here, the + * missing knob belongs in `src/config/sensor-sources/schema.ts` instead. + */ + +const MINUTE_MS = 60_000 + +const parseTimestamp = (value?: string | null): Date | null => { + if (!value) return null + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +const isNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) + +/** Minutes elapsed from `earlier` to `now`. Negative if `earlier` is ahead. */ +export const minutesSince = (earlier: Date, now: Date): number => + (now.getTime() - earlier.getTime()) / MINUTE_MS + +/** Compact age for alert detail text: "45 min", "3.2 hr", "6.1 days". */ +export const formatAge = (minutes: number): string => { + if (minutes < 60) return `${Math.round(minutes)} min` + if (minutes < 60 * 48) return `${(minutes / 60).toFixed(1)} hr` + return `${(minutes / (60 * 24)).toFixed(1)} days` +} + +const formatValue = (value: number, metric?: MetricDefinition): string => { + const rendered = value.toFixed(metric?.precision ?? 1) + return metric?.unit ? `${rendered}${metric.unit}` : rendered +} + +type Firing = { + severity: Exclude + detail: string + metricKey?: string + value?: number +} + +const evaluateStale = ( + rule: Extract, + device: SensorDevice, + now: Date +): Firing | null => { + const timestamp = parseTimestamp(device[rule.field]) + + // A device in the registry that has never reported is broken, not new- + // and-quiet. Sources without a given timestamp concept (e.g. no gateway) + // simply do not declare a rule against that field. + if (!timestamp) { + return { severity: 'critical', detail: 'never reported' } + } + + const age = minutesSince(timestamp, now) + if (age >= rule.criticalAfterMinutes) { + return { + severity: 'critical', + detail: `${formatAge(age)} ago (critical after ${formatAge( + rule.criticalAfterMinutes + )})`, + } + } + if (age >= rule.warnAfterMinutes) { + return { + severity: 'warning', + detail: `${formatAge(age)} ago (warn after ${formatAge( + rule.warnAfterMinutes + )})`, + } + } + return null +} + +const evaluateThreshold = ( + rule: Extract, + device: SensorDevice, + metric?: MetricDefinition +): Firing | null => { + const value = device.metrics[rule.metric] + + // Unlike `stale`, a missing metric is not an alert: vendors omit metrics + // for hardware that does not have them. + if (!isNumber(value)) return null + + const crossed = (bound: number) => + rule.direction === 'below' ? value <= bound : value >= bound + const word = rule.direction === 'below' ? 'below' : 'above' + + if (crossed(rule.criticalAt)) { + return { + severity: 'critical', + detail: `${formatValue(value, metric)} (critical ${word} ${formatValue( + rule.criticalAt, + metric + )})`, + metricKey: rule.metric, + value, + } + } + if (crossed(rule.warnAt)) { + return { + severity: 'warning', + detail: `${formatValue(value, metric)} (warn ${word} ${formatValue( + rule.warnAt, + metric + )})`, + metricKey: rule.metric, + value, + } + } + return null +} + +const evaluateRange = ( + rule: Extract, + device: SensorDevice, + metric?: MetricDefinition +): Firing | null => { + const value = device.metrics[rule.metric] + if (!isNumber(value)) return null + + const belowCritical = isNumber(rule.criticalMin) && value < rule.criticalMin + const aboveCritical = isNumber(rule.criticalMax) && value > rule.criticalMax + if (belowCritical || aboveCritical) { + return { + severity: 'critical', + detail: `${formatValue(value, metric)} far outside ${formatValue( + rule.min, + metric + )}-${formatValue(rule.max, metric)}`, + metricKey: rule.metric, + value, + } + } + + if (value < rule.min || value > rule.max) { + return { + severity: 'warning', + detail: `${formatValue(value, metric)} outside ${formatValue( + rule.min, + metric + )}-${formatValue(rule.max, metric)}`, + metricKey: rule.metric, + value, + } + } + return null +} + +const evaluateGap = ( + rule: Extract, + device: SensorDevice +): Firing | null => { + const { expectedSampleCount: expected, observedSampleCount: observed } = + device + + // Both halves of the accounting are required; without them we cannot tell + // "no records missing" from "no information". + if (!isNumber(expected) || !isNumber(observed) || expected <= 0) return null + + const missed = expected - observed + if (missed <= 0) return null + + const detail = `${missed} of ${expected} records missing` + if (missed >= rule.criticalMissedIntervals) { + return { severity: 'critical', detail } + } + if (missed >= rule.warnMissedIntervals) { + return { severity: 'warning', detail } + } + return null +} + +/** All alerts firing for one device under its source's rules. */ +export const evaluateDevice = ( + device: SensorDevice, + source: SensorSourceConfig, + now: Date = new Date() +): SensorAlert[] => { + const metricsByKey = new Map(source.metrics.map((m) => [m.key, m])) + + return source.alertRules.reduce((alerts, rule) => { + const metric = + rule.kind === 'threshold' || rule.kind === 'range' + ? metricsByKey.get(rule.metric) + : undefined + + let firing: Firing | null = null + switch (rule.kind) { + case 'stale': + firing = evaluateStale(rule, device, now) + break + case 'threshold': + firing = evaluateThreshold(rule, device, metric) + break + case 'range': + firing = evaluateRange(rule, device, metric) + break + case 'gap': + firing = evaluateGap(rule, device) + break + } + + if (firing) { + alerts.push({ + ruleId: rule.id, + sourceId: source.id, + deviceId: device.deviceId, + deviceLabel: device.label, + label: rule.label, + ...firing, + }) + } + return alerts + }, []) +} + +/** All alerts firing across every device of a source. */ +export const evaluateSource = ( + devices: SensorDevice[], + source: SensorSourceConfig, + now: Date = new Date() +): SensorAlert[] => + devices.flatMap((device) => evaluateDevice(device, source, now)) + +/** Worst severity present, or 'ok' when nothing is firing. */ +export const worstSeverity = (alerts: SensorAlert[]): AlertSeverity => + alerts.reduce( + (worst, alert) => + SEVERITY_ORDER.indexOf(alert.severity) > SEVERITY_ORDER.indexOf(worst) + ? alert.severity + : worst, + 'ok' + ) + +export type AlertSummary = Record + +/** + * Device counts by worst severity. `ok` counts devices with no alerts at all, + * so the three buckets always sum to `devices.length`. + */ +export const summarizeDevices = ( + devices: SensorDevice[], + alerts: SensorAlert[] +): AlertSummary => { + const byDevice = new Map() + for (const alert of alerts) { + const existing = byDevice.get(alert.deviceId) + if (existing) existing.push(alert) + else byDevice.set(alert.deviceId, [alert]) + } + + return devices.reduce( + (summary, device) => { + const severity = worstSeverity(byDevice.get(device.deviceId) ?? []) + summary[severity] += 1 + return summary + }, + { ok: 0, warning: 0, critical: 0 } + ) +} diff --git a/src/components/SensorDashboard/severity.ts b/src/components/SensorDashboard/severity.ts new file mode 100644 index 00000000..de74f1fa --- /dev/null +++ b/src/components/SensorDashboard/severity.ts @@ -0,0 +1,28 @@ +import type { AlertSeverity } from '@/config/sensor-sources' + +/** + * Single place mapping alert severity onto MUI palette slots and copy, so the + * tiles, chips, grid cells, and alert list cannot drift apart. + */ + +export const SEVERITY_COLOR: Record< + AlertSeverity, + 'success' | 'warning' | 'error' +> = { + ok: 'success', + warning: 'warning', + critical: 'error', +} + +export const SEVERITY_LABEL: Record = { + ok: 'Healthy', + warning: 'Warning', + critical: 'Critical', +} + +/** Order used for tiles and legends: worst first. */ +export const SEVERITY_DISPLAY_ORDER: AlertSeverity[] = [ + 'critical', + 'warning', + 'ok', +] diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 3aa6c797..f0fcc15a 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -1,3 +1,4 @@ +import type { LucideIcon } from 'lucide-react' import { BookOpen, Database, @@ -9,10 +10,10 @@ import { LineChart, Map as MapIcon, MapPin, + RadioTower, Search, Users, } from 'lucide-react' -import type { LucideIcon } from 'lucide-react' import type { PortalRole } from '@/utils/accessControl' /** @@ -130,6 +131,13 @@ export const RESOURCE_NAV: NavItem[] = [ resource: 'ocotillo.collections', roles: viewerAndAbove, }, + { + label: 'Sensor Dashboard', + href: '/ocotillo/sensor-dashboard', + icon: RadioTower, + resource: 'ocotillo.sensor-dashboard', + roles: viewerAndAbove, + }, { label: 'Unassociated Assets', href: '/ocotillo/asset/unassociated', diff --git a/src/config/sensor-sources/index.ts b/src/config/sensor-sources/index.ts new file mode 100644 index 00000000..a83130fc --- /dev/null +++ b/src/config/sensor-sources/index.ts @@ -0,0 +1,29 @@ +import type { SensorSourceConfig } from './schema' +import { vanEssenDiver } from './sources/van-essen-diver' +import { wellntel } from './sources/wellntel' + +/** + * The sensor source registry. + * + * To add a telemetered sensor source: create a file in `./sources/` exporting + * a `defineSensorSource({...})` config, then add it to this array. That is the + * whole change -- the dashboard, alert engine, and mock provider are all + * driven off this registry and contain no per-source logic. + */ +export const SENSOR_SOURCES: SensorSourceConfig[] = [vanEssenDiver, wellntel] + +/** Sources the dashboard should fetch and render. */ +export const enabledSensorSources = (): SensorSourceConfig[] => + SENSOR_SOURCES.filter((source) => source.enabled) + +export const getSensorSource = (id: string): SensorSourceConfig | undefined => + SENSOR_SOURCES.find((source) => source.id === id) + +// Duplicate ids would make `getSensorSource` silently return the wrong config. +const ids = SENSOR_SOURCES.map((source) => source.id) +const duplicate = ids.find((id, i) => ids.indexOf(id) !== i) +if (duplicate) { + throw new Error(`Duplicate sensor source id in registry: "${duplicate}"`) +} + +export * from './schema' diff --git a/src/config/sensor-sources/schema.ts b/src/config/sensor-sources/schema.ts new file mode 100644 index 00000000..34e64cd5 --- /dev/null +++ b/src/config/sensor-sources/schema.ts @@ -0,0 +1,252 @@ +import { z } from 'zod' + +/** + * Sensor source configuration schema. + * + * Adding a new telemetered sensor source (a vendor cloud such as Van Essen + * Diver or Wellntel) must be a *configuration* change only: drop a new file in + * `./sources/`, register it in `./index.ts`, and the dashboard picks it up. + * Nothing in `src/components/SensorDashboard/` may branch on a source id. + * + * The config is split into three concerns: + * + * - `transport` how the UI reaches the source (always via the OcotilloAPI + * proxy -- vendor credentials never reach the browser). + * - `vendor` how the vendor's raw payload maps onto our normalized + * device shape. OcotilloAPI performs the mapping; this block + * is the authoritative spec it implements, and the mock + * provider uses it to generate realistic fixtures. + * - `metrics` / what the dashboard displays and what counts as unhealthy. + * `alertRules` + */ + +/** Severity ordering matters: index is used to pick the worst alert. */ +export const SEVERITY_ORDER = ['ok', 'warning', 'critical'] as const + +export const alertSeveritySchema = z.enum(SEVERITY_ORDER) +export type AlertSeverity = z.infer + +/** + * Timestamp fields on a normalized device that a `stale` rule can watch. + * `lastCommunicationAt` is the device checking in (is it alive?); + * `lastObservationAt` is usable data arriving (is it producing?). + * A device can be online and still stop recording, so these are separate. + */ +export const timestampFieldSchema = z.enum([ + 'lastCommunicationAt', + 'lastObservationAt', +]) +export type TimestampField = z.infer + +const ruleBase = { + /** Stable id, unique within a source. Used as the React key and in tests. */ + id: z.string().min(1), + /** Shown in the alert list. Falls back to a generated description. */ + label: z.string().min(1), +} + +/** Time since a timestamp exceeds a threshold. Covers "offline" and "stale". */ +export const staleRuleSchema = z.object({ + ...ruleBase, + kind: z.literal('stale'), + field: timestampFieldSchema, + warnAfterMinutes: z.number().positive(), + criticalAfterMinutes: z.number().positive(), +}) + +/** + * A numeric metric crosses a one-sided bound. Covers battery and signal + * (`direction: 'below'`) as well as things like pressure (`'above'`). + */ +export const thresholdRuleSchema = z.object({ + ...ruleBase, + kind: z.literal('threshold'), + metric: z.string().min(1), + direction: z.enum(['below', 'above']), + warnAt: z.number(), + criticalAt: z.number(), +}) + +/** A numeric metric leaves a plausible range. Sanity check on readings. */ +export const rangeRuleSchema = z.object({ + ...ruleBase, + kind: z.literal('range'), + metric: z.string().min(1), + min: z.number(), + max: z.number(), + /** Outside the range is a warning; outside these is critical. */ + criticalMin: z.number().optional(), + criticalMax: z.number().optional(), +}) + +/** + * Fewer samples arrived than the recording interval implies. Catches a logger + * that is reporting but dropping records, which `stale` alone would miss. + */ +export const gapRuleSchema = z.object({ + ...ruleBase, + kind: z.literal('gap'), + warnMissedIntervals: z.number().positive(), + criticalMissedIntervals: z.number().positive(), +}) + +export const alertRuleSchema = z.discriminatedUnion('kind', [ + staleRuleSchema, + thresholdRuleSchema, + rangeRuleSchema, + gapRuleSchema, +]) +export type AlertRule = z.infer + +/** A numeric column the dashboard renders for every device of this source. */ +export const metricDefinitionSchema = z.object({ + key: z.string().min(1), + label: z.string().min(1), + unit: z.string().optional(), + /** Decimal places in the grid. Defaults to 1. */ + precision: z.number().int().min(0).max(6).default(1), + /** Hide from the device grid but keep available to alert rules. */ + hidden: z.boolean().default(false), +}) +export type MetricDefinition = z.infer + +/** + * Where a normalized field comes from in the vendor payload. `path` is a + * dot-notation path into the vendor's device object. + */ +export const vendorFieldMappingSchema = z.object({ + path: z.string().min(1), + /** + * Multiply the raw value. Use for unit conversion (e.g. Van Essen reports + * battery volts; a percentage needs scaling upstream instead -- prefer + * asking the backend for the already-correct unit over fudging here). + */ + scale: z.number().optional(), + offset: z.number().optional(), +}) + +export const vendorSpecSchema = z.object({ + /** Human name of the vendor cloud, shown in the source card. */ + name: z.string().min(1), + /** Link to vendor console, opened from the source card. */ + consoleUrl: z.string().url().optional(), + /** + * Normalized field/metric key -> vendor payload location. OcotilloAPI + * implements this mapping; keeping it here keeps the contract in one file + * per source and lets the mock provider generate matching fixtures. + */ + fieldMap: z.record(z.string(), vendorFieldMappingSchema), +}) + +export const transportSchema = z.object({ + /** + * Only one kind today. Vendor credentials live in OcotilloAPI and never + * reach the browser, so there is deliberately no `direct` option. + */ + kind: z.literal('ocotillo-proxy'), + /** Path segment under the OcotilloAPI base URL, no leading slash. */ + basePath: z.string().min(1), +}) + +export const ingestionSchema = z.object({ + /** Ocotillo resource the pulled data lands in. */ + targetResource: z.string().min(1), + /** Lexicon parameter the source produces. */ + parameter: z.string().min(1), + /** + * Nominal recording interval. `gap` rules use this to work out how many + * samples were expected; overridden per-device when the deployment record + * carries its own `recording_interval`. + */ + defaultIntervalMinutes: z.number().positive(), + /** Operators may trigger a pull from the dashboard. */ + allowManualTrigger: z.boolean().default(true), +}) + +export const sensorSourceSchema = z.object({ + /** Kebab-case, stable, used in URLs and as the React key. */ + id: z + .string() + .min(1) + .regex(/^[a-z0-9-]+$/, 'source id must be kebab-case'), + label: z.string().min(1), + /** Disabled sources stay in the registry but are not fetched or rendered. */ + enabled: z.boolean().default(true), + transport: transportSchema, + vendor: vendorSpecSchema, + metrics: z.array(metricDefinitionSchema).default([]), + alertRules: z.array(alertRuleSchema).default([]), + ingestion: ingestionSchema, +}) + +export type SensorSourceConfig = z.infer +/** Pre-parse shape: fields with defaults are optional when authoring. */ +export type SensorSourceConfigInput = z.input + +/** + * Validates a source config at import time so a malformed file fails the build + * (via `tsc`) or throws on first load rather than rendering an empty dashboard. + */ +export const defineSensorSource = ( + config: SensorSourceConfigInput +): SensorSourceConfig => { + const result = sensorSourceSchema.safeParse(config) + if (!result.success) { + throw new Error( + `Invalid sensor source config "${config.id}": ${result.error.message}` + ) + } + const source = result.data + + // A rule pointing at a metric that was never declared would silently never + // fire, which is worse than a hard failure at import time. + const known = new Set(source.metrics.map((m) => m.key)) + const dangling = source.alertRules + .filter((rule) => rule.kind === 'threshold' || rule.kind === 'range') + .filter((rule) => !known.has(rule.metric)) + .map((rule) => `${rule.id} -> ${rule.metric}`) + if (dangling.length > 0) { + throw new Error( + `Sensor source "${source.id}" has alert rules referencing undeclared ` + + `metrics: ${dangling.join(', ')}` + ) + } + + const ids = source.alertRules.map((rule) => rule.id) + const duplicate = ids.find((id, i) => ids.indexOf(id) !== i) + if (duplicate) { + throw new Error( + `Sensor source "${source.id}" has duplicate alert rule id "${duplicate}"` + ) + } + + // Thresholds that are ordered the wrong way round still parse but can never + // reach their critical branch, producing a rule that quietly under-reports. + const inverted = source.alertRules.filter((rule) => { + switch (rule.kind) { + case 'stale': + return rule.criticalAfterMinutes < rule.warnAfterMinutes + case 'gap': + return rule.criticalMissedIntervals < rule.warnMissedIntervals + case 'threshold': + return rule.direction === 'below' + ? rule.criticalAt > rule.warnAt + : rule.criticalAt < rule.warnAt + case 'range': + return ( + rule.min > rule.max || + (rule.criticalMin !== undefined && rule.criticalMin > rule.min) || + (rule.criticalMax !== undefined && rule.criticalMax < rule.max) + ) + } + }) + if (inverted.length > 0) { + throw new Error( + `Sensor source "${source.id}" has alert rules whose critical bound is ` + + `not more severe than its warning bound: ` + + `${inverted.map((rule) => rule.id).join(', ')}` + ) + } + + return source +} diff --git a/src/config/sensor-sources/sources/van-essen-diver.ts b/src/config/sensor-sources/sources/van-essen-diver.ts new file mode 100644 index 00000000..a2e06d9e --- /dev/null +++ b/src/config/sensor-sources/sources/van-essen-diver.ts @@ -0,0 +1,136 @@ +import { defineSensorSource } from '../schema' + +/** + * Van Essen Diver telemetry (Diver-NETZ / DiverHQ). + * + * Pressure transducers reporting through a DXT/Diver-Gate telemetry unit. + * Water column is measured as pressure above the sensor; conversion to depth + * below the measuring point and barometric compensation happen in OcotilloAPI, + * so the metrics below are already-corrected values. + * + * TODO(vendor-docs): `vendor.fieldMap` paths are modelled on the documented + * DiverHQ device payload but have not been checked against a live response. + * Confirm each path against the account API docs and correct here -- no other + * file needs to change. + */ +export const vanEssenDiver = defineSensorSource({ + id: 'van-essen-diver', + label: 'Van Essen Diver', + enabled: true, + + transport: { + kind: 'ocotillo-proxy', + basePath: 'sensor-source/van-essen-diver', + }, + + vendor: { + name: 'Van Essen Diver-NETZ', + consoleUrl: 'https://www.diverhq.com', + fieldMap: { + deviceId: { path: 'instrument.serialNumber' }, + label: { path: 'monitoringPoint.name' }, + serialNumber: { path: 'instrument.serialNumber' }, + pointId: { path: 'monitoringPoint.externalId' }, + lastCommunicationAt: { path: 'telemetry.lastContactUtc' }, + lastObservationAt: { path: 'lastMeasurement.timestampUtc' }, + 'location.latitude': { path: 'monitoringPoint.latitude' }, + 'location.longitude': { path: 'monitoringPoint.longitude' }, + vendorStatus: { path: 'telemetry.status' }, + // Reported 0-1; the dashboard shows a percentage. + batteryPercent: { path: 'instrument.batteryRemaining', scale: 100 }, + memoryUsedPercent: { path: 'instrument.memoryUsed', scale: 100 }, + signalPercent: { path: 'telemetry.signalQuality', scale: 100 }, + waterLevelFeet: { path: 'lastMeasurement.waterLevel' }, + temperatureCelsius: { path: 'lastMeasurement.temperature' }, + }, + }, + + metrics: [ + { key: 'batteryPercent', label: 'Battery', unit: '%', precision: 0 }, + { key: 'signalPercent', label: 'Signal', unit: '%', precision: 0 }, + { key: 'memoryUsedPercent', label: 'Memory', unit: '%', precision: 0 }, + { key: 'waterLevelFeet', label: 'Water level', unit: 'ft', precision: 2 }, + { + key: 'temperatureCelsius', + label: 'Temp', + unit: '°C', + precision: 1, + }, + ], + + alertRules: [ + { + id: 'diver-offline', + kind: 'stale', + label: 'Logger has not checked in', + field: 'lastCommunicationAt', + // DXT units transmit daily; two missed days is a real problem. + warnAfterMinutes: 60 * 26, + criticalAfterMinutes: 60 * 24 * 3, + }, + { + id: 'diver-no-data', + kind: 'stale', + label: 'No new measurements', + field: 'lastObservationAt', + warnAfterMinutes: 60 * 26, + criticalAfterMinutes: 60 * 24 * 3, + }, + { + id: 'diver-battery', + kind: 'threshold', + label: 'Battery low', + metric: 'batteryPercent', + direction: 'below', + warnAt: 25, + criticalAt: 10, + }, + { + id: 'diver-memory', + kind: 'threshold', + label: 'Logger memory filling up', + metric: 'memoryUsedPercent', + direction: 'above', + warnAt: 80, + criticalAt: 95, + }, + { + id: 'diver-signal', + kind: 'threshold', + label: 'Weak telemetry signal', + metric: 'signalPercent', + direction: 'below', + warnAt: 30, + criticalAt: 15, + }, + { + id: 'diver-level-range', + kind: 'range', + label: 'Water level outside plausible range', + metric: 'waterLevelFeet', + // Sensor above water or absurd depth both indicate a bad reading + // rather than a real water-level change. + min: 0, + max: 1500, + criticalMin: -5, + criticalMax: 2000, + }, + { + id: 'diver-gap', + kind: 'gap', + label: 'Missing records since last transmission', + // ~24 hourly records expected per day; 24 missed would mean a total + // outage, which the stale rules already cover. + warnMissedIntervals: 6, + criticalMissedIntervals: 12, + }, + ], + + ingestion: { + targetResource: 'observation/transducer-groundwater-level', + parameter: 'groundwater-level', + // Diver loggers are commonly set to hourly sampling in this network. + defaultIntervalMinutes: 60, + allowManualTrigger: true, + }, +}) diff --git a/src/config/sensor-sources/sources/wellntel.ts b/src/config/sensor-sources/sources/wellntel.ts new file mode 100644 index 00000000..7cd5cfcc --- /dev/null +++ b/src/config/sensor-sources/sources/wellntel.ts @@ -0,0 +1,138 @@ +import { defineSensorSource } from '../schema' + +/** + * Wellntel acoustic water-level sensors. + * + * A Wellntel install is a sensor on the wellhead paired with a gateway that + * uploads to the Wellntel cloud. Readings are acoustic depth-to-water, so a + * failing sensor tends to show up as dropped/implausible readings rather than + * a clean offline signal -- hence the tighter `gap` and `range` rules than the + * Diver source uses. + * + * TODO(vendor-docs): `vendor.fieldMap` paths are modelled, not verified. + * Check against the Wellntel account API docs and correct here -- no other + * file needs to change. + */ +export const wellntel = defineSensorSource({ + id: 'wellntel', + label: 'Wellntel', + enabled: true, + + transport: { + kind: 'ocotillo-proxy', + basePath: 'sensor-source/wellntel', + }, + + vendor: { + name: 'Wellntel Insights', + consoleUrl: 'https://my.wellntel.com', + fieldMap: { + deviceId: { path: 'sensor.id' }, + label: { path: 'well.name' }, + serialNumber: { path: 'sensor.serial' }, + pointId: { path: 'well.external_id' }, + lastCommunicationAt: { path: 'gateway.last_seen_at' }, + lastObservationAt: { path: 'latest_reading.recorded_at' }, + 'location.latitude': { path: 'well.latitude' }, + 'location.longitude': { path: 'well.longitude' }, + vendorStatus: { path: 'sensor.status' }, + batteryPercent: { path: 'sensor.battery_level' }, + signalPercent: { path: 'gateway.signal_strength' }, + depthToWaterFeet: { path: 'latest_reading.depth_to_water_ft' }, + // Wellntel scores each acoustic return; low confidence means the + // reading is probably an echo off casing rather than the water surface. + readingConfidence: { path: 'latest_reading.confidence' }, + }, + }, + + metrics: [ + { key: 'batteryPercent', label: 'Battery', unit: '%', precision: 0 }, + { key: 'signalPercent', label: 'Signal', unit: '%', precision: 0 }, + { + key: 'depthToWaterFeet', + label: 'Depth to water', + unit: 'ft', + precision: 2, + }, + { + key: 'readingConfidence', + label: 'Confidence', + unit: '%', + precision: 0, + }, + ], + + alertRules: [ + { + id: 'wellntel-gateway-offline', + kind: 'stale', + label: 'Gateway has not checked in', + field: 'lastCommunicationAt', + // Gateways report several times a day, so silence is noticed sooner. + warnAfterMinutes: 60 * 8, + criticalAfterMinutes: 60 * 24, + }, + { + id: 'wellntel-no-data', + kind: 'stale', + label: 'No new readings', + field: 'lastObservationAt', + warnAfterMinutes: 60 * 12, + criticalAfterMinutes: 60 * 24 * 2, + }, + { + id: 'wellntel-battery', + kind: 'threshold', + label: 'Battery low', + metric: 'batteryPercent', + direction: 'below', + warnAt: 30, + criticalAt: 15, + }, + { + id: 'wellntel-signal', + kind: 'threshold', + label: 'Weak gateway signal', + metric: 'signalPercent', + direction: 'below', + warnAt: 30, + criticalAt: 15, + }, + { + id: 'wellntel-confidence', + kind: 'threshold', + label: 'Low acoustic reading confidence', + metric: 'readingConfidence', + direction: 'below', + warnAt: 70, + criticalAt: 50, + }, + { + id: 'wellntel-depth-range', + kind: 'range', + label: 'Depth to water outside plausible range', + metric: 'depthToWaterFeet', + min: 0, + max: 1200, + criticalMin: -2, + criticalMax: 2000, + }, + { + id: 'wellntel-gap', + kind: 'gap', + label: 'Missing readings since last upload', + // Only ~6 readings are expected per day at a 4h interval, so the + // thresholds have to be small to be reachable at all. + warnMissedIntervals: 2, + criticalMissedIntervals: 4, + }, + ], + + ingestion: { + targetResource: 'observation/transducer-groundwater-level', + parameter: 'groundwater-level', + // Wellntel sensors typically report every 4 hours. + defaultIntervalMinutes: 240, + allowManualTrigger: true, + }, +}) diff --git a/src/hooks/useSensorSources.ts b/src/hooks/useSensorSources.ts new file mode 100644 index 00000000..33023859 --- /dev/null +++ b/src/hooks/useSensorSources.ts @@ -0,0 +1,163 @@ +import { useQueries, useQueryClient } from '@tanstack/react-query' +import { useCallback, useMemo } from 'react' +import type { AlertSummary } from '@/components/SensorDashboard/sensorAlerts' +import { + evaluateSource, + summarizeDevices, +} from '@/components/SensorDashboard/sensorAlerts' +import type { SensorSourceConfig } from '@/config/sensor-sources' +import { enabledSensorSources } from '@/config/sensor-sources' +import type { + PendingBatch, + SensorAlert, + SensorDevice, + SensorSourceSnapshot, + SensorSourceStatus, +} from '@/interfaces/sensor-dashboard' +import { sensorSourceClient } from '@/providers/sensor-source-provider' + +/** + * Loads every enabled sensor source and evaluates its alert rules. + * + * One query per source per endpoint, so a vendor cloud being down degrades + * only its own card instead of blanking the dashboard. + */ + +const STATUS_KEY = 'sensor-source-status' +const DEVICE_KEY = 'sensor-source-devices' +const PENDING_KEY = 'sensor-source-pending' + +/** Telemetry arrives hourly at best; polling harder just burns vendor quota. */ +const REFETCH_INTERVAL_MS = 5 * 60_000 + +export interface SensorSourceView extends SensorSourceSnapshot { + config: SensorSourceConfig + isLoading: boolean + error: Error | null + summary: AlertSummary +} + +export interface UseSensorSourcesResult { + sources: SensorSourceView[] + /** Alerts across every source, worst-first. */ + alerts: SensorAlert[] + /** Device counts by worst severity, across every source. */ + summary: AlertSummary + pending: PendingBatch[] + isLoading: boolean + refetch: () => Promise +} + +const EMPTY_STATUS = (source: SensorSourceConfig): SensorSourceStatus => ({ + sourceId: source.id, + reachable: false, + deviceCount: 0, + lastPolledAt: null, + error: null, +}) + +export const useSensorSources = (): UseSensorSourcesResult => { + const queryClient = useQueryClient() + const configs = useMemo(() => enabledSensorSources(), []) + + const statusQueries = useQueries({ + queries: configs.map((source) => ({ + queryKey: [STATUS_KEY, source.id], + queryFn: () => sensorSourceClient.getStatus(source), + refetchInterval: REFETCH_INTERVAL_MS, + })), + }) + + const deviceQueries = useQueries({ + queries: configs.map((source) => ({ + queryKey: [DEVICE_KEY, source.id], + queryFn: () => sensorSourceClient.getDevices(source), + refetchInterval: REFETCH_INTERVAL_MS, + })), + }) + + const pendingQueries = useQueries({ + queries: configs.map((source) => ({ + queryKey: [PENDING_KEY, source.id], + queryFn: () => sensorSourceClient.getPending(source), + refetchInterval: REFETCH_INTERVAL_MS, + })), + }) + + const sources = useMemo(() => { + // `now` is captured once per recomputation so every source is evaluated + // against the same instant -- otherwise two cards can disagree about + // whether a device is stale. + const now = new Date() + + return configs.map((config, index) => { + const statusQuery = statusQueries[index] + const deviceQuery = deviceQueries[index] + const pendingQuery = pendingQueries[index] + + const devices: SensorDevice[] = deviceQuery?.data ?? [] + const alerts = evaluateSource(devices, config, now) + const error = + (statusQuery?.error as Error | null) ?? + (deviceQuery?.error as Error | null) ?? + (pendingQuery?.error as Error | null) ?? + null + + const status = statusQuery?.data ?? EMPTY_STATUS(config) + + return { + config, + sourceId: config.id, + status: error + ? { ...status, reachable: false, error: error.message } + : status, + devices, + alerts, + pending: pendingQuery?.data ?? [], + summary: summarizeDevices(devices, alerts), + isLoading: + Boolean(statusQuery?.isLoading) || + Boolean(deviceQuery?.isLoading) || + Boolean(pendingQuery?.isLoading), + error, + } + }) + }, [configs, statusQueries, deviceQueries, pendingQueries]) + + const refetch = useCallback(async () => { + await Promise.all( + [STATUS_KEY, DEVICE_KEY, PENDING_KEY].map((key) => + queryClient.invalidateQueries({ queryKey: [key] }) + ) + ) + }, [queryClient]) + + return useMemo(() => { + const alerts = sources + .flatMap((source) => source.alerts) + // Critical first so the alert panel needs no scrolling to be useful. + .sort((a, b) => { + if (a.severity === b.severity) + return a.deviceLabel.localeCompare(b.deviceLabel) + return a.severity === 'critical' ? -1 : 1 + }) + + const summary = sources.reduce( + (total, source) => ({ + ok: total.ok + source.summary.ok, + warning: total.warning + source.summary.warning, + critical: total.critical + source.summary.critical, + }), + { ok: 0, warning: 0, critical: 0 } + ) + + return { + sources, + alerts, + summary, + pending: sources.flatMap((source) => source.pending), + isLoading: sources.some((source) => source.isLoading), + refetch, + } + }, [sources, refetch]) +} diff --git a/src/interfaces/sensor-dashboard/index.ts b/src/interfaces/sensor-dashboard/index.ts new file mode 100644 index 00000000..5c21c700 --- /dev/null +++ b/src/interfaces/sensor-dashboard/index.ts @@ -0,0 +1,127 @@ +import type { AlertSeverity } from '@/config/sensor-sources' + +/** + * Normalized shapes the sensor dashboard consumes. + * + * OcotilloAPI is responsible for talking to each vendor cloud and flattening + * the response into these shapes -- the mapping is specified per source in + * `src/config/sensor-sources/sources/*.ts` under `vendor.fieldMap`. Vendor + * credentials stay server-side; the browser only ever sees these types. + * + * Contract (all paths relative to the OcotilloAPI base URL): + * + * GET {basePath} -> SensorSourceStatus + * GET {basePath}/device -> { data: SensorDevice[] } + * GET {basePath}/pending -> { data: PendingBatch[] } + * POST {basePath}/ingest -> IngestRun + * GET {basePath}/ingest/{runId} -> IngestRun + * + * where `{basePath}` is `transport.basePath` from the source config. + */ + +/** One physical logger / sensor install reporting through a vendor cloud. */ +export interface SensorDevice { + sourceId: string + /** Vendor's stable id for the device. Unique within a source. */ + deviceId: string + label: string + serialNumber?: string | null + /** Ocotillo PointID, when the device has been matched to a Thing. */ + pointId?: string | null + /** Ocotillo Thing id, when matched. Enables deep-linking to the well. */ + thingId?: string | null + /** ISO 8601. Last time the device (or its gateway) contacted the vendor. */ + lastCommunicationAt?: string | null + /** ISO 8601. Timestamp of the most recent usable measurement. */ + lastObservationAt?: string | null + location?: { latitude: number; longitude: number } | null + /** Vendor's own status string, shown verbatim as supplementary detail. */ + vendorStatus?: string | null + /** + * Normalized metric values keyed by `metrics[].key` in the source config. + * A key may be absent or null when the vendor did not report it. + */ + metrics: Record + /** + * Per-device sampling interval from the deployment record, overriding the + * source's `ingestion.defaultIntervalMinutes`. + */ + recordingIntervalMinutes?: number | null + /** + * Sample accounting over the source's most recent reporting window, used by + * `gap` rules. Both must be present for a gap rule to evaluate. + */ + observedSampleCount?: number | null + expectedSampleCount?: number | null +} + +/** A rule firing against a specific device. */ +export interface SensorAlert { + ruleId: string + sourceId: string + deviceId: string + deviceLabel: string + /** Only 'warning' or 'critical' -- an 'ok' device produces no alert. */ + severity: Exclude + label: string + /** Human-readable specifics, e.g. "18% (critical below 15%)". */ + detail: string + /** Present for threshold/range rules. */ + metricKey?: string + value?: number +} + +/** Reachability of the vendor integration itself, independent of devices. */ +export interface SensorSourceStatus { + sourceId: string + reachable: boolean + /** ISO 8601. When OcotilloAPI last successfully polled the vendor. */ + lastPolledAt?: string | null + deviceCount: number + /** Populated when `reachable` is false. */ + error?: string | null +} + +/** A window of vendor-side data not yet ingested into Ocotillo. */ +export interface PendingBatch { + sourceId: string + batchId: string + deviceId: string + deviceLabel: string + pointId?: string | null + /** ISO 8601 bounds of the un-ingested window. */ + startDatetime: string + endDatetime: string + recordCount: number + parameter: string + /** ISO 8601. When OcotilloAPI first saw this batch as available. */ + detectedAt: string +} + +export type IngestRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' + +/** An ingestion job triggered from the dashboard. */ +export interface IngestRun { + runId: string + sourceId: string + status: IngestRunStatus + batchIds: string[] + startedAt: string + finishedAt?: string | null + recordsIngested?: number | null + message?: string | null +} + +/** Request body for POST {basePath}/ingest. */ +export interface IngestRequest { + batchIds: string[] +} + +/** A source plus everything the dashboard renders for it. */ +export interface SensorSourceSnapshot { + sourceId: string + status: SensorSourceStatus + devices: SensorDevice[] + alerts: SensorAlert[] + pending: PendingBatch[] +} diff --git a/src/pages/ocotillo/sensor-dashboard/index.tsx b/src/pages/ocotillo/sensor-dashboard/index.tsx new file mode 100644 index 00000000..425e00aa --- /dev/null +++ b/src/pages/ocotillo/sensor-dashboard/index.tsx @@ -0,0 +1,132 @@ +import RefreshIcon from '@mui/icons-material/Refresh' +import { Alert, Box, Button, Stack, Tab, Tabs, Typography } from '@mui/material' +import { useState } from 'react' +import { AlertList } from '@/components/SensorDashboard/AlertList' +import { AlertSummaryTiles } from '@/components/SensorDashboard/AlertSummaryTiles' +import { DeviceGrid } from '@/components/SensorDashboard/DeviceGrid' +import { PendingIngestionPanel } from '@/components/SensorDashboard/PendingIngestionPanel' +import { SourceStatusCard } from '@/components/SensorDashboard/SourceStatusCard' +import type { AlertSeverity } from '@/config/sensor-sources' +import { useSensorSources } from '@/hooks/useSensorSources' +import { settings } from '@/settings' + +/** + * Unified alert platform for telemetered sensors. + * + * Two jobs, one per tab: + * 1. Health & status -- is every logger alive and reporting sanely? + * 2. Pending ingestion -- what data is sitting in a vendor cloud that + * Ocotillo has not pulled in yet, and pull it. + * + * Sources come from `src/config/sensor-sources`. Nothing here knows about a + * specific vendor. + */ +export const SensorDashboardPage = () => { + const [tab, setTab] = useState(0) + const [severityFilter, setSeverityFilter] = useState( + null + ) + const { sources, alerts, summary, pending, isLoading, refetch } = + useSensorSources() + + const totalPending = pending.reduce( + (sum, batch) => sum + batch.recordCount, + 0 + ) + + return ( + + + + Sensor Dashboard + + Health and pending data across {sources.length} telemetered sensor + source{sources.length === 1 ? '' : 's'}. + + + + + + {settings.sensor_mock && ( + + Showing generated fixture data. The OcotilloAPI sensor-source + endpoints are not implemented yet -- set{' '} + VITE_SENSOR_MOCK=false once they are. + + )} + + setTab(next)} + sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }} + > + + + + + {tab === 0 && ( + + + + + + {sources.map((source) => ( + + + + + ))} + + )} + + {tab === 1 && ( + + + {pending.length} batch{pending.length === 1 ? '' : 'es'} awaiting + ingestion, {totalPending} record + {totalPending === 1 ? '' : 's'} total. + + + {/* `refetch` is memoized -- an inline arrow here would reset the + panel's run-polling effect on every render. */} + {sources.map((source) => ( + + ))} + + )} + + ) +} + +export default SensorDashboardPage diff --git a/src/providers/sensor-source-mock.ts b/src/providers/sensor-source-mock.ts new file mode 100644 index 00000000..4f475ce6 --- /dev/null +++ b/src/providers/sensor-source-mock.ts @@ -0,0 +1,306 @@ +import type { AlertRule, SensorSourceConfig } from '@/config/sensor-sources' +import type { + IngestRun, + PendingBatch, + SensorDevice, + SensorSourceStatus, +} from '@/interfaces/sensor-dashboard' +import type { SensorSourceClient } from './sensor-source-provider' + +/** + * Fixture client used when `VITE_SENSOR_MOCK=true`. + * + * Everything is derived from the source config -- metrics, thresholds, and + * intervals -- so a newly added source gets believable fixtures with no change + * here. Values are seeded off the source id and device index, so a reload + * shows the same fleet rather than reshuffling under you. + */ + +const DEVICES_PER_SOURCE = 14 +const MINUTE_MS = 60_000 + +/** Deterministic PRNG. Same seed in, same sequence out. */ +const mulberry32 = (seed: number) => { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) >>> 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const hashString = (value: string): number => { + let hash = 2166136261 + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + return hash >>> 0 +} + +const seededRandom = (...parts: (string | number)[]) => + mulberry32(hashString(parts.join('|'))) + +const pick = (random: () => number, items: T[]): T => + items[Math.floor(random() * items.length)] + +const between = (random: () => number, min: number, max: number) => + min + random() * (max - min) + +/** + * Every 5th device is unhealthy and every 7th is badly so, giving each source + * a predictable mix of ok / warning / critical without hand-written fixtures. + */ +type Health = 'healthy' | 'degraded' | 'failing' + +const healthFor = (index: number): Health => { + if (index % 7 === 6) return 'failing' + if (index % 5 === 4) return 'degraded' + return 'healthy' +} + +const ruleFor = ( + source: SensorSourceConfig, + kind: K, + predicate: (rule: Extract) => boolean = () => true +): Extract | undefined => + source.alertRules.find( + (rule): rule is Extract => + rule.kind === kind && predicate(rule as Extract) + ) + +/** + * Generate a metric value positioned relative to whatever rules watch it, so + * a "failing" device actually trips the source's own configured thresholds. + */ +const metricValue = ( + source: SensorSourceConfig, + metricKey: string, + health: Health, + random: () => number +): number => { + const threshold = ruleFor( + source, + 'threshold', + (rule) => rule.metric === metricKey + ) + const range = ruleFor(source, 'range', (rule) => rule.metric === metricKey) + + if (threshold) { + const { direction, warnAt, criticalAt } = threshold + if (direction === 'below') { + if (health === 'failing') return between(random, 0, criticalAt) + if (health === 'degraded') return between(random, criticalAt, warnAt) + return between(random, warnAt + 1, Math.max(warnAt + 1, 100)) + } + if (health === 'failing') return between(random, criticalAt, criticalAt + 5) + if (health === 'degraded') return between(random, warnAt, criticalAt) + return between(random, 0, warnAt - 1) + } + + if (range) { + if (health === 'failing') { + return between( + random, + range.criticalMax ?? range.max, + (range.max || 1) * 2 + ) + } + // Keep the bulk of readings in the middle of the plausible band. + const span = range.max - range.min + return between(random, range.min + span * 0.1, range.min + span * 0.6) + } + + return between(random, 0, 100) +} + +const buildDevice = ( + source: SensorSourceConfig, + index: number, + now: number +): SensorDevice => { + const random = seededRandom(source.id, index) + const health = healthFor(index) + const interval = source.ingestion.defaultIntervalMinutes + + const staleRule = ruleFor( + source, + 'stale', + (rule) => rule.field === 'lastCommunicationAt' + ) + const dataRule = ruleFor( + source, + 'stale', + (rule) => rule.field === 'lastObservationAt' + ) + + const ageFor = ( + rule: Extract | undefined + ): number => { + if (!rule) return between(random, 0, interval) + if (health === 'failing') + return between( + random, + rule.criticalAfterMinutes, + rule.criticalAfterMinutes * 2 + ) + if (health === 'degraded') + return between(random, rule.warnAfterMinutes, rule.criticalAfterMinutes) + return between(random, 0, rule.warnAfterMinutes * 0.5) + } + + const metrics: Record = {} + for (const metric of source.metrics) { + metrics[metric.key] = Number( + metricValue(source, metric.key, health, random).toFixed(metric.precision) + ) + } + + // Sample accounting over a 24h window. Shortfalls are expressed as a + // fraction of `expected` so sources with long intervals (few samples per + // day) still produce sensible counts. + const expected = Math.max(1, Math.round((60 * 24) / interval)) + const shortfall = + health === 'failing' + ? Math.ceil(expected * between(random, 0.5, 0.9)) + : health === 'degraded' + ? Math.max(1, Math.round(expected * between(random, 0.15, 0.3))) + : 0 + const observed = Math.max(0, expected - shortfall) + + // Prefix per source so two vendors' fixtures never share a PointID -- a + // real network would not have the same well monitored by both. + const prefix = source.id + .split('-') + .map((part) => part[0]) + .join('') + .toUpperCase() + const pointId = `${prefix}-${(index + 1).toString().padStart(3, '0')}` + + return { + sourceId: source.id, + deviceId: `${source.id}-${(index + 1).toString().padStart(3, '0')}`, + label: `${pointId} ${pick(random, [ + 'Windmill', + 'Cottonwood', + 'Mesa', + 'Arroyo', + 'Bosque', + 'Rio Abajo', + 'Sandia', + 'Ocotillo', + ])}`, + serialNumber: `${Math.floor(between(random, 100000, 999999))}`, + pointId, + thingId: null, + lastCommunicationAt: new Date( + now - ageFor(staleRule) * MINUTE_MS + ).toISOString(), + lastObservationAt: new Date( + now - ageFor(dataRule) * MINUTE_MS + ).toISOString(), + location: { + // Scattered across New Mexico. + latitude: Number(between(random, 32.0, 36.9).toFixed(5)), + longitude: Number(between(random, -108.9, -103.2).toFixed(5)), + }, + vendorStatus: health === 'healthy' ? 'Active' : 'Needs attention', + metrics, + recordingIntervalMinutes: interval, + expectedSampleCount: expected, + observedSampleCount: observed, + } +} + +const devicesFor = (source: SensorSourceConfig, now: number) => + Array.from({ length: DEVICES_PER_SOURCE }, (_, index) => + buildDevice(source, index, now) + ) + +const buildPending = ( + source: SensorSourceConfig, + devices: SensorDevice[], + now: number +): PendingBatch[] => + devices + // Not every device has un-ingested data waiting. + .filter((_, index) => index % 3 !== 2) + .map((device, index) => { + const random = seededRandom(source.id, 'pending', index) + const windowHours = Math.round(between(random, 6, 72)) + const end = now - Math.round(between(random, 0, 4)) * 60 * MINUTE_MS + const start = end - windowHours * 60 * MINUTE_MS + return { + sourceId: source.id, + batchId: `${device.deviceId}-batch-${index + 1}`, + deviceId: device.deviceId, + deviceLabel: device.label, + pointId: device.pointId, + startDatetime: new Date(start).toISOString(), + endDatetime: new Date(end).toISOString(), + recordCount: Math.round( + (windowHours * 60) / source.ingestion.defaultIntervalMinutes + ), + parameter: source.ingestion.parameter, + detectedAt: new Date(end + 30 * MINUTE_MS).toISOString(), + } + }) + +/** Runs triggered during this session, so status polling returns something. */ +const runs = new Map() + +const delay = (value: T, ms = 220): Promise => + new Promise((resolve) => setTimeout(() => resolve(value), ms)) + +export const mockSensorSourceClient: SensorSourceClient = { + getStatus: async (source) => + delay({ + sourceId: source.id, + reachable: true, + lastPolledAt: new Date(Date.now() - 4 * MINUTE_MS).toISOString(), + deviceCount: DEVICES_PER_SOURCE, + error: null, + }), + + getDevices: async (source) => delay(devicesFor(source, Date.now())), + + getPending: async (source) => { + const now = Date.now() + return delay(buildPending(source, devicesFor(source, now), now)) + }, + + triggerIngest: async (source, batchIds) => { + const runId = `${source.id}-run-${runs.size + 1}` + const run: IngestRun = { + runId, + sourceId: source.id, + status: 'running', + batchIds, + startedAt: new Date().toISOString(), + finishedAt: null, + recordsIngested: null, + message: null, + } + runs.set(runId, run) + + // Settle shortly after, so the UI shows running -> succeeded. + setTimeout(() => { + runs.set(runId, { + ...run, + status: 'succeeded', + finishedAt: new Date().toISOString(), + recordsIngested: batchIds.length * 24, + message: `Ingested ${batchIds.length} batch(es)`, + }) + }, 2500) + + return delay(run) + }, + + getIngestRun: async (_source, runId) => { + const run = runs.get(runId) + if (!run) throw new Error(`Unknown mock ingest run "${runId}"`) + return delay(run, 60) + }, +} diff --git a/src/providers/sensor-source-provider.ts b/src/providers/sensor-source-provider.ts new file mode 100644 index 00000000..65359a23 --- /dev/null +++ b/src/providers/sensor-source-provider.ts @@ -0,0 +1,88 @@ +import type { SensorSourceConfig } from '@/config/sensor-sources' +import type { + IngestRequest, + IngestRun, + PendingBatch, + SensorDevice, + SensorSourceStatus, +} from '@/interfaces/sensor-dashboard' +import { axiosCall, fetcher } from '@/providers/ocotillo-data-provider' +import { settings } from '@/settings' +import { mockSensorSourceClient } from './sensor-source-mock' + +/** + * Client for the OcotilloAPI sensor-source proxy. + * + * Vendor clouds (Van Essen, Wellntel) are reached through OcotilloAPI so their + * credentials stay server-side. Every method takes the source config rather + * than a bare id, so routing is driven entirely by `transport.basePath`. + */ +export interface SensorSourceClient { + getStatus(source: SensorSourceConfig): Promise + getDevices(source: SensorSourceConfig): Promise + getPending(source: SensorSourceConfig): Promise + triggerIngest( + source: SensorSourceConfig, + batchIds: string[] + ): Promise + getIngestRun(source: SensorSourceConfig, runId: string): Promise +} + +/** + * OcotilloAPI wraps collections as `{ data: [...] }`; single objects are + * returned bare. Tolerate a bare array so a simpler backend shape does not + * break the dashboard. + */ +const unwrapList = (payload: unknown): T[] => { + if (Array.isArray(payload)) return payload as T[] + if (payload && typeof payload === 'object' && 'data' in payload) { + const inner = (payload as { data: unknown }).data + if (Array.isArray(inner)) return inner as T[] + } + return [] +} + +const httpSensorSourceClient: SensorSourceClient = { + getStatus: async (source) => { + const { data } = await fetcher(source.transport.basePath) + return data as SensorSourceStatus + }, + + getDevices: async (source) => { + const { data } = await fetcher(`${source.transport.basePath}/device`) + return unwrapList(data) + }, + + getPending: async (source) => { + const { data } = await fetcher(`${source.transport.basePath}/pending`) + return unwrapList(data) + }, + + triggerIngest: async (source, batchIds) => { + const body: IngestRequest = { batchIds } + const { data } = await axiosCall(`${source.transport.basePath}/ingest`, { + method: 'POST', + data: body, + }) + return data as IngestRun + }, + + getIngestRun: async (source, runId) => { + const { data } = await fetcher( + `${source.transport.basePath}/ingest/${runId}` + ) + return data as IngestRun + }, +} + +/** + * The OcotilloAPI sensor-source endpoints do not exist yet. Until they do, + * `VITE_SENSOR_MOCK=true` serves fixtures generated from the same source + * configs, so the dashboard is fully exercisable. Flipping the flag is the + * only change needed once the backend ships. + */ +export const sensorSourceClient: SensorSourceClient = settings.sensor_mock + ? mockSensorSourceClient + : httpSensorSourceClient + +export { httpSensorSourceClient, mockSensorSourceClient } diff --git a/src/resources/ocotillo.tsx b/src/resources/ocotillo.tsx index 34cc9580..7e454a83 100644 --- a/src/resources/ocotillo.tsx +++ b/src/resources/ocotillo.tsx @@ -1,8 +1,8 @@ import { // Apps, Construction, - DatasetLinked, Contacts, + DatasetLinked, // DynamicFormOutlined, Image, LibraryBooksOutlined, @@ -14,7 +14,7 @@ import { Place, ScaleOutlined, // ScienceOutlined, - // SettingsInputAntenna, + SettingsInputAntenna, // Spa, Timeline, Workspaces, @@ -337,6 +337,14 @@ const ocotillo = [ icon: , }, }, + { + name: 'sensor-dashboard', + list: '/ocotillo/sensor-dashboard', + meta: { + label: 'Sensor Dashboard', + icon: , + }, + }, { name: 'observation', icon: , diff --git a/src/routes/ocotillo.tsx b/src/routes/ocotillo.tsx index 3031e471..5296b25b 100644 --- a/src/routes/ocotillo.tsx +++ b/src/routes/ocotillo.tsx @@ -1,81 +1,80 @@ -import { Route, Routes } from 'react-router' import { ErrorComponent } from '@refinedev/mui' -import { ContactList, ContactShow } from '@/pages/ocotillo/contact' +import { Route, Routes } from 'react-router' +import { ProtectedRoute } from '@/components' import { - SpringList, - SpringCreate, - WellCreate, - WellList, - WellShow, - WellShowPdfPreview, - WellBatchExport, - WellProjectList, - WellProjectShow, - SpringShow, -} from '@/pages/ocotillo/thing' -import { MapView } from '@/pages/ocotillo/map' + AssetCreate, + AssetEdit, + AssetList, + AssetShow, + UnassociatedAssetList, +} from '@/pages/ocotillo/asset' import { CollectionsPage } from '@/pages/ocotillo/collections' +import { ContactList, ContactShow } from '@/pages/ocotillo/contact' +import { GroundwaterLevelForm } from '@/pages/ocotillo/groundwater-level-form/stepperform' +import { + GroupCreate, + GroupEdit, + GroupList, + GroupShow, +} from '@/pages/ocotillo/group' +import { HydrographCorrectionPage } from '@/pages/ocotillo/hydrograph-correction' +import { + CategoryCreate, + CategoryEdit, + LexiconList, + TermCreate, + TermEdit, +} from '@/pages/ocotillo/lexicon' import { - LocationList, LocationCreate, LocationEdit, + LocationList, LocationShow, } from '@/pages/ocotillo/location' +import { MapView } from '@/pages/ocotillo/map' import { - SensorList, - SensorCreate, - SensorEdit, - SensorShow, -} from '@/pages/ocotillo/sensor' + GroundwaterLevelObservationCreate, + GroundwaterLevelObservationList, +} from '@/pages/ocotillo/observation' import { + SampleCreate, + SampleEdit, SampleList, SampleShow, - SampleEdit, - SampleCreate, } from '@/pages/ocotillo/sample' import { - GroundwaterLevelObservationCreate, - GroundwaterLevelObservationList, -} from '@/pages/ocotillo/observation' -import { - GroupCreate, - GroupEdit, - GroupList, - GroupShow, -} from '@/pages/ocotillo/group' + SensorCreate, + SensorEdit, + SensorList, + SensorShow, +} from '@/pages/ocotillo/sensor' +import { SensorDashboardPage } from '@/pages/ocotillo/sensor-dashboard' import { - AssetList, - AssetCreate, - AssetEdit, - AssetShow, - UnassociatedAssetList, -} from '@/pages/ocotillo/asset' + SpringCreate, + SpringList, + SpringShow, + WellBatchExport, + WellCreate, + WellList, + WellProjectList, + WellProjectShow, + WellShow, + WellShowPdfPreview, +} from '@/pages/ocotillo/thing' import { - ThingIdLinkList, ThingIdLinkCreate, ThingIdLinkEdit, + ThingIdLinkList, ThingIdLinkShow, } from '@/pages/ocotillo/thing-id-link' - -import { - TermCreate, - TermEdit, - CategoryCreate, - CategoryEdit, -} from '@/pages/ocotillo/lexicon' - -import { GroundwaterLevelForm } from '@/pages/ocotillo/groundwater-level-form/stepperform' -import { WellInventoryForm } from '@/pages/ocotillo/well-inventory-form' -import { LexiconList } from '@/pages/ocotillo/lexicon' import { WaterChemistryApp } from '@/pages/ocotillo/water-chemistry-app' -import { HydrographCorrectionPage } from '@/pages/ocotillo/hydrograph-correction' +import { WellInventoryForm } from '@/pages/ocotillo/well-inventory-form' import { WellScreenCreate, WellScreenEdit, WellScreenList, WellScreenShow, } from '@/pages/ocotillo/well-screen' -import { ProtectedRoute } from '@/components' export const OcotilloRoutes = () => { return ( @@ -249,6 +248,16 @@ export const OcotilloRoutes = () => { } /> + + + + + } + /> + {/* Forms */} } /> diff --git a/src/settings.tsx b/src/settings.tsx index a98d9b81..0ee811d3 100644 --- a/src/settings.tsx +++ b/src/settings.tsx @@ -35,6 +35,15 @@ export const settings = { nmbgmr_geothermal_api_url: import.meta.env.VITE_NMBGMR_GEOTHERMAL_API_URL || 'http://localhost:8008', mapboxToken: import.meta.env.VITE_MAPBOX_TOKEN || '', + + /** + * Serve the sensor dashboard from generated fixtures instead of the + * OcotilloAPI sensor-source endpoints. Defaults on under test so suites do + * not depend on backend routes that do not exist yet. + */ + sensor_mock: + import.meta.env.VITE_SENSOR_MOCK === 'true' || isVitest || isTest, + fief: { baseURL: import.meta.env.VITE_FIEF_BASE_URL || diff --git a/src/test/utils/accessControl.test.ts b/src/test/utils/accessControl.test.ts index db9a04af..32df3b11 100644 --- a/src/test/utils/accessControl.test.ts +++ b/src/test/utils/accessControl.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' +import { resources } from '@/resources' import { canAccessResource, getAccessCapabilities, isResourceListAdminOnly, normalizeAccessControlGroups, } from '@/utils/accessControl' -import { resources } from '@/resources' type Action = 'list' | 'show' | 'create' | 'edit' | 'delete' | 'manage' type Scenario = { @@ -42,6 +42,7 @@ const expectedRegisteredRoutableResources = [ 'ocotillo.lexicon', 'ocotillo.location', 'ocotillo.map', + 'ocotillo.sensor-dashboard', 'ocotillo.thing-well', 'ocotillo.thing-well-batch-export', 'ocotillo.thing-well-pdf-preview', @@ -61,6 +62,7 @@ const expectedAccessByScenario: Scenario[] = [ 'ocotillo.collections', 'ocotillo.map', 'ocotillo.contact', + 'ocotillo.sensor-dashboard', 'ocotillo.thing-well', 'ocotillo.thing-well-batch-export', 'ocotillo.thing-well-projects', @@ -74,6 +76,7 @@ const expectedAccessByScenario: Scenario[] = [ 'ocotillo.map', 'ocotillo.thing-well', 'ocotillo.contact', + 'ocotillo.sensor-dashboard', 'ocotillo.thing-well-batch-export', 'ocotillo.thing-well-projects', ], @@ -106,6 +109,7 @@ const expectedAccessByScenario: Scenario[] = [ 'ocotillo.map', 'ocotillo.thing-well', 'ocotillo.contact', + 'ocotillo.sensor-dashboard', 'ocotillo.thing-well-batch-export', 'ocotillo.thing-well-projects', ], diff --git a/src/utils/accessControl.ts b/src/utils/accessControl.ts index fecc915e..2def3556 100644 --- a/src/utils/accessControl.ts +++ b/src/utils/accessControl.ts @@ -90,6 +90,14 @@ const resourcePolicies: Record = { manage: adminRoles, }, 'ocotillo.hydrograph-correction': { list: adminRoles, show: adminRoles }, + // Read-only health view for viewers; triggering an ingestion run writes to + // the observation tables, so that stays with editors and admins. + 'ocotillo.sensor-dashboard': { + list: viewerRoles, + show: viewerRoles, + create: editorRoles, + manage: editorRoles, + }, 'ocotillo.thing-well-pdf-preview': { list: adminRoles, show: adminRoles }, 'ocotillo.thing-well-batch-export': { list: viewerRoles, show: viewerRoles }, 'ocotillo.thing-well-projects': { list: viewerRoles, show: viewerRoles }, From cd4f1a27ca402f85405798873a21705a19a823d4 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 30 Jul 2026 17:33:29 -0700 Subject: [PATCH 2/2] Use demo sensor data on preview deploys Preview deploys have no sensor backend to talk to, so the dashboard now defaults to generated fixtures there, keyed off the VITE_APP_ENV=preview that CD_preview.yml already sets. Staging and production build with their own VITE_APP_ENV and are unaffected. The fallback deliberately fails towards real data: a build that forgets to set VITE_APP_ENV gets mocking off, so it surfaces an honest error rather than convincing fake sensor readings. VITE_SENSOR_MOCK still overrides in either direction, and a blank value (which is what Docker sets for an unpassed ARG) is treated as "no override" rather than as an explicit opt-out. Because getting this wrong in the wrong direction would put fabricated readings on a production dashboard, the resolution rule is pinned by tests covering preview, staging, production, missing VITE_APP_ENV, and both override directions. Also strengthens the in-page notice: it now reads as a warning that every device and reading is generated and that triggering a run writes nothing, rather than an aside aimed at developers. --- Dockerfile | 4 + src/pages/ocotillo/sensor-dashboard/index.tsx | 9 ++- src/settings.tsx | 24 ++++-- src/test/config/sensorMock.test.ts | 80 +++++++++++++++++++ 4 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 src/test/config/sensorMock.test.ts diff --git a/Dockerfile b/Dockerfile index 25348105..6a35dc71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,9 @@ ARG VITE_PUBLIC_POSTHOG_HOST ARG VITE_APP_ENV=preview ARG VITE_APP_VERSION=unknown ARG VITE_TEST_AUTH +# Left unset by default: the sensor dashboard falls back to mocking on +# VITE_APP_ENV=preview. Set explicitly to force it on or off. +ARG VITE_SENSOR_MOCK ENV NODE_OPTIONS=--max-old-space-size=4096 ENV VITE_DISABLE_SOURCEMAP=true @@ -28,6 +31,7 @@ ENV VITE_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY ENV VITE_POSTHOG_HOST=$VITE_PUBLIC_POSTHOG_HOST ENV VITE_APP_ENV=$VITE_APP_ENV ENV VITE_APP_VERSION=$VITE_APP_VERSION +ENV VITE_SENSOR_MOCK=$VITE_SENSOR_MOCK RUN npm run build:ci -- --mode $MODE diff --git a/src/pages/ocotillo/sensor-dashboard/index.tsx b/src/pages/ocotillo/sensor-dashboard/index.tsx index 425e00aa..35cf2617 100644 --- a/src/pages/ocotillo/sensor-dashboard/index.tsx +++ b/src/pages/ocotillo/sensor-dashboard/index.tsx @@ -61,10 +61,11 @@ export const SensorDashboardPage = () => { {settings.sensor_mock && ( - - Showing generated fixture data. The OcotilloAPI sensor-source - endpoints are not implemented yet -- set{' '} - VITE_SENSOR_MOCK=false once they are. + + Demo data. The OcotilloAPI sensor-source endpoints do + not exist yet, so every device, reading, and pending batch on this + page is generated -- no real sensor is being described. Triggering an + ingestion run does not write anything. )} diff --git a/src/settings.tsx b/src/settings.tsx index 0ee811d3..a94ddb22 100644 --- a/src/settings.tsx +++ b/src/settings.tsx @@ -12,6 +12,22 @@ const isTest = getNodeEnv('NODE_ENV') === 'test' || import.meta.env.MODE === 'test' || import.meta.env.NODE_ENV === 'test' +const isPreview = import.meta.env.VITE_APP_ENV === 'preview' + +/** + * Whether the sensor dashboard runs on generated fixtures instead of the + * OcotilloAPI sensor-source endpoints (which do not exist yet). + * + * On by default wherever there is no real backend to talk to -- preview + * deploys and test runs -- and off everywhere else, so a build that forgets + * to set VITE_APP_ENV shows real data or an honest error rather than + * convincing fake numbers. VITE_SENSOR_MOCK overrides in either direction. + */ +const sensorMockOverride = import.meta.env.VITE_SENSOR_MOCK +const useSensorMock = + sensorMockOverride === 'true' || sensorMockOverride === 'false' + ? sensorMockOverride === 'true' + : isPreview || isVitest || isTest export const settings = { rowHeight: 27, @@ -36,13 +52,7 @@ export const settings = { import.meta.env.VITE_NMBGMR_GEOTHERMAL_API_URL || 'http://localhost:8008', mapboxToken: import.meta.env.VITE_MAPBOX_TOKEN || '', - /** - * Serve the sensor dashboard from generated fixtures instead of the - * OcotilloAPI sensor-source endpoints. Defaults on under test so suites do - * not depend on backend routes that do not exist yet. - */ - sensor_mock: - import.meta.env.VITE_SENSOR_MOCK === 'true' || isVitest || isTest, + sensor_mock: useSensorMock, fief: { baseURL: diff --git a/src/test/config/sensorMock.test.ts b/src/test/config/sensorMock.test.ts new file mode 100644 index 00000000..55dd697c --- /dev/null +++ b/src/test/config/sensorMock.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * `settings.sensor_mock` decides whether the sensor dashboard shows generated + * fixtures or real OcotilloAPI data. Getting it wrong in the wrong direction + * means convincing fake sensor readings on a production dashboard, so the + * resolution rule is pinned here. + * + * The setting is resolved at module load, so each case stubs the env and + * re-imports. + */ + +const loadSensorMock = async () => { + vi.resetModules() + const { settings } = await import('@/settings') + return settings.sensor_mock +} + +describe('settings.sensor_mock', () => { + beforeEach(() => { + // The suite runs under vitest, which would otherwise force mocking on and + // mask every case below. + vi.stubEnv('VITEST', '') + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('MODE', 'production') + vi.stubEnv('VITE_SENSOR_MOCK', '') + vi.stubEnv('VITE_APP_ENV', '') + }) + + afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() + }) + + it('is on for preview deploys, which have no sensor backend', async () => { + vi.stubEnv('VITE_APP_ENV', 'preview') + expect(await loadSensorMock()).toBe(true) + }) + + it('is off for staging', async () => { + vi.stubEnv('VITE_APP_ENV', 'staging') + expect(await loadSensorMock()).toBe(false) + }) + + it('is off for production', async () => { + vi.stubEnv('VITE_APP_ENV', 'production') + expect(await loadSensorMock()).toBe(false) + }) + + it('is off when VITE_APP_ENV is missing', async () => { + // Fail towards real data: a build that forgets the flag should surface an + // honest error, not fabricated readings. + expect(await loadSensorMock()).toBe(false) + }) + + it('is on under test so suites do not need a backend', async () => { + vi.stubEnv('VITEST', 'true') + expect(await loadSensorMock()).toBe(true) + }) + + it('lets VITE_SENSOR_MOCK force it on outside preview', async () => { + vi.stubEnv('VITE_APP_ENV', 'staging') + vi.stubEnv('VITE_SENSOR_MOCK', 'true') + expect(await loadSensorMock()).toBe(true) + }) + + it('lets VITE_SENSOR_MOCK force it off inside preview', async () => { + vi.stubEnv('VITE_APP_ENV', 'preview') + vi.stubEnv('VITE_SENSOR_MOCK', 'false') + expect(await loadSensorMock()).toBe(false) + }) + + it('ignores a blank override rather than reading it as false', async () => { + // Docker sets unpassed ARGs to an empty string, which must not be + // mistaken for an explicit opt-out. + vi.stubEnv('VITE_APP_ENV', 'preview') + vi.stubEnv('VITE_SENSOR_MOCK', '') + expect(await loadSensorMock()).toBe(true) + }) +})