Started working on editable dashboards

This commit is contained in:
Jan Zípek 2022-08-23 23:35:36 +02:00
parent 6f85f4a45b
commit 1d46147979
Signed by: kamen
GPG Key ID: A17882625B33AC31
24 changed files with 807 additions and 10 deletions

View File

@ -0,0 +1,41 @@
import { DashboardContent } from '@/utils/parseDashboard'
import { request } from './request'
export type DashboardInfo = {
id: string
contents: string
}
export const getDashboards = () => request<DashboardInfo[]>('/api/dashboards')
export const getDashboard = (id: string) =>
request<DashboardInfo>(`/api/dashboards/${encodeURI(id)}`)
export const createDashboard = (body: {
id: string
contents: DashboardContent
}) =>
request<DashboardInfo>(`/api/dashboards`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
...body,
contents: JSON.stringify(body.contents),
}),
})
export const updateDashboard = ({
id,
...body
}: {
id: string
contents: DashboardContent
}) =>
request<DashboardInfo>(`/api/dashboards/${encodeURI(id)}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
...body,
contents: JSON.stringify(body.contents),
}),
})

View File

@ -194,3 +194,59 @@ form.horizontal .input label {
.checkbox-label input[type=checkbox] {
margin-top: 6px;
}
.grid-sensors {
position: relative;
margin: 0.25rem;
}
.grid-sensors .grid-box {
position: absolute;
transition: all 0.2s;
padding: 0.25rem;
box-sizing: border-box;
}
.grid-sensors .grid-box.dragging {
transition: none;
}
.grid-sensors .grid-box .box {
position: relative;
}
.grid-sensors .grid-box .box .resize-h {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 10px;
cursor: e-resize;
}
.grid-sensors .grid-box .box .resize-v {
position: absolute;
right: 0;
left: 0;
bottom: 0;
height: 10px;
cursor: s-resize;
}
.grid-sensors .grid-box .box .resize {
position: absolute;
right: 0;;
bottom: 0;
width: 10px;
height: 10px;
cursor: se-resize;
}
.grid-sensors .box-preview {
position: absolute;
background-color: #3988FF;
opacity: 0.5;
transition: all 0.2s;
z-index: 1;
border-radius: 0.5rem;
}

View File

@ -1,5 +1,5 @@
import { useAppContext } from '@/contexts/AppContext'
import { DashboardPage } from './dashboard/DashboardPage'
import { NewDashboardPage } from './dashboard/NewDashboardPage'
import { LoginPage } from './login/LoginPage'
export const Router = () => {
@ -7,7 +7,7 @@ export const Router = () => {
return (
<>
{loggedIn && <DashboardPage />}
{loggedIn && <NewDashboardPage />}
{!loggedIn && <LoginPage />}
</>
)

View File

@ -0,0 +1,59 @@
import { createDashboard, getDashboards } 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'
export const NewDashboardPage = () => {
const dashboards = useQuery(['/dashboards'], getDashboards)
const dashboard = dashboards.data?.find((i) => i.id === 'default')
const dashboardContent = useMemo(
() => (dashboard ? parseDashboard(dashboard.contents) : undefined),
[dashboard]
)
// Terrible code - ensure there's default dashboard
useEffect(() => {
if (dashboards.data && !dashboard) {
createDashboard({
id: 'default',
contents: createDashboardContent(),
}).then(() => {
dashboards.refetch()
})
}
}, [dashboards.data, dashboard])
const [boxes, setBoxes] = useState(dashboardContent?.boxes ?? [])
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])
})
}}
/>
))}
</div>
)
}

View File

@ -0,0 +1,136 @@
import { getElementPosition } from '@/utils/getElementPosition'
import { useWindowEvent } from '@/utils/hooks/useWindowEvent'
import { useRef } from 'preact/hooks'
import { GRID_WIDTH } from '../constants'
import { ResizingMode, useResize } from '../hooks/useResize'
import { useDragging } from '../hooks/useDragging'
import { BoxDefinition } from '../types'
type Props = {
box: BoxDefinition
boxes: BoxDefinition[]
onPosition: (p: { x: number; y: number }) => void
onResize: (p: { w: number; h: number }) => void
}
export const EditableBox = ({ box, boxes, onPosition, onResize }: Props) => {
const boxRef = useRef<HTMLDivElement>(null)
const outerWidth = boxRef.current?.parentElement?.offsetWidth ?? 100
const cellWidth = outerWidth / GRID_WIDTH
const { dragging, setDragging, draggingPosition } = useDragging({
cellWidth,
box,
boxes,
})
const { resizing, setResizing, resizingSize } = useResize({
box,
boxes,
cellWidth,
})
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault()
if (!dragging) {
const pos = getElementPosition(e.target as HTMLDivElement)
setDragging({
active: true,
x: pos.left,
y: pos.top,
offsetX: e.clientX - pos.left,
offsetY: e.clientY - pos.top,
})
}
}
const handleResizeDown = (target: ResizingMode) => (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!resizing) {
setResizing({
mode: target,
w: (box.w / GRID_WIDTH) * outerWidth,
h: box.h,
offsetX: e.clientX,
offsetY: e.clientY,
})
}
}
useWindowEvent('mouseup', () => {
if (dragging) {
onPosition(draggingPosition)
setDragging({ ...dragging, active: false })
}
if (resizing.mode !== ResizingMode.NONE) {
onResize({ w: resizingSize.w, h: resizingSize.h })
setResizing({ ...resizing, mode: ResizingMode.NONE })
}
})
return (
<>
<div
ref={boxRef}
className={`grid-box${dragging ? ' dragging' : ''}`}
onMouseDown={handleMouseDown}
style={{
left: (box.x / GRID_WIDTH) * 100 + '%',
top: box.y + 'px',
width: (box.w / GRID_WIDTH) * 100 + '%',
height: box.h + 'px',
...(dragging.active && {
left: dragging.x,
top: dragging.y,
zIndex: 2,
}),
}}
>
<div className="box" style={{ height: '100%' }}>
<div
className="resize-h"
onMouseDown={handleResizeDown(ResizingMode.WIDTH)}
/>
<div
className="resize-v"
onMouseDown={handleResizeDown(ResizingMode.HEIGHT)}
/>
<div
className="resize"
onMouseDown={handleResizeDown(ResizingMode.ALL)}
/>
</div>
</div>
{resizing.mode !== ResizingMode.NONE && (
<div
className={'box-preview'}
style={{
left: (box.x / GRID_WIDTH) * 100 + '%',
top: box.y + 'px',
width: (resizingSize.w / GRID_WIDTH) * 100 + '%',
height: resizingSize.h + 'px',
}}
/>
)}
{dragging.active && (
<div
className={'box-preview'}
style={{
left: (draggingPosition.x / GRID_WIDTH) * 100 + '%',
top: draggingPosition.y,
width: (box.w / GRID_WIDTH) * 100 + '%',
height: box.h + 'px',
}}
/>
)}
</>
)
}

View File

@ -0,0 +1 @@
export const GRID_WIDTH = 20

View File

@ -0,0 +1,68 @@
import { useWindowEvent } from '@/utils/hooks/useWindowEvent'
import { useState } from 'preact/hooks'
import { BoxDefinition } from '../types'
import { GRID_WIDTH } from '../constants'
type Props = {
cellWidth: number
boxes: BoxDefinition[]
box: BoxDefinition
}
// TODO: This is not optimized at all
export const useDragging = ({ cellWidth, boxes, box }: Props) => {
const [state, setState] = useState({
active: false,
x: 0,
y: 0,
offsetX: 0,
offsetY: 0,
})
const actualX = Math.max(
0,
Math.min(GRID_WIDTH - box.w, Math.round(state.x / cellWidth))
)
const dragY = Math.round(state.y / 16) * 16
const gridHeights = Array(GRID_WIDTH)
.fill(null)
.map((_, index) => {
return boxes
.filter(
(b) =>
b.id !== box.id &&
b.x <= index &&
b.x + b.w > index &&
((b.y < dragY + box.h && dragY < b.y + b.h) || b.y < dragY)
)
.reduce(
(acc, item) => (item.y + item.h > acc ? item.y + item.h : acc),
0
)
})
useWindowEvent('mousemove', (e) => {
if (state.active) {
setState({
...state,
x: e.clientX - state.offsetX,
y: e.clientY - state.offsetY,
})
}
})
return {
dragging: state,
setDragging: setState,
draggingPosition: {
x: actualX,
y: Math.max(
...Array(box.w)
.fill(null)
.map((_, x) => gridHeights[actualX + x])
),
},
}
}

View File

@ -0,0 +1,105 @@
import { useWindowEvent } from '@/utils/hooks/useWindowEvent'
import { useState } from 'preact/hooks'
import { BoxDefinition } from '../types'
import { GRID_WIDTH } from '../constants'
export enum ResizingMode {
NONE,
WIDTH,
HEIGHT,
ALL,
}
type Props = {
cellWidth: number
boxes: BoxDefinition[]
box: BoxDefinition
}
// TODO: This is not optimized at all
export const useResize = ({ cellWidth, box, boxes }: Props) => {
const [state, setState] = useState({
mode: ResizingMode.NONE,
w: 0,
h: 0,
offsetX: 0,
offsetY: 0,
})
const isResizing = state.mode !== ResizingMode.NONE
const maxHeights = isResizing
? Array(GRID_WIDTH)
.fill(null)
.map((_, index) => {
return boxes
.filter(
(b) =>
b.id !== box.id &&
b.x <= index &&
b.x + b.w > index &&
b.y > box.y
)
.reduce(
(acc, item) => (item.y - box.y > acc ? item.y - box.y : acc),
0
)
})
: []
const actualHeight = isResizing
? Math.min(
...Array(box.w)
.fill(null)
.map((_, x) => maxHeights[box.x + x])
.filter((x) => x > 0),
Math.round(state.h / 16) * 16
)
: 0
const maxWidth = isResizing
? boxes
.filter(
(b) =>
b.id !== box.id &&
b.x > box.x &&
b.y < box.y + box.h &&
box.y < b.y + b.h
)
.map((b) => b.x - box.x)
.reduce((acc, item) => (item < acc ? item : acc), GRID_WIDTH)
: 0
const actualWidth = Math.min(maxWidth, Math.round(state.w / cellWidth))
useWindowEvent('mousemove', (e) => {
if (isResizing) {
const newState = {
...state,
}
if (
state.mode === ResizingMode.ALL ||
state.mode === ResizingMode.HEIGHT
) {
newState.h = box.h + e.clientY - state.offsetY
}
if (
state.mode === ResizingMode.ALL ||
state.mode === ResizingMode.WIDTH
) {
newState.w =
(box.w / GRID_WIDTH) * outerWidth + e.clientX - state.offsetX
}
setState(newState)
}
})
return {
setResizing: setState,
resizingSize: { w: actualWidth, h: actualHeight },
resizing: state,
}
}

View File

@ -0,0 +1,3 @@
import { DashboardContentBox } from '@/utils/parseDashboard'
export type BoxDefinition = DashboardContentBox

View File

@ -0,0 +1,34 @@
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]
if (box.y > 0) {
const above = boxes
.filter(
(b) =>
b.id !== box.id &&
b.x < box.x + box.w &&
box.x < b.x + b.w &&
b.y < box.y
)
.sort((a, b) => b.y - a.y)
if (above.length === 0) {
box.y = 0
i = -1
} else {
const newY = above[0].h + above[0].y
if (box.y !== newY) {
box.y = newY
i = -1
}
}
}
}
return boxes
}

View File

@ -0,0 +1,6 @@
import { DashboardContent } from './parseDashboard'
export const createDashboardContent = (): DashboardContent => ({
version: '1.0',
boxes: [],
})

View File

@ -0,0 +1,56 @@
export interface ElementPositionResult {
left: number
top: number
width: number
height: number
}
export function getElementPosition(
el: HTMLElement,
global = true
): ElementPositionResult {
if (!el) {
return {
left: 0,
top: 0,
width: 0,
height: 0,
}
}
const bb = el.getBoundingClientRect()
if (global) {
return {
left: bb.left,
top: bb.top,
width: bb.width,
height: bb.height,
}
}
let offsetLeft = el.offsetLeft
let offsetTop = el.offsetTop
let current: HTMLElement | null = el.offsetParent as HTMLElement
while (current) {
offsetLeft += current.offsetLeft
offsetTop += current.offsetTop
current = current.offsetParent as HTMLElement
if (!global && current) {
const position = getComputedStyle(current).getPropertyValue('position')
if (position === 'relative' || position === 'absolute') {
break
}
}
}
return {
left: offsetLeft,
top: offsetTop,
width: bb.width,
height: bb.height,
}
}

View File

@ -0,0 +1,27 @@
import { useCallback, useEffect, useRef } from 'preact/hooks'
export const useEvent = <E extends Event>(
target: EventTarget,
event: string,
callback: (e: E) => void,
cancelBubble = false
) => {
// Hold reference to callback
const callbackRef = useRef(callback)
callbackRef.current = callback
// Since we use ref, .current will always be correct callback
const listener = useCallback((e: Event) => {
if (callbackRef.current) {
callbackRef.current(e as E)
}
}, [])
useEffect(() => {
// Add our listener on mount
target.addEventListener(event, listener, cancelBubble)
// Remove it on dismount
return () => target.removeEventListener(event, listener)
}, [target, event, cancelBubble, listener])
}

View File

@ -0,0 +1,7 @@
import { useEvent } from './useEvent'
export const useWindowEvent = <K extends keyof WindowEventMap>(
event: K,
callback: (e: WindowEventMap[K]) => void,
cancelBubble = false
) => useEvent(window, event, callback, cancelBubble)

View File

@ -0,0 +1,18 @@
export type DashboardContent = {
version: string
boxes: DashboardContentBox[]
}
export type DashboardContentBox = {
id: string
x: number
y: number
w: number
h: number
sensor: string
title?: string
}
export const parseDashboard = (input: string) => {
return JSON.parse(input) as DashboardContent
}

View File

@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS dashboards (
id TEXT NOT NULL,
contents TEXT NOT NULL,
PRIMARY KEY (id)
);

View File

@ -39,14 +39,18 @@ func main() {
// Routes that are only accessible after logging in
loginProtected := router.Group("/", middleware.LoginAuthMiddleware(server))
loginProtected.GET("/api/sensors", routes.GetSensors(server))
loginProtected.GET("/api/sensors/:sensor/values", routes.HandleGetSensorValues(server))
loginProtected.GET("/api/sensors/:sensor/values", routes.GetSensorValues(server))
loginProtected.GET("/api/sensors/:sensor/config", routes.GetSensorConfig(server))
loginProtected.PUT("/api/sensors/:sensor/config/:key", routes.HandlePutSensorConfig(server))
loginProtected.PUT("/api/sensors/:sensor/config/:key", routes.PutSensorConfig(server))
loginProtected.GET("/api/dashboards", routes.GetDashboards(server))
loginProtected.POST("/api/dashboards", routes.PostDashboard(server))
loginProtected.GET("/api/dashboards/:id", routes.GetDashboardById(server))
loginProtected.PUT("/api/dashboards/:id", routes.PutDashboard(server))
loginProtected.POST("/api/logout", routes.Logout(server))
// Routes accessible using auth key
keyProtected := router.Group("/", middleware.KeyAuthMiddleware(server))
keyProtected.POST("/api/sensors/:sensor/values", routes.HandlePostSensorValues(server))
keyProtected.POST("/api/sensors/:sensor/values", routes.PostSensorValues(server))
// Starts session cleanup goroutine
server.StartCleaner()

View File

@ -0,0 +1,86 @@
package routes
import (
"basic-sensor-receiver/app"
"net/http"
"github.com/gin-gonic/gin"
)
type postDashboardBody struct {
Id string `json:"id"`
Contents string `json:"contents"`
}
type putDashboardBody struct {
Contents string `json:"contents"`
}
func GetDashboards(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := s.Services.Dashboards.GetList()
if err != nil {
c.AbortWithError(500, err)
return
}
c.JSON(http.StatusOK, items)
}
}
func GetDashboardById(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
item, err := s.Services.Dashboards.GetById(id)
if err != nil {
c.AbortWithError(500, err)
return
}
c.JSON(http.StatusOK, item)
}
}
func PostDashboard(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
body := postDashboardBody{}
if err := c.BindJSON(&body); err != nil {
c.AbortWithError(400, err)
return
}
item, err := s.Services.Dashboards.Create(body.Id, body.Contents)
if err != nil {
c.AbortWithError(500, err)
return
}
c.JSON(http.StatusOK, item)
}
}
func PutDashboard(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
body := putDashboardBody{}
if err := c.BindJSON(&body); err != nil {
c.AbortWithError(400, err)
return
}
item, err := s.Services.Dashboards.Update(id, body.Contents)
if err != nil {
c.AbortWithError(500, err)
return
}
c.JSON(http.StatusOK, item)
}
}

View File

@ -11,7 +11,7 @@ type sensorConfigValue struct {
Value string `json:"value"`
}
func HandlePutSensorConfig(s *app.Server) gin.HandlerFunc {
func PutSensorConfig(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
var configValue sensorConfigValue
sensor := c.Param("sensor")

View File

@ -16,7 +16,7 @@ type getSensorQuery struct {
To int64 `form:"to"`
}
func HandlePostSensorValues(s *app.Server) gin.HandlerFunc {
func PostSensorValues(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
var newValue postSensorValueBody
sensor := c.Param("sensor")
@ -35,7 +35,7 @@ func HandlePostSensorValues(s *app.Server) gin.HandlerFunc {
}
}
func HandleGetSensorValues(s *app.Server) gin.HandlerFunc {
func GetSensorValues(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
var query getSensorQuery

View File

@ -0,0 +1,83 @@
package services
type DashboardsService struct {
ctx *Context
}
type DashboardItem struct {
Id string `json:"id"`
Contents string `json:"contents"`
}
func (s *DashboardsService) GetList() ([]DashboardItem, error) {
items := make([]DashboardItem, 0)
rows, err := s.ctx.DB.Query("SELECT id, contents FROM dashboards")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
item := DashboardItem{}
err := rows.Scan(&item.Id, &item.Contents)
if err != nil {
return nil, err
}
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return items, nil
}
func (s *DashboardsService) Create(id string, contents string) (*DashboardItem, error) {
item := DashboardItem{
Id: id,
Contents: contents,
}
_, err := s.ctx.DB.Exec("INSERT INTO dashboards (id, contents) VALUES (?, ?)", item.Id, item.Contents)
if err != nil {
return nil, err
}
return &item, nil
}
func (s *DashboardsService) Update(id string, contents string) (*DashboardItem, error) {
item := DashboardItem{
Id: id,
Contents: contents,
}
_, err := s.ctx.DB.Exec("UPDATE dashboards SET contents = ? WHERE id = ?", contents, id)
if err != nil {
return nil, err
}
return &item, nil
}
func (s *DashboardsService) GetById(id string) (*DashboardItem, error) {
item := DashboardItem{}
row := s.ctx.DB.QueryRow("SELECT id, contents FROM dashboards WHERE id = ?", id)
err := row.Scan(&item.Id, &item.Contents)
if err != nil {
return nil, err
}
return &item, nil
}

View File

@ -24,7 +24,7 @@ func (s *SensorValuesService) Push(sensor string, value float64) (int64, error)
func (s *SensorValuesService) GetList(sensor string, from int64, to int64) ([]sensorValue, error) {
var value float64
var timestamp int64
var values []sensorValue
values := make([]sensorValue, 0)
rows, err := s.ctx.DB.Query("SELECT timestamp, value FROM sensor_values WHERE sensor = ? AND timestamp > ? AND timestamp < ? ORDER BY timestamp ASC", sensor, from, to)

View File

@ -11,7 +11,7 @@ type sensorItem struct {
}
func (s *SensorsService) GetList() ([]sensorItem, error) {
var sensors []sensorItem
sensors := make([]sensorItem, 0)
var sensor string
var lastUpdate string

View File

@ -11,6 +11,7 @@ type Services struct {
Sensors *SensorsService
Sessions *SessionsService
Auth *AuthService
Dashboards *DashboardsService
}
type Context struct {
@ -29,6 +30,7 @@ func InitializeServices(ctx *Context) *Services {
services.Sensors = &SensorsService{ctx: ctx}
services.Sessions = &SessionsService{ctx: ctx}
services.Auth = &AuthService{ctx: ctx}
services.Dashboards = &DashboardsService{ctx: ctx}
return &services
}