Finish alerts implementation

This commit is contained in:
Jan Zípek 2024-03-31 09:50:09 +02:00
parent 72bf572981
commit 7e9625ecf3
27 changed files with 1290 additions and 90 deletions

View File

@ -7,5 +7,9 @@
"path": "client" "path": "client"
} }
], ],
"settings": {} "settings": {
"cSpell.words": [
"tgbotapi"
]
}
} }

6
client/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"prettier.useTabs": false,
"editor.tabSize": 2,
"editor.detectIndentation": false,
"editor.insertSpaces": false
}

View File

@ -13,14 +13,19 @@ export type AlertInfo = {
export const getAlerts = () => request<AlertInfo[]>('/api/alerts') export const getAlerts = () => request<AlertInfo[]>('/api/alerts')
export const createAlert = (data: Omit<AlertInfo, 'id'>) => export const createAlert = (
data: Omit<AlertInfo, 'id' | 'lastStatus' | 'lastStatusAt'>
) =>
request<AlertInfo>('/api/alerts', { request<AlertInfo>('/api/alerts', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, 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<AlertInfo, 'lastStatus' | 'lastStatusAt'>) =>
request<AlertInfo>(`/api/alerts/${id}`, { request<AlertInfo>(`/api/alerts/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },

View File

@ -10,25 +10,14 @@ export type ContactPointInfo = {
export const getContactPoints = () => export const getContactPoints = () =>
request<ContactPointInfo[]>('/api/contact-points') request<ContactPointInfo[]>('/api/contact-points')
export const createContactPoint = (data: { export const createContactPoint = (data: Omit<ContactPointInfo, 'id'>) =>
name: string
type: string
config: string
}) =>
request<ContactPointInfo>('/api/contact-points', { request<ContactPointInfo>('/api/contact-points', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ data }), body: JSON.stringify(data),
}) })
export const updateContactPoint = ({ export const updateContactPoint = ({ id, ...body }: ContactPointInfo) =>
id,
...body
}: {
id: number
type: string
config: string
}) =>
request<ContactPointInfo>(`/api/contact-points/${id}`, { request<ContactPointInfo>(`/api/contact-points/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
@ -41,3 +30,10 @@ export const deleteContactPoint = (id: number) =>
{ method: 'DELETE' }, { method: 'DELETE' },
'void' 'void'
) )
export const testContactPoint = (body: { type: string; typeConfig: string }) =>
request<ContactPointInfo>(`/api/contact-points/test`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})

View File

@ -9,3 +9,7 @@
border-bottom: 1px solid var(--box-border-color); border-bottom: 1px solid var(--box-border-color);
} }
} }
.box-shadow {
box-shadow: var(--box-shadow);
}

View File

@ -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;
}
}
}
}

View File

@ -0,0 +1,13 @@
.alerts-page .content {
max-width: 50rem;
padding: 1rem;
.section-title {
display: flex;
align-items: center;
h2 {
flex: 1;
}
}
}

View File

@ -1,4 +1,4 @@
@import "themes/basic"; @import 'themes/basic';
body, body,
html { html {
@ -38,14 +38,16 @@ a {
stroke: currentColor; stroke: currentColor;
} }
@import "components/button"; @import 'components/button';
@import "components/input"; @import 'components/input';
@import "components/modal"; @import 'components/modal';
@import "components/form"; @import 'components/form';
@import "components/grid-sensors"; @import 'components/grid-sensors';
@import "components/user-layout"; @import 'components/user-layout';
@import "components/box"; @import 'components/box';
@import 'components/data-table';
@import "pages/login-page"; @import 'pages/login-page';
@import "pages/sensors-page"; @import 'pages/sensors-page';
@import "pages/dashboard-page"; @import 'pages/dashboard-page';
@import 'pages/alerts-page';

View File

@ -0,0 +1,60 @@
import { cn } from '@/utils/cn'
import { ComponentChild } from 'preact'
type Props<TRow> = {
data: TRow[]
columns: DataTableColumn<TRow>[]
hideHeader?: boolean
}
type DataTableColumn<TRow> = {
key: keyof TRow | string
title?: ComponentChild
render: (row: TRow, rowIndex: number, colIndex: number) => ComponentChild
className?: string
} & ({ scale: number } | { width: string })
export const DataTable = <TRow,>({
data,
columns,
hideHeader,
}: Props<TRow>) => {
return (
<div className="data-table">
{!hideHeader && (
<div className="data-table-header">
{columns.map((col) => (
<div
key={col.key}
className={cn('col', 'head', col.className)}
style={
'width' in col
? { width: col.width, flexShrink: 0, flexGrow: 0 }
: { flex: col.scale }
}
>
{col.title}
</div>
))}
</div>
)}
<div className="data-table-body">
{data.map((row, rowIndex) => (
<div className="row" key={rowIndex}>
{columns.map((col, colIndex) => (
<div
key={col.key}
className={cn('col', col.className)}
style={
'width' in col ? { width: col.width } : { flex: col.scale }
}
>
{col.render(row, rowIndex, colIndex)}
</div>
))}
</div>
))}
</div>
</div>
)
}

View File

@ -1,20 +1,19 @@
import { PlusIcon } from '@/icons'
import { UserLayout } from '@/layouts/UserLayout/UserLayout' import { UserLayout } from '@/layouts/UserLayout/UserLayout'
import { ContactPointsTable } from './components/ContactPointsTable/ContactPointsTable'
import { AlertsTable } from './components/AlertsTable/AlertsTable'
export const AlertsPage = () => { export const AlertsPage = () => {
return ( return (
<UserLayout <UserLayout
header={ header={
<div className="sensors-head"> <div className="alerts-head">
<div>Alerts</div> <div>Alerts</div>
<button>
<PlusIcon /> Add alert
</button>
</div> </div>
} }
className="sensors-page" className="alerts-page"
> >
TEST <ContactPointsTable />
<AlertsTable />
</UserLayout> </UserLayout>
) )
} }

View File

@ -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<AlertInfo>()
const [showDelete, setShowDelete] = useState<AlertInfo>()
return (
<div className="alerts">
<div className="section-title">
<h2>Alerts</h2>
<button onClick={() => alerts.refetch()}>Refresh</button>
<button onClick={() => setShowNew(true)}>Add alert</button>
</div>
<div className="box-shadow">
<DataTable
data={alerts.data ?? []}
columns={[
{ key: 'name', title: 'Name', render: (c) => 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) => (
<div>
<button onClick={() => setEdited(c)}>Edit</button>
<button onClick={() => setShowDelete(c)}>Delete</button>
</div>
),
},
]}
/>
</div>
{(showNew || edited) && (
<AlertFormModal
open
alert={edited}
onClose={() => {
setEdited(undefined)
setShowNew(false)
}}
/>
)}
{showDelete && (
<ConfirmModal
open
onConfirm={() => deleteMutation.mutate(showDelete.id)}
onCancel={() => setShowDelete(undefined)}
>
Are you sure you want to delete {showDelete.name} alert?
</ConfirmModal>
)}
</div>
)
}

View File

@ -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 (
<Modal open={open} onClose={onClose}>
<form onSubmit={handleSubmit}>
<div className="input">
<label>Name</label>
<input required minLength={1} autoFocus {...register('name')} />
</div>
<div className="input">
<label>Contact Point</label>
<select required {...register('contactPointId', { type: 'integer' })}>
{contactPoints.data?.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div className="input">
<label>Custom Message</label>
<textarea {...register('customMessage')} />
</div>
<div className="input">
<label>Trigger After (seconds)</label>
<input
type="number"
min={0}
required
{...register('triggerInterval', { type: 'integer' })}
/>
</div>
<div className="input">
<label>Condition Type</label>
<select required {...register('conditionType')}>
<option value="sensor_value">Sensor Value</option>
<option value="sensor_last_contact">Sensor Last Contact</option>
</select>
</div>
{conditionType === 'sensor_value' && (
<>
<div className="input">
<label>Sensor</label>
<select
required
{...register('sensorValueSensorId', { type: 'integer' })}
>
{sensors.data?.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div className="input">
<label>Condition</label>
<select required {...register('sensorValueCondition')}>
<option value="less">Less</option>
<option value="more">More</option>
<option value="equal">Equal</option>
</select>
</div>
<div className="input">
<label>Value</label>
<input
type="number"
required
{...register('sensorValueValue', { type: 'number' })}
/>
</div>
</>
)}
{conditionType === 'sensor_last_contact' && (
<>
<div className="input">
<label>Sensor</label>
<select
required
{...register('sensorLastContactSensorId', { type: 'integer' })}
>
{sensors.data?.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div className="input">
<label>Value</label>
<input
type="number"
required
{...register('sensorLastContactValue', { type: 'number' })}
/>
</div>
<div className="input">
<label>Value Unit</label>
<select required {...register('sensorLastContactValueUnit')}>
<option value="s">Seconds</option>
<option value="m">Minutes</option>
<option value="h">Hours</option>
<option value="d">Days</option>
</select>
</div>
</>
)}
<div className="actions">
<button className="cancel" onClick={onClose}>
Cancel
</button>
<button disabled={isLoading}>{alert ? 'Update' : 'Create'}</button>
</div>
</form>
</Modal>
)
}

View File

@ -0,0 +1,83 @@
import {
ContactPointInfo,
deleteContactPoint,
getContactPoints,
} from '@/api/contactPoints'
import { useState } from 'preact/hooks'
import { useMutation, useQuery } from 'react-query'
import { ContactPointFormModal } from './components/ContactPointFormModal'
import { DataTable } from '@/components/DataTable'
import { ConfirmModal } from '@/components/ConfirmModal'
export const ContactPointsTable = () => {
const contactPoints = useQuery('/contact-points', () => getContactPoints())
const deleteMutation = useMutation(deleteContactPoint, {
onSuccess: () => {
contactPoints.refetch()
setShowDelete(undefined)
},
})
const [showNew, setShowNew] = useState(false)
const [edited, setEdited] = useState<ContactPointInfo>()
const [showDelete, setShowDelete] = useState<ContactPointInfo>()
return (
<div className="contact-points">
<div className="section-title">
<h2>Contact Points</h2>
<button onClick={() => setShowNew(true)}>Add contact point</button>
</div>
<div className="box-shadow">
<DataTable
data={contactPoints.data ?? []}
columns={[
{ key: 'name', title: 'Name', render: (c) => c.name, scale: 1 },
{
key: 'type',
title: 'Type',
render: (c) => c.type,
width: '5rem',
},
{
key: 'actions',
title: 'Actions',
width: '10rem',
className: 'actions',
render: (c) => (
<div>
<button onClick={() => setEdited(c)}>Edit</button>
<button onClick={() => setShowDelete(c)}>Delete</button>
</div>
),
},
]}
/>
</div>
{(showNew || edited) && (
<ContactPointFormModal
open
contactPoint={edited}
onClose={() => {
setShowNew(false)
setEdited(undefined)
}}
/>
)}
{showDelete && (
<ConfirmModal
open
onConfirm={() => deleteMutation.mutate(showDelete.id)}
onCancel={() => setShowDelete(undefined)}
confirmText="Delete"
>
Do you want to delete {showDelete.name} contact point?
</ConfirmModal>
)}
</div>
)
}

View File

@ -0,0 +1,156 @@
import {
ContactPointInfo,
createContactPoint,
testContactPoint,
updateContactPoint,
} from '@/api/contactPoints'
import { Modal } from '@/components/Modal'
import { useForm } from '@/utils/hooks/useForm'
import { tryParseContactPointConfig } from '@/utils/tryParseContactPointConfig'
import { useMutation, useQueryClient } from 'react-query'
type Props = {
contactPoint?: ContactPointInfo
open: boolean
onClose: () => void
}
type FormValues = {
name: string
type: string
telegramApiKey?: string
telegramTargetChannel?: number
}
export const ContactPointFormModal = ({
open,
onClose,
contactPoint,
}: Props) => {
const queryClient = useQueryClient()
const createMutation = useMutation(createContactPoint)
const updateMutation = useMutation(updateContactPoint)
const testMutation = useMutation(testContactPoint)
const isLoading = createMutation.isLoading || updateMutation.isLoading
const parsedTypeConfig =
contactPoint && tryParseContactPointConfig(contactPoint)
const getTypeConfig = (v: FormValues) => {
if (v.type === 'telegram') {
return {
apiKey: v.telegramApiKey,
targetChannel: v.telegramTargetChannel,
}
}
return {}
}
const { handleSubmit, register, watch, getValues } = useForm({
defaultValue: () => ({
name: contactPoint?.name ?? '',
type: contactPoint?.type ?? 'telegram',
...(parsedTypeConfig?.type === 'telegram' && {
telegramApiKey: parsedTypeConfig.apiKey ?? '',
telegramTargetChannel: parsedTypeConfig.targetChannel,
}),
}),
onSubmit: async (v) => {
if (isLoading) {
return
}
const typeConfig = JSON.stringify(getTypeConfig(v))
if (contactPoint) {
await updateMutation.mutateAsync({
id: contactPoint.id,
name: v.name,
type: v.type,
typeConfig: typeConfig,
})
} else {
await createMutation.mutateAsync({
name: v.name,
type: v.type,
typeConfig: typeConfig,
})
}
queryClient.invalidateQueries('/contact-points')
onClose()
},
})
const type = watch('type')
return (
<Modal open={open} onClose={onClose}>
<form onSubmit={handleSubmit}>
<div className="input">
<label>Name</label>
<input
type="text"
minLength={1}
required
autoFocus
{...register('name')}
/>
</div>
<div className="input">
<label>Type</label>
<select required {...register('type')}>
<option value="telegram">Telegram</option>
</select>
</div>
{type === 'telegram' && (
<>
<div className="input">
<label>API Key</label>
<input
type="text"
minLength={1}
required
{...register('telegramApiKey')}
/>
</div>
<div className="input">
<label>Target Channel</label>
<input
type="text"
minLength={1}
required
{...register('telegramTargetChannel', { type: 'integer' })}
/>
</div>
</>
)}
<div className="actions">
<button
className="test"
type="button"
onClick={() =>
testMutation.mutate({
type: getValues().type,
typeConfig: JSON.stringify(getTypeConfig(getValues())),
})
}
>
Test
</button>
<button className="cancel" type="button" onClick={onClose}>
Cancel
</button>
<button disabled={isLoading}>Save</button>
</div>
</form>
</Modal>
)
}

View File

@ -0,0 +1,391 @@
import { ComponentChild, createContext } from 'preact'
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks'
import { ConditionalKeys } from 'type-fest'
type FormValue =
| string
| number
| boolean
| null
| Array<string | number | boolean>
type BaseValue = Record<string, FormValue>
type Props<TValue extends BaseValue> = {
onSubmit?: (value: TValue) => void
onChange?: (value: TValue) => void
defaultValue: TValue | (() => TValue)
}
type ReferencedElement =
| HTMLInputElement
| HTMLSelectElement
| HTMLTextAreaElement
| null
type InternalState<TValue extends BaseValue> = {
onSubmit?: (value: TValue) => void
onChange?: (value: TValue) => void
subscribers: ((value: TValue) => void)[]
value: TValue
refs: Record<string, ReferencedElement>
}
type InputOptions = {
type?: 'string' | 'number' | 'integer' | 'boolean'
}
export type RegisterCallback<TValue extends BaseValue = BaseValue> = <
TName extends Extract<keyof TValue, string>
>(
name: TName,
options?: InputOptions
) => {
name: TName
ref: (ref: ReferencedElement) => void
onChange: (e: Event) => void
value: string
}
export type RegisterArrayCallback<TValue extends BaseValue = BaseValue> = <
TName extends ConditionalKeys<TValue, Array<unknown>>,
TArrayValue = TValue[TName] extends Array<infer T> ? T : unknown
>(
name: TName
) => {
register: (index: number) => ReturnType<RegisterCallback>
append: (value: TArrayValue) => number
remove: (index: number) => void
values: TArrayValue[]
}
export type WatchCallback<TValue extends BaseValue = BaseValue> = <
TName extends Extract<keyof TValue, string>
>(
name: TName
) => TValue[TName]
export type SetValueCallback<TValue extends BaseValue = BaseValue> = <
TName extends Extract<keyof TValue, string>
>(
name: TName,
value: TValue[TName],
skipUpdate?: boolean
) => void
export type GetValues<TValue extends BaseValue = BaseValue> = () => TValue
export type FormInterface<TValue extends BaseValue = BaseValue> = {
register: RegisterCallback<TValue>
registerArray: RegisterArrayCallback<TValue>
watch: WatchCallback<TValue>
setValue: SetValueCallback<TValue>
handleSubmit: (e: Event) => void
getValues: GetValues<TValue>
}
const FormContext = createContext<FormInterface | null>(null)
const INDEXED_NAME_REGEX = /(.*)\[[0-9]+\]/
export const useForm = <TValue extends BaseValue = BaseValue>({
defaultValue,
onSubmit,
onChange,
}: Props<TValue>) => {
const firstRenderRef = useRef(true)
const internalRef = useRef(
(firstRenderRef.current
? {
onSubmit,
onChange,
subscribers: [],
value:
typeof defaultValue === 'function'
? (defaultValue as () => TValue)()
: defaultValue,
refs: {},
}
: {}) as InternalState<TValue>
)
internalRef.current = {
onSubmit,
onChange,
subscribers: internalRef.current.subscribers,
value: internalRef.current.value,
refs: {},
}
firstRenderRef.current = false
const getCurrentValueByName = useCallback((name: string): FormValue => {
const current = internalRef.current.value
if (name.includes('[')) {
const match = INDEXED_NAME_REGEX.exec(name)
if (match) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const arrayValue = current[match[1]] as any[]
return arrayValue?.[+match[2]]
}
}
return current[name]
}, [])
const setCurrentValueByName = useCallback(
(name: string, value: FormValue) => {
const current = internalRef.current.value
if (name.includes('[')) {
const match = INDEXED_NAME_REGEX.exec(name)
if (match) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let arrayValue = current[match[1]] as any[]
if (!Array.isArray(arrayValue)) {
arrayValue = []
}
arrayValue[+match[2]] = value
internalRef.current.value = {
...internalRef.current.value,
[match[1]]: arrayValue,
}
}
}
internalRef.current.value = {
...internalRef.current.value,
[name]: value,
}
},
[]
)
const setRefValue = useCallback((name: string, value: FormValue) => {
const ref = internalRef.current.refs[name]
if (ref) {
if (ref.type === 'checkbox') {
const input = ref as HTMLInputElement
input.checked = !!value
} else {
ref.value = String(value)
}
}
}, [])
const handleInputRef = useCallback(
(
ref: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null
) => {
if (ref) {
const name = ref.name
if (!name) {
console.warn('Element has no name!', ref)
return
}
const predefinedValue = getCurrentValueByName(name)
internalRef.current.refs[name] = ref
if (predefinedValue !== undefined && predefinedValue !== null) {
setRefValue(name, predefinedValue)
}
}
},
[]
)
const setValue: SetValueCallback<TValue> = useCallback(
<TName extends Extract<keyof TValue, string>>(
name: TName,
value: TValue[TName],
skipUpdate = false
) => {
setCurrentValueByName(name, value)
if (!skipUpdate) {
setRefValue(name as string, value)
}
internalRef.current.onChange?.(internalRef.current.value)
internalRef.current.subscribers.forEach((s) =>
s(internalRef.current.value)
)
},
[]
)
const handleInputChange = useCallback(
(e: Event, options?: InputOptions) => {
const target = e.target as ReferencedElement
if (!target) {
return
}
let value: FormValue = target.value
if (target instanceof HTMLInputElement && target.type == 'checkbox') {
value = target.checked
} else if (options?.type === 'integer') {
const parsed = parseInt(value, 10)
value = isNaN(parsed) ? null : parsed
} else if (options?.type === 'number') {
const parsed = parseFloat(value)
value = isNaN(parsed) ? null : parsed
} else if (options?.type === 'boolean') {
value = target.value === 'true'
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setValue(target.name as any, value as any, true)
},
[setValue]
)
const register: RegisterCallback<TValue> = useCallback(
<TName extends Extract<keyof TValue, string>>(
name: TName,
options?: InputOptions
) => {
return {
name,
ref: handleInputRef,
onChange: (e: Event) => handleInputChange(e, options),
value: `${internalRef.current.value[name] ?? ''}`,
...(options?.type === 'boolean' && {
value: 'true',
}),
}
},
[handleInputChange]
)
const registerArray: RegisterArrayCallback<TValue> = useCallback(
<
TName extends ConditionalKeys<TValue, Array<unknown>>,
TArrayValue = TValue[TName] extends Array<infer T> ? T : unknown
>(
name: TName
) => {
const [values, setValues] = useState([] as TArrayValue[])
return {
register: (index: number, options?: InputOptions) => {
return {
name: `${String(name)}[${index}]`,
ref: handleInputRef,
onChange: (e: Event) => handleInputChange(e, options),
value: `${internalRef.current.value[name] ?? ''}`,
...(options?.type === 'boolean' && {
value: 'true',
}),
}
},
append: (value: TArrayValue) => {
setValues((v) => [...v, value])
return values.length
},
remove: (index: number) => {
setValues((v) => {
const res = v.slice()
res.splice(index, 1)
return res
})
},
values,
}
},
[register]
)
const watch: WatchCallback<TValue> = useCallback(
<TName extends keyof TValue>(name: TName) => {
const [value, setValue] = useState<FormValue>(
internalRef.current.value[name] ?? ''
)
useEffect(() => {
const cb = (v: TValue) => setValue(v[name])
internalRef.current.subscribers.push(cb)
return () => {
const index = internalRef.current.subscribers.indexOf(cb)
if (index >= 0) {
internalRef.current.subscribers.splice(index, 1)
}
}
}, [])
return value as NonNullable<TValue[TName]>
},
[handleInputChange]
)
const getValues: GetValues<TValue> = useCallback(
() => internalRef.current.value,
[]
)
const handleSubmit = useCallback((e: Event) => {
e.preventDefault()
e.stopPropagation()
internalRef.current.onSubmit?.(internalRef.current.value)
}, [])
const formInterface = useMemo(() => {
const ctx = {
register,
registerArray,
watch,
setValue,
handleSubmit,
getValues,
}
const ContextProvider = ({ children }: { children: ComponentChild }) => (
<FormContext.Provider value={ctx as unknown as FormInterface<BaseValue>}>
{children}
</FormContext.Provider>
)
return { ...ctx, ContextProvider }
}, [register, registerArray, watch, setValue, handleSubmit])
return formInterface
}
export const useFormContext = <TValues extends BaseValue = BaseValue>() => {
const form = useContext(FormContext) as FormInterface<TValues> | null
if (!form) {
throw new Error('Failed to load context')
}
return form
}

View File

@ -0,0 +1,39 @@
import { tryParseJson } from './tryParseJson'
type SensorValueAlertCondition = {
type: 'sensor_value'
sensorId: number
condition: 'less' | 'more' | 'equal'
value: number
}
type SensorLastContactAlertCondition = {
type: 'sensor_last_contact'
sensorId: number
value: number
valueUnit: 's' | 'm' | 'h' | 'd'
}
type AlertCondition =
| SensorValueAlertCondition
| SensorLastContactAlertCondition
export const tryParseAlertCondition = (
condition: string
): AlertCondition | null => {
const data = tryParseJson(condition)
if (!data) {
return null
}
if (data.type === 'sensor_value') {
return data as SensorValueAlertCondition
}
if (data.type === 'sensor_last_contact') {
return data as SensorLastContactAlertCondition
}
return null
}

View File

@ -0,0 +1,30 @@
import { ContactPointInfo } from '@/api/contactPoints'
import { tryParseJson } from './tryParseJson'
type ContactPointTelegramConfig = {
type: 'telegram'
apiKey: string
targetChannel: number
}
export type ParsedContactPointConfig = ContactPointTelegramConfig
export const tryParseContactPointConfig = (
contactPoint: ContactPointInfo
): ParsedContactPointConfig | null => {
const data = tryParseJson(contactPoint.typeConfig)
if (!data) {
return null
}
if (contactPoint.type === 'telegram') {
return {
type: 'telegram',
apiKey: data.apiKey,
targetChannel: data.targetChannel,
}
}
return null
}

View File

@ -0,0 +1,7 @@
export const tryParseJson = (json: string) => {
try {
return JSON.parse(json)
} catch {
return null
}
}

3
server/.gitignore vendored
View File

@ -1,4 +1,5 @@
.env .env
basic-sensor-receiver.exe basic-sensor-receiver.exe
basic-sensor-receiver basic-sensor-receiver
*.sqlite3 *.sqlite3
tmp/

View File

@ -1,13 +1,20 @@
package app package app
import "time" import (
"fmt"
"time"
)
func (s *Server) StartAlerts() { func (s *Server) StartAlerts() {
ticker := time.NewTicker(time.Second * 10) ticker := time.NewTicker(time.Second * 10)
go func() { go func() {
for { for {
s.Services.Alerts.EvaluateAlerts() err := s.Services.Alerts.EvaluateAlerts()
if err != nil {
fmt.Println("Error evaluating alerts: ", err)
}
<-ticker.C <-ticker.C
} }
}() }()

View File

@ -3,6 +3,7 @@ package integrations
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"strings" "strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
@ -40,12 +41,16 @@ func (s TelegramIntegration) ProcessEvent(evt *ContactPointEvent, rawConfig stri
text = data.Alert.CustomMessage text = data.Alert.CustomMessage
} }
text = strings.Replace(text, "{value}", fmt.Sprintf("%f", data.LastValue), -1) text = strings.Replace(text, "{value}", strconv.FormatFloat(data.LastValue, 'f', -1, 64), -1)
msg := tgbotapi.NewMessage(config.TargetChannel, text) msg := tgbotapi.NewMessage(config.TargetChannel, text)
msg.ParseMode = "Markdown" msg.ParseMode = "Markdown"
_, err = bot.Send(msg) _, err = bot.Send(msg)
return err return err
case ContactPointEventTest:
msg := tgbotapi.NewMessage(config.TargetChannel, "Test message from Basic Sensor Receiver")
_, err = bot.Send(msg)
return err
} }
return nil return nil

View File

@ -62,6 +62,7 @@ func main() {
loginProtected.GET("/api/contact-points/:contactPointId", routes.GetContactPoint(server)) loginProtected.GET("/api/contact-points/:contactPointId", routes.GetContactPoint(server))
loginProtected.PUT("/api/contact-points/:contactPointId", routes.PutContactPoint(server)) loginProtected.PUT("/api/contact-points/:contactPointId", routes.PutContactPoint(server))
loginProtected.DELETE("/api/contact-points/:contactPointId", routes.DeleteContactPoint(server)) loginProtected.DELETE("/api/contact-points/:contactPointId", routes.DeleteContactPoint(server))
loginProtected.POST("/api/contact-points/test", routes.TestContactPoint(server))
loginProtected.POST("/api/logout", routes.Logout(server)) loginProtected.POST("/api/logout", routes.Logout(server))
// Routes accessible using auth key // Routes accessible using auth key

View File

@ -23,7 +23,7 @@ type AlertConditionSensorValue struct {
} }
type AlertConditionSensorLastContact struct { type AlertConditionSensorLastContact struct {
SensorId int64 `json:"sensorId"` SensorId int64 `json:"sensorId"`
Value int64 `json:"value"` Value float64 `json:"value"`
ValueUnit string `json:"valueUnit"` ValueUnit string `json:"valueUnit"`
} }

View File

@ -13,6 +13,7 @@ type postAlertsBody struct {
Name string `json:"name"` Name string `json:"name"`
Condition string `json:"condition"` Condition string `json:"condition"`
TriggerInterval int64 `json:"triggerInterval"` TriggerInterval int64 `json:"triggerInterval"`
CustomMessage string `json:"customMessage"`
} }
type putAlertsBody struct { type putAlertsBody struct {
@ -20,6 +21,7 @@ type putAlertsBody struct {
Name string `json:"name"` Name string `json:"name"`
Condition string `json:"condition"` Condition string `json:"condition"`
TriggerInterval int64 `json:"triggerInterval"` TriggerInterval int64 `json:"triggerInterval"`
CustomMessage string `json:"customMessage"`
} }
func GetAlerts(s *app.Server) gin.HandlerFunc { func GetAlerts(s *app.Server) gin.HandlerFunc {
@ -44,7 +46,7 @@ func PostAlerts(s *app.Server) gin.HandlerFunc {
return return
} }
alert, err := s.Services.Alerts.Create(body.ContactPointId, body.Name, body.Condition, body.TriggerInterval) alert, err := s.Services.Alerts.Create(body.ContactPointId, body.Name, body.Condition, body.TriggerInterval, body.CustomMessage)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
@ -71,7 +73,7 @@ func PutAlert(s *app.Server) gin.HandlerFunc {
return return
} }
alert, err := s.Services.Alerts.Update(alertId, body.ContactPointId, body.Name, body.Condition, body.TriggerInterval) alert, err := s.Services.Alerts.Update(alertId, body.ContactPointId, body.Name, body.Condition, body.TriggerInterval, body.CustomMessage)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)

View File

@ -20,6 +20,11 @@ type putContactPointsBody struct {
TypeConfig string `json:"typeConfig"` TypeConfig string `json:"typeConfig"`
} }
type testContactPointBody struct {
Type string `json:"type"`
TypeConfig string `json:"typeConfig"`
}
func GetContactPoints(s *app.Server) gin.HandlerFunc { func GetContactPoints(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
contactPoints, err := s.Services.ContactPoints.GetList() contactPoints, err := s.Services.ContactPoints.GetList()
@ -116,7 +121,27 @@ func DeleteContactPoint(s *app.Server) gin.HandlerFunc {
return return
} }
c.JSON(http.StatusOK, gin.H{}) c.AbortWithStatus(http.StatusOK)
}
}
func TestContactPoint(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
body := testContactPointBody{}
if err := c.BindJSON(&body); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
err := s.Services.ContactPoints.Test(body.Type, body.TypeConfig)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.AbortWithStatus(http.StatusOK)
} }
} }

View File

@ -43,7 +43,7 @@ func (s *AlertsService) EvaluateAlerts() error {
} }
func unitToSeconds(unit string) int64 { func unitToSeconds(unit string) float64 {
switch unit { switch unit {
case "s": case "s":
return 1 return 1
@ -76,7 +76,7 @@ func (s *AlertsService) EvaluateAlert(alert *models.AlertItem) error {
case "sensor_value": case "sensor_value":
{ {
sensorValueCondition = models.AlertConditionSensorValue{ sensorValueCondition = models.AlertConditionSensorValue{
SensorId: condition["sensorId"].(int64), SensorId: int64(condition["sensorId"].(float64)),
Condition: condition["condition"].(string), Condition: condition["condition"].(string),
Value: condition["value"].(float64), Value: condition["value"].(float64),
} }
@ -97,8 +97,8 @@ func (s *AlertsService) EvaluateAlert(alert *models.AlertItem) error {
case "sensor_last_contact": case "sensor_last_contact":
{ {
sensorLastContactCondition := models.AlertConditionSensorLastContact{ sensorLastContactCondition := models.AlertConditionSensorLastContact{
SensorId: condition["sensorId"].(int64), SensorId: int64(condition["sensorId"].(float64)),
Value: condition["value"].(int64), Value: condition["value"].(float64),
ValueUnit: condition["valueUnit"].(string), ValueUnit: condition["valueUnit"].(string),
} }
@ -109,7 +109,7 @@ func (s *AlertsService) EvaluateAlert(alert *models.AlertItem) error {
return err return err
} }
conditionInSec := sensorLastContactCondition.Value * unitToSeconds(sensorLastContactCondition.ValueUnit) conditionInSec := int64(sensorLastContactCondition.Value * unitToSeconds(sensorLastContactCondition.ValueUnit))
conditionMet = time.Now().Unix()-value.Timestamp > conditionInSec conditionMet = time.Now().Unix()-value.Timestamp > conditionInSec
@ -119,46 +119,56 @@ func (s *AlertsService) EvaluateAlert(alert *models.AlertItem) error {
} }
if conditionMet { if conditionMet {
if newStatus == "good" { if alert.LastStatus == "good" {
newStatus = "alerting" newStatus = "pending"
} else if newStatus == "pending" { } else if alert.LastStatus == "pending" {
if time.Now().Unix()-alert.LastStatusAt > alert.TriggerInterval { if time.Now().Unix()-alert.LastStatusAt >= alert.TriggerInterval {
newStatus = "alerting" newStatus = "alerting"
} }
} }
} else {
if alert.LastStatus == "alerting" {
newStatus = "pending"
} else if alert.LastStatus == "pending" {
if time.Now().Unix()-alert.LastStatusAt >= alert.TriggerInterval {
newStatus = "good"
}
}
} }
if newStatus != alert.LastStatus { if newStatus != alert.LastStatus {
s.ctx.DB.Exec("UPDATE alerts SET last_status = ?, last_status_at = ? WHERE id = ?", newStatus, time.Now().Unix(), alert.Id) s.ctx.DB.Exec("UPDATE alerts SET last_status = ?, last_status_at = ? WHERE id = ?", newStatus, time.Now().Unix(), alert.Id)
sensor, err := s.ctx.Services.Sensors.GetById(sensorId) if newStatus == "alerting" {
if err != nil { sensor, err := s.ctx.Services.Sensors.GetById(sensorId)
return err if err != nil {
} return err
}
contactPoint, err := s.ctx.Services.ContactPoints.GetById(alert.ContactPointId) contactPoint, err := s.ctx.Services.ContactPoints.GetById(alert.ContactPointId)
if err != nil { if err != nil {
return err return err
} }
dispatchService, err := contactPoint.getService(s.ctx) dispatchService, err := contactPoint.getService(s.ctx)
if err != nil { if err != nil {
return err return err
} }
err = dispatchService.ProcessEvent(&integrations.ContactPointEvent{ err = dispatchService.ProcessEvent(&integrations.ContactPointEvent{
Type: "alert", Type: integrations.ContactPointEventAlertTriggered,
AlertTriggeredEvent: &integrations.ContactPointAlertTriggeredEvent{ AlertTriggeredEvent: &integrations.ContactPointAlertTriggeredEvent{
Alert: alert, Alert: alert,
Sensor: sensor, Sensor: sensor,
SensorValueCondition: &sensorValueCondition, SensorValueCondition: &sensorValueCondition,
SensorLastContactCondition: &sensorLastContactCondition, SensorLastContactCondition: &sensorLastContactCondition,
LastValue: lastValue, LastValue: lastValue,
}, },
}, contactPoint.TypeConfig) }, contactPoint.TypeConfig)
if err != nil { if err != nil {
return err return err
}
} }
} }
@ -166,7 +176,7 @@ func (s *AlertsService) EvaluateAlert(alert *models.AlertItem) error {
} }
func (s *AlertsService) GetList() ([]*models.AlertItem, error) { func (s *AlertsService) GetList() ([]*models.AlertItem, error) {
rows, err := s.ctx.DB.Query("SELECT id, name, condition, trigger_interval, last_status, last_status_at FROM alerts ORDER BY name ASC") rows, err := s.ctx.DB.Query("SELECT id, contact_point_id, name, condition, trigger_interval, last_status, last_status_at FROM alerts ORDER BY name ASC")
if err != nil { if err != nil {
return nil, err return nil, err
@ -179,7 +189,7 @@ func (s *AlertsService) GetList() ([]*models.AlertItem, error) {
for rows.Next() { for rows.Next() {
alert := models.AlertItem{} alert := models.AlertItem{}
err := rows.Scan(&alert.Id, &alert.Name, &alert.Condition, &alert.TriggerInterval, &alert.LastStatus, &alert.LastStatusAt) err := rows.Scan(&alert.Id, &alert.ContactPointId, &alert.Name, &alert.Condition, &alert.TriggerInterval, &alert.LastStatus, &alert.LastStatusAt)
if err != nil { if err != nil {
return nil, err return nil, err
@ -199,8 +209,8 @@ func (s *AlertsService) GetList() ([]*models.AlertItem, error) {
func (s *AlertsService) GetById(id int64) (*models.AlertItem, error) { func (s *AlertsService) GetById(id int64) (*models.AlertItem, error) {
alert := models.AlertItem{} alert := models.AlertItem{}
row := s.ctx.DB.QueryRow("SELECT id, name, condition, trigger_interval, last_status, last_status_at FROM alerts WHERE id = ?", id) row := s.ctx.DB.QueryRow("SELECT id, contact_point_id, name, condition, trigger_interval, last_status, last_status_at FROM alerts WHERE id = ?", id)
err := row.Scan(&alert.Id, &alert.Name, &alert.Condition, &alert.TriggerInterval, &alert.LastStatus, &alert.LastStatusAt) err := row.Scan(&alert.Id, &alert.ContactPointId, &alert.Name, &alert.Condition, &alert.TriggerInterval, &alert.LastStatus, &alert.LastStatusAt)
if err != nil { if err != nil {
return nil, err return nil, err
@ -209,19 +219,20 @@ func (s *AlertsService) GetById(id int64) (*models.AlertItem, error) {
return &alert, nil return &alert, nil
} }
func (s *AlertsService) Create(contactPointId int64, name string, condition string, triggerInterval int64) (*models.AlertItem, error) { func (s *AlertsService) Create(contactPointId int64, name string, condition string, triggerInterval int64, customMessage string) (*models.AlertItem, error) {
alert := models.AlertItem{ alert := models.AlertItem{
ContactPointId: contactPointId, ContactPointId: contactPointId,
Name: name, Name: name,
Condition: condition, Condition: condition,
TriggerInterval: triggerInterval, TriggerInterval: triggerInterval,
CustomMessage: customMessage,
LastStatus: "good", LastStatus: "good",
LastStatusAt: 0, LastStatusAt: 0,
} }
res, err := s.ctx.DB.Exec( res, err := s.ctx.DB.Exec(
"INSERT INTO alerts (contact_point_id, name, condition, trigger_interval, last_status, last_status_at) VALUES (?, ?, ?, ?, ?)", "INSERT INTO alerts (contact_point_id, name, condition, trigger_interval, last_status, last_status_at, custom_message) VALUES (?, ?, ?, ?, ?, ?, ?)",
alert.ContactPointId, alert.Name, alert.Condition, alert.TriggerInterval, alert.LastStatus, alert.LastStatusAt, alert.ContactPointId, alert.Name, alert.Condition, alert.TriggerInterval, alert.LastStatus, alert.LastStatusAt, alert.CustomMessage,
) )
if err != nil { if err != nil {
@ -247,18 +258,19 @@ func (s *AlertsService) DeleteById(id int64) error {
return nil return nil
} }
func (s *AlertsService) Update(id int64, contactPointId int64, name string, condition string, triggerInterval int64) (*models.AlertItem, error) { func (s *AlertsService) Update(id int64, contactPointId int64, name string, condition string, triggerInterval int64, customMessage string) (*models.AlertItem, error) {
alert := models.AlertItem{ alert := models.AlertItem{
Id: id, Id: id,
ContactPointId: contactPointId, ContactPointId: contactPointId,
Name: name, Name: name,
Condition: condition, Condition: condition,
TriggerInterval: triggerInterval, TriggerInterval: triggerInterval,
CustomMessage: customMessage,
} }
_, err := s.ctx.DB.Exec( _, err := s.ctx.DB.Exec(
"UPDATE alerts SET contact_point_id = ?, name = ?, condition = ?, trigger_interval = ? WHERE id = ?", "UPDATE alerts SET contact_point_id = ?, name = ?, condition = ?, trigger_interval = ?, custom_message = ? WHERE id = ?",
alert.ContactPointId, alert.Name, alert.Condition, alert.TriggerInterval, alert.Id, alert.ContactPointId, alert.Name, alert.Condition, alert.TriggerInterval, alert.CustomMessage, alert.Id,
) )
if err != nil { if err != nil {

View File

@ -123,7 +123,12 @@ func (s *ContactPointsService) Delete(id int64) error {
return err return err
} }
func (s *ContactPointsService) Test(item *ContactPointItem) error { func (s *ContactPointsService) Test(contactPointType string, typeConfig string) error {
item := ContactPointItem{
Type: contactPointType,
TypeConfig: typeConfig,
}
service, err := item.getTestService(s.ctx) service, err := item.getTestService(s.ctx)
if err != nil { if err != nil {