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 condition: string
contactPointId: number contactPointId: number
customMessage: string customMessage: string
customResolvedMessage: string
triggerInterval: number triggerInterval: number
lastStatus: string lastStatus: string
lastStatusAt: string lastStatusAt: string

View File

@ -1,4 +1,5 @@
button { button,
.button {
font-family: var(--main-font); font-family: var(--main-font);
background: var(--button-bg-color); background: var(--button-bg-color);
color: var(--button-fg-color); color: var(--button-fg-color);
@ -10,6 +11,17 @@ button {
align-items: center; align-items: center;
justify-content: 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 { &:hover {
background-color: var(--button-hover-bg-color); background-color: var(--button-hover-bg-color);
color: var(--button-hover-fg-color); color: var(--button-hover-fg-color);
@ -28,10 +40,19 @@ button {
color: var(--button-cancel-fg-color); color: var(--button-cancel-fg-color);
} }
> .icon {
margin-right: 0.2rem;
}
> .svg-icon { > .svg-icon {
display: flex; display: flex;
align-items: center; align-items: center;
margin-top: 1px; margin-top: 1px;
margin-right: 0.2rem; 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; flex-direction: column;
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
padding: 0.25rem 0.5rem; padding: 0.4rem 0.5rem;
&.actions { &.actions {
align-items: flex-end; align-items: flex-end;

View File

@ -2,18 +2,10 @@ form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.input {
display: flex;
flex-direction: column;
margin-bottom: 0.5rem;
label {
margin-bottom: 0.2rem;
}
}
.actions { .actions {
text-align: right; display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 1rem; margin-top: 1rem;
button { button {
@ -24,6 +16,12 @@ form {
background-color: var(--button-cancel-bg-color); background-color: var(--button-cancel-bg-color);
color: var(--button-cancel-fg-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, select,
input { input,
textarea {
font-family: var(--main-font); font-family: var(--main-font);
padding: 0.15rem 0.4rem; padding: 0.15rem 0.4rem;
box-sizing: border-box; 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 { .checkbox-label {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -58,3 +85,68 @@ input {
flex-shrink: 0; 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; flex: 1;
> button { > button {
margin-left: auto; margin-left: 1rem;
} }
} }
section.content { section.content {
.sensors-list { max-width: 50rem;
display: grid;
grid-template-columns: repeat(6, 1fr);
overflow: auto;
padding: 1rem; padding: 1rem;
}
@media screen and (max-width: 1500px) {
grid-template-columns: repeat(4, 1fr);
} }
@media screen and (max-width: 1200px) { .sensor-detail {
grid-template-columns: repeat(3, 1fr); .auth {
}
@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 {
.auth-value { .auth-value {
display: flex; display: flex;
font-size: 85%; font-size: 85%;
@ -87,7 +56,7 @@
} }
} }
> .actions { .actions {
text-align: right; text-align: right;
margin-top: 0.5rem; margin-top: 0.5rem;
@ -96,6 +65,3 @@
} }
} }
} }
}
}
}

View File

@ -12,6 +12,8 @@
--button-cancel-fg-color: #000; --button-cancel-fg-color: #000;
--button-remove-bg-color: transparent; --button-remove-bg-color: transparent;
--button-remove-fg-color: #f00; --button-remove-fg-color: #f00;
--button-disabled-bg-color: #91a9b0;
--button-disabled-fg-color: #000;
--header-bg-color: #fff; --header-bg-color: #fff;
--header-spacer-color: #ddd; --header-spacer-color: #ddd;
--header-shadow: linear-gradient( --header-shadow: linear-gradient(
@ -48,8 +50,11 @@
--input-focus-border-color: #3988ff; --input-focus-border-color: #3988ff;
--input-bg-color: #fff; --input-bg-color: #fff;
--input-fg-color: #000; --input-fg-color: #000;
--input-appendix-bg-color: #eee;
--input-appendix-fg-color: #000;
--input-disabled-fg-color: #000; --input-disabled-fg-color: #000;
--input-disabled-bg-color: #fff; --input-disabled-bg-color: #fff;
--input-hint-color: #444;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -76,6 +81,8 @@
--input-focus-border-color: #666; --input-focus-border-color: #666;
--input-bg-color: #222; --input-bg-color: #222;
--input-fg-color: #ccc; --input-fg-color: #ccc;
--input-appendix-bg-color: #333;
--input-appendix-fg-color: #ccc;
--input-disabled-fg-color: #bbb; --input-disabled-fg-color: #bbb;
--input-disabled-bg-color: #2a2a2a; --input-disabled-bg-color: #2a2a2a;
--menu-shadow: linear-gradient( --menu-shadow: linear-gradient(
@ -83,6 +90,9 @@
rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.2) 100% 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 { .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 children: ComponentChild
} }
type CreateModalProps = { type CreateModalProps<TInput = void> = {
content: ComponentChild content: ComponentChild | ((data: TInput) => ComponentChild)
onConfirm: () => void onConfirm: (data: TInput) => void
onCancel?: () => void onCancel?: () => void
} }
type CreateModalResult = { type CreateModalResult<TInput = void> = {
show: () => void show: TInput extends void ? () => void : (input: TInput) => void
} }
type ConfirmModalsContextType = { type ConfirmModalsContextType = {
createModal: (props: CreateModalProps) => CreateModalResult createModal: <TInput = void>(
props: CreateModalProps<TInput>
) => CreateModalResult<TInput>
} }
type ModalState = { type ModalState<TInput = void> = {
id: string id: string
input: TInput
props: CreateModalProps props: CreateModalProps
} }
@ -32,15 +35,23 @@ const ConfirmModalsContext = createContext<ConfirmModalsContextType | null>(
export const ConfirmModalsContextProvider = ({ children }: Props) => { export const ConfirmModalsContextProvider = ({ children }: Props) => {
const [modals, setModals] = useState([] as ModalState[]) const [modals, setModals] = useState([] as ModalState[])
const createModal = useCallback((props: CreateModalProps) => { const createModal = useCallback(
<TInput = void,>(props: CreateModalProps<TInput>) => {
return { return {
show: () => show: ((input: TInput) =>
setModals((p) => [ setModals((p) => [
...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) => { const handleClose = (modal: ModalState) => {
setModals((p) => p.filter((m) => m.id !== modal.id)) setModals((p) => p.filter((m) => m.id !== modal.id))
@ -51,7 +62,7 @@ export const ConfirmModalsContextProvider = ({ children }: Props) => {
const handleConfirm = (modal: ModalState) => { const handleConfirm = (modal: ModalState) => {
setModals((p) => p.filter((m) => m.id !== modal.id)) setModals((p) => p.filter((m) => m.id !== modal.id))
modal.props.onConfirm() modal.props.onConfirm(modal.input)
} }
const value = useMemo(() => ({ createModal }), [createModal]) const value = useMemo(() => ({ createModal }), [createModal])
@ -66,7 +77,9 @@ export const ConfirmModalsContextProvider = ({ children }: Props) => {
onCancel={() => handleClose(m)} onCancel={() => handleClose(m)}
onConfirm={() => handleConfirm(m)} onConfirm={() => handleConfirm(m)}
> >
{m.props.content} {typeof m.props.content === 'function'
? m.props.content(m.input)
: m.props.content}
</ConfirmModal> </ConfirmModal>
))} ))}
</ConfirmModalsContext.Provider> </ConfirmModalsContext.Provider>
@ -83,8 +96,10 @@ export const useConfirmModalsContext = () => {
return ctx return ctx
} }
export const useConfirmModal = (modal: CreateModalProps) => { export const useConfirmModal = <TInput = void,>(
modal: CreateModalProps<TInput>
) => {
const ctx = useConfirmModalsContext() 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 EditIcon } from '@/assets/icons/edit.svg'
export { ReactComponent as ClipboardCopyIcon } from '@/assets/icons/clipboard-copy.svg' export { ReactComponent as ClipboardCopyIcon } from '@/assets/icons/clipboard-copy.svg'
export { ReactComponent as ClipboardCheckIcon } from '@/assets/icons/clipboard-check.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 { AlertInfo, deleteAlert, getAlerts } from '@/api/alerts'
import { ConfirmModal } from '@/components/ConfirmModal' import { ConfirmModal } from '@/components/ConfirmModal'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { EditIcon, PlusIcon, TrashIcon } from '@/icons'
import { useState } from 'preact/hooks' import { useState } from 'preact/hooks'
import { useMutation, useQuery } from 'react-query' import { useMutation, useQuery } from 'react-query'
import { AlertFormModal } from './components/AlertFormModal' import { AlertFormModal } from './components/AlertFormModal'
import { EditIcon, PlusIcon, RefreshIcon } from '@/icons'
export const AlertsTable = () => { export const AlertsTable = () => {
const alerts = useQuery('/alerts', () => getAlerts()) const alerts = useQuery('/alerts', () => getAlerts(), {
refetchInterval: 500,
})
const deleteMutation = useMutation(deleteAlert, { const deleteMutation = useMutation(deleteAlert, {
onSuccess: () => { onSuccess: () => {
@ -25,9 +27,6 @@ export const AlertsTable = () => {
<div className="section-title"> <div className="section-title">
<h2>Alerts</h2> <h2>Alerts</h2>
<div> <div>
<button onClick={() => alerts.refetch()}>
<RefreshIcon /> Refresh
</button>{' '}
<button onClick={() => setShowNew(true)}> <button onClick={() => setShowNew(true)}>
<PlusIcon /> Add alert <PlusIcon /> Add alert
</button> </button>
@ -48,14 +47,19 @@ export const AlertsTable = () => {
{ {
key: 'actions', key: 'actions',
title: 'Actions', title: 'Actions',
width: '10rem', width: '12rem',
className: 'actions', className: 'actions',
render: (c) => ( render: (c) => (
<div> <div>
<button
onClick={() => setShowDelete(c)}
className="danger-variant"
>
<TrashIcon /> Delete
</button>
<button onClick={() => setEdited(c)}> <button onClick={() => setEdited(c)}>
<EditIcon /> Edit <EditIcon /> Edit
</button>{' '} </button>
<button onClick={() => setShowDelete(c)}>Delete</button>
</div> </div>
), ),
}, },

View File

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

View File

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

View File

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

View File

@ -1,16 +1,30 @@
import { getSensors, SensorInfo } from '@/api/sensors' import { deleteSensor, getSensors, SensorInfo } from '@/api/sensors'
import { PlusIcon } from '@/icons' import { DataTable } from '@/components/DataTable'
import { EditIcon, EyeIcon, PlusIcon, TrashIcon } from '@/icons'
import { UserLayout } from '@/layouts/UserLayout/UserLayout' import { UserLayout } from '@/layouts/UserLayout/UserLayout'
import { useState } from 'preact/hooks' import { useState } from 'preact/hooks'
import { useQuery } from 'react-query' import { useMutation, useQuery, useQueryClient } from 'react-query'
import { SensorFormModal } from './components/SensorFormModal' import { SensorFormModal } from './components/SensorFormModal'
import { SensorItem } from './components/SensorItem' import { useConfirmModal } from '@/contexts/ConfirmModalsContext'
import { SensorDetailModal } from './components/SensorDetailModal'
export const SensorsPage = () => { export const SensorsPage = () => {
const sensors = useQuery(['/sensors'], getSensors) const sensors = useQuery(['/sensors'], getSensors)
const queryClient = useQueryClient()
const deleteMutation = useMutation(deleteSensor, {
onSuccess: () => queryClient.invalidateQueries(['/sensors']),
})
const [showNew, setShowNew] = useState(false) const [showNew, setShowNew] = useState(false)
const [edited, setEdited] = useState<SensorInfo>() 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 ( return (
<UserLayout <UserLayout
@ -24,11 +38,38 @@ export const SensorsPage = () => {
} }
className="sensors-page" className="sensors-page"
> >
<div className="sensors-list"> <div className="box-shadow">
{sensors.data?.map((i) => ( <DataTable
<SensorItem key={i.id} sensor={i} onEdit={setEdited} /> 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>
),
},
]}
/>
</div>
{(showNew || edited) && ( {(showNew || edited) && (
<SensorFormModal <SensorFormModal
open open
@ -39,6 +80,18 @@ export const SensorsPage = () => {
}} }}
/> />
)} )}
{shown && (
<SensorDetailModal
open
sensor={shown}
onClose={() => setShown(undefined)}
onEdit={() => {
setShown(undefined)
setEdited(shown)
}}
/>
)}
</UserLayout> </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 { if data.SensorValueCondition != nil {
text := fmt.Sprintf("✅ %s is at {value}", data.Sensor.Name) text := fmt.Sprintf("✅ %s is at {value}", data.Sensor.Name)
if data.Alert.CustomMessage != "" { if data.Alert.CustomResolvedMessage != "" {
text = data.Alert.CustomMessage text = data.Alert.CustomResolvedMessage
} }
text = strings.Replace(text, "{value}", strconv.FormatFloat(data.LastValue, 'f', -1, 64), -1) 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"` Name string `json:"name" db:"name"`
Condition string `json:"condition" db:"condition"` Condition string `json:"condition" db:"condition"`
CustomMessage string `json:"customMessage" db:"custom_message"` 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 */ /* how long does the condition have to be true for the alert to go off */
TriggerInterval int64 `json:"triggerInterval" db:"trigger_interval"` TriggerInterval int64 `json:"triggerInterval" db:"trigger_interval"`

View File

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

View File

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