Improved sensors page

This commit is contained in:
Jan Zípek 2024-03-31 20:47:43 +02:00
parent 4c19e89c1c
commit badde4a20e
Signed by: kamen
GPG Key ID: A17882625B33AC31
26 changed files with 578 additions and 224 deletions

View File

@ -6,6 +6,7 @@ export type AlertInfo = {
condition: string
contactPointId: number
customMessage: string
customResolvedMessage: string
triggerInterval: number
lastStatus: string
lastStatusAt: string

View File

@ -1,4 +1,5 @@
button {
button,
.button {
font-family: var(--main-font);
background: var(--button-bg-color);
color: var(--button-fg-color);
@ -10,6 +11,17 @@ button {
align-items: center;
justify-content: center;
&:disabled {
background-color: var(--button-disabled-bg-color);
color: var(--button-disabled-fg-color);
cursor: default;
&:hover {
background-color: var(--button-disabled-bg-color);
color: var(--button-disabled-fg-color);
}
}
&:hover {
background-color: var(--button-hover-bg-color);
color: var(--button-hover-fg-color);
@ -28,10 +40,19 @@ button {
color: var(--button-cancel-fg-color);
}
> .icon {
margin-right: 0.2rem;
}
> .svg-icon {
display: flex;
align-items: center;
margin-top: 1px;
margin-right: 0.2rem;
}
&.danger-variant {
background-color: var(--button-remove-bg-color);
color: var(--button-remove-fg-color);
}
}

View File

@ -11,7 +11,7 @@
flex-direction: column;
justify-content: center;
align-items: flex-start;
padding: 0.25rem 0.5rem;
padding: 0.4rem 0.5rem;
&.actions {
align-items: flex-end;

View File

@ -2,18 +2,10 @@ form {
display: flex;
flex-direction: column;
.input {
display: flex;
flex-direction: column;
margin-bottom: 0.5rem;
label {
margin-bottom: 0.2rem;
}
}
.actions {
text-align: right;
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 1rem;
button {
@ -24,6 +16,12 @@ form {
background-color: var(--button-cancel-bg-color);
color: var(--button-cancel-fg-color);
}
button.remove {
margin-right: auto;
background-color: var(--button-remove-bg-color);
color: var(--button-remove-fg-color);
}
}
}
@ -42,3 +40,32 @@ form.horizontal {
}
}
}
fieldset {
margin-bottom: 0.75rem;
border-color: var(--input-border-color);
.input:last-child {
margin-bottom: 0;
}
}
.form-section {
margin-bottom: 1.5rem;
border: 1px solid var(--input-border-color);
.input:last-child {
margin-bottom: 0;
}
.form-section {
margin-bottom: 1rem;
}
>.body>.hint {
color: var(--input-hint-color);
font-size: 90%;
margin-top: 0rem;
margin-bottom: 0.5rem;
}
}

View File

@ -1,5 +1,6 @@
select,
input {
input,
textarea {
font-family: var(--main-font);
padding: 0.15rem 0.4rem;
box-sizing: border-box;
@ -25,6 +26,32 @@ input {
}
}
.input-container {
display: flex;
.input-suffix {
flex-shrink: 0;
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius);
border-top-left-radius: 0;
border-bottom-left-radius: 0;
margin-left: -2px;
color: var(--input-appendix-fg-color);
background-color: var(--input-appendix-bg-color);
>span {
display: flex;
align-items: center;
padding: 0 0.3rem;
height: 100%;
}
>button {
height: 100%;
}
}
}
.checkbox-label {
display: inline-flex;
align-items: center;
@ -58,3 +85,68 @@ input {
flex-shrink: 0;
}
}
.input {
display: flex;
flex-direction: column;
margin-bottom: 0.75rem;
&.no-margin {
margin-bottom: 0;
}
label {
margin-bottom: 0.2rem;
}
&.with-hint {
label {
margin-bottom: 0;
}
}
.hint {
color: var(--input-hint-color);
font-size: 90%;
margin-top: 0rem;
margin-bottom: 0.50rem;
}
.control {
flex: 1;
input,
select,
textarea {
width: 100%;
}
.range-input {
display: flex;
width: 100%;
align-items: center;
input {
width: 5rem;
}
.range-container {
flex: 1;
display: flex;
align-items: center;
margin-left: 1rem;
.min,
.max {
flex: 0;
}
input {
flex: 1;
margin: 0 0.5rem;
width: auto;
}
}
}
}
}

View File

@ -0,0 +1,3 @@
<svg data-slot="icon" fill="none" stroke-width="1.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m20.25 7.5-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5m6 4.125 2.25 2.25m0 0 2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25 2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z"></path>
</svg>

After

Width:  |  Height:  |  Size: 541 B

View File

@ -0,0 +1,3 @@
<svg data-slot="icon" fill="none" stroke-width="1.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"></path>
</svg>

After

Width:  |  Height:  |  Size: 244 B

View File

@ -0,0 +1,3 @@
<svg data-slot="icon" fill="none" stroke-width="1.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"></path>
</svg>

After

Width:  |  Height:  |  Size: 637 B

View File

@ -5,49 +5,18 @@
flex: 1;
> button {
margin-left: auto;
margin-left: 1rem;
}
}
section.content {
.sensors-list {
display: grid;
grid-template-columns: repeat(6, 1fr);
overflow: auto;
max-width: 50rem;
padding: 1rem;
@media screen and (max-width: 1500px) {
grid-template-columns: repeat(4, 1fr);
}
}
@media screen and (max-width: 1200px) {
grid-template-columns: repeat(3, 1fr);
}
@media screen and (max-width: 900px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (max-width: 768px) {
grid-template-columns: repeat(1, 1fr);
}
.sensor-item {
margin: 0.25rem;
padding: 0.5rem;
> div {
flex-grow: 0;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
}
> .name {
margin-bottom: 0.5rem;
}
> .auth {
.sensor-detail {
.auth {
.auth-value {
display: flex;
font-size: 85%;
@ -87,7 +56,7 @@
}
}
> .actions {
.actions {
text-align: right;
margin-top: 0.5rem;
@ -96,6 +65,3 @@
}
}
}
}
}
}

View File

@ -12,6 +12,8 @@
--button-cancel-fg-color: #000;
--button-remove-bg-color: transparent;
--button-remove-fg-color: #f00;
--button-disabled-bg-color: #91a9b0;
--button-disabled-fg-color: #000;
--header-bg-color: #fff;
--header-spacer-color: #ddd;
--header-shadow: linear-gradient(
@ -48,8 +50,11 @@
--input-focus-border-color: #3988ff;
--input-bg-color: #fff;
--input-fg-color: #000;
--input-appendix-bg-color: #eee;
--input-appendix-fg-color: #000;
--input-disabled-fg-color: #000;
--input-disabled-bg-color: #fff;
--input-hint-color: #444;
}
@media (prefers-color-scheme: dark) {
@ -76,6 +81,8 @@
--input-focus-border-color: #666;
--input-bg-color: #222;
--input-fg-color: #ccc;
--input-appendix-bg-color: #333;
--input-appendix-fg-color: #ccc;
--input-disabled-fg-color: #bbb;
--input-disabled-bg-color: #2a2a2a;
--menu-shadow: linear-gradient(
@ -83,6 +90,9 @@
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.2) 100%
);
--input-hint-color: #aaa;
--button-remove-bg-color: transparent;
--button-remove-fg-color: rgb(255, 118, 118);
}
.sensor-item .auth .auth-value .label {

View File

@ -0,0 +1,20 @@
import { cn } from '@/utils/cn'
import { ComponentChild } from 'preact'
type Props = {
label: string
name?: string
children: ComponentChild
hint?: string
}
export const FormCheckboxField = ({ label, children, name, hint }: Props) => {
return (
<div className={cn('input', hint && 'with-hint')}>
<label className="checkbox-label" htmlFor={name}>
{children} <span>{label}</span>
</label>
{hint && <div className="hint">{hint}</div>}
</div>
)
}

View File

@ -0,0 +1,28 @@
import { cn } from '@/utils/cn'
import { ComponentChild } from 'preact'
type Props = {
label: string
name?: string
children: ComponentChild
hint?: string
inputWidth?: string
}
export const FormField = ({
label,
children,
name,
hint,
inputWidth,
}: Props) => {
return (
<div className={cn('input', hint && 'with-hint')}>
<label htmlFor={name}>{label}</label>
{hint && <div className="hint">{hint}</div>}
<div className="control" style={{ width: inputWidth }}>
{children}
</div>
</div>
)
}

View File

@ -0,0 +1,33 @@
import { ComponentChild } from 'preact'
import { Box } from './Box'
type Props = {
title: string
actions?: ComponentChild
children: ComponentChild
collapsible?: boolean
defaultCollapsed?: boolean
hint?: ComponentChild
}
export const FormSection = ({
title,
actions,
children,
collapsible,
defaultCollapsed,
hint,
}: Props) => {
return (
<Box
collapsible={collapsible}
header={title}
defaultCollapsed={defaultCollapsed}
className={'form-section'}
actions={actions}
>
{hint && <div className="hint">{hint}</div>}
{children}
</Box>
)
}

View File

@ -6,22 +6,25 @@ type Props = {
children: ComponentChild
}
type CreateModalProps = {
content: ComponentChild
onConfirm: () => void
type CreateModalProps<TInput = void> = {
content: ComponentChild | ((data: TInput) => ComponentChild)
onConfirm: (data: TInput) => void
onCancel?: () => void
}
type CreateModalResult = {
show: () => void
type CreateModalResult<TInput = void> = {
show: TInput extends void ? () => void : (input: TInput) => void
}
type ConfirmModalsContextType = {
createModal: (props: CreateModalProps) => CreateModalResult
createModal: <TInput = void>(
props: CreateModalProps<TInput>
) => CreateModalResult<TInput>
}
type ModalState = {
type ModalState<TInput = void> = {
id: string
input: TInput
props: CreateModalProps
}
@ -32,15 +35,23 @@ const ConfirmModalsContext = createContext<ConfirmModalsContextType | null>(
export const ConfirmModalsContextProvider = ({ children }: Props) => {
const [modals, setModals] = useState([] as ModalState[])
const createModal = useCallback((props: CreateModalProps) => {
const createModal = useCallback(
<TInput = void,>(props: CreateModalProps<TInput>) => {
return {
show: () =>
show: ((input: TInput) =>
setModals((p) => [
...p,
{ id: new Date().getTime().toString(), props },
]),
{
id: new Date().getTime().toString(),
props,
input,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as ModalState<any>,
])) as TInput extends void ? () => void : (input: TInput) => void,
}
}, [])
},
[]
)
const handleClose = (modal: ModalState) => {
setModals((p) => p.filter((m) => m.id !== modal.id))
@ -51,7 +62,7 @@ export const ConfirmModalsContextProvider = ({ children }: Props) => {
const handleConfirm = (modal: ModalState) => {
setModals((p) => p.filter((m) => m.id !== modal.id))
modal.props.onConfirm()
modal.props.onConfirm(modal.input)
}
const value = useMemo(() => ({ createModal }), [createModal])
@ -66,7 +77,9 @@ export const ConfirmModalsContextProvider = ({ children }: Props) => {
onCancel={() => handleClose(m)}
onConfirm={() => handleConfirm(m)}
>
{m.props.content}
{typeof m.props.content === 'function'
? m.props.content(m.input)
: m.props.content}
</ConfirmModal>
))}
</ConfirmModalsContext.Provider>
@ -83,8 +96,10 @@ export const useConfirmModalsContext = () => {
return ctx
}
export const useConfirmModal = (modal: CreateModalProps) => {
export const useConfirmModal = <TInput = void,>(
modal: CreateModalProps<TInput>
) => {
const ctx = useConfirmModalsContext()
return ctx.createModal(modal)
return ctx.createModal<TInput>(modal)
}

View File

@ -9,4 +9,7 @@ export { ReactComponent as EyeOffIcon } from '@/assets/icons/eye-off.svg'
export { ReactComponent as EditIcon } from '@/assets/icons/edit.svg'
export { ReactComponent as ClipboardCopyIcon } from '@/assets/icons/clipboard-copy.svg'
export { ReactComponent as ClipboardCheckIcon } from '@/assets/icons/clipboard-check.svg'
export { ReactComponent as ChevronDown } from '@/assets/icons/chevron-down.svg'
export { ReactComponent as ChevronDownIcon } from '@/assets/icons/chevron-down.svg'
export { ReactComponent as ArchiveIcon } from '@/assets/icons/archive.svg'
export { ReactComponent as TrashIcon } from '@/assets/icons/trash.svg'
export { ReactComponent as TimesIcon } from '@/assets/icons/times.svg'

View File

@ -1,13 +1,15 @@
import { AlertInfo, deleteAlert, getAlerts } from '@/api/alerts'
import { ConfirmModal } from '@/components/ConfirmModal'
import { DataTable } from '@/components/DataTable'
import { EditIcon, PlusIcon, TrashIcon } from '@/icons'
import { useState } from 'preact/hooks'
import { useMutation, useQuery } from 'react-query'
import { AlertFormModal } from './components/AlertFormModal'
import { EditIcon, PlusIcon, RefreshIcon } from '@/icons'
export const AlertsTable = () => {
const alerts = useQuery('/alerts', () => getAlerts())
const alerts = useQuery('/alerts', () => getAlerts(), {
refetchInterval: 500,
})
const deleteMutation = useMutation(deleteAlert, {
onSuccess: () => {
@ -25,9 +27,6 @@ export const AlertsTable = () => {
<div className="section-title">
<h2>Alerts</h2>
<div>
<button onClick={() => alerts.refetch()}>
<RefreshIcon /> Refresh
</button>{' '}
<button onClick={() => setShowNew(true)}>
<PlusIcon /> Add alert
</button>
@ -48,14 +47,19 @@ export const AlertsTable = () => {
{
key: 'actions',
title: 'Actions',
width: '10rem',
width: '12rem',
className: 'actions',
render: (c) => (
<div>
<button
onClick={() => setShowDelete(c)}
className="danger-variant"
>
<TrashIcon /> Delete
</button>
<button onClick={() => setEdited(c)}>
<EditIcon /> Edit
</button>{' '}
<button onClick={() => setShowDelete(c)}>Delete</button>
</button>
</div>
),
},

View File

@ -1,6 +1,7 @@
import { AlertInfo, createAlert, updateAlert } from '@/api/alerts'
import { getContactPoints } from '@/api/contactPoints'
import { getSensors } from '@/api/sensors'
import { FormField } from '@/components/FormField'
import { Modal } from '@/components/Modal'
import { useForm } from '@/utils/hooks/useForm'
import { tryParseAlertCondition } from '@/utils/tryParseAlertCondition'
@ -28,10 +29,12 @@ export const AlertFormModal = ({ alert, open, onClose }: Props) => {
defaultValue: () => ({
name: '',
customMessage: '',
customResolvedMessage: '',
triggerInterval: 0,
...(alert && {
name: alert.name,
customMessage: alert.customMessage,
customResolvedMessage: alert.customResolvedMessage,
contactPointId: alert.contactPointId,
triggerInterval: alert.triggerInterval,
}),
@ -85,6 +88,7 @@ export const AlertFormModal = ({ alert, open, onClose }: Props) => {
name: v.name,
contactPointId: v.contactPointId,
customMessage: v.customMessage,
customResolvedMessage: v.customResolvedMessage,
triggerInterval: v.triggerInterval,
condition,
}
@ -107,7 +111,7 @@ export const AlertFormModal = ({ alert, open, onClose }: Props) => {
const conditionType = watch('conditionType')
return (
<Modal open={open} onClose={onClose}>
<Modal open={open} onClose={onClose} width="30rem">
<form onSubmit={handleSubmit}>
<div className="input">
<label>Name</label>
@ -125,20 +129,39 @@ export const AlertFormModal = ({ alert, open, onClose }: Props) => {
</select>
</div>
<div className="input">
<label>Custom Message</label>
<FormField
name="customMessage"
label="Custom Triggered Message"
hint="Message sent when alert is triggered"
>
<textarea {...register('customMessage')} />
</div>
</FormField>
<div className="input">
<label>Trigger After (seconds)</label>
<FormField
name="customResolvedMessage"
label="Custom Resolved Message"
hint="Message sent when alert is resolved"
>
<textarea {...register('customResolvedMessage')} />
</FormField>
<FormField
name="triggerInterval"
label="Trigger After"
hint="How long to wait when condition is alerting until actual alert is sent out"
>
<div className="input-container">
<input
type="number"
min={0}
required
{...register('triggerInterval', { type: 'integer' })}
/>
<div className="input-suffix">
<span>seconds</span>
</div>
</div>
</FormField>
<div className="input">
<label>Condition Type</label>
@ -165,29 +188,27 @@ export const AlertFormModal = ({ alert, open, onClose }: Props) => {
</div>
<div className="input">
<label>Condition</label>
<label>When last value is</label>
<div className="input-container">
<select required {...register('sensorValueCondition')}>
<option value="less">Less</option>
<option value="more">More</option>
<option value="less">Less than</option>
<option value="more">More than</option>
<option value="equal">Equal</option>
</select>
</div>
<div className="input">
<label>Value</label>
<input
type="number"
required
{...register('sensorValueValue', { type: 'number' })}
/>
</div>
</div>
</>
)}
{conditionType === 'sensor_last_contact' && (
<>
<div className="input">
<label>Sensor</label>
<label>Target Sensor</label>
<select
required
{...register('sensorLastContactSensorId', { type: 'integer' })}
@ -201,16 +222,13 @@ export const AlertFormModal = ({ alert, open, onClose }: Props) => {
</div>
<div className="input">
<label>Value</label>
<label>{"When sensor didn't submit anything for"}</label>
<div className="input-container">
<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>
@ -218,6 +236,7 @@ export const AlertFormModal = ({ alert, open, onClose }: Props) => {
<option value="d">Days</option>
</select>
</div>
</div>
</>
)}

View File

@ -3,12 +3,12 @@ import {
deleteContactPoint,
getContactPoints,
} from '@/api/contactPoints'
import { ConfirmModal } from '@/components/ConfirmModal'
import { DataTable } from '@/components/DataTable'
import { EditIcon, PlusIcon, TrashIcon } from '@/icons'
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'
import { PlusIcon } from '@/icons'
export const ContactPointsTable = () => {
const contactPoints = useQuery('/contact-points', () => getContactPoints())
@ -47,12 +47,16 @@ export const ContactPointsTable = () => {
{
key: 'actions',
title: 'Actions',
width: '10rem',
width: '12rem',
className: 'actions',
render: (c) => (
<div>
<button onClick={() => setEdited(c)}>Edit</button>
<button onClick={() => setShowDelete(c)}>Delete</button>
<button onClick={() => setShowDelete(c)} className="remove">
<TrashIcon /> Delete
</button>
<button onClick={() => setEdited(c)}>
<EditIcon /> Edit
</button>
</div>
),
},

View File

@ -1,4 +1,4 @@
import { ChevronDown } from '@/icons'
import { ChevronDownIcon } from '@/icons'
import { useDashboardContext } from '@/pages/dashboard/contexts/DashboardContext'
import { useFilterIntervals } from '@/pages/dashboard/hooks/useFilterIntervals'
import { cn } from '@/utils/cn'
@ -22,7 +22,7 @@ export const DashboardHeaderFilterToggle = () => {
<div className="filter-toggle">
<div className="current-value" onClick={() => setShow((v) => !v)}>
<span>{selected}</span>
<ChevronDown />
<ChevronDownIcon />
</div>
<div className={cn('filter-popup', show && 'show')}>
<div className="box">

View File

@ -1,16 +1,30 @@
import { getSensors, SensorInfo } from '@/api/sensors'
import { PlusIcon } from '@/icons'
import { deleteSensor, getSensors, SensorInfo } from '@/api/sensors'
import { DataTable } from '@/components/DataTable'
import { EditIcon, EyeIcon, PlusIcon, TrashIcon } from '@/icons'
import { UserLayout } from '@/layouts/UserLayout/UserLayout'
import { useState } from 'preact/hooks'
import { useQuery } from 'react-query'
import { useMutation, useQuery, useQueryClient } from 'react-query'
import { SensorFormModal } from './components/SensorFormModal'
import { SensorItem } from './components/SensorItem'
import { useConfirmModal } from '@/contexts/ConfirmModalsContext'
import { SensorDetailModal } from './components/SensorDetailModal'
export const SensorsPage = () => {
const sensors = useQuery(['/sensors'], getSensors)
const queryClient = useQueryClient()
const deleteMutation = useMutation(deleteSensor, {
onSuccess: () => queryClient.invalidateQueries(['/sensors']),
})
const [showNew, setShowNew] = useState(false)
const [edited, setEdited] = useState<SensorInfo>()
const [shown, setShown] = useState<SensorInfo>()
const deleteConfirm = useConfirmModal({
content: (deleted: SensorInfo) =>
`Are you sure you want to delete sensor ${deleted.name}?`,
onConfirm: (deleted) => deleteMutation.mutate(deleted.id),
})
return (
<UserLayout
@ -24,11 +38,38 @@ export const SensorsPage = () => {
}
className="sensors-page"
>
<div className="sensors-list">
{sensors.data?.map((i) => (
<SensorItem key={i.id} sensor={i} onEdit={setEdited} />
))}
<div className="box-shadow">
<DataTable
data={sensors.data ?? []}
columns={[
{ key: 'type', title: 'ID', render: (c) => c.id, width: '1rem' },
{ key: 'name', title: 'Name', render: (c) => c.name, scale: 1 },
{
key: 'actions',
title: 'Actions',
width: '15rem',
className: 'actions',
render: (c) => (
<div>
<button
className="danger-variant"
onClick={() => deleteConfirm.show(c)}
>
<TrashIcon /> Delete
</button>
<button onClick={() => setEdited(c)}>
<EditIcon /> Edit
</button>
<button onClick={() => setShown(c)}>
<EyeIcon /> Detail
</button>
</div>
),
},
]}
/>
</div>
{(showNew || edited) && (
<SensorFormModal
open
@ -39,6 +80,18 @@ export const SensorsPage = () => {
}}
/>
)}
{shown && (
<SensorDetailModal
open
sensor={shown}
onClose={() => setShown(undefined)}
onEdit={() => {
setShown(undefined)
setEdited(shown)
}}
/>
)}
</UserLayout>
)
}

View File

@ -0,0 +1,42 @@
import { SensorInfo } from '@/api/sensors'
import { InputWithCopy } from '@/components/InputWithCopy'
import { Modal } from '@/components/Modal'
import { CancelIcon, EditIcon } from '@/icons'
type Props = {
open: boolean
onClose: () => void
onEdit: () => void
sensor: SensorInfo
}
export const SensorDetailModal = ({ open, onClose, sensor, onEdit }: Props) => {
return (
<Modal open={open} onClose={onClose} className="sensor-detail">
<div className="name">{sensor.name}</div>
<div className="auth">
<div className="auth-value">
<div className="label">ID</div>
<div className="value">
<InputWithCopy value={sensor.id.toString()} />
</div>
</div>
<div className="auth-value">
<div className="label">KEY</div>
<div className="value">
<InputWithCopy value={sensor.authKey} />
</div>
</div>
</div>
<div className="actions">
<button onClick={() => onClose()} className="cancel">
<CancelIcon /> Close
</button>
<button onClick={() => onEdit()}>
<EditIcon /> Edit
</button>
</div>
</Modal>
)
}

View File

@ -0,0 +1 @@
ALTER TABLE alerts ADD COLUMN custom_resolved_message TEXT DEFAULT '' NOT NULL;

View File

@ -73,8 +73,8 @@ func (s TelegramIntegration) ProcessEvent(evt *ContactPointEvent, rawConfig stri
if data.SensorValueCondition != nil {
text := fmt.Sprintf("✅ %s is at {value}", data.Sensor.Name)
if data.Alert.CustomMessage != "" {
text = data.Alert.CustomMessage
if data.Alert.CustomResolvedMessage != "" {
text = data.Alert.CustomResolvedMessage
}
text = strings.Replace(text, "{value}", strconv.FormatFloat(data.LastValue, 'f', -1, 64), -1)

View File

@ -6,6 +6,7 @@ type AlertItem struct {
Name string `json:"name" db:"name"`
Condition string `json:"condition" db:"condition"`
CustomMessage string `json:"customMessage" db:"custom_message"`
CustomResolvedMessage string `json:"customResolvedMessage" db:"custom_resolved_message"`
/* how long does the condition have to be true for the alert to go off */
TriggerInterval int64 `json:"triggerInterval" db:"trigger_interval"`

View File

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

View File

@ -13,7 +13,7 @@ func (s *AlertsService) GetList() ([]models.AlertItem, error) {
alerts := []models.AlertItem{}
err := s.ctx.DB.Select(&alerts, `
SELECT id, contact_point_id, name, custom_message, condition, trigger_interval, last_status, last_status_at
SELECT id, contact_point_id, name, custom_message, custom_resolved_message, condition, trigger_interval, last_status, last_status_at
FROM alerts
ORDER BY name ASC
`)
@ -30,7 +30,7 @@ func (s *AlertsService) GetById(id int64) (*models.AlertItem, error) {
err := s.ctx.DB.Get(&alert,
`
SELECT id, contact_point_id, name, custom_message, condition, trigger_interval, last_status, last_status_at
SELECT id, contact_point_id, name, custom_message, custom_resolved_message, condition, trigger_interval, last_status, last_status_at
FROM alerts
WHERE id = $1
`,
@ -44,13 +44,14 @@ func (s *AlertsService) GetById(id int64) (*models.AlertItem, error) {
return &alert, nil
}
func (s *AlertsService) Create(contactPointId int64, name string, condition string, triggerInterval int64, customMessage string) (*models.AlertItem, error) {
func (s *AlertsService) Create(contactPointId int64, name string, condition string, triggerInterval int64, customMessage string, customResolvedMessage string) (*models.AlertItem, error) {
alert := models.AlertItem{
ContactPointId: contactPointId,
Name: name,
Condition: condition,
TriggerInterval: triggerInterval,
CustomMessage: customMessage,
CustomResolvedMessage: customResolvedMessage,
LastStatus: models.AlertStatusOk,
LastStatusAt: time.Now().Unix(),
}
@ -58,8 +59,8 @@ func (s *AlertsService) Create(contactPointId int64, name string, condition stri
res, err := s.ctx.DB.NamedExec(
`
INSERT INTO alerts
(contact_point_id, name, condition, trigger_interval, last_status, last_status_at, custom_message) VALUES
(:contact_point_id, :name, :condition, :trigger_interval, :last_status, :last_status_at, :custom_message)
(contact_point_id, name, condition, trigger_interval, last_status, last_status_at, custom_message, custom_resolved_message) VALUES
(:contact_point_id, :name, :condition, :trigger_interval, :last_status, :last_status_at, :custom_message, :custom_resolved_message)
`,
alert,
)
@ -77,7 +78,7 @@ func (s *AlertsService) Create(contactPointId int64, name string, condition stri
return &alert, nil
}
func (s *AlertsService) Update(id int64, contactPointId int64, name string, condition string, triggerInterval int64, customMessage string) (*models.AlertItem, error) {
func (s *AlertsService) Update(id int64, contactPointId int64, name string, condition string, triggerInterval int64, customMessage string, customResolvedMessage string) (*models.AlertItem, error) {
alert := models.AlertItem{
Id: id,
ContactPointId: contactPointId,
@ -85,6 +86,7 @@ func (s *AlertsService) Update(id int64, contactPointId int64, name string, cond
Condition: condition,
TriggerInterval: triggerInterval,
CustomMessage: customMessage,
CustomResolvedMessage: customResolvedMessage,
}
_, err := s.ctx.DB.NamedExec(
@ -95,7 +97,8 @@ func (s *AlertsService) Update(id int64, contactPointId int64, name string, cond
contact_point_id = :contact_point_id,
condition = :condition,
trigger_interval = :trigger_interval,
custom_message = :custom_message
custom_message = :custom_message,
custom_resolved_message = :custom_resolved_message
WHERE id = :id
`,
alert,