Dynamic dashboards now work

This commit is contained in:
Jan Zípek 2022-08-24 08:59:18 +02:00
parent 1d46147979
commit 6879b11b4a
Signed by: kamen
GPG Key ID: A17882625B33AC31
13 changed files with 511 additions and 158 deletions

View File

@ -198,6 +198,7 @@ form.horizontal .input label {
.grid-sensors {
position: relative;
margin: 0.25rem;
flex: 1;
}
.grid-sensors .grid-box {
@ -242,6 +243,19 @@ form.horizontal .input label {
cursor: se-resize;
}
.grid-sensors .grid-box .box .header {
display: flex;
}
.grid-sensors .grid-box .box .header .drag-handle {
flex: 1;
cursor: move;
}
.grid-sensors .grid-box .box .header .actions {
margin-left: auto;
}
.grid-sensors .box-preview {
position: absolute;
background-color: #3988FF;
@ -250,3 +264,10 @@ form.horizontal .input label {
z-index: 1;
border-radius: 0.5rem;
}
.dashboard {
height: 100%;
overflow: auto;
display: flex;
flex-direction: column;
}

View File

@ -1,42 +0,0 @@
import { getSensors, SensorInfo } from '@/api/sensors'
import { intervalToRange } from '@/utils/intervalToRange'
import { useState } from 'preact/hooks'
import { useQuery } from 'react-query'
import { Filters, FilterValue } from './components/Filters'
import { Sensor } from './components/Sensor'
import { SensorSettings } from './components/SensorSettings'
export const DashboardPage = () => {
const [editedSensor, setEditedSensor] = useState<SensorInfo>()
const [filter, setFilter] = useState<FilterValue>(() => {
const range = intervalToRange('week', new Date(), new Date())
return { interval: 'week', customFrom: range[0], customTo: range[1] }
})
const sensors = useQuery(['/sensors'], getSensors)
return (
<>
<Filters preset={filter} onApply={setFilter} />
<div className="sensors">
{sensors.data?.map((s) => (
<Sensor
sensor={s}
filter={filter}
key={s.sensor}
onEdit={setEditedSensor}
/>
))}
</div>
{editedSensor && (
<SensorSettings
sensor={editedSensor}
onClose={() => setEditedSensor(undefined)}
onUpdate={() => sensors.refetch()}
/>
)}
</>
)
}

View File

@ -1,10 +1,16 @@
import { createDashboard, getDashboards } from '@/api/dashboards'
import {
createDashboard,
getDashboards,
updateDashboard,
} from '@/api/dashboards'
import { createDashboardContent } from '@/utils/createDashboardContent'
import { parseDashboard } from '@/utils/parseDashboard'
import { useEffect, useMemo, useState } from 'preact/hooks'
import { useQuery } from 'react-query'
import { EditableBox } from './components/EditableBox'
import { normalizeBoxes } from './utils/normalizeBoxes'
import { DashboardGrid } from './components/DashboardGrid'
import { DashboardHeader } from './components/DashboardHeader'
import { DashboardContextProvider } from './contexts/DashboardContext'
import { BoxDefinition } from './types'
export const NewDashboardPage = () => {
const dashboards = useQuery(['/dashboards'], getDashboards)
@ -15,6 +21,47 @@ export const NewDashboardPage = () => {
[dashboard]
)
const [boxes, setBoxes] = useState(dashboardContent?.boxes ?? [])
const handleChange = (cb: (boxes: BoxDefinition[]) => BoxDefinition[]) => {
const newBoxes = cb(boxes)
setBoxes(newBoxes)
if (dashboard && dashboardContent) {
updateDashboard({
id: dashboard?.id,
contents: {
...dashboardContent,
boxes: newBoxes,
},
})
}
}
const handleRefresh = () => {
console.log('Nothing to refresh right now')
}
const handleNewBox = () => {
const box = {
id: new Date().getTime().toString(),
x: 0,
y: 0,
w: 12,
h: 200,
}
const otherBoxes = boxes.map((b) => {
b.y += 200
return b
})
setBoxes([box, ...otherBoxes])
// TODO: Save
}
// Terrible code - ensure there's default dashboard
useEffect(() => {
if (dashboards.data && !dashboard) {
@ -27,33 +74,16 @@ export const NewDashboardPage = () => {
}
}, [dashboards.data, dashboard])
const [boxes, setBoxes] = useState(dashboardContent?.boxes ?? [])
useEffect(() => {
setBoxes(dashboardContent?.boxes ?? [])
}, [dashboardContent])
return (
<div className="grid-sensors">
{boxes.map((b) => (
<EditableBox
box={b}
key={b.id}
boxes={boxes}
onPosition={(p) => {
setBoxes((previous) => {
b.x = p.x
b.y = p.y
return normalizeBoxes([...previous])
})
}}
onResize={(s) => {
setBoxes((previous) => {
b.w = s.w
b.h = s.h
return normalizeBoxes([...previous])
})
}}
/>
))}
<DashboardContextProvider>
<div className="dashboard">
<DashboardHeader onRefresh={handleRefresh} onNewBox={handleNewBox} />
<DashboardGrid boxes={boxes} onChange={handleChange} />
</div>
</DashboardContextProvider>
)
}

View File

@ -0,0 +1,82 @@
import { getSensorValues } from '@/api/sensorValues'
import { useEffect, useRef } from 'preact/hooks'
import { useQuery } from 'react-query'
import { useDashboardContext } from '../contexts/DashboardContext'
import { BoxDefinition } from '../types'
type Props = {
box: BoxDefinition
}
export const BoxGraphContent = ({ box }: Props) => {
const { filter } = useDashboardContext()
const bodyRef = useRef<HTMLDivElement>(null)
const valuesQuery = {
sensor: box.sensor ?? '-1',
from: filter.customFrom,
to: filter.customTo,
}
const values = useQuery(['/sensor/values', valuesQuery], () =>
getSensorValues(valuesQuery)
)
useEffect(() => {
// TODO: These should be probably returned by server, could be outdated
const from = filter.customFrom
const to = filter.customTo
const minValue = parseFloat(box.min ?? '')
const maxValue = parseFloat(box.max ?? '')
const customRange = !isNaN(minValue) && !isNaN(maxValue)
if (bodyRef.current && values.data) {
window.Plotly.newPlot(
bodyRef.current,
[
{
...(box.graphType === 'line' && {
type: 'scatter',
mode: 'lines',
}),
...(box.graphType === 'points' && {
type: 'scatter',
mode: 'markers',
}),
...(box.graphType === 'lineAndPoints' && {
type: 'scatter',
mode: 'lines+markers',
}),
...(box.graphType === 'bar' && { type: 'bar' }),
x: values.data.map((v) => new Date(v.timestamp * 1000)),
y: values.data.map((v) => v.value),
line: {
width: 1,
},
},
],
{
xaxis: { range: [from, to], type: 'date' },
yaxis: {
...(customRange && { range: [minValue, maxValue] }),
...(box.unit && { ticksuffix: ` ${box.unit}` }),
},
margin: {
l: 70,
r: 20,
b: 60,
t: 20,
pad: 5,
},
height: box.h - 50,
},
{
responsive: true,
}
)
}
}, [values.data, box])
return <div ref={bodyRef} />
}

View File

@ -0,0 +1,123 @@
import { getSensors } from '@/api/sensors'
import { useState } from 'preact/hooks'
import { useQuery } from 'react-query'
import { BoxDefinition } from '../types'
type Props = {
value: BoxDefinition
onSave: (newValue: BoxDefinition) => void
onClose: () => void
}
export const BoxSettings = ({ value, onSave, onClose }: Props) => {
const sensors = useQuery(['/sensors'], getSensors)
const [formState, setFormState] = useState(() => ({
sensor: value.sensor,
title: value.title,
min: value.min,
max: value.max,
graphType: value.graphType,
unit: value.unit,
}))
const handleSave = async (e: Event) => {
e.preventDefault()
e.stopPropagation()
onClose()
onSave({
...value,
...formState,
})
}
const handleChange = (e: Event) => {
const target = e.target as HTMLSelectElement | HTMLInputElement
setFormState({
...formState,
[target.name]: target.value,
})
}
const preventPropagation = (e: Event) => {
e.stopPropagation()
}
return (
<div className="settings-modal show" onMouseDown={onClose}>
<div
className="inner"
onMouseDown={preventPropagation}
onMouseUp={preventPropagation}
onClick={preventPropagation}
>
<div className="body">
<form onSubmit={handleSave}>
<div className="input">
<label>Sensor</label>
<select
name="sensor"
value={formState.sensor || ''}
onChange={handleChange}
>
{sensors.data?.map((s) => (
<option key={s.sensor} value={s.sensor}>
{s.config?.name ?? s.sensor}
</option>
))}
</select>
</div>
<div className="input">
<label>Title</label>
<input
name="title"
value={formState.title}
onChange={handleChange}
/>
</div>
<div className="input">
<label>Type</label>
<select
name="graphType"
value={formState.graphType || 'line'}
onChange={handleChange}
>
<option value="line">Line</option>
<option value="points">Points</option>
<option value="lineAndPoints">Line + Points</option>
<option value="bar">Bar</option>
</select>
</div>
<div className="input">
<label>Unit</label>
<input
name="unit"
value={formState.unit}
onChange={handleChange}
/>
</div>
<div className="input">
<label>Min value</label>
<input name="min" value={formState.min} onChange={handleChange} />
</div>
<div className="input">
<label>Max value</label>
<input name="max" value={formState.max} onChange={handleChange} />
</div>
<div className="actions">
<button className="cancel" onClick={onClose} type="button">
Cancel
</button>
<button>Save</button>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,45 @@
import { BoxDefinition } from '../types'
import { normalizeBoxes } from '../utils/normalizeBoxes'
import { EditableBox } from './EditableBox'
type Props = {
boxes: BoxDefinition[]
onChange: (cb: (previous: BoxDefinition[]) => BoxDefinition[]) => void
}
export const DashboardGrid = ({ boxes, onChange }: Props) => {
return (
<div className="grid-sensors">
{boxes.map((b) => (
<EditableBox
box={b}
key={b.id}
boxes={boxes}
onPosition={(p) => {
onChange((previous) =>
normalizeBoxes(
previous.map((pb) =>
pb.id === b.id ? { ...pb, x: p.x, y: p.y } : pb
)
)
)
}}
onResize={(s) => {
onChange((previous) =>
normalizeBoxes(
previous.map((pb) =>
pb.id === b.id ? { ...pb, w: s.w, h: s.h } : pb
)
)
)
}}
onEdit={(newB) => {
onChange((previous) =>
previous.map((b) => (b.id === newB.id ? newB : b))
)
}}
/>
))}
</div>
)
}

View File

@ -0,0 +1,19 @@
import { Filters } from './Filters'
type Props = {
onNewBox: () => void
onRefresh: () => void
}
export const DashboardHeader = ({ onNewBox, onRefresh }: Props) => {
return (
<div className="filters">
<div className="inner">
<button onClick={onNewBox}>Add box</button>
<Filters />
<button onClick={onRefresh}>Refresh all</button>
</div>
<div className="shadow"></div>
</div>
)
}

View File

@ -1,21 +1,32 @@
import { getElementPosition } from '@/utils/getElementPosition'
import { useWindowEvent } from '@/utils/hooks/useWindowEvent'
import { useRef } from 'preact/hooks'
import { useRef, useState } from 'preact/hooks'
import { GRID_WIDTH } from '../constants'
import { ResizingMode, useResize } from '../hooks/useResize'
import { useDragging } from '../hooks/useDragging'
import { BoxDefinition } from '../types'
import { BoxSettings } from './BoxSettings'
import { BoxGraphContent } from './BoxGraphContent'
type Props = {
box: BoxDefinition
boxes: BoxDefinition[]
onPosition: (p: { x: number; y: number }) => void
onResize: (p: { w: number; h: number }) => void
onEdit: (box: BoxDefinition) => void
}
export const EditableBox = ({ box, boxes, onPosition, onResize }: Props) => {
export const EditableBox = ({
box,
boxes,
onPosition,
onResize,
onEdit,
}: Props) => {
const boxRef = useRef<HTMLDivElement>(null)
const [editing, setEditing] = useState(false)
const outerWidth = boxRef.current?.parentElement?.offsetWidth ?? 100
const cellWidth = outerWidth / GRID_WIDTH
@ -34,8 +45,8 @@ export const EditableBox = ({ box, boxes, onPosition, onResize }: Props) => {
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault()
if (!dragging) {
const pos = getElementPosition(e.target as HTMLDivElement)
if (!dragging.active && boxRef.current) {
const pos = getElementPosition(boxRef.current)
setDragging({
active: true,
@ -51,7 +62,7 @@ export const EditableBox = ({ box, boxes, onPosition, onResize }: Props) => {
e.preventDefault()
e.stopPropagation()
if (!resizing) {
if (resizing.mode === ResizingMode.NONE) {
setResizing({
mode: target,
w: (box.w / GRID_WIDTH) * outerWidth,
@ -63,7 +74,7 @@ export const EditableBox = ({ box, boxes, onPosition, onResize }: Props) => {
}
useWindowEvent('mouseup', () => {
if (dragging) {
if (dragging.active) {
onPosition(draggingPosition)
setDragging({ ...dragging, active: false })
}
@ -79,7 +90,6 @@ export const EditableBox = ({ box, boxes, onPosition, onResize }: Props) => {
<div
ref={boxRef}
className={`grid-box${dragging ? ' dragging' : ''}`}
onMouseDown={handleMouseDown}
style={{
left: (box.x / GRID_WIDTH) * 100 + '%',
top: box.y + 'px',
@ -93,6 +103,21 @@ export const EditableBox = ({ box, boxes, onPosition, onResize }: Props) => {
}}
>
<div className="box" style={{ height: '100%' }}>
<div className="header">
<div className="drag-handle" onMouseDown={handleMouseDown}>
<div className="name">{box.title ?? box.sensor ?? ''}</div>
</div>
<div className="actions">
<button className="config" onClick={() => setEditing(true)}>
Config
</button>
<button className="refresh">Refresh</button>
</div>
</div>
<div className="body">
{box.sensor && <BoxGraphContent box={box} />}
</div>
<div
className="resize-h"
onMouseDown={handleResizeDown(ResizingMode.WIDTH)}
@ -131,6 +156,14 @@ export const EditableBox = ({ box, boxes, onPosition, onResize }: Props) => {
}}
/>
)}
{editing && (
<BoxSettings
value={box}
onSave={onEdit}
onClose={() => setEditing(false)}
/>
)}
</>
)
}

View File

@ -1,6 +1,7 @@
import { DateTimeInput } from '@/components/DateTimeInput'
import { intervalToRange } from '@/utils/intervalToRange'
import { useState } from 'preact/hooks'
import { useDashboardContext } from '../contexts/DashboardContext'
export type FilterInterval =
| 'hour'
@ -16,12 +17,9 @@ export type FilterValue = {
customTo: Date
}
type Props = {
preset: FilterValue
onApply: (v: FilterValue) => void
}
export const Filters = () => {
const { filter: preset, setFilter } = useDashboardContext()
export const Filters = ({ preset, onApply }: Props) => {
const [value, setValue] = useState(preset)
const isCustomSelected = value.interval === 'custom'
@ -30,7 +28,7 @@ export const Filters = ({ preset, onApply }: Props) => {
e.preventDefault()
e.stopPropagation()
onApply(value)
setFilter(value)
}
const handleIntervalChange = (e: Event) => {
@ -47,8 +45,6 @@ export const Filters = ({ preset, onApply }: Props) => {
}
return (
<div className="filters">
<div className="inner">
<div className="filter-form">
<form className="horizontal" onSubmit={handleSubmit}>
<div className="input">
@ -93,12 +89,5 @@ export const Filters = ({ preset, onApply }: Props) => {
<button>Apply</button>
</form>
</div>
<div className="actions">
{/*<label class="checkbox-label"><input type="checkbox"><span>auto-refresh</span></label>*/}
<button>Refresh</button>
</div>
</div>
<div className="shadow"></div>
</div>
)
}

View File

@ -0,0 +1,41 @@
import { intervalToRange } from '@/utils/intervalToRange'
import { ComponentChild, createContext } from 'preact'
import { StateUpdater, useContext, useMemo, useState } from 'preact/hooks'
import { FilterValue } from '../components/Filters'
type DashboardContextType = {
filter: FilterValue
setFilter: StateUpdater<FilterValue>
}
const DashboardContext = createContext<DashboardContextType | null>(null)
export const DashboardContextProvider = ({
children,
}: {
children: ComponentChild
}) => {
const [filter, setFilter] = useState<FilterValue>(() => {
const range = intervalToRange('week', new Date(), new Date())
return { interval: 'week', customFrom: range[0], customTo: range[1] }
})
const value = useMemo(() => ({ filter, setFilter }), [filter])
return (
<DashboardContext.Provider value={value}>
{children}
</DashboardContext.Provider>
)
}
export const useDashboardContext = () => {
const ctx = useContext(DashboardContext)
if (!ctx) {
throw new Error('useDashboardContext used outside of DashboardContext')
}
return ctx
}

View File

@ -89,8 +89,7 @@ export const useResize = ({ cellWidth, box, boxes }: Props) => {
state.mode === ResizingMode.ALL ||
state.mode === ResizingMode.WIDTH
) {
newState.w =
(box.w / GRID_WIDTH) * outerWidth + e.clientX - state.offsetX
newState.w = box.w * cellWidth + e.clientX - state.offsetX
}
setState(newState)

View File

@ -1,10 +1,16 @@
import { BoxDefinition } from '../types'
export function normalizeBoxes(boxes: BoxDefinition[]) {
// TODO: This is not optimized at all
for (let i = 0; i < boxes.length; i++) {
const box = boxes[i]
let sorted = false
// TODO: This is not optimized at all
while (!sorted) {
// Sort boxes to have the lowest ones first
boxes.sort((a, b) => a.y - b.y)
sorted = true
for (const box of boxes) {
if (box.y > 0) {
const above = boxes
.filter(
@ -18,13 +24,16 @@ export function normalizeBoxes(boxes: BoxDefinition[]) {
if (above.length === 0) {
box.y = 0
i = -1
sorted = false
break
} else {
const newY = above[0].h + above[0].y
if (box.y !== newY) {
box.y = newY
i = -1
sorted = false
break
}
}
}
}

View File

@ -9,8 +9,12 @@ export type DashboardContentBox = {
y: number
w: number
h: number
sensor: string
sensor?: string
title?: string
min?: string
max?: string
unit?: string
graphType?: string
}
export const parseDashboard = (input: string) => {