From 7e9625ecf38cf2d758b8eb4c0825156c20bf0503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Z=C3=ADpek?= Date: Sun, 31 Mar 2024 09:50:09 +0200 Subject: [PATCH] Finish alerts implementation --- basic-sensor-receiver.code-workspace | 6 +- client/.vscode/settings.json | 6 + client/src/api/alerts.ts | 11 +- client/src/api/contactPoints.ts | 24 +- client/src/assets/components/_box.scss | 4 + client/src/assets/components/_data-table.scss | 35 ++ client/src/assets/pages/_alerts-page.scss | 13 + client/src/assets/style.scss | 24 +- client/src/components/DataTable.tsx | 60 +++ client/src/pages/alerts/AlertsPage.tsx | 13 +- .../components/AlertsTable/AlertsTable.tsx | 79 ++++ .../AlertsTable/components/AlertFormModal.tsx | 233 +++++++++++ .../ContactPointsTable/ContactPointsTable.tsx | 83 ++++ .../components/ContactPointFormModal.tsx | 156 +++++++ client/src/utils/hooks/useForm.tsx | 391 ++++++++++++++++++ client/src/utils/tryParseAlertCondition.ts | 39 ++ .../src/utils/tryParseContactPointConfig.ts | 30 ++ client/src/utils/tryParseJson.ts | 7 + server/.gitignore | 3 +- server/app/alerts.go | 11 +- server/integrations/telegram.go | 7 +- server/main.go | 1 + server/models/alerts.go | 6 +- server/routes/alerts.go | 6 +- server/routes/contact_points.go | 27 +- server/services/alerts_service.go | 98 +++-- server/services/contact_points_service.go | 7 +- 27 files changed, 1290 insertions(+), 90 deletions(-) create mode 100644 client/.vscode/settings.json create mode 100644 client/src/assets/components/_data-table.scss create mode 100644 client/src/assets/pages/_alerts-page.scss create mode 100644 client/src/components/DataTable.tsx create mode 100644 client/src/pages/alerts/components/AlertsTable/AlertsTable.tsx create mode 100644 client/src/pages/alerts/components/AlertsTable/components/AlertFormModal.tsx create mode 100644 client/src/pages/alerts/components/ContactPointsTable/ContactPointsTable.tsx create mode 100644 client/src/pages/alerts/components/ContactPointsTable/components/ContactPointFormModal.tsx create mode 100644 client/src/utils/hooks/useForm.tsx create mode 100644 client/src/utils/tryParseAlertCondition.ts create mode 100644 client/src/utils/tryParseContactPointConfig.ts create mode 100644 client/src/utils/tryParseJson.ts diff --git a/basic-sensor-receiver.code-workspace b/basic-sensor-receiver.code-workspace index 82a44f6..57ccc2b 100644 --- a/basic-sensor-receiver.code-workspace +++ b/basic-sensor-receiver.code-workspace @@ -7,5 +7,9 @@ "path": "client" } ], - "settings": {} + "settings": { + "cSpell.words": [ + "tgbotapi" + ] + } } \ No newline at end of file diff --git a/client/.vscode/settings.json b/client/.vscode/settings.json new file mode 100644 index 0000000..91a7965 --- /dev/null +++ b/client/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "prettier.useTabs": false, + "editor.tabSize": 2, + "editor.detectIndentation": false, + "editor.insertSpaces": false +} \ No newline at end of file diff --git a/client/src/api/alerts.ts b/client/src/api/alerts.ts index 8bb8ca5..e26c668 100644 --- a/client/src/api/alerts.ts +++ b/client/src/api/alerts.ts @@ -13,14 +13,19 @@ export type AlertInfo = { export const getAlerts = () => request('/api/alerts') -export const createAlert = (data: Omit) => +export const createAlert = ( + data: Omit +) => request('/api/alerts', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ data }), + body: JSON.stringify(data), }) -export const updateAlert = ({ id, ...body }: AlertInfo) => +export const updateAlert = ({ + id, + ...body +}: Omit) => request(`/api/alerts/${id}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, diff --git a/client/src/api/contactPoints.ts b/client/src/api/contactPoints.ts index 3fb8778..0c162c8 100644 --- a/client/src/api/contactPoints.ts +++ b/client/src/api/contactPoints.ts @@ -10,25 +10,14 @@ export type ContactPointInfo = { export const getContactPoints = () => request('/api/contact-points') -export const createContactPoint = (data: { - name: string - type: string - config: string -}) => +export const createContactPoint = (data: Omit) => request('/api/contact-points', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ data }), + body: JSON.stringify(data), }) -export const updateContactPoint = ({ - id, - ...body -}: { - id: number - type: string - config: string -}) => +export const updateContactPoint = ({ id, ...body }: ContactPointInfo) => request(`/api/contact-points/${id}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, @@ -41,3 +30,10 @@ export const deleteContactPoint = (id: number) => { method: 'DELETE' }, 'void' ) + +export const testContactPoint = (body: { type: string; typeConfig: string }) => + request(`/api/contact-points/test`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) diff --git a/client/src/assets/components/_box.scss b/client/src/assets/components/_box.scss index 8ae7e84..127407d 100644 --- a/client/src/assets/components/_box.scss +++ b/client/src/assets/components/_box.scss @@ -9,3 +9,7 @@ border-bottom: 1px solid var(--box-border-color); } } + +.box-shadow { + box-shadow: var(--box-shadow); +} diff --git a/client/src/assets/components/_data-table.scss b/client/src/assets/components/_data-table.scss new file mode 100644 index 0000000..a29e36e --- /dev/null +++ b/client/src/assets/components/_data-table.scss @@ -0,0 +1,35 @@ +.data-table { + border: 1px solid var(--box-border-color); + background-color: var(--box-bg-color); + + .data-table-header, + .row { + display: flex; + + .col { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + padding: 0.25rem 0.5rem; + + &.actions { + align-items: flex-end; + + button { + margin-left: 0.25rem; + } + } + } + } + + .data-table-body { + .row { + border-bottom: 1px solid var(--box-border-color); + + &:last-child { + border-bottom: none; + } + } + } +} \ No newline at end of file diff --git a/client/src/assets/pages/_alerts-page.scss b/client/src/assets/pages/_alerts-page.scss new file mode 100644 index 0000000..384f180 --- /dev/null +++ b/client/src/assets/pages/_alerts-page.scss @@ -0,0 +1,13 @@ +.alerts-page .content { + max-width: 50rem; + padding: 1rem; + + .section-title { + display: flex; + align-items: center; + + h2 { + flex: 1; + } + } +} diff --git a/client/src/assets/style.scss b/client/src/assets/style.scss index 87cd974..8610253 100644 --- a/client/src/assets/style.scss +++ b/client/src/assets/style.scss @@ -1,4 +1,4 @@ -@import "themes/basic"; +@import 'themes/basic'; body, html { @@ -38,14 +38,16 @@ a { stroke: currentColor; } -@import "components/button"; -@import "components/input"; -@import "components/modal"; -@import "components/form"; -@import "components/grid-sensors"; -@import "components/user-layout"; -@import "components/box"; +@import 'components/button'; +@import 'components/input'; +@import 'components/modal'; +@import 'components/form'; +@import 'components/grid-sensors'; +@import 'components/user-layout'; +@import 'components/box'; +@import 'components/data-table'; -@import "pages/login-page"; -@import "pages/sensors-page"; -@import "pages/dashboard-page"; +@import 'pages/login-page'; +@import 'pages/sensors-page'; +@import 'pages/dashboard-page'; +@import 'pages/alerts-page'; diff --git a/client/src/components/DataTable.tsx b/client/src/components/DataTable.tsx new file mode 100644 index 0000000..0becaf6 --- /dev/null +++ b/client/src/components/DataTable.tsx @@ -0,0 +1,60 @@ +import { cn } from '@/utils/cn' +import { ComponentChild } from 'preact' + +type Props = { + data: TRow[] + columns: DataTableColumn[] + hideHeader?: boolean +} + +type DataTableColumn = { + key: keyof TRow | string + title?: ComponentChild + render: (row: TRow, rowIndex: number, colIndex: number) => ComponentChild + className?: string +} & ({ scale: number } | { width: string }) + +export const DataTable = ({ + data, + columns, + hideHeader, +}: Props) => { + return ( +
+ {!hideHeader && ( +
+ {columns.map((col) => ( +
+ {col.title} +
+ ))} +
+ )} +
+ {data.map((row, rowIndex) => ( +
+ {columns.map((col, colIndex) => ( +
+ {col.render(row, rowIndex, colIndex)} +
+ ))} +
+ ))} +
+
+ ) +} diff --git a/client/src/pages/alerts/AlertsPage.tsx b/client/src/pages/alerts/AlertsPage.tsx index 04496f4..fe8c451 100644 --- a/client/src/pages/alerts/AlertsPage.tsx +++ b/client/src/pages/alerts/AlertsPage.tsx @@ -1,20 +1,19 @@ -import { PlusIcon } from '@/icons' import { UserLayout } from '@/layouts/UserLayout/UserLayout' +import { ContactPointsTable } from './components/ContactPointsTable/ContactPointsTable' +import { AlertsTable } from './components/AlertsTable/AlertsTable' export const AlertsPage = () => { return ( +
Alerts
-
} - className="sensors-page" + className="alerts-page" > - TEST + +
) } diff --git a/client/src/pages/alerts/components/AlertsTable/AlertsTable.tsx b/client/src/pages/alerts/components/AlertsTable/AlertsTable.tsx new file mode 100644 index 0000000..d37f4f5 --- /dev/null +++ b/client/src/pages/alerts/components/AlertsTable/AlertsTable.tsx @@ -0,0 +1,79 @@ +import { AlertInfo, deleteAlert, getAlerts } from '@/api/alerts' +import { ConfirmModal } from '@/components/ConfirmModal' +import { DataTable } from '@/components/DataTable' +import { useState } from 'preact/hooks' +import { useMutation, useQuery } from 'react-query' +import { AlertFormModal } from './components/AlertFormModal' + +export const AlertsTable = () => { + const alerts = useQuery('/alerts', () => getAlerts()) + + const deleteMutation = useMutation(deleteAlert, { + onSuccess: () => { + alerts.refetch() + setShowDelete(undefined) + }, + }) + + const [showNew, setShowNew] = useState(false) + const [edited, setEdited] = useState() + const [showDelete, setShowDelete] = useState() + + return ( +
+
+

Alerts

+ + +
+ +
+ c.name, scale: 1 }, + { + key: 'lastStatus', + title: 'Last status', + render: (c) => c.lastStatus, + width: '10rem', + }, + { + key: 'actions', + title: 'Actions', + width: '10rem', + className: 'actions', + render: (c) => ( +
+ + +
+ ), + }, + ]} + /> +
+ + {(showNew || edited) && ( + { + setEdited(undefined) + setShowNew(false) + }} + /> + )} + + {showDelete && ( + deleteMutation.mutate(showDelete.id)} + onCancel={() => setShowDelete(undefined)} + > + Are you sure you want to delete {showDelete.name} alert? + + )} +
+ ) +} diff --git a/client/src/pages/alerts/components/AlertsTable/components/AlertFormModal.tsx b/client/src/pages/alerts/components/AlertsTable/components/AlertFormModal.tsx new file mode 100644 index 0000000..5030926 --- /dev/null +++ b/client/src/pages/alerts/components/AlertsTable/components/AlertFormModal.tsx @@ -0,0 +1,233 @@ +import { AlertInfo, createAlert, updateAlert } from '@/api/alerts' +import { getContactPoints } from '@/api/contactPoints' +import { getSensors } from '@/api/sensors' +import { Modal } from '@/components/Modal' +import { useForm } from '@/utils/hooks/useForm' +import { tryParseAlertCondition } from '@/utils/tryParseAlertCondition' +import { useMutation, useQuery, useQueryClient } from 'react-query' + +type Props = { + alert?: AlertInfo + open: boolean + onClose: () => void +} + +export const AlertFormModal = ({ alert, open, onClose }: Props) => { + const queryClient = useQueryClient() + const createMutation = useMutation(createAlert) + const updateMutation = useMutation(updateAlert) + + const contactPoints = useQuery('/contact-points', () => getContactPoints()) + const sensors = useQuery('/sensors', () => getSensors()) + + const isLoading = createMutation.isLoading || updateMutation.isLoading + + const parsedCondition = alert && tryParseAlertCondition(alert.condition) + + const { handleSubmit, register, watch } = useForm({ + defaultValue: () => ({ + name: '', + customMessage: '', + triggerInterval: 0, + ...(alert && { + name: alert.name, + contactPointId: alert.contactPointId, + customMessage: alert.customMessage, + triggerInterval: alert.triggerInterval, + }), + ...(parsedCondition && { + conditionType: parsedCondition.type, + ...(parsedCondition.type === 'sensor_value' && { + sensorValueSensorId: parsedCondition.sensorId, + sensorValueCondition: parsedCondition.condition, + sensorValueValue: parsedCondition.value, + }), + ...(parsedCondition.type === 'sensor_last_contact' && { + sensorLastContactSensorId: parsedCondition.sensorId, + sensorLastContactValue: parsedCondition.value, + sensorLastContactValueUnit: parsedCondition.valueUnit, + }), + }), + }), + onSubmit: async (v) => { + if (isLoading) { + return + } + + if (!v.contactPointId) { + return + } + + const getConditionData = () => { + switch (v.conditionType) { + case 'sensor_value': + return { + type: 'sensor_value', + sensorId: v.sensorValueSensorId, + condition: v.sensorValueCondition, + value: v.sensorValueValue, + } + case 'sensor_last_contact': + return { + type: 'sensor_last_contact', + sensorId: v.sensorLastContactSensorId, + value: v.sensorLastContactValue, + valueUnit: v.sensorLastContactValueUnit, + } + } + + return {} + } + + const condition = JSON.stringify(getConditionData()) + + const data = { + name: v.name, + contactPointId: v.contactPointId, + customMessage: v.customMessage, + triggerInterval: v.triggerInterval, + condition, + } + + if (alert) { + await updateMutation.mutateAsync({ + id: alert.id, + ...data, + }) + } else { + await createMutation.mutateAsync(data) + } + + queryClient.invalidateQueries('/alerts') + + onClose() + }, + }) + + const conditionType = watch('conditionType') + + return ( + +
+
+ + +
+ +
+ + +
+ +
+ +