Fix validation, add sensor last contact field, add alert status formatting

This commit is contained in:
Jan Zípek 2024-04-01 17:03:45 +02:00
parent e1b0d16ce6
commit e6438ad0f1
22 changed files with 215 additions and 75 deletions

View File

@ -1,5 +1,12 @@
import { request } from './request'
export enum AlertStatuses {
OK = 'ok',
ALERT_PENDING = 'alert_pending',
ALERTING = 'alerting',
OK_PENDING = 'ok_pending',
}
export type AlertInfo = {
id: number
name: string
@ -8,8 +15,8 @@ export type AlertInfo = {
customMessage: string
customResolvedMessage: string
triggerInterval: number
lastStatus: string
lastStatusAt: string
lastStatus: AlertStatuses
lastStatusAt: number
}
export const getAlerts = () => request<AlertInfo[]>('/api/alerts')

View File

@ -25,7 +25,7 @@ export const updateContactPoint = ({ id, ...body }: ContactPointInfo) =>
})
export const deleteContactPoint = (id: number) =>
request<ContactPointInfo>(
request<void, 'void'>(
`/api/contact-points/${id}`,
{ method: 'DELETE' },
'void'

View File

@ -43,7 +43,7 @@ export const updateDashboard = ({
})
export const deleteDashboard = (id: number) =>
request<DashboardInfo>(
request<void, 'void'>(
`/api/dashboards/${id}`,
{
method: 'DELETE',

View File

@ -4,6 +4,7 @@ export type SensorInfo = {
id: number
name: string
authKey: string
lastContactAt?: number
}
export const getSensors = () => request<SensorInfo[]>('/api/sensors')
@ -23,4 +24,4 @@ export const updateSensor = ({ id, ...body }: { id: number; name: string }) =>
})
export const deleteSensor = (id: number) =>
request<SensorInfo>(`/api/sensors/${id}`, { method: 'DELETE' }, 'void')
request<void, 'void'>(`/api/sensors/${id}`, { method: 'DELETE' }, 'void')

View File

@ -5,4 +5,27 @@
.contact-points {
margin-bottom: 2rem;
}
.alert-status {
text-transform: uppercase;
padding: 0.2rem 0.4rem;
border-radius: var(--border-radius);
display: inline-flex;
&.status-ok {
background-color: #016101;
color: #fff;
}
&.status-alert_pending,
&.status-ok_pending {
background-color: #ff8c00;
color: #fff;
}
&.status-alerting {
background-color: #d22424;
color: #fff;
}
}
}

View File

@ -0,0 +1,25 @@
import { AlertInfo, AlertStatuses } from '@/api/alerts'
import { cn } from '@/utils/cn'
import { formatDateTime } from '@/utils/formatDateTime'
type Props = {
alert: Pick<AlertInfo, 'lastStatus' | 'lastStatusAt'>
}
const statusToStr = {
[AlertStatuses.OK]: 'OK',
[AlertStatuses.ALERT_PENDING]: 'Alert pending',
[AlertStatuses.ALERTING]: 'Alerting',
[AlertStatuses.OK_PENDING]: 'OK pending',
} as const
export const AlertStatus = ({ alert }: Props) => {
return (
<div
className={cn('alert-status', `status-${alert.lastStatus}`)}
title={`Last update at ${formatDateTime(alert.lastStatusAt)}`}
>
{statusToStr[alert.lastStatus]}
</div>
)
}

View File

@ -6,6 +6,7 @@ import { useState } from 'preact/hooks'
import { useMutation, useQuery } from 'react-query'
import { AlertFormModal } from './components/AlertFormModal'
import { NoAlerts } from '../NoAlerts'
import { AlertStatus } from '../AlertStatus'
export const AlertsTable = () => {
const alerts = useQuery('/alerts', getAlerts, {
@ -43,8 +44,8 @@ export const AlertsTable = () => {
{
key: 'lastStatus',
title: 'Last status',
render: (c) => c.lastStatus,
width: '10rem',
render: (c) => <AlertStatus alert={c} />,
width: '6rem',
},
{
key: 'actions',

View File

@ -53,7 +53,10 @@ export const ContactPointsTable = () => {
className: 'actions',
render: (c) => (
<div>
<button onClick={() => setShowDelete(c)} className="remove">
<button
onClick={() => setShowDelete(c)}
className="danger-variant"
>
<TrashIcon /> Delete
</button>
<button onClick={() => setEdited(c)}>

View File

@ -8,6 +8,7 @@ import { SensorFormModal } from './components/SensorFormModal'
import { useConfirmModal } from '@/contexts/ConfirmModalsContext'
import { SensorDetailModal } from './components/SensorDetailModal'
import { NoSensors } from './components/NoSensors'
import { formatDateTime } from '@/utils/formatDateTime'
export const SensorsPage = () => {
const sensors = useQuery(['/sensors'], getSensors)
@ -43,6 +44,13 @@ export const SensorsPage = () => {
columns={[
{ key: 'type', title: 'ID', render: (c) => c.id, width: '1rem' },
{ key: 'name', title: 'Name', render: (c) => c.name, scale: 1 },
{
key: 'lastContact',
title: 'Last Contact At',
render: (c) =>
c.lastContactAt ? formatDateTime(c.lastContactAt) : 'Never',
width: '10rem',
},
{
key: 'actions',
title: 'Actions',

View File

@ -0,0 +1,11 @@
export const formatDateTime = (unixInSeconds: number) => {
const date = new Date(unixInSeconds * 1000)
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
})
}

View File

@ -0,0 +1 @@
ALTER TABLE sensors ADD COLUMN last_contact_at INTEGER;

View File

@ -1,7 +1,8 @@
package models
type SensorItem struct {
Id int64 `json:"id"`
Name string `json:"name"`
AuthKey string `json:"authKey"`
Id int64 `json:"id" db:"id"`
Name string `json:"name" db:"name"`
AuthKey string `json:"authKey" db:"auth_key"`
LastContactAt *int64 `json:"lastContactAt" db:"last_contact_at"`
}

View File

@ -12,8 +12,8 @@ type postAndPutAlertsBody struct {
Name string `json:"name" binding:"required"`
Condition string `json:"condition" binding:"required"`
TriggerInterval int64 `json:"triggerInterval" binding:"required"`
CustomMessage string `json:"customMessage" binding:"required"`
CustomResolvedMessage string `json:"customResolvedMessage" binding:"required"`
CustomMessage string `json:"customMessage"`
CustomResolvedMessage string `json:"customResolvedMessage"`
}
func GetAlerts(s *app.Server) gin.HandlerFunc {
@ -32,7 +32,9 @@ func GetAlerts(s *app.Server) gin.HandlerFunc {
func PostAlerts(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
body := postAndPutAlertsBody{}
bindJSONBodyOrAbort(c, &body)
if err := bindJSONBodyOrAbort(c, &body); err != nil {
return
}
alert, err := s.Services.Alerts.Create(body.ContactPointId, body.Name, body.Condition, body.TriggerInterval, body.CustomMessage, body.CustomResolvedMessage)
@ -47,10 +49,15 @@ func PostAlerts(s *app.Server) gin.HandlerFunc {
func PutAlert(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
alertId := getIntParamOrAbort(c, "alertId")
alertId, err := getIntParamOrAbort(c, "alertId")
if err != nil {
return
}
body := postAndPutAlertsBody{}
bindJSONBodyOrAbort(c, &body)
if err := bindJSONBodyOrAbort(c, &body); err != nil {
return
}
alert, err := s.Services.Alerts.Update(alertId, body.ContactPointId, body.Name, body.Condition, body.TriggerInterval, body.CustomMessage, body.CustomResolvedMessage)
@ -65,7 +72,10 @@ func PutAlert(s *app.Server) gin.HandlerFunc {
func GetAlert(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
alertId := getIntParamOrAbort(c, "alertId")
alertId, err := getIntParamOrAbort(c, "alertId")
if err != nil {
return
}
alert, err := s.Services.Alerts.GetById(alertId)
@ -80,7 +90,10 @@ func GetAlert(s *app.Server) gin.HandlerFunc {
func DeleteAlert(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
alertId := getIntParamOrAbort(c, "alertId")
alertId, err := getIntParamOrAbort(c, "alertId")
if err != nil {
return
}
if err := s.Services.Alerts.DeleteById(alertId); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)

View File

@ -15,7 +15,9 @@ type postLoginBody struct {
func Login(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
body := postLoginBody{}
bindJSONBodyOrAbort(c, &body)
if err := bindJSONBodyOrAbort(c, &body); err != nil {
return
}
if body.Password != s.Config.AuthPassword || body.Username != s.Config.AuthUsername {
c.AbortWithStatus(401)

View File

@ -34,8 +34,10 @@ func GetContactPoints(s *app.Server) gin.HandlerFunc {
func PostContactPoints(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
body := postOrPutContactPointsBody{}
bindJSONBodyOrAbort(c, &body)
if err := bindJSONBodyOrAbort(c, &body); err != nil {
return
}
contactPoint, err := s.Services.ContactPoints.Create(body.Name, body.Type, body.TypeConfig)
if err != nil {
@ -49,10 +51,15 @@ func PostContactPoints(s *app.Server) gin.HandlerFunc {
func PutContactPoint(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
contactPointId := getIntParamOrAbort(c, "contactPointId")
contactPointId, err := getIntParamOrAbort(c, "contactPointId")
if err != nil {
return
}
body := postOrPutContactPointsBody{}
bindJSONBodyOrAbort(c, &body)
if err := bindJSONBodyOrAbort(c, &body); err != nil {
return
}
contactPoint, err := s.Services.ContactPoints.Update(contactPointId, body.Name, body.Type, body.TypeConfig)
@ -67,7 +74,10 @@ func PutContactPoint(s *app.Server) gin.HandlerFunc {
func GetContactPoint(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
contactPointId := getIntParamOrAbort(c, "contactPointId")
contactPointId, err := getIntParamOrAbort(c, "contactPointId")
if err != nil {
return
}
contactPoint, err := s.Services.ContactPoints.GetById(contactPointId)
@ -82,9 +92,12 @@ func GetContactPoint(s *app.Server) gin.HandlerFunc {
func DeleteContactPoint(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
contactPointId := getIntParamOrAbort(c, "contactPointId")
contactPointId, err := getIntParamOrAbort(c, "contactPointId")
if err != nil {
return
}
err := s.Services.ContactPoints.Delete(contactPointId)
err = s.Services.ContactPoints.Delete(contactPointId)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
@ -98,7 +111,9 @@ func DeleteContactPoint(s *app.Server) gin.HandlerFunc {
func TestContactPoint(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
body := testContactPointBody{}
bindJSONBodyOrAbort(c, &body)
if err := bindJSONBodyOrAbort(c, &body); err != nil {
return
}
err := s.Services.ContactPoints.Test(body.Type, body.TypeConfig)

View File

@ -33,7 +33,10 @@ func GetDashboards(s *app.Server) gin.HandlerFunc {
func GetDashboardById(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
id := getIntParamOrAbort(c, "id")
id, err := getIntParamOrAbort(c, "id")
if err != nil {
return
}
item, err := s.Services.Dashboards.GetById(id)
@ -54,7 +57,10 @@ func GetDashboardById(s *app.Server) gin.HandlerFunc {
func PostDashboard(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
body := postDashboardBody{}
bindJSONBodyOrAbort(c, &body)
if err := bindJSONBodyOrAbort(c, &body); err != nil {
return
}
item, err := s.Services.Dashboards.Create(body.Name, body.Contents)
@ -69,11 +75,16 @@ func PostDashboard(s *app.Server) gin.HandlerFunc {
func PutDashboard(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
id := getIntParamOrAbort(c, "id")
id, err := getIntParamOrAbort(c, "id")
if err != nil {
return
}
body := putDashboardBody{}
bindJSONBodyOrAbort(c, &body)
if err := bindJSONBodyOrAbort(c, &body); err != nil {
return
}
item, err := s.Services.Dashboards.Update(id, body.Name, body.Contents)
if err != nil {
@ -87,9 +98,12 @@ func PutDashboard(s *app.Server) gin.HandlerFunc {
func DeleteDashboard(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
id := getIntParamOrAbort(c, "id")
id, err := getIntParamOrAbort(c, "id")
if err != nil {
return
}
err := s.Services.Dashboards.Delete(id)
err = s.Services.Dashboards.Delete(id)
if err != nil {
c.AbortWithError(500, err)

View File

@ -17,7 +17,9 @@ func PutSensorConfig(s *app.Server) gin.HandlerFunc {
sensor := c.Param("sensor")
key := c.Param("key")
bindJSONBodyOrAbort(c, &configValue)
if err := bindJSONBodyOrAbort(c, &configValue); err != nil {
return
}
if err := s.Services.SensorConfig.SetValue(sensor, key, configValue.Value); err != nil {
c.AbortWithError(500, err)

View File

@ -25,9 +25,14 @@ func PostSensorValues(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
var newValue postSensorValueBody
bindJSONBodyOrAbort(c, &newValue)
if err := bindJSONBodyOrAbort(c, &newValue); err != nil {
return
}
sensorId := getIntParamOrAbort(c, "sensor")
sensorId, err := getIntParamOrAbort(c, "sensor")
if err != nil {
return
}
if _, err := s.Services.SensorValues.Push(sensorId, newValue.Value); err != nil {
c.AbortWithError(400, err)
@ -42,7 +47,10 @@ func GetSensorValues(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
var query getSensorValuesQuery
sensorId := getIntParamOrAbort(c, "sensor")
sensorId, err := getIntParamOrAbort(c, "sensor")
if err != nil {
return
}
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@ -63,7 +71,10 @@ func GetSensorLatestValue(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
var query getLatestSensorValueQuery
sensorId := getIntParamOrAbort(c, "sensor")
sensorId, err := getIntParamOrAbort(c, "sensor")
if err != nil {
return
}
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})

View File

@ -29,7 +29,9 @@ func GetSensors(s *app.Server) gin.HandlerFunc {
func PostSensors(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
body := postOrPutSensorsBody{}
bindJSONBodyOrAbort(c, &body)
if err := bindJSONBodyOrAbort(c, &body); err != nil {
return
}
sensor, err := s.Services.Sensors.Create(body.Name)
@ -44,10 +46,15 @@ func PostSensors(s *app.Server) gin.HandlerFunc {
func PutSensor(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
sensorId := getIntParamOrAbort(c, "sensor")
sensorId, err := getIntParamOrAbort(c, "sensor")
if err != nil {
return
}
body := postOrPutSensorsBody{}
bindJSONBodyOrAbort(c, &body)
if err := bindJSONBodyOrAbort(c, &body); err != nil {
return
}
sensor, err := s.Services.Sensors.Update(sensorId, body.Name)
@ -62,10 +69,15 @@ func PutSensor(s *app.Server) gin.HandlerFunc {
func GetSensor(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
sensorId := getIntParamOrAbort(c, "sensor")
sensorId, err := getIntParamOrAbort(c, "sensor")
if err != nil {
return
}
body := postOrPutSensorsBody{}
bindJSONBodyOrAbort(c, &body)
if err := bindJSONBodyOrAbort(c, &body); err != nil {
return
}
sensor, err := s.Services.Sensors.GetById(sensorId)
@ -80,9 +92,12 @@ func GetSensor(s *app.Server) gin.HandlerFunc {
func DeleteSensor(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
sensorId := getIntParamOrAbort(c, "sensor")
sensorId, err := getIntParamOrAbort(c, "sensor")
if err != nil {
return
}
err := s.Services.Sensors.DeleteById(sensorId)
err = s.Services.Sensors.DeleteById(sensorId)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)

View File

@ -6,19 +6,24 @@ import (
"github.com/gin-gonic/gin"
)
func getIntParamOrAbort(c *gin.Context, key string) int64 {
func getIntParamOrAbort(c *gin.Context, key string) (int64, error) {
value := c.Param(key)
val, err := strconv.ParseInt(value, 10, 64)
if err != nil {
c.AbortWithStatusJSON(400, gin.H{"error": "Invalid " + key})
return 0, err
}
return val
return val, nil
}
func bindJSONBodyOrAbort(c *gin.Context, body interface{}) {
func bindJSONBodyOrAbort(c *gin.Context, body interface{}) error {
if err := c.ShouldBindJSON(body); err != nil {
c.AbortWithStatusJSON(400, gin.H{"error": err.Error()})
return err
}
return nil
}

View File

@ -19,7 +19,13 @@ func (s *SensorValuesService) Push(sensorId int64, value float64) (int64, error)
res, err := s.ctx.DB.Exec("INSERT INTO sensor_values (timestamp, sensor_id, value) VALUES (?, ?, ?)", time.Now().Unix(), sensorId, value)
if err != nil {
return 0, err
return 0, fmt.Errorf("failed to insert sensor value: %v", err)
}
_, err = s.ctx.DB.Exec("UPDATE sensors SET last_contact_at = ? WHERE id = ?", time.Now().Unix(), sensorId)
if err != nil {
return 0, fmt.Errorf("failed to update last_contact_at: %v", err)
}
return res.LastInsertId()

View File

@ -3,7 +3,6 @@ package services
import (
"basic-sensor-receiver/models"
"crypto/rand"
"database/sql"
"math/big"
)
@ -12,32 +11,10 @@ type SensorsService struct {
}
func (s *SensorsService) GetList() ([]models.SensorItem, error) {
sensors := make([]models.SensorItem, 0)
sensors := []models.SensorItem{}
rows, err := s.ctx.DB.Query("SELECT id, name, auth_key FROM sensors")
err := s.ctx.DB.Select(&sensors, "SELECT id, name, auth_key, last_contact_at FROM sensors")
if err != nil {
if err == sql.ErrNoRows {
return sensors, nil
}
return nil, err
}
defer rows.Close()
for rows.Next() {
item := models.SensorItem{}
err := rows.Scan(&item.Id, &item.Name, &item.AuthKey)
if err != nil {
return nil, err
}
sensors = append(sensors, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
@ -75,9 +52,8 @@ func (s *SensorsService) Create(name string) (*models.SensorItem, error) {
func (s *SensorsService) GetById(id int64) (*models.SensorItem, error) {
item := models.SensorItem{}
row := s.ctx.DB.QueryRow("SELECT id, name, auth_key FROM sensors WHERE id = ?", id)
err := s.ctx.DB.Get(&item, "SELECT id, name, auth_key, last_contact_at FROM sensors WHERE id = $1", id)
err := row.Scan(&item.Id, &item.Name, &item.AuthKey)
if err != nil {
return nil, err
}