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/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/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}
+
+
+
+
+
+ }
+ disabled={
+ !canIngest ||
+ !source.config.ingestion.allowManualTrigger ||
+ selectedIds.length === 0 ||
+ isSubmitting
+ }
+ onClick={handleIngest}
+ >
+ {selectedIds.length === 0
+ ? 'Select batches to ingest'
+ : `Ingest ${selectedIds.length} batch${
+ selectedIds.length === 1 ? '' : 'es'
+ } (${totalRecords} records)`}
+
+
+
+
+
+ {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..35cf2617
--- /dev/null
+++ b/src/pages/ocotillo/sensor-dashboard/index.tsx
@@ -0,0 +1,133 @@
+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'}.
+
+
+ }
+ onClick={() => void refetch()}
+ disabled={isLoading}
+ >
+ Refresh
+
+
+
+ {settings.sensor_mock && (
+
+ 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.
+
+ )}
+
+ 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..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,
@@ -35,6 +51,9 @@ 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 || '',
+
+ sensor_mock: useSensorMock,
+
fief: {
baseURL:
import.meta.env.VITE_FIEF_BASE_URL ||
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)
+ })
+})
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 },