This commit is contained in:
Sergey Yarkov 2023-10-26 17:41:52 +03:00
parent 1cdf164fba
commit 8932402d3b
No known key found for this signature in database
GPG Key ID: E91CE1FABC619B6F
12 changed files with 555 additions and 266 deletions

View File

@ -85,7 +85,16 @@ export const modbusController = {
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async removeSlaveDevice(req, res, next) {},
async removeSlaveDevice(req, res, next) {
try {
const device = await db.get(`SELECT id FROM "modbus_slaves" WHERE id = ?`, [req.body.id]);
if (!device) return res.status(404).json({ message: 'Device not found.' });
await db.run(`DELETE FROM "modbus_slaves" WHERE id = ?`, [req.body.id]);
return res.status(200).json({ message: 'Device removed.' });
} catch (error) {
next(error);
}
},
/**
* Create display value for modbus slave device
@ -112,7 +121,27 @@ export const modbusController = {
async getLatestLog(req, res, next) {},
/**
* Create event stream for sending data from modbus device
* Creates event stream aboud modbus connection status
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async status(req, res, next) {
try {
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Connection", "keep-alive");
let intervalId = setInterval(() => {
res.write(`event: message\ndata: ${JSON.stringify(modbusClient.isOpen)}\n\n`);
}, 500);
req.on('close', () => clearInterval(intervalId));
} catch (error) {
next(error);
}
},
/**
* Creates event stream for sending data from modbus device
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next

BIN
data.db

Binary file not shown.

5
index.d.ts vendored
View File

@ -10,9 +10,10 @@ export type AppConfig = {
log_interval_ms: number;
};
export type ModbusSlave = {
export type SlaveModbusDevice = {
id: number;
name: string;
display_addr: number;
display_reg_addr: number | null;
display_reg_format: number | null;
is_logging: boolean;
}

View File

@ -21,8 +21,11 @@ CREATE TABLE IF NOT EXISTS "app_config" (
CREATE TABLE IF NOT EXISTS "modbus_slaves" (
"id" INTEGER CHECK (id >= 1 AND id <= 255),
"name" VARCHAR(256) NOT NULL,
"display_reg_addr" INTEGER CHECK(display_reg_addr >= 0 AND display_reg_addr <= 65534),
"display_reg_format" INTEGER CHECK(display_reg_format IN(16, 32)),
-- Holding register
"g_display_reg_addr" INTEGER CHECK(g_display_reg_addr >= 0 AND g_display_reg_addr <= 65534),
-- Use 1 or 2 bytes
"g_display_reg_format" INTEGER CHECK(g_display_reg_format IN(16, 32)),
"g_y_label" VARCHAR(40),
"is_logging" BOOLEAN NOT NULL,
PRIMARY KEY("id")
);
@ -36,6 +39,14 @@ CREATE TABLE IF NOT EXISTS "display_values" (
FOREIGN KEY("slave_id") REFERENCES modbus_slaves("id") ON DELETE CASCADE
);
INSERT INTO "modbus_slaves" (
"id", "name", "g_display_reg_addr", "g_display_reg_format", "g_y_label", "is_logging"
) VALUES
(1, "Device #1", 1, 16, "Label 1", 1),
(2, "Device #2", 2, 32, "Label 2", 1),
(3, "Device #3", 8, 16, "Label 3", 0),
(4, "Device #4", NULL, NULL, "Label 4", 0);
--------------------------------------------------------------------------------
-- Down
--------------------------------------------------------------------------------

View File

@ -603,19 +603,27 @@ video {
position: relative;
}
.z-50 {
z-index: 50;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.my-2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.my-4 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.my-2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
.mb-3 {
margin-bottom: 0.75rem;
}
.ml-auto {
@ -630,6 +638,22 @@ video {
margin-right: 0.5rem;
}
.mr-auto {
margin-right: auto;
}
.mt-1 {
margin-top: 0.25rem;
}
.mt-1\.5 {
margin-top: 0.375rem;
}
.mt-10 {
margin-top: 2.5rem;
}
.mt-16 {
margin-top: 4rem;
}
@ -638,6 +662,10 @@ video {
margin-top: 0.5rem;
}
.mt-20 {
margin-top: 5rem;
}
.mt-3 {
margin-top: 0.75rem;
}
@ -646,8 +674,12 @@ video {
margin-top: 1rem;
}
.mt-10 {
margin-top: 2.5rem;
.mt-5 {
margin-top: 1.25rem;
}
.mt-6 {
margin-top: 1.5rem;
}
.block {
@ -706,14 +738,14 @@ video {
max-width: 42rem;
}
.max-w-6xl {
max-width: 72rem;
}
.max-w-5xl {
max-width: 64rem;
}
.max-w-md {
max-width: 28rem;
}
.flex-1 {
flex: 1 1 0%;
}
@ -762,6 +794,10 @@ video {
align-items: center;
}
.justify-end {
justify-content: flex-end;
}
.justify-between {
justify-content: space-between;
}
@ -772,6 +808,12 @@ video {
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-y-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.25rem * var(--tw-space-y-reverse));
}
.space-y-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse)));
@ -816,6 +858,21 @@ video {
border-style: dashed;
}
.bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
}
.bg-red-500 {
--tw-bg-opacity: 1;
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: #2563eb;
}
@ -862,15 +919,37 @@ video {
padding-right: 0.625rem;
}
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.py-3 {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
.py-3\.5 {
padding-top: 0.875rem;
padding-bottom: 0.875rem;
}
.pl-2 {
padding-left: 0.5rem;
}
.pr-6 {
padding-right: 1.5rem;
}
.pt-5 {
padding-top: 1.25rem;
}
.text-left {
text-align: left;
}
.text-center {
text-align: center;
}
@ -885,9 +964,14 @@ video {
line-height: 2.25rem;
}
.text-5xl {
font-size: 3rem;
line-height: 1;
.text-4xl {
font-size: 2.25rem;
line-height: 2.5rem;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.text-sm {
@ -895,6 +979,11 @@ video {
line-height: 1.25rem;
}
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem;
}
.font-bold {
font-weight: 700;
}
@ -913,16 +1002,40 @@ video {
color: rgb(156 163 175 / var(--tw-text-opacity));
}
.text-gray-500 {
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity));
}
.text-gray-700 {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.text-green-700 {
--tw-text-opacity: 1;
color: rgb(21 128 61 / var(--tw-text-opacity));
}
.text-red-500 {
--tw-text-opacity: 1;
color: rgb(239 68 68 / var(--tw-text-opacity));
}
.text-red-600 {
--tw-text-opacity: 1;
color: rgb(220 38 38 / var(--tw-text-opacity));
}
.text-red-700 {
--tw-text-opacity: 1;
color: rgb(185 28 28 / var(--tw-text-opacity));
}
.underline {
text-decoration-line: underline;
}
.opacity-70 {
opacity: 0.7;
}
@ -943,6 +1056,24 @@ video {
display: none !important;
}
.backdrop {
position: fixed;
top: 0px;
left: 0px;
bottom: 0px;
right: 0px;
overflow-y: auto;
background-color: rgba(0,0,0,.15);
padding-left: 0.625rem;
padding-right: 0.625rem;
}
a {
--tw-text-opacity: 1;
color: rgb(37 99 235 / var(--tw-text-opacity));
text-decoration-line: underline;
}
input,
select {
margin-top: 0.125rem;
@ -971,6 +1102,11 @@ button:disabled {
pointer-events: all !important;
}
.hover\:bg-gray-100:hover {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
}
.hover\:bg-gray-50:hover {
--tw-bg-opacity: 1;
background-color: rgb(249 250 251 / var(--tw-bg-opacity));

View File

@ -12,6 +12,14 @@
display: none !important;
}
.backdrop {
@apply fixed top-0 left-0 bottom-0 right-0 bg-[rgba(0,0,0,.15)] px-2.5 overflow-y-auto;
}
a {
@apply underline text-blue-600;
}
input,
select {
@apply w-full border py-1 rounded-sm my-0.5 bg-white;

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Modbus Logger App</title>
<link rel="stylesheet" href="/css/dist.css" />
<link rel="stylesheet" href="/css/dygraphs.css" />
<script defer src="/js/dygraphs.min.js"></script>
<script defer type="module" src="/js/app.js"></script>
</head>
@ -17,48 +18,69 @@
<!-- Root element -->
<div x-data x-cloak class="mx-auto max-w-5xl flex flex-col h-screen">
<!-- Monitoring content -->
<main class="flex-1" x-bind:hidden="$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'">
<!-- <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="border-r min-w-[256px] p-2.5 pt-5">
<div class="flex flex-col space-y-2">
<span class="italic text-sm">Devices list:</span>
<div class="flex flex-col space-y-1">
<span class="italic text-sm">Devices:</span>
<span x-show="devices.length === 0" class="text-gray-500 text-sm text-center block">No devices added.</span>
<div class="flex flex-col max-h-[520px] overflow-y-auto space-y-2">
<div class="w-full select-none border px-2.5 py-3 rounded-sm hover:bg-gray-50 cursor-pointer">
<h2 class="text-md font-bold">Device #1</h2>
</div>
<div class="w-full select-none border px-2.5 py-3 rounded-sm hover:bg-gray-50 cursor-pointer">
<h2 class="text-md font-bold">Device #2</h2>
</div>
<div class="w-full select-none border px-2.5 py-3 rounded-sm hover:bg-gray-50 cursor-pointer">
<h2 class="text-md font-bold">Device #3</h2>
</div>
</div>
<div class="w-full text-center border-2 border-dashed rounded-sm hover:bg-gray-50 cursor-pointer">
<span class="text-5xl text-gray-400 select-none">+</span>
<template x-for="device in devices">
<button @click="selectDevice(device)" :class="{'bg-gray-100': selectedDevice?.id === device.id}" class="w-full 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>
</button>
</template>
</div>
<button class="w-full text-center border-2 border-dashed rounded-sm hover:bg-gray-50 cursor-pointer text-left">
<span class="text-4xl text-gray-400 select-none">+</span>
</button>
</div>
<hr class="my-4" />
<div><span class="italic text-sm">Connection:</span><br /></div>
<div>
<span class="italic text-sm">Connection:</span>
<div class="flex flex-col mt-1.5">
<span class="text-sm">Status:
<b x-show="$store.app.is_opened" class="text-green-700">Opened</b>
<b x-show="!$store.app.is_opened" class="text-red-700">Closed</b>
</span>
<a class="text-sm" @click="$store.app.setPage('configuration')" href="#">
Reconfigurate
</a>
</div>
</div>
</div>
<div class="flex-auto pl-2">
<div x-show="selectedDevice != null">
<div class="pt-5 border-b flex justify-between items-center pr-6">
<h2 x-text="selectedDevice?.name" class="font-bold text-3xl my-2"></h2>
<div>
<a href="#">Edit</a>
<a @click="$store.confirmDialog.open('Remove device', `Delete ${selectedDevice.name}? All logs will be cleared.`, removeDevice, selectedDevice.id)" class="text-red-500" href="#">Delete</a>
</div>
<div class="flex-auto">
<div class="pt-5 border-b">
<h2 class="font-bold text-3xl my-2">Device #1</h2>
</div>
<div class="mt-4">
<div id="div_g" style="width: 720px; height: 300px"></div>
<div id="div_g" x-ref="graph" style="width: 720px; height: 300px"></div>
</div>
<hr class="mt-10" />
<h2 class="text-xl font-bold mb-3">Display values</h2>
</div>
<div class="select-none" x-show="selectedDevice === null">
<span class="block text-center italic text-gray-500 mt-10">No device selected.</span>
</div>
</div>
<!-- <div class="border-l"></div> -->
</div>
</main>
</template>
<!-- Configuration content -->
<main x-data="configAppPage" x-bind:hidden="$store.app.currentPage !== 'configuration'">
<template x-if="$store.app.currentPage === 'configuration'">
<main class="mt-6" x-data="configAppPage" x-bind:hidden="$store.app.currentPage !== 'configuration'">
<div class="border rounded-sm p-4 max-w-2xl mx-auto">
<h2 class="text-2xl font-bold">Setup configuration</h2>
<p class="text-gray-700 italic">
@ -210,8 +232,10 @@
</form>
</div>
</main>
</template>
<!-- Loading content -->
<template x-if="$store.app.currentPage === 'loading'">
<main x-bind:hidden="$store.app.currentPage !== 'loading'">
<div class="text-center mt-16">
<div role="status">
@ -235,8 +259,10 @@
<p class="mt-4">Loading...</p>
</div>
</main>
</template>
<!-- Error content -->
<template x-if="$store.app.currentPage === 'error'">
<main x-bind:hidden="$store.app.currentPage !== 'error'">
<div>
<h1 class="text-2xl font-bold">Something went wrong.</h1>
@ -247,13 +273,34 @@
</div>
</div>
</main>
</template>
<!-- 404 content -->
<template x-if="$store.app.currentPage === '404'">
<main x-bind:hidden="$store.app.currentPage !== '404'">
<div>
<h1 class="text 2xl font-bold">Page not found</h1>
<div class="text-center mt-5">
<h1 class="text-2xl font-bold">Page not found</h1>
<a @click="$store.app.setPage('monitoring')" class="underline text-blue" href="#">return to monitoring</a>
</div>
</main>
</template>
<div x-show="$store.confirmDialog.isOpen" class="backdrop z-50">
<div role="dialog" @click.away="$store.confirmDialog.close()" class="px-4 py-3.5 ml-auto mr-auto mt-20 bg-white max-w-md border rounded-sm">
<div class="flex justify-between items-center">
<h2 x-text="$store.confirmDialog.labels.title" class="font-bold text-lg"></h2>
<button @click="$store.confirmDialog.close()" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M6.4 19L5 17.6l5.6-5.6L5 6.4L6.4 5l5.6 5.6L17.6 5L19 6.4L13.4 12l5.6 5.6l-1.4 1.4l-5.6-5.6L6.4 19Z"/></svg>
</button>
</div>
<hr>
<p x-text="$store.confirmDialog.labels.body"></p>
<div class="mt-4 flex justify-end space-x-2">
<button @click="$store.confirmDialog.confirm()" class="btn">Confirm</button>
<button @click="$store.confirmDialog.close()" class="btn bg-red-500">Cancel</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -4,34 +4,18 @@ import api from "./api.js";
import * as utils from "./utils/index.js";
const pages = ["loading", "monitoring", "configuration", "404"];
var data = [];
var t = new Date();
let graphData = [];
const t = new Date();
for (var i = 10; i >= 0; i--) {
var x = new Date(t.getTime() - i * 1000);
data.push([x, Math.random()]);
graphData.push([x, Math.random()]);
}
document.addEventListener("DOMContentLoaded", () => {
var g = new Dygraph(document.getElementById("div_g"), data, {
drawPoints: false,
showRoller: false,
// valueRange: [0.0, 6.8],
labels: ["Time", "Random"],
});
window.intervalId = setInterval(function () {
var x = new Date(); // current time
var y = Math.random();
data.push([x, y]);
g.updateOptions({ file: data });
}, 1000);
});
document.addEventListener("alpine:init", async () => {
Alpine.store("app", {
is_configured: false,
is_opened: false,
config: {
mb_connection_type: "TCP",
mb_tcp_ip: "127.0.0.1",
@ -45,9 +29,9 @@ document.addEventListener("alpine:init", async () => {
},
currentPage: "loading",
error: null,
devices: [],
async init() {
try {
const connStatusEventSource = new EventSource('/api/modbus/status');
const { config } = await api.get("/app/config");
const { is_configured } = await api.get("/app/is_configured");
@ -55,14 +39,15 @@ document.addEventListener("alpine:init", async () => {
if (config) this.config = config;
if (is_configured) {
const devices = await api.get("/modbus/devices");
this.devices = devices;
connStatusEventSource.onmessage = (e) => (this.is_opened = e.data === 'true');
await utils.wait(500);
this.currentPage = "monitoring";
return;
}
this.currentPage = "configuration";
} catch (error) {
console.error(error);
this.currentPage = "error";
this.error = error;
}
@ -78,6 +63,46 @@ document.addEventListener("alpine:init", async () => {
},
});
Alpine.data('monitoringPage', () => ({
selectedDevice: null,
devices: [],
async init() {
try {
this.$store.app.currentPage = 'loading'
var g = new Dygraph(document.getElementById("div_g"), graphData, {
drawPoints: false,
showRoller: false,
ylabel: 'Y Label',
valueRange: [0.0, 1.8],
//labels: ["Time", "Random"],
});
this.devices = await api.get('/modbus/devices');
window.intervalId = setInterval(() => {
graphData.push([new Date(), Math.random()]);
g.updateOptions({ file: graphData })
}, 500)
} catch (error) {
this.$store.app.error = error;
} finally {
this.$store.app.currentPage = 'monitoring'
}
},
selectDevice(device) {
if (device.id === this.selectedDevice?.id) return;
graphData = [];
this.selectedDevice = device;
},
async removeDevice(id, ctx) {
try {
//await api.post('/modbus/remove_device', { id });
this.devices = _this.devices.filter(d => d.id != id);
this.selectedDevice = null;
} catch (error) {
console.error('Error while removing device.', error)
}
}
}))
Alpine.data("configAppPage", () => ({
selectedConnectionType: "TCP",
isLoading: false,
@ -99,6 +124,7 @@ document.addEventListener("alpine:init", async () => {
};
await api.patch("/app/config", body[data.mb_connection_type]);
await api.post("/modbus/connect");
await utils.wait(500);
this.$store.app.currentPage = "monitoring";
} catch (error) {
this.errorMessage = `${error.message}`;
@ -107,4 +133,29 @@ document.addEventListener("alpine:init", async () => {
}
},
}));
Alpine.store("confirmDialog", {
isOpen: false,
labels: {
title: 'Confirm dialog',
body: 'Are you sure you want to perform this action?',
},
cb: {
fn: null,
args: []
},
open(title, body, cb, ...args) {
this.isOpen = true;
this.labels = { title, body };
this.cb.fn = cb;
this.cb.args = args;
},
confirm() {
this.cb.fn && this.cb.fn(...this.cb.args);
this.close();
},
close() {
this.isOpen = false;
}
})
});

View File

@ -1 +1,3 @@
export * from './object.js'
export const wait = (ms) => new Promise((res) => setTimeout(() => res(), ms));

View File

@ -3,7 +3,7 @@ import express from "express";
/**
* Validators
*/
import { appConfigSchema } from "#root/validators/index.js";
import { appConfigSchema, removeModbusDeviceSchema } from "#root/validators/index.js";
/**
* Controllers
@ -22,8 +22,10 @@ apiRouter.patch("/app/config", validateBodyMiddleware(appConfigSchema), appContr
apiRouter.get("/app/is_configured", appController.isConfigured);
apiRouter.get("/modbus/devices", modbusController.list);
apiRouter.post('/modbus/remove_device', validateBodyMiddleware(removeModbusDeviceSchema), modbusController.removeSlaveDevice);
apiRouter.post("/modbus/connect", modbusController.connect);
apiRouter.post("/modbus/close", modbusController.close);
apiRouter.get("/modbus/data_stream", modbusController.streamData);
apiRouter.get('/modbus/status', modbusController.status);
export { apiRouter };

View File

@ -2,7 +2,7 @@ import db from "#root/config/database.config.js";
async function run() {
try {
await db.migrate({ force: false });
await db.migrate({ force: true });
console.log("Database initialized.");
} catch (error) {
console.error(error);

View File

@ -16,3 +16,5 @@ export const appConfigSchema = object({
mb_rtu_stop_bits: number().oneOf([1, 2]).when("mb_connection_type", requiredIfConnTypeEquals("RTU")),
log_interval_ms: number().min(1000).required(),
});
export const removeModbusDeviceSchema = object({ id: number().max(255).min(1).required() })