Add MQTT brokers section

This commit is contained in:
Jan Zípek 2024-04-01 19:42:20 +02:00
parent 9232a9a0d6
commit 0a54980cf1
Signed by: kamen
GPG Key ID: A17882625B33AC31
21 changed files with 630 additions and 162 deletions

View File

@ -1,21 +0,0 @@
import { request } from './request'
export const publishMqttMessage = (body: {
server: string
clientId?: string
username?: string
password?: string
qos?: number
retain?: boolean
message: string
topic: string
}) =>
request(
`/api/mqtt/publish`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
},
'void'
)

View File

@ -0,0 +1,57 @@
import { request } from './request'
export type MQTTBrokerInfo = {
id: number
name: string
address: string
username?: string
password?: string
clientId?: string
}
export const getMQTTBrokers = () =>
request<MQTTBrokerInfo[]>('/api/mqtt/brokers')
export const createMQTTBroker = (data: Omit<MQTTBrokerInfo, 'id'>) =>
request<MQTTBrokerInfo>('/api/mqtt/brokers', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(data),
})
export const updateMQTTBroker = ({
id,
...body
}: Omit<MQTTBrokerInfo, 'lastStatus' | 'lastStatusAt'>) =>
request<MQTTBrokerInfo>(`/api/mqtt/brokers/${id}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
export const deleteMQTTBroker = (id: number) =>
request<MQTTBrokerInfo, 'void'>(
`/api/mqtt/brokers/${id}`,
{ method: 'DELETE' },
'void'
)
export const publishMqttBroker = ({
mqttBrokerId,
...body
}: {
mqttBrokerId: number
qos?: number
retain?: boolean
message: string
topic: string
}) =>
request(
`/api/mqtt/brokers/${mqttBrokerId}/publish`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
},
'void'
)

View File

@ -54,17 +54,16 @@ textarea {
.color-input {
display: flex;
align-items: center;
align-items: stretch;
.current-color {
width: 2rem;
height: 1.5rem;
border: 1px solid var(--input-border-color);
margin-right: 0.5rem;
}
.color-picker {
margin-top: -280px;
margin-top: -240px;
> .title {
display: flex;

View File

@ -33,6 +33,7 @@ export const UserMenu = ({ shown, popup, onHide }: Props) => {
<NavLink href="/">📈 Dashboards</NavLink>
<NavLink href="/sensors"> Sensors</NavLink>
<NavLink href="/alerts">🚨 Alerts</NavLink>
<NavLink href="/mqtt/brokers"> MQTT Brokers</NavLink>
{/*<a href="#">⚙️ Settings</a>*/}
</nav>
</div>

View File

@ -5,6 +5,7 @@ import { AlertsPage } from './alerts/AlertsPage'
import { NewDashboardPage } from './dashboard/NewDashboardPage'
import { LoginPage } from './login/LoginPage'
import { SensorsPage } from './sensors/SensorsPage'
import { MqttBrokersPage } from './mqttBrokers/MqttBrokersPage'
export const Router = () => {
const { loggedIn } = useAppContext()
@ -22,6 +23,9 @@ export const Router = () => {
<Route path="/alerts">
<AlertsPage />
</Route>
<Route path="/mqtt/brokers">
<MqttBrokersPage />
</Route>
</Wouter>
)}
{!loggedIn && <LoginPage />}

View File

@ -1,9 +1,11 @@
import { getMQTTBrokers } from '@/api/mqttBrokers'
import { CssColorInput } from '@/components/CssColorInput'
import { FormCheckboxField } from '@/components/FormCheckboxField'
import { FormField } from '@/components/FormField'
import { DashboardMQTTButtonData } from '@/utils/dashboard/parseDashboard'
import { useForm } from '@/utils/hooks/useForm'
import { omit } from '@/utils/omit'
import { useQuery } from 'react-query'
type Props = {
value?: DashboardMQTTButtonData
@ -11,9 +13,10 @@ type Props = {
}
export const MQTTButtonSettings = ({ value, onChange }: Props) => {
const brokers = useQuery('/mqtt/brokers', getMQTTBrokers)
const { register } = useForm({
defaultValue: () => ({
server: '',
topic: '',
message: '',
retain: false,
@ -24,21 +27,14 @@ export const MQTTButtonSettings = ({ value, onChange }: Props) => {
return (
<>
<FormField
name="server"
label="MQTT Broker Address"
hint="Example: tcp://10.10.1.1:1883"
>
<input required {...register('server')} />
</FormField>
<FormField name="clientId" label="Client ID">
<input {...register('clientId')} />
</FormField>
<FormField name="username" label="Username">
<input {...register('username')} />
</FormField>
<FormField name="password" label="Password">
<input type="password" {...register('password')} />
<FormField name="mqttBrokerId" label="MQTT Broker">
<select {...register('mqttBrokerId', { type: 'integer' })}>
{brokers.data?.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</FormField>
<FormField name="qoc" label="QoS">
<select {...register('qos', { type: 'integer' })}>

View File

@ -1,20 +1,28 @@
import { DashboardMQTTButtonData } from '@/utils/dashboard/parseDashboard'
import { EditableBox, EditableBoxProps } from './EditableBox'
import { useMutation } from 'react-query'
import { publishMqttMessage } from '@/api/mqtt'
import { cn } from '@/utils/cn'
import { publishMqttBroker } from '@/api/mqttBrokers'
type Props = EditableBoxProps & {
data: DashboardMQTTButtonData
}
export const BoxMQTTButtonContent = ({ data, ...boxProps }: Props) => {
const pushMutation = useMutation(publishMqttMessage)
const pushMutation = useMutation(publishMqttBroker)
const onClick = () => {
pushMutation.mutate({
...data,
})
const id = data.mqttBrokerId
if (id) {
pushMutation.mutate({
mqttBrokerId: id,
message: data.message,
topic: data.topic,
qos: data.qos,
retain: data.retain,
})
}
}
return (

View File

@ -0,0 +1,88 @@
import {
MQTTBrokerInfo,
deleteMQTTBroker,
getMQTTBrokers,
} from '@/api/mqttBrokers'
import { DataTable } from '@/components/DataTable'
import { useConfirmModal } from '@/contexts/ConfirmModalsContext'
import { EditIcon, PlusIcon, TrashIcon } from '@/icons'
import { UserLayout } from '@/layouts/UserLayout/UserLayout'
import { useState } from 'preact/hooks'
import { useMutation, useQuery, useQueryClient } from 'react-query'
import { MQTTBrokerFormModal } from './components/MQTTBrokerFormModal'
import { NoMQTTBrokers } from './components/NoMQTTBrokers'
export const MqttBrokersPage = () => {
const brokers = useQuery(['/mqtt/brokers'], getMQTTBrokers)
const queryClient = useQueryClient()
const deleteMutation = useMutation(deleteMQTTBroker, {
onSuccess: () => queryClient.invalidateQueries(['/mqtt/brokers']),
})
const [showNew, setShowNew] = useState(false)
const [edited, setEdited] = useState<MQTTBrokerInfo>()
const deleteConfirm = useConfirmModal({
content: (deleted: MQTTBrokerInfo) =>
`Are you sure you want to delete sensor ${deleted.name}?`,
onConfirm: (deleted) => deleteMutation.mutate(deleted.id),
})
return (
<UserLayout className="sensors-page">
<div className="section-title">
<h2>Sensors</h2>
<button onClick={() => setShowNew(true)}>
<PlusIcon /> Add MQTT Broker
</button>
</div>
<div className="box-shadow">
<DataTable
query={brokers}
emptyMessage={<NoMQTTBrokers />}
columns={[
{ key: 'name', title: 'Name', render: (c) => c.name, scale: 1 },
{
key: 'address',
title: 'Address',
render: (c) => c.address,
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>
</div>
),
},
]}
/>
</div>
{(showNew || edited) && (
<MQTTBrokerFormModal
open
mqttBroker={edited}
onClose={() => {
setShowNew(false)
setEdited(undefined)
}}
/>
)}
</UserLayout>
)
}

View File

@ -0,0 +1,100 @@
import {
MQTTBrokerInfo,
createMQTTBroker,
updateMQTTBroker,
} from '@/api/mqttBrokers'
import { FormField } from '@/components/FormField'
import { Modal } from '@/components/Modal'
import { useForm } from '@/utils/hooks/useForm'
import { useMutation, useQueryClient } from 'react-query'
type Props = {
open: boolean
onClose: () => void
mqttBroker?: MQTTBrokerInfo
}
export const MQTTBrokerFormModal = ({ open, onClose, mqttBroker }: Props) => {
const queryClient = useQueryClient()
const createMutation = useMutation(createMQTTBroker)
const updateMutation = useMutation(updateMQTTBroker)
const { handleSubmit, register } = useForm({
defaultValue: () => ({
name: mqttBroker?.name ?? '',
address: mqttBroker?.address ?? '',
clientId: mqttBroker?.clientId ?? '',
username: mqttBroker?.username ?? '',
password: mqttBroker?.password ?? '',
}),
onSubmit: async (v) => {
if (isLoading) {
return
}
const data = {
name: v.name,
address: v.address,
clientId: v.clientId,
username: v.username,
password: v.password,
}
if (mqttBroker) {
await updateMutation.mutateAsync({
id: mqttBroker.id,
...data,
})
} else {
await createMutation.mutateAsync(data)
}
queryClient.invalidateQueries(['/mqtt/brokers'])
onClose()
},
})
const isLoading = createMutation.isLoading || updateMutation.isLoading
return (
<Modal onClose={onClose} open={open}>
<form onSubmit={handleSubmit}>
<div className="input">
<label>Name</label>
<input
type="text"
minLength={1}
required
autoFocus
{...register('name')}
/>
</div>
<FormField
name="address"
label="MQTT Broker Address"
hint="Example: tcp://10.10.1.1:1883"
>
<input required {...register('address')} />
</FormField>
<FormField name="clientId" label="Client ID">
<input {...register('clientId')} />
</FormField>
<FormField name="username" label="Username">
<input {...register('username')} />
</FormField>
<FormField name="password" label="Password">
<input type="password" {...register('password')} />
</FormField>
<div className="actions">
<button className="cancel" type="button" onClick={onClose}>
Cancel
</button>
<button disabled={isLoading}>Save</button>
</div>
</form>
</Modal>
)
}

View File

@ -0,0 +1,23 @@
import { PlusIcon } from '@/icons'
import { useState } from 'preact/hooks'
import { MQTTBrokerFormModal } from './MQTTBrokerFormModal'
export const NoMQTTBrokers = () => {
const [showNew, setShowNew] = useState(false)
return (
<>
<div className="empty">
<div>No MQTT brokers defined.</div>
<div>
<button onClick={() => setShowNew(true)}>
<PlusIcon /> Add a new MQTT broker
</button>
</div>
</div>
{showNew && (
<MQTTBrokerFormModal open onClose={() => setShowNew(false)} />
)}
</>
)
}

View File

@ -1,56 +0,0 @@
import { deleteSensor, SensorInfo } from '@/api/sensors'
import { InputWithCopy } from '@/components/InputWithCopy'
import { useConfirmModal } from '@/contexts/ConfirmModalsContext'
import { CancelIcon, EditIcon } from '@/icons'
import { useMutation, useQueryClient } from 'react-query'
type Props = {
sensor: SensorInfo
onEdit: (sensor: SensorInfo) => void
}
export const SensorItem = ({ sensor, onEdit }: Props) => {
const queryClient = useQueryClient()
const deleteMutation = useMutation(deleteSensor, {
onSuccess: () => queryClient.invalidateQueries(['/sensors']),
})
const deleteConfirm = useConfirmModal({
content: `Are you sure you want to delete sensor ${sensor.name}?`,
onConfirm: () => deleteMutation.mutate(sensor.id),
})
return (
<div className="box sensor-item">
<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={() => onEdit(sensor)}>
<EditIcon /> Edit
</button>
<button
onClick={deleteConfirm.show}
disabled={deleteMutation.isLoading}
>
<CancelIcon /> Delete
</button>
</div>
</div>
)
}

View File

@ -46,10 +46,7 @@ export type DashboardDialData = {
export type DashboardMQTTButtonData = {
type: 'mqttButton'
server: string
clientId?: string
username?: string
password?: string
mqttBrokerId?: number
qos?: number
retain?: boolean
message: string

View File

@ -29,5 +29,8 @@ export const useHashRouterLocation = () => {
const locationWithoutQueryString = location.split('?')[0]
return [locationWithoutQueryString, setLocation] as const
return [locationWithoutQueryString, setLocation] as [
string,
(to: string) => void
]
}

View File

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS mqtt_brokers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
address TEXT NOT NULL,
username TEXT,
password TEXT,
client_id TEXT
);

View File

@ -1,15 +1,12 @@
package services
package integrations
import (
MQTT "github.com/eclipse/paho.mqtt.golang"
)
type MQTTService struct {
// TODO:
// ctx *Context
}
type MQTTIntegration struct{}
func (s *MQTTService) Publish(server string, username string, password string, clientId string, retain bool, qos byte, topic string, message string) error {
func (s *MQTTIntegration) Publish(server string, username string, password string, clientId string, retain bool, qos byte, topic string, message string) error {
opts := MQTT.NewClientOptions()
opts.AddBroker(server)
opts.SetUsername(username)

View File

@ -69,7 +69,12 @@ func main() {
loginProtected.PUT("/api/contact-points/:contactPointId", routes.PutContactPoint(server))
loginProtected.DELETE("/api/contact-points/:contactPointId", routes.DeleteContactPoint(server))
loginProtected.POST("/api/contact-points/test", routes.TestContactPoint(server))
loginProtected.POST("/api/mqtt/publish", routes.PutMQTTPublish(server))
loginProtected.GET("/api/mqtt/brokers", routes.GetMQTTBrokers(server))
loginProtected.POST("/api/mqtt/brokers", routes.PostMQTTBroker(server))
loginProtected.GET("/api/mqtt/brokers/:brokerId", routes.GetMQTTBroker(server))
loginProtected.PUT("/api/mqtt/brokers/:brokerId", routes.PutMQTTBroker(server))
loginProtected.DELETE("/api/mqtt/brokers/:brokerId", routes.DeleteMQTTBroker(server))
loginProtected.POST("/api/mqtt/brokers/:brokerId/publish", routes.PostMQTTBrokerPublish(server))
if server.Config.AuthEnabled {
loginProtected.POST("/api/logout", routes.Logout(server))

View File

@ -0,0 +1,10 @@
package models
type MQTTBrokerItem struct {
Id int `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Address string `json:"address" db:"address"`
Username *string `json:"username" db:"username"`
Password *string `json:"password" db:"password"`
ClientId *string `json:"client_id" db:"client_id"`
}

View File

@ -1,47 +0,0 @@
package routes
import (
"basic-sensor-receiver/app"
"net/http"
"github.com/gin-gonic/gin"
)
type putMQTTPublishBody struct {
Server string `json:"server" binding:"required"`
ClientId string `json:"clientId"`
Username string `json:"username"`
Password string `json:"password"`
Retain *bool `json:"retain"`
Qos *float64 `json:"qos"`
Topic string `json:"topic" binding:"required"`
Message string `json:"message" binding:"required"`
}
func PutMQTTPublish(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
body := putMQTTPublishBody{}
if err := bindJSONBodyOrAbort(c, &body); err != nil {
return
}
qos := byte(0)
retain := false
if body.Retain != nil {
retain = *body.Retain
}
if body.Qos != nil {
qos = byte(*body.Qos)
}
if err := s.Services.MQTT.Publish(body.Server, body.Username, body.Password, body.ClientId, retain, qos, body.Topic, body.Message); err != nil {
c.AbortWithError(500, err)
return
}
c.Writer.WriteHeader(http.StatusOK)
}
}

View File

@ -0,0 +1,146 @@
package routes
import (
"basic-sensor-receiver/app"
"net/http"
"github.com/gin-gonic/gin"
)
type postOrPutMQTTBrokerRequest struct {
Name string `json:"name"`
Address string `json:"address"`
Username *string `json:"username"`
Password *string `json:"password"`
ClientId *string `json:"client_id"`
}
type postMQTTPublishRequest struct {
Retain *bool `json:"retain"`
Qos *float64 `json:"qos"`
Topic string `json:"topic" binding:"required"`
Message string `json:"message" binding:"required"`
}
func GetMQTTBrokers(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
brokers, err := s.Services.MQTTBrokers.GetList()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, brokers)
}
}
func PostMQTTBroker(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
body := postOrPutMQTTBrokerRequest{}
if err := bindJSONBodyOrAbort(c, &body); err != nil {
return
}
broker, err := s.Services.MQTTBrokers.Create(body.Name, body.Address, body.Username, body.Password, body.ClientId)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, broker)
}
}
func PutMQTTBroker(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
brokerId, err := getIntParamOrAbort(c, "brokerId")
if err != nil {
return
}
body := postOrPutMQTTBrokerRequest{}
if err := bindJSONBodyOrAbort(c, &body); err != nil {
return
}
broker, err := s.Services.MQTTBrokers.Update(brokerId, body.Name, body.Address, body.Username, body.Password, body.ClientId)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, broker)
}
}
func GetMQTTBroker(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
brokerId, err := getIntParamOrAbort(c, "brokerId")
if err != nil {
return
}
broker, err := s.Services.MQTTBrokers.GetById(brokerId)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, broker)
}
}
func DeleteMQTTBroker(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
brokerId, err := getIntParamOrAbort(c, "brokerId")
if err != nil {
return
}
err = s.Services.MQTTBrokers.Delete(brokerId)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, gin.H{})
}
}
func PostMQTTBrokerPublish(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
brokerId, err := getIntParamOrAbort(c, "brokerId")
if err != nil {
return
}
body := postMQTTPublishRequest{}
if err := bindJSONBodyOrAbort(c, &body); err != nil {
return
}
qos := byte(0)
retain := false
if body.Retain != nil {
retain = *body.Retain
}
if body.Qos != nil {
qos = byte(*body.Qos)
}
if err := s.Services.MQTTBrokers.PublishTopic(brokerId, body.Topic, body.Message, qos, retain); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Writer.WriteHeader(http.StatusOK)
}
}

View File

@ -0,0 +1,148 @@
package services
import "basic-sensor-receiver/models"
type MQTTBrokersService struct {
ctx *Context
}
func (s *MQTTBrokersService) GetList() ([]models.MQTTBrokerItem, error) {
brokers := []models.MQTTBrokerItem{}
err := s.ctx.DB.Select(&brokers, `
SELECT id, name, address, username, password, client_id
FROM mqtt_brokers
ORDER BY name ASC
`)
if err != nil {
return nil, err
}
return brokers, nil
}
func (s *MQTTBrokersService) GetById(id int64) (*models.MQTTBrokerItem, error) {
broker := models.MQTTBrokerItem{}
err := s.ctx.DB.Get(&broker,
`
SELECT id, name, address, username, password, client_id
FROM mqtt_brokers
WHERE id = $1
`,
id,
)
if err != nil {
return nil, err
}
return &broker, nil
}
func (s *MQTTBrokersService) Create(name string, address string, username *string, password *string, clientId *string) (*models.MQTTBrokerItem, error) {
broker := models.MQTTBrokerItem{
Name: name,
Address: address,
Username: username,
Password: password,
ClientId: clientId,
}
res, err := s.ctx.DB.NamedExec(
`
INSERT INTO mqtt_brokers (name, address, username, password, client_id)
VALUES (:name, :address, :username, :password, :client_id)
`,
broker,
)
if err != nil {
return nil, err
}
id, err := res.LastInsertId()
if err != nil {
return nil, err
}
broker.Id = int(id)
return &broker, nil
}
func (s *MQTTBrokersService) Update(id int64, name string, address string, username *string, password *string, clientId *string) (*models.MQTTBrokerItem, error) {
broker := models.MQTTBrokerItem{
Id: int(id),
Name: name,
Address: address,
Username: username,
Password: password,
ClientId: clientId,
}
_, err := s.ctx.DB.NamedExec(
`
UPDATE mqtt_brokers
SET name = :name, address = :address, username = :username, password = :password, client_id = :client_id
WHERE id = :id
`,
broker,
)
if err != nil {
return nil, err
}
return &broker, nil
}
func (s *MQTTBrokersService) Delete(id int64) error {
_, err := s.ctx.DB.Exec(
`
DELETE FROM mqtt_brokers
WHERE id = $1
`,
id,
)
if err != nil {
return err
}
return nil
}
func (s *MQTTBrokersService) PublishTopic(brokerId int64, topic string, message string, qos byte, retain bool) error {
broker, err := s.GetById(brokerId)
if err != nil {
return err
}
username := ""
if broker.Username != nil {
username = *broker.Username
}
password := ""
if broker.Password != nil {
password = *broker.Password
}
clientId := ""
if broker.ClientId != nil {
clientId = *broker.ClientId
}
return s.ctx.Integrations.MQTT.Publish(
broker.Address,
username,
password,
clientId,
retain,
qos,
topic,
message,
)
}

View File

@ -17,11 +17,12 @@ type Services struct {
Alerts *AlertsService
AlertsEvaluator *AlertsEvaluatorService
ContactPoints *ContactPointsService
MQTT *MQTTService
MQTTBrokers *MQTTBrokersService
}
type Integrations struct {
Telegram *integrations.TelegramIntegration
MQTT *integrations.MQTTIntegration
}
type Context struct {
@ -45,10 +46,11 @@ func InitializeServices(ctx *Context) *Services {
services.Alerts = &AlertsService{ctx: ctx}
services.AlertsEvaluator = &AlertsEvaluatorService{ctx: ctx}
services.ContactPoints = &ContactPointsService{ctx: ctx}
services.MQTT = &MQTTService{}
services.MQTTBrokers = &MQTTBrokersService{ctx: ctx}
ctx.Integrations = &Integrations{}
ctx.Integrations.Telegram = &integrations.TelegramIntegration{}
ctx.Integrations.MQTT = &integrations.MQTTIntegration{}
return &services
}