This commit is contained in:
Sergey Yarkov 2023-10-30 16:26:56 +03:00
parent 8a0cfe5063
commit 90271dc45f
15 changed files with 265 additions and 196 deletions

View File

@ -65,7 +65,23 @@ export const modbusController = {
*/ */
async list(req, res, next) { async list(req, res, next) {
try { try {
const slaves = await db.all('SELECT * from modbus_slaves'); const slaves = await db.all(`
SELECT ms.id AS id, ms.name AS name, g_display_reg_addr, g_display_reg_format, g_y_label, is_logging,
CASE
WHEN COUNT(dv.id) = 0 THEN '[]'
ELSE '[' || GROUP_CONCAT(
JSON_OBJECT(
'id', dv.id,
'name', dv.name,
'reg_addr', dv.reg_addr,
'reg_format', dv.reg_format
), ', '
) || ']' END AS display_values
FROM modbus_slaves AS ms
LEFT JOIN display_values AS dv ON dv.slave_id = ms.id
GROUP BY ms.id, ms.name;
`);
slaves.forEach(s => (s.display_values = JSON.parse(s.display_values)))
return res.status(200).json(slaves); return res.status(200).json(slaves);
} catch (error) { } catch (error) {
next(error) next(error)
@ -80,6 +96,7 @@ export const modbusController = {
*/ */
async createDevice(req, res, next) { async createDevice(req, res, next) {
try { try {
const { id, name, g_display_reg_addr, g_display_reg_format, g_y_label, is_logging } = req.body;
await db.run(`INSERT INTO "modbus_slaves" ( await db.run(`INSERT INTO "modbus_slaves" (
"id", "id",
"name", "name",
@ -88,8 +105,8 @@ export const modbusController = {
"g_y_label", "g_y_label",
"is_logging") "is_logging")
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
[...req.body]); [id, name, g_display_reg_addr, g_display_reg_format, g_y_label, is_logging]);
return res.send(200).json({ message: 'Device created.', data: req.body }); return res.status(200).json({ message: 'Device created.' });
} catch (error) { } catch (error) {
next(error) next(error)
} }
@ -140,7 +157,14 @@ export const modbusController = {
* @param {import('express').Response} res * @param {import('express').Response} res
* @param {import('express').NextFunction} next * @param {import('express').NextFunction} next
*/ */
async removeDisplayValue(req, res, next) {}, async removeDisplayValue(req, res, next) {
try {
await db.run(`DELETE FROM "display_values" WHERE id = ?`, req.body.id);
return res.status(200).json({ message: 'Display value removed.' });
} catch (error) {
next(error);
}
},
/** /**
* Get latest data log from modbus slave device * Get latest data log from modbus slave device

BIN
data.db

Binary file not shown.

19
getslaves.sql Normal file
View File

@ -0,0 +1,19 @@
SELECT
ms.id AS id,
ms.name AS name,
g_display_reg_addr,
g_display_reg_format,
g_y_label,
is_logging,
CASE
WHEN COUNT(dv.id) = 0 THEN '[]'
ELSE '[' || GROUP_CONCAT(
JSON_OBJECT(
'name', dv.name,
'reg_addr', dv.reg_addr,
'reg_format', dv.reg_format
), ', '
) || ']' END AS display_values
FROM modbus_slaves AS ms
LEFT JOIN display_values AS dv ON dv.slave_id = ms.id
GROUP BY ms.id, ms.name;

View File

@ -22,19 +22,20 @@ CREATE TABLE IF NOT EXISTS "modbus_slaves" (
"id" INTEGER CHECK (id >= 1 AND id <= 255), "id" INTEGER CHECK (id >= 1 AND id <= 255),
"name" VARCHAR(256) NOT NULL, "name" VARCHAR(256) NOT NULL,
-- Holding register -- Holding register
"g_display_reg_addr" INTEGER CHECK(g_display_reg_addr >= 0 AND g_display_reg_addr <= 65534), "g_display_reg_addr" INTEGER CHECK(g_display_reg_addr >= 0 AND g_display_reg_addr <= 65534) DEFAULT NULL,
-- Use 1 or 2 bytes -- Use 1 or 2 bytes
"g_display_reg_format" INTEGER CHECK(g_display_reg_format IN(16, 32)), "g_display_reg_format" INTEGER CHECK(g_display_reg_format IN(16, 32)) DEFAULT 16,
"g_y_label" VARCHAR(40), "g_y_label" VARCHAR(40) DEFAULT "Label",
"is_logging" BOOLEAN NOT NULL, "is_logging" BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY("id") PRIMARY KEY("id")
); );
CREATE TABLE IF NOT EXISTS "display_values" ( CREATE TABLE IF NOT EXISTS "display_values" (
"id" INTEGER, "id" INTEGER,
"name" VARCHAR(32), "name" VARCHAR(32) NOT NULL,
"slave_id" INTEGER NOT NULL, "slave_id" INTEGER NOT NULL,
"reg_addr" INTEGER DEFAULT 0 NOT NULL CHECK(reg_addr >= 0 AND reg_addr <= 65534), "reg_addr" INTEGER DEFAULT 0 NOT NULL CHECK(reg_addr >= 0 AND reg_addr <= 65534),
"reg_format" INTEGER NOT NULL CHECK(reg_format IN(16, 32)) DEFAULT 16,
PRIMARY KEY("id"), PRIMARY KEY("id"),
FOREIGN KEY("slave_id") REFERENCES modbus_slaves("id") ON DELETE CASCADE FOREIGN KEY("slave_id") REFERENCES modbus_slaves("id") ON DELETE CASCADE
); );
@ -62,6 +63,17 @@ INSERT INTO "modbus_slaves" (
(3, "Device #3", 8, 16, "Label 3", 0), (3, "Device #3", 8, 16, "Label 3", 0),
(4, "Device #4", NULL, NULL, "Label 4", 0); (4, "Device #4", NULL, NULL, "Label 4", 0);
INSERT INTO "display_values" (
"id", "name", "slave_id", "reg_addr", "reg_format"
) VALUES
(1, "CV1", 1, 0, 16),
(2, "CV2", 1, 1, 16),
(3, "SP", 1, 2, 16),
(4, "CV1", 2, 3, 16),
(5, "CV2", 2, 4, 16),
(6, "SP", 2, 5, 16),
(7, "CV1", 3, 6, 16),
(8, "SP", 3, 7, 16);
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
-- Down -- Down
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------

View File

@ -632,16 +632,16 @@ video {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.my-4 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.my-3 { .my-3 {
margin-top: 0.75rem; margin-top: 0.75rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.my-4 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.mb-2 { .mb-2 {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@ -662,10 +662,6 @@ video {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.mr-auto {
margin-right: auto;
}
.mt-1 { .mt-1 {
margin-top: 0.25rem; margin-top: 0.25rem;
} }
@ -686,10 +682,6 @@ video {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.mt-20 {
margin-top: 5rem;
}
.mt-3 { .mt-3 {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
@ -706,10 +698,6 @@ video {
margin-top: 1.5rem; margin-top: 1.5rem;
} }
.mt-auto {
margin-top: auto;
}
.block { .block {
display: block; display: block;
} }
@ -750,6 +738,10 @@ video {
max-height: 400px; max-height: 400px;
} }
.min-h-\[140px\] {
min-height: 140px;
}
.min-h-\[50px\] { .min-h-\[50px\] {
min-height: 50px; min-height: 50px;
} }
@ -766,31 +758,10 @@ video {
width: 100%; width: 100%;
} }
.w-16 {
width: 4rem;
}
.w-24 {
width: 6rem;
}
.w-52 {
width: 13rem;
}
.min-w-\[256px\] { .min-w-\[256px\] {
min-width: 256px; min-width: 256px;
} }
.min-w-fit {
min-width: -moz-fit-content;
min-width: fit-content;
}
.min-w-\[100px\] {
min-width: 100px;
}
.max-w-2xl { .max-w-2xl {
max-width: 42rem; max-width: 42rem;
} }
@ -799,10 +770,6 @@ video {
max-width: 64rem; max-width: 64rem;
} }
.max-w-md {
max-width: 28rem;
}
.flex-1 { .flex-1 {
flex: 1 1 0%; flex: 1 1 0%;
} }
@ -811,10 +778,6 @@ video {
flex: 1 1 auto; flex: 1 1 auto;
} }
.basis-52 {
flex-basis: 13rem;
}
.transform { .transform {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
} }
@ -843,42 +806,10 @@ video {
resize: both; resize: both;
} }
.grid-flow-col {
grid-auto-flow: column;
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-cols-\[200px_minmax\(900px\2c _1fr\)_100px\] {
grid-template-columns: 200px minmax(900px, 1fr) 100px;
}
.grid-cols-1 { .grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr)); grid-template-columns: repeat(1, minmax(0, 1fr));
} }
.grid-rows-3 {
grid-template-rows: repeat(3, minmax(0, 1fr));
}
.grid-rows-4 {
grid-template-rows: repeat(4, minmax(0, 1fr));
}
.grid-rows-1 {
grid-template-rows: repeat(1, minmax(0, 1fr));
}
.grid-rows-2 {
grid-template-rows: repeat(2, minmax(0, 1fr));
}
.flex-row { .flex-row {
flex-direction: row; flex-direction: row;
} }
@ -891,6 +822,10 @@ video {
flex-wrap: wrap; flex-wrap: wrap;
} }
.items-start {
align-items: flex-start;
}
.items-center { .items-center {
align-items: center; align-items: center;
} }
@ -907,10 +842,6 @@ video {
justify-content: space-between; justify-content: space-between;
} }
.gap-3 {
gap: 0.75rem;
}
.gap-2 { .gap-2 {
gap: 0.5rem; gap: 0.5rem;
} }
@ -939,10 +870,6 @@ video {
margin-bottom: calc(0.75rem * var(--tw-space-y-reverse)); margin-bottom: calc(0.75rem * var(--tw-space-y-reverse));
} }
.overflow-auto {
overflow: auto;
}
.overflow-y-auto { .overflow-y-auto {
overflow-y: auto; overflow-y: auto;
} }
@ -951,10 +878,6 @@ video {
border-radius: 0.125rem; border-radius: 0.125rem;
} }
.rounded-md {
border-radius: 0.375rem;
}
.border { .border {
border-width: 1px; border-width: 1px;
} }
@ -993,11 +916,6 @@ video {
background-color: rgb(239 68 68 / var(--tw-bg-opacity)); background-color: rgb(239 68 68 / var(--tw-bg-opacity));
} }
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.fill-blue-600 { .fill-blue-600 {
fill: #2563eb; fill: #2563eb;
} }
@ -1044,19 +962,13 @@ video {
padding-right: 0.625rem; padding-right: 0.625rem;
} }
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.py-3 { .py-3 {
padding-top: 0.75rem; padding-top: 0.75rem;
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
} }
.py-3\.5 { .pl-0 {
padding-top: 0.875rem; padding-left: 0px;
padding-bottom: 0.875rem;
} }
.pl-2 { .pl-2 {
@ -1071,10 +983,6 @@ video {
padding-top: 1.25rem; padding-top: 1.25rem;
} }
.pl-0 {
padding-left: 0px;
}
.text-left { .text-left {
text-align: left; text-align: left;
} }
@ -1098,6 +1006,11 @@ video {
line-height: 2.5rem; line-height: 2.5rem;
} }
.text-5xl {
font-size: 3rem;
line-height: 1;
}
.text-lg { .text-lg {
font-size: 1.125rem; font-size: 1.125rem;
line-height: 1.75rem; line-height: 1.75rem;
@ -1113,11 +1026,6 @@ video {
line-height: 1.75rem; line-height: 1.75rem;
} }
.text-5xl {
font-size: 3rem;
line-height: 1;
}
.font-bold { .font-bold {
font-weight: 700; font-weight: 700;
} }
@ -1221,6 +1129,12 @@ select {
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
} }
input[type="checkbox"] {
margin: 0px;
width: auto;
vertical-align: middle;
}
input { input {
padding-left: 0.375rem; padding-left: 0.375rem;
padding-right: 0.375rem; padding-right: 0.375rem;
@ -1253,22 +1167,12 @@ button:disabled {
} }
} }
@media (min-width: 640px) {
.sm\:grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
}
@media (min-width: 768px) { @media (min-width: 768px) {
@media (min-width: 640px) { @media (min-width: 640px) {
.md\:sm\:grid-cols-2 { .md\:sm\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
.md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
@ -1277,8 +1181,4 @@ button:disabled {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
} }
.lg\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
} }

View File

@ -29,6 +29,10 @@ select {
@apply w-full border py-1 rounded-sm my-0.5 bg-white; @apply w-full border py-1 rounded-sm my-0.5 bg-white;
} }
input[type="checkbox"] {
@apply w-auto m-0 align-middle;
}
input { input {
@apply px-1.5; @apply px-1.5;
} }

View File

@ -20,10 +20,6 @@
<!-- Monitoring content --> <!-- Monitoring content -->
<template x-if="$store.app.currentPage === 'monitoring'"> <template x-if="$store.app.currentPage === 'monitoring'">
<main x-data="monitoringPage" class="flex-1" x-bind:hidden="$store.app.currentPage !== 'monitoring'"> <main x-data="monitoringPage" class="flex-1" x-bind:hidden="$store.app.currentPage !== 'monitoring'">
<!-- <div>
<h1 class="text-3xl font-bold">Monitoring page</h1>
<button @click="$store.app.setPage('configuration')" class="btn">Config</button>
</div> -->
<div class="flex h-full flex-row space-x-2"> <div class="flex h-full flex-row space-x-2">
<div class="border-r min-w-[256px] p-2.5 pl-0 pt-5"> <div class="border-r min-w-[256px] p-2.5 pl-0 pt-5">
<div class="flex flex-col space-y-1"> <div class="flex flex-col space-y-1">
@ -32,13 +28,12 @@
<span x-show="devices.length === 0" class="text-gray-500 text-sm text-center block"> <span x-show="devices.length === 0" class="text-gray-500 text-sm text-center block">
No devices added. No devices added.
</span> </span>
<div class="flex flex-col max-h-[400px] overflow-y-auto space-y-2"> <div @add-device.window="devices = [...devices, $event.detail]; selectedDevice = $event.detail" class="flex flex-col max-h-[400px] overflow-y-auto space-y-2">
<template x-for="device in devices" :key="device.id"> <template x-for="device in devices" :key="device.id">
<button <button
@click="selectDevice(device)" @click="selectDevice(device)"
:class="{'bg-gray-100': selectedDevice?.id === device.id}" :class="{'bg-gray-100': selectedDevice?.id === device.id}"
class="w-full min-h-[50px] select-none border px-2.5 py-3 text-left rounded-sm hover:bg-gray-100 cursor-pointer" class="w-full min-h-[50px] select-none border px-2.5 py-3 text-left rounded-sm hover:bg-gray-100 cursor-pointer">
>
<span x-text="device.name" class="text-md font-bold"></span> <span x-text="device.name" class="text-md font-bold"></span>
</button> </button>
</template> </template>
@ -46,14 +41,15 @@
<!-- MODAL: Create new device --> <!-- MODAL: Create new device -->
<template x-teleport="body"> <template x-teleport="body">
<div <div
@keyup.escape.window="modalCreateDevice.close()" x-data="deviceModal"
@open-create-device-modal.window="open()"
@keyup.escape.window="close()"
class="backdrop z-50" class="backdrop z-50"
x-show="modalCreateDevice.isOpen" x-show="isOpen">
>
<div class="modal"> <div class="modal">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<h2 class="font-bold text-lg">Create new device</h2> <h2 class="font-bold text-lg">Create new device</h2>
<button @click="modalCreateDevice.close()" aria-label="Close"> <button @click="close()" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path <path
fill="currentColor" fill="currentColor"
@ -63,30 +59,60 @@
</button> </button>
</div> </div>
<hr /> <hr />
<form x-on:submit.prevent="createDevice" action="#"> <form x-data="{ graphView: '' }" @submit.prevent="create(() => graphView = 'none')" action="#">
<div class="flex flex-col space-y-2"> <div x-id="['id', 'name', 'graph-display', 'g_display_reg_addr', 'g_display_reg_format', 'g_y_label', 'is_logging']" class="flex flex-col space-y-2">
<div> <div>
<label for="id">Slave Id</label> <label :for="$id('id')"><span class="text-red-500">*</span>Slave ID:</label>
<input placeholder="1" id="id" name="id" type="number" min="1" max="255" required /> <input x-model.number="data.id" :id="$id('id')" name="id" placeholder="1" type="number" min="1" max="255" required />
</div> </div>
<div> <div>
<label for="id">Name</label> <label :for="$id('name')"><span class="text-red-500">*</span>Name:</label>
<input placeholder="Device #1" id="id" name="id" type="text" required /> <input x-model="data.name" placeholder="Device #1" :id="$id('name')" name="name" type="text" required />
</div>
<div>
<label :for="$id('graph-display')">Graph Display</label>
<select x-model="graphView" :id="$id('graph-display')">
<option value="none">None</option>
<option value="new-value">New value</option>
</select>
</div>
<div x-show="graphView === 'new-value'">
<div>
<label :for="$id('g_y_label')"><span class="text-red-500">*</span>Value name:</label>
<input x-model="data.g_y_label" :id="$id('g_y_label')" :required="graphView === 'new-value'" name="g_y_label" placeholder="PV" type="text" />
</div>
<div>
<label :for="$id('g_display_reg_addr')"><span class="text-red-500">*</span>Register address:</label>
<input x-model="data.g_display_reg_addr" :id="$id('g_display_reg_addr')" name="g_display_reg_addr" placeholder="1" type="number" min="0" max="65534" :required="graphView === 'new-value'" />
</div>
<div>
<label :for="$id('g_display_reg_format')"><span class="text-red-500">*</span>Format (bits):</label>
<select x-model.number="data.g_display_reg_format" :id="$id('g_display_reg_format')" name="g_display_reg_format">
<option value="16">16</option>
<option value="32">32</option>
</select>
</div>
<div>
<input x-model="data.is_logging" :id="$id('is_logging')" name="is_logging" type="checkbox" />
<label :for="$id('is_logging')">Logging</label>
</div>
</div> </div>
</div> </div>
<div>
<span x-text="error?.message" class="text-red-500"></span>
</div>
<div class="mt-4 flex justify-end space-x-2">
<button x-bind:disabled="isLoading" x-bind:class="isLoading && 'opacity-70'" type="submit" class="btn">Create</button>
<button @click="close()" class="btn bg-red-500">Cancel</button>
</div>
</form> </form>
<div class="mt-4 flex justify-end space-x-2">
<button class="btn">Create</button>
<button @click="modalCreateDevice.close()" class="btn bg-red-500">Cancel</button>
</div>
</div> </div>
</div> </div>
</template> </template>
<!-- Create new device --> <!-- Create new device -->
<button <button
@click="modalCreateDevice.open()" @click="$dispatch('open-create-device-modal')"
class="w-full min-h-[50px] text-center border-2 border-dashed rounded-sm hover:bg-gray-50 cursor-pointer" class="w-full min-h-[50px] text-center border-2 border-dashed rounded-sm hover:bg-gray-50 cursor-pointer">
>
<span class="text-4xl text-gray-400 select-none">+</span> <span class="text-4xl text-gray-400 select-none">+</span>
</button> </button>
</div> </div>
@ -107,25 +133,25 @@
<div class="flex-auto pl-2"> <div class="flex-auto pl-2">
<div x-show="selectedDevice != null"> <div x-show="selectedDevice != null">
<div class="pt-5 border-b flex flex-wrap justify-between items-center pr-6"> <div class="pt-5 border-b flex flex-wrap justify-between items-center pr-6">
<h2 x-text="selectedDevice?.name" class="font-bold text-3xl my-2 mr-2"></h2> <div class="mb-2">
<!-- Device create, delete --> <h2 x-text="selectedDevice?.name" class="font-bold text-3xl my-2 mr-2"></h2>
<span>ID: </span><span x-text="selectedDevice?.id"></span>
</div>
<!-- Device create, delete buttons -->
<div> <div>
<a href="#">Edit</a> <a href="#">Edit</a>
<a <a @click="$dispatch('dialog', {
@click="$dispatch('dialog', {
title: 'Remove device', title: 'Remove device',
body: `Remove ${selectedDevice.name}? All logs will be cleared.`, body: `Remove ${selectedDevice.name}? All logs will be cleared.`,
isOpen: true, isOpen: true,
onConfirm: async () => await removeDevice(selectedDevice.id) onConfirm: async () => await removeDevice(selectedDevice.id)
});" });"
class="text-red-500" class="text-red-500"
href="#" href="#">
>
Delete Delete
</a> </a>
</div> </div>
</div> </div>
<!-- Display Graph --> <!-- Display Graph -->
<div x-show="selectedDevice?.g_display_reg_addr != null" class="mt-4"> <div x-show="selectedDevice?.g_display_reg_addr != null" class="mt-4">
<div id="div_g" style="width: 720px; height: 300px"></div> <div id="div_g" style="width: 720px; height: 300px"></div>
@ -133,13 +159,14 @@
</div> </div>
<!-- Display values --> <!-- Display values -->
<h2 class="text-xl font-bold mb-3 mt-5">Display values</h2> <h2 class="text-xl font-bold mb-3 mt-5">Display values</h2>
<div> <div class="mb-2">
<div class="grid grid-cols-1 md:sm:grid-cols-2 lg:sm:grid-cols-3 gap-2"> <div class="grid grid-cols-1 md:sm:grid-cols-2 lg:sm:grid-cols-3 gap-2">
<template x-for="value in [1,2,3,4,5]"> <template x-for="value in selectedDevice?.display_values" :key="value.id">
<div class="border-2 p-2.5 rounded-sm"> <div class="border-2 p-2.5 rounded-sm">
<div class="flex justify-between"> <div class="flex justify-between items-start">
<h3 class="text-xl font-bold">CV1</h3> <h3 x-text="value.name" class="text-xl font-bold"></h3>
<div> <div class="flex">
<!-- Edit display value -->
<button> <button>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path <path
@ -148,7 +175,13 @@
/> />
</svg> </svg>
</button> </button>
<button> <!-- Remove display value -->
<button @click="$dispatch('dialog', {
title: 'Remove display value',
body: `Remove display value ${value.name}?`,
isOpen: true,
onConfirm: async () => await removeDisplayValue(value.id)
});">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path <path
fill="#ef4444" fill="#ef4444"
@ -160,14 +193,14 @@
</div> </div>
<hr /> <hr />
<div class="flex flex-col"> <div class="flex flex-col">
<span>Value: 7892</span> <span>Value: ---</span>
<span>Reg Addr: 6</span> <span>Reg Addr: <span x-text="value.reg_addr"></span></span>
<span>Format: 32</span> <span>Format: <span x-text="value.reg_format"></span></span>
</div> </div>
</div> </div>
</template> </template>
<div <div
class="flex justify-center items-center border-4 text-center border-dashed p-2.5 rounded-sm cursor-pointer hover:bg-gray-50" class="flex justify-center min-h-[140px] items-center border-4 text-center border-dashed p-2.5 rounded-sm cursor-pointer hover:bg-gray-50"
> >
<span class="text-5xl text-gray-400 font-bold select-none">+</span> <span class="text-5xl text-gray-400 font-bold select-none">+</span>
</div> </div>

View File

@ -52,23 +52,58 @@ document.addEventListener("alpine:init", async () => {
this.currentPage = "404"; this.currentPage = "404";
return; return;
} }
this.currentPage = page; this.currentPage = page;
}, },
}); });
Alpine.data('deviceModal', () => ({
isOpen: false,
isLoading: false,
error: null,
data: {
id: null,
name: '',
g_display_reg_addr: null,
g_display_reg_format: 16,
g_y_label: '',
is_logging: false
},
resetDataFields() {
this.data.id = null;
this.data.name = '';
this.data.g_display_reg_addr = null;
this.data.g_display_reg_format = 16;
this.data.g_y_label = '';
this.data.is_logging = false;
},
async create(cb) {
try {
this.isLoading = true;
this.error = null;
await api.post('/modbus/create_device', utils.dellNullableKeys({...this.data}));
graphData = [] // todo: update graph data
this.$dispatch('add-device', {...this.data}); // add device to state
this.resetDataFields();
this.close();
cb && cb();
} catch (error) {
this.error = error
console.error(error);
} finally {
this.isLoading = false;
}
},
open() {
this.isOpen = true;
},
close() {
this.isOpen = false;
},
}))
Alpine.data("monitoringPage", () => ({ Alpine.data("monitoringPage", () => ({
selectedDevice: null, selectedDevice: null,
devices: [], devices: [],
modalCreateDevice: {
isOpen: false,
open() {
this.isOpen = true;
},
close() {
this.isOpen = false;
},
},
async init() { async init() {
try { try {
this.$store.app.currentPage = "loading"; this.$store.app.currentPage = "loading";
@ -92,7 +127,7 @@ document.addEventListener("alpine:init", async () => {
}, },
selectDevice(device) { selectDevice(device) {
if (device.id === this.selectedDevice?.id) return; if (device.id === this.selectedDevice?.id) return;
graphData = []; graphData = []; // todo: reset graph
this.selectedDevice = device; this.selectedDevice = device;
}, },
async removeDevice(id) { async removeDevice(id) {
@ -104,6 +139,14 @@ document.addEventListener("alpine:init", async () => {
console.error("Error while removing device.", error); console.error("Error while removing device.", error);
} }
}, },
async removeDisplayValue(id) {
try {
await api.post('/modbus/remove_display-value', { id });
this.selectedDevice.display_values = this.selectedDevice.display_values.filter(v => v.id != id);
} catch (error) {
console.error("Error while removing display value", error);
}
}
})); }));
Alpine.data("configAppPage", () => ({ Alpine.data("configAppPage", () => ({

View File

@ -8,4 +8,10 @@ export function objKeysExclude(obj, keys) {
const res = structuredClone(obj); const res = structuredClone(obj);
Object.keys(res).forEach(k => keys.includes(k) && delete res[k]); Object.keys(res).forEach(k => keys.includes(k) && delete res[k]);
return res; return res;
}
export function dellNullableKeys(obj) {
const res = structuredClone(obj);
Object.keys(res).forEach(k => obj[k] === null && delete res[k])
return res;
} }

18
routes/api/app.router.js Normal file
View File

@ -0,0 +1,18 @@
import express from 'express'
import cp from 'child_process'
import { appConfigSchema } from "#root/validators/index.js";
import { appController } from "#root/controllers/index.js";
import { validateBodyMiddleware } from "#root/middlewares/index.js";
const appRouter = express.Router();
appRouter.get("/config", appController.getConfig);
appRouter.patch("/config", validateBodyMiddleware(appConfigSchema), appController.updateConfig);
appRouter.get("/is_configured", appController.isConfigured);
appRouter.get('/ping', (req, res) => res.status(200).send('pong'));
appRouter.get('/commit-hash', (req, res) => {
const hash = cp.execSync('git rev-parse HEAD').toString().trim();
return res.status(200).json({ hash })
})
export { appRouter }

View File

@ -1,14 +1,11 @@
import express from "express"; import express from "express";
import { modbusRouter } from './modbus.router.js' import { modbusRouter } from './modbus.router.js'
import { appConfigSchema } from "#root/validators/index.js"; import { appRouter } from "./app.router.js";
import { appController } from "#root/controllers/index.js";
import { validateBodyMiddleware } from "#root/middlewares/index.js";
const apiRouter = express.Router(); const apiRouter = express.Router();
apiRouter.use("/modbus", modbusRouter); apiRouter.use("/modbus", modbusRouter);
apiRouter.get("/app/config", appController.getConfig); apiRouter.use('/app', appRouter)
apiRouter.patch("/app/config", validateBodyMiddleware(appConfigSchema), appController.updateConfig);
apiRouter.get("/app/is_configured", appController.isConfigured);
export { apiRouter }; export { apiRouter };

View File

@ -1,6 +1,6 @@
import express from "express"; import express from "express";
import { modbusController } from "#root/controllers/index.js" import { modbusController } from "#root/controllers/index.js"
import { modbusDeviceSchema, removeModbusDeviceSchema } from "#root/validators/index.js"; import { modbusDeviceSchema, removeDisplayValueSchema, removeModbusDeviceSchema } from "#root/validators/index.js";
import { validateBodyMiddleware } from "#root/middlewares/index.js"; import { validateBodyMiddleware } from "#root/middlewares/index.js";
const modbusRouter = express.Router(); const modbusRouter = express.Router();
@ -9,6 +9,7 @@ modbusRouter.get("/devices", modbusController.list);
modbusRouter.post("/create_device", validateBodyMiddleware(modbusDeviceSchema), modbusController.createDevice) modbusRouter.post("/create_device", validateBodyMiddleware(modbusDeviceSchema), modbusController.createDevice)
modbusRouter.post('/remove_device', validateBodyMiddleware(removeModbusDeviceSchema), modbusController.removeDevice); modbusRouter.post('/remove_device', validateBodyMiddleware(removeModbusDeviceSchema), modbusController.removeDevice);
modbusRouter.post('/update_device', validateBodyMiddleware(modbusDeviceSchema), modbusController.updateDevice); modbusRouter.post('/update_device', validateBodyMiddleware(modbusDeviceSchema), modbusController.updateDevice);
modbusRouter.post('/remove_display-value', validateBodyMiddleware(removeDisplayValueSchema), modbusController.removeDisplayValue);
modbusRouter.post("/connect", modbusController.connect); modbusRouter.post("/connect", modbusController.connect);
modbusRouter.post("/close", modbusController.close); modbusRouter.post("/close", modbusController.close);
modbusRouter.get("/data_stream", modbusController.streamData); modbusRouter.get("/data_stream", modbusController.streamData);

View File

@ -0,0 +1,11 @@
import { object, number, boolean, string } from 'yup';
export const displayValueSchema = object({
id: number().required(),
name: string().required(),
slave_id: number().required(),
reg_addr: number().min(0).max(65534).required(),
reg_format: number().oneOf([16, 32]).required()
})
export const removeDisplayValueSchema = displayValueSchema.pick(['id']);

View File

@ -17,4 +17,5 @@ export const appConfigSchema = object({
log_interval_ms: number().min(1000).required(), log_interval_ms: number().min(1000).required(),
}); });
export * from './modbus-device.schema.js' export * from './modbus-device.schema.js'
export * from './display-value.schema.js'

View File

@ -3,7 +3,7 @@ import { object, number, boolean, string } from 'yup';
export const modbusDeviceSchema = object({ export const modbusDeviceSchema = object({
id: number().required(), id: number().required(),
name: string().required(), name: string().required(),
g_display_reg_addr: number().nullable(), g_display_reg_addr: number().min(0).max(65534).nullable(),
g_display_reg_format: number().oneOf([16, 32]).nullable(), g_display_reg_format: number().oneOf([16, 32]).nullable(),
g_y_label: string().max(40).nullable(), g_y_label: string().max(40).nullable(),
is_logging: boolean().required() is_logging: boolean().required()