diff --git a/client/src/api/alerts.ts b/client/src/api/alerts.ts index e26c668..408e7c3 100644 --- a/client/src/api/alerts.ts +++ b/client/src/api/alerts.ts @@ -6,6 +6,7 @@ export type AlertInfo = { condition: string contactPointId: number customMessage: string + customResolvedMessage: string triggerInterval: number lastStatus: string lastStatusAt: string diff --git a/client/src/assets/components/_button.scss b/client/src/assets/components/_button.scss index 7db25cd..bbeed80 100644 --- a/client/src/assets/components/_button.scss +++ b/client/src/assets/components/_button.scss @@ -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); + } } diff --git a/client/src/assets/components/_data-table.scss b/client/src/assets/components/_data-table.scss index a29e36e..745e21b 100644 --- a/client/src/assets/components/_data-table.scss +++ b/client/src/assets/components/_data-table.scss @@ -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; @@ -32,4 +32,4 @@ } } } -} \ No newline at end of file +} diff --git a/client/src/assets/components/_form.scss b/client/src/assets/components/_form.scss index b2eeec4..bd3afdf 100644 --- a/client/src/assets/components/_form.scss +++ b/client/src/assets/components/_form.scss @@ -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; + } +} \ No newline at end of file diff --git a/client/src/assets/components/_input.scss b/client/src/assets/components/_input.scss index 024799a..a3f6bc2 100644 --- a/client/src/assets/components/_input.scss +++ b/client/src/assets/components/_input.scss @@ -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; @@ -36,14 +63,14 @@ input { .input.buttons { .button-picker { - > button { + >button { margin: 0.1rem; } } } .input.date-time { - > div { + >div { display: 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; + } + } + } + } +} \ No newline at end of file diff --git a/client/src/assets/icons/archive.svg b/client/src/assets/icons/archive.svg new file mode 100644 index 0000000..a4f6a3d --- /dev/null +++ b/client/src/assets/icons/archive.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/client/src/assets/icons/times.svg b/client/src/assets/icons/times.svg new file mode 100644 index 0000000..f9d436e --- /dev/null +++ b/client/src/assets/icons/times.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/client/src/assets/icons/trash.svg b/client/src/assets/icons/trash.svg new file mode 100644 index 0000000..8d4f563 --- /dev/null +++ b/client/src/assets/icons/trash.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/client/src/assets/pages/_sensors-page.scss b/client/src/assets/pages/_sensors-page.scss index 43b7dba..602ae66 100644 --- a/client/src/assets/pages/_sensors-page.scss +++ b/client/src/assets/pages/_sensors-page.scss @@ -5,97 +5,63 @@ flex: 1; > button { - margin-left: auto; + margin-left: 1rem; } } section.content { - .sensors-list { - display: grid; - grid-template-columns: repeat(6, 1fr); - overflow: auto; - padding: 1rem; + max-width: 50rem; + padding: 1rem; + } +} - @media screen and (max-width: 1500px) { - grid-template-columns: repeat(4, 1fr); +.sensor-detail { + .auth { + .auth-value { + display: flex; + font-size: 85%; + margin: 0.25rem 0; + + .label { + width: 2rem; + background-color: #ddd; + color: #000; + padding: 0 0.25rem; + border-radius: 0.25rem; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + display: flex; + align-items: center; + justify-content: flex-end; } - @media screen and (max-width: 1200px) { - grid-template-columns: repeat(3, 1fr); - } + .value { + flex: 1; + display: flex; - @media screen and (max-width: 900px) { - grid-template-columns: repeat(2, 1fr); - } + input { + flex: 1; + border-radius: 0; + } - @media screen and (max-width: 768px) { - grid-template-columns: repeat(1, 1fr); - } - - .sensor-item { - margin: 0.25rem; - padding: 0.5rem; - - > div { + button { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + padding: 0rem 0.5rem; flex-grow: 0; flex-shrink: 0; - overflow: hidden; - text-overflow: ellipsis; - } - - > .name { - margin-bottom: 0.5rem; - } - - > .auth { - .auth-value { - display: flex; - font-size: 85%; - margin: 0.25rem 0; - - .label { - width: 2rem; - background-color: #ddd; - color: #000; - padding: 0 0.25rem; - border-radius: 0.25rem; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - display: flex; - align-items: center; - justify-content: flex-end; - } - - .value { - flex: 1; - display: flex; - - input { - flex: 1; - border-radius: 0; - } - - button { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - padding: 0rem 0.5rem; - flex-grow: 0; - flex-shrink: 0; - font-size: 120%; - } - } - } - } - - > .actions { - text-align: right; - margin-top: 0.5rem; - - button { - margin-left: 0.25rem; - } + font-size: 120%; } } } } + + .actions { + text-align: right; + margin-top: 0.5rem; + + button { + margin-left: 0.25rem; + } + } } diff --git a/client/src/assets/themes/_basic.scss b/client/src/assets/themes/_basic.scss index c2ef140..629c8a4 100644 --- a/client/src/assets/themes/_basic.scss +++ b/client/src/assets/themes/_basic.scss @@ -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 { diff --git a/client/src/components/FormCheckboxField.tsx b/client/src/components/FormCheckboxField.tsx new file mode 100644 index 0000000..6549d5b --- /dev/null +++ b/client/src/components/FormCheckboxField.tsx @@ -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 ( +
+ + {hint &&
{hint}
} +
+ ) +} diff --git a/client/src/components/FormField.tsx b/client/src/components/FormField.tsx new file mode 100644 index 0000000..2cc82c7 --- /dev/null +++ b/client/src/components/FormField.tsx @@ -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 ( +
+ + {hint &&
{hint}
} +
+ {children} +
+
+ ) +} diff --git a/client/src/components/FormSection.tsx b/client/src/components/FormSection.tsx new file mode 100644 index 0000000..aef9eac --- /dev/null +++ b/client/src/components/FormSection.tsx @@ -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 ( + + {hint &&
{hint}
} + {children} +
+ ) +} diff --git a/client/src/contexts/ConfirmModalsContext.tsx b/client/src/contexts/ConfirmModalsContext.tsx index c3dbc70..8d1454b 100644 --- a/client/src/contexts/ConfirmModalsContext.tsx +++ b/client/src/contexts/ConfirmModalsContext.tsx @@ -6,22 +6,25 @@ type Props = { children: ComponentChild } -type CreateModalProps = { - content: ComponentChild - onConfirm: () => void +type CreateModalProps = { + content: ComponentChild | ((data: TInput) => ComponentChild) + onConfirm: (data: TInput) => void onCancel?: () => void } -type CreateModalResult = { - show: () => void +type CreateModalResult = { + show: TInput extends void ? () => void : (input: TInput) => void } type ConfirmModalsContextType = { - createModal: (props: CreateModalProps) => CreateModalResult + createModal: ( + props: CreateModalProps + ) => CreateModalResult } -type ModalState = { +type ModalState = { id: string + input: TInput props: CreateModalProps } @@ -32,15 +35,23 @@ const ConfirmModalsContext = createContext( export const ConfirmModalsContextProvider = ({ children }: Props) => { const [modals, setModals] = useState([] as ModalState[]) - const createModal = useCallback((props: CreateModalProps) => { - return { - show: () => - setModals((p) => [ - ...p, - { id: new Date().getTime().toString(), props }, - ]), - } - }, []) + const createModal = useCallback( + (props: CreateModalProps) => { + return { + show: ((input: TInput) => + setModals((p) => [ + ...p, + { + id: new Date().getTime().toString(), + props, + input, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as ModalState, + ])) 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} ))} @@ -83,8 +96,10 @@ export const useConfirmModalsContext = () => { return ctx } -export const useConfirmModal = (modal: CreateModalProps) => { +export const useConfirmModal = ( + modal: CreateModalProps +) => { const ctx = useConfirmModalsContext() - return ctx.createModal(modal) + return ctx.createModal(modal) } diff --git a/client/src/icons.ts b/client/src/icons.ts index 1627d10..f1c950c 100644 --- a/client/src/icons.ts +++ b/client/src/icons.ts @@ -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' diff --git a/client/src/pages/alerts/components/AlertsTable/AlertsTable.tsx b/client/src/pages/alerts/components/AlertsTable/AlertsTable.tsx index f12b78a..88b5231 100644 --- a/client/src/pages/alerts/components/AlertsTable/AlertsTable.tsx +++ b/client/src/pages/alerts/components/AlertsTable/AlertsTable.tsx @@ -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 = () => {

Alerts

- {' '} @@ -48,14 +47,19 @@ export const AlertsTable = () => { { key: 'actions', title: 'Actions', - width: '10rem', + width: '12rem', className: 'actions', render: (c) => (
+ {' '} - +
), }, diff --git a/client/src/pages/alerts/components/AlertsTable/components/AlertFormModal.tsx b/client/src/pages/alerts/components/AlertsTable/components/AlertFormModal.tsx index 51b3560..30134e6 100644 --- a/client/src/pages/alerts/components/AlertsTable/components/AlertFormModal.tsx +++ b/client/src/pages/alerts/components/AlertsTable/components/AlertFormModal.tsx @@ -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 ( - +
@@ -125,20 +129,39 @@ export const AlertFormModal = ({ alert, open, onClose }: Props) => {
-
- +