Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.development.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .env.devserver.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
101 changes: 101 additions & 0 deletions src/components/SensorDashboard/AlertList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Alert severity="success" variant="outlined">
No active alerts -- every configured sensor is reporting within its
thresholds.
</Alert>
)
}

// `alerts` arrives worst-first, so taking the first N per device keeps each
// device's most severe findings.
const perDevice = new Map<string, number>()
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 (
<Card variant="outlined">
<CardContent sx={{ pb: 0 }}>
<Typography variant="h6">Active alerts</Typography>
</CardContent>
<List dense disablePadding>
{shown.map((alert, index) => (
<ListItem key={`${alert.sourceId}-${alert.deviceId}-${alert.ruleId}`}>
<Stack sx={{ width: '100%' }} spacing={0.5}>
{index > 0 && <Divider sx={{ mb: 1 }} />}
<Stack direction="row" spacing={1} alignItems="center">
<Chip
size="small"
color={SEVERITY_COLOR[alert.severity]}
label={SEVERITY_LABEL[alert.severity]}
/>
<Typography variant="subtitle2">{alert.deviceLabel}</Typography>
{/* Device labels are only unique within a source -- two
vendors can both have a "MG-007", so name the source. */}
<Typography variant="caption" color="text.secondary">
{getSensorSource(alert.sourceId)?.label ?? alert.sourceId}
</Typography>
</Stack>
<ListItemText
primary={alert.label}
secondary={alert.detail}
slotProps={{
primary: { variant: 'body2' },
secondary: { variant: 'caption' },
}}
/>
</Stack>
</ListItem>
))}
</List>
{alerts.length > shown.length && (
<CardContent sx={{ pt: 0 }}>
<Typography variant="caption" color="text.secondary">
+{alerts.length - shown.length} more -- see the device tables below.
</Typography>
</CardContent>
)}
</Card>
)
}
68 changes: 68 additions & 0 deletions src/components/SensorDashboard/AlertSummaryTiles.tsx
Original file line number Diff line number Diff line change
@@ -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<AlertSeverity, string> = {
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) => (
<Box
sx={{
display: 'grid',
gap: 2,
gridTemplateColumns: { xs: '1fr', sm: 'repeat(3, 1fr)' },
}}
>
{SEVERITY_DISPLAY_ORDER.map((severity) => {
const color = SEVERITY_COLOR[severity]
const isSelected = selected === severity
return (
<Card
key={severity}
variant="outlined"
sx={{
borderColor: isSelected ? `${color}.main` : 'divider',
borderWidth: isSelected ? 2 : 1,
}}
>
<CardActionArea
onClick={() => onSelect(isSelected ? null : severity)}
sx={{ p: 2 }}
>
<Typography variant="overline" color="text.secondary">
{SEVERITY_LABEL[severity]}
</Typography>
<Typography
variant="h3"
sx={{ color: `${color}.main`, lineHeight: 1.1 }}
>
{summary[severity]}
</Typography>
<Typography variant="body2" color="text.secondary">
{CAPTION[severity]}
</Typography>
</CardActionArea>
</Card>
)
})}
</Box>
)
167 changes: 167 additions & 0 deletions src/components/SensorDashboard/DeviceGrid.tsx
Original file line number Diff line number Diff line change
@@ -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 <Typography variant="body2">--</Typography>
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return <Typography variant="body2">--</Typography>
}
return (
<Tooltip title={formatAppDateTime(value)}>
<Typography variant="body2">
{formatAge(minutesSince(parsed, new Date()))} ago
</Typography>
</Tooltip>
)
}

/**
* 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<Row[]>(() => {
const byDevice = new Map<string, SensorAlert[]>()
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<GridColDef<Row>[]>(() => {
const base: GridColDef<Row>[] = [
{
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 }) => (
<Tooltip
title={
row.deviceAlerts.length
? row.deviceAlerts
.map((a) => `${a.label}: ${a.detail}`)
.join('\n')
: 'No alerts'
}
>
<Chip
size="small"
color={SEVERITY_COLOR[row.severity]}
variant={row.severity === 'ok' ? 'outlined' : 'filled'}
label={
row.deviceAlerts.length
? `${SEVERITY_LABEL[row.severity]} (${row.deviceAlerts.length})`
: SEVERITY_LABEL[row.severity]
}
/>
</Tooltip>
),
},
{ 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<Row>[] = 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 (
<Stack spacing={1}>
<DataGrid
rows={rows}
columns={columns}
loading={isLoading}
// The shared `settings.rowHeight` (27px) is tuned for plain text rows
// and crops the status chips, so this grid sets its own.
rowHeight={44}
columnHeaderHeight={44}
disableRowSelectionOnClick
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
sorting: { sortModel: [{ field: 'severity', sort: 'desc' }] },
}}
pageSizeOptions={[25, 50, 100]}
sx={{ minHeight: 240 }}
/>
</Stack>
)
}
Loading
Loading