99 Commits

Author SHA1 Message Date
wu
3263093788 docs:book修改summary下的路径 2022-10-11 16:11:32 +08:00
wu
44762837ec docs:book增加scada的文档&清空readme文件内容 2022-10-11 15:03:01 +08:00
Yu Sun
bf45b72d6f chore(gitignore):忽略编译好的book 2022-10-10 16:40:11 +08:00
Yu Sun
2165d95937 docs(book):增加技术细节、自定义sc、修复dns和版本号错误说明 2022-10-10 16:39:28 +08:00
Yu Sun
9fd20de60a fix(statefulset):使用affinity管理节点分配 2022-10-10 16:38:11 +08:00
Yu Sun
4a292ef4bb feat(sc):增加一个自定义位置的storage示例 2022-10-10 16:37:36 +08:00
Yu Sun
055a3477a5 fix(pg_conf):增加pg的时区设置 2022-10-10 16:36:46 +08:00
Yu Sun
ed314a3c05 perf(pg_conf):增加、修改pg和tsdb优化的配置项 2022-10-08 17:54:51 +08:00
Yu Sun
a481e56dbc perf(distributed_stable):副本数3,v_bool也作为dimension 2022-10-08 14:43:30 +08:00
Yu Sun
c45cfba0be fix(sprintf):修复参数错误 2022-10-08 10:20:16 +08:00
Yu Sun
529f6f695b fix(auth):timescaledb的passfile可以复用pg的,但需要参数指定 2022-10-08 10:19:41 +08:00
Yu Sun
f45e3534ad fix(ENodeConfigure):修正拼写错误 2022-10-07 19:56:25 +08:00
Yu Sun
d0af74e974 fix(ENodeConfigure):dimension要后续一个个添加 2022-10-07 19:54:28 +08:00
Yu Sun
01bbe22143 style(help):调整了帮助信息格式 2022-10-07 19:40:23 +08:00
Yu Sun
3e70ef9f72 feat(main):增加参数指定worker个数 2022-10-07 19:38:48 +08:00
Yu Sun
1a1a25b3c7 feat(helm_value):创建集群时直接初始化scada数据库(新建并加入数据节点 2022-10-07 19:28:37 +08:00
Yu Sun
62821836b6 fix(job_attach_dn):现在新增数据节点需要密码验证 2022-10-07 19:27:35 +08:00
Yu Sun
8b5b345202 feat(ENodeConfigure):创建新超表的时候自动适配是否为分布式 2022-10-07 19:26:51 +08:00
Yu Sun
fbe6bda621 feat(charts):完成第一版helm charts 2022-10-03 19:40:37 +08:00
Yu Sun
8b878b603a fix(Image):手动编译event扩展 2022-09-28 18:49:07 +08:00
wu
cec792f425 修复Dockerfile时区失效&APK源指向latest-stable&增加php的openssl 2022-09-26 20:53:27 +08:00
wu
b00e96caa0 Dockerfile减少不必要的依赖 2022-09-25 21:01:05 +08:00
home
9c1aef946b 更新Dockerfile文件 2022-09-22 21:39:25 +08:00
Yu Sun
15914748b9 使用host的ipc 2022-09-09 16:31:00 +08:00
wu
fc631f7576 修复EDataCapture类的构造函数类型语法错误问题 2022-09-08 18:22:11 +08:00
wu
a2b83e2408 优化set_node_data以节点的type作为依据批量插入PG 2022-09-08 18:14:01 +08:00
wu
4222a3e71f 优化转发mes的device_code接口代码 2022-09-08 18:06:14 +08:00
wu
6fbb696779 scada数据查询页面增加loading遮罩层 2022-09-08 17:54:50 +08:00
wu
d169ba986d 增加node节点状态类型转发至MES接口&relay_device_status模 式,控制是否转发 2022-08-31 17:51:14 +08:00
wu
e7ce0403f1 更新设备配置导入导出功能 2022-08-31 15:54:38 +08:00
wu
e9840d6904 清理设备节点表格自带的筛选代码 2022-08-25 16:16:27 +08:00
wu
7c022ac307 修复切换设备时节点筛选框有值会显示出来 2022-08-25 10:50:05 +08:00
wu
2a8f4377ff 设备节点增加筛选功能 2022-08-25 10:13:06 +08:00
wu
c63a0a699d 增加设备节点名称过长显示tooltip&修改表格项长度 2022-08-24 16:31:16 +08:00
wu
231912e617 删除设备节点的发送按钮&增加节点复制功能 2022-08-24 16:13:21 +08:00
wu
2a86558335 优化代码&删除多余的请求 2022-08-24 14:10:27 +08:00
wu
f845494607 服务重启&设备配置应用添加确认提示框 2022-08-23 14:50:01 +08:00
wu
fd549832e6 修复绑定节点后绑定的值延迟显示在列表中 2022-08-23 14:09:19 +08:00
wu
af6e1c1cd4 请求HslServer接口的用户名密码统一写入在api文件 2022-08-23 11:18:12 +08:00
wu
a35532f8e7 删除新增服务弹窗的用户、密码输入框 2022-08-23 10:39:15 +08:00
wu
5403b4699b 增加设备节点当前值监控&当前值写入 2022-08-23 10:07:32 +08:00
wu
98b97af791 修复服务监控页面状态标签在第一次加载时报错 2022-08-22 15:57:06 +08:00
wu
20cd6e0f3a 设备监控页面添加定时器请求接口 2022-08-22 15:37:49 +08:00
wu
64388ae7b5 删除新增设备节点的当前值输入框 2022-08-22 15:00:12 +08:00
wu
c02125f192 修复服务、设备新增后不显示新的数据 2022-08-22 14:30:19 +08:00
wu
783b54e101 设备节点添加数据类型增加uint,short,ushort,long,ulong,double,byte,sbyte 2022-08-22 13:53:47 +08:00
wu
7a1f26ed80 修改ParentDeviceCode为undefined时,自动请求自动删除问题 2022-08-22 12:16:02 +08:00
wu
1eb319915a 修复服务无法编辑问题 2022-08-22 11:23:55 +08:00
Yu Sun
f578d351ca Merge branch 'master' of ssh://118.195.187.246:10022/ysun/EdgeManager 2022-08-22 11:06:45 +08:00
Yu Sun
a447918f54 正确处理ON DELETE CASCADE 2022-08-22 11:06:31 +08:00
wu
668c1bcf7c parent_code修改为@ParentDeviceCode,顺便先开放下拉框出来----淦 2022-08-21 13:06:08 +08:00
wu
74fcfc16f5 增加设备节点当前值获取HslServer的设备节点值功能 2022-08-21 13:00:46 +08:00
Yu Sun
c4f38267e9 Merge branch 'master' of ssh://118.195.187.246:10022/ysun/EdgeManager 2022-08-21 12:05:32 +08:00
Yu Sun
2acfd42255 change parent_code back to parent_device_code(wtf 2022-08-21 12:05:14 +08:00
wu
20dc733332 处理新建服务后,没做任何配置,显示上一个打开过的服务配置问题 2022-08-21 00:54:49 +08:00
home
311cf315de 服务监控页面中的服务增加状态显示,离线只显示服务信息,不显示设备信息 2022-08-21 00:24:55 +08:00
home
2889e27a02 设备节点直接更新修改为添加、删除、修改后需要点击应用再更新至HslServer 2022-08-20 23:23:30 +08:00
wu
7775f707b1 修改设备节点类型是string时添加长度,其他类型自动删除长度参数 2022-08-20 18:17:53 +08:00
wu
f7140382b4 服务设备配置增加读取mes的设备台账编码 2022-08-20 17:03:52 +08:00
wu
7926287dd8 scada节点配置工序单元修改为下拉可输入 2022-08-20 17:02:26 +08:00
wu
6cdeaf1c31 SCADA前端增加数据类别录入 2022-08-20 14:48:50 +08:00
wu
42ce278c36 清除服务账号密码输入框,env文件配置服务密码 2022-08-20 14:34:31 +08:00
Yu Sun
ede4c9160d 数据库为node配置增加一个enum类型,暂时不增加相关业务逻辑 2022-08-20 14:19:12 +08:00
Yu Sun
a948c11eb9 采集数据时新增一个parent_code字段,同时device_code更改为必填 2022-08-20 14:18:13 +08:00
wu
c33b6b9cd8 Merge branch 'master' of http://118.195.187.246:10030/ysun/EdgeManager 2022-08-20 00:11:33 +08:00
wu
cf05ec7653 添加账号密码录入&添加服务设备状态&优化代码 2022-08-20 00:11:27 +08:00
Yu Sun
92fb34f579 操,更改设备状态需要用get方法 2022-08-19 17:47:32 +08:00
Yu Sun
a01cf0b232 新增设备暂停/继续命令 2022-08-19 17:06:18 +08:00
Yu Sun
69173cb7a1 现在不需要在监控页面调用后端查询服务和设备关系了 2022-08-19 17:05:57 +08:00
wu
fd060438bf 更改设备配置json文件、添加数字输入框、修复设备监控页面BUG 2022-08-18 23:09:51 +08:00
Yu Sun
12e00952fb add server update status field 2022-08-18 22:16:02 +08:00
wu
cbad19a3f6 完成服务重启功能 2022-08-18 14:25:29 +08:00
wu
73814c500e 完成页面监控功能 2022-08-18 14:22:55 +08:00
wu
bf18a739ae 设备配置增加规则验证、组件是否显示&修改设备节点添加、修改 2022-08-18 14:21:08 +08:00
Yu Sun
aca5946dc2 为了监控页面后端还是加了一次性返回全部服务和设备的接口 2022-08-17 17:12:24 +08:00
wu
ce6e09b03b 修改设备添加、删除,修复动态组件日期格式 2022-08-17 00:05:22 +08:00
Yu Sun
0ed5eddd9a remove monitors;
add command for restart/close server
2022-08-16 00:41:31 +08:00
Yu Sun
6f28d923d2 fix bug by reassign with server info 2022-08-14 19:39:41 +08:00
Yu Sun
eec9e06137 fix entry bug 2022-08-14 19:39:07 +08:00
wu
8674b502d5 修改设备监控页面显示数据名称 2022-08-14 18:32:10 +08:00
wu
36068feed9 修复设备配置页面测试出现的多个BUG 2022-08-14 18:30:55 +08:00
Yu Sun
2d5e542593 finish all except ECommands;
handle GET content ahead
2022-08-13 21:47:41 +08:00
Yu Sun
94d66a7919 do NOT need reference here 2022-08-13 21:46:16 +08:00
wu
c59b544a65 增加服务监控页面样式 2022-08-13 16:21:39 +08:00
wu
b455bf6daf 增加设备配置功能页面 2022-08-13 16:20:48 +08:00
Yu Sun
0c9b560ca7 use sync for check Hslserver connection 2022-08-12 21:04:45 +08:00
Yu Sun
d43d2eedb2 finish API for add/remove/get device 2022-08-12 21:04:18 +08:00
Yu Sun
96f2c8068b 合并伍总的修改:修正逻辑相反错误、优化逻辑 2022-08-12 13:56:35 +08:00
Yu Sun
59f5a646c6 实现了服务的增删改查、设备增加 2022-08-12 13:55:31 +08:00
wuyanwei
78ecd669b3 修复no_dup_code参数判断的代码逻辑处理 2022-08-12 13:02:54 +08:00
wuyanwei
a5acd554ab 修复非no_dup_code模式下,请求不带有working_subclass,查询获取第一个结果 2022-08-12 12:56:30 +08:00
Yu Sun
d4385e6471 Merge branch 'master' of ssh://118.195.187.246:10022/ysun/EdgeManager 2022-08-11 16:04:41 +08:00
Yu Sun
fd7e58c02c 修复以code为插入依据可能导致的bug 2022-08-11 16:02:25 +08:00
wuyanwei
47004e2377 增加set_node_data接口根据code查询working_subclass 2022-08-10 17:52:44 +08:00
Yu Sun
f262e55698 修正无默认值参数位置问题 2022-08-10 16:04:35 +08:00
Yu Sun
fc42a5b1f0 紧急修复拼写错误 2022-08-10 15:22:18 +08:00
Yu Sun
54eae08472 增加端口号参数;对是否允许code在working_subclass间复用设定两种情况 2022-08-10 14:56:42 +08:00
Yu Sun
b54c1650cd 把EdgeManager做成daemon 2022-08-09 16:39:05 +08:00
Yu Sun
2540bdd9b7 增加调试环境的说明 2022-08-09 11:50:14 +08:00
65 changed files with 7315 additions and 469 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
*
!/EdgeManager/
!EdgeManager.php
!composer*

5
.env
View File

@@ -15,3 +15,8 @@ VUE_APP_I18N_FALLBACK_LOCALE=en
# element 颜色
VUE_APP_ELEMENT_COLOR=#409EFF
VUE_APP_HSLSERVER_PASSWORD=123456
#==true出现下拉数据来源于MES
#==false 手动输入以及不渲染mes的设备编码
VUE_APP_CHOOSABLE=false

3
.gitignore vendored
View File

@@ -21,4 +21,5 @@ yarn-error.log*
*.sw?
/vendor
/linux_x64
/EdgeServer-net6.0-linux-x64
/docs/book

View File

@@ -1,35 +1,61 @@
FROM ubuntu:22.04
FROM alpine:3.15
ARG DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Shanghai
# https://serverfault.com/a/683651
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
ADD https://php.hernandev.com/key/php-alpine.rsa.pub /etc/apk/keys/php-alpine.rsa.pub
RUN sed -i 's/archive.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list \
&& apt update \
&& apt install -y software-properties-common \
&& add-apt-repository -y ppa:ondrej/php \
&& sed -i 's/ppa.launchpadcontent.net/launchpad.proxy.ustclug.org/g' /etc/apt/sources.list.d/ondrej-ubuntu-php-jammy.list \
&& apt update \
&& apt install -y --no-install-recommends \
make \
curl \
RUN apk add ca-certificates \
&& echo -e '\
https://mirrors.ustc.edu.cn/alpine/latest-stable/main\n\
https://mirrors.ustc.edu.cn/alpine/latest-stable/community\n\
https://php.hernandev.com/v3.15/php-8.1\
' > /etc/apk/repositories \
&& apk update \
&& apk add \
tzdata \
php \
php-pear \
php-dev \
php-curl \
php-xdebug \
php-mbstring \
php-xml \
php-pgsql \
# event扩展运行时需要
php-sockets \
# 以下两件为workerman运行时需要
# https://github.com/walkor/workerman/issues/8
php-posix \
php-pcntl \
# 安装event扩展需要pecl
php-pear \
# 提供phpize
php-dev \
# 编译event扩展需要
autoconf \
# 以下两件足以在alpine里提供一般C编译环境
# g++则会提供gcc、musl-dev、libc-dev和g++,有点冗余
gcc \
musl-dev \
libevent-dev \
&& rm -rf /var/lib/apt/lists/*
make \
composer \
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& ln -s /usr/bin/php8 /usr/bin/php
RUN printf "\n\n\n\n\nno\nyes\n\n" | pecl install event \
&& printf "; priority=30\nextension=event.so\n" > /etc/php/$(ls /etc/php/)/mods-available/event.ini \
&& phpenmod -v $(ls /etc/php/) event \
&& curl -s https://mirrors.aliyun.com/composer/composer.phar -o /usr/local/bin/composer \
&& chmod +x /usr/local/bin/composer \
&& composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/
&& echo "extension=event.so" > /etc/php8/conf.d/02_event.ini
COPY . /EdgeManager
WORKDIR /EdgeManager
RUN composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/ \
&& composer u --no-cache
RUN apk del \
tzdata \
composer \
ca-certificates \
php-pear \
php-dev \
autoconf \
gcc \
musl-dev \
make \
&& rm -rf /var/cache/apk/* \
/etc/apk/keys/php-alpine.rsa.pub \
/tmp/*

View File

@@ -7,21 +7,39 @@ use Workerman\Protocols\Http\Response;
require_once __DIR__ . '/vendor/autoload.php';
use EdgeManager\EDataCapture\{ EDataCapture, ENodeConfigure };
use EdgeManager\EController\{ EConfigure, ECommand };
$options = getopt('h::', ['server_name:', 'user:', 'password:', 'help::']);
$options = getopt('h::', ['no_dup_code', 'relay_device_status', 'server_name:', 'port::', 'user:', 'password:', 'worker_num::', 'help::']);
init_db($options['server_name'], $options['user'], $options['password']);
if (array_key_exists('h', $options) or array_key_exists('help', $options)) {
print_r(
"EdgeManager使用说明
--no_dup_code 禁止code在不同的working subclass间复用
--relay_device_status 不判断是否是设备状态并转发到MES接口
--server_name pg实例的FQDN
--user pg实例的用户名
--port pg实例的端口号
--password pg实例的密码
--worker_num 主程序进程数默认为20个
-h, --help 显示此帮助信息
"
);
exit;
}
init_db($options['server_name'], $options['port'] ?? 5432, $options['user'], $options['password']);
$worker = new Worker('http://0.0.0.0:8888');
$worker -> name = 'EntryPoint';
$worker -> count = 20;
$worker -> count = $options['worker_num'] ?? 20;
$worker -> onWorkerStart = function(Worker $worker) {
global $options, $dbconn;
$dbconn = pg_connect(sprintf(
"host=%s dbname=scada user=%s password=%s",
$options['server_name'], $options['user'], $options['password']
"host=%s port=%s dbname=scada user=%s password=%s",
$options['server_name'], $options['port'] ?? 5432, $options['user'], $options['password']
));
};
@@ -31,16 +49,20 @@ $worker -> onMessage = function(TcpConnection $connection, Request $request) {
// 先预处理POST内容
if ($request->header('content-type') === 'application/json') {
$post = $request -> post();
$get = $request -> get();
if (isset($post['action'])) {
$post = json_decode(json_encode($post, JSON_PRESERVE_ZERO_FRACTION));
}
} else {
$get = $request -> get();
$body = $request -> rawBody();
if ($body === "") {
$response = new Response(200, [
'Content-Type' => 'application/json;charset=utf-8',
], "空请求!");
$connection -> send($response);
if (count($get) === 0) {
$response = new Response(200, [
'Content-Type' => 'application/json;charset=utf-8',
], "空请求!");
$connection -> send($response);
}
} else {
$post = json_decode($request -> rawBody());
if (json_last_error() !== JSON_ERROR_NONE) {
@@ -79,7 +101,7 @@ $worker -> onMessage = function(TcpConnection $connection, Request $request) {
// 执行异步连接
$task_connection->connect();
} else {
$enode_configure = new ENodeConfigure($dbconn, post: $post);
$enode_configure = new ENodeConfigure($dbconn, no_dup_code: $options['no_dup_code'] ?? true, post: $post);
$res = $enode_configure -> $action();
if ($res === true)
@@ -93,16 +115,57 @@ $worker -> onMessage = function(TcpConnection $connection, Request $request) {
'msg' => '服务器内部逻辑错误,请联系开发者!'
)));
}
}
}
} else if (
str_ends_with($post -> action, 'server')
or str_ends_with($post -> action, 'device')
) {
$action = $post -> action;
unset($post -> action);
if ($action === 'add_server') {
$task_connection = new AsyncTcpConnection('Text://127.0.0.1:1888');
$task_data = array(
'action' => 'add_server',
'data' => $post,
);
$task_connection -> send(json_encode($task_data));
$task_connection -> onMessage = function(AsyncTcpConnection $task_connection, $task_result) use ($connection) {
$task_connection -> close();
$connection -> send($task_result);
};
// 执行异步连接
$task_connection->connect();
} else {
$e_configure = new EConfigure($dbconn, post: $post);
$res = $e_configure -> $action();
if ($res === true)
$connection -> send(json_encode(array(
'code' => 0,
'msg' => 'Success'
)));
else if ($res === "REMAINING") {
$connection -> send(json_encode(array(
'code' => 1,
'msg' => '该服务中仍有关联的设备,不允许删除!'
)));
} else if ($res === false) {
$connection -> send(json_encode(array(
'code' => 1,
'msg' => '服务器内部逻辑错误,请联系开发者!'
)));
}
}
} else if ($post -> action === 'set_node_data') {
$data_capture = new EDataCapture($dbconn, post: $post);
$data_capture = new EDataCapture($dbconn, no_dup_code: $options['no_dup_code'] ?? true, relay_device_status: $options['relay_device_status'] ?? true, post: $post);
if ($data_capture -> check_res === 'WRONG_WORKING_SUBCLASS') {
$response = new Response(200, [
'Content-Type' => 'application/json;charset=utf-8',
], json_encode(array(
'action' => 'result_set_node_data',
'errcode' => 4002,
'errmsg' => '未登记过的工序单元!'
'errmsg' => '工序单元有误,请检查是否未指定或未登记'
)));
$connection -> send($response);
} else if ($data_capture -> check_res === 'MISMATCH_TYPE') {
@@ -114,6 +177,15 @@ $worker -> onMessage = function(TcpConnection $connection, Request $request) {
'errmsg' => '节点编码和数值类型不匹配!'
)));
$connection -> send($response);
} else if ($data_capture -> check_res === 'NO_DEVICE_CODE') {
$response = new Response(200, [
'Content-Type' => 'application/json;charset=utf-8',
], json_encode(array(
'action' => 'result_set_node_data',
'errcode' => 4002,
'errmsg' => 'device_code为必填字段'
)));
$connection -> send($response);
}
$res = $data_capture -> set_node_data();
if ($res === true) {
@@ -129,6 +201,19 @@ $worker -> onMessage = function(TcpConnection $connection, Request $request) {
'errmsg' => 'ROLLBACKed: Bad data received (structure and/or values)'
)));
}
} else if ($post -> action === 'exec') {
$task_connection = new AsyncTcpConnection('Text://127.0.0.1:1888');
$task_data = array(
'action' => 'exec',
'data' => $post,
);
$task_connection -> send(json_encode($task_data));
$task_connection -> onMessage = function(AsyncTcpConnection $task_connection, $task_result) use ($connection) {
$task_connection -> close();
$connection -> send($task_result);
};
// 执行异步连接
$task_connection->connect();
} else {
// 有action但是不知道是什么鬼动作
$response = new Response(200, [
@@ -153,23 +238,22 @@ $worker -> onMessage = function(TcpConnection $connection, Request $request) {
}
}
$get = $request -> get();
if (isset($get['query'])) {
if ($get['query'] == 'nodes') {
$enode_configure = new ENodeConfigure($dbconn, get: $get);
if ($get['query'] === 'nodes') {
$enode_configure = new ENodeConfigure($dbconn, no_dup_code: $options['no_dup_code'] ?? true, get: $get);
$nodes = $enode_configure -> get_nodes();
if (is_null($nodes))
$connection -> send(json_encode(array(
'code' => 1,
'msg' => 'no node data yet'
'msg' => '未添加过节点!'
)));
else
$connection -> send(json_encode(array(
'code' => 0,
'data' => $nodes
)));
} else if ($get['query'] == 'working_subclasses') {
$enode_configure = new ENodeConfigure($dbconn, get: $get);
} else if ($get['query'] === 'working_subclasses') {
$enode_configure = new ENodeConfigure($dbconn, no_dup_code: $options['no_dup_code'] ?? true, get: $get);
$working_subclasses = $enode_configure -> get_working_subclasses($dbconn);
if (is_null($working_subclasses))
$connection -> send(json_encode(array(
@@ -181,8 +265,8 @@ $worker -> onMessage = function(TcpConnection $connection, Request $request) {
'code' => 0,
'data' => $working_subclasses
)));
} else if ($get['query'] == 'codes') {
$enode_configure = new ENodeConfigure($dbconn, get: $get);
} else if ($get['query'] === 'codes') {
$enode_configure = new ENodeConfigure($dbconn, no_dup_code: $options['no_dup_code'] ?? true, get: $get);
$codes = $enode_configure -> get_codes_by_working_subclasses();
if (is_null($codes))
$connection -> send(json_encode(array(
@@ -194,8 +278,8 @@ $worker -> onMessage = function(TcpConnection $connection, Request $request) {
'code' => 0,
'data' => $codes
)));
} else if ($get['query'] == 'node_data') {
$data_capture = new EDataCapture($dbconn, get: $get);
} else if ($get['query'] === 'node_data') {
$data_capture = new EDataCapture($dbconn, no_dup_code: $options['no_dup_code'] ?? true, get: $get);
$data = $data_capture -> get_node_data();
if (is_null($data))
$connection -> send(json_encode(array(
@@ -212,6 +296,44 @@ $worker -> onMessage = function(TcpConnection $connection, Request $request) {
'code' => 0,
'data' => $data
)));
} else if ($get['query'] === 'servers') {
$servers = EConfigure::get_servers($dbconn);
if (is_null($servers))
$connection -> send(json_encode(array(
'code' => 1,
'msg' => '未添加过服务!'
)));
else
$connection -> send(json_encode(array(
'code' => 0,
'data' => $servers
)));
} else if ($get['query'] === 'device') {
$e_configure = new EConfigure($dbconn, get: $get);
$device = $e_configure -> get_device();
if (is_null($device))
$connection -> send(json_encode(array(
'code' => 1,
'msg' => '未添加过设备!'
)));
else
$connection -> send(json_encode(array(
'code' => 0,
'data' => $device
)));
} else if ($get['query'] === 'device_list') {
$e_configure = new EConfigure($dbconn, get: $get);
$devices = $e_configure -> get_device_list();
if (is_null($devices))
$connection -> send(json_encode(array(
'code' => 1,
'msg' => '未添加过设备!'
)));
else
$connection -> send(json_encode(array(
'code' => 0,
'data' => $devices
)));
} else {
$connection -> send(json_encode(array(
'code' => 1,
@@ -228,8 +350,8 @@ $consumer -> onWorkerStart = function(Worker $consumer) {
global $options, $consumer_dbconn;
$consumer_dbconn = pg_connect(sprintf(
"host=%s dbname=scada user=%s password=%s",
$options['server_name'], $options['user'], $options['password']
"host=%s port=%s dbname=scada user=%s password=%s",
$options['server_name'], $options['port'] ?? 5432, $options['user'], $options['password']
));
};
@@ -239,7 +361,7 @@ $consumer -> onMessage = function(TcpConnection $connection, $task_data) {
$task_data = json_decode($task_data);
if ($task_data -> action === 'add_node') {
$enode_configure = new ENodeConfigure($consumer_dbconn, post: $task_data -> data);
$enode_configure = new ENodeConfigure($consumer_dbconn, no_dup_code: $options['no_dup_code'] ?? true, post: $task_data -> data);
$res = $enode_configure -> add_node();
if ($res === true)
@@ -250,7 +372,7 @@ $consumer -> onMessage = function(TcpConnection $connection, $task_data) {
else if ($res === "REPLICATED")
$connection -> send(json_encode(array(
'code' => 1,
'msg' => '同一工序单元内的节点编码不可重复!'
'msg' => isset($options['no_dup_code']) ? '节点编码不可重复!' : '同一工序单元内的节点编码不可重复!'
)));
else if ($res === false) {
$connection -> send(json_encode(array(
@@ -258,6 +380,45 @@ $consumer -> onMessage = function(TcpConnection $connection, $task_data) {
'msg' => '服务器内部逻辑错误,请联系开发者!'
)));
}
} else if ($task_data -> action === 'add_server') {
$e_configure = new EConfigure($consumer_dbconn, post: $task_data -> data);
$res = $e_configure -> add_server();
if ($res === true)
$connection -> send(json_encode(array(
'code' => 0,
'msg' => 'Success'
)));
else if ($res === "REPLICATED")
$connection -> send(json_encode(array(
'code' => 1,
'msg' => '该地址或名称已注册过服务!'
)));
else if ($res === false) {
$connection -> send(json_encode(array(
'code' => 1,
'msg' => '服务器内部逻辑错误,请联系开发者!'
)));
}
} else if ($task_data -> action === 'exec') {
$e_command = new ECommand($consumer_dbconn, $task_data -> data);
$e_command -> exec();
$connection -> send(json_encode(array(
'code' => 0,
'msg' => 'Success'
)));
} else if ($task_data -> action === 'set_device_status') {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'http://8.sctmes.com:22347');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json'));
curl_setopt($ch, CURLOPT_HEADER, 1);
curl_setopt($ch,CURLOPT_POSTFIELDS, json_encode($task_data) );
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$return = curl_exec($ch);
curl_close($ch);
}
};

View File

@@ -0,0 +1,79 @@
<?php
namespace EdgeManager\EController;
class ECommand {
function __construct(
protected $dbconn,
protected $post = NULL,
) {}
function exec() {
$command2API = [
'server_restart' => '/Admin/ServerCloseAndRestart',
'server_close' => '/Admin/ServerClose',
'device_stop' => '/Edge/DeviceStopRequest',
'device_continue' => '/Edge/DeviceContinueRequest'
];
$res = pg_query($this -> dbconn, sprintf(
"SELECT url, port
FROM hf_mes_scada_edgeserver_controller_server
WHERE id = '%s'",
$this -> post -> server_id
));
$server_info = pg_fetch_row($res);
if (str_starts_with($this -> post -> command, 'server')) {
$ch = curl_init(
$server_info[0]
. ":"
. $server_info[1]
. $command2API[$this -> post -> command]
);
curl_setopt($ch, CURLOPT_POST, 1);
} else if (str_starts_with($this -> post -> command, 'device')) {
$ch = curl_init(
$server_info[0]
. ":"
. $server_info[1]
. $command2API[$this -> post -> command]
. '?data='
. $this -> post -> device_name
);
} else {
return;
}
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json'));
curl_setopt($ch, CURLOPT_HEADER, 1);
curl_setopt(
$ch,
CURLOPT_USERPWD,
$this -> post -> username
. ":"
. $this -> post -> password
);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$return = curl_exec($ch);
curl_close($ch);
if ($return) {
pg_update(
$this -> dbconn,
"hf_mes_scada_edgeserver_controller_server",
['updated' => false],
['id' => $this -> post -> server_id]
);
}
pg_insert(
$this -> dbconn,
'hf_mes_scada_edgeserver_controller_command',
[
'server_id' => $this -> post -> server_id,
'device_name' => $this -> post -> server_id ?? NULL,
'command' => $this -> post -> command,
'success' => $return
],
);
}
}

View File

@@ -0,0 +1,148 @@
<?php
namespace EdgeManager\EController;
class EConfigure {
function __construct(
protected $dbconn,
protected $post = NULL,
protected $get = NULL,
) {}
function add_server() {
pg_query($this -> dbconn, "BEGIN");
$addr_exists = pg_query($this -> dbconn, sprintf(
"SELECT EXISTS (
SELECT 1 FROM hf_mes_scada_edgeserver_controller_server
WHERE url = '%s'
AND port = '%s'
)",
$this -> post -> url,
$this -> post -> port
));
$name_exists = pg_query($this -> dbconn, sprintf(
"SELECT EXISTS (
SELECT 1 FROM hf_mes_scada_edgeserver_controller_server
WHERE name = '%s'
)",
$this -> post -> name
));
if (
pg_fetch_assoc($addr_exists)['exists'] === 't'
or pg_fetch_assoc($name_exists)['exists'] === 't'
) {
pg_query($this -> dbconn, "ROLLBACK");
return "REPLICATED";
} else {
$res = pg_insert(
$this -> dbconn,
'hf_mes_scada_edgeserver_controller_server',
(array) $this -> post
);
if ($res) {
pg_query($this -> dbconn, "COMMIT");
return true;
} else {
pg_query($this -> dbconn, "ROLLBACK");
return false;
}
}
}
function remove_server() {
$res = pg_delete(
$this -> dbconn,
'hf_mes_scada_edgeserver_controller_server',
(array) $this -> post,
);
return $res ? true : "REMAINING";
}
function update_server() {
return pg_update(
$this -> dbconn,
'hf_mes_scada_edgeserver_controller_server',
(array) $this -> post,
['id' => $this -> post -> id]
);
}
static function get_servers($dbconn) {
$res = pg_query($dbconn, "SELECT * FROM hf_mes_scada_edgeserver_controller_server");
return pg_fetch_all($res);
}
function add_device() {
$res = pg_insert(
$this -> dbconn,
'hf_mes_scada_edgeserver_controller_device',
(array) $this -> post
);
return $res === false ? $res : true;
}
function remove_device() {
return pg_delete(
$this -> dbconn,
'hf_mes_scada_edgeserver_controller_device',
(array) $this -> post
);
}
function update_device() {
pg_query($this -> dbconn, "BEGIN");
$res[] = pg_update(
$this -> dbconn,
'hf_mes_scada_edgeserver_controller_device',
(array) $this -> post,
['id' => $this -> post -> id]
);
$res[] = $id_res = pg_query(
$this -> dbconn, sprintf(
"SELECT server_id
FROM hf_mes_scada_edgeserver_controller_device
WHERE id = '%s'
LIMIT 1",
$this -> post -> id
)
);
$server_id = pg_fetch_row($id_res)[0];
$res[] = pg_update(
$this -> dbconn,
"hf_mes_scada_edgeserver_controller_server",
['updated' => true],
['id' => $server_id]
);
if (in_array(false, $res)) {
pg_query($this -> dbconn, "ROLLBACK");
return false;
} else {
pg_query($this -> dbconn, "COMMIT");
return true;
}
}
function get_device() {
$res = pg_query($this -> dbconn, sprintf(
"SELECT * FROM hf_mes_scada_edgeserver_controller_device
WHERE id = '%s'",
$this -> get['id']
));
return pg_fetch_all($res);
}
function get_device_list() {
$res = pg_query($this -> dbconn, sprintf(
"SELECT id, name FROM hf_mes_scada_edgeserver_controller_device
WHERE server_id = '%s'",
$this -> get['id']
));
return pg_fetch_all($res);
}
}

View File

@@ -1,74 +1,123 @@
<?php
namespace EdgeManager\EDataCapture;
use \Workerman\Connection\AsyncTcpConnection;
class EDataCapture {
function __construct(
protected $dbconn,
protected $no_dup_code,
protected $relay_device_status,
protected $post = NULL,
protected $get = NULL,
public $check_res = NULL,
protected $working_subclass = NULL,
protected $code_type = [],
protected $data = []
protected $data = [],
protected $status = array(
1 => 'IDLE',
2 => 'RUN',
3 => 'FINISH',
4 => 'TROUBLE',
5 => 'PAUSE',
6 => 'OFFLINE')
) {
if (!is_null($this -> post)) {
if (!in_array(
$this -> post -> param -> working_subclass,
ENodeConfigure::get_working_subclasses($this -> dbconn)
)) {
$this -> check_res = 'WRONG_WORKING_SUBCLASS';
return;
} else {
$this -> working_subclass = $this -> post -> param -> working_subclass;
if(isset($this -> post -> param -> working_subclass)){
if (!in_array(
$this -> post -> param -> working_subclass,
ENodeConfigure::get_working_subclasses($this -> dbconn)
)) {
$this -> check_res = 'WRONG_WORKING_SUBCLASS';
return;
} else {
$this -> working_subclass = $this -> post -> param -> working_subclass;
}
}else{
if ($this -> no_dup_code) {
$this -> check_res = 'WRONG_WORKING_SUBCLASS';
return;
}
$working_subclass = ENodeConfigure::get_working_subclasses_by_codes($this -> dbconn, $this -> post -> param -> data[0] -> code);
$this -> working_subclass = $working_subclass['working_subclass'];
}
$res = pg_fetch_all(pg_query($this -> dbconn, sprintf(
"SELECT code, type
"SELECT code, type, category
FROM hf_mes_scada_data_capture_node_configure
WHERE working_subclass = '%s'",
$this -> post -> param -> working_subclass
$this -> working_subclass
)));
$code_type = &$this -> code_type;
array_walk($res, function(&$v, $k) use (&$code_type) {
$code_type[$v['code']] = $v['type'];
array_walk($res, function($v, $k) use (&$code_type) {
$code_type[$v['code']] = array('type' => $v['type'], 'category' => $v['category']);
});
foreach ($this -> post -> param -> data as &$row) {
if (($code_type[$row -> code]) === 'float' and is_int($row -> value))
if (($code_type[$row -> code]['type']) === 'float' and is_int($row -> value))
$row -> value = (float) number_format((float) $row -> value, 1, '.', '');
$check_func = 'is_' . $code_type[$row -> code];
$check_func = 'is_' . $code_type[$row -> code]['type'];
if (!$check_func($row -> value)) {
$this -> check_res = 'MISMATCH_TYPE';
return;
} else {
$this -> data[$row -> code][] = [
'value' => $row -> value,
'device_code' => $row -> device_code ?? NULL,
'batch' => $row -> batch ?? NULL
];
}
if (!isset($row -> device_code)) {
$this -> check_res = 'NO_DEVICE_CODE';
return;
}
if($this -> relay_device_status && $code_type[$row -> code]['category'] === 'DEVICE_STATUS'){
if(!is_int($row -> value) && !in_array($row -> value , $this->status)){
$this -> check_res = 'MISMATCH_TYPE';
return;
}
$task_data = array(
'action' => 'set_device_status',
'param' => array(
"device_code" => $row -> parent_device_code,
"status" => is_int($row -> value) ? $this->status[$row -> value] : $row -> value,
"errcode" => $row -> errcode ?? NULL,
"msg" => $row -> msg ?? NULL,
),
);
$task_connection = new AsyncTcpConnection('Text://127.0.0.1:1888');
$task_connection -> send(json_encode($task_data));
$task_connection -> onMessage = function(AsyncTcpConnection $task_connection, $task_result){
$task_connection -> close();
};
// 执行异步连接
$task_connection->connect();
}
$this -> data[$code_type[$row -> code]['type']][] = [
'code' => $row -> code,
'value' => $row -> value,
'device_code' => $row -> device_code,
'parent_device_code' => $row -> parent_device_code ?? NULL,
'batch' => $row -> batch ?? NULL
];
}
unset($row);
}
}
function set_node_data() {
pg_query($this -> dbconn, "BEGIN");
foreach ($this -> data as $code => $value) {
foreach ($this -> data as $type => $value) {
foreach (array_chunk($value, 6710885, true) as $chunk) {
$sql_head = sprintf(
'INSERT INTO "hf_mes_scada_data_capture_node_data_%s" (code, v_%s, device_code, batch)
VALUES',
$this -> working_subclass,
$this -> code_type[$code]
$type
);
foreach ($chunk as $row) {
$sql_values[] = sprintf(
"('%s', '%s', '%s', '%s')",
$code,
$row['code'],
$row['value'] === false ? 'f' : $row['value'],
$row['device_code'] ?? NULL,
$row['device_code'],
$row['parent_device_code'] ?? NULL,
$row['batch'] ?? NULL
);
}
@@ -78,14 +127,7 @@ class EDataCapture {
unset($chunk);
}
unset($code, $value);
if (in_array(false, $res)) {
pg_query($this -> dbconn, "ROLLBACK");
return false;
} else {
pg_query($this -> dbconn, "COMMIT");
return true;
}
return true;
}
function get_node_data() {

View File

@@ -4,19 +4,30 @@ namespace EdgeManager\EDataCapture;
class ENodeConfigure {
function __construct(
protected $dbconn,
protected $no_dup_code,
protected $post = NULL,
protected $get = NULL
protected $get = NULL,
) {}
function add_node() {
pg_query($this -> dbconn, "BEGIN");
$exists = pg_query($this -> dbconn, sprintf(
"SELECT EXISTS(
SELECT 1 FROM hf_mes_scada_data_capture_node_configure
WHERE code = '%s'
AND working_subclass = '%s'
)", $this -> post -> code, $this -> post -> working_subclass
));
if ($this -> no_dup_code) {
$exists = pg_query($this -> dbconn, sprintf(
"SELECT EXISTS(
SELECT 1 FROM hf_mes_scada_data_capture_node_configure
WHERE code = '%s'
AND working_subclass = '%s'
)", $this -> post -> code, $this -> post -> working_subclass
));
} else {
$exists = pg_query($this -> dbconn, sprintf(
"SELECT EXISTS(
SELECT 1 FROM hf_mes_scada_data_capture_node_configure
WHERE code = '%s'
)", $this -> post -> code
));
}
if (pg_fetch_assoc($exists)['exists'] === 't') {
pg_query($this -> dbconn, "ROLLBACK");
return "REPLICATED";
@@ -27,12 +38,13 @@ class ENodeConfigure {
$res[] = pg_query($this -> dbconn, sprintf(
'CREATE TABLE IF NOT EXISTS "%s" (
id serial8,
code text,
code text NOT NULL,
v_string text,
v_int int,
v_float float8,
v_bool bool,
device_code text,
device_code text NOT NULL,
parent_device_code text,
batch text,
capture_time timestamp NOT NULL DEFAULT NOW()
)', $table_name
@@ -50,7 +62,18 @@ class ENodeConfigure {
", $table_name
));
if (!in_array($table_name, pg_fetch_all_columns($table_exists, 1))) {
$res[] = pg_query($this -> dbconn, sprintf("SELECT create_hypertable('\"%s\"', 'capture_time')", $table_name));
$res[] = $node_count = pg_query($this -> dbconn, "SELECT COUNT(*) from timescaledb_information.data_nodes");
$node_count = intval(pg_fetch_assoc($node_count)['count']);
if ($node_count === 0) {
$res[] = pg_query($this -> dbconn, sprintf("SELECT create_hypertable('\"%s\"', 'capture_time')", $table_name));
} else {
$res[] = pg_query($this -> dbconn, sprintf("SELECT create_distributed_hypertable('\"%s\"', 'capture_time', 'code', replication_factor => 3)", $table_name));
$res[] = pg_query($this -> dbconn, sprintf("SELECT add_dimension('\"%s\"', 'device_code', number_partitions => %s)", $table_name, $node_count));
$res[] = pg_query($this -> dbconn, sprintf("SELECT add_dimension('\"%s\"', 'parent_device_code', number_partitions => %s)", $table_name, $node_count));
$res[] = pg_query($this -> dbconn, sprintf("SELECT add_dimension('\"%s\"', 'batch', number_partitions => %s)", $table_name, $node_count));
$res[] = pg_query($this -> dbconn, sprintf("SELECT add_dimension('\"%s\"', 'v_bool', number_partitions => 2)", $table_name));
}
$res[] = pg_query($this -> dbconn, sprintf(
'CREATE INDEX ON "%s" (v_string, v_int, v_float, v_bool, capture_time DESC)
WHERE COALESCE(v_string, v_int::text, v_float::text, v_bool::text) IS NOT NULL
@@ -100,7 +123,7 @@ class ENodeConfigure {
$this -> dbconn,
'hf_mes_scada_data_capture_node_configure',
(array) $this -> post,
['code' => $this -> post -> code]
['id' => $this -> post -> id]
);
}
@@ -114,6 +137,16 @@ class ENodeConfigure {
return pg_fetch_all_columns($res, 0);
}
static function get_working_subclasses_by_codes($dbconn, $code) {
$res = pg_query($dbconn, sprintf(
"SELECT working_subclass
FROM hf_mes_scada_data_capture_node_configure
WHERE code = '%s'",
$code
));
return pg_fetch_assoc($res);
}
function get_codes_by_working_subclasses() {
$res = pg_query($this -> dbconn, sprintf(
"SELECT code

View File

@@ -1,20 +1,38 @@
<?php
function init_db($server_name, $user, $password) {
$dbconn = pg_connect(sprintf("host=%s user=%s password=%s", $server_name, $user, $password));
function init_db($server_name, $port, $user, $password) {
$dbconn = pg_connect(sprintf("host=%s port=%s user=%s password=%s", $server_name, $port, $user, $password));
$res = pg_query($dbconn, "select datname from pg_database");
if (!in_array('scada', pg_fetch_all_columns($res, 0)))
pg_query($dbconn, "CREATE DATABASE scada");
pg_close($dbconn);
$dbconn = pg_connect(sprintf("host=%s dbname=scada user=%s password=%s", $server_name, $user, $password));
$dbconn = pg_connect(sprintf("host=%s port=%s dbname=scada user=%s password=%s", $server_name, $port, $user, $password));
// 全局未启用TSDB的时候在这里对scada数据库启用就好
pg_query($dbconn, "CREATE EXTENSION IF NOT EXISTS timescaledb");
pg_query(
$dbconn,
"DO $$ BEGIN
CREATE TYPE category
AS ENUM (
'DEVICE_STATUS',
'ITEM_ID',
'DEVICE_DATA',
'PROCESS_DATA',
'RESULT_DATA',
'STATISTICAL_DATA'
);
EXCEPTION
WHEN duplicate_object
THEN null;
END $$"
);
pg_query($dbconn, "CREATE TABLE IF NOT EXISTS hf_mes_scada_data_capture_node_configure (
id serial2 primary key,
code text,
name text NOT NULL,
type text NOT NULL,
category category,
flow_code text,
working_subclass text NOT NULL,
workstation text,
@@ -25,20 +43,32 @@ function init_db($server_name, $user, $password) {
id serial2 primary key,
code text,
name text NOT NULL,
ip text NOT NULL,
url text NOT NULL,
port int2 NOT NULL,
sync_addr text NOT NULL,
address text NOT NULL,
create_date timestamp NOT NULL DEFAULT NOW(),
note text,
updated bool NOT NULL DEFAULT false
)");
pg_query($dbconn, "CREATE TABLE IF NOT EXISTS hf_mes_scada_edgeserver_controller_device (
id serial2 primary key,
code text,
name text NOT NULL,
server_id serial2
REFERENCES hf_mes_scada_edgeserver_controller_server(id),
conf json,
create_date timestamp NOT NULL DEFAULT NOW(),
note text
)");
pg_query($dbconn, "CREATE TABLE IF NOT EXISTS hf_mes_scada_edgeserver_controller_command (
id serial8 primary key,
server_id serial2 references hf_mes_scada_edgeserver_controller_server(id),
device_id text,
node text,
server_id serial2
REFERENCES hf_mes_scada_edgeserver_controller_server(id)
ON DELETE CASCADE,
device_name text,
command text,
create_date timestamp NOT NULL DEFAULT NOW(),
update_status bool
success bool
)");
pg_close($dbconn);
}

333
README.md
View File

@@ -1,333 +0,0 @@
# EdgeManagerSCADA系统
- [EdgeManagerSCADA系统](#edgemanagerscada系统)
- [API](#api)
- [class `EDataCapture`](#class-edatacapture)
- [0. HTTP POST: `set_node_data`](#0-http-post-set_node_data)
- [使用指北](#使用指北)
- [后端](#后端)
- [前端](#前端)
- [开发环境](#开发环境)
- [技术细节](#技术细节)
- [0. `EdgeManager\EDataCapture\EDataCapture -> set_data()`为什么是以`6710885`为大小chunked的](#0-edgemanageredatacaptureedatacapture---set_data为什么是以6710885为大小chunked的)
## API
> API设计最大程度遵循了已有的MES规范。
### class `EDataCapture`
#### 0. HTTP POST: `set_node_data`
**请求**
> 上位机程序中请单次传入尽量多的数据使得性能最大化。
>
> 但仍需兼顾传输速率和超时时间。
>
> 无需指定数值类型,服务端会自动根据已添加的节点信息检查,若不符则会报错。
```json
{
"action": "set_node_data",
"param": {
"working_subclass": <string>,
"data": [
{
"code": <string>,
// value的类型需与type对应
"value": <string | int | float | bool>,
"device_code": [string],
"batch": [string]
},
{
"code": <string>,
"value": <string | int | float | bool>,
"device_code": [string],
"batch": [string]
},
...
]
}
}
```
**返回**
操作成功:
```json
{
"action": "result_set_node_data",
"errcode": 0,
"errmsg": ""
}
```
操作失败(**工序单元尚未登记**
```json
{
"action": "result_set_node_data",
"errcode": 4002,
"errmsg": "未登记过的工序单元!"
}
```
操作失败(**数值类型错误**
```json
{
"action": "result_set_node_data",
"errcode": 4002,
"errmsg": "节点编码和数值类型不匹配!"
}
```
操作失败(不明原因):
```json
{
"action": "result_set_node_data",
"errcode": 4002,
"errmsg": "ROLLBACKed: Bad data received (structure and/or values)"
}
```
## 使用指北
本项目可独立运行也可作为MES的插件使用。
下文简要介绍如何将代码合并入MES中。
需要保证`timescaledb`的版本在**2.2.0或以上**。
### 后端
添加pg扩展Ubuntu
```bash
sudo apt install php-pgsql
```
添加pg扩展CentOS 7
```bash
sudo yum install php-pgsql
```
添加pg扩展CentOS8
```bash
sudo dnf install php-pgsql
```
目录结构:
```pre
📦EdgeManager
┣ 📂EDataCapture
┃ ┣ 📜EDataCapture.php
┃ ┗ 📜ENodeConfigure.php
┣ 📜Init.php
┗ 📜Utils.php
```
建议使用composer autoload引入。
将目录`EdgeManager`复制至项目后在composer.json内写入
```json
"autoload": {
"psr-4": {
"EdgeManager\\": "EdgeManager/"
},
"files": [
"EdgeManager/Utils.php",
"EdgeManager/Init.php"
]
},
```
运行`composer dump-autoload`即可正常调用EdgeManager的代码。
### 前端
添加依赖(**使用npm**
```bash
npm i element-ui \
@d2-projects/d2-crud \
vue-cheetah-grid \
@d2-projects/vue-table-export \
@d2-projects/vue-table-import \
github-markdown-css \
marked@^2.0.0 \
jschardet -S
```
添加依赖(**使用yarn**
```bash
yarn add element-ui \
@d2-projects/d2-crud \
vue-cheetah-grid \
@d2-projects/vue-table-export \
@d2-projects/vue-table-import \
github-markdown-css \
marked@^2.0.0 \
jschardet
```
需要在`main.js`中增加(全局引入组件):
```js
// D2-Crud
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import D2Crud from '@d2-projects/d2-crud'
// Cheetah-Grid
import vueCheetahGrid from 'vue-cheetah-grid'
// 表格导出插件
import pluginExport from '@d2-projects/vue-table-export'
import pluginImport from '@d2-projects/vue-table-import'
Vue.use(ElementUI)
Vue.use(D2Crud)
Vue.use(vueCheetahGrid)
Vue.use(pluginExport)
Vue.use(pluginImport)
```
*(可选)*`package.json`里更改`element-ui`版本:
```json
"element-ui": ">2.15.9 || 2.15.8",
```
`2.15.9`有一个小[bug](https://github.com/ElemeFE/element/issues/21941),会导致性能下降。
此外请参照目录结构中注释进行代码合并:
```pre
📦src
┣ 📂api
┃ ┣ 📂modules
┃ ┃ ┣ 📜scada.configure.api.js # 增添Axios请求
┃ ┃ ┗ 📜sys.user.api.js
┃ ┣ 📜index.js
┃ ┣ 📜service.js
┃ ┗ 📜tools.js
┣ 📂assets
┣ 📂components
┃ ┣ 📂d2-markdown # 渲染markdown所需组件在D2Admin的基础上精简了功能
┃ ┃ ┗ 📜index.vue
┃ ┗ 📜index.js # 在此处注册d2-markdown
┣ 📂libs
┣ 📂locales
┣ 📂menu
┃ ┗ 📜index.js # 增添菜单
┣ 📂plugin
┣ 📂router
┃ ┣ 📜index.js
┃ ┗ 📜routes.js # 增添路由
┣ 📂store
┣ 📂views
┃ ┣ 📂scada # 增添页面
┃ ┃ ┣ 📂scadaConfigure
┃ ┃ ┃ ┗ 📜index.vue
┃ ┃ ┗ 📂scadaQuery
┃ ┃ ┃ ┗ 📜index.vue
┃ ┗ 📂system
┣ 📜App.vue
┣ 📜i18n.js
┣ 📜main.js
┗ 📜setting.js
```
## 开发环境
拉取代码:
```bash
# 建议先配置SSH key pair
git clone ssh://git@118.195.187.246:10022/ysun/EdgeManager.git
cd EdgeManager
```
一键部署PHP workerman和TimescaleDB环境
```bash
docker compose up -d
```
进入交互式Prompt
```bash
docker exec -it edge_manager bash
```
后端调试:
```bash
# In container
php EdgeManager.php --server_name=GPU-server-01 --user=postgres --password=big_dick start
```
前端调试:
```bash
# In host
# yarn
# yarn watch
yarn serve
```
客户端连接:
```bash
# sudo apt install postgresql-client
# 登入
psql -h localhost -U postgres
# 显示数据库列表
\l
```
## 技术细节
### 0. `EdgeManager\EDataCapture\EDataCapture -> set_data()`为什么是以`6710885`为大小chunked的
首先明确一点根据PostgreSQL的技术架构条件允许的情况下**一次性插入多条记录是效率最高的**,比如:
```sql
INSERT INTO films (code, title, did, date_prod, kind) VALUES
('B6717', 'Tampopo', 110, '1985-02-10', 'Comedy'),
('HG120', 'The Dinner Game', 140, DEFAULT, 'Comedy');
```
这其中,仅插入部分字段的值,比如:
```sql
INSERT INTO films (code, title, did, date_prod, kind)
VALUES ('T_601', 'Yojimbo', 106, '1961-06-16', 'Drama');
```
的效率又远远不如插入全部列但使用特殊变量DEFAULT替代缺少的字段比如
```sql
INSERT INTO films VALUES
('UA502', 'Bananas', DEFAULT, '1971-07-13', 'Comedy', '82 minutes');
```
PostgreSQL的文档里没有提及这个话题因为源码里为SQL语句的内存分配[指定了一个最大长度常量`MaxAllocSize`](https://github.com/postgres/postgres/blob/2373fe78dfc9d4aa2348a86fffdf8eb9d757e9d5/src/common/stringinfo.c#L28)其大小为比1GB小1字节。这个值可以在编译前手动修改所以理论上每个PG运行的实例都可以不一样如果我们使用的PG并非修改过源码手动编译的那么SQL语句的最大长度为`1024 ** 3 - 1 = 1073741823` bytes。
> 安全起见下述计算假定working_subclass等字段的最大长度均为我们以往约定的45 bytes。
而此处我源码中的SQL语句头长度为138 bytes注意末尾空格
![](imgs/sql_head.png)
单个语句体的长度为160 bytes
![](imgs/sql_body.png)
所以其最多可以一次性插入`(1073741823 - 138) / 160`条记录,向下取整后即为`6710885`

1
charts/.helmignore Normal file
View File

@@ -0,0 +1 @@
*.png

21
charts/Chart.yaml Normal file
View File

@@ -0,0 +1,21 @@
# This file and its contents are licensed under the Apache License 2.0.
# Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
apiVersion: v1
name: timescaledb-multinode
description: 'TimescaleDB Multinode Deployment.'
version: 0.8.0
# appVersion specifies the version of the software, which can vary wildly,
# e.g. TimescaleDB 1.4.1 on PostgreSQL 11 or TimescaleDB 1.5.0 on PostgreSQL 12.
# https://github.com/helm/helm/blob/master/docs/charts.md#the-appversion-field
# To avoid confusion, we will not expose appVersion
# appVersion: 0.0.1
home: https://github.com/timescale/helm-charts
sources:
- https://github.com/timescale/helm-charts
- https://github.com/timescale/timescaledb-docker-ha
- https://github.com/zalando/patroni
# The chart is deprecated. We are looking for maintainers to remove this field.
# More in https://github.com/timescale/helm-charts/blob/main/charts/timescaledb-multinode/README.md#call-for-maintainers
deprecated: false

134
charts/admin-guide.md Normal file
View File

@@ -0,0 +1,134 @@
<!---
This file and its contents are licensed under the Apache License 2.0.
Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
-->
# TimescaleDB Single Administrator Guide
##### Table of Contents
- [Configuration](#configuration)
- [Backups](#backups)
- [Cleanup](#cleanup)
- [Troubleshooting](#troubleshooting)
## Configuration
The following table lists the configurable parameters of the TimescaleDB Helm chart and their default values.
| Parameter | Description | Default |
|-----------------------------------|---------------------------------------------|-----------------------------------------------------|
| `nameOverride` | Override the name of the chart | `timescaledb` |
| `fullnameOverride` | Override the fullname of the chart | `nil` |
| `replicaCount` | Amount of pods to spawn | `3` |
| `image.repository` | The image to pull | `timescale/timescaledb-ha` |
| `image.tag` | The version of the image to pull | `pg12.5-ts2.0.0-p0`
| `image.pullPolicy` | The pull policy | `IfNotPresent` |
| `credentials.accessNode.superuser`| Password of the superuser for the Access Node | `tea` |
| `credentials.dataNode.superuser` | Password of the superuser for the Data Nodes | `coffee` |
| `env` | Extra custom environment variables, expressed as [EnvVar](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.16/#envvarsource-v1-core) | `PGDATA` and some language settings |
| `resources` | Any resources you wish to assign to the pod | `{}` |
| `nodeSelector` | Node label to use for scheduling | `{}` |
| `tolerations` | List of node taints to tolerate | `[]` |
| `affinityTemplate` | A template string to use to generate the affinity settings | Anti-affinity preferred on hostname |
| `affinity` | Affinity settings. Overrides `affinityTemplate` if set. | `{}` |
| `postgresql.databases` | List of databases to automatically create a multinode setup for | `["postgres", "example"]` |
| `postgresql.parameters` | [PostgreSQL parameters](https://www.postgresql.org/docs/current/config-setting.html#CONFIG-SETTING-CONFIGURATION-FILE)) | Some required and preferred settings |
| `schedulerName` | Alternate scheduler name | `nil` |
| `persistentVolume.accessModes` | Persistent Volume access modes | `[ReadWriteOnce]` |
| `persistentVolume.annotations` | Annotations for Persistent Volume Claim` | `{}` |
| `persistentVolume.mountPath` | Persistent Volume mount root path | `/var/lib/postgresql` |
| `persistentVolume.size` | Persistent Volume size | `5Gi` |
| `persistentVolume.storageClass` | Persistent Volume Storage Class | `volume.alpha.kubernetes.io/storage-class: default` |
| `persistentVolume.subPath` | Subdirectory of Persistent Volume to mount | `""` |
| `rbac.create` | Create required role and rolebindings | `true` |
| `serviceAccount.create` | If true, create a new service account | `true` |
| `serviceAccount.name` | Service account to be used. If not set and `serviceAccount.create` is `true`, a name is generated using the fullname template | `nil` |
### Examples
- Override value using commandline parameters
```console
helm upgrade --install my-release . --set image.tag=pg12.5-ts2.0.0-p0 --set image.pullPolicy=Always
```
- Override values using `myvalues.yaml`
```yaml
# Filename: myvalues.yaml
image:
tag: pg12.5-ts2.0.0-p0
pullPolicy: Always
postgresql:
databases:
- postgres
- proddb
parameters:
checkpoint_completion_target: 32MB
work_mem: 16MB
shared_buffers: 512MB
```
```console
helm upgrade --install my-release . -f myvalues.yaml
```
## Cleanup
Removing a deployment can be done by deleting a Helm deployment, however, removing the deployment does not remove:
- the Persistent Volume Claims (pvc) belonging to the cluster
To fully purge a deployment in Kubernetes, you should do the following:
```console
# Delete the Helm deployment
helm delete my-release
# Delete pvc and the headless Patroni service
kubectl delete $(kubectl get pvc -l release=my-release -o name)
```
## Troubleshooting
### List Resources
All the resources that are deployed can be listed by providing the filter `-l release=my-release`.
```console
kubectl get all -l release=my-release
```
The output should be similar to the below output:
```console
NAME READY STATUS RESTARTS AGE
pod/my-release-timescaledb-access-0 1/1 Running 0 11m
pod/my-release-timescaledb-data-0 1/1 Running 0 11m
pod/my-release-timescaledb-data-1 1/1 Running 0 11m
pod/my-release-timescaledb-data-2 1/1 Running 0 11m
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/my-release-timescaledb LoadBalancer 10.152.183.60 <pending> 5432:31819/TCP 11m
service/my-release-timescaledb-data ClusterIP None <none> 5432/TCP 11m
NAME READY AGE
statefulset.apps/my-release-timescaledb-access 1/1 11m
statefulset.apps/my-release-timescaledb-data 3/3 11m
```
> **INFO** When listing resources within minutes of deploying a new Helm chart, you may see a list of jobs and its pods;
these jobs are there to create the database, and to attach the data nodes to the access node. There will be quite a few,
but these should disappear within minutes after successful deployment.
### Investigate TimescaleDB logs
The logs for the Access Node of TimescaleDB can be accessed as follows:
```console
kubectl logs $(kubectl get pod -l release=my-release,timescaleNodeType=access) timescaledb
```
### Verify multinode topology
```console
$ kubectl exec -ti $(kubectl get pod -l timescaleNodeType=access -o name) -c timescaledb -- psql -d example -c 'select node_name from timescaledb_information.data_nodes'
```
```text
node_name
-------------------------------
my-release-timescaledb-data-0
my-release-timescaledb-data-1
my-release-timescaledb-data-2
(3 rows)
```

View File

@@ -0,0 +1,10 @@
---
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
name: ram-hostpath
provisioner: microk8s.io/hostpath
reclaimPolicy: Delete
parameters:
pvDir: /mnt/ramdisk
volumeBindingMode: WaitForFirstConsumer

View File

@@ -0,0 +1,28 @@
# This file and its contents are licensed under the Apache License 2.0.
# Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
TimescaleDB can be accessed via port 5432 on the following DNS name from within your cluster:
{{ template "timescaledb.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local
To get your password for superuser run:
# superuser password
PGPASSWORD_SUPERUSER=$(kubectl get secret --namespace {{ .Release.Namespace }} {{ template "timescaledb.fullname" . }} -o jsonpath="{.data.password-superuser}" | base64 --decode)
# admin password
PGPASSWORD_ADMIN=$(kubectl get secret --namespace {{ .Release.Namespace }} {{ template "timescaledb.fullname" . }} -o jsonpath="{.data.password-admin}" | base64 --decode)
To connect to your database:
1. Run a postgres pod and connect using the psql cli:
# login as superuser
kubectl run -i --tty --rm psql --image=postgres \
--env "PGPASSWORD=$PGPASSWORD_SUPERUSER" \
--command -- psql -U postgres \
-h {{ template "timescaledb.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local postgres
# login as admin
kubectl run -i -tty --rm psql --image=postgres \
--env "PGPASSWORD=$PGPASSWORD_ADMIN" \
--command -- psql -U admin \
-h {{ template "timescaledb.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local postgres

View File

@@ -0,0 +1,60 @@
{{/*
This file and its contents are licensed under the Apache License 2.0.
Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
*/}}
{{/* vim: set filetype=mustache: */}}
{{/*
Expand the name of the chart.
*/}}
{{- define "timescaledb.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "timescaledb.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "timescaledb.dataname" -}}
{{ template "timescaledb.fullname" . }}-data
{{- end -}}
{{- define "timescaledb.accessname" -}}
{{ template "timescaledb.fullname" . }}-access
{{- end -}}
{{- define "postgres.uid" -}}
{{- default .Values.uid "1000" -}}
{{- end -}}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "timescaledb.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Create the name of the service account to use.
*/}}
{{- define "timescaledb.serviceAccountName" -}}
{{- if .Values.serviceAccount.create -}}
{{ default (include "timescaledb.fullname" .) .Values.serviceAccount.name }}
{{- else -}}
{{ default "default" .Values.serviceAccount.name }}
{{- end -}}
{{- end -}}

View File

@@ -0,0 +1,77 @@
# This file and its contents are licensed under the Apache License 2.0.
# Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
{{- range $pod, $e := until ( .Values.dataNodes | int) }}
{{- range $index, $dbname := $.Values.postgresql.databases }}
---
apiVersion: batch/v1
kind: Job
metadata:
name: {{ printf "attachdn-%s-db%s-data%s" $.Release.Name ($index | toString) ($pod | toString) | trunc 63 }}
labels:
app: {{ template "timescaledb.fullname" $ }}
chart: {{ template "timescaledb.chart" $ }}
release: {{ $.Release.Name }}
heritage: {{ $.Release.Service }}
annotations:
"helm.sh/hook-delete-policy": hook-succeeded
spec:
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
app: {{ template "timescaledb.fullname" $ }}
chart: {{ template "timescaledb.chart" $ }}
release: {{ $.Release.Name }}
heritage: {{ $.Release.Service }}
dataNode: {{ template "timescaledb.dataname" $ }}-{{ $pod }}
spec:
containers:
- name: attachdn-{{ $index }}
image: postgres:14.5-alpine # A relatively small official image that can run psql
command:
- sh
- -c
# We wait for the data node to allow connections
# We wait for the access node to allow connections to DBNAME
- >
while ! pg_isready -U postgres -h "${DATA_NODE_DNS}"; do sleep 1; done;
while ! psql -d "${ACCESS_SVC_CONNSTR}" --set dbname="${DBNAME}" --set ON_ERROR_STOP=1 --command '\c :"dbname"'; do sleep 1; done;
echo "${SQLCOMMAND}" | psql -d "${ACCESS_SVC_CONNSTR}" --file=- --echo-queries --set ON_ERROR_STOP=1 \
--set dbname="${DBNAME}" \
--set data_node_name="${DATA_NODE_NAME}" \
--set data_node_dns="${DATA_NODE_DNS}"
env:
{{- /*
Some parameter juggling is required to ensure we don't have SQL injection;
which is not necessarily a major security leak at this stage, but we want
to be able to support database names like 'test db' or, 'CamelCase'.
The template quote function ensures bash will be able to interpret the variable.
The --set dbname= and subsequent :'dbname' psql_variable ensures no SQL injection can occur.
https://www.postgresql.org/docs/current/app-psql.html#APP-PSQL-INTERPOLATION
*/}}
- name: DBNAME
value: {{ $dbname | quote }}
- name: ACCESS_SVC_CONNSTR
value: host={{ template "timescaledb.fullname" $ }} user=postgres connect_timeout=3 sslmode=disable
- name: DATA_NODE_DNS
value: {{ template "timescaledb.dataname" $ }}-{{ $pod }}.{{ template "timescaledb.dataname" $ }}
- name: DATA_NODE_NAME
value: {{ template "timescaledb.dataname" $ }}-{{ $pod }}
- name: SQLCOMMAND
value: |
\c :"dbname"
SELECT *
FROM add_data_node(:'data_node_name'::name, host => :'data_node_dns', if_not_exists => true)
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: {{ template "timescaledb.accessname" $ }}
key: password-superuser
restartPolicy: OnFailure
backoffLimit: 2
...
{{ end }}
{{ end }}

View File

@@ -0,0 +1,72 @@
# This file and its contents are licensed under the Apache License 2.0.
# Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
{{- range $index, $dbname := .Values.postgresql.databases }}
---
apiVersion: batch/v1
kind: Job
metadata:
name: {{ printf "createdb-%s-db%s" $.Release.Name ($index | toString) | trunc 63 }}
labels:
app: {{ template "timescaledb.fullname" $ }}
chart: {{ template "timescaledb.chart" $ }}
release: {{ $.Release.Name }}
heritage: {{ $.Release.Service }}
annotations:
"helm.sh/hook-delete-policy": hook-succeeded
spec:
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
app: {{ template "timescaledb.fullname" $ }}
chart: {{ template "timescaledb.chart" $ }}
release: {{ $.Release.Name }}
heritage: {{ $.Release.Service }}
spec:
containers:
- name: createdb-{{ $index }}
image: postgres:14.5-alpine # A relatively small official image that can run psql
command:
- sh
- -c
- >
while ! pg_isready -U postgres -h {{ template "timescaledb.fullname" $ }}; do sleep 1; done;
echo "${SQLCOMMAND}" | psql --file=- --echo-queries -d "${ACCESS_SVC_CONNSTR}" \
--set ON_ERROR_STOP=1 \
--set dbname="${DBNAME}"
env:
{{- /*
Some parameter juggling is required to ensure we don't have SQL injection;
which is not necessarily a major security leak at this stage, but we want
to be able to support database names like 'test db' or, 'CamelCase'.
The template quote function ensures bash will be able to interpret the variable.
The --set dbname= and subsequent :'dbname' psql_variable ensures no SQL injection can occur.
https://www.postgresql.org/docs/current/app-psql.html#APP-PSQL-INTERPOLATION
*/}}
- name: DBNAME
value: {{ $dbname | quote }}
- name: ACCESS_SVC_CONNSTR
value: host={{ template "timescaledb.fullname" $ }} user=postgres connect_timeout=3 sslmode=disable
- name: SQLCOMMAND
value: |
SELECT format('CREATE DATABASE %I', :'dbname')
WHERE NOT EXISTS (
SELECT
FROM pg_database
WHERE datname=:'dbname'
)
\gexec
\c :"dbname"
CREATE EXTENSION IF NOT EXISTS timescaledb;
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: {{ template "timescaledb.accessname" $ }}
key: password-superuser
restartPolicy: OnFailure
backoffLimit: 2
...
{{ end }}

View File

@@ -0,0 +1,31 @@
# This file and its contents are licensed under the Apache License 2.0.
# Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
---
apiVersion: v1
kind: Secret
metadata:
name: {{ template "timescaledb.accessname" . }}
labels:
app: {{ template "timescaledb.fullname" . }}
chart: {{ template "timescaledb.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
type: Opaque
data:
password-superuser: {{ .Values.credentials.accessNode.superuser | b64enc }}
...
---
apiVersion: v1
kind: Secret
metadata:
name: {{ template "timescaledb.dataname" . }}
labels:
app: {{ template "timescaledb.fullname" . }}
chart: {{ template "timescaledb.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
type: Opaque
data:
password-superuser: {{ .Values.credentials.dataNode.superuser | b64enc }}
...

View File

@@ -0,0 +1,14 @@
# This file and its contents are licensed under the Apache License 2.0.
# Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ template "timescaledb.serviceAccountName" . }}
labels:
app: {{ template "timescaledb.fullname" . }}
chart: {{ template "timescaledb.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- end }}

View File

@@ -0,0 +1,166 @@
# This file and its contents are licensed under the Apache License 2.0.
# Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ template "timescaledb.accessname" . }}
labels:
app: {{ template "timescaledb.fullname" . }}
chart: {{ template "timescaledb.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
serviceName: {{ template "timescaledb.accessname" . }}
replicas: 1
podManagementPolicy: Parallel
selector:
matchLabels:
app: {{ template "timescaledb.fullname" . }}
release: {{ .Release.Name }}
timescaleNodeType: access
template:
metadata:
name: {{ template "timescaledb.accessname" . }}
labels:
app: {{ template "timescaledb.fullname" . }}
release: {{ .Release.Name }}
timescaleNodeType: access
spec:
serviceAccountName: {{ template "timescaledb.serviceAccountName" . }}
securityContext:
# The postgres user inside the TimescaleDB image has uid=1000.
# This configuration ensures the permissions of the mounts are suitable
fsGroup: {{ template "postgres.uid" }}
runAsGroup: {{ template "postgres.uid" }}
runAsNonRoot: true
runAsUser: {{ template "postgres.uid" }}
initContainers:
- name: initdb
securityContext:
allowPrivilegeEscalation: false
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: POSTGRESQL_CUSTOM_PARAMETERS
value: |
{{- range $key, $value := .Values.postgresql.parameters }}
{{ printf "%s = '%s'" $key ($value | toString) }}
{{- end }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ template "timescaledb.accessname" . }}
key: password-superuser
- name: POSTGRES_PASSWORD_DATA_NODE
valueFrom:
secretKeyRef:
name: {{ template "timescaledb.dataname" . }}
key: password-superuser
{{- if .Values.env }}
{{ .Values.env | default list | toYaml | indent 8 }}
{{- end }}
command:
- sh
- '-c'
# By calling the original entrypoint with the first argument being postgres
# we ensure we do everything that is required to init a PostgreSQL instance.
# By supplying --single however, we ensure the postmaster is running in the
# foreground, allowing us to do some more initialization
- |
set -e
install -o postgres -g postgres -m 0700 -d "${PGDATA}"
/docker-entrypoint.sh postgres --single < /dev/null
grep -qxF "include 'postgresql_helm_customizations.conf'" "${PGDATA}/postgresql.conf" \
|| echo "include 'postgresql_helm_customizations.conf'" >> "${PGDATA}/postgresql.conf"
echo "Writing custom PostgreSQL Parameters to ${PGDATA}/postgresql_helm_customizations.conf"
echo "cluster_name = '$(hostname)'" > "${PGDATA}/postgresql_helm_customizations.conf"
echo "${POSTGRESQL_CUSTOM_PARAMETERS}" | sort >> "${PGDATA}/postgresql_helm_customizations.conf"
echo "*:*:*:postgres:${POSTGRES_PASSWORD_DATA_NODE}" > "${PGDATA}/../.pgpass"
chown postgres:postgres "${PGDATA}/../.pgpass" "${PGDATA}/postgresql_helm_customizations.conf"
chmod 0600 "${PGDATA}/../.pgpass"
echo "Adding host all all all md5 in pg_hba.conf"
grep -qxF "host all all all md5" "${PGDATA}/pg_hba.conf" \
|| echo "host all all all md5" >> ${PGDATA}/pg_hba.conf
volumeMounts:
- name: storage-volume
mountPath: "{{ .Values.persistentVolume.mountPath }}"
subPath: "{{ .Values.persistentVolume.subPath }}"
containers:
- name: timescaledb
securityContext:
allowPrivilegeEscalation: false
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
# We start postgres with a fully cleared environment
command:
- sh
- '-c'
- exec env -i PGDATA="${PGDATA}" PATH="${PATH}" /docker-entrypoint.sh postgres
env:
- name: POD_NAMESPACE
valueFrom:
fieldRef:
apiVersion: v1
fieldPath: metadata.namespace
{{- if .Values.env }}
{{ .Values.env | default list | toYaml | indent 8 }}
{{- end }}
ports:
- containerPort: 5432
volumeMounts:
- name: storage-volume
mountPath: "{{ .Values.persistentVolume.mountPath }}"
subPath: "{{ .Values.persistentVolume.subPath }}"
resources:
{{ toYaml .Values.resources | indent 10 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{ toYaml . | indent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{ toYaml . | indent 8 }}
{{- end }}
{{- if .Values.schedulerName }}
schedulerName: {{ .Values.schedulerName }}
{{- end }}
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- an
{{- if not .Values.persistentVolume.enabled }}
- name: storage-volume
emptyDir: {}
{{- end }}
{{- if .Values.persistentVolume.enabled }}
volumeClaimTemplates:
- metadata:
name: storage-volume
annotations:
{{- if .Values.persistentVolume.annotations }}
{{ toYaml .Values.persistentVolume.annotations | indent 8 }}
{{- end }}
labels:
app: {{ template "timescaledb.fullname" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
accessModes:
{{ toYaml .Values.persistentVolume.accessModes | indent 8 }}
resources:
requests:
storage: "{{ .Values.persistentVolume.size }}"
{{- if .Values.persistentVolume.storageClass }}
{{- if (eq "-" .Values.persistentVolume.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.persistentVolume.storageClass }}"
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,161 @@
# This file and its contents are licensed under the Apache License 2.0.
# Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ template "timescaledb.dataname" . }}
labels:
app: {{ template "timescaledb.fullname" . }}
chart: {{ template "timescaledb.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
serviceName: {{ template "timescaledb.dataname" . }}
replicas: {{ .Values.dataNodes }}
podManagementPolicy: Parallel
selector:
matchLabels:
app: {{ template "timescaledb.fullname" . }}
release: {{ .Release.Name }}
timescaleNodeType: data
template:
metadata:
name: {{ template "timescaledb.dataname" . }}
labels:
app: {{ template "timescaledb.fullname" . }}
release: {{ .Release.Name }}
timescaleNodeType: data
spec:
serviceAccountName: {{ template "timescaledb.serviceAccountName" . }}
securityContext:
# The postgres user inside the TimescaleDB image has uid=1000.
# This configuration ensures the permissions of the mounts are suitable
fsGroup: {{ template "postgres.uid" }}
runAsGroup: {{ template "postgres.uid" }}
runAsNonRoot: true
runAsUser: {{ template "postgres.uid" }}
initContainers:
- name: initdb
securityContext:
allowPrivilegeEscalation: false
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: POSTGRESQL_CUSTOM_PARAMETERS
value: |
{{- range $key, $value := .Values.postgresql.parameters }}
{{ printf "%s = '%s'" $key ($value | toString) }}
{{- end }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ template "timescaledb.dataname" . }}
key: password-superuser
{{- if .Values.env }}
{{ .Values.env | default list | toYaml | indent 8 }}
{{- end }}
command:
- sh
- '-c'
# By calling the original entrypoint with the first argument being postgres
# we ensure we do everything that is required to init a PostgreSQL instance.
# By supplying --single however, we ensure the postmaster is running in the
# foreground, allowing us to do some more initialization
- |
set -e
install -o postgres -g postgres -m 0700 -d "${PGDATA}" "${PGDATA}/../conf.d"
/docker-entrypoint.sh postgres --single < /dev/null
grep -qxF "include 'postgresql_helm_customizations.conf'" "${PGDATA}/postgresql.conf" \
|| echo "include 'postgresql_helm_customizations.conf'" >> "${PGDATA}/postgresql.conf"
echo "Writing custom PostgreSQL Parameters to ${PGDATA}/postgresql_helm_customizations.conf"
echo "cluster_name = '$(hostname)'" > "${PGDATA}/postgresql_helm_customizations.conf"
echo "${POSTGRESQL_CUSTOM_PARAMETERS}" | sort >> "${PGDATA}/postgresql_helm_customizations.conf"
echo "Adding host all all all md5 in pg_hba.conf"
grep -qxF "host all all all md5" "${PGDATA}/pg_hba.conf" \
|| echo "host all all all md5" >> ${PGDATA}/pg_hba.conf
# The TimescaleDB extension should not be available by default, as this interferes with the bootstrapping
# done by the access nodes. Therefore we drop the extensions from template1
echo "DROP EXTENSION timescaledb" | /docker-entrypoint.sh postgres --single -D "${PGDATA}" template1
volumeMounts:
- name: storage-volume
mountPath: "{{ .Values.persistentVolume.mountPath }}"
subPath: "{{ .Values.persistentVolume.subPath }}"
containers:
- name: timescaledb
securityContext:
allowPrivilegeEscalation: false
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
# We start postgres with a fully cleared environment
command:
- sh
- '-c'
- exec env -i PGDATA="${PGDATA}" PATH="${PATH}" /docker-entrypoint.sh postgres
env:
- name: POD_NAMESPACE
valueFrom:
fieldRef:
apiVersion: v1
fieldPath: metadata.namespace
{{- if .Values.env }}
{{ .Values.env | default list | toYaml | indent 8 }}
{{- end }}
ports:
- containerPort: 5432
volumeMounts:
- name: storage-volume
mountPath: "{{ .Values.persistentVolume.mountPath }}"
subPath: "{{ .Values.persistentVolume.subPath }}"
resources:
{{ toYaml .Values.resources | indent 10 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{ toYaml . | indent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{ toYaml . | indent 8 }}
{{- end }}
{{- if .Values.schedulerName }}
schedulerName: {{ .Values.schedulerName }}
{{- end }}
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: NotIn
values:
- an
{{- if not .Values.persistentVolume.enabled }}
- name: storage-volume
emptyDir: {}
{{- end }}
{{- if .Values.persistentVolume.enabled }}
volumeClaimTemplates:
- metadata:
name: storage-volume
annotations:
{{- if .Values.persistentVolume.annotations }}
{{ toYaml .Values.persistentVolume.annotations | indent 8 }}
{{- end }}
labels:
app: {{ template "timescaledb.fullname" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
accessModes:
{{ toYaml .Values.persistentVolume.accessModes | indent 8 }}
resources:
requests:
storage: "{{ .Values.persistentVolume.size }}"
{{- if .Values.persistentVolume.storageClass }}
{{- if (eq "-" .Values.persistentVolume.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.persistentVolume.storageClass }}"
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,25 @@
# This file and its contents are licensed under the Apache License 2.0.
# Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
---
apiVersion: v1
kind: Service
metadata:
name: {{ template "timescaledb.fullname" . }}
labels:
app: {{ template "timescaledb.fullname" . }}
chart: {{ template "timescaledb.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
annotations:
service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout: "4000"
spec:
type: LoadBalancer
ports:
- name: postgresql
port: 5432
protocol: TCP
selector:
app: {{ template "timescaledb.fullname" . }}
timescaleNodeType: access
...

View File

@@ -0,0 +1,23 @@
# This file and its contents are licensed under the Apache License 2.0.
# Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
---
apiVersion: v1
kind: Service
metadata:
name: {{ template "timescaledb.dataname" . }}
labels:
app: {{ template "timescaledb.fullname" . }}
chart: {{ template "timescaledb.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
clusterIP: None
ports:
- name: postgresql
port: 5432
protocol: TCP
selector:
app: {{ template "timescaledb.fullname" . }}
timescaleNodeType: data
...

130
charts/values.yaml Normal file
View File

@@ -0,0 +1,130 @@
# This file and its contents are licensed under the Apache License 2.0.
# Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
dataNodes: 3
# To prevent very long names, we override the name, otherwise it would default to
# timescaledb-multinode (the name of the chart)
nameOverride: timescaledb
image:
# Image was built from
# https://github.com/timescale/timescaledb-docker-ha
repository: timescale/timescaledb-ha
tag: pg14.5-ts2.8.0-patroni-static-primary-latest
pullPolicy: IfNotPresent
# Credentials used by PostgreSQL
credentials:
accessNode:
superuser: big_dick
dataNode:
superuser: big_dick
# Extra custom environment variables.
# These should be an EnvVar, as this allows you to inject secrets into the environment
# https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.16/#envvar-v1-core
env:
- name: LC_ALL
value: C.UTF-8
- name: LANG
value: C.UTF-8
- name: PGDATA
# This should be a subdirectory of the persistentVolume (if any), as PostgreSQL will need to
# fully manage permissions. Also, using /var/lib/postgresql/data is discouraged, as this is
# a Docker Volume in many Docker images, which means the data is not actually persisted.
value: /var/lib/postgresql/pgdata
persistentVolume:
enabled: true
size: 100G
## database data Persistent Volume Storage Class
## If defined, storageClassName: <storageClass>
## If set to "-", storageClassName: "", which disables dynamic provisioning
## If undefined (the default) or set to null, no storageClassName spec is
## set, choosing the default provisioner. (gp2 on AWS, standard on
## GKE, AWS & OpenStack)
##
# storageClass: "-"
subPath: ""
mountPath: "/var/lib/postgresql"
annotations: {}
accessModes:
- ReadWriteOnce
resources: {}
postgresql:
databases:
- postgres
- scada
parameters:
max_connections: 300
max_prepared_transactions: 300
effective_cache_size: 96272MB
maintenance_work_mem: 2047MB
max_worker_processes: 367
max_parallel_workers_per_gather: 32
max_parallel_workers: 64
wal_buffers: 16MB
shared_buffers: 32090MB
work_mem: 5134kB
default_statistics_target: 500
random_page_cost: 1.1
checkpoint_completion_target: 0.9
max_locks_per_transaction: 512
autovacuum_max_workers: 10
autovacuum_naptime: 10
effective_io_concurrency: 256
log_connections: 'on'
log_line_prefix: "%t [%p]: [%c-%l] %u@%d,app=%a [%e] "
log_min_duration_statement: '1s'
log_statement: ddl
log_checkpoints: 'on'
log_lock_waits: 'on'
max_wal_senders: 0
wal_level: minimal
fsync: false
min_wal_size: 512MB
wal_writer_delay: 1000
temp_file_limit: 1GB
timescaledb.passfile: '/var/lib/postgresql/.pgpass'
synchronous_commit: 'off'
timescaledb.enable_2pc: false
timescaledb.max_background_workers: 300
timezone: 'Asia/Shanghai'
# https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#nodeselector
nodeSelector: {}
# https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/
tolerations: []
# https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#affinity-and-anti-affinity
affinityTemplate: |
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: "kubernetes.io/hostname"
labelSelector:
matchLabels:
app: {{ template "timescaledb.name" . }}
release: {{ .Release.Name | quote }}
affinity: {}
## Use an alternate scheduler, e.g. "stork".
## ref: https://kubernetes.io/docs/tasks/administer-cluster/configure-multiple-schedulers/
##
# schedulerName:
rbac:
# Specifies whether RBAC resources should be created
create: true
serviceAccount:
# Specifies whether a ServiceAccount should be created
create: true
# The name of the ServiceAccount to use.
# If not set and create is true, a name is generated using the fullname template
name:

View File

@@ -1,5 +1,5 @@
services:
scada:
edge_manager:
build:
context: .
container_name: edge_manager

6
docs/book.toml Normal file
View File

@@ -0,0 +1,6 @@
[book]
authors = ["Yu Sun"]
language = "en"
multilingual = false
src = "src"
title = "EdgeManager开发指北"

26
docs/src/SUMMARY.md Normal file
View File

@@ -0,0 +1,26 @@
# Summary
[项目简介](introduction.md)
[系统配置](system.md)
# SCADA系统
- [环境搭建](scada/environment.md)
- [作为MES插件使用](scada/plug_in.md)
- [前端配置](scada/front.md)
- [后端配置](scada/service.md)
- [API](scada/api.md)
- [技术细节](scada/details.md)
# 测试
- [集群搭建](tests/cluster.md)
- [运行多节点TimescaleDB服务](tests/tsdb.md)
- [技术细节](tests/details.md)
- [常见问题](tests/questions/questions.md)
- [Pods无法清除](tests/questions/terminating.md)
- [Pods创建失败](tests/questions/failed_pod.md)
- [使用物理内存作持久化存储](tests/questions/ram.md)

3
docs/src/introduction.md Normal file
View File

@@ -0,0 +1,3 @@
# 项目简介
待补充,请阅读其他内容。

91
docs/src/scada/api.md Normal file
View File

@@ -0,0 +1,91 @@
# API
> API设计最大程度遵循了已有的MES规范。
## 请求说明
>上位机程序中请单次传入尽量多的数据使得性能最大化。
>
>但仍需兼顾传输速率和超时时间。
>
>无需指定数值类型,服务端会自动根据已添加的节点信息检查,若不符则会报错。
### set_node_data上传设备实时数据
#### 输入接口参数说明:
| 参数名称 | 参数说明 | 类型 | 是否必传 |备注 |
| ---- | ---- | ---- | ---- |---- |
| action | 接口名称 |String | 是 |
| param | 接口参数 |Object | 是|
| working_subclass | 工序单元属性 |String | 可选 | 通过数据系统对各工序动态定义|
| data | 上传数值 |Array | 是|
| code | 值 | String | 是 |
| value | 值 |string/int/float/bool | 是 | value的类型需与数据库节点配置表的type对应|
| device_code | 设备编码 |string | 是 |
| parent_device_code | 值 | String | 可选 | MES系统中的设备编号,上传设备状态时为必传|
| batch | 批次号 | String | 可选 | MES系统中的生产批次号|
```json
{
"action": "set_node_data",
"param": {
"working_subclass": <string>, // 可选
"data": [
{
"code": <string>,
"value": <string | int | float | bool>,// value的类型需与type对应
"device_code": [string], // 必需字段
"parent_device_code": [string], // 可选字段对应MES中的device_code
"batch": [string] // 可选字段
},
{
//code的值在后台的数据类别为设备状态类型时
"code": <string>,
// value的类型需与type对应
"value": <string | int >,
"device_code": [string], // 必需字段
"parent_device_code": [string], // 必需字段对应MES中的code
"batch": [string] // 可选字段
},
{
"code": <string>,
"value": <string | int | float | bool>,
"device_code": [string],
"parent_device_code": [string],
"batch": [string]
},
...
]
}
}
```
#### 1.2 输出接口参数说明:
| 参数名称 | 参数说明 | 类型 | 长度 |备注 |
| ----- | ----- | ----- | ----- |----- |
| action | 接口名称 |String | 45 |
| errcode | 返回码 |Int | 11 |0请求成功<br>4002未登记过的工序单元<br>4002节点编码和数值类型不匹配<br>4002ROLLBACKed: Bad data received (structure and/or values) |
| errmsg | 错误信息 |String | 255 |errcode 不为0errmsg才有错误信息 |
```
{
action: "result_set_node_data",
errcode: 0,
errmsg: ""
}
```
## 上传设备状态
>数据节点的数据类型在后台设定为设备状态时该节点上传的值只能上传int或string两种类型。
>
>并且int或string这两种类型只能上传下方表格中int或string指定的值若不符则会报错。
>
>上传对应的设备状态parent_device_code为必传字段
| int类型 | string类型 | 描述 |
| ----- | ----- | ----- |
| 1 | IDLE | 设备已经初始化,再等待工作 |
| 2 | RUN | 设备在工作 |
| 3 | FINISH | 工序结束后托盘还没取出时设备为FINISH取出后变为IDLE状态 |
| 4 | TROUBLE | 设备有问题需要维修这时msg有相应的关键字 |
| 5 | PAUSE | 设备通过手工操作变成暂停状态 |
| 6 | OFFLINE | 设备处于离线状态 |

39
docs/src/scada/details.md Normal file
View File

@@ -0,0 +1,39 @@
## 技术细节
### 0. `EdgeManager\EDataCapture\EDataCapture -> set_data()`为什么是以`6710885`为大小chunked的
首先明确一点根据PostgreSQL的技术架构条件允许的情况下**一次性插入多条记录是效率最高的**,比如:
```sql
INSERT INTO films (code, title, did, date_prod, kind) VALUES
('B6717', 'Tampopo', 110, '1985-02-10', 'Comedy'),
('HG120', 'The Dinner Game', 140, DEFAULT, 'Comedy');
```
这其中,仅插入部分字段的值,比如:
```sql
INSERT INTO films (code, title, did, date_prod, kind)
VALUES ('T_601', 'Yojimbo', 106, '1961-06-16', 'Drama');
```
的效率又远远不如插入全部列但使用特殊变量DEFAULT替代缺少的字段比如
```sql
INSERT INTO films VALUES
('UA502', 'Bananas', DEFAULT, '1971-07-13', 'Comedy', '82 minutes');
```
PostgreSQL的文档里没有提及这个话题因为源码里为SQL语句的内存分配[指定了一个最大长度常量`MaxAllocSize`](https://github.com/postgres/postgres/blob/2373fe78dfc9d4aa2348a86fffdf8eb9d757e9d5/src/common/stringinfo.c#L28)其大小为比1GB小1字节。这个值可以在编译前手动修改所以理论上每个PG运行的实例都可以不一样如果我们使用的PG并非修改过源码手动编译的那么SQL语句的最大长度为`1024 ** 3 - 1 = 1073741823` bytes。
> 安全起见下述计算假定working_subclass等字段的最大长度均为我们以往约定的45 bytes。
而此处我源码中的SQL语句头长度为138 bytes注意末尾空格
![](sql_head.png)
单个语句体的长度为160 bytes
![](sql_body.png)
所以其最多可以一次性插入`(1073741823 - 138) / 160`条记录,向下取整后即为`6710885`

View File

@@ -0,0 +1,77 @@
# 环境搭建
## 开发环境
拉取代码:
```bash
# 建议先配置SSH key pair
git clone ssh://git@118.195.187.246:10022/ysun/EdgeManager.git
cd EdgeManager
```
一键部署PHP workerman和TimescaleDB环境
```bash
# docker build --network host -t edge_manager .
docker compose up -d
```
进入交互式Prompt
```bash
docker exec -it edge_manager sh
```
后端调试:
>--no_dup_code禁止code在不同的working subclass间复用
>
>--relay_device_status不判断是否是设备状态并转发到MES接口
>
>--port连接PG数据库端口默认5432端口
```bash
# In container
php EdgeManager.php --no_dup_code --relay_device_status --server_name=GPU-server-01 --user=postgres --password=big_dick start
```
前端调试:
```bash
# In host
# yarn
# yarn watch
yarn serve
```
客户端PG连接
```bash
# sudo apt install postgresql-client
# 登入
psql -h localhost -U postgres
# 显示数据库列表
\l
```
## 调试环境
不使用`docker compose`创建两个container分别运行EdgeManager和pg
```bash
# cd EdgeManager # 先定位到项目目录方便创建image和挂载
# 创建EdgeManager的image
docker build --network host -t edge_manager .
# 创建container运行EdgeManager
docker run -d --name edge_manager_test -v $PWD:/EdgeManager --network host --ipc host -it edge_manager
# 创建container运行pg将端口映射到host的55432
docker run -d --name pg_test -v $PWD/config/postgresql.conf:/etc/postgresql/postgresql.conf -p 55432:5432 -e POSTGRES_PASSWORD=big_dick -it timescale/timescaledb-ha:pg14-latest postgres -c 'config_file=/etc/postgresql/postgresql.conf'
# 进入交互式Prompt
docker exec -it edge_manager_test sh
# 启动EdgeManagerworkerman命令省略...
# 常用命令
# 查看全部container
docker ps -a
# 启动已停止的container
docker start [container]

91
docs/src/scada/front.md Normal file
View File

@@ -0,0 +1,91 @@
# 前端配置
添加依赖(**使用npm**
```bash
npm i element-ui \
@d2-projects/d2-crud \
vue-cheetah-grid \
@d2-projects/vue-table-export \
@d2-projects/vue-table-import \
github-markdown-css \
marked@^2.0.0 \
jschardet -S
```
添加依赖(**使用yarn**
```bash
yarn add element-ui \
@d2-projects/d2-crud \
vue-cheetah-grid \
@d2-projects/vue-table-export \
@d2-projects/vue-table-import \
github-markdown-css \
marked@^2.0.0 \
jschardet
```
需要在`main.js`中增加(全局引入组件):
```js
// D2-Crud
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import D2Crud from '@d2-projects/d2-crud'
// Cheetah-Grid
import vueCheetahGrid from 'vue-cheetah-grid'
// 表格导出插件
import pluginExport from '@d2-projects/vue-table-export'
import pluginImport from '@d2-projects/vue-table-import'
Vue.use(ElementUI)
Vue.use(D2Crud)
Vue.use(vueCheetahGrid)
Vue.use(pluginExport)
Vue.use(pluginImport)
```
*(可选)*`package.json`里更改`element-ui`版本:
```json
"element-ui": ">2.15.9 || 2.15.8",
```
`2.15.9`有一个小[bug](https://github.com/ElemeFE/element/issues/21941),会导致性能下降。
此外请参照目录结构中注释进行代码合并:
```pre
📦src
┣ 📂api
┃ ┣ 📂modules
┃ ┃ ┣ 📜scada.configure.api.js # 增添Axios请求
┃ ┃ ┗ 📜sys.user.api.js
┃ ┣ 📜index.js
┃ ┣ 📜service.js
┃ ┗ 📜tools.js
┣ 📂assets
┣ 📂components
┃ ┣ 📂d2-markdown # 渲染markdown所需组件在D2Admin的基础上精简了功能
┃ ┃ ┗ 📜index.vue
┃ ┗ 📜index.js # 在此处注册d2-markdown
┣ 📂libs
┣ 📂locales
┣ 📂menu
┃ ┗ 📜index.js # 增添菜单
┣ 📂plugin
┣ 📂router
┃ ┣ 📜index.js
┃ ┗ 📜routes.js # 增添路由
┣ 📂store
┣ 📂views
┃ ┣ 📂scada # 增添页面
┃ ┃ ┣ 📂scadaConfigure
┃ ┃ ┃ ┗ 📜index.vue
┃ ┃ ┗ 📂scadaQuery
┃ ┃ ┃ ┗ 📜index.vue
┃ ┗ 📂system
┣ 📜App.vue
┣ 📜i18n.js
┣ 📜main.js
┗ 📜setting.js
```

View File

@@ -0,0 +1,6 @@
# SCADA系统
> 本项目可独立运行也可作为MES的插件使用。
>
> 下面章节简要介绍如何将代码合并入MES中。
>
> 需要保证timescaledb的版本在2.2.0或以上。

48
docs/src/scada/service.md Normal file
View File

@@ -0,0 +1,48 @@
# 后端配置
添加pg扩展Ubuntu
```bash
sudo apt install php-pgsql
```
添加pg扩展CentOS 7
```bash
sudo yum install php-pgsql
```
添加pg扩展CentOS8
```bash
sudo dnf install php-pgsql
```
目录结构:
```pre
📦EdgeManager
┣ 📂EDataCapture
┃ ┣ 📜EDataCapture.php
┃ ┗ 📜ENodeConfigure.php
┣ 📜Init.php
┗ 📜Utils.php
```
建议使用composer autoload引入。
将目录`EdgeManager`复制至项目后在composer.json内写入
```json
"autoload": {
"psr-4": {
"EdgeManager\\": "EdgeManager/"
},
"files": [
"EdgeManager/Utils.php",
"EdgeManager/Init.php"
]
},
```
运行`composer dump-autoload`即可正常调用EdgeManager的代码。

BIN
docs/src/scada/sql_body.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

BIN
docs/src/scada/sql_head.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

43
docs/src/system.md Normal file
View File

@@ -0,0 +1,43 @@
# 系统配置
> 建议在开发环境、测试环境和生产环境均应用该页面中建议的设置。
>
> 如果使用集群,则每台机器都应应用该页面中建议的设置。
## OS安装
关键参数:
1. Ubuntu Server 22.04.1 LTS
2. minimized安装
3. 磁盘挂载不要选LVM方式
4. 关闭swap(`/etc/fstab`去掉对应行后重启生效)
## 更改时区为一致
```bash
sudo timedatectl set-timezone Asia/Shanghai
```
## 内核调优
运行以下命令:
```bash
echo '
net.ipv4.tcp_max_tw_buckets = 20000
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 262144
net.core.netdev_max_backlog = 30000
fs.file-max = 6815744
net.netfilter.nf_conntrack_max = 2621440
' | sudo tee -a /etc/sysctl.conf
echo '
* soft nofile 1024000
* hard nofile 1024000
root soft nofile 1024000
root hard nofile 1024000
' | sudo tee -a /etc/security/limits.conf
```
然后`sudo reboot`重启服务器。

145
docs/src/tests/cluster.md Normal file
View File

@@ -0,0 +1,145 @@
# 集群搭建
> 操作系统的安装和配置请参照[系统配置](../system.md)章节。
## hostname配置
可通过`hostname`命令检查当前服务器的hostname。
在一切开始之前首先要确保4台物理机的hostname分别为`an``dn0``dn1``dn2`
其中,`an`代表多节点TimescaleDB集群中的Access Node接入节点`dnx`则代表Data Node数据节点
在安装操作系统时可以一并设置hostname。如果有改变hostname的需要可以类似这样操作
```bash
sudo hostnamectl set-hostname an
```
则hostname会被改为`an`**即时生效**)。
## hosts配置
可通过编辑`/etc/hosts`文件将局域网内hostname和IP地址的映射关系写入此处不赘述具体步骤。
> 需确保集群内每一节点都可以**正确地**将其他节点的hostname解析为对应的IP地址。
## MicroK8s安装
```bash
sudo snap install microk8s --classic --channel=1.25
```
> MicroK8s安装也可以在Ubuntu安装过程勾选完成。
>
> 1.25版本对最新内核的cgroup v2识别可能有问题`microk8s.inspect`会[提示warning](https://github.com/canonical/microk8s/issues/3375)),忽略即可,并不影响集群稳定性。
将当前用户加入`microk8s`用户组方便管理:
```bash
sudo usermod -a -G microk8s $USER
sudo chown -f -R $USER ~/.kube
newgrp microk8s
```
gcr镜像无法在祖国大陆直接获取[使用阿里云替代](https://microk8s.io/docs/registry-private#configure-registry-mirrors-7),但截至该文档编写时,官方文档提供的解决方法[有问题](https://github.com/canonical/microk8s/issues/472#issuecomment-1256947878),可以用下述方法替代:
```bash
sed -i 's#k8s.gcr.io#registry.aliyuncs.com/google_containers#g' /var/snap/microk8s/current/args/containerd-template.toml
sudo snap restart microk8s
```
确认K8s是否已经正常运行
```bash
microk8s.status -w
```
如无异常,片刻后会显示如下内容:
```pre
microk8s is running
high-availability: no
datastore master nodes: 127.0.0.1:19001
datastore standby nodes: none
addons:
enabled:
ha-cluster # (core) Configure high availability on the current node
disabled:
community # (core) The community addons repository
dashboard # (core) The Kubernetes dashboard
dns # (core) CoreDNS
gpu # (core) Automatic enablement of Nvidia CUDA
helm # (core) Helm 2 - the package manager for Kubernetes
helm3 # (core) Helm 3 - Kubernetes package manager
host-access # (core) Allow Pods connecting to Host services smoothly
hostpath-storage # (core) Storage class; allocates storage from host directory
ingress # (core) Ingress controller for external access
mayastor # (core) OpenEBS MayaStor
metallb # (core) Loadbalancer for your Kubernetes cluster
metrics-server # (core) K8s Metrics Server for API access to service metrics
prometheus # (core) Prometheus operator for monitoring and logging
rbac # (core) Role-Based Access Control for authorisation
registry # (core) Private image registry exposed on localhost:32000
storage # (core) Alias to hostpath-storage add-on, deprecated
```
此外,为了方便拉取`docker.io`上的镜像,最好配置使用[阿里云的镜像加速器](https://cr.console.aliyun.com/cn-hangzhou/instances/mirrors)
```bash
echo '
server = "https://registry-1.docker.io"
[host."https://r3vfmoy2.mirror.aliyuncs.com"]
capabilities = ["pull", "resolve"]
' | tee /var/snap/microk8s/current/args/certs.d/docker.io/hosts.toml
sudo snap restart microk8s
```
重复上述步骤使得MicroK8s在每台服务器上得以妥善安装配置。
## 组建集群
组建集群的详细步骤可以参考[官方文档](https://microk8s.io/docs/clustering),但需**注意**
1. `microk8s join`之前需要在每个node的`/etc/hosts`中加入其他node的信息
2. 每个节点加入后token会过期需要再次`microk8s add-node`获取新的token
## 启用Helm3扩展
> Helm3扩展自MicroK8s v1.25开始已经默认开启,无需手动安装。
同样是祖国大陆受GFW限制的问题如果直接`microk8s.enable helm3`是从官方拉取文件安装Helm3速度极慢且中途易出现下载中断。
可以把对应的安装脚本里的文件位置指向华为云的镜像:
```bash
sed 's#https://get.helm.sh#https://mirrors.huaweicloud.com/helm/v3.8.0#' /var/snap/microk8s/common/addons/core/addons/helm3/enable | SNAP=/snap/microk8s/current SNAP_DATA=/var/snap/microk8s/current SNAP_ARCH=amd64 bash
```
运行完毕后再执行`microk8s.status`即可看到Helm3扩展已启用。
## 后续配置
由于我们要使用Helm来作为包管理器而helm会读取K8s的`config`文件,如果不加设置,默认会使用`/var/snap/microk8s/current/credentials/client.config`,但由于安全性问题,会被警告:
```pre
WARNING: Kubernetes configuration file is group-readable. This is insecure.
```
所以我们需要创建用户级别的配置文件并赋予正确的文件权限:
```bash
microk8s config > ~/.kube/config
chmod go-r ~/.kube/config
```
为便于操作,再将常用命令做一下`alias`
```bash
echo '
alias kubectl="microk8s kubectl"
alias helm="microk8s helm3 --kubeconfig ~/.kube/config"
' | tee -a ~/.bash_aliases
```

89
docs/src/tests/details.md Normal file
View File

@@ -0,0 +1,89 @@
# 技术细节
## 0. 如何确保集群的access-node/data-node pods运行于我们期望的物理节点上
首先要明确一个概念由于需要对Access Node和Data Node进行区分TimescaleDB集群其实是一个有状态的应用。
有状态应用借助K8s的[StatefulSet workload](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/)可以很便捷的部署。
但很久以前TimescaleDB官方维护的multinodes版本Helm仓库里对于Access Node的节点选择并没有清晰定义。
[`charts/templates/statefulset-timescaledb-accessnode.yaml`](https://github.com/timescale/helm-charts/blob/29b589c96821a3f9ffe890cd775d7da471007aa0/charts/timescaledb-multinode/templates/statefulset-timescaledb-accessnode.yaml#L117-L118):
```yaml
{{- with .Values.nodeSelector }}
nodeSelector:
```
[`charts/values.yaml`](https://github.com/timescale/helm-charts/blob/29b589c96821a3f9ffe890cd775d7da471007aa0/charts/timescaledb-multinode/values.yaml#L91):
```yaml
nodeSelector: {}
```
咱就是说,直接就摆烂了。
这直接导致多个接入节点和数据节点的pods很大概率错误地尝试分配在同一个物理节点上由于资源冲突最终造成随机的某个节点创建失败。
按照[我们之前对hostname的定义](cluster.md#hostname配置),最后将`charts/templates/statefulset-timescaledb-accessnode.yaml`里对应部分改为:
```yaml
nodeSelector:
kubernetes.io/hostname: an
```
解决了这个问题。
`kubernetes.io/hostname`是K8s的一个保留label通过如下命令可以查看
```bash
kubectl get no --show-labels=true
```
![](node_label.png)
这里有一个复杂情况如果设定的Data Node数目超过了节点数目亦或是其中某个节点挂掉集群的Access Node或者Data Node是会尝试漂移到某个节点上的也就是某些节点上会启动多于一个TimescaleDB实例。这是我们不想看到的。
在Helm chart中妥善设置节点亲和性可以有效解决这个问题
`charts/templates/statefulset-timescaledb-accessnode.yaml`:
```yaml
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- an
```
`charts/templates/statefulset-timescaledb-datanode.yaml`:
```yaml
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: NotIn
values:
- an
```
核心思想是使用`requiredDuringSchedulingIgnoredDuringExecution`替代`preferredDuringSchedulingIgnoredDuringExecution`策略,以实现强制性的调度[^note]。
[^note]: <https://kubernetes.io/zh-cn/docs/tasks/configure-pod-container/assign-pods-nodes-using-node-affinity/#schedule-a-Pod-using-required-node-affinity>
## 1. TimescaleDB集群的多节点间操作是如何实现免密码认证的
PostgreSQL有一个[Password file](https://www.postgresql.org/docs/current/libpq-pgpass.html)的设定,通过一个`.pgpass`来进行一些免密认证。
TimescaleDB可以复用这个文件但是必须在PostgreSQL的配置文件中以[`timescaledb.passfile`](https://docs.timescale.com/timescaledb/latest/how-to-guides/configuration/timescaledb-config/#timescaledb-passfile-string)字段的形式提供。
打个比方,应用层中使用[`create-distributed-hypertable()`](https://docs.timescale.com/api/latest/distributed-hypertables/create_distributed_hypertable/#create-distributed-hypertable)函数创建分布式超表的时候其是不允许将密码传参提供的这个时候就一定要环境中提供免密认证使得各个nodes之间可以自由连通。
而[`add_data_node()`](https://docs.timescale.com/api/latest/distributed-hypertables/add_data_node/)函数则可以直接传参指定密码,因其为环境部署过程中可能需要的。但是出于安全性考虑,也**不建议**这样做。

BIN
docs/src/tests/get_all.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
docs/src/tests/psql.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,33 @@
# Pods创建失败
## 问题描述
使用`kubectl get all -l release=tsdb` 发现有单个数据节点或`(1个数据节点 + 1个接入节点)`的pods的状态是某种error`kubectl describe`之后发现类似下面的报错:
```pre
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Pulled 4m34s (x2 over 4m48s) kubelet Container image "timescale/timescaledb-ha:pg14.5-ts2.8.0-patroni-static-primary-latest" already present on machine
...
Warning MissingClusterDNS 7s (x8 over 39s) kubelet pod: "tsdb-timescaledb-data-1_default(62ba2d9e-6cff-4446-aa2b-ae46f5948997)". kubelet does not have ClusterDNS IP configured and cannot create Pod using "ClusterFirst" policy. Falling back to "Default" policy.
```
## 解决方案
很容易误会是没有启用`core/dns`扩展所致,但事实上我们已经[启用了](../tsdb.md#安装必要扩展addons)改扩展。
`charts/templates/svc-timescaledb-data.yaml`文件中的相关定义为:
```yaml
spec:
clusterIP: None
```
而K8s其实是允许定义Headless Services的[^note],就是通过将`.spec.clusterIP`指定为`"None"`
所以问题其实是:**没有能够正确应用Selector把pod分配到正确的节点上**。
具体的设置方法可参照[技术细节](../details.md#0-如何确保集群的access-node-pod运行于我们设定的物理节点上)。
[^note]: <https://kubernetes.io/docs/concepts/services-networking/service/#headless-services>

View File

@@ -0,0 +1,3 @@
# 常见问题
本章节用于记录测试过程中常见问题及解决方案。

View File

@@ -0,0 +1,38 @@
# 使用物理内存作持久化存储
首先划分一块足够的内存空间并挂载:
```bash
sudo mkdir -pm777 /mnt/ramdisk
sudo mount -t ramfs -o size=10G ramdisk /mnt/ramdisk
```
> **需要在每个节点上都如法炮制**。
创建新的StorageClass
```bash
kubectl apply -f ram-hostpath-sc.yaml
```
重新启动集群服务的时候,使用命令行传参的方式覆盖默认(`values.yaml`)设置。
```bash
helm install tsdb --set persistentVolume.size=8G --set persistentVolume.storageClass=ram-hostpath .
```
Microk8s的hostpath storage有bug新创建的PV权限并非`777`会导致StatefulSet的pods全部启动失败。暂时只能在每个节点上手动设置权限
```bash
sudo chmod -R 777 /mnt/ramdisk/
```
之后静等对应的pods自动重启。
[移除集群](../tsdb.html#移除集群)的操作不再赘述。
如要清除创建的StorageClass
```bash
kubectl delete sc/ram-hostpath
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,27 @@
# Pods无法清除
## 问题描述
执行`helm uninstall`或者`kubectl delete`等命令的时候发现pods无法删除
![](terminating.png)
使用`kubectl describe`观察详情的时候发现通讯失败但Sandbox其实已经被清除了
![](sandbox.png)
## 解决方案
运行:
```bash
for p in $(kubectl get pods | grep Terminating | awk '{print $1}'); do kubectl delete pod $p --grace-period=0 --force; done
```
如果运行上面的命令后仍有pod处于`Unknown`状态,大胆清除[^note]
```bash
kubectl patch pod <pod> -p '{"metadata":{"finalizers":null}}'
```
[^note]: <https://kubernetes.io/docs/tasks/run-application/force-delete-stateful-set-pod/#delete-pods>

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

115
docs/src/tests/tsdb.md Normal file
View File

@@ -0,0 +1,115 @@
# 运行多节点TimescaleDB服务
在[上一章节](cluster.md)中我们完成了基本的K8s集群搭建并启用了Helm3扩展以便于利用helm charts便捷部署应用于其上。
接下来说明如何配置一个测试用集群。
> 注意,生产环境中的配置可以参考本章,但不应照搬;
>
> 本章中的配置仅提供**最简易**环境并论证运维**可行性**,性能和稳定性方面均**无法胜任生产环境**。
## 安装必要扩展Addons
运行以下命令,并耐心等待自动配置完成:
```bash
microk8s enable dns hostpath-storage metallb
```
`dns`是必需的因为Data Nodes上运行的TimescaleDB实例作为[Headless Services](questions/failed_pod.md#解决方案)本身是没有为它们提供IP的。如果没有K8s内建的DNS服务根本就无法根据pod name获取到具体的IP也就是说这些pods间无法通讯数据库集群会创建失败。
其中,`hostpath-storage`是microk8s自带的一个PV**P**ersistent**V**olumes持久化存储插件其为开发者提供了动态供应dynamic provisioning的PV。
简单来讲用户通过helm charts或其他方式每创建一个带有PVC**P**ersistent**V**olume**C**laim的pod动态供应者dynamic provisioner就会为其自动创建一个可用的PV。
> **禁止在生产环境中使用hostpath-storage**
>
> 由hostpath-storage提供的PV并不支持数据在节点间迁移所以只能用于开发/测试环境。
`metallb`则是轻量级的为bare-metal服务器上提供负载均衡的解决方案。
> bare-metal特指比如IoT场景这种局域网中互连的服务器们即区别于“云上”的虚拟服务器**V**irtual **P**rivate **S**erver, VPS
启用metallb扩展时命令行会提示输入IP网段来创建一个地址池当service类型为`LoadBalancer`metallb会从已创建的地址池中挑选一个IP来作为服务的`EXTERNAL-IP`
> 应为metallb提供**与host同一层的**IP地址因其可以直接在局域网内被直接访问而无需额外的DNS服务。
>
> 但**不应重复使用节点的IP**会导致network traffic造成节点间无法通讯集群不能正常工作。
>
> 这也为**部署于K8s集群外的应用**比如EdgeManager提供了访问集群内服务的支撑。
指定网段时可以使用`-`符号连接两个IP地址来表示一个区间。比如也可以使用这种方式在启用扩展时直接创建地址池
```bash
microk8s.enable metallb:192.168.0.198-192.168.0.199
```
运行完毕后,`192.168.0.198``192.168.0.199`均会被加入地址池中。metallb一般会挑选第一个当第一个地址不可用时则顺序替换为下一个地址。
## 启动集群
先拉取代码仓库:
```bash
git pull http://118.195.187.246:10030/ysun/EdgeManager.git
```
运行:
```bash
# 进入位于项目根目录的charts目录内
cd EdgeManager/charts
helm install tsdb .
```
## 移除集群
运行:
```bash
helm uninstall tsdb
```
`helm uninstall`并不会丧心病狂地删除持久化的数据,因此如果想**彻底删除**这些数据可以用以下命令删除PVC
```bash
kubectl delete $(kubectl get pvc -l release=tsdb -o name)
```
稍后对应的PV也会被清除因为我们之前使用的PV插件`hostpath-storage`是dynamic provisioner
> 如果需要重新部署服务,建议使用`kubectl get pv`确定PV已经完全被释放后再进行。
## 验证集群状态
运行:
```bash
kubectl get all -l release=tsdb
```
![](get_all.png)
图中可见,`192.168.0.198`即为metallb提供的可供集群外部应用访问的IP。
验证Access Node上是否成功加入了数据节点
```bash
kubectl exec -ti $(kubectl get pod -l timescaleNodeType=access -o name) -c timescaledb -- psql -d scada -c 'select node_name from timescaledb_information.data_nodes'
```
![](show_nodes.png)
尝试从另外一台物理机访问该多节点TimescaleDB集群
![](psql.png)
至此,集群已经可以正常使用了。
## 部署图
该测试环境的部署结构[^note]为:
![](timescaledb-multi.png)
[^note]: <https://docs.timescale.com/timescaledb/latest/overview/timescale-kubernetes/#multi-node-distributed-timescaledb>

View File

@@ -0,0 +1,127 @@
import { handlePost } from '../tools'
export default ({ service, request, serviceForMock, requestForMock, mock, faker, tools }) => ({
/**
* @description 方法名称
* @param {Object} data 请求携带的信息
*/
VERIFY_SERVER (url) {
return request({
auth: {
username: 'admin',
password: process.env.VUE_APP_HSLSERVER_PASSWORD
},
method: 'post',
url: url + '/Admin/ServerSettingsRequest'
})
},
MODIFY_SERVER (url, data) {
return request({
auth: {
username: 'admin',
password: process.env.VUE_APP_HSLSERVER_PASSWORD
},
data: data,
method: 'post',
url: url + '/Admin/ServerSettingsModify'
})
},
ADD_SERVER: (data) => handlePost(request, data),
UPDATE_SERVER: (data) => handlePost(request, data),
REMOVE_SERVER: (data) => handlePost(request, data),
QUERY_SERVERS () {
return request({ url: '?query=servers' })
},
QUERY_SERVE_MONITORING () {
return request({ url: '?query=working_subclasses' })
},
GET_DEVICE (serveId) {
return request({ url: '?query=device_list&id=' + serveId })
},
GET_DEVICE_STATUS (serveId) {
return request({ url: '?query=device_status&id=' + serveId })
},
ADD_DEVICE: (data) => handlePost(request, data),
DEL_DEVICE: (data) => handlePost(request, data),
GET_HSLSERVER_CONFIGURE (url) {
return request({
auth: {
username: 'admin',
password: process.env.VUE_APP_HSLSERVER_PASSWORD
},
method: 'post',
url: url + '/Admin/JsonSettingsRequest'
})
},
SET_HSLSERVER_CONFIGURE (url, data) {
return request({
auth: {
username: 'admin',
password: process.env.VUE_APP_HSLSERVER_PASSWORD
},
data: data,
method: 'post',
url: url + '/Admin/JsonSettingsModify'
})
},
SET_DEVICE_CONFIGURE: (data) => handlePost(request, data),
GET_DEVICE_CONFIGURE (id) {
return request({ url: '?query=device&id=' + id })
},
GET_SERVE_DEVICE_MONITORING (url) {
return request({
auth: {
username: 'admin',
password: process.env.VUE_APP_HSLSERVER_PASSWORD
},
url: url + '/Edge/DeviceData?data=/'
})
},
GET_ALL_DEVICES () {
return request({ url: '?query=all_devices' })
},
SET_SERVER_EXEC: (data) => handlePost(request, data),
GET_SERVER_DEVICE_STATUS (url) {
return request({
auth: {
username: 'admin',
password: process.env.VUE_APP_HSLSERVER_PASSWORD
},
url: url + '/Edge/DeviceData?data=__status'
})
},
GET_DEVICE_POINT_VALUE (url, deviceName, data, type) {
return request({
validateStatus: (status) => {
return status === 404 ? 200 : status
},
auth: {
username: 'admin',
password: process.env.VUE_APP_HSLSERVER_PASSWORD
},
data: {
address: data
},
method: 'post',
url: url + '/' + deviceName + '/Read' + type + '32'
})
},
SET_DEVICE_POINT_VALUE (url, deviceName, data, type, value) {
return request({
auth: {
username: 'admin',
password: process.env.VUE_APP_HSLSERVER_PASSWORD
},
data: {
address: data,
value: value
},
method: 'post',
url: url + '/' + deviceName + '/Write' + type + '32'
})
}
})

View File

@@ -1,8 +1,4 @@
const handlePost = (request, data) => (request({
url: '',
method: 'post',
data
}))
import { handlePost } from '../tools'
export default ({ service, request, serviceForMock, requestForMock, mock, faker, tools }) => ({
/**
@@ -14,7 +10,7 @@ export default ({ service, request, serviceForMock, requestForMock, mock, faker,
UPDATE_NODE: (data) => handlePost(request, data),
REMOVE_NODE: (data) => handlePost(request, data),
QUERY_NODE () {
QUERY_NODES () {
return request({ url: '?query=nodes' })
},

View File

@@ -84,3 +84,9 @@ export function errorCreate (msg) {
errorLog(error)
throw error
}
export const handlePost = (request, data) => (request({
url: '',
method: 'post',
data
}))

View File

@@ -23,5 +23,12 @@ export const menuAside = supplementPath([
{ path: '/scada_configure', title: 'SCADA节点配置' },
{ path: '/scada_query', title: 'SCADA数据查询' }
]
},
{
title: '采集服务管理',
children: [
{ path: '/edge_server_configure', title: '服务配置' },
{ path: '/edge_server_monitor', title: '服务监控' }
]
}
])

View File

@@ -39,6 +39,24 @@ const frameIn = [
},
component: _import('scada/scadaQuery')
},
{
path: 'edge_server_configure',
name: 'edge_server_configure',
meta: {
title: '服务配置',
auth: true
},
component: _import('edgeServer/edgeServerConfigure')
},
{
path: 'edge_server_monitor',
name: 'edge_server_monitor',
meta: {
title: '服务监控',
auth: true
},
component: _import('edgeServer/edgeServerMonitor')
},
// 系统 前端日志
{
path: 'log',

View File

@@ -0,0 +1,886 @@
<template>
<el-container>
<el-aside width="15%">
<div class="menu-box">
<el-card class="box-card">
<div slot="header" class="menu-header">
<div class="header-title">设备</div>
<el-dropdown size="small" style="vertical-align: middle" trigger="click" @command="handleCommand">
<el-button type="primary" round>
<i class="el-icon-s-tools" style="font-size: 14px"></i><i class="el-icon-arrow-down el-icon--right"
style="font-size: 14px"></i>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="add">新加设备</el-dropdown-item>
<el-dropdown-item command="del">删除设备</el-dropdown-item>
<el-dropdown-item>设备重启</el-dropdown-item>
<el-dropdown-item>
<el-upload
class="upload-demo"
ref="upload"
action=""
:before-upload="importDeviceConfigure">
导入配置
</el-upload>
</el-dropdown-item>
<el-dropdown-item command="export" >导出配置</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
<el-menu :style="{ borderRight: 'none' }" :defaultActive="deviceActiveStatus" @select="getDeviceConfigure">
<el-menu-item class="serve-item" shadow="always" v-for="(item, i) in deviceData" :key="i" :index="i + ''">
<div style="position: relative">
<div style="display: inline-block">{{ item.name }}</div>
<div :class="{
'device-status': true,
'device-status-online': item.status,
'device-status-offline': !item.status,
}"></div>
</div>
</el-menu-item>
</el-menu>
</el-card>
</div>
</el-aside>
<el-main :style="{ padding: '10px' }">
<el-card class="box-card">
<div slot="header" style="position: relative">
<div class="header-title">设备配置</div>
<el-button class="header-button" type="primary" @click="setDeviceConfigureConfirm">应用</el-button>
</div>
<device-configure ref="deviceConfigure" :defaultDeviceName="defaultDeviceName"
:defaultFormData="defaultFormData" />
</el-card>
<el-card class="box-card" style="margin-top: 5px">
<d2-crud ref="d2Crud"
:columns="columns"
:data="devicePointTableData"
:rowHandle="rowHandle"
add-title="新增"
edit-title="绑定"
:add-template="addTemplate"
:add-rules="addRules"
:form-options="formOptions"
@row-add="addDevicePoint"
@row-edit="devicePointBandingNode"
@row-remove="delDevicePoint"
@cpoy='copyDevicePoint'
@dialog-cancel="handleDialogCancel"
@banding="bandingNodeTemplate"
@cell-data-change="handleCellDataChange"
@form-data-change="handleFormDataChange"
>
<div slot="header">
<el-button style="margin-bottom: 5px" @click="addRow">新增</el-button>
<span class="demo-form-inline" style="margin-left:15px;">
筛选<el-input placeholder="请输入筛选内容" v-model="nameSearchValue" style="width:300px" class="input-with-select"></el-input>
</span>
</div>
</d2-crud>
</el-card>
</el-main>
</el-container>
</template>
<script>
import { each, assign, unset, filter, isArray, map, capitalize, split, toUpper, replace } from 'lodash'
const DataTypeCodeOptions = [
{
value: 'string',
label: 'string (字符串)'
},
{
value: 'int',
label: 'int (整数)'
},
{
value: 'float',
label: 'float (浮点数)'
},
{
value: 'bool',
label: 'bool (逻辑值)'
},
{
value: 'uint',
label: 'uint'
},
{
value: 'short',
label: 'short'
},
{
value: 'ushort',
label: 'ushort'
},
{
value: 'long',
label: 'long'
},
{
value: 'ulong',
label: 'ulong'
},
{
value: 'double',
label: 'double'
},
{
value: 'byte',
label: 'byte'
},
{
value: 'sbyte',
label: 'sbyte'
}
]
export default {
props: {
server: {
default: {
id: 0,
url: '',
port: ''
}
}
},
components: {
DeviceConfigure: () => import('./deviceConfigure')
},
data () {
return {
active: '',
columns: [
{
title: '序号',
key: 'id'
},
{
title: '名称',
key: '@Name',
'show-overflow-tooltip': true,
component: {
size: 'small'
}
},
{
title: '绑定',
key: '@Binding'
},
{
title: '地址',
key: '@Address',
component: {
name: 'el-input',
size: 'small'
}
},
{
title: '类型',
key: '@DataTypeCode',
component: {
name: 'el-select',
size: 'small',
options: DataTypeCodeOptions
}
},
{
title: '采集周期',
key: '@RequestInterval',
component: {
name: 'el-input',
size: 'small'
}
},
{
title: '长度',
key: '@Length',
width: '80%',
component: {
name: 'el-input',
size: 'small'
}
},
{
title: '说明',
key: '@Description',
component: {
name: 'el-input',
size: 'small'
}
},
{
title: '当前值',
key: '@Value',
width: '80%',
component: {
name: 'el-input',
size: 'small'
}
}
],
rowHandle: {
minWidth: '150',
custom: [
{
text: '绑定',
size: 'mini',
emit: 'banding',
show (index, row) {
if (row.showBindButton) {
return true
}
return false
}
},
{
text: '复制',
size: 'mini',
emit: 'cpoy',
show (index, row) {
if (row.showCopyButton) {
return true
}
return false
}
}
],
remove: {
size: 'mini',
confirm: true,
show (index, row) {
if (row.showRemoveButton) {
return true
}
return false
}
}
},
formOptions: {
modal: false,
saveLoading: false
},
addTemplate: {
'@Name': {
title: '名称'
},
'@Address': {
title: '地址'
},
'@DisplayName': {
title: '显示名称'
},
'@RequestType': {
title: '请求类型'
},
'@DataTypeCode': {
title: '数据类型',
component: {
name: 'el-select',
options: DataTypeCodeOptions,
span: 12
}
},
'@RequestInterval': {
title: '采集周期(ms)'
},
'@Length': {
title: '长度'
},
'@Description': {
title: '说明'
}
},
addRules: {
'@Name': [
{
required: true,
type: 'string',
message: '请输入名称',
trigger: 'blur'
}
],
'@Address': [
{
required: true,
type: 'string',
message: '请输入地址',
trigger: 'blur'
}
],
'@DisplayName': [
{
required: true,
type: 'string',
message: '请输入显示名称',
trigger: 'blur'
}
],
'@RequestType': [
{
required: true,
type: 'string',
message: '请输入请求类型',
trigger: 'blur'
}
],
'@DataTypeCode': [
{
required: true,
type: 'string',
message: '请选择类型',
trigger: 'blur'
}
],
'@RequestInterval': [
{ required: true, message: '请输入采集周期(ms)', trigger: 'blur' }
]
},
timing: null,
nameSearchValue: '',
serverData: {},
devicePointData: [],
nodeCodeData: [],
workingSubclasses: [],
dialogVisible: false,
deviceActiveStatus: '0',
defaultDeviceName: '',
defaultFormData: {},
deviceData: [],
deviceConfigure: {}
}
},
watch: {
server: {
handler (val) {
this.serverData = val
if (val.id) {
this.getDevice()
this.getDeviceStatus()
}
},
immediate: true
}
},
computed: {
devicePointTableData () {
if (this.nameSearchValue) {
return this.devicePointData.filter((item) => {
if (item['@Name'].search(this.nameSearchValue) > -1) {
return item
}
})
} else {
return this.devicePointData
}
}
},
methods: {
addRow () {
this.$refs.d2Crud.showDialog({
mode: 'add'
})
},
handleFormDataChange ({ key }) {
if (key === 'workingSubclass') {
const { workingSubclass } = this.$refs.d2Crud.formData
this.getCodesByWorkingSubclass(workingSubclass)
this.$refs.d2Crud.$forceUpdate()
}
},
handleCellDataChange ({ rowIndex, row, key, value }) {
let type
if (row['@DataTypeCode'][0] === 'u') {
type = split(row['@DataTypeCode'], '')
type[1] = toUpper(type[0] + type[1])
type = type.join('')
} else {
type = capitalize(row['@DataTypeCode'])
}
if (key === '@Value') {
this.$api.SET_DEVICE_POINT_VALUE(
'http://' + this.serverData.url + ':' + this.serverData.port,
this.deviceData[this.deviceActiveStatus].name,
row['@Address'],
type,
value
).then(res => {
if (!res.IsSuccess) {
this.$message({
message: res.Message,
type: 'error'
})
}
})
}
this.devicePointData[rowIndex] = row
},
bandingNodeTemplate ({ index }) {
this.$refs.d2Crud.showDialog({
mode: 'edit',
rowIndex: index,
template: {
workingSubclass: {
title: '工序编码',
value: '',
component: {
name: 'el-select',
options: this.workingSubclasses,
span: 12
}
},
nodeCode: {
title: '节点名称',
value: '',
component: {
name: 'el-select',
options: this.nodeCodeData,
span: 12
}
}
}
})
},
handleDialogCancel (done) {
this.$message({
message: '用户取消保存',
type: 'warning'
})
done()
},
handleCommand (command) {
switch (command) {
case 'add':
this.addDevice()
break
case 'del':
this.delDevice()
break
case 'export':
this.exportDeviceConfigure()
break
}
},
async getCodesByWorkingSubclass (workingSubclass) {
try {
const nodeCode = await this.$api.QUERY_CODES(workingSubclass)
const nodeCodeData = []
each(nodeCode, (o) => {
nodeCodeData.push({ value: o, label: o })
})
this.nodeCodeData = nodeCodeData
this.$nextTick(() => {
this.$refs.d2Crud.handleFormTemplateMode(
'nodeCode'
).component.options = this.nodeCodeData
this.$refs.d2Crud.$forceUpdate()
})
} catch (e) {
console.log(e)
}
},
async getworkingSubclasses () {
try {
const workingSubclasses = await this.$api.QUERY_WORKING_SUBCLASSES()
const workingSubclassesData = []
each(workingSubclasses, (o) => {
workingSubclassesData.push({ value: o, label: o })
})
this.workingSubclasses = workingSubclassesData
} catch (e) {
console.log(e)
}
},
async getDevice () {
try {
this.deviceData = await this.$api.GET_DEVICE(this.serverData.id)
if (this.deviceData.length > 0) {
this.getDeviceConfigure(this.deviceActiveStatus)
} else {
this.devicePointData = [] // 如果没有设备,防止显示上一次打开的设备数据先清空数据
this.defaultDeviceName = ''
this.nameSearchValue = ''
}
} catch (e) {
console.log(e)
}
},
async getDeviceConfigure (e) {
this.deviceActiveStatus = e
this.devicePointData = [] // 当切换设备时把保存configure、point的数据清空
this.defaultDeviceName = ''
this.nameSearchValue = ''
const deviceData = await this.$api.GET_DEVICE_CONFIGURE(
this.deviceData[e].id
)
if (deviceData[0].conf) {
const conf = JSON.parse(deviceData[0].conf)
if (conf.DeviceTypeName !== undefined) {
this.defaultDeviceName = conf.DeviceTypeName
this.defaultFormData = conf
}
// 获取point信息
const devicePointAddress = []
if (conf.RequestNode !== undefined && conf.RequestNode.length > 0) {
this.devicePointData = each(conf.RequestNode, (o, i) => {
let type
if (o['@DataTypeCode'][0] === 'u') {
type = split(o['@DataTypeCode'], '')
type[1] = toUpper(type[0] + type[1])
type = type.join('')
} else {
type = capitalize(o['@DataTypeCode'])
}
devicePointAddress.push({
key: i,
value: o['@Address'],
type:
o['@DataTypeCode'][0] === 'u'
? replace(o['@DataTypeCode'])
: capitalize(o['@DataTypeCode'])
})
assign(o, {
id: i + 1,
showBindButton: true,
showCopyButton: true,
showRemoveButton: true
})
})
if (devicePointAddress.length > 0) {
this.getDevicePoint(devicePointAddress)
}
}
}
},
addDevice () {
this.$prompt('输入设备名称', '新加设备', {
confirmButtonText: '确定',
inputPattern: /^[\s\S]*.*[^\s][\s\S]*$/,
inputErrorMessage: '请输入设备名称'
}).then(async ({ value }) => {
await this.$api.ADD_DEVICE({
action: 'add_device',
name: value,
server_id: this.serverData.id
})
this.getDevice()
this.$message({
message: '新加设备成功',
type: 'success'
})
})
},
async delDevice () {
const deviceConfigure = await this.$api.GET_HSLSERVER_CONFIGURE(
'http://' + this.serverData.url + ':' + this.serverData.port
)
let deviceNode = deviceConfigure.Content.Settings.GroupNode[0].DeviceNode
if (deviceNode !== undefined && isArray(deviceNode)) {
deviceNode = filter(deviceNode, (item) => {
return item['@Name'] === this.deviceData[this.deviceActiveStatus].name
? null
: item
})
deviceConfigure.Content.Settings.GroupNode[0].DeviceNode = deviceNode
} else {
unset(deviceConfigure, 'Content.Settings.GroupNode[0].DeviceNode')
}
try {
await this.$api.SET_HSLSERVER_CONFIGURE(
'http://' + this.serverData.url + ':' + this.serverData.port,
{ data: deviceConfigure.Content }
)
await this.$api.DEL_DEVICE({
action: 'remove_device',
id: this.deviceData[this.deviceActiveStatus].id
})
this.$message({
message: '删除设备成功',
type: 'success'
})
this.getDevice()
} catch (e) {
console.log(e)
}
},
async addDevicePoint (row, done) {
this.formOptions.saveLoading = true
if (!this.$refs.deviceConfigure.defaultDeviceTypeNameValue) {
this.$message({
message: '请先应用设备配置',
type: 'error'
})
done(false)
} else {
if (row['@DataTypeCode'] === 'string' && !row['@Length']) {
this.$message({
message: '数据类型为string长度不能为空',
type: 'error'
})
this.formOptions.saveLoading = false
return false
}
assign(row, {
id: this.devicePointData.length + 1,
showBindButton: true,
showCopyButton: true,
showRemoveButton: true
})
this.devicePointData.push(row)
done()
}
this.formOptions.saveLoading = false
},
async delDevicePoint ({ index, row }, done) {
this.$delete(this.devicePointData, index)
done()
},
getDevicePoint (devicePointAddress) {
clearInterval(this.timing) // 先关闭上一个定时器
this.timing = setInterval(() => {
each(devicePointAddress, async (item) => {
this.$api
.GET_DEVICE_POINT_VALUE(
'http://' + this.serverData.url + ':' + this.serverData.port,
this.deviceData[this.deviceActiveStatus].name,
item.value,
item.type
)
.then((res) => {
this.$set(
this.devicePointData[item.key],
'@Value',
res.Content
)
})
})
}, 10000)
},
async devicePointBandingNode ({ index, row }, done) {
this.$delete(this.devicePointData[index], '@Binding')
this.$set(this.devicePointData[index], '@Binding', row.nodeCode)
done()
},
async copyDevicePoint ({ row }) {
this.$prompt('复制该点位前,请输入设备点位名称', '复制点位', {
confirmButtonText: '确定',
inputPattern: /^[\s\S]*.*[^\s][\s\S]*$/,
inputErrorMessage: '请输入设备点位名称'
}).then(async ({ value }) => {
row.id = this.devicePointData.length + 1
row['@Name'] = value
this.devicePointData.push(row)
})
},
setDeviceConfigureConfirm () {
this.$confirm('是否要应用该配置信息,更改配置后需要重启改服务才能使配置生效?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.setDeviceConfigure()
}).catch(() => {
this.$message({
type: 'info',
message: '已取消应用设备配置'
})
})
},
async setDeviceConfigure () {
try {
// 验证表单
this.$refs.deviceConfigure.$refs.form.validate((valid) => {
if (!valid) {
return false
}
})
const deviceConfigure = await this.$api.GET_HSLSERVER_CONFIGURE(
'http://' + this.serverData.url + ':' + this.serverData.port
)
const deviceConfigureModelValue = this.$refs.deviceConfigure.deviceConfigureModelValue
if (this.devicePointData.length > 0) {
let devicePointData = this.devicePointData
devicePointData = each(devicePointData, (item) => {
if (item['@DataTypeCode'] !== 'string') {
unset(item, '@Length')
}
unset(item, 'id')
unset(item, 'showBindButton')
unset(item, 'showCopyButton')
unset(item, 'showRemoveButton')
unset(item, '@Value')
item['@Binding'] = item['@Binding'] || ''
})
deviceConfigureModelValue.RequestNode = devicePointData
}
deviceConfigureModelValue['@Name'] = this.deviceData[this.deviceActiveStatus].name
let deviceNode = deviceConfigure.Content.Settings.GroupNode[0].DeviceNode || []
let isExist = false
if (deviceNode !== undefined) {
deviceNode = isArray(deviceNode) ? deviceNode : [deviceNode]
deviceNode = map(deviceNode, (item) => {
if (
item['@Name'] === this.deviceData[this.deviceActiveStatus].name
) {
isExist = true
item = deviceConfigureModelValue
}
return item
})
}
if (!isExist) {
deviceNode.push(deviceConfigureModelValue)
}
deviceConfigure.Content.Settings.GroupNode[0].DeviceNode = deviceNode
await this.$api.SET_HSLSERVER_CONFIGURE(
'http://' + this.serverData.url + ':' + this.serverData.port,
{ data: deviceConfigure.Content }
)
deviceConfigureModelValue.DeviceTypeName =
this.$refs.deviceConfigure.defaultDeviceTypeNameValue
const data = {
action: 'update_device',
conf: JSON.stringify(deviceConfigureModelValue),
id: this.deviceData[this.deviceActiveStatus].id
}
this.$api.SET_DEVICE_CONFIGURE(data)
this.$message({
message: '操作成功',
type: 'success'
})
this.$emit('changeServerConfig', 't')
} catch (e) {
console.log(e)
}
},
async getDeviceStatus () {
const res = await this.$api.GET_SERVER_DEVICE_STATUS(
'http://' + this.serverData.url + ':' + this.serverData.port
)
if (res) {
this.$emit('changeStatus', 'online')
const deviceStatus = {}
each(res.Content.__deviceList, (item) => {
deviceStatus[item.__name] = {
status: item.__requestEnable,
name: item.__name
}
})
each(this.deviceData, (item) => {
if (deviceStatus[item.name]) {
item.status = deviceStatus[item.name].status
}
})
} else {
this.$emit('changeStatus', false)
}
},
exportDeviceConfigure () {
const deviceConfigure = this.$refs.deviceConfigure.deviceConfigureModelValue
const devicePointData = this.devicePointData
deviceConfigure.DeviceNode = devicePointData
deviceConfigure.DeviceTypeName = this.$refs.deviceConfigure.defaultDeviceTypeNameValue
const deviceData = JSON.stringify(deviceConfigure)
const exportLink = document.createElement('a')
const blob = new Blob([deviceData], { type: 'text/json' })
exportLink.href = URL.createObjectURL(blob)
exportLink.download = deviceConfigure['@Name'] + '.json'
exportLink.style.display = 'none'
document.body.appendChild(exportLink)
exportLink.click()
exportLink.remove()
},
importDeviceConfigure (file) {
const reader = new FileReader()
reader.readAsText(file, 'UTF-8')
const that = this
reader.onload = function (e) {
const fileData = e.target.result
const deviceConfigureData = JSON.parse(fileData)
that.devicePointData = deviceConfigureData.DeviceNode
unset(deviceConfigureData, 'DeviceNode')
that.defaultFormData = deviceConfigureData
that.defaultDeviceName = deviceConfigureData.DeviceTypeName
}
return false
},
clearTime () {
clearInterval(this.timing)
this.timing = null
}
},
mounted () {
this.getworkingSubclasses()
}
}
</script>
<style scoped>
.menu-box {
box-sizing: border-box;
padding: 10px;
vertical-align: middle;
}
.el-dropdown {
vertical-align: top;
}
.el-dropdown+.el-dropdown {
margin-left: 15px;
}
.el-icon-arrow-down {
font-size: 12px;
}
.menu-header {
position: relative;
}
.header-title {
width: 60%;
display: inline-block;
font-weight: bold;
font-size: 20px;
color: black;
vertical-align: middle;
}
.header-button {
float: right;
vertical-align: middle;
}
.serve-item {
border: none;
margin-bottom: 10px;
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 10%);
overflow: hidden;
outline: 0;
border-radius: 4px;
}
.device-status {
width: 10px;
height: 10px;
border-radius: 50%;
right: 0;
position: absolute;
top: 50%;
transform: translate(0, -50%);
}
.device-status-online {
background-color: #67c23a;
}
.device-status-offline {
background-color: #ccc;
}
</style>

View File

@@ -0,0 +1,155 @@
<template>
<div>
<el-form :inline="true" size="mini" :model="deviceConfigureModelValue" ref="form" :rules="rules"
label-position="right" class="demo-form-inline">
<el-form-item label="设备">
<el-select @change="deviceChange" filterable v-model="defaultDeviceTypeNameValue" placeholder="请选择">
<el-option v-for="item in deviceTypeData" :key="item" :label="item" :value="item">
</el-option>
</el-select>
</el-form-item>
<!-- v-if="choosable === 'true'" -->
<el-form-item label="设备编码" >
<el-select filterable allow-create v-model="deviceConfigureModelValue['@ParentDeviceCode']" placeholder="请选择">
<el-option v-for="(item, i) in deviceCodeData" :key="i" :label="item.name" :value="item.code">
</el-option>
</el-select>
</el-form-item>
<template v-for="(item, i) in deviceConfigureFormItem">
<el-form-item :key="i" :label="item.labelName" :prop="item.key" v-if="!item.isShow">
<template v-if="item.type === 'text'">
<el-input v-model='deviceConfigureModelValue[item.key]' :disabled="item.disabled"
:placeholder="item.placeholder"></el-input>
</template>
<template v-if="item.type === 'number'">
<el-input-number v-model='deviceConfigureModelValue[item.key]' :disabled="item.disabled" :controls="false"
:placeholder="item.placeholder"></el-input-number>
</template>
<template v-if="item.type === 'select'">
<el-select v-model='deviceConfigureModelValue[item.key]' style="width:80px" :disabled="item.disabled"
:placeholder="item.placeholder">
<el-option v-for="index in item.option" :key="index.value" :label="index.label" :value="index.value">
</el-option>
</el-select>
</template>
<template v-if="item.type === 'time'">
<el-date-picker v-model='deviceConfigureModelValue[item.key]' type="datetime"
:placeholder="item.placeholder" value-format='yyyy-MM-dd HH:mm:ss' format='yyyy-MM-dd HH:mm:ss'
:disabled="item.disabled" style="width:180px">
</el-date-picker>
</template>
</el-form-item>
</template>
</el-form>
</div>
</template>
<script>
import deviceConfigureFormItemData from './deviceSetting/index.json'
import { each, assign } from 'lodash'
// import { getDeviceAll } from '@/api/basic/device'
export default {
name: 'deviceConfigure',
props: {
loading: {
default: false
},
defaultDeviceName: {
default: ''
},
defaultFormData: {
default: () => []
}
},
computed: {
},
watch: {
defaultDeviceName: {
handler (val) {
this.defaultDeviceTypeNameValue = val
this.deviceChange(val)
},
immediate: true
},
defaultFormData: {
handler (val) {
this.setDeviceDefaultFormItemValue(val)
},
immediate: true
}
},
data () {
return {
deviceConfigureFormItem: [],
defaultDeviceTypeNameValue: '',
deviceCode: '',
deviceCodeData: [],
choosable: process.env.VUE_APP_CHOOSABLE,
deviceConfigureModelValue: {},
rules: {},
deviceSelectedVlaue: '',
deviceTypeData: [
]
}
},
methods: {
deviceChange (e) {
// 获取所有字符串的key 用于v-model渲染
const deviceConfigureModelValue = {}
const rules = {}
each(deviceConfigureFormItemData[e], (item) => {
if (item.type === 'time') {
deviceConfigureModelValue[item.key] = item.defaultValue ? item.defaultValue : new Date()
} else {
deviceConfigureModelValue[item.key] = item.defaultValue ? item.defaultValue : ''
}
if (item.rules !== undefined) {
rules[item.key] = item.rules
}
})
this.rules = rules
this.deviceConfigureModelValue = assign(deviceConfigureModelValue, { '@ParentDeviceCode': '' })
// this.deviceConfigureModelValue = this.choosable === 'true' ? assign(deviceConfigureModelValue, { parent_device_code: '' }) : deviceConfigureModelValue
this.deviceConfigureFormItem = deviceConfigureFormItemData[e]
},
setDeviceDefaultFormItemValue (val) {
const deviceConfigureModelValue = {}
// if (this.choosable === 'true') {
// deviceConfigureModelValue["@ParentDeviceCode"] = val["@ParentDeviceCode"]
// }
deviceConfigureModelValue['@ParentDeviceCode'] = val['@ParentDeviceCode'] || ''
each(deviceConfigureFormItemData[this.defaultDeviceTypeNameValue], (item) => {
if (item.type === 'time') {
deviceConfigureModelValue[item.key] = val[item.key] ? val[item.key] : new Date()
} else {
deviceConfigureModelValue[item.key] = val[item.key] ? val[item.key] : item.defaultValue
}
}
)
this.deviceConfigureModelValue = deviceConfigureModelValue
},
getDeviceType () {
this.deviceTypeData = Object.keys(deviceConfigureFormItemData)
},
getDeviceAll () {
// getDeviceAll().then(res => {
// this.deviceCodeData = res.data
// })
}
},
mounted () {
if (this.choosable === 'true') {
this.getDeviceAll()
}
this.getDeviceType()
}
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,395 @@
<template>
<d2-container>
<d2-crud
ref="d2Crud"
:columns="columns"
:data="data"
:rowHandle="rowHandle"
add-title="新增服务"
edit-title="修改服务配置"
:add-template="addTemplate"
:edit-template="editTemplate"
:form-options="formOptions"
:add-rules="addRules"
@dialog-open="handleDialogOpen"
@row-add="handleRowAdd"
@row-edit="handleRowEdit"
@row-remove="handleRowRemove"
@dialog-cancel="handleDialogCancel"
@set-device="drawerShow"
@exec="setServerExec">
<el-button slot="header" style="margin-bottom: 5px" @click="addRow">新增</el-button>
</d2-crud>
<el-drawer
size="95%"
:append-to-body='true'
:visible.sync="isDrawerShow"
@close="closeDrawer"
>
<div slot="title" >
<span class="title-text">前置服务 {{selectedServerData.name}} URL:{{selectedServerData.url}}</span>
<el-tag
:color="serveStatus[selectedServerData.status].backgroundColor"
:style="{color:serveStatus[selectedServerData.status].textColor,borderColor:serveStatus[selectedServerData.status].borderColor}"
>
{{serveStatus[selectedServerData.status].name}}
</el-tag>
<el-alert v-if="this.selectedServerData.updated === 't'" title="设备配置已更改,请在服务配置界面重启对应服务生效" type="warning" style="width:400px;display: inline-block;vertical-align: middle; margin-left: 5px;" :closable="false" />
</div>
<device ref="deviceTemplate" :server='server' @changeStatus='changeSelectedServerStatus' @changeServerConfig='changeSelectedServerConfig' />
</el-drawer>
</d2-container>
</template>
<script>
import { assign, each } from 'lodash'
const genRanHex = size => [...Array(size)].map(() => Math.floor(Math.random() * 16).toString(16).toUpperCase()).join('')
export default {
components: {
Device: () => import('./device')
},
data () {
return {
columns: [
{
title: '序号',
key: 'id'
},
{
title: '名称',
key: 'name'
},
{
title: 'URL',
key: 'url'
},
{
title: '端口',
key: 'port'
},
{
title: '绑定地址',
key: 'address'
},
{
title: '创建时间',
key: 'create_date'
},
{
title: '备注',
key: 'note'
}
],
isDrawerShow: false,
server: {},
serveStatus: {
online: {
name: '在线',
textColor: '#67c23a',
backgroundColor: '#f0f9eb',
borderColor: '#e1f3d8'
},
offline: {
name: '离线',
textColor: '#67c23a',
backgroundColor: '#f0f9eb',
borderColor: '#e1f3d8'
}
},
selectedServerData: {
name: '',
url: '',
port: '',
updated: 'f',
status: 'offline'
},
data: [],
serverSettings: {},
rowHandle: {
minWidth: '200',
edit: {
icon: 'el-icon-edit',
text: '编辑',
size: 'small',
show (index, row) {
if (row.showEditButton) {
return true
}
return false
}
},
remove: {
icon: 'el-icon-delete',
size: 'small',
fixed: 'right',
confirm: true,
show (index, row) {
if (row.showRemoveButton) {
return true
}
return false
}
},
custom: [
{
text: '设备配置',
size: 'small',
emit: 'set-device'
},
{
text: '重启服务',
size: 'small',
emit: 'exec'
}
]
},
addTemplate: {
deviceName: {
title: '服务名称',
value: 'Edge:' + genRanHex(12)
},
uniqueID: {
title: '唯一标识',
value: '',
component: {
disabled: true,
placeholder: '采集服务唯一标识,测试成功后返回'
}
},
url: {
title: 'URL',
value: '',
component: {
span: 12
}
},
port: {
title: '端口',
value: 522,
component: {
span: 12
}
},
address: {
title: '绑定地址',
value: '',
component: {
placeholder: '请填入绑定EdgeManager实例的地址比如http://xxx.xxx:xxxx'
}
}
},
editTemplate: {
address: {
title: '绑定地址',
component: {
placeholder: '仅可以修改已绑定EdgeManager实例的地址'
}
}
},
formOptions: {
labelWidth: '80px',
labelPosition: 'left',
saveLoading: false,
saveButtonText: '测试',
saveButtonType: 'text'
},
addRules: {
deviceName: [{ required: true, type: 'string', message: '服务名称不可为空', trigger: 'blur' }],
url: [{ required: true, type: 'string', message: '服务地址不可为空', trigger: 'blur' }],
port: [{ required: true, type: 'integer', transform: v => +v, message: '端口号必须指定,且需为(0, 65535]内的正整数', trigger: 'blur' }],
userName: [{ required: true, message: '用户名不可为空', trigger: 'blur' }],
password: [{ required: true, type: 'string', message: '密码不可为空', trigger: 'blur' }],
address: [{ type: 'url', message: '绑定地址必须为合法URL', trigger: 'blur' }]
}
}
},
methods: {
async getServers () {
try {
const res = await this.$api.QUERY_SERVERS()
this.data = each(res, (o) => (
assign(o, {
showEditButton: true,
showRemoveButton: true
})
))
} catch (e) {
console.log(e)
}
},
handleDialogOpen ({ mode }) {
if (mode === 'edit') {
this.formOptions.saveButtonText = '确定'
this.formOptions.saveButtonType = 'text'
}
},
// 普通的新增
addRow () {
this.$refs.d2Crud.showDialog({
mode: 'add'
})
},
async handleRowAdd (row, done) {
this.formOptions.saveLoading = true
try {
// 定义测试动作
if (this.formOptions.saveButtonText === '测试') {
this.serverSettings = await this.$api.VERIFY_SERVER(
'http://' + row.url + ':' + row.port
)
if (this.serverSettings) {
this.$message({
message: '测试通过!',
type: 'success'
})
}
each(Object.keys(row), (p) => {
this.addTemplate[p].value = row[p]
})
this.addTemplate.uniqueID.value = this.serverSettings.Content.ServerInfoConfig.UniqueId
this.$refs.d2Crud.showDialog({
mode: 'add'
})
this.formOptions.saveButtonText = '添加'
this.formOptions.saveButtonType = 'success'
// 定义添加动作
} else {
this.serverSettings.Content.ServerInfoConfig.DeviceName = row.deviceName
this.serverSettings.Content.ServerInfoConfig.CaptureURL = row.address
this.$api.MODIFY_SERVER(
'http://' + row.url + ':' + row.port,
{ data: this.serverSettings.Content }
)
await this.$api.ADD_SERVER({
action: 'add_server',
name: row.deviceName,
url: row.url,
port: row.port,
address: row.address
})
this.$message({
message: '添加成功',
type: 'success'
})
this.getServers()
done()
}
} catch (e) {
this.$message({
message: '测试/添加失败!',
type: 'error'
})
console.log(e)
}
this.formOptions.saveLoading = false
},
async handleRowEdit ({ index, row }, done) {
this.formOptions.saveLoading = true
const serverSettings = await this.$api.VERIFY_SERVER(
'http://' + row.url + ':' + row.port
)
serverSettings.Content.ServerInfoConfig.CaptureURL = row.address
this.$api.MODIFY_SERVER(
'http://' + row.url + ':' + row.port,
{ data: serverSettings.Content }
)
this.$api.UPDATE_SERVER({
action: 'update_server',
id: row.id,
address: row.address
})
this.$message({
message: '编辑成功',
type: 'success'
})
done()
this.formOptions.saveLoading = false
},
async handleRowRemove ({ index, row }, done) {
await this.$api.REMOVE_SERVER({
action: 'remove_server',
id: row.id
})
this.$message({
message: '删除成功',
type: 'success'
})
done()
},
setServerExec ({ row }) {
this.$confirm('是否要重启动该服务', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await this.$api.SET_SERVER_EXEC({
action: 'exec',
server_id: row.id,
command: 'server_restart'
})
this.$message({
message: '服务应用请求成功,请求动作已添加至请求队列中,请从服务监控页面查看结果',
type: 'success'
})
this.getServers()
} catch (e) {
console.log(e)
}
}).catch(() => {
this.$message({
type: 'info',
message: '已重启动该服务'
})
})
},
handleDialogCancel (done) {
this.$message({
message: '用户放弃改动',
type: 'warning'
})
done()
},
drawerShow ({ row }) {
this.selectedServerData = {
name: row.name,
url: row.url,
port: row.port,
updated: row.updated,
status: row.status ? row.status : 'offline'
}
this.server = { id: row.id, url: row.url, port: row.port }
this.isDrawerShow = true
},
closeDrawer () {
this.server = { id: 0, url: '', port: '' }
if (this.$refs.deviceTemplate.timing) {
this.$refs.deviceTemplate.clearTime()
}
},
changeSelectedServerStatus (status) {
this.selectedServerData.status = status || 'offline'
},
changeSelectedServerConfig (updated) {
this.getServers()
this.selectedServerData.updated = updated || 'f'
}
},
mounted () {
this.getServers()
}
}
</script>
<style scoped>
.title-text{
font-weight: bold;
font-size: 20px;
color: black;
vertical-align: middle;
margin-right: 15px;
}
</style>

View File

@@ -0,0 +1,252 @@
<template>
<d2-container>
<div>
<el-card
class="box-card"
v-for="(item, index) in serverData"
:key="index"
>
<div slot="header" class="device-header">
<span
>前置服务 {{ item.server_name }} ({{ item.url }}:{{ item.port }} )
<el-tag
v-if="item.status !== undefined"
:color="serveStatus[item.status].backgroundColor"
:style="{color:serveStatus[item.status].textColor,borderColor:serveStatus[item.status].borderColor}"
>
{{ serveStatus[item.status].name }}
</el-tag>
</span>
</div>
<el-row :gutter="12">
<el-col :span="6" v-for="(i, k) in item.devices" :key="k">
<el-popover placement="top" trigger="click">
<el-menu
@select="(index) => setDeviceExec(i, index, item)"
:style="{ borderRight: 'none' }"
>
<el-menu-item index="device_stop">
<span slot="title">暂停</span>
</el-menu-item>
<el-menu-item index="device_continue">
<span slot="title">启动</span>
</el-menu-item>
</el-menu>
<el-card
slot="reference"
:class="{
'box-card': true,
online: i.deviceStatus,
offline: !i.deviceStatus,
}"
>
<div slot="header" class="header-title">
<span>{{ i.device_name }}</span>
</div>
<el-row :gutter="24">
<el-col
:span="5"
style="padding-right: 0px; margin-bottom: 5px"
>
<span>设备类型</span>
</el-col>
<el-col :span="14" style="padding-left: 0px"
>{{ i.device_type }}</el-col
>
</el-row>
<el-row :gutter="24">
<el-col
:span="5"
style="padding-right: 0px; margin-bottom: 5px"
>
<span>IP/端口</span>
</el-col>
<el-col :span="14" style="padding-left: 0px"
>{{ i.config }}
</el-col>
</el-row>
<el-row :gutter="24">
<el-col
:span="5"
style="padding-right: 0px; margin-bottom: 5px"
>
<span>活动时间</span>
</el-col>
<el-col :span="14" style="padding-left: 0px"
>{{ i.startTime }}</el-col
>
</el-row>
<el-row :gutter="24">
<el-col
:span="5"
style="padding-right: 0px; margin-bottom: 5px"
>
<span>采集耗时</span>
</el-col>
<el-col :span="14" style="padding-left: 0px"
>{{ i.captureSpendTime }}</el-col
>
</el-row>
<el-row :gutter="24">
<el-col
:span="5"
style="padding-right: 0px; margin-bottom: 5px"
>
<span>成功次数</span>
</el-col>
<el-col :span="14" style="padding-left: 0px"
>{{ i.success }}</el-col
>
</el-row>
<el-row :gutter="24">
<el-col
:span="5"
style="padding-right: 0px; margin-bottom: 5px"
>
<span>失败次数</span>
</el-col>
<el-col :span="14" style="padding-left: 0px"
>{{ i.failed }}</el-col
>
</el-row>
<el-row :gutter="24">
<el-alert
v-if="i.failedMsg"
:title="i.failedMsg"
type="error"
:closable="false"
>
</el-alert>
<div v-else style="height: 35px"></div>
</el-row>
</el-card>
</el-popover>
</el-col>
</el-row>
</el-card>
</div>
</d2-container>
</template>
<script>
import { unset, map } from 'lodash'
export default {
data () {
return {
serverData: [],
deviceData: [],
deviceCommand: {},
serveStatus: {
online: {
name: '在线',
textColor: '#67c23a',
backgroundColor: '#f0f9eb',
borderColor: '#e1f3d8'
},
offline: {
name: '离线',
textColor: '#67c23a',
backgroundColor: '#f0f9eb',
borderColor: '#e1f3d8'
}
}
}
},
methods: {
async setDeviceExec (device, command, server) {
try {
await this.$api.SET_SERVER_EXEC({
action: 'exec',
server_id: server.id,
command: command,
device_name: device.device_name
})
this.$message({
message: '请求成功,请求动作已添加至请求队列中',
type: 'success'
})
this.getServe()
} catch (e) {
console.log(e)
}
},
async getServe () {
this.serverData = await this.$api.QUERY_SERVERS()
this.serverData.forEach((element, index) => {
this.$api
.GET_SERVE_DEVICE_MONITORING(
'http://' + element.url + ':' + element.port
)
.then((deviceStatus) => {
if (deviceStatus.IsSuccess) {
const data = deviceStatus.Content
unset(data, '__status')
const deviceData = map(data, (element) => {
const config = element.__config.split(' ')
return {
config: config.length > 0 ? config[2] : '',
startTime: element.__startTime,
captureSpendTime: element.__captureSpendTime,
success: element.__success,
failed: element.__failed,
failedMsg: element.__failedMsg,
deviceStatus: element.__requestEnable,
device_name: element.__name,
device_type: config.length > 0 ? config[0] : ''
}
})
this.$set(this.serverData[index], 'devices', deviceData)
this.$set(this.serverData[index], 'status', 'online')
}
})
.catch(() => {
this.$message({
message: this.serverData[index].name + '服务请求失败',
type: 'error'
})
this.$set(this.serverData[index], 'status', 'offline')
})
})
}
},
mounted () {
this.getServe()
setInterval(() => {
this.getServe()
}, 30000)
}
}
</script>
<style scoped>
.box-card {
margin: 5px 0px;
color: #fff;
font-weight: bold;
}
.device-header {
color: #000;
font-weight: bold;
font-size: 18px;
}
.online {
background-color: #67c23a;
}
.offline {
background-color: #666;
}
.text {
font-size: 14px;
}
.item {
margin-bottom: 18px;
}
.header-title {
font-size: 18px;
}
</style>

View File

@@ -42,11 +42,13 @@
import tips from './tips.md'
import jschardet from 'jschardet'
import { assign, each, pick, pickBy, startsWith, includes, every } from 'lodash'
// import { getWorkingsubclassAll } from '@/api/basic/device'
export default {
data () {
return {
tips,
choosable: process.env.VUE_APP_CHOOSABLE,
columns: [
{
title: '序号',
@@ -65,17 +67,13 @@ export default {
key: 'type'
},
{
title: '流程',
key: 'flow_code'
title: '数据类别',
key: 'category'
},
{
title: '工序单元',
key: 'working_subclass'
},
{
title: '工作站',
key: 'workstation'
},
{
title: '创建时间',
key: 'create_date'
@@ -145,14 +143,49 @@ export default {
span: 12
}
},
flow_code: {
title: '流程'
category: {
title: '数据类别',
value: '',
component: {
name: 'el-select',
options: [
{
value: 'DEVICE_STATUS',
label: '设备状态'
},
{
value: 'ITEM_ID_ITEM',
label: '编号'
},
{
value: 'DEVICE_DATA',
label: '运行数据'
},
{
value: 'PROCESS_DATA',
label: '过程数据'
},
{
value: 'RESULT_DATA',
label: '结果数据'
},
{
value: 'STATISTICAL_DATA',
label: '统计数据'
}
],
span: 12
}
},
working_subclass: {
title: '工序单元'
},
workstation: {
title: '工作站'
title: '工序单元',
component: {
name: 'el-select',
filterable: true,
allowCreate: true,
options: this.WorkingsubclassData,
span: 12
}
},
note: {
title: '备注'
@@ -162,12 +195,6 @@ export default {
name: {
title: '节点名称'
},
flow_code: {
title: '流程'
},
workstation: {
title: '工作站'
},
note: {
title: '备注'
}
@@ -181,6 +208,7 @@ export default {
code: [{ required: true, type: 'string', message: '节点编码必须填写', trigger: 'blur' }],
name: [{ required: true, type: 'string', message: '节点名称必须填写', trigger: 'blur' }],
type: [{ required: true, message: '必须指定节点类型', trigger: 'blur' }],
category: [{ required: true, message: '必须指定数据类别', trigger: 'blur' }],
working_subclass: [{
required: true,
type: 'string',
@@ -193,7 +221,7 @@ export default {
methods: {
async getNodes () {
try {
const res = await this.$api.QUERY_NODE()
const res = await this.$api.QUERY_NODES()
this.data = each(res, (o) => (
assign(o, {
showEditButton: true,
@@ -206,6 +234,10 @@ export default {
},
// 普通的新增
addRow () {
this.$nextTick(() => {
this.$refs.d2Crud.handleFormTemplateMode('working_subclass').component.options = this.WorkingsubclassData
this.$refs.d2Crud.$forceUpdate()
})
this.$refs.d2Crud.showDialog({
mode: 'add'
})
@@ -294,9 +326,19 @@ export default {
type: 'warning'
})
done()
},
getWorkingsubclassAll () {
// getWorkingsubclassAll().then(res=>{
// this.WorkingsubclassData = map(res.data, (o) => {
// return { value: o.code, label: o.name }
// })
// })
}
},
mounted () {
if (this.choosable === 'true') {
this.getWorkingsubclassAll()
}
this.getNodes()
}
}

View File

@@ -94,6 +94,7 @@
<c-grid
:filter="tableFilter"
:key='key'
:loading="loading"
:data="data">
<c-grid-column
field="id"
@@ -209,6 +210,7 @@ export default {
time: [now - 3600 * 1000 * 24 * 7, +now],
code: ''
},
loading: false,
workingSubclasses: [],
codes: [],
data: [],
@@ -334,6 +336,7 @@ export default {
}
},
async getData () {
this.loading = true
this.excelData = []
if (!this.formInline.workingSubclass) {
this.$message.error('请选择工序单元')
@@ -348,6 +351,7 @@ export default {
} catch (e) {
console.log(e)
}
this.loading = false
},
async exportExcel () {
const data = this.excelData.length === 0 ? this.data : this.excelData
@@ -390,6 +394,7 @@ export default {
this.$message.error('请先选择比较方法!')
return false
}
this.excelData = []
this.key = !this.key // 取反重新组件渲染
},