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} + + + + + + + + + + + {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'}. + + + + + + {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 },