From 1b4cffd132fcb8cb12ac2605df21df35517d359e Mon Sep 17 00:00:00 2001 From: kbalinthunor Date: Tue, 2 Jun 2026 11:13:56 +0300 Subject: [PATCH 01/24] feat(influxdb): add InfluxDB south connector supporting v1, v2 and v3 --- backend/eslint.config.mjs | 4 +- backend/package-lock.json | 315 ++++++ backend/package.json | 3 + backend/shared/model/history-query.model.ts | 26 + backend/shared/model/south-connector.model.ts | 24 + backend/shared/model/south-settings.model.ts | 27 + backend/src/service/south-manifests.ts | 4 +- backend/src/settings-interface.generator.ts | 2 + backend/src/south/south-connector-factory.ts | 11 + backend/src/south/south-influxdb/manifest.ts | 380 ++++++++ .../south-influxdb/south-influxdb.spec.ts | 622 ++++++++++++ .../south/south-influxdb/south-influxdb.ts | 280 ++++++ backend/src/web-server/routes.ts | 248 ++++- documentation/docs/api/openapi.json | 899 +++++++++++++++++- .../docs/guide/south-connectors/influxdb.mdx | 168 ++++ frontend/src/i18n/en.json | 44 + 16 files changed, 3015 insertions(+), 42 deletions(-) create mode 100644 backend/src/south/south-influxdb/manifest.ts create mode 100644 backend/src/south/south-influxdb/south-influxdb.spec.ts create mode 100644 backend/src/south/south-influxdb/south-influxdb.ts create mode 100644 documentation/docs/guide/south-connectors/influxdb.mdx diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs index e007de9c68..462e4f4785 100644 --- a/backend/eslint.config.mjs +++ b/backend/eslint.config.mjs @@ -90,10 +90,12 @@ export default [ { // Test files and shared mocks use async functions extensively to satisfy // Promise-returning interfaces, without ever needing an await expression. + // Generator mocks that throw before yielding are also legitimate in tests. // Flagging these as errors would produce hundreds of false positives. files: ['**/*.spec.ts', 'src/tests/**/*.ts'], rules: { - 'require-await': 'off' + 'require-await': 'off', + 'require-yield': 'off' } }, { diff --git a/backend/package-lock.json b/backend/package-lock.json index 36be7d14ef..3347395745 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,8 @@ "@aws-sdk/client-s3": "3.1075.0", "@azure/storage-blob": "12.33.0", "@azure/storage-file-datalake": "12.31.0", + "@influxdata/influxdb-client": "1.35.0", + "@influxdata/influxdb3-client": "2.2.0", "@smithy/node-http-handler": "4.8.2", "ads-client": "2.2.0", "argon2": "0.44.0", @@ -27,6 +29,7 @@ "helmet": "8.2.0", "http-proxy": "1.18.1", "https-proxy-agent": "7.0.6", + "influx": "5.12.0", "isolated-vm": "7.0.0", "joi": "18.2.3", "jsmodbus": "4.0.10", @@ -1588,6 +1591,78 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/@hapi/accept": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@hapi/accept/-/accept-6.0.3.tgz", @@ -2003,6 +2078,29 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@influxdata/influxdb-client": { + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.35.0.tgz", + "integrity": "sha512-woWMi8PDpPQpvTsRaUw4Ig+nOGS/CWwAwS66Fa1Vr/EkW+NEwxI8YfPBsdBMn33jK2Y86/qMiiuX/ROHIkJLTw==", + "license": "MIT" + }, + "node_modules/@influxdata/influxdb3-client": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb3-client/-/influxdb3-client-2.2.0.tgz", + "integrity": "sha512-kI2pDsIGsJ7ziIOM4gY/xBgWuCaDfPusiQbRRRBv9v20bVjp0RflV280K0JyjGpy/gctnVFSZ4EygLzySPllvg==", + "license": "MIT", + "dependencies": { + "@grpc/grpc-js": "^1.9.9", + "@protobuf-ts/grpc-transport": "^2.9.1", + "@protobuf-ts/grpcweb-transport": "^2.9.1", + "@protobuf-ts/runtime-rpc": "^2.9.1", + "apache-arrow": "^21.1.0", + "grpc-web": "^2.0.2" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2157,6 +2255,16 @@ "integrity": "sha512-Iev3Cfa3MC+06JXXeFzSLew/7yeCXLMhjesyH/BD4EQYlUUDV49Ln3s8hd7p5XK5UesO/dO3K1LFJ7RT7v0Ddg==", "license": "BSD-3-Clause" }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@jsep-plugin/assignment": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", @@ -2443,6 +2551,107 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@protobuf-ts/grpc-transport": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/grpc-transport/-/grpc-transport-2.11.1.tgz", + "integrity": "sha512-l6wrcFffY+tuNnuyrNCkRM8hDIsAZVLA8Mn7PKdVyYxITosYh60qW663p9kL6TWXYuDCL3oxH8ih3vLKTDyhtg==", + "license": "Apache-2.0", + "dependencies": { + "@protobuf-ts/runtime": "^2.11.1", + "@protobuf-ts/runtime-rpc": "^2.11.1" + }, + "peerDependencies": { + "@grpc/grpc-js": "^1.6.0" + } + }, + "node_modules/@protobuf-ts/grpcweb-transport": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/grpcweb-transport/-/grpcweb-transport-2.11.1.tgz", + "integrity": "sha512-1W4utDdvOB+RHMFQ0soL4JdnxjXV+ddeGIUg08DvZrA8Ms6k5NN6GBFU2oHZdTOcJVpPrDJ02RJlqtaoCMNBtw==", + "license": "Apache-2.0", + "dependencies": { + "@protobuf-ts/runtime": "^2.11.1", + "@protobuf-ts/runtime-rpc": "^2.11.1" + } + }, + "node_modules/@protobuf-ts/runtime": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/runtime/-/runtime-2.11.1.tgz", + "integrity": "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@protobuf-ts/runtime-rpc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/runtime-rpc/-/runtime-rpc-2.11.1.tgz", + "integrity": "sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ==", + "license": "Apache-2.0", + "dependencies": { + "@protobuf-ts/runtime": "^2.11.1" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@roberts_lando/vfs": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@roberts_lando/vfs/-/vfs-0.3.3.tgz", @@ -2593,6 +2802,15 @@ } } }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tediousjs/connection-string": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-1.1.0.tgz", @@ -3101,6 +3319,18 @@ "@types/node": "*" } }, + "node_modules/@types/command-line-args": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", + "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", + "license": "MIT" + }, + "node_modules/@types/command-line-usage": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz", + "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", + "license": "MIT" + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -3959,6 +4189,41 @@ ], "license": "MIT" }, + "node_modules/apache-arrow": { + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz", + "integrity": "sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/command-line-args": "^5.2.3", + "@types/command-line-usage": "^5.0.4", + "@types/node": "^24.0.3", + "command-line-args": "^6.0.1", + "command-line-usage": "^7.0.1", + "flatbuffers": "^25.1.24", + "json-bignum": "^0.0.3", + "tslib": "^2.6.2" + }, + "bin": { + "arrow2csv": "bin/arrow2csv.js" + } + }, + "node_modules/apache-arrow/node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/apache-arrow/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -5739,6 +6004,12 @@ "node": ">=16" } }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, "node_modules/flatted": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", @@ -6030,6 +6301,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/grpc-web": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/grpc-web/-/grpc-web-2.0.2.tgz", + "integrity": "sha512-bvBP0/d5jyVM3eGxxffJhRLAPpH6eXhJeUzBT+bSIEgUKkG4a/BotEimVeW3phP0WLnsJnkRl8uQdRf2yDLaVA==", + "license": "Apache-2.0" + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -6255,6 +6532,12 @@ "node": ">=0.8.19" } }, + "node_modules/influx": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/influx/-/influx-5.12.0.tgz", + "integrity": "sha512-qS3qh7Rt/3/iFUBcDC2wdfxAxgfQKFt9FXKA/PTb7dQD5FIDod9v8LlOxK43Mj71y7umRb9NjkYWw1sk8wfTQQ==", + "license": "MIT" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -6604,6 +6887,14 @@ "ms": "^2.1.1" } }, + "node_modules/json-bignum": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz", + "integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -9277,6 +9568,30 @@ "node": ">=0.4.0" } }, + "node_modules/protobufjs": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.2.tgz", + "integrity": "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/backend/package.json b/backend/package.json index 975024ebcd..0614d1c95f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -48,6 +48,8 @@ ], "dependencies": { "@aws-sdk/client-s3": "3.1075.0", + "@influxdata/influxdb-client": "1.35.0", + "@influxdata/influxdb3-client": "2.2.0", "@azure/storage-blob": "12.33.0", "@azure/storage-file-datalake": "12.31.0", "@smithy/node-http-handler": "4.8.2", @@ -65,6 +67,7 @@ "helmet": "8.2.0", "http-proxy": "1.18.1", "https-proxy-agent": "7.0.6", + "influx": "5.12.0", "isolated-vm": "7.0.0", "joi": "18.2.3", "jsmodbus": "4.0.10", diff --git a/backend/shared/model/history-query.model.ts b/backend/shared/model/history-query.model.ts index afde9c0c88..69b4d97882 100644 --- a/backend/shared/model/history-query.model.ts +++ b/backend/shared/model/history-query.model.ts @@ -9,6 +9,8 @@ import { SouthFolderScannerSettings, SouthFTPItemSettings, SouthFTPSettings, + SouthInfluxDBItemSettings, + SouthInfluxDBSettings, SouthModbusItemSettings, SouthModbusSettings, SouthMQTTItemSettings, @@ -321,6 +323,14 @@ export interface HistoryQueryFolderScannerSouthDTO extends HistoryQuerySouthType export interface HistoryQueryFTPSouthDTO extends HistoryQuerySouthTypedDTO<'ftp', SouthFTPSettings, SouthFTPItemSettings> { items: Array; } +/** History query south DTO for InfluxDB time series database. */ +export interface HistoryQueryInfluxDBSouthDTO extends HistoryQuerySouthTypedDTO< + 'influxdb', + SouthInfluxDBSettings, + SouthInfluxDBItemSettings +> { + items: Array; +} /** History query south DTO for Modbus. */ export interface HistoryQueryModbusSouthDTO extends HistoryQuerySouthTypedDTO<'modbus', SouthModbusSettings, SouthModbusItemSettings> { items: Array; @@ -418,6 +428,7 @@ export type HistoryQueryDTO = BaseEntity & | HistoryQueryADSSouthDTO | HistoryQueryFolderScannerSouthDTO | HistoryQueryFTPSouthDTO + | HistoryQueryInfluxDBSouthDTO | HistoryQueryModbusSouthDTO | HistoryQueryMQTTSouthDTO | HistoryQueryMSSQLSouthDTO @@ -516,6 +527,14 @@ export interface HistoryQueryFolderScannerSouthCommandDTO extends HistoryQuerySo export interface HistoryQueryFTPSouthCommandDTO extends HistoryQuerySouthCommandTypedDTO<'ftp', SouthFTPSettings, SouthFTPItemSettings> { items: Array; } +/** History query south command for InfluxDB time series database. */ +export interface HistoryQueryInfluxDBSouthCommandDTO extends HistoryQuerySouthCommandTypedDTO< + 'influxdb', + SouthInfluxDBSettings, + SouthInfluxDBItemSettings +> { + items: Array; +} /** History query south command for Modbus. */ export interface HistoryQueryModbusSouthCommandDTO extends HistoryQuerySouthCommandTypedDTO< 'modbus', @@ -664,6 +683,7 @@ export type HistoryQueryCommandDTO = HistoryQueryCommandCommonDTO & | HistoryQueryADSSouthCommandDTO | HistoryQueryFolderScannerSouthCommandDTO | HistoryQueryFTPSouthCommandDTO + | HistoryQueryInfluxDBSouthCommandDTO | HistoryQueryModbusSouthCommandDTO | HistoryQueryMQTTSouthCommandDTO | HistoryQueryMSSQLSouthCommandDTO @@ -700,6 +720,8 @@ export interface HistoryQueryADSItemDTO extends HistoryQueryItemTypedDTO {} /** History query item DTO for FTP file transfer. */ export interface HistoryQueryFTPItemDTO extends HistoryQueryItemTypedDTO {} +/** History query item DTO for InfluxDB time series database. */ +export interface HistoryQueryInfluxDBItemDTO extends HistoryQueryItemTypedDTO {} /** History query item DTO for Modbus. */ export interface HistoryQueryModbusItemDTO extends HistoryQueryItemTypedDTO {} /** History query item DTO for MQTT. */ @@ -739,6 +761,7 @@ export type HistoryQueryItemDTO = | HistoryQueryADSItemDTO | HistoryQueryFolderScannerItemDTO | HistoryQueryFTPItemDTO + | HistoryQueryInfluxDBItemDTO | HistoryQueryModbusItemDTO | HistoryQueryMQTTItemDTO | HistoryQueryMSSQLItemDTO @@ -762,6 +785,8 @@ export interface HistoryQueryADSItemCommandDTO extends HistoryQueryItemCommandTy export interface HistoryQueryFolderScannerItemCommandDTO extends HistoryQueryItemCommandTypedDTO {} /** History query item command for FTP file transfer. */ export interface HistoryQueryFTPItemCommandDTO extends HistoryQueryItemCommandTypedDTO {} +/** History query item command for InfluxDB time series database. */ +export interface HistoryQueryInfluxDBItemCommandDTO extends HistoryQueryItemCommandTypedDTO {} /** History query item command for Modbus. */ export interface HistoryQueryModbusItemCommandDTO extends HistoryQueryItemCommandTypedDTO {} /** History query item command for MQTT. */ @@ -801,6 +826,7 @@ export type HistoryQueryItemCommandDTO = | HistoryQueryADSItemCommandDTO | HistoryQueryFolderScannerItemCommandDTO | HistoryQueryFTPItemCommandDTO + | HistoryQueryInfluxDBItemCommandDTO | HistoryQueryModbusItemCommandDTO | HistoryQueryMQTTItemCommandDTO | HistoryQueryMSSQLItemCommandDTO diff --git a/backend/shared/model/south-connector.model.ts b/backend/shared/model/south-connector.model.ts index c2ab7e98e2..4efb12de91 100644 --- a/backend/shared/model/south-connector.model.ts +++ b/backend/shared/model/south-connector.model.ts @@ -7,6 +7,8 @@ import { SouthFolderScannerSettings, SouthFTPItemSettings, SouthFTPSettings, + SouthInfluxDBItemSettings, + SouthInfluxDBSettings, SouthModbusItemSettings, SouthModbusSettings, SouthMQTTItemSettings, @@ -67,6 +69,7 @@ export const OIBUS_SOUTH_TYPES = [ 'ads', // Beckhoff ADS protocol 'folder-scanner', // File system folder scanning 'ftp', // FTP file transfer protocol + 'influxdb', // InfluxDB time series database 'modbus', // Modbus industrial protocol 'mqtt', // MQTT messaging protocol 'mssql', // Microsoft SQL Server database @@ -430,6 +433,10 @@ export interface SouthConnectorFolderScannerDTO extends SouthConnectorTypedDTO< export interface SouthConnectorFTPDTO extends SouthConnectorTypedDTO<'ftp', SouthFTPSettings, SouthFTPItemSettings> { items: Array; } +/** South connector configuration for InfluxDB time series database. */ +export interface SouthConnectorInfluxDBDTO extends SouthConnectorTypedDTO<'influxdb', SouthInfluxDBSettings, SouthInfluxDBItemSettings> { + items: Array; +} /** South connector configuration for Modbus. */ export interface SouthConnectorModbusDTO extends SouthConnectorTypedDTO<'modbus', SouthModbusSettings, SouthModbusItemSettings> { items: Array; @@ -507,6 +514,7 @@ export type SouthConnectorDTO = | SouthConnectorADSDTO | SouthConnectorFolderScannerDTO | SouthConnectorFTPDTO + | SouthConnectorInfluxDBDTO | SouthConnectorModbusDTO | SouthConnectorMQTTDTO | SouthConnectorMSSQLDTO @@ -681,6 +689,14 @@ export interface SouthConnectorFolderScannerCommandDTO extends SouthConnectorCom export interface SouthConnectorFTPCommandDTO extends SouthConnectorCommandTypedDTO<'ftp', SouthFTPSettings, SouthFTPItemSettings> { items: Array; } +/** South connector command for InfluxDB time series database. */ +export interface SouthConnectorInfluxDBCommandDTO extends SouthConnectorCommandTypedDTO< + 'influxdb', + SouthInfluxDBSettings, + SouthInfluxDBItemSettings +> { + items: Array; +} /** South connector command for Modbus. */ export interface SouthConnectorModbusCommandDTO extends SouthConnectorCommandTypedDTO< 'modbus', @@ -774,6 +790,7 @@ export type SouthConnectorCommandDTO = | SouthConnectorADSCommandDTO | SouthConnectorFolderScannerCommandDTO | SouthConnectorFTPCommandDTO + | SouthConnectorInfluxDBCommandDTO | SouthConnectorModbusCommandDTO | SouthConnectorMQTTCommandDTO | SouthConnectorMSSQLCommandDTO @@ -797,6 +814,8 @@ export interface SouthConnectorADSItemDTO extends SouthConnectorItemTypedDTO {} /** South connector item DTO for FTP file transfer. */ export interface SouthConnectorFTPItemDTO extends SouthConnectorItemTypedDTO {} +/** South connector item DTO for InfluxDB time series database. */ +export interface SouthConnectorInfluxDBItemDTO extends SouthConnectorItemTypedDTO {} /** South connector item DTO for Modbus. */ export interface SouthConnectorModbusItemDTO extends SouthConnectorItemTypedDTO {} /** South connector item DTO for MQTT. */ @@ -836,6 +855,7 @@ export type SouthConnectorItemDTO = | SouthConnectorADSItemDTO | SouthConnectorFolderScannerItemDTO | SouthConnectorFTPItemDTO + | SouthConnectorInfluxDBItemDTO | SouthConnectorModbusItemDTO | SouthConnectorMQTTItemDTO | SouthConnectorMSSQLItemDTO @@ -859,6 +879,8 @@ export interface SouthConnectorADSItemCommandDTO extends SouthConnectorItemComma export interface SouthConnectorFolderScannerItemCommandDTO extends SouthConnectorItemCommandTypedDTO {} /** South connector item command for FTP file transfer. */ export interface SouthConnectorFTPItemCommandDTO extends SouthConnectorItemCommandTypedDTO {} +/** South connector item command for InfluxDB time series database. */ +export interface SouthConnectorInfluxDBItemCommandDTO extends SouthConnectorItemCommandTypedDTO {} /** South connector item command for Modbus. */ export interface SouthConnectorModbusItemCommandDTO extends SouthConnectorItemCommandTypedDTO {} /** South connector item command for MQTT. */ @@ -898,6 +920,7 @@ export type SouthConnectorItemCommandDTO = | SouthConnectorADSItemCommandDTO | SouthConnectorFolderScannerItemCommandDTO | SouthConnectorFTPItemCommandDTO + | SouthConnectorInfluxDBItemCommandDTO | SouthConnectorModbusItemCommandDTO | SouthConnectorMQTTItemCommandDTO | SouthConnectorMSSQLItemCommandDTO @@ -1104,6 +1127,7 @@ export interface SouthConnectorItemSearchParam { export const SOUTH_SINGLE_ITEMS: Array = [ 'folder-scanner', 'ftp', + 'influxdb', 'mssql', 'mysql', 'odbc', diff --git a/backend/shared/model/south-settings.model.ts b/backend/shared/model/south-settings.model.ts index ea45cc450f..5419d6753f 100644 --- a/backend/shared/model/south-settings.model.ts +++ b/backend/shared/model/south-settings.model.ts @@ -13,6 +13,12 @@ export type SouthADSSettingsBoolAsText = (typeof SOUTH_A_D_S_SETTINGS_BOOL_AS_TE export const SOUTH_F_T_P_SETTINGS_AUTHENTICATIONS = ['none', 'password'] as const; export type SouthFTPSettingsAuthentication = (typeof SOUTH_F_T_P_SETTINGS_AUTHENTICATIONS)[number]; +export const SOUTH_INFLUX_D_B_SETTINGS_VERSIONS = ['1', '2', '3'] as const; +export type SouthInfluxDBSettingsVersion = (typeof SOUTH_INFLUX_D_B_SETTINGS_VERSIONS)[number]; + +export const SOUTH_INFLUX_D_B_SETTINGS_PROTOCOLS = ['http', 'https'] as const; +export type SouthInfluxDBSettingsProtocol = (typeof SOUTH_INFLUX_D_B_SETTINGS_PROTOCOLS)[number]; + export const SOUTH_MODBUS_SETTINGS_ADDRESS_OFFSETS = ['modbus', 'jbus'] as const; export type SouthModbusSettingsAddressOffset = (typeof SOUTH_MODBUS_SETTINGS_ADDRESS_OFFSETS)[number]; @@ -415,6 +421,20 @@ export interface SouthFTPSettings { compression: boolean; } +export interface SouthInfluxDBSettings { + version: SouthInfluxDBSettingsVersion; + host?: string; + port?: number; + protocol?: SouthInfluxDBSettingsProtocol; + database?: string; + username?: string | null; + password?: string | null; + url?: string; + token?: string | null; + organisation?: string; + bucket?: string; +} + export interface SouthModbusSettings { host: string; port: number; @@ -564,6 +584,7 @@ export type SouthSettings = | SouthADSSettings | SouthFolderScannerSettings | SouthFTPSettings + | SouthInfluxDBSettings | SouthModbusSettings | SouthMQTTSettings | SouthMSSQLSettings @@ -804,6 +825,11 @@ export interface SouthFTPItemSettings { recursive: boolean; } +export interface SouthInfluxDBItemSettings { + query: string; + requestTimeout: number; +} + export interface SouthModbusItemSettings { address: string; modbusType: SouthModbusItemSettingsModbusType; @@ -908,6 +934,7 @@ export type SouthItemSettings = | SouthADSItemSettings | SouthFolderScannerItemSettings | SouthFTPItemSettings + | SouthInfluxDBItemSettings | SouthModbusItemSettings | SouthMQTTItemSettings | SouthMSSQLItemSettings diff --git a/backend/src/service/south-manifests.ts b/backend/src/service/south-manifests.ts index 3160594759..500dae384a 100644 --- a/backend/src/service/south-manifests.ts +++ b/backend/src/service/south-manifests.ts @@ -16,6 +16,7 @@ import oledbManifest from '../south/south-oledb/manifest'; import piManifest from '../south/south-pi/manifest'; import sftpManifest from '../south/south-sftp/manifest'; import ftpManifest from '../south/south-ftp/manifest'; +import influxdbManifest from '../south/south-influxdb/manifest'; import restManifest from '../south/south-rest/manifest'; export const southManifestList: Array = [ @@ -36,5 +37,6 @@ export const southManifestList: Array = [ piManifest, restManifest, sftpManifest, - ftpManifest + ftpManifest, + influxdbManifest ]; diff --git a/backend/src/settings-interface.generator.ts b/backend/src/settings-interface.generator.ts index 76db87f112..136837a922 100644 --- a/backend/src/settings-interface.generator.ts +++ b/backend/src/settings-interface.generator.ts @@ -370,6 +370,8 @@ function buildSouthInterfaceName(connectorId: string, itemInterface: boolean): s return `SouthFolderScanner${prefix}Settings`; case 'ftp': return `SouthFTP${prefix}Settings`; + case 'influxdb': + return `SouthInfluxDB${prefix}Settings`; case 'modbus': return `SouthModbus${prefix}Settings`; case 'mqtt': diff --git a/backend/src/south/south-connector-factory.ts b/backend/src/south/south-connector-factory.ts index e7d6fe85fd..86b6ff3f27 100644 --- a/backend/src/south/south-connector-factory.ts +++ b/backend/src/south/south-connector-factory.ts @@ -10,6 +10,8 @@ import { SouthFolderScannerSettings, SouthFTPItemSettings, SouthFTPSettings, + SouthInfluxDBItemSettings, + SouthInfluxDBSettings, SouthItemSettings, SouthModbusItemSettings, SouthModbusSettings, @@ -60,6 +62,7 @@ import SouthPostgreSQL from '../south/south-postgresql/south-postgresql'; import SouthRest from '../south/south-rest/south-rest'; import SouthSFTP from '../south/south-sftp/south-sftp'; import SouthFTP from '../south/south-ftp/south-ftp'; +import SouthInfluxDB from '../south/south-influxdb/south-influxdb'; import SouthSQLite from '../south/south-sqlite/south-sqlite'; import SouthConnector from './south-connector'; import { Instant } from '../model/types'; @@ -222,6 +225,14 @@ export const buildSouth = ( logger, southCacheFolder ); + case 'influxdb': + return new SouthInfluxDB( + settings as SouthConnectorEntity, + addContent, + southCacheRepository, + logger, + southCacheFolder + ); case 'sqlite': return new SouthSQLite( settings as SouthConnectorEntity, diff --git a/backend/src/south/south-influxdb/manifest.ts b/backend/src/south/south-influxdb/manifest.ts new file mode 100644 index 0000000000..38ca91bbed --- /dev/null +++ b/backend/src/south/south-influxdb/manifest.ts @@ -0,0 +1,380 @@ +import { SouthConnectorManifest } from '../../../shared/model/south-connector.model'; + +const manifest: SouthConnectorManifest = { + id: 'influxdb', + category: 'database', + modes: { + subscription: false, + lastPoint: false, + lastFile: false, + history: true + }, + settings: { + type: 'object', + key: 'settings', + translationKey: 'configuration.oibus.manifest.south.settings', + displayProperties: { + visible: true, + wrapInBox: false + }, + enablingConditions: [ + { + referralPathFromRoot: 'version', + targetPathFromRoot: 'host', + values: ['1'] + }, + { + referralPathFromRoot: 'version', + targetPathFromRoot: 'port', + values: ['1'] + }, + { + referralPathFromRoot: 'version', + targetPathFromRoot: 'protocol', + values: ['1'] + }, + { + referralPathFromRoot: 'version', + targetPathFromRoot: 'database', + values: ['1', '3'] + }, + { + referralPathFromRoot: 'version', + targetPathFromRoot: 'username', + values: ['1'] + }, + { + referralPathFromRoot: 'version', + targetPathFromRoot: 'password', + values: ['1'] + }, + { + referralPathFromRoot: 'version', + targetPathFromRoot: 'url', + values: ['2', '3'] + }, + { + referralPathFromRoot: 'version', + targetPathFromRoot: 'token', + values: ['2', '3'] + }, + { + referralPathFromRoot: 'version', + targetPathFromRoot: 'organisation', + values: ['2'] + }, + { + referralPathFromRoot: 'version', + targetPathFromRoot: 'bucket', + values: ['2'] + } + ], + validators: [], + attributes: [ + { + type: 'string-select', + key: 'version', + translationKey: 'configuration.oibus.manifest.south.influxdb.version', + defaultValue: '1', + selectableValues: ['1', '2', '3'], + validators: [ + { + type: 'REQUIRED', + arguments: [] + } + ], + displayProperties: { + row: 0, + columns: 4, + displayInViewMode: true + } + }, + { + type: 'string', + key: 'host', + translationKey: 'configuration.oibus.manifest.south.influxdb.host', + defaultValue: 'localhost', + validators: [ + { + type: 'REQUIRED', + arguments: [] + } + ], + displayProperties: { + row: 1, + columns: 5, + displayInViewMode: true + } + }, + { + type: 'number', + key: 'port', + translationKey: 'configuration.oibus.manifest.south.influxdb.port', + defaultValue: 8086, + unit: null, + validators: [ + { + type: 'REQUIRED', + arguments: [] + }, + { + type: 'MINIMUM', + arguments: ['1'] + }, + { + type: 'MAXIMUM', + arguments: ['65535'] + } + ], + displayProperties: { + row: 1, + columns: 4, + displayInViewMode: true + } + }, + { + type: 'string-select', + key: 'protocol', + translationKey: 'configuration.oibus.manifest.south.influxdb.protocol', + defaultValue: 'http', + selectableValues: ['http', 'https'], + validators: [ + { + type: 'REQUIRED', + arguments: [] + } + ], + displayProperties: { + row: 1, + columns: 3, + displayInViewMode: true + } + }, + { + type: 'string', + key: 'database', + translationKey: 'configuration.oibus.manifest.south.influxdb.database', + defaultValue: null, + validators: [ + { + type: 'REQUIRED', + arguments: [] + } + ], + displayProperties: { + row: 2, + columns: 6, + displayInViewMode: true + } + }, + { + type: 'string', + key: 'username', + translationKey: 'configuration.oibus.manifest.south.influxdb.username', + defaultValue: null, + validators: [], + displayProperties: { + row: 3, + columns: 6, + displayInViewMode: true + } + }, + { + type: 'secret', + key: 'password', + translationKey: 'configuration.oibus.manifest.south.influxdb.password', + validators: [], + displayProperties: { + row: 3, + columns: 6, + displayInViewMode: false + } + }, + { + type: 'string', + key: 'url', + translationKey: 'configuration.oibus.manifest.south.influxdb.url', + defaultValue: 'http://localhost:8086', + validators: [ + { + type: 'REQUIRED', + arguments: [] + } + ], + displayProperties: { + row: 1, + columns: 9, + displayInViewMode: true + } + }, + { + type: 'secret', + key: 'token', + translationKey: 'configuration.oibus.manifest.south.influxdb.token', + validators: [], + displayProperties: { + row: 2, + columns: 6, + displayInViewMode: false + } + }, + { + type: 'string', + key: 'organisation', + translationKey: 'configuration.oibus.manifest.south.influxdb.organisation', + defaultValue: null, + validators: [ + { + type: 'REQUIRED', + arguments: [] + } + ], + displayProperties: { + row: 3, + columns: 6, + displayInViewMode: true + } + }, + { + type: 'string', + key: 'bucket', + translationKey: 'configuration.oibus.manifest.south.influxdb.bucket', + defaultValue: null, + validators: [ + { + type: 'REQUIRED', + arguments: [] + } + ], + displayProperties: { + row: 3, + columns: 6, + displayInViewMode: true + } + } + ] + }, + items: { + type: 'array', + key: 'items', + translationKey: 'configuration.oibus.manifest.south.items', + paginate: true, + numberOfElementPerPage: 20, + validators: [], + rootAttribute: { + type: 'object', + key: 'item', + translationKey: 'configuration.oibus.manifest.south.items.item', + displayProperties: { + visible: true, + wrapInBox: false + }, + enablingConditions: [], + validators: [], + attributes: [ + { + type: 'string', + key: 'name', + translationKey: 'configuration.oibus.manifest.south.items.name', + defaultValue: null, + validators: [ + { + type: 'REQUIRED', + arguments: [] + } + ], + displayProperties: { + row: 0, + columns: 4, + displayInViewMode: true + } + }, + { + type: 'boolean', + key: 'enabled', + translationKey: 'configuration.oibus.manifest.south.items.enabled', + defaultValue: true, + validators: [ + { + type: 'REQUIRED', + arguments: [] + } + ], + displayProperties: { + row: 0, + columns: 4, + displayInViewMode: true + } + }, + { + type: 'scan-mode', + key: 'scanMode', + acceptableType: 'POLL', + translationKey: 'configuration.oibus.manifest.south.items.scan-mode', + validators: [ + { + type: 'REQUIRED', + arguments: [] + } + ], + displayProperties: { + row: 0, + columns: 4, + displayInViewMode: true + } + }, + { + type: 'object', + key: 'settings', + translationKey: 'configuration.oibus.manifest.south.items.settings', + displayProperties: { + visible: true, + wrapInBox: true + }, + enablingConditions: [], + validators: [], + attributes: [ + { + type: 'code', + key: 'query', + contentType: 'sql', + translationKey: 'configuration.oibus.manifest.south.items.influxdb.query', + defaultValue: "SELECT * FROM measurement WHERE time > '@StartTime' AND time <= '@EndTime'", + validators: [ + { + type: 'REQUIRED', + arguments: [] + } + ], + displayProperties: { + row: 0, + columns: 12, + displayInViewMode: true + } + }, + { + type: 'number', + key: 'requestTimeout', + translationKey: 'configuration.oibus.manifest.south.items.influxdb.request-timeout', + unit: 'ms', + defaultValue: 15000, + validators: [ + { + type: 'REQUIRED', + arguments: [] + } + ], + displayProperties: { + row: 1, + columns: 4, + displayInViewMode: false + } + } + ] + } + ] + } + } +}; + +export default manifest; diff --git a/backend/src/south/south-influxdb/south-influxdb.spec.ts b/backend/src/south/south-influxdb/south-influxdb.spec.ts new file mode 100644 index 0000000000..2b4bd67590 --- /dev/null +++ b/backend/src/south/south-influxdb/south-influxdb.spec.ts @@ -0,0 +1,622 @@ +import { describe, it, before, beforeEach, afterEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import fs from 'node:fs/promises'; +import testData from '../../tests/utils/test-data'; +import { mockModule, reloadModule } from '../../tests/utils/test-utils'; +import SouthCacheRepositoryMock from '../../tests/__mocks__/repository/cache/south-cache-repository.mock'; +import SouthCacheServiceMock from '../../tests/__mocks__/service/south-cache-service.mock'; +import EncryptionServiceMock from '../../tests/__mocks__/service/encryption-service.mock'; +import PinoLogger from '../../tests/__mocks__/service/logger/logger.mock'; +import type { SouthConnectorEntity, SouthConnectorItemEntity } from '../../model/south-connector.model'; +import type { OIBusContent } from '../../../shared/model/engine.model'; +import type { SouthItemSettings } from '../../../shared/model/south-settings.model'; +import type { SouthInfluxDBItemSettings, SouthInfluxDBSettings } from '../../../shared/model/south-settings.model'; +import type SouthInfluxDBClass from './south-influxdb'; +import type SouthCacheRepository from '../../repository/cache/south-cache.repository'; + +const nodeRequire = createRequire(import.meta.url); + +// — v1 mock (influx package) — +// Note: mock.fn() cannot be used as a constructor with `new`, so we use plain functions +// and attach mock.fn() instances as the methods they return. +let influxV1QueryMock = mock.fn(async (_q: string) => [{ time: '2020-02-01T00:00:00Z', value: 1 }] as unknown); +let influxV1PingMock = mock.fn(async () => [{ online: true, version: '1.8.10', url: 'http://localhost:8086' }] as unknown); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function MockInfluxDBv1(this: any) { + this.query = influxV1QueryMock; + this.ping = influxV1PingMock; +} +const influxV1Exports = { InfluxDB: MockInfluxDBv1 }; + +// — v2 mock (@influxdata/influxdb-client) — +let queryRowsMock = mock.fn( + ( + _query: string, + { + next, + complete + }: { + next: (row: Array, meta: { toObject: (r: Array) => Record }) => void; + error: (e: Error) => void; + complete: () => void; + } + ) => { + next(['2020-02-01T00:00:00Z', '1'], { toObject: (r: Array) => ({ _time: r[0], _value: r[1] }) }); + complete(); + } +); +let getQueryApiMock = mock.fn((_org: string) => ({ queryRows: queryRowsMock })); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function MockInfluxDBv2(this: any) { + this.getQueryApi = getQueryApiMock; +} +const influxV2Exports = { InfluxDB: MockInfluxDBv2 }; + +// — v3 mock (@influxdata/influxdb3-client) — +let influxV3QueryMock = mock.fn((_query: string, _database?: string) => + (async function* () { + yield { time: '2020-02-01T00:00:00Z', value: 1 } as Record; + })() +); +let influxV3CloseMock = mock.fn(async () => undefined); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function MockInfluxDBv3(this: any) { + this.query = influxV3QueryMock; + this.close = influxV3CloseMock; +} +const influxV3Exports = { InfluxDBClient: MockInfluxDBv3 }; + +// — utils mock — +const utilsExports = { + generateRandomId: mock.fn(() => 'random-id'), + sanitizeFilename: mock.fn((name: unknown) => name), + logQuery: mock.fn() +}; + +describe('SouthInfluxDB', () => { + let SouthInfluxDB: typeof SouthInfluxDBClass; + + const logger = new PinoLogger(); + const addContentCallback = mock.fn( + async (_southId: string, _data: OIBusContent, _queryTime: string, _items: Array>) => + undefined + ); + const southCacheRepository = new SouthCacheRepositoryMock() as unknown as SouthCacheRepository; + let southCacheService: SouthCacheServiceMock; + + let fsMock: { writeFile: ReturnType }; + + before(() => { + mockModule(nodeRequire, '../../service/utils', utilsExports); + mockModule(nodeRequire, 'influx', influxV1Exports); + mockModule(nodeRequire, '@influxdata/influxdb-client', influxV2Exports); + mockModule(nodeRequire, '@influxdata/influxdb3-client', influxV3Exports); + mockModule(nodeRequire, '../../service/encryption.service', { + __esModule: true, + encryptionService: new EncryptionServiceMock('', '') + }); + mockModule(nodeRequire, '../../service/south-cache.service', { + __esModule: true, + default: function () { + return southCacheService; + } + }); + SouthInfluxDB = reloadModule<{ default: typeof SouthInfluxDBClass }>(nodeRequire, './south-influxdb').default; + }); + + beforeEach(() => { + southCacheService = new SouthCacheServiceMock(); + addContentCallback.mock.resetCalls(); + + influxV1QueryMock = mock.fn(async (_q: string) => [{ time: '2020-02-01T00:00:00Z', value: 1 }] as unknown); + influxV1PingMock = mock.fn(async () => [{ online: true, version: '1.8.10', url: 'http://localhost:8086' }] as unknown); + + queryRowsMock = mock.fn( + ( + _query: string, + { + next, + complete + }: { + next: (row: Array, meta: { toObject: (r: Array) => Record }) => void; + error: (e: Error) => void; + complete: () => void; + } + ) => { + next(['2020-02-01T00:00:00Z', '1'], { toObject: (r: Array) => ({ _time: r[0], _value: r[1] }) }); + complete(); + } + ); + getQueryApiMock = mock.fn((_org: string) => ({ queryRows: queryRowsMock })); + + influxV3QueryMock = mock.fn((_query: string, _database?: string) => + (async function* () { + yield { time: '2020-02-01T00:00:00Z', value: 1 } as Record; + })() + ); + influxV3CloseMock = mock.fn(async () => undefined); + + utilsExports.generateRandomId = mock.fn(() => 'random-id'); + utilsExports.sanitizeFilename = mock.fn((name: unknown) => name); + utilsExports.logQuery = mock.fn(); + + fsMock = { writeFile: mock.method(fs, 'writeFile', async () => undefined) as unknown as ReturnType }; + + mock.timers.enable({ apis: ['Date'], now: new Date(testData.constants.dates.FAKE_NOW) }); + }); + + afterEach(() => { + mock.timers.reset(); + mock.restoreAll(); + }); + + const baseItem: SouthConnectorItemEntity = { + id: 'id1', + name: 'item1', + enabled: true, + settings: { + query: "SELECT * FROM measurement WHERE time > '@StartTime' AND time <= '@EndTime'", + requestTimeout: 15000 + }, + scanMode: testData.scanMode.list[0], + group: null, + syncWithGroup: false, + maxReadInterval: 3600, + readDelay: 0, + overlap: 0, + createdBy: '', + updatedBy: '', + createdAt: '', + updatedAt: '' + }; + + // —————————————————————————————————————————————————— + // InfluxDB v1 + // —————————————————————————————————————————————————— + describe('InfluxDB v1', () => { + let south: SouthInfluxDBClass; + const configV1: SouthConnectorEntity = { + id: 'southId', + name: 'south', + type: 'influxdb', + description: 'my influxdb v1 connector', + enabled: true, + settings: { + version: '1', + host: 'localhost', + port: 8086, + protocol: 'http', + database: 'mydb', + username: 'admin', + password: 'pass' + }, + groups: [], + items: [baseItem], + createdBy: '', + updatedBy: '', + createdAt: '', + updatedAt: '' + }; + + beforeEach(() => { + south = new SouthInfluxDB(configV1, addContentCallback, southCacheRepository, logger, 'cacheFolder'); + }); + + it('should test connection successfully', async () => { + const result = await south.testConnection(); + assert.ok(result.items.some(i => i.key === 'Version')); + assert.equal(result.items.find(i => i.key === 'Version')?.value, '1.8.10'); + }); + + it('should test connection without version info in ping result', async () => { + influxV1PingMock = mock.fn(async () => [{ online: true }] as unknown); + const result = await south.testConnection(); + assert.ok(!result.items.some(i => i.key === 'Version')); + assert.ok(result.items.some(i => i.key === 'Host')); + }); + + it('should test connection with no password, no username and default protocol', async () => { + const southNoAuth = new SouthInfluxDB( + { ...configV1, settings: { version: '1', host: 'localhost', port: 8086, database: 'mydb' } }, + addContentCallback, + southCacheRepository, + logger, + 'cacheFolder' + ); + const result = await southNoAuth.testConnection(); + assert.ok(result.items.some(i => i.key === 'Version')); + }); + + it('should throw when ping fails', async () => { + influxV1PingMock = mock.fn(async () => { + throw new Error('Connection refused'); + }); + await assert.rejects(south.testConnection(), /Could not connect to InfluxDB v1/); + }); + + it('should throw when no host is online', async () => { + influxV1PingMock = mock.fn(async () => [{ online: false }] as unknown); + await assert.rejects(south.testConnection(), /is not responding/); + }); + + it('should run historyQuery, write JSON file and call addContent', async () => { + const queryDataMock = mock.method( + south, + 'queryData', + mock.fn(async () => [{ time: '2020-02-01T00:00:00Z', value: 1 }]) + ); + const result = await south.historyQuery([baseItem], testData.constants.dates.DATE_1, testData.constants.dates.DATE_2); + assert.equal(queryDataMock.mock.calls.length, 1); + assert.equal(fsMock.writeFile.mock.calls.length, 1); + assert.equal(addContentCallback.mock.calls.length, 1); + const content = addContentCallback.mock.calls[0].arguments[1] as { type: string; filePath: string }; + assert.equal(content.type, 'any'); + assert.ok(content.filePath.includes('random-id.json')); + assert.notEqual(result.value, null); + }); + + it('should not write file or call addContent when no results', async () => { + mock.method( + south, + 'queryData', + mock.fn(async () => []) + ); + const result = await south.historyQuery([baseItem], testData.constants.dates.DATE_1, testData.constants.dates.DATE_2); + assert.equal(fsMock.writeFile.mock.calls.length, 0); + assert.equal(addContentCallback.mock.calls.length, 0); + assert.equal(result.trackedInstant, null); + assert.equal(result.value, null); + }); + + it('should track max instant automatically from the time field', async () => { + mock.method( + south, + 'queryData', + mock.fn(async () => [{ time: testData.constants.dates.DATE_2, value: 1 }]) + ); + const result = await south.historyQuery([baseItem], testData.constants.dates.DATE_1, testData.constants.dates.DATE_2); + assert.equal(result.trackedInstant, testData.constants.dates.DATE_2); + }); + + it('should return JSON content in testItem', async () => { + mock.method( + south, + 'queryData', + mock.fn(async () => [{ time: '2020-02-01T00:00:00Z', value: 1 }]) + ); + const result = await south.testItem(baseItem, { + history: { startTime: testData.constants.dates.DATE_1, endTime: testData.constants.dates.DATE_2 } + }); + assert.equal(result.type, 'any'); + assert.ok(result.content?.startsWith('[')); + assert.ok(JSON.parse(result.content!).length === 1); + }); + + it('should execute v1 query via queryData', async () => { + const result = await south.queryData(baseItem, testData.constants.dates.DATE_1, testData.constants.dates.DATE_2); + assert.ok(Array.isArray(result)); + }); + + it('should substitute @StartTime and @EndTime in query', async () => { + let capturedQuery = ''; + influxV1QueryMock = mock.fn(async (q: string) => { + capturedQuery = q; + return []; + }); + await south.queryData(baseItem, '2020-01-01T00:00:00.000Z', '2020-02-01T00:00:00.000Z'); + assert.ok(!capturedQuery.includes('@StartTime'), 'Query should not contain @StartTime'); + assert.ok(!capturedQuery.includes('@EndTime'), 'Query should not contain @EndTime'); + }); + + it('should convert Date objects in v1 results to ISO strings', async () => { + const ts = new Date('2021-01-01T00:00:00.000Z'); + influxV1QueryMock = mock.fn(async (_q: string) => [{ time: ts, value: 23.5 }] as unknown); + const result = await south.queryData(baseItem, testData.constants.dates.DATE_1, testData.constants.dates.DATE_2); + assert.equal(result[0].time, '2021-01-01T00:00:00.000Z'); + assert.equal(result[0].value, 23.5); + }); + + it('should run v1 queryData without password, username or protocol', async () => { + const southMinimal = new SouthInfluxDB( + { ...configV1, settings: { version: '1', host: 'localhost', port: 8086, database: 'mydb' } }, + addContentCallback, + southCacheRepository, + logger, + 'cacheFolder' + ); + influxV1QueryMock = mock.fn(async () => []); + const result = await southMinimal.queryData(baseItem, testData.constants.dates.DATE_1, testData.constants.dates.DATE_2); + assert.equal(result.length, 0); + }); + + it('should throw for unsupported version', async () => { + const southBadVersion = new SouthInfluxDB( + { ...configV1, settings: { ...configV1.settings, version: 'invalid' as '1' } }, + addContentCallback, + southCacheRepository, + logger, + 'cacheFolder' + ); + await assert.rejects(southBadVersion.testConnection(), /Unsupported InfluxDB version/); + await assert.rejects( + southBadVersion.queryData(baseItem, testData.constants.dates.DATE_1, testData.constants.dates.DATE_2), + /Unsupported InfluxDB version/ + ); + }); + }); + + // —————————————————————————————————————————————————— + // InfluxDB v2 + // —————————————————————————————————————————————————— + describe('InfluxDB v2', () => { + let south: SouthInfluxDBClass; + const configV2: SouthConnectorEntity = { + id: 'southId', + name: 'south', + type: 'influxdb', + description: 'my influxdb v2 connector', + enabled: true, + settings: { + version: '2', + url: 'http://localhost:8086', + token: 'my-token', + organisation: 'my-org', + bucket: 'my-bucket' + }, + groups: [], + items: [baseItem], + createdBy: '', + updatedBy: '', + createdAt: '', + updatedAt: '' + }; + + beforeEach(() => { + south = new SouthInfluxDB(configV2, addContentCallback, southCacheRepository, logger, 'cacheFolder'); + }); + + it('should test connection and return bucket count', async () => { + queryRowsMock = mock.fn( + ( + _query: string, + { + next, + complete + }: { + next: (r: Array, m: { toObject: (r: Array) => Record }) => void; + error: (e: Error) => void; + complete: () => void; + } + ) => { + next([], { toObject: () => ({ name: 'bucket1' }) }); + next([], { toObject: () => ({ name: 'bucket2' }) }); + complete(); + } + ); + getQueryApiMock = mock.fn(() => ({ queryRows: queryRowsMock })); + const result = await south.testConnection(); + assert.equal(result.items.find(i => i.key === 'Buckets')?.value, '2'); + }); + + it('should throw when v2 query fails on connection test', async () => { + queryRowsMock = mock.fn((_query: string, { error }: { next: unknown; error: (e: Error) => void; complete: unknown }) => { + error(new Error('Unauthorized')); + }); + getQueryApiMock = mock.fn(() => ({ queryRows: queryRowsMock })); + await assert.rejects(south.testConnection(), /Could not connect to InfluxDB v2/); + }); + + it('should run v2 query and return rows', async () => { + queryRowsMock = mock.fn( + ( + _query: string, + { + next, + complete + }: { + next: (row: Array, meta: { toObject: (r: Array) => Record }) => void; + error: (e: Error) => void; + complete: () => void; + } + ) => { + next(['2020-02-01T00:00:00Z', '42'], { toObject: (r: Array) => ({ _time: r[0], _value: r[1] }) }); + complete(); + } + ); + getQueryApiMock = mock.fn(() => ({ queryRows: queryRowsMock })); + const result = await south.queryData(baseItem, testData.constants.dates.DATE_1, testData.constants.dates.DATE_2); + assert.equal(result.length, 1); + }); + + it('should throw when v2 data query fails', async () => { + queryRowsMock = mock.fn((_query: string, { error }: { next: unknown; error: (e: Error) => void; complete: unknown }) => { + error(new Error('Query failed')); + }); + getQueryApiMock = mock.fn(() => ({ queryRows: queryRowsMock })); + await assert.rejects(south.queryData(baseItem, testData.constants.dates.DATE_1, testData.constants.dates.DATE_2), /Query failed/); + }); + + it('should test connection and run queryData without token', async () => { + const southNoToken = new SouthInfluxDB( + { ...configV2, settings: { ...configV2.settings, token: null } }, + addContentCallback, + southCacheRepository, + logger, + 'cacheFolder' + ); + queryRowsMock = mock.fn( + ( + _query: string, + { + next, + complete + }: { + next: (r: Array, m: { toObject: (r: Array) => Record }) => void; + error: (e: Error) => void; + complete: () => void; + } + ) => { + next([], { toObject: () => ({}) }); + complete(); + } + ); + getQueryApiMock = mock.fn(() => ({ queryRows: queryRowsMock })); + const connResult = await southNoToken.testConnection(); + assert.equal(connResult.items.find(i => i.key === 'Buckets')?.value, '1'); + + const itemNoTracking = { ...baseItem, settings: { ...baseItem.settings, trackingInstant: { trackInstant: false } } }; + queryRowsMock = mock.fn( + ( + _query: string, + { + next, + complete + }: { + next: (row: Array, meta: { toObject: (r: Array) => Record }) => void; + error: (e: Error) => void; + complete: () => void; + } + ) => { + next([], { toObject: () => ({ _time: '2020-02-01T00:00:00Z' }) }); + complete(); + } + ); + getQueryApiMock = mock.fn(() => ({ queryRows: queryRowsMock })); + const queryResult = await southNoToken.queryData(itemNoTracking, testData.constants.dates.DATE_1, testData.constants.dates.DATE_2); + assert.equal(queryResult.length, 1); + }); + }); + + // —————————————————————————————————————————————————— + // InfluxDB v3 + // —————————————————————————————————————————————————— + describe('InfluxDB v3', () => { + let south: SouthInfluxDBClass; + const configV3: SouthConnectorEntity = { + id: 'southId', + name: 'south', + type: 'influxdb', + description: 'my influxdb v3 connector', + enabled: true, + settings: { + version: '3', + url: 'https://eu-central-1-1.aws.cloud3.influxdata.com', + token: 'my-token-v3', + database: 'mydb' + }, + groups: [], + items: [baseItem], + createdBy: '', + updatedBy: '', + createdAt: '', + updatedAt: '' + }; + + beforeEach(() => { + south = new SouthInfluxDB(configV3, addContentCallback, southCacheRepository, logger, 'cacheFolder'); + }); + + it('should test connection successfully', async () => { + influxV3QueryMock = mock.fn((_query: string, _database?: string) => + (async function* () { + yield { name: 'measurement1' } as Record; + })() + ); + influxV3CloseMock = mock.fn(async () => undefined); + const result = await south.testConnection(); + assert.ok(result.items.some(i => i.key === 'Database')); + assert.equal(result.items.find(i => i.key === 'Connected')?.value, 'true'); + }); + + it('should throw and always close client when v3 connection test fails', async () => { + let closeCalled = false; + influxV3QueryMock = mock.fn( + (_query: string, _database?: string) => + (async function* () { + throw new Error('Forbidden'); + })() as AsyncGenerator, void, unknown> + ); + influxV3CloseMock = mock.fn(async () => { + closeCalled = true; + }); + await assert.rejects(south.testConnection(), /Could not connect to InfluxDB v3/); + assert.ok(closeCalled, 'close() should be called even after error'); + }); + + it('should run v3 query and return rows', async () => { + influxV3QueryMock = mock.fn((_query: string, _database?: string) => + (async function* () { + yield { time: '2020-02-01T00:00:00Z', value: 99 }; + })() + ); + influxV3CloseMock = mock.fn(async () => undefined); + const result = await south.queryData(baseItem, testData.constants.dates.DATE_1, testData.constants.dates.DATE_2); + assert.equal(result.length, 1); + }); + + it('should always close client even when v3 query throws', async () => { + let closeCalled = false; + influxV3QueryMock = mock.fn( + (_query: string, _database?: string) => + (async function* () { + throw new Error('Query error'); + })() as AsyncGenerator, void, unknown> + ); + influxV3CloseMock = mock.fn(async () => { + closeCalled = true; + }); + await assert.rejects(south.queryData(baseItem, testData.constants.dates.DATE_1, testData.constants.dates.DATE_2)); + assert.ok(closeCalled, 'close() should be called even after query error'); + }); + + it('should test connection and run queryData without token', async () => { + const southNoToken = new SouthInfluxDB( + { ...configV3, settings: { ...configV3.settings, token: null } }, + addContentCallback, + southCacheRepository, + logger, + 'cacheFolder' + ); + influxV3QueryMock = mock.fn((_query: string, _database?: string) => + (async function* () { + yield { name: 'measurement1' } as Record; + })() + ); + influxV3CloseMock = mock.fn(async () => undefined); + const connResult = await southNoToken.testConnection(); + assert.equal(connResult.items.find(i => i.key === 'Connected')?.value, 'true'); + + influxV3QueryMock = mock.fn((_query: string, _database?: string) => + (async function* () { + yield { time: '2020-02-01T00:00:00Z', value: 42 } as Record; + })() + ); + influxV3CloseMock = mock.fn(async () => undefined); + const queryResult = await southNoToken.queryData(baseItem, testData.constants.dates.DATE_1, testData.constants.dates.DATE_2); + assert.equal(queryResult.length, 1); + }); + + it('should handle empty results for both testConnection and queryData', async () => { + influxV3QueryMock = mock.fn( + (_query: string, _database?: string) => + (async function* () { + // empty — for await loop body never entered + })() as AsyncGenerator, void, unknown> + ); + influxV3CloseMock = mock.fn(async () => undefined); + const connResult = await south.testConnection(); + assert.equal(connResult.items.find(i => i.key === 'Connected')?.value, 'true'); + + influxV3QueryMock = mock.fn( + (_query: string, _database?: string) => + (async function* () { + // empty + })() as AsyncGenerator, void, unknown> + ); + influxV3CloseMock = mock.fn(async () => undefined); + const queryResult = await south.queryData(baseItem, testData.constants.dates.DATE_1, testData.constants.dates.DATE_2); + assert.equal(queryResult.length, 0); + }); + }); +}); diff --git a/backend/src/south/south-influxdb/south-influxdb.ts b/backend/src/south/south-influxdb/south-influxdb.ts new file mode 100644 index 0000000000..7f926648eb --- /dev/null +++ b/backend/src/south/south-influxdb/south-influxdb.ts @@ -0,0 +1,280 @@ +import { InfluxDB } from '@influxdata/influxdb-client'; +import { InfluxDBClient } from '@influxdata/influxdb3-client'; +import { InfluxDB as InfluxDBv1 } from 'influx'; +import SouthConnector from '../south-connector'; +import { generateRandomId, logQuery, sanitizeFilename } from '../../service/utils'; +import { encryptionService } from '../../service/encryption.service'; +import { Instant } from '../../../shared/model/types'; +import { SouthHistoryQuery } from '../south-interface'; +import { DateTime } from 'luxon'; +import { SouthInfluxDBItemSettings, SouthInfluxDBSettings, SouthItemSettings } from '../../../shared/model/south-settings.model'; +import { OIBusConnectionTestResult, OIBusContent } from '../../../shared/model/engine.model'; +import { SouthConnectorEntity, SouthConnectorItemEntity } from '../../model/south-connector.model'; +import SouthCacheRepository from '../../repository/cache/south-cache.repository'; +import { SouthConnectorItemTestingSettings } from '../../../shared/model/south-connector.model'; +import type { ILogger } from '../../model/logger.model'; +import path from 'node:path'; +import fs from 'node:fs/promises'; + +export default class SouthInfluxDB extends SouthConnector implements SouthHistoryQuery { + constructor( + connector: SouthConnectorEntity, + engineAddContentCallback: ( + southId: string, + data: OIBusContent, + queryTime: Instant, + items: Array> + ) => Promise, + southCacheRepository: SouthCacheRepository, + logger: ILogger, + cacheFolderPath: string + ) { + super(connector, engineAddContentCallback, southCacheRepository, logger, cacheFolderPath); + } + + override testConnection(): Promise { + const { version } = this.connector.settings; + switch (version) { + case '1': + return this.testConnectionV1(); + case '2': + return this.testConnectionV2(); + case '3': + return this.testConnectionV3(); + default: + return Promise.reject(new Error(`Unsupported InfluxDB version: ${version as string}`)); + } + } + + private async testConnectionV1(): Promise { + const { host, port, database, username } = this.connector.settings; + const password = this.connector.settings.password ? encryptionService.decryptText(this.connector.settings.password) : undefined; + + const client = new InfluxDBv1({ + host: host!, + port: port!, + database: database!, + username: username ?? undefined, + password: password ?? undefined, + protocol: this.connector.settings.protocol ?? 'http' + }); + + let pingResults: Awaited>; + try { + pingResults = await client.ping(5000); + } catch (error: unknown) { + throw new Error(`Could not connect to InfluxDB v1 at ${host}:${port}. ${(error as Error).message}`); + } + + const online = pingResults.some(p => p.online); + if (!online) { + throw new Error(`InfluxDB v1 at ${host}:${port} is not responding`); + } + + const items: Array<{ key: string; value: string }> = [{ key: 'Host', value: `${host}:${port}` }]; + const version = pingResults.find(p => p.online)?.version; + if (version) { + items.unshift({ key: 'Version', value: String(version) }); + } + + return { items }; + } + + private async testConnectionV2(): Promise { + const { url, organisation } = this.connector.settings; + const token = this.connector.settings.token ? encryptionService.decryptText(this.connector.settings.token) : ''; + + const client = new InfluxDB({ url: url!, token }); + const queryApi = client.getQueryApi(organisation!); + + let rowCount = 0; + try { + await new Promise((resolve, reject) => { + queryApi.queryRows('buckets()', { + next: () => { + rowCount++; + }, + error: reject, + complete: resolve + }); + }); + } catch (error: unknown) { + throw new Error(`Could not connect to InfluxDB v2 at ${url}. ${(error as Error).message}`); + } + + return { items: [{ key: 'Buckets', value: String(rowCount) }] }; + } + + private async testConnectionV3(): Promise { + const { url, database } = this.connector.settings; + const token = this.connector.settings.token ? encryptionService.decryptText(this.connector.settings.token) : ''; + + const client = new InfluxDBClient({ host: url!, token, database: database! }); + try { + for await (const _ of client.query('SELECT 1', database!)) { + break; + } + return { + items: [ + { key: 'Database', value: database! }, + { key: 'Connected', value: 'true' } + ] + }; + } catch (error: unknown) { + throw new Error(`Could not connect to InfluxDB v3 at ${url}. ${(error as Error).message}`); + } finally { + await client.close(); + } + } + + override async testItem( + item: SouthConnectorItemEntity, + testingSettings: SouthConnectorItemTestingSettings + ): Promise { + const startTime = testingSettings.history!.startTime; + const endTime = testingSettings.history!.endTime; + const result = await this.queryData(item, startTime, endTime); + const content = JSON.stringify(result); + const filename = `${sanitizeFilename(this.connector.name)}-${sanitizeFilename(item.name)}.json`; + return { type: 'any', filePath: filename, content }; + } + + async historyQuery( + items: Array>, + startTime: Instant, + endTime: Instant + ): Promise<{ trackedInstant: Instant | null; value: unknown | null }> { + const item = items[0]; + + const startRequest = DateTime.now(); + const result = await this.queryData(item, startTime, endTime); + const requestDuration = DateTime.now().toMillis() - startRequest.toMillis(); + + if (result.length === 0) { + this.logger.debug(`No result found for item ${item.name}. Request done in ${requestDuration} ms`); + return { trackedInstant: null, value: null }; + } + + this.logger.info(`Found ${result.length} results for item ${item.name} in ${requestDuration} ms`); + const filename = `${sanitizeFilename(this.connector.name)}-${sanitizeFilename(item.name)}-${generateRandomId()}.json`; + const filePath = path.resolve(this.tmpFolder, filename); + await fs.writeFile(filePath, JSON.stringify(result)); + const maxInstant = this.extractMaxInstant(result); + await this.addContent({ type: 'any', filePath }, startRequest.toUTC().toISO(), [item]); + return { trackedInstant: maxInstant, value: result[result.length - 1] }; + } + + // InfluxDB returns rows in ascending time order by default (InfluxQL, Flux, IOx SQL), + // so the last row always carries the most recent timestamp. + private extractMaxInstant(results: Array>): Instant | null { + const field = this.connector.settings.version === '2' ? '_time' : 'time'; + const v = results[results.length - 1]?.[field]; + return typeof v === 'string' && v.length > 0 ? (v as Instant) : null; + } + + queryData( + item: SouthConnectorItemEntity, + startTime: Instant, + endTime: Instant + ): Promise>> { + switch (this.connector.settings.version) { + case '1': + return this.queryDataV1(item, startTime, endTime); + case '2': + return this.queryDataV2(item, startTime, endTime); + case '3': + return this.queryDataV3(item, startTime, endTime); + default: + return Promise.reject(new Error(`Unsupported InfluxDB version: ${this.connector.settings.version as string}`)); + } + } + + private async queryDataV1( + item: SouthConnectorItemEntity, + startTime: Instant, + endTime: Instant + ): Promise>> { + const { host, port, database, username } = this.connector.settings; + const password = this.connector.settings.password ? encryptionService.decryptText(this.connector.settings.password) : undefined; + + logQuery(item.settings.query, startTime, endTime, this.logger); + + const query = item.settings.query.replace(/@StartTime/g, startTime).replace(/@EndTime/g, endTime); + + const client = new InfluxDBv1({ + host: host!, + port: port!, + database: database!, + username: username ?? undefined, + password: password ?? undefined, + protocol: this.connector.settings.protocol ?? 'http', + pool: { requestTimeout: item.settings.requestTimeout } + }); + + const results = await client.query(query); + // influx returns timestamps as INanoDate (extends Date) — normalise to ISO strings + return (results as unknown as Array>).map(row => { + const normalised: Record = {}; + for (const [key, value] of Object.entries(row)) { + if (value instanceof Date) { + normalised[key] = (value as Date).toISOString(); + } else if (typeof value === 'string' || typeof value === 'number') { + normalised[key] = value; + } + } + return normalised; + }); + } + + private async queryDataV2( + item: SouthConnectorItemEntity, + startTime: Instant, + endTime: Instant + ): Promise>> { + const { url, organisation } = this.connector.settings; + const token = this.connector.settings.token ? encryptionService.decryptText(this.connector.settings.token) : ''; + + logQuery(item.settings.query, startTime, endTime, this.logger); + + const query = item.settings.query.replace(/@StartTime/g, startTime).replace(/@EndTime/g, endTime); + + const client = new InfluxDB({ url: url!, token }); + const queryApi = client.getQueryApi(organisation!); + + const rows: Array> = []; + await new Promise((resolve, reject) => { + queryApi.queryRows(query, { + next: (row, tableMeta) => { + rows.push(tableMeta.toObject(row) as Record); + }, + error: reject, + complete: resolve + }); + }); + return rows; + } + + private async queryDataV3( + item: SouthConnectorItemEntity, + startTime: Instant, + endTime: Instant + ): Promise>> { + const { url, database } = this.connector.settings; + const token = this.connector.settings.token ? encryptionService.decryptText(this.connector.settings.token) : ''; + + logQuery(item.settings.query, startTime, endTime, this.logger); + + const query = item.settings.query.replace(/@StartTime/g, startTime).replace(/@EndTime/g, endTime); + + const client = new InfluxDBClient({ host: url!, token, database: database! }); + try { + const rows: Array> = []; + for await (const row of client.query(query, database!)) { + rows.push(row as Record); + } + return rows; + } finally { + await client.close(); + } + } +} diff --git a/backend/src/web-server/routes.ts b/backend/src/web-server/routes.ts index a674194811..87384cecc3 100644 --- a/backend/src/web-server/routes.ts +++ b/backend/src/web-server/routes.ts @@ -463,7 +463,7 @@ const models: TsoaRoute.Models = { // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "OIBusSouthType": { "dataType": "refAlias", - "type": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["opcua"]},{"dataType":"enum","enums":["mqtt"]},{"dataType":"enum","enums":["modbus"]},{"dataType":"enum","enums":["oianalytics"]},{"dataType":"enum","enums":["ads"]},{"dataType":"enum","enums":["folder-scanner"]},{"dataType":"enum","enums":["ftp"]},{"dataType":"enum","enums":["mssql"]},{"dataType":"enum","enums":["mysql"]},{"dataType":"enum","enums":["odbc"]},{"dataType":"enum","enums":["oledb"]},{"dataType":"enum","enums":["opc"]},{"dataType":"enum","enums":["oracle"]},{"dataType":"enum","enums":["osisoft-pi"]},{"dataType":"enum","enums":["postgresql"]},{"dataType":"enum","enums":["rest"]},{"dataType":"enum","enums":["sftp"]},{"dataType":"enum","enums":["sqlite"]}],"validators":{}}, + "type": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["opcua"]},{"dataType":"enum","enums":["mqtt"]},{"dataType":"enum","enums":["modbus"]},{"dataType":"enum","enums":["oianalytics"]},{"dataType":"enum","enums":["ads"]},{"dataType":"enum","enums":["folder-scanner"]},{"dataType":"enum","enums":["ftp"]},{"dataType":"enum","enums":["influxdb"]},{"dataType":"enum","enums":["mssql"]},{"dataType":"enum","enums":["mysql"]},{"dataType":"enum","enums":["odbc"]},{"dataType":"enum","enums":["oledb"]},{"dataType":"enum","enums":["opc"]},{"dataType":"enum","enums":["oracle"]},{"dataType":"enum","enums":["osisoft-pi"]},{"dataType":"enum","enums":["postgresql"]},{"dataType":"enum","enums":["rest"]},{"dataType":"enum","enums":["sftp"]},{"dataType":"enum","enums":["sqlite"]}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "OIBusSouthCategory": { @@ -822,6 +822,104 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "SouthInfluxDBItemSettings": { + "dataType": "refObject", + "properties": { + "query": {"dataType":"string","required":true}, + "requestTimeout": {"dataType":"double","required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "SouthConnectorInfluxDBItemDTO": { + "dataType": "refObject", + "properties": { + "id": {"dataType":"string","required":true}, + "createdBy": {"ref":"UserInfo","required":true}, + "updatedBy": {"ref":"UserInfo","required":true}, + "createdAt": {"ref":"Instant","required":true}, + "updatedAt": {"ref":"Instant","required":true}, + "name": {"dataType":"string","required":true}, + "enabled": {"dataType":"boolean","required":true}, + "settings": {"ref":"SouthInfluxDBItemSettings","required":true}, + "scanMode": {"dataType":"union","subSchemas":[{"ref":"ScanModeDTO"},{"dataType":"enum","enums":[null]}],"required":true}, + "group": {"dataType":"union","subSchemas":[{"ref":"SouthItemGroupDTO"},{"dataType":"enum","enums":[null]}],"required":true}, + "syncWithGroup": {"dataType":"boolean","required":true}, + "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "SouthInfluxDBSettingsVersion": { + "dataType": "refAlias", + "type": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["1"]},{"dataType":"enum","enums":["2"]},{"dataType":"enum","enums":["3"]}],"validators":{}}, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "SouthInfluxDBSettingsProtocol": { + "dataType": "refAlias", + "type": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["http"]},{"dataType":"enum","enums":["https"]}],"validators":{}}, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "SouthInfluxDBSettings": { + "dataType": "refObject", + "properties": { + "version": {"ref":"SouthInfluxDBSettingsVersion","required":true}, + "host": {"dataType":"string"}, + "port": {"dataType":"double"}, + "protocol": {"ref":"SouthInfluxDBSettingsProtocol"}, + "database": {"dataType":"string"}, + "username": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}]}, + "password": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}]}, + "url": {"dataType":"string"}, + "token": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}]}, + "organisation": {"dataType":"string"}, + "bucket": {"dataType":"string"}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "SouthConnectorItemTypedDTO_SouthInfluxDBItemSettings_": { + "dataType": "refObject", + "properties": { + "id": {"dataType":"string","required":true}, + "createdBy": {"ref":"UserInfo","required":true}, + "updatedBy": {"ref":"UserInfo","required":true}, + "createdAt": {"ref":"Instant","required":true}, + "updatedAt": {"ref":"Instant","required":true}, + "name": {"dataType":"string","required":true}, + "enabled": {"dataType":"boolean","required":true}, + "settings": {"ref":"SouthInfluxDBItemSettings","required":true}, + "scanMode": {"dataType":"union","subSchemas":[{"ref":"ScanModeDTO"},{"dataType":"enum","enums":[null]}],"required":true}, + "group": {"dataType":"union","subSchemas":[{"ref":"SouthItemGroupDTO"},{"dataType":"enum","enums":[null]}],"required":true}, + "syncWithGroup": {"dataType":"boolean","required":true}, + "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "SouthConnectorInfluxDBDTO": { + "dataType": "refObject", + "properties": { + "id": {"dataType":"string","required":true}, + "createdBy": {"ref":"UserInfo","required":true}, + "updatedBy": {"ref":"UserInfo","required":true}, + "createdAt": {"ref":"Instant","required":true}, + "updatedAt": {"ref":"Instant","required":true}, + "name": {"dataType":"string","required":true}, + "type": {"dataType":"enum","enums":["influxdb"],"required":true}, + "description": {"dataType":"string","required":true}, + "enabled": {"dataType":"boolean","required":true}, + "settings": {"ref":"SouthInfluxDBSettings","required":true}, + "items": {"dataType":"array","array":{"dataType":"refObject","ref":"SouthConnectorInfluxDBItemDTO"},"required":true}, + "groups": {"dataType":"array","array":{"dataType":"refObject","ref":"SouthItemGroupDTO"},"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "SouthModbusItemSettingsModbusType": { "dataType": "refAlias", "type": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["coil"]},{"dataType":"enum","enums":["discrete-input"]},{"dataType":"enum","enums":["input-register"]},{"dataType":"enum","enums":["holding-register"]}],"validators":{}}, @@ -973,7 +1071,7 @@ const models: TsoaRoute.Models = { // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "SouthMQTTSettingsQos": { "dataType": "refAlias", - "type": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["0"]},{"dataType":"enum","enums":["1"]},{"dataType":"enum","enums":["2"]}],"validators":{}}, + "type": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["1"]},{"dataType":"enum","enums":["2"]},{"dataType":"enum","enums":["0"]}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "SouthMQTTSettingsAuthenticationType": { @@ -2744,7 +2842,7 @@ const models: TsoaRoute.Models = { // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "SouthConnectorDTO": { "dataType": "refAlias", - "type": {"dataType":"union","subSchemas":[{"ref":"SouthConnectorADSDTO"},{"ref":"SouthConnectorFolderScannerDTO"},{"ref":"SouthConnectorFTPDTO"},{"ref":"SouthConnectorModbusDTO"},{"ref":"SouthConnectorMQTTDTO"},{"ref":"SouthConnectorMSSQLDTO"},{"ref":"SouthConnectorMySQLDTO"},{"ref":"SouthConnectorODBCDTO"},{"ref":"SouthConnectorOIAnalyticsDTO"},{"ref":"SouthConnectorOLEDBDTO"},{"ref":"SouthConnectorOPCDTO"},{"ref":"SouthConnectorOPCUADTO"},{"ref":"SouthConnectorOracleDTO"},{"ref":"SouthConnectorOsisoftPIDTO"},{"ref":"SouthConnectorPostgreSQLDTO"},{"ref":"SouthConnectorRESTDTO"},{"ref":"SouthConnectorSFTPDTO"},{"ref":"SouthConnectorSQLiteDTO"}],"validators":{}}, + "type": {"dataType":"union","subSchemas":[{"ref":"SouthConnectorADSDTO"},{"ref":"SouthConnectorFolderScannerDTO"},{"ref":"SouthConnectorFTPDTO"},{"ref":"SouthConnectorInfluxDBDTO"},{"ref":"SouthConnectorModbusDTO"},{"ref":"SouthConnectorMQTTDTO"},{"ref":"SouthConnectorMSSQLDTO"},{"ref":"SouthConnectorMySQLDTO"},{"ref":"SouthConnectorODBCDTO"},{"ref":"SouthConnectorOIAnalyticsDTO"},{"ref":"SouthConnectorOLEDBDTO"},{"ref":"SouthConnectorOPCDTO"},{"ref":"SouthConnectorOPCUADTO"},{"ref":"SouthConnectorOracleDTO"},{"ref":"SouthConnectorOsisoftPIDTO"},{"ref":"SouthConnectorPostgreSQLDTO"},{"ref":"SouthConnectorRESTDTO"},{"ref":"SouthConnectorSFTPDTO"},{"ref":"SouthConnectorSQLiteDTO"}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "SouthConnectorADSItemCommandDTO": { @@ -2913,6 +3011,58 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "SouthConnectorInfluxDBItemCommandDTO": { + "dataType": "refObject", + "properties": { + "id": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, + "enabled": {"dataType":"boolean","required":true}, + "name": {"dataType":"string","required":true}, + "settings": {"ref":"SouthInfluxDBItemSettings","required":true}, + "scanModeId": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, + "scanModeName": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, + "groupId": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, + "groupName": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, + "syncWithGroup": {"dataType":"boolean","required":true}, + "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "SouthConnectorItemCommandTypedDTO_SouthInfluxDBItemSettings_": { + "dataType": "refObject", + "properties": { + "id": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, + "enabled": {"dataType":"boolean","required":true}, + "name": {"dataType":"string","required":true}, + "settings": {"ref":"SouthInfluxDBItemSettings","required":true}, + "scanModeId": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, + "scanModeName": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, + "groupId": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, + "groupName": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, + "syncWithGroup": {"dataType":"boolean","required":true}, + "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "SouthConnectorInfluxDBCommandDTO": { + "dataType": "refObject", + "properties": { + "name": {"dataType":"string","required":true}, + "type": {"dataType":"enum","enums":["influxdb"],"required":true}, + "description": {"dataType":"string","required":true}, + "enabled": {"dataType":"boolean","required":true}, + "settings": {"ref":"SouthInfluxDBSettings","required":true}, + "items": {"dataType":"array","array":{"dataType":"refObject","ref":"SouthConnectorInfluxDBItemCommandDTO"},"required":true}, + "groups": {"dataType":"array","array":{"dataType":"refObject","ref":"SouthItemGroupCommandDTO"},"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "SouthConnectorModbusItemCommandDTO": { "dataType": "refObject", "properties": { @@ -3695,7 +3845,7 @@ const models: TsoaRoute.Models = { // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "SouthConnectorCommandDTO": { "dataType": "refAlias", - "type": {"dataType":"union","subSchemas":[{"ref":"SouthConnectorADSCommandDTO"},{"ref":"SouthConnectorFolderScannerCommandDTO"},{"ref":"SouthConnectorFTPCommandDTO"},{"ref":"SouthConnectorModbusCommandDTO"},{"ref":"SouthConnectorMQTTCommandDTO"},{"ref":"SouthConnectorMSSQLCommandDTO"},{"ref":"SouthConnectorMySQLCommandDTO"},{"ref":"SouthConnectorODBCCommandDTO"},{"ref":"SouthConnectorOIAnalyticsCommandDTO"},{"ref":"SouthConnectorOLEDBCommandDTO"},{"ref":"SouthConnectorOPCCommandDTO"},{"ref":"SouthConnectorOPCUACommandDTO"},{"ref":"SouthConnectorOracleCommandDTO"},{"ref":"SouthConnectorOsisoftPICommandDTO"},{"ref":"SouthConnectorPostgreSQLCommandDTO"},{"ref":"SouthConnectorRESTCommandDTO"},{"ref":"SouthConnectorSFTPCommandDTO"},{"ref":"SouthConnectorSQLiteCommandDTO"}],"validators":{}}, + "type": {"dataType":"union","subSchemas":[{"ref":"SouthConnectorADSCommandDTO"},{"ref":"SouthConnectorFolderScannerCommandDTO"},{"ref":"SouthConnectorFTPCommandDTO"},{"ref":"SouthConnectorInfluxDBCommandDTO"},{"ref":"SouthConnectorModbusCommandDTO"},{"ref":"SouthConnectorMQTTCommandDTO"},{"ref":"SouthConnectorMSSQLCommandDTO"},{"ref":"SouthConnectorMySQLCommandDTO"},{"ref":"SouthConnectorODBCCommandDTO"},{"ref":"SouthConnectorOIAnalyticsCommandDTO"},{"ref":"SouthConnectorOLEDBCommandDTO"},{"ref":"SouthConnectorOPCCommandDTO"},{"ref":"SouthConnectorOPCUACommandDTO"},{"ref":"SouthConnectorOracleCommandDTO"},{"ref":"SouthConnectorOsisoftPICommandDTO"},{"ref":"SouthConnectorPostgreSQLCommandDTO"},{"ref":"SouthConnectorRESTCommandDTO"},{"ref":"SouthConnectorSFTPCommandDTO"},{"ref":"SouthConnectorSQLiteCommandDTO"}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "OIBusConnectionTestResult": { @@ -3708,7 +3858,7 @@ const models: TsoaRoute.Models = { // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "SouthSettings": { "dataType": "refAlias", - "type": {"dataType":"union","subSchemas":[{"ref":"SouthADSSettings"},{"ref":"SouthFolderScannerSettings"},{"ref":"SouthFTPSettings"},{"ref":"SouthModbusSettings"},{"ref":"SouthMQTTSettings"},{"ref":"SouthMSSQLSettings"},{"ref":"SouthMySQLSettings"},{"ref":"SouthODBCSettings"},{"ref":"SouthOIAnalyticsSettings"},{"ref":"SouthOLEDBSettings"},{"ref":"SouthOPCSettings"},{"ref":"SouthOPCUASettings"},{"ref":"SouthOracleSettings"},{"ref":"SouthPISettings"},{"ref":"SouthPostgreSQLSettings"},{"ref":"SouthRestSettings"},{"ref":"SouthSFTPSettings"},{"ref":"SouthSQLiteSettings"}],"validators":{}}, + "type": {"dataType":"union","subSchemas":[{"ref":"SouthADSSettings"},{"ref":"SouthFolderScannerSettings"},{"ref":"SouthFTPSettings"},{"ref":"SouthInfluxDBSettings"},{"ref":"SouthModbusSettings"},{"ref":"SouthMQTTSettings"},{"ref":"SouthMSSQLSettings"},{"ref":"SouthMySQLSettings"},{"ref":"SouthODBCSettings"},{"ref":"SouthOIAnalyticsSettings"},{"ref":"SouthOLEDBSettings"},{"ref":"SouthOPCSettings"},{"ref":"SouthOPCUASettings"},{"ref":"SouthOracleSettings"},{"ref":"SouthPISettings"},{"ref":"SouthPostgreSQLSettings"},{"ref":"SouthRestSettings"},{"ref":"SouthSFTPSettings"},{"ref":"SouthSQLiteSettings"}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "OIBusTimeValue": { @@ -3775,7 +3925,7 @@ const models: TsoaRoute.Models = { // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "SouthItemSettings": { "dataType": "refAlias", - "type": {"dataType":"union","subSchemas":[{"ref":"SouthADSItemSettings"},{"ref":"SouthFolderScannerItemSettings"},{"ref":"SouthFTPItemSettings"},{"ref":"SouthModbusItemSettings"},{"ref":"SouthMQTTItemSettings"},{"ref":"SouthMSSQLItemSettings"},{"ref":"SouthMySQLItemSettings"},{"ref":"SouthODBCItemSettings"},{"ref":"SouthOIAnalyticsItemSettings"},{"ref":"SouthOLEDBItemSettings"},{"ref":"SouthOPCItemSettings"},{"ref":"SouthOPCUAItemSettings"},{"ref":"SouthOracleItemSettings"},{"ref":"SouthPIItemSettings"},{"ref":"SouthPostgreSQLItemSettings"},{"ref":"SouthRestItemSettings"},{"ref":"SouthSFTPItemSettings"},{"ref":"SouthSQLiteItemSettings"}],"validators":{}}, + "type": {"dataType":"union","subSchemas":[{"ref":"SouthADSItemSettings"},{"ref":"SouthFolderScannerItemSettings"},{"ref":"SouthFTPItemSettings"},{"ref":"SouthInfluxDBItemSettings"},{"ref":"SouthModbusItemSettings"},{"ref":"SouthMQTTItemSettings"},{"ref":"SouthMSSQLItemSettings"},{"ref":"SouthMySQLItemSettings"},{"ref":"SouthODBCItemSettings"},{"ref":"SouthOIAnalyticsItemSettings"},{"ref":"SouthOLEDBItemSettings"},{"ref":"SouthOPCItemSettings"},{"ref":"SouthOPCUAItemSettings"},{"ref":"SouthOracleItemSettings"},{"ref":"SouthPIItemSettings"},{"ref":"SouthPostgreSQLItemSettings"},{"ref":"SouthRestItemSettings"},{"ref":"SouthSFTPItemSettings"},{"ref":"SouthSQLiteItemSettings"}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "SouthConnectorItemTestingSettings": { @@ -3798,7 +3948,7 @@ const models: TsoaRoute.Models = { // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "SouthConnectorItemDTO": { "dataType": "refAlias", - "type": {"dataType":"union","subSchemas":[{"ref":"SouthConnectorADSItemDTO"},{"ref":"SouthConnectorFolderScannerItemDTO"},{"ref":"SouthConnectorFTPItemDTO"},{"ref":"SouthConnectorModbusItemDTO"},{"ref":"SouthConnectorMQTTItemDTO"},{"ref":"SouthConnectorMSSQLItemDTO"},{"ref":"SouthConnectorMySQLItemDTO"},{"ref":"SouthConnectorODBCItemDTO"},{"ref":"SouthConnectorOIAnalyticsItemDTO"},{"ref":"SouthConnectorOLEDBItemDTO"},{"ref":"SouthConnectorOPCItemDTO"},{"ref":"SouthConnectorOPCUAItemDTO"},{"ref":"SouthConnectorOracleItemDTO"},{"ref":"SouthConnectorOsisoftPIItemDTO"},{"ref":"SouthConnectorPostgreSQLItemDTO"},{"ref":"SouthConnectorRESTItemDTO"},{"ref":"SouthConnectorSFTPItemDTO"},{"ref":"SouthConnectorSQLiteItemDTO"}],"validators":{}}, + "type": {"dataType":"union","subSchemas":[{"ref":"SouthConnectorADSItemDTO"},{"ref":"SouthConnectorFolderScannerItemDTO"},{"ref":"SouthConnectorFTPItemDTO"},{"ref":"SouthConnectorInfluxDBItemDTO"},{"ref":"SouthConnectorModbusItemDTO"},{"ref":"SouthConnectorMQTTItemDTO"},{"ref":"SouthConnectorMSSQLItemDTO"},{"ref":"SouthConnectorMySQLItemDTO"},{"ref":"SouthConnectorODBCItemDTO"},{"ref":"SouthConnectorOIAnalyticsItemDTO"},{"ref":"SouthConnectorOLEDBItemDTO"},{"ref":"SouthConnectorOPCItemDTO"},{"ref":"SouthConnectorOPCUAItemDTO"},{"ref":"SouthConnectorOracleItemDTO"},{"ref":"SouthConnectorOsisoftPIItemDTO"},{"ref":"SouthConnectorPostgreSQLItemDTO"},{"ref":"SouthConnectorRESTItemDTO"},{"ref":"SouthConnectorSFTPItemDTO"},{"ref":"SouthConnectorSQLiteItemDTO"}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "Page_SouthConnectorItemDTO_": { @@ -3829,7 +3979,7 @@ const models: TsoaRoute.Models = { // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "SouthConnectorItemCommandDTO": { "dataType": "refAlias", - "type": {"dataType":"union","subSchemas":[{"ref":"SouthConnectorADSItemCommandDTO"},{"ref":"SouthConnectorFolderScannerItemCommandDTO"},{"ref":"SouthConnectorFTPItemCommandDTO"},{"ref":"SouthConnectorModbusItemCommandDTO"},{"ref":"SouthConnectorMQTTItemCommandDTO"},{"ref":"SouthConnectorMSSQLItemCommandDTO"},{"ref":"SouthConnectorMySQLItemCommandDTO"},{"ref":"SouthConnectorODBCItemCommandDTO"},{"ref":"SouthConnectorOIAnalyticsItemCommandDTO"},{"ref":"SouthConnectorOLEDBItemCommandDTO"},{"ref":"SouthConnectorOPCItemCommandDTO"},{"ref":"SouthConnectorOPCUAItemCommandDTO"},{"ref":"SouthConnectorOracleItemCommandDTO"},{"ref":"SouthConnectorOsisoftPIItemCommandDTO"},{"ref":"SouthConnectorPostgreSQLItemCommandDTO"},{"ref":"SouthConnectorRESTItemCommandDTO"},{"ref":"SouthConnectorSFTPItemCommandDTO"},{"ref":"SouthConnectorSQLiteItemCommandDTO"}],"validators":{}}, + "type": {"dataType":"union","subSchemas":[{"ref":"SouthConnectorADSItemCommandDTO"},{"ref":"SouthConnectorFolderScannerItemCommandDTO"},{"ref":"SouthConnectorFTPItemCommandDTO"},{"ref":"SouthConnectorInfluxDBItemCommandDTO"},{"ref":"SouthConnectorModbusItemCommandDTO"},{"ref":"SouthConnectorMQTTItemCommandDTO"},{"ref":"SouthConnectorMSSQLItemCommandDTO"},{"ref":"SouthConnectorMySQLItemCommandDTO"},{"ref":"SouthConnectorODBCItemCommandDTO"},{"ref":"SouthConnectorOIAnalyticsItemCommandDTO"},{"ref":"SouthConnectorOLEDBItemCommandDTO"},{"ref":"SouthConnectorOPCItemCommandDTO"},{"ref":"SouthConnectorOPCUAItemCommandDTO"},{"ref":"SouthConnectorOracleItemCommandDTO"},{"ref":"SouthConnectorOsisoftPIItemCommandDTO"},{"ref":"SouthConnectorPostgreSQLItemCommandDTO"},{"ref":"SouthConnectorRESTItemCommandDTO"},{"ref":"SouthConnectorSFTPItemCommandDTO"},{"ref":"SouthConnectorSQLiteItemCommandDTO"}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "SouthCsvDelimiterRequest": { @@ -4511,7 +4661,7 @@ const models: TsoaRoute.Models = { // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "NorthMQTTSettingsQos": { "dataType": "refAlias", - "type": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["0"]},{"dataType":"enum","enums":["1"]},{"dataType":"enum","enums":["2"]}],"validators":{}}, + "type": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["1"]},{"dataType":"enum","enums":["2"]},{"dataType":"enum","enums":["0"]}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "NorthMQTTSettingsAuthenticationType": { @@ -5019,6 +5169,38 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "HistoryQueryInfluxDBItemCommandDTO": { + "dataType": "refObject", + "properties": { + "id": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, + "name": {"dataType":"string","required":true}, + "enabled": {"dataType":"boolean","required":true}, + "settings": {"ref":"SouthInfluxDBItemSettings","required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "HistoryQueryItemCommandTypedDTO_SouthInfluxDBItemSettings_": { + "dataType": "refObject", + "properties": { + "id": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, + "name": {"dataType":"string","required":true}, + "enabled": {"dataType":"boolean","required":true}, + "settings": {"ref":"SouthInfluxDBItemSettings","required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "HistoryQueryInfluxDBSouthCommandDTO": { + "dataType": "refObject", + "properties": { + "southType": {"dataType":"enum","enums":["influxdb"],"required":true}, + "southSettings": {"ref":"SouthInfluxDBSettings","required":true}, + "items": {"dataType":"array","array":{"dataType":"refObject","ref":"HistoryQueryInfluxDBItemCommandDTO"},"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "HistoryQueryModbusItemCommandDTO": { "dataType": "refObject", "properties": { @@ -5591,7 +5773,7 @@ const models: TsoaRoute.Models = { // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "HistoryQueryCommandDTO": { "dataType": "refAlias", - "type": {"dataType":"intersection","subSchemas":[{"ref":"HistoryQueryCommandCommonDTO"},{"dataType":"union","subSchemas":[{"ref":"HistoryQueryADSSouthCommandDTO"},{"ref":"HistoryQueryFolderScannerSouthCommandDTO"},{"ref":"HistoryQueryFTPSouthCommandDTO"},{"ref":"HistoryQueryModbusSouthCommandDTO"},{"ref":"HistoryQueryMQTTSouthCommandDTO"},{"ref":"HistoryQueryMSSQLSouthCommandDTO"},{"ref":"HistoryQueryMySQLSouthCommandDTO"},{"ref":"HistoryQueryODBCSouthCommandDTO"},{"ref":"HistoryQueryOIAnalyticsSouthCommandDTO"},{"ref":"HistoryQueryOLEDBSouthCommandDTO"},{"ref":"HistoryQueryOPCSouthCommandDTO"},{"ref":"HistoryQueryOPCUASouthCommandDTO"},{"ref":"HistoryQueryOracleSouthCommandDTO"},{"ref":"HistoryQueryOsisoftPISouthCommandDTO"},{"ref":"HistoryQueryPostgreSQLSouthCommandDTO"},{"ref":"HistoryQueryRESTSouthCommandDTO"},{"ref":"HistoryQuerySFTPSouthCommandDTO"},{"ref":"HistoryQuerySQLiteSouthCommandDTO"}]},{"dataType":"union","subSchemas":[{"ref":"HistoryQueryAmazonS3NorthCommandDTO"},{"ref":"HistoryQueryAzureBlobNorthCommandDTO"},{"ref":"HistoryQueryConsoleNorthCommandDTO"},{"ref":"HistoryQueryFileWriterNorthCommandDTO"},{"ref":"HistoryQueryModbusNorthCommandDTO"},{"ref":"HistoryQueryMQTTNorthCommandDTO"},{"ref":"HistoryQueryOIAnalyticsNorthCommandDTO"},{"ref":"HistoryQueryOPCUANorthCommandDTO"},{"ref":"HistoryQueryRESTNorthCommandDTO"},{"ref":"HistoryQuerySFTPNorthCommandDTO"}]}],"validators":{}}, + "type": {"dataType":"intersection","subSchemas":[{"ref":"HistoryQueryCommandCommonDTO"},{"dataType":"union","subSchemas":[{"ref":"HistoryQueryADSSouthCommandDTO"},{"ref":"HistoryQueryFolderScannerSouthCommandDTO"},{"ref":"HistoryQueryFTPSouthCommandDTO"},{"ref":"HistoryQueryInfluxDBSouthCommandDTO"},{"ref":"HistoryQueryModbusSouthCommandDTO"},{"ref":"HistoryQueryMQTTSouthCommandDTO"},{"ref":"HistoryQueryMSSQLSouthCommandDTO"},{"ref":"HistoryQueryMySQLSouthCommandDTO"},{"ref":"HistoryQueryODBCSouthCommandDTO"},{"ref":"HistoryQueryOIAnalyticsSouthCommandDTO"},{"ref":"HistoryQueryOLEDBSouthCommandDTO"},{"ref":"HistoryQueryOPCSouthCommandDTO"},{"ref":"HistoryQueryOPCUASouthCommandDTO"},{"ref":"HistoryQueryOracleSouthCommandDTO"},{"ref":"HistoryQueryOsisoftPISouthCommandDTO"},{"ref":"HistoryQueryPostgreSQLSouthCommandDTO"},{"ref":"HistoryQueryRESTSouthCommandDTO"},{"ref":"HistoryQuerySFTPSouthCommandDTO"},{"ref":"HistoryQuerySQLiteSouthCommandDTO"}]},{"dataType":"union","subSchemas":[{"ref":"HistoryQueryAmazonS3NorthCommandDTO"},{"ref":"HistoryQueryAzureBlobNorthCommandDTO"},{"ref":"HistoryQueryConsoleNorthCommandDTO"},{"ref":"HistoryQueryFileWriterNorthCommandDTO"},{"ref":"HistoryQueryModbusNorthCommandDTO"},{"ref":"HistoryQueryMQTTNorthCommandDTO"},{"ref":"HistoryQueryOIAnalyticsNorthCommandDTO"},{"ref":"HistoryQueryOPCUANorthCommandDTO"},{"ref":"HistoryQueryRESTNorthCommandDTO"},{"ref":"HistoryQuerySFTPNorthCommandDTO"}]}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "OIBusCreateHistoryQueryCommandDTO": { @@ -5681,7 +5863,7 @@ const models: TsoaRoute.Models = { // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "HistoryQueryItemCommandDTO": { "dataType": "refAlias", - "type": {"dataType":"union","subSchemas":[{"ref":"HistoryQueryADSItemCommandDTO"},{"ref":"HistoryQueryFolderScannerItemCommandDTO"},{"ref":"HistoryQueryFTPItemCommandDTO"},{"ref":"HistoryQueryModbusItemCommandDTO"},{"ref":"HistoryQueryMQTTItemCommandDTO"},{"ref":"HistoryQueryMSSQLItemCommandDTO"},{"ref":"HistoryQueryMySQLItemCommandDTO"},{"ref":"HistoryQueryODBCItemCommandDTO"},{"ref":"HistoryQueryOIAnalyticsItemCommandDTO"},{"ref":"HistoryQueryOLEDBItemCommandDTO"},{"ref":"HistoryQueryOPCItemCommandDTO"},{"ref":"HistoryQueryOPCUAItemCommandDTO"},{"ref":"HistoryQueryOracleItemCommandDTO"},{"ref":"HistoryQueryOsisoftPIItemCommandDTO"},{"ref":"HistoryQueryPostgreSQLItemCommandDTO"},{"ref":"HistoryQueryRESTItemCommandDTO"},{"ref":"HistoryQuerySFTPItemCommandDTO"},{"ref":"HistoryQuerySQLiteItemCommandDTO"}],"validators":{}}, + "type": {"dataType":"union","subSchemas":[{"ref":"HistoryQueryADSItemCommandDTO"},{"ref":"HistoryQueryFolderScannerItemCommandDTO"},{"ref":"HistoryQueryFTPItemCommandDTO"},{"ref":"HistoryQueryInfluxDBItemCommandDTO"},{"ref":"HistoryQueryModbusItemCommandDTO"},{"ref":"HistoryQueryMQTTItemCommandDTO"},{"ref":"HistoryQueryMSSQLItemCommandDTO"},{"ref":"HistoryQueryMySQLItemCommandDTO"},{"ref":"HistoryQueryODBCItemCommandDTO"},{"ref":"HistoryQueryOIAnalyticsItemCommandDTO"},{"ref":"HistoryQueryOLEDBItemCommandDTO"},{"ref":"HistoryQueryOPCItemCommandDTO"},{"ref":"HistoryQueryOPCUAItemCommandDTO"},{"ref":"HistoryQueryOracleItemCommandDTO"},{"ref":"HistoryQueryOsisoftPIItemCommandDTO"},{"ref":"HistoryQueryPostgreSQLItemCommandDTO"},{"ref":"HistoryQueryRESTItemCommandDTO"},{"ref":"HistoryQuerySFTPItemCommandDTO"},{"ref":"HistoryQuerySQLiteItemCommandDTO"}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "OIBusTestHistoryQuerySouthItemCommandDTO": { @@ -6540,6 +6722,46 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "HistoryQueryInfluxDBItemDTO": { + "dataType": "refObject", + "properties": { + "id": {"dataType":"string","required":true}, + "createdBy": {"ref":"UserInfo","required":true}, + "updatedBy": {"ref":"UserInfo","required":true}, + "createdAt": {"ref":"Instant","required":true}, + "updatedAt": {"ref":"Instant","required":true}, + "name": {"dataType":"string","required":true}, + "enabled": {"dataType":"boolean","required":true}, + "settings": {"ref":"SouthInfluxDBItemSettings","required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "HistoryQueryItemTypedDTO_SouthInfluxDBItemSettings_": { + "dataType": "refObject", + "properties": { + "id": {"dataType":"string","required":true}, + "createdBy": {"ref":"UserInfo","required":true}, + "updatedBy": {"ref":"UserInfo","required":true}, + "createdAt": {"ref":"Instant","required":true}, + "updatedAt": {"ref":"Instant","required":true}, + "name": {"dataType":"string","required":true}, + "enabled": {"dataType":"boolean","required":true}, + "settings": {"ref":"SouthInfluxDBItemSettings","required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "HistoryQueryInfluxDBSouthDTO": { + "dataType": "refObject", + "properties": { + "southType": {"dataType":"enum","enums":["influxdb"],"required":true}, + "southSettings": {"ref":"SouthInfluxDBSettings","required":true}, + "items": {"dataType":"array","array":{"dataType":"refObject","ref":"HistoryQueryInfluxDBItemDTO"},"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "HistoryQueryModbusItemDTO": { "dataType": "refObject", "properties": { @@ -7232,7 +7454,7 @@ const models: TsoaRoute.Models = { // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "HistoryQueryDTO": { "dataType": "refAlias", - "type": {"dataType":"intersection","subSchemas":[{"ref":"BaseEntity"},{"ref":"HistoryQueryCommonDTO"},{"dataType":"union","subSchemas":[{"ref":"HistoryQueryADSSouthDTO"},{"ref":"HistoryQueryFolderScannerSouthDTO"},{"ref":"HistoryQueryFTPSouthDTO"},{"ref":"HistoryQueryModbusSouthDTO"},{"ref":"HistoryQueryMQTTSouthDTO"},{"ref":"HistoryQueryMSSQLSouthDTO"},{"ref":"HistoryQueryMySQLSouthDTO"},{"ref":"HistoryQueryODBCSouthDTO"},{"ref":"HistoryQueryOIAnalyticsSouthDTO"},{"ref":"HistoryQueryOLEDBSouthDTO"},{"ref":"HistoryQueryOPCSouthDTO"},{"ref":"HistoryQueryOPCUASouthDTO"},{"ref":"HistoryQueryOracleSouthDTO"},{"ref":"HistoryQueryOsisoftPISouthDTO"},{"ref":"HistoryQueryPostgreSQLSouthDTO"},{"ref":"HistoryQueryRESTSouthDTO"},{"ref":"HistoryQuerySFTPSouthDTO"},{"ref":"HistoryQuerySQLiteSouthDTO"}]},{"dataType":"union","subSchemas":[{"ref":"HistoryQueryAmazonS3NorthDTO"},{"ref":"HistoryQueryAzureBlobNorthDTO"},{"ref":"HistoryQueryConsoleNorthDTO"},{"ref":"HistoryQueryFileWriterNorthDTO"},{"ref":"HistoryQueryModbusNorthDTO"},{"ref":"HistoryQueryMQTTNorthDTO"},{"ref":"HistoryQueryOIAnalyticsNorthDTO"},{"ref":"HistoryQueryOPCUANorthDTO"},{"ref":"HistoryQueryRESTNorthDTO"},{"ref":"HistoryQuerySFTPNorthDTO"}]}],"validators":{}}, + "type": {"dataType":"intersection","subSchemas":[{"ref":"BaseEntity"},{"ref":"HistoryQueryCommonDTO"},{"dataType":"union","subSchemas":[{"ref":"HistoryQueryADSSouthDTO"},{"ref":"HistoryQueryFolderScannerSouthDTO"},{"ref":"HistoryQueryFTPSouthDTO"},{"ref":"HistoryQueryInfluxDBSouthDTO"},{"ref":"HistoryQueryModbusSouthDTO"},{"ref":"HistoryQueryMQTTSouthDTO"},{"ref":"HistoryQueryMSSQLSouthDTO"},{"ref":"HistoryQueryMySQLSouthDTO"},{"ref":"HistoryQueryODBCSouthDTO"},{"ref":"HistoryQueryOIAnalyticsSouthDTO"},{"ref":"HistoryQueryOLEDBSouthDTO"},{"ref":"HistoryQueryOPCSouthDTO"},{"ref":"HistoryQueryOPCUASouthDTO"},{"ref":"HistoryQueryOracleSouthDTO"},{"ref":"HistoryQueryOsisoftPISouthDTO"},{"ref":"HistoryQueryPostgreSQLSouthDTO"},{"ref":"HistoryQueryRESTSouthDTO"},{"ref":"HistoryQuerySFTPSouthDTO"},{"ref":"HistoryQuerySQLiteSouthDTO"}]},{"dataType":"union","subSchemas":[{"ref":"HistoryQueryAmazonS3NorthDTO"},{"ref":"HistoryQueryAzureBlobNorthDTO"},{"ref":"HistoryQueryConsoleNorthDTO"},{"ref":"HistoryQueryFileWriterNorthDTO"},{"ref":"HistoryQueryModbusNorthDTO"},{"ref":"HistoryQueryMQTTNorthDTO"},{"ref":"HistoryQueryOIAnalyticsNorthDTO"},{"ref":"HistoryQueryOPCUANorthDTO"},{"ref":"HistoryQueryRESTNorthDTO"},{"ref":"HistoryQuerySFTPNorthDTO"}]}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "HistorySouthItemTestRequest": { @@ -7247,7 +7469,7 @@ const models: TsoaRoute.Models = { // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "HistoryQueryItemDTO": { "dataType": "refAlias", - "type": {"dataType":"union","subSchemas":[{"ref":"HistoryQueryADSItemDTO"},{"ref":"HistoryQueryFolderScannerItemDTO"},{"ref":"HistoryQueryFTPItemDTO"},{"ref":"HistoryQueryModbusItemDTO"},{"ref":"HistoryQueryMQTTItemDTO"},{"ref":"HistoryQueryMSSQLItemDTO"},{"ref":"HistoryQueryMySQLItemDTO"},{"ref":"HistoryQueryODBCItemDTO"},{"ref":"HistoryQueryOIAnalyticsItemDTO"},{"ref":"HistoryQueryOLEDBItemDTO"},{"ref":"HistoryQueryOPCItemDTO"},{"ref":"HistoryQueryOPCUAItemDTO"},{"ref":"HistoryQueryOracleItemDTO"},{"ref":"HistoryQueryOsisoftPIItemDTO"},{"ref":"HistoryQueryPostgreSQLItemDTO"},{"ref":"HistoryQueryRESTItemDTO"},{"ref":"HistoryQuerySFTPItemDTO"},{"ref":"HistoryQuerySQLiteItemDTO"}],"validators":{}}, + "type": {"dataType":"union","subSchemas":[{"ref":"HistoryQueryADSItemDTO"},{"ref":"HistoryQueryFolderScannerItemDTO"},{"ref":"HistoryQueryFTPItemDTO"},{"ref":"HistoryQueryInfluxDBItemDTO"},{"ref":"HistoryQueryModbusItemDTO"},{"ref":"HistoryQueryMQTTItemDTO"},{"ref":"HistoryQueryMSSQLItemDTO"},{"ref":"HistoryQueryMySQLItemDTO"},{"ref":"HistoryQueryODBCItemDTO"},{"ref":"HistoryQueryOIAnalyticsItemDTO"},{"ref":"HistoryQueryOLEDBItemDTO"},{"ref":"HistoryQueryOPCItemDTO"},{"ref":"HistoryQueryOPCUAItemDTO"},{"ref":"HistoryQueryOracleItemDTO"},{"ref":"HistoryQueryOsisoftPIItemDTO"},{"ref":"HistoryQueryPostgreSQLItemDTO"},{"ref":"HistoryQueryRESTItemDTO"},{"ref":"HistoryQuerySFTPItemDTO"},{"ref":"HistoryQuerySQLiteItemDTO"}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "Page_HistoryQueryItemDTO_": { diff --git a/documentation/docs/api/openapi.json b/documentation/docs/api/openapi.json index c8e6886fbf..c3f4bef574 100644 --- a/documentation/docs/api/openapi.json +++ b/documentation/docs/api/openapi.json @@ -1510,6 +1510,7 @@ "ads", "folder-scanner", "ftp", + "influxdb", "mssql", "mysql", "odbc", @@ -2978,6 +2979,377 @@ "type": "object", "additionalProperties": false }, + "SouthInfluxDBItemSettings": { + "properties": { + "query": { + "type": "string" + }, + "requestTimeout": { + "type": "number", + "format": "double" + } + }, + "required": [ + "query", + "requestTimeout" + ], + "type": "object", + "additionalProperties": false + }, + "SouthConnectorInfluxDBItemDTO": { + "description": "South connector item DTO for InfluxDB time series database.", + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the entity.", + "example": "entity123" + }, + "createdBy": { + "$ref": "#/components/schemas/UserInfo", + "description": "The user who created the entity." + }, + "updatedBy": { + "$ref": "#/components/schemas/UserInfo", + "description": "The user who last updated the entity." + }, + "createdAt": { + "$ref": "#/components/schemas/Instant", + "description": "The date and time when the entity was created.", + "example": "2023-10-31T12:34:56.789Z" + }, + "updatedAt": { + "$ref": "#/components/schemas/Instant", + "description": "The date and time when the entity was last updated.", + "example": "2023-10-31T13:45:00.123Z" + }, + "name": { + "type": "string", + "description": "The name of the item.", + "example": "Temperature Logs" + }, + "enabled": { + "type": "boolean", + "description": "Whether this item is enabled and should be collected.", + "example": true + }, + "settings": { + "$ref": "#/components/schemas/SouthInfluxDBItemSettings", + "description": "Item-specific settings for data collection." + }, + "scanMode": { + "allOf": [ + { + "$ref": "#/components/schemas/ScanModeDTO" + } + ], + "nullable": true, + "description": "The scan mode configuration for this item." + }, + "group": { + "allOf": [ + { + "$ref": "#/components/schemas/SouthItemGroupDTO" + } + ], + "nullable": true, + "description": "The group this item belongs to, if any." + }, + "syncWithGroup": { + "type": "boolean", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "example": true + }, + "maxReadInterval": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Maximum read interval in seconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", + "example": 3600 + }, + "readDelay": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", + "example": 200 + }, + "overlap": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", + "example": 1000 + } + }, + "required": [ + "id", + "createdBy", + "updatedBy", + "createdAt", + "updatedAt", + "name", + "enabled", + "settings", + "scanMode", + "group", + "syncWithGroup", + "maxReadInterval", + "readDelay", + "overlap" + ], + "type": "object", + "additionalProperties": false + }, + "SouthInfluxDBSettingsVersion": { + "type": "string", + "enum": [ + "1", + "2", + "3" + ] + }, + "SouthInfluxDBSettingsProtocol": { + "type": "string", + "enum": [ + "http", + "https" + ] + }, + "SouthInfluxDBSettings": { + "properties": { + "version": { + "$ref": "#/components/schemas/SouthInfluxDBSettingsVersion" + }, + "host": { + "type": "string" + }, + "port": { + "type": "number", + "format": "double" + }, + "protocol": { + "$ref": "#/components/schemas/SouthInfluxDBSettingsProtocol" + }, + "database": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "url": { + "type": "string" + }, + "token": { + "type": "string", + "nullable": true + }, + "organisation": { + "type": "string" + }, + "bucket": { + "type": "string" + } + }, + "required": [ + "version" + ], + "type": "object", + "additionalProperties": false + }, + "SouthConnectorItemTypedDTO_SouthInfluxDBItemSettings_": { + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the entity.", + "example": "entity123" + }, + "createdBy": { + "$ref": "#/components/schemas/UserInfo", + "description": "The user who created the entity." + }, + "updatedBy": { + "$ref": "#/components/schemas/UserInfo", + "description": "The user who last updated the entity." + }, + "createdAt": { + "$ref": "#/components/schemas/Instant", + "description": "The date and time when the entity was created.", + "example": "2023-10-31T12:34:56.789Z" + }, + "updatedAt": { + "$ref": "#/components/schemas/Instant", + "description": "The date and time when the entity was last updated.", + "example": "2023-10-31T13:45:00.123Z" + }, + "name": { + "type": "string", + "description": "The name of the item.", + "example": "Temperature Logs" + }, + "enabled": { + "type": "boolean", + "description": "Whether this item is enabled and should be collected.", + "example": true + }, + "settings": { + "$ref": "#/components/schemas/SouthInfluxDBItemSettings", + "description": "Item-specific settings for data collection." + }, + "scanMode": { + "allOf": [ + { + "$ref": "#/components/schemas/ScanModeDTO" + } + ], + "nullable": true, + "description": "The scan mode configuration for this item." + }, + "group": { + "allOf": [ + { + "$ref": "#/components/schemas/SouthItemGroupDTO" + } + ], + "nullable": true, + "description": "The group this item belongs to, if any." + }, + "syncWithGroup": { + "type": "boolean", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "example": true + }, + "maxReadInterval": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Maximum read interval in seconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", + "example": 3600 + }, + "readDelay": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", + "example": 200 + }, + "overlap": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", + "example": 1000 + } + }, + "required": [ + "id", + "createdBy", + "updatedBy", + "createdAt", + "updatedAt", + "name", + "enabled", + "settings", + "scanMode", + "group", + "syncWithGroup", + "maxReadInterval", + "readDelay", + "overlap" + ], + "type": "object", + "additionalProperties": false + }, + "SouthConnectorInfluxDBDTO": { + "description": "South connector configuration for InfluxDB time series database.", + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the entity.", + "example": "entity123" + }, + "createdBy": { + "$ref": "#/components/schemas/UserInfo", + "description": "The user who created the entity." + }, + "updatedBy": { + "$ref": "#/components/schemas/UserInfo", + "description": "The user who last updated the entity." + }, + "createdAt": { + "$ref": "#/components/schemas/Instant", + "description": "The date and time when the entity was created.", + "example": "2023-10-31T12:34:56.789Z" + }, + "updatedAt": { + "$ref": "#/components/schemas/Instant", + "description": "The date and time when the entity was last updated.", + "example": "2023-10-31T13:45:00.123Z" + }, + "name": { + "type": "string", + "description": "The name of the South connector.", + "example": "Production Data Files" + }, + "type": { + "type": "string", + "enum": [ + "influxdb" + ], + "nullable": false, + "description": "The type of the South connector.", + "example": "folder-scanner" + }, + "description": { + "type": "string", + "description": "Description of the South connector's purpose.", + "example": "Scans production data CSV files" + }, + "enabled": { + "type": "boolean", + "description": "Whether the South connector is enabled and active.", + "example": true + }, + "settings": { + "$ref": "#/components/schemas/SouthInfluxDBSettings", + "description": "Configuration settings specific to this South connector type." + }, + "items": { + "items": { + "$ref": "#/components/schemas/SouthConnectorInfluxDBItemDTO" + }, + "type": "array", + "description": "List of items (data points) configured for this connector." + }, + "groups": { + "items": { + "$ref": "#/components/schemas/SouthItemGroupDTO" + }, + "type": "array", + "description": "List of groups attached to this connector" + } + }, + "required": [ + "id", + "createdBy", + "updatedBy", + "createdAt", + "updatedAt", + "name", + "type", + "description", + "enabled", + "settings", + "items", + "groups" + ], + "type": "object", + "additionalProperties": false + }, "SouthModbusItemSettingsModbusType": { "type": "string", "enum": [ @@ -9739,6 +10111,9 @@ { "$ref": "#/components/schemas/SouthConnectorFTPDTO" }, + { + "$ref": "#/components/schemas/SouthConnectorInfluxDBDTO" + }, { "$ref": "#/components/schemas/SouthConnectorModbusDTO" }, @@ -10030,14 +10405,253 @@ }, "required": [ "id", - "standardSettings", - "historySettings" + "standardSettings", + "historySettings" + ], + "type": "object", + "additionalProperties": false + }, + "SouthConnectorADSCommandDTO": { + "description": "South connector command for Beckhoff ADS.", + "properties": { + "name": { + "type": "string", + "description": "The name of the South connector.", + "example": "Production Data Files" + }, + "type": { + "type": "string", + "enum": [ + "ads" + ], + "nullable": false, + "description": "The type of the South connector.", + "example": "folder-scanner" + }, + "description": { + "type": "string", + "description": "Description of the South connector's purpose.", + "example": "Scans production data CSV files" + }, + "enabled": { + "type": "boolean", + "description": "Whether the South connector should be enabled.", + "example": true + }, + "settings": { + "$ref": "#/components/schemas/SouthADSSettings", + "description": "Configuration settings specific to this South connector type." + }, + "items": { + "items": { + "$ref": "#/components/schemas/SouthConnectorADSItemCommandDTO" + }, + "type": "array", + "description": "List of items (data points) to configure for this connector." + }, + "groups": { + "items": { + "$ref": "#/components/schemas/SouthItemGroupCommandDTO" + }, + "type": "array", + "description": "List of groups used to gather items" + } + }, + "required": [ + "name", + "type", + "description", + "enabled", + "settings", + "items", + "groups" + ], + "type": "object", + "additionalProperties": false + }, + "SouthConnectorFolderScannerItemCommandDTO": { + "description": "South connector item command for the Folder Scanner.", + "properties": { + "id": { + "type": "string", + "nullable": true, + "description": "The ID of the item (null when creating a new item).", + "example": null + }, + "enabled": { + "type": "boolean", + "description": "Whether this item should be enabled.", + "example": true + }, + "name": { + "type": "string", + "description": "The name of the item.", + "example": "Temperature Logs" + }, + "settings": { + "$ref": "#/components/schemas/SouthFolderScannerItemSettings", + "description": "Item-specific settings for data collection." + }, + "scanModeId": { + "type": "string", + "nullable": true, + "description": "The ID of the scan mode to use for this item.\nNull when the scan mode should be determined by the system.", + "example": "periodic-5min" + }, + "scanModeName": { + "type": "string", + "nullable": true, + "description": "The name of the scan mode to use when ID is not available.\nNull when not specifying by name.", + "example": null + }, + "groupId": { + "type": "string", + "nullable": true, + "description": "The ID of the group this item belongs to.\nNull when the item is not in any group.", + "example": "group-123" + }, + "groupName": { + "type": "string", + "nullable": true, + "description": "The name of the group this item belongs to.\nUsed when importing from CSV where group IDs are not available.\nNull when the item is not in any group.", + "example": "Production Line A" + }, + "syncWithGroup": { + "type": "boolean", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields are inherited from the group.", + "example": true + }, + "maxReadInterval": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Maximum read interval in seconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", + "example": 3600 + }, + "readDelay": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", + "example": 200 + }, + "overlap": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", + "example": 1000 + } + }, + "required": [ + "id", + "enabled", + "name", + "settings", + "scanModeId", + "scanModeName", + "groupId", + "groupName", + "syncWithGroup", + "maxReadInterval", + "readDelay", + "overlap" + ], + "type": "object", + "additionalProperties": false + }, + "SouthConnectorItemCommandTypedDTO_SouthFolderScannerItemSettings_": { + "properties": { + "id": { + "type": "string", + "nullable": true, + "description": "The ID of the item (null when creating a new item).", + "example": null + }, + "enabled": { + "type": "boolean", + "description": "Whether this item should be enabled.", + "example": true + }, + "name": { + "type": "string", + "description": "The name of the item.", + "example": "Temperature Logs" + }, + "settings": { + "$ref": "#/components/schemas/SouthFolderScannerItemSettings", + "description": "Item-specific settings for data collection." + }, + "scanModeId": { + "type": "string", + "nullable": true, + "description": "The ID of the scan mode to use for this item.\nNull when the scan mode should be determined by the system.", + "example": "periodic-5min" + }, + "scanModeName": { + "type": "string", + "nullable": true, + "description": "The name of the scan mode to use when ID is not available.\nNull when not specifying by name.", + "example": null + }, + "groupId": { + "type": "string", + "nullable": true, + "description": "The ID of the group this item belongs to.\nNull when the item is not in any group.", + "example": "group-123" + }, + "groupName": { + "type": "string", + "nullable": true, + "description": "The name of the group this item belongs to.\nUsed when importing from CSV where group IDs are not available.\nNull when the item is not in any group.", + "example": "Production Line A" + }, + "syncWithGroup": { + "type": "boolean", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields are inherited from the group.", + "example": true + }, + "maxReadInterval": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Maximum read interval in seconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", + "example": 3600 + }, + "readDelay": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", + "example": 200 + }, + "overlap": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", + "example": 1000 + } + }, + "required": [ + "id", + "enabled", + "name", + "settings", + "scanModeId", + "scanModeName", + "groupId", + "groupName", + "syncWithGroup", + "maxReadInterval", + "readDelay", + "overlap" ], "type": "object", "additionalProperties": false }, - "SouthConnectorADSCommandDTO": { - "description": "South connector command for Beckhoff ADS.", + "SouthConnectorFolderScannerCommandDTO": { + "description": "South connector command for the Folder Scanner.", "properties": { "name": { "type": "string", @@ -10047,7 +10661,7 @@ "type": { "type": "string", "enum": [ - "ads" + "folder-scanner" ], "nullable": false, "description": "The type of the South connector.", @@ -10064,12 +10678,12 @@ "example": true }, "settings": { - "$ref": "#/components/schemas/SouthADSSettings", + "$ref": "#/components/schemas/SouthFolderScannerSettings", "description": "Configuration settings specific to this South connector type." }, "items": { "items": { - "$ref": "#/components/schemas/SouthConnectorADSItemCommandDTO" + "$ref": "#/components/schemas/SouthConnectorFolderScannerItemCommandDTO" }, "type": "array", "description": "List of items (data points) to configure for this connector." @@ -10094,8 +10708,8 @@ "type": "object", "additionalProperties": false }, - "SouthConnectorFolderScannerItemCommandDTO": { - "description": "South connector item command for the Folder Scanner.", + "SouthConnectorFTPItemCommandDTO": { + "description": "South connector item command for FTP file transfer.", "properties": { "id": { "type": "string", @@ -10114,7 +10728,7 @@ "example": "Temperature Logs" }, "settings": { - "$ref": "#/components/schemas/SouthFolderScannerItemSettings", + "$ref": "#/components/schemas/SouthFTPItemSettings", "description": "Item-specific settings for data collection." }, "scanModeId": { @@ -10185,7 +10799,7 @@ "type": "object", "additionalProperties": false }, - "SouthConnectorItemCommandTypedDTO_SouthFolderScannerItemSettings_": { + "SouthConnectorItemCommandTypedDTO_SouthFTPItemSettings_": { "properties": { "id": { "type": "string", @@ -10204,7 +10818,7 @@ "example": "Temperature Logs" }, "settings": { - "$ref": "#/components/schemas/SouthFolderScannerItemSettings", + "$ref": "#/components/schemas/SouthFTPItemSettings", "description": "Item-specific settings for data collection." }, "scanModeId": { @@ -10275,8 +10889,8 @@ "type": "object", "additionalProperties": false }, - "SouthConnectorFolderScannerCommandDTO": { - "description": "South connector command for the Folder Scanner.", + "SouthConnectorFTPCommandDTO": { + "description": "South connector command for FTP file transfer.", "properties": { "name": { "type": "string", @@ -10286,7 +10900,7 @@ "type": { "type": "string", "enum": [ - "folder-scanner" + "ftp" ], "nullable": false, "description": "The type of the South connector.", @@ -10303,12 +10917,12 @@ "example": true }, "settings": { - "$ref": "#/components/schemas/SouthFolderScannerSettings", + "$ref": "#/components/schemas/SouthFTPSettings", "description": "Configuration settings specific to this South connector type." }, "items": { "items": { - "$ref": "#/components/schemas/SouthConnectorFolderScannerItemCommandDTO" + "$ref": "#/components/schemas/SouthConnectorFTPItemCommandDTO" }, "type": "array", "description": "List of items (data points) to configure for this connector." @@ -10333,8 +10947,8 @@ "type": "object", "additionalProperties": false }, - "SouthConnectorFTPItemCommandDTO": { - "description": "South connector item command for FTP file transfer.", + "SouthConnectorInfluxDBItemCommandDTO": { + "description": "South connector item command for InfluxDB time series database.", "properties": { "id": { "type": "string", @@ -10353,7 +10967,7 @@ "example": "Temperature Logs" }, "settings": { - "$ref": "#/components/schemas/SouthFTPItemSettings", + "$ref": "#/components/schemas/SouthInfluxDBItemSettings", "description": "Item-specific settings for data collection." }, "scanModeId": { @@ -10424,7 +11038,7 @@ "type": "object", "additionalProperties": false }, - "SouthConnectorItemCommandTypedDTO_SouthFTPItemSettings_": { + "SouthConnectorItemCommandTypedDTO_SouthInfluxDBItemSettings_": { "properties": { "id": { "type": "string", @@ -10443,7 +11057,7 @@ "example": "Temperature Logs" }, "settings": { - "$ref": "#/components/schemas/SouthFTPItemSettings", + "$ref": "#/components/schemas/SouthInfluxDBItemSettings", "description": "Item-specific settings for data collection." }, "scanModeId": { @@ -10514,8 +11128,8 @@ "type": "object", "additionalProperties": false }, - "SouthConnectorFTPCommandDTO": { - "description": "South connector command for FTP file transfer.", + "SouthConnectorInfluxDBCommandDTO": { + "description": "South connector command for InfluxDB time series database.", "properties": { "name": { "type": "string", @@ -10525,7 +11139,7 @@ "type": { "type": "string", "enum": [ - "ftp" + "influxdb" ], "nullable": false, "description": "The type of the South connector.", @@ -10542,12 +11156,12 @@ "example": true }, "settings": { - "$ref": "#/components/schemas/SouthFTPSettings", + "$ref": "#/components/schemas/SouthInfluxDBSettings", "description": "Configuration settings specific to this South connector type." }, "items": { "items": { - "$ref": "#/components/schemas/SouthConnectorFTPItemCommandDTO" + "$ref": "#/components/schemas/SouthConnectorInfluxDBItemCommandDTO" }, "type": "array", "description": "List of items (data points) to configure for this connector." @@ -14168,6 +14782,9 @@ { "$ref": "#/components/schemas/SouthConnectorFTPCommandDTO" }, + { + "$ref": "#/components/schemas/SouthConnectorInfluxDBCommandDTO" + }, { "$ref": "#/components/schemas/SouthConnectorModbusCommandDTO" }, @@ -14254,6 +14871,9 @@ { "$ref": "#/components/schemas/SouthFTPSettings" }, + { + "$ref": "#/components/schemas/SouthInfluxDBSettings" + }, { "$ref": "#/components/schemas/SouthModbusSettings" }, @@ -14526,6 +15146,9 @@ { "$ref": "#/components/schemas/SouthFTPItemSettings" }, + { + "$ref": "#/components/schemas/SouthInfluxDBItemSettings" + }, { "$ref": "#/components/schemas/SouthModbusItemSettings" }, @@ -14632,6 +15255,9 @@ { "$ref": "#/components/schemas/SouthConnectorFTPItemDTO" }, + { + "$ref": "#/components/schemas/SouthConnectorInfluxDBItemDTO" + }, { "$ref": "#/components/schemas/SouthConnectorModbusItemDTO" }, @@ -14796,6 +15422,9 @@ { "$ref": "#/components/schemas/SouthConnectorFTPItemCommandDTO" }, + { + "$ref": "#/components/schemas/SouthConnectorInfluxDBItemCommandDTO" + }, { "$ref": "#/components/schemas/SouthConnectorModbusItemCommandDTO" }, @@ -21101,6 +21730,85 @@ "type": "object", "additionalProperties": false }, + "HistoryQueryInfluxDBItemCommandDTO": { + "description": "History query item command for InfluxDB time series database.", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "settings": { + "$ref": "#/components/schemas/SouthInfluxDBItemSettings" + } + }, + "required": [ + "id", + "name", + "enabled", + "settings" + ], + "type": "object", + "additionalProperties": false + }, + "HistoryQueryItemCommandTypedDTO_SouthInfluxDBItemSettings_": { + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "settings": { + "$ref": "#/components/schemas/SouthInfluxDBItemSettings" + } + }, + "required": [ + "id", + "name", + "enabled", + "settings" + ], + "type": "object", + "additionalProperties": false + }, + "HistoryQueryInfluxDBSouthCommandDTO": { + "description": "History query south command for InfluxDB time series database.", + "properties": { + "southType": { + "type": "string", + "enum": [ + "influxdb" + ], + "nullable": false + }, + "southSettings": { + "$ref": "#/components/schemas/SouthInfluxDBSettings" + }, + "items": { + "items": { + "$ref": "#/components/schemas/HistoryQueryInfluxDBItemCommandDTO" + }, + "type": "array" + } + }, + "required": [ + "southType", + "southSettings", + "items" + ], + "type": "object", + "additionalProperties": false + }, "HistoryQueryModbusItemCommandDTO": { "description": "History query item command for Modbus.", "properties": { @@ -22512,6 +23220,9 @@ { "$ref": "#/components/schemas/HistoryQueryFTPSouthCommandDTO" }, + { + "$ref": "#/components/schemas/HistoryQueryInfluxDBSouthCommandDTO" + }, { "$ref": "#/components/schemas/HistoryQueryModbusSouthCommandDTO" }, @@ -23026,6 +23737,9 @@ { "$ref": "#/components/schemas/HistoryQueryFTPItemCommandDTO" }, + { + "$ref": "#/components/schemas/HistoryQueryInfluxDBItemCommandDTO" + }, { "$ref": "#/components/schemas/HistoryQueryModbusItemCommandDTO" }, @@ -28034,6 +28748,131 @@ "type": "object", "additionalProperties": false }, + "HistoryQueryInfluxDBItemDTO": { + "description": "History query item DTO for InfluxDB time series database.", + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the entity.", + "example": "entity123" + }, + "createdBy": { + "$ref": "#/components/schemas/UserInfo", + "description": "The user who created the entity." + }, + "updatedBy": { + "$ref": "#/components/schemas/UserInfo", + "description": "The user who last updated the entity." + }, + "createdAt": { + "$ref": "#/components/schemas/Instant", + "description": "The date and time when the entity was created.", + "example": "2023-10-31T12:34:56.789Z" + }, + "updatedAt": { + "$ref": "#/components/schemas/Instant", + "description": "The date and time when the entity was last updated.", + "example": "2023-10-31T13:45:00.123Z" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "settings": { + "$ref": "#/components/schemas/SouthInfluxDBItemSettings" + } + }, + "required": [ + "id", + "createdBy", + "updatedBy", + "createdAt", + "updatedAt", + "name", + "enabled", + "settings" + ], + "type": "object", + "additionalProperties": false + }, + "HistoryQueryItemTypedDTO_SouthInfluxDBItemSettings_": { + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the entity.", + "example": "entity123" + }, + "createdBy": { + "$ref": "#/components/schemas/UserInfo", + "description": "The user who created the entity." + }, + "updatedBy": { + "$ref": "#/components/schemas/UserInfo", + "description": "The user who last updated the entity." + }, + "createdAt": { + "$ref": "#/components/schemas/Instant", + "description": "The date and time when the entity was created.", + "example": "2023-10-31T12:34:56.789Z" + }, + "updatedAt": { + "$ref": "#/components/schemas/Instant", + "description": "The date and time when the entity was last updated.", + "example": "2023-10-31T13:45:00.123Z" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "settings": { + "$ref": "#/components/schemas/SouthInfluxDBItemSettings" + } + }, + "required": [ + "id", + "createdBy", + "updatedBy", + "createdAt", + "updatedAt", + "name", + "enabled", + "settings" + ], + "type": "object", + "additionalProperties": false + }, + "HistoryQueryInfluxDBSouthDTO": { + "description": "History query south DTO for InfluxDB time series database.", + "properties": { + "southType": { + "type": "string", + "enum": [ + "influxdb" + ], + "nullable": false + }, + "southSettings": { + "$ref": "#/components/schemas/SouthInfluxDBSettings" + }, + "items": { + "items": { + "$ref": "#/components/schemas/HistoryQueryInfluxDBItemDTO" + }, + "type": "array" + } + }, + "required": [ + "southType", + "southSettings", + "items" + ], + "type": "object", + "additionalProperties": false + }, "HistoryQueryModbusItemDTO": { "description": "History query item DTO for Modbus.", "properties": { @@ -30138,6 +30977,9 @@ { "$ref": "#/components/schemas/HistoryQueryFTPSouthDTO" }, + { + "$ref": "#/components/schemas/HistoryQueryInfluxDBSouthDTO" + }, { "$ref": "#/components/schemas/HistoryQueryModbusSouthDTO" }, @@ -30277,6 +31119,9 @@ { "$ref": "#/components/schemas/HistoryQueryFTPItemDTO" }, + { + "$ref": "#/components/schemas/HistoryQueryInfluxDBItemDTO" + }, { "$ref": "#/components/schemas/HistoryQueryModbusItemDTO" }, diff --git a/documentation/docs/guide/south-connectors/influxdb.mdx b/documentation/docs/guide/south-connectors/influxdb.mdx new file mode 100644 index 0000000000..1cfb57f055 --- /dev/null +++ b/documentation/docs/guide/south-connectors/influxdb.mdx @@ -0,0 +1,168 @@ +--- +sidebar_position: 19 +--- + +import ThrottlingSettings from './_throttling-settings.mdx'; + +# InfluxDB + +Connect to and query [InfluxDB](https://www.influxdata.com/) time-series databases with OIBus. All +three major InfluxDB API versions are supported: InfluxDB v1 (InfluxQL), InfluxDB v2 (Flux), and +InfluxDB v3 (SQL / Flight SQL). + +:::info Technology Overview + +- Purpose-built time-series database by [InfluxData](https://www.influxdata.com/) +- History-only connector: queries a time range on each scan cycle +- Three distinct API versions, each with a different query language and authentication scheme + +::: + +## API Versions + +InfluxDB has gone through three major API revisions that are **not backwards-compatible**. Select the +version that matches your server before configuring the other settings. + +| Version | Query language | Authentication | Typical deployment | +| ------- | ----------------------------- | --------------------------------- | --------------------------------------- | +| **v1** | InfluxQL (SQL-like) | Username / Password | Self-hosted InfluxDB 1.x | +| **v2** | Flux (functional pipeline) | API Token + Organisation + Bucket | InfluxDB 2.x, InfluxDB Cloud (TSM) | +| **v3** | SQL (Apache Arrow Flight SQL) | API Token | InfluxDB 3.x, InfluxDB Cloud Serverless | + +## Specific Settings + +### Common + +| Setting | Description | +| ----------- | ------------------------------------------- | +| **Version** | InfluxDB API version to use: `1`, `2`, `3`. | + +### InfluxDB v1 + +| Setting | Description | Example Value | +| ------------ | ---------------------------------------------- | ------------- | +| **Host** | Hostname or IP address of the InfluxDB server. | `localhost` | +| **Port** | Server port. Default: `8086`. | `8086` | +| **Protocol** | Transport protocol: `http` or `https`. | `http` | +| **Database** | Name of the InfluxDB database to query. | `telegraf` | +| **Username** | Optional. Username for authentication. | `reader` | +| **Password** | Optional. Password for authentication. | `••••••••` | + +### InfluxDB v2 + +| Setting | Description | Example Value | +| ---------------- | ------------------------------------------------------- | ----------------------- | +| **URL** | Full URL of the InfluxDB v2 instance. | `http://localhost:8086` | +| **Token** | API token with read access to the target bucket. | `••••••••` | +| **Organisation** | Name of the InfluxDB organisation that owns the bucket. | `my-org` | +| **Bucket** | Name of the bucket to query. | `sensors` | + +### InfluxDB v3 + +| Setting | Description | Example Value | +| ------------ | ---------------------------------------------------------------- | ----------------------------------------------- | +| **URL** | Full URL of the InfluxDB v3 / Cloud Serverless endpoint. | `https://us-east-1-1.aws.cloud2.influxdata.com` | +| **Token** | API token with read access to the target database. | `••••••••` | +| **Database** | Name of the database (equivalent to a v1 database or v2 bucket). | `sensors` | + +:::tip Use a read-only token +Create a dedicated read-only token or user for OIBus. On InfluxDB v1, grant only `READ` privilege on +the target database; on v2/v3, scope the token to the specific bucket or database. +::: + +## Item Settings + +Each item represents one query executed against the InfluxDB server on each scan cycle. Items have +their own scan mode and throttling settings (there are no groups in the InfluxDB connector). + +| Setting | Description | Example Value | +| ------------- | ----------------------------------- | ------------- | +| **Scan mode** | Schedule used to trigger the query. | `Every 1 min` | + + + +### Query + +| Setting | Description | Example Value | +| ------------------- | ----------------------------------------------------------------------------- | ------------- | +| **Query** | The query to run. Supports `@StartTime` and `@EndTime` placeholders. | See below | +| **Request timeout** | Maximum execution time in milliseconds before the query is aborted. (v1 only) | `15000` | + +#### Query examples by version + +**v1 — InfluxQL** + +```sql +SELECT mean("temperature"), mean("pressure") +FROM "sensors" +WHERE time > '@StartTime' AND time <= '@EndTime' +GROUP BY time(1m), "device_id" +``` + +**v2 — Flux** + +```flux +from(bucket: "sensors") + |> range(start: @StartTime, stop: @EndTime) + |> filter(fn: (r) => r["_measurement"] == "temperature") + |> aggregateWindow(every: 1m, fn: mean, createEmpty: false) +``` + +**v3 — SQL** + +```sql +SELECT time, device_id, temperature, pressure +FROM sensors +WHERE time > '@StartTime' AND time <= '@EndTime' +ORDER BY time +``` + +## Time Variables + +The following placeholders can be used anywhere in the query string and are replaced by OIBus before +sending the request to the server: + +| Variable | Value injected | Example | +| ------------ | ----------------------------------------------------------------------------------------------------------------- | -------------------------- | +| `@StartTime` | Start of the current query interval. Initialised to the first scan time, then advanced by the tracking mechanism. | `2024-01-15T10:00:00.000Z` | +| `@EndTime` | End of the current query interval, capped to the current time or the sub-interval end. | `2024-01-15T11:00:00.000Z` | + +The substitution is a **plain text replacement** — wrap the placeholders in quotes if the query +language requires string literals (InfluxQL, SQL). In Flux, `range(start:, stop:)` accepts RFC 3339 +strings directly without surrounding quotes. + +:::info Query splitting +When a large time range is requested, OIBus splits it into smaller sub-intervals based on the **Max +read interval** throttling setting. Each sub-interval gets its own `@StartTime` / `@EndTime` pair. +::: + +## Timestamp Tracking + +After each successful query, OIBus automatically extracts the maximum timestamp from the result set +and uses it as `@StartTime` of the next scan cycle. No configuration is required. + +The timestamp column name is determined by the API version: + +| Version | Timestamp column | Format | +| ------- | ---------------- | ------------------------------------------------------------------------------ | +| v1 | `time` | ISO 8601 string — OIBus normalizes the raw `INanoDate` returned by the client | +| v2 | `_time` | RFC 3339 string returned directly by the Flux query API | +| v3 | `time` | ISO 8601 string returned by the Arrow Flight client | + +If no results are returned, `@StartTime` is not advanced and the next scan retries the same interval. + +## Data Flow + +1. OIBus substitutes `@StartTime` and `@EndTime` into the query string. +2. The query is sent to the InfluxDB server via the appropriate client library for the configured version. +3. Results are collected as a JSON array of row objects. +4. OIBus extracts the maximum value of the timestamp column (`time` for v1/v3, `_time` for v2) and + stores it as the `@StartTime` for the next scan cycle. +5. The JSON array is written to a file and forwarded to configured North connectors as a raw JSON + payload. + +:::note Output format +Unlike SQL connectors (which produce CSV files), the InfluxDB connector always outputs a **JSON +file**. Use a North connector that accepts raw file payloads — OIAnalytics, Azure Blob, S3, File +Writer — or add a JSON-to-CSV transformer if your downstream system requires CSV. +::: diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 336a7a40cd..b66c0ad72d 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -299,6 +299,7 @@ "ads": "ADS - TwinCAT®", "folder-scanner": "Folder scanner", "ftp": "FTP", + "influxdb": "InfluxDB", "modbus": "Modbus", "mqtt": "MQTT", "mssql": "Microsoft SQL Server™", @@ -319,6 +320,7 @@ "ads": "The ADS protocol used in TwinCAT® systems", "folder-scanner": "Read files from a local or remote folder", "ftp": "Read files from a remote FTP server", + "influxdb": "Query InfluxDB time series databases (v1, v2, v3)", "modbus": "Access Modbus registers on a PLC", "mqtt": "Subscribe to MQTT broker topics", "mssql": "Query Microsoft SQL Server™ databases", @@ -1547,6 +1549,28 @@ "username": "Username", "password": "Password" }, + "influxdb": { + "version": { + "title": "Version", + "1": "v1 (InfluxDB 1.x – InfluxQL)", + "2": "v2 (InfluxDB 2.x – Flux)", + "3": "v3 (InfluxDB 3.x – SQL)" + }, + "host": "Host", + "port": "Port", + "protocol": { + "title": "Protocol", + "http": "HTTP", + "https": "HTTPS" + }, + "database": "Database", + "username": "Username", + "password": "Password", + "url": "URL", + "token": "API token", + "organisation": "Organisation", + "bucket": "Bucket" + }, "odbc": { "remote-agent": "Use remote agent", "agent-url": "Remote agent URL", @@ -1875,6 +1899,26 @@ "output-timezone": "Output timezone" } }, + "influxdb": { + "query": "Query", + "request-timeout": "Request timeout", + "track-max-instant": "Track max instant", + "date-time": { + "track-instant": "Track instant", + "json-path": "JSON path", + "type": { + "title": "Type", + "iso-string": "ISO String", + "unix-epoch": "UNIX epoch (s)", + "unix-epoch-ms": "UNIX epoch (ms)", + "string": "String" + }, + "timezone": "Timezone", + "format": "Format", + "locale": "Locale" + }, + "date-time-input": "Date time input" + }, "odbc": { "query": "Query", "date-time-fields": { From c13a43cbbbb6ce64a6c067249fba768deaf2c669 Mon Sep 17 00:00:00 2001 From: kbalinthunor Date: Fri, 29 May 2026 09:47:33 +0300 Subject: [PATCH 02/24] feat(engine): configure default admin credentials and port at first run --- backend/src/index.ts | 10 +- backend/src/model/oibus-init-config.model.ts | 5 + .../config/engine.repository.spec.ts | 28 ++++- .../repository/config/engine.repository.ts | 5 +- .../repository/config/user.repository.spec.ts | 43 ++++++- .../src/repository/config/user.repository.ts | 10 +- backend/src/service/repository.service.ts | 8 +- backend/src/service/utils.spec.ts | 66 +++++++++++ backend/src/service/utils.ts | 45 +++++++- build/deps/linux-arm64/oibus-setup.sh | 26 +++++ build/deps/linux-x64/oibus-setup.sh | 26 +++++ build/deps/win-x64/install.bat | 31 +++++ build/deps/win-x64/setup.iss | 106 ++++++++++++++++++ build/docker/Dockerfile | 8 ++ 14 files changed, 404 insertions(+), 13 deletions(-) create mode 100644 backend/src/model/oibus-init-config.model.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 696e810959..210d7e617d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,9 +1,10 @@ import path from 'node:path'; +import { rmSync } from 'node:fs'; import WebServer from './web-server/web-server'; import LoggerService from './service/logger/logger.service'; import { encryptionService } from './service/encryption.service'; -import { createFolder, getCommandLineArguments, getOIBusInfo } from './service/utils'; +import { createFolder, getCommandLineArguments, getOIBusInfo, readInitConfig, INIT_CONFIG_FILENAME } from './service/utils'; import RepositoryService from './service/repository.service'; import NorthService from './service/north.service'; import SouthService from './service/south.service'; @@ -64,13 +65,16 @@ const CERT_FOLDER = 'certs'; await migrateCrypto(path.resolve(CRYPTO_DATABASE)); await migrateSouthCache(path.resolve(CACHE_FOLDER, CACHE_DATABASE)); + const initConfig = readInitConfig(); + const repositoryService = new RepositoryService( path.resolve(CONFIG_DATABASE), path.resolve(LOG_FOLDER_NAME, LOG_DB_NAME), path.resolve(LOG_FOLDER_NAME, METRICS_DB_NAME), path.resolve(CRYPTO_DATABASE), path.resolve(CACHE_FOLDER, CACHE_DATABASE), - launcherVersion + launcherVersion, + initConfig ); const oibusSettings = repositoryService.engineRepository.get(); @@ -299,6 +303,8 @@ const CERT_FOLDER = 'certs'; process.exit(); }); + rmSync(INIT_CONFIG_FILENAME, { force: true }); + const updatedOIBusSettings = repositoryService.engineRepository.get()!; loggerService.logger!.info(`OIBus fully started: ${JSON.stringify(getOIBusInfo(updatedOIBusSettings))}`); console.info(`OIBus fully started: ${JSON.stringify(getOIBusInfo(updatedOIBusSettings))}`); diff --git a/backend/src/model/oibus-init-config.model.ts b/backend/src/model/oibus-init-config.model.ts new file mode 100644 index 0000000000..988ca3bfe3 --- /dev/null +++ b/backend/src/model/oibus-init-config.model.ts @@ -0,0 +1,5 @@ +export interface OIBusInitConfig { + adminUsername?: string; + adminPassword?: string; + port?: number; +} diff --git a/backend/src/repository/config/engine.repository.spec.ts b/backend/src/repository/config/engine.repository.spec.ts index 1d83653693..231ef32893 100644 --- a/backend/src/repository/config/engine.repository.spec.ts +++ b/backend/src/repository/config/engine.repository.spec.ts @@ -62,7 +62,7 @@ describe('EngineRepository with empty database', () => { }); describe('Engine', () => { - it('should properly init engine settings table', () => { + it('should properly init engine settings table with default port', () => { const repository = new EngineRepository(database, '3.5.0'); const result = stripAuditFields(repository.get()); @@ -82,6 +82,12 @@ describe('EngineRepository with empty database', () => { oia: { level: 'silent', interval: 10 } }); }); + + it('should use a custom port when provided', () => { + const repository = new EngineRepository(database, '3.5.0', 3000); + // createDefault is a no-op because the record already exists from the previous test + assert.strictEqual(repository.get()!.port, 2223); + }); }); describe('User', () => { @@ -117,5 +123,25 @@ describe('EngineRepository with empty database', () => { mock.restoreAll(); }); + + }); +}); + +describe('EngineRepository with custom default port', () => { + const CUSTOM_PORT_DB_PATH = 'src/tests/test-config-engine-custom.db'; + let db: Database; + + before(async () => { + db = await initDatabase('config', false, CUSTOM_PORT_DB_PATH); + }); + + after(async () => { + db.close(); + await emptyDatabase('config', CUSTOM_PORT_DB_PATH); + }); + + it('should seed engine settings with a custom port', () => { + const repository = new EngineRepository(db, '3.5.0', 3000); + assert.strictEqual(repository.get()!.port, 3000); }); }); diff --git a/backend/src/repository/config/engine.repository.ts b/backend/src/repository/config/engine.repository.ts index e1b4b04e71..71d4fbd457 100644 --- a/backend/src/repository/config/engine.repository.ts +++ b/backend/src/repository/config/engine.repository.ts @@ -48,9 +48,10 @@ const DEFAULT_ENGINE_SETTINGS: Omit< export default class EngineRepository { constructor( private readonly database: Database, - launcherVersion: string + launcherVersion: string, + defaultPort = DEFAULT_ENGINE_SETTINGS.port ) { - this.createDefault(DEFAULT_ENGINE_SETTINGS, launcherVersion); + this.createDefault({ ...DEFAULT_ENGINE_SETTINGS, port: defaultPort }, launcherVersion); } get(): EngineSettings | null { diff --git a/backend/src/repository/config/user.repository.spec.ts b/backend/src/repository/config/user.repository.spec.ts index 70e07510d4..4cfa844365 100644 --- a/backend/src/repository/config/user.repository.spec.ts +++ b/backend/src/repository/config/user.repository.spec.ts @@ -2,7 +2,7 @@ import { before, after, beforeEach, afterEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { mock } from 'node:test'; import { Database } from 'better-sqlite3'; -import { emptyDatabase, initDatabase, stripAuditFields } from '../../tests/utils/test-utils'; +import { emptyDatabase, flushPromises, initDatabase, stripAuditFields } from '../../tests/utils/test-utils'; import testData from '../../tests/utils/test-data'; import UserRepository from './user.repository'; import { createPageFromArray } from '../../../shared/model/types'; @@ -90,3 +90,44 @@ describe('UserRepository', () => { assert.strictEqual(repository.findById(testData.users.list[1].id), null); }); }); + +describe('UserRepository with custom default credentials', () => { + const CUSTOM_DB_PATH = 'src/tests/test-config-user-custom.db'; + let db: Database; + + before(async () => { + db = await initDatabase('config', false, CUSTOM_DB_PATH); + }); + + after(async () => { + db.close(); + await emptyDatabase('config', CUSTOM_DB_PATH); + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('should create a default user with custom login and password', async () => { + mock.method(argon2, 'hash', async (password: string) => password); + + const repository = new UserRepository(db, 'customuser', 'custompass'); + await flushPromises(); + + const users = repository.list(); + assert.strictEqual(users.length, 1); + assert.strictEqual(users[0].login, 'customuser'); + assert.strictEqual(repository.getHashedPasswordByLogin('customuser'), 'custompass'); + }); + + it('should not recreate the user when the same login already exists', async () => { + mock.method(argon2, 'hash', async (password: string) => password); + + new UserRepository(db, 'customuser', 'anotherpass'); + await flushPromises(); + + // Still only one user; createDefault is a no-op when the same login is already present + const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }; + assert.strictEqual(row.count, 1); + }); +}); diff --git a/backend/src/repository/config/user.repository.ts b/backend/src/repository/config/user.repository.ts index cd5d9da140..215592bd74 100644 --- a/backend/src/repository/config/user.repository.ts +++ b/backend/src/repository/config/user.repository.ts @@ -20,7 +20,11 @@ const DEFAULT_USER: Omit { + this.create({ ...DEFAULT_USER, login: this.defaultLogin }, this.defaultPassword, 'system').catch(err => { console.error(err.message); }); } diff --git a/backend/src/service/repository.service.ts b/backend/src/service/repository.service.ts index 91b2b45892..9a8b7944a9 100644 --- a/backend/src/service/repository.service.ts +++ b/backend/src/service/repository.service.ts @@ -1,5 +1,6 @@ import Database from 'better-sqlite3'; +import { OIBusInitConfig } from '../model/oibus-init-config.model'; import EngineRepository from '../repository/config/engine.repository'; import IpFilterRepository from '../repository/config/ip-filter.repository'; import ScanModeRepository from '../repository/config/scan-mode.repository'; @@ -55,7 +56,8 @@ export default class RepositoryService { metricsDatabasePath: string, cryptoDatabasePath: string, cacheDatabasePath: string, - launcherVersion: string + launcherVersion: string, + initConfig: OIBusInitConfig = {} ) { this.oibusDatabase = Database(oibusDatabasePath); this.metricsDatabase = Database(metricsDatabasePath); @@ -77,12 +79,12 @@ export default class RepositoryService { this._ipFilterRepository = new IpFilterRepository(this.oibusDatabase); this._scanModeRepository = new ScanModeRepository(this.oibusDatabase); this._certificateRepository = new CertificateRepository(this.oibusDatabase); - this._engineRepository = new EngineRepository(this.oibusDatabase, launcherVersion); + this._engineRepository = new EngineRepository(this.oibusDatabase, launcherVersion, initConfig.port); this._northConnectorRepository = new NorthConnectorRepository(this.oibusDatabase); this._southConnectorRepository = new SouthConnectorRepository(this.oibusDatabase); this._southItemGroupRepository = new SouthItemGroupRepository(this.oibusDatabase); this._historyQueryRepository = new HistoryQueryRepository(this.oibusDatabase); - this._userRepository = new UserRepository(this.oibusDatabase); + this._userRepository = new UserRepository(this.oibusDatabase, initConfig.adminUsername, initConfig.adminPassword); this._oianalyticsRegistrationRepository = new OIAnalyticsRegistrationRepository(this.oibusDatabase); this._oianalyticsCommandRepository = new OIAnalyticsCommandRepository(this.oibusDatabase); this._oianalyticsMessageRepository = new OIAnalyticsMessageRepository(this.oibusDatabase); diff --git a/backend/src/service/utils.spec.ts b/backend/src/service/utils.spec.ts index d36896de01..eb6328cfc0 100644 --- a/backend/src/service/utils.spec.ts +++ b/backend/src/service/utils.spec.ts @@ -1971,4 +1971,70 @@ describe('Service utils', () => { assert.deepStrictEqual(result, [[grouped1, grouped2], [ungrouped]]); }); }); + + describe('readInitConfig', () => { + const envVarsToClean = ['ADMIN_USERNAME', 'ADMIN_PASSWORD', 'DEFAULT_PORT', 'ADMIN_USERNAME_FILE', 'ADMIN_PASSWORD_FILE']; + + afterEach(() => { + mock.restoreAll(); + for (const key of envVarsToClean) { + delete process.env[key]; + } + }); + + it('should read credentials and port from a valid init file', () => { + mock.method(fsSync, 'existsSync', () => true); + mock.method(fsSync, 'readFileSync', () => '{"adminUsername":"alice","adminPassword":"s3cr3t","port":3000}'); + + const result = utils.readInitConfig(); + assert.deepStrictEqual(result, { adminUsername: 'alice', adminPassword: 's3cr3t', port: 3000 }); + }); + + it('should fall back to env vars when init file does not exist', () => { + mock.method(fsSync, 'existsSync', () => false); + process.env.ADMIN_USERNAME = 'bob'; + process.env.ADMIN_PASSWORD = 'secret'; + process.env.DEFAULT_PORT = '4000'; + + const result = utils.readInitConfig(); + assert.deepStrictEqual(result, { adminUsername: 'bob', adminPassword: 'secret', port: 4000 }); + }); + + it('should fall back to env vars when init file is malformed JSON', () => { + mock.method(fsSync, 'existsSync', () => true); + mock.method(fsSync, 'readFileSync', () => '{invalid json}'); + process.env.ADMIN_USERNAME = 'charlie'; + + const result = utils.readInitConfig(); + assert.strictEqual(result.adminUsername, 'charlie'); + }); + + it('should return undefined port when DEFAULT_PORT is not a valid number', () => { + mock.method(fsSync, 'existsSync', () => false); + process.env.DEFAULT_PORT = 'abc'; + + const result = utils.readInitConfig(); + assert.strictEqual(result.port, undefined); + }); + + it('should read credentials from Docker secret files when _FILE env vars are set', () => { + mock.method(fsSync, 'existsSync', () => false); + mock.method(fsSync, 'readFileSync', (filePath: string) => { + if (filePath === '/run/secrets/admin_pass') return 'dockersecret\n'; + throw new Error(`unexpected path: ${filePath}`); + }); + process.env.ADMIN_PASSWORD_FILE = '/run/secrets/admin_pass'; + + const result = utils.readInitConfig(); + assert.strictEqual(result.adminPassword, 'dockersecret'); + }); + + it('should ignore fields with wrong types in the init file', () => { + mock.method(fsSync, 'existsSync', () => true); + mock.method(fsSync, 'readFileSync', () => '{"adminUsername":123,"adminPassword":null,"port":"not-a-number"}'); + + const result = utils.readInitConfig(); + assert.deepStrictEqual(result, { adminUsername: undefined, adminPassword: undefined, port: undefined }); + }); + }); }); diff --git a/backend/src/service/utils.ts b/backend/src/service/utils.ts index 5367afecee..09da02d12a 100644 --- a/backend/src/service/utils.ts +++ b/backend/src/service/utils.ts @@ -1,5 +1,5 @@ import fs from 'node:fs/promises'; -import { createReadStream, createWriteStream } from 'node:fs'; +import { createReadStream, createWriteStream, readFileSync, existsSync } from 'node:fs'; import { pipeline } from 'node:stream/promises'; import zlib from 'node:zlib'; import path from 'node:path'; @@ -9,6 +9,7 @@ import { DateTime } from 'luxon'; import unzipper from 'unzipper'; import { CsvCharacter, DateTimeType, Instant, Interval, SerializationSettings, Timezone } from '../../shared/model/types'; +import { OIBusInitConfig } from '../model/oibus-init-config.model'; import csv from 'papaparse'; import { EngineSettingsDTO, OIBusContent, OIBusInfo } from '../../shared/model/engine.model'; import os from 'node:os'; @@ -53,6 +54,48 @@ export const getCommandLineArguments = () => { }; }; +export const INIT_CONFIG_FILENAME = 'oibus.init.json'; + +const readSecretOrEnv = (fileEnvVar: string, directEnvVar: string): string | undefined => { + const filePath = process.env[fileEnvVar]; + if (filePath) { + try { + return readFileSync(filePath, 'utf8').trim(); + } catch (err) { + console.warn(`Failed to read secret from ${filePath}: ${(err as Error).message}`); + } + } + return process.env[directEnvVar] || undefined; +}; + +/** + * Read the one-time init config from file (installer path) or env vars (Docker path). + * Priority: oibus.init.json > ADMIN_USERNAME_FILE/ADMIN_PASSWORD_FILE > ADMIN_USERNAME/ADMIN_PASSWORD/DEFAULT_PORT > undefined. + */ +export const readInitConfig = (): OIBusInitConfig => { + if (existsSync(INIT_CONFIG_FILENAME)) { + try { + const raw = readFileSync(INIT_CONFIG_FILENAME, 'utf8'); + const parsed = JSON.parse(raw) as Record; + return { + adminUsername: typeof parsed.adminUsername === 'string' ? parsed.adminUsername : undefined, + adminPassword: typeof parsed.adminPassword === 'string' ? parsed.adminPassword : undefined, + port: typeof parsed.port === 'number' ? parsed.port : undefined + }; + } catch (err) { + console.warn(`Failed to parse ${INIT_CONFIG_FILENAME}: ${(err as Error).message}; falling back to env vars / defaults`); + } + } + + const portEnv = process.env.DEFAULT_PORT; + const parsedPort = portEnv ? parseInt(portEnv, 10) : NaN; + return { + adminUsername: readSecretOrEnv('ADMIN_USERNAME_FILE', 'ADMIN_USERNAME'), + adminPassword: readSecretOrEnv('ADMIN_PASSWORD_FILE', 'ADMIN_PASSWORD'), + port: Number.isNaN(parsedPort) ? undefined : parsedPort + }; +}; + /** * Method to return a delayed promise. */ diff --git a/build/deps/linux-arm64/oibus-setup.sh b/build/deps/linux-arm64/oibus-setup.sh index 6f6a803cab..9470ae92e9 100755 --- a/build/deps/linux-arm64/oibus-setup.sh +++ b/build/deps/linux-arm64/oibus-setup.sh @@ -71,6 +71,32 @@ if [[ -f "$my_data_directory/oibus.db" ]]; then fi fi +# Escape a string for safe embedding in a JSON value (backslash then double-quote). +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + printf '%s' "$s" +} + +# If no existing database, prompt for initial admin credentials and port +if [[ ! -f "$conf_path/oibus.db" ]]; then + read -rp "Enter the admin username (default: admin): " admin_username + admin_username="${admin_username:=admin}" + + read -rsp "Enter the admin password (default: pass): " admin_password + echo + admin_password="${admin_password:=pass}" + + read -rp "Enter the port OIBus will listen on (default: 2223): " oibus_port + oibus_port="${oibus_port:=2223}" + + printf '{"adminUsername":"%s","adminPassword":"%s","port":%s}' \ + "$(json_escape "$admin_username")" "$(json_escape "$admin_password")" "$oibus_port" \ + > "$conf_path/oibus.init.json" + chmod 600 "$conf_path/oibus.init.json" +fi + # Stop OIBus if already installed and running if [[ -f "/etc/systemd/system/oibus.service" ]]; then echo 'Stopping OIBus service...' diff --git a/build/deps/linux-x64/oibus-setup.sh b/build/deps/linux-x64/oibus-setup.sh index 6f6a803cab..9470ae92e9 100755 --- a/build/deps/linux-x64/oibus-setup.sh +++ b/build/deps/linux-x64/oibus-setup.sh @@ -71,6 +71,32 @@ if [[ -f "$my_data_directory/oibus.db" ]]; then fi fi +# Escape a string for safe embedding in a JSON value (backslash then double-quote). +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + printf '%s' "$s" +} + +# If no existing database, prompt for initial admin credentials and port +if [[ ! -f "$conf_path/oibus.db" ]]; then + read -rp "Enter the admin username (default: admin): " admin_username + admin_username="${admin_username:=admin}" + + read -rsp "Enter the admin password (default: pass): " admin_password + echo + admin_password="${admin_password:=pass}" + + read -rp "Enter the port OIBus will listen on (default: 2223): " oibus_port + oibus_port="${oibus_port:=2223}" + + printf '{"adminUsername":"%s","adminPassword":"%s","port":%s}' \ + "$(json_escape "$admin_username")" "$(json_escape "$admin_password")" "$oibus_port" \ + > "$conf_path/oibus.init.json" + chmod 600 "$conf_path/oibus.init.json" +fi + # Stop OIBus if already installed and running if [[ -f "/etc/systemd/system/oibus.service" ]]; then echo 'Stopping OIBus service...' diff --git a/build/deps/win-x64/install.bat b/build/deps/win-x64/install.bat index 0caa63383d..50662b853b 100644 --- a/build/deps/win-x64/install.bat +++ b/build/deps/win-x64/install.bat @@ -11,6 +11,9 @@ if NOT %errorLevel% == 0 ( set DATA_FOLDER_PATH= set SERVICE_NAME="OIBus" +set ADMIN_USERNAME= +set ADMIN_PASSWORD= +set OIBUS_PORT= :PARSE_PARAMETERS if "%~1"=="" goto PARSE_PARAMETERS_DONE @@ -26,6 +29,24 @@ if "%~1"=="-c" ( shift goto PARSE_PARAMETERS ) +if "%~1"=="-u" ( + set "ADMIN_USERNAME=%~2" + shift + shift + goto PARSE_PARAMETERS +) +if "%~1"=="-p" ( + set "ADMIN_PASSWORD=%~2" + shift + shift + goto PARSE_PARAMETERS +) +if "%~1"=="-port" ( + set "OIBUS_PORT=%~2" + shift + shift + goto PARSE_PARAMETERS +) shift goto PARSE_PARAMETERS :PARSE_PARAMETERS_DONE @@ -40,6 +61,16 @@ if "%DATA_FOLDER_PATH%"=="" ( if not exist "%DATA_FOLDER_PATH%" mkdir %DATA_FOLDER_PATH% +if not exist "%DATA_FOLDER_PATH%\oibus.db" ( + if "%ADMIN_USERNAME%"=="" set /P ADMIN_USERNAME=Enter the admin username (default: admin): + if "%ADMIN_USERNAME%"=="" set ADMIN_USERNAME=admin + if "%ADMIN_PASSWORD%"=="" set /P ADMIN_PASSWORD=Enter the admin password (default: pass): + if "%ADMIN_PASSWORD%"=="" set ADMIN_PASSWORD=pass + if "%OIBUS_PORT%"=="" set /P OIBUS_PORT=Enter the port OIBus will listen on (default: 2223): + if "%OIBUS_PORT%"=="" set OIBUS_PORT=2223 + powershell -Command "$json = [pscustomobject]@{adminUsername=$env:ADMIN_USERNAME; adminPassword=$env:ADMIN_PASSWORD; port=[int]$env:OIBUS_PORT} | ConvertTo-Json -Compress; [IO.File]::WriteAllText([IO.Path]::Combine($env:DATA_FOLDER_PATH, 'oibus.init.json'), $json)" +) + echo Stopping %SERVICE_NAME% service... nssm.exe stop %SERVICE_NAME% >nul 2>&1 diff --git a/build/deps/win-x64/setup.iss b/build/deps/win-x64/setup.iss index 8104fcb945..5853933db5 100644 --- a/build/deps/win-x64/setup.iss +++ b/build/deps/win-x64/setup.iss @@ -104,9 +104,17 @@ var ServiceNameEdit: TNewEdit; DataDirEdit: TNewEdit; + AdminPage: TWizardPage; + AdminUsernameEdit: TNewEdit; + AdminPasswordEdit: TNewEdit; + PortEdit: TNewEdit; + // Global variables to store user choices FinalServiceName: String; FinalDataDir: String; + FinalAdminUsername: String; + FinalAdminPassword: String; + FinalPort: String; // --- Getter Functions for [Registry] --- @@ -194,6 +202,84 @@ begin DataDirEdit.Top := btnBrowse.Top; DataDirEdit.Left := 0; DataDirEdit.Width := ConfigPage.SurfaceWidth - btnBrowse.Width - ScaleX(10); + + // Create Admin Credentials Page + AdminPage := CreateCustomPage(ConfigPage.ID, 'Admin Credentials', 'Set the initial admin username, password and port'); + + var lblAdminUser: TNewStaticText; + lblAdminUser := TNewStaticText.Create(AdminPage); + lblAdminUser.Parent := AdminPage.Surface; + lblAdminUser.Caption := 'Admin username (default: admin):'; + lblAdminUser.Top := 0; + lblAdminUser.Left := 0; + lblAdminUser.Width := AdminPage.SurfaceWidth; + + AdminUsernameEdit := TNewEdit.Create(AdminPage); + AdminUsernameEdit.Parent := AdminPage.Surface; + AdminUsernameEdit.Text := 'admin'; + AdminUsernameEdit.Top := lblAdminUser.Top + lblAdminUser.Height + ScaleY(8); + AdminUsernameEdit.Left := 0; + AdminUsernameEdit.Width := AdminPage.SurfaceWidth; + + var lblAdminPass: TNewStaticText; + lblAdminPass := TNewStaticText.Create(AdminPage); + lblAdminPass.Parent := AdminPage.Surface; + lblAdminPass.Caption := 'Admin password (default: pass):'; + lblAdminPass.Top := AdminUsernameEdit.Top + AdminUsernameEdit.Height + ScaleY(20); + lblAdminPass.Left := 0; + lblAdminPass.Width := AdminPage.SurfaceWidth; + + AdminPasswordEdit := TNewEdit.Create(AdminPage); + AdminPasswordEdit.Parent := AdminPage.Surface; + AdminPasswordEdit.PasswordChar := '*'; + AdminPasswordEdit.Text := 'pass'; + AdminPasswordEdit.Top := lblAdminPass.Top + lblAdminPass.Height + ScaleY(8); + AdminPasswordEdit.Left := 0; + AdminPasswordEdit.Width := AdminPage.SurfaceWidth; + + var lblPort: TNewStaticText; + lblPort := TNewStaticText.Create(AdminPage); + lblPort.Parent := AdminPage.Surface; + lblPort.Caption := 'Port OIBus will listen on (default: 2223):'; + lblPort.Top := AdminPasswordEdit.Top + AdminPasswordEdit.Height + ScaleY(20); + lblPort.Left := 0; + lblPort.Width := AdminPage.SurfaceWidth; + + PortEdit := TNewEdit.Create(AdminPage); + PortEdit.Parent := AdminPage.Surface; + PortEdit.Text := '2223'; + PortEdit.Top := lblPort.Top + lblPort.Height + ScaleY(8); + PortEdit.Left := 0; + PortEdit.Width := AdminPage.SurfaceWidth; +end; + +// Escape a string for safe embedding in a JSON value. +function EscapeJsonString(const S: String): String; +var + I: Integer; + C: Char; + Escaped: String; +begin + Escaped := ''; + for I := 1 to Length(S) do + begin + C := S[I]; + if C = '\' then + Escaped := Escaped + '\\' + else if C = '"' then + Escaped := Escaped + '\"' + else + Escaped := Escaped + C; + end; + Result := Escaped; +end; + +// Hide the AdminPage when upgrading over an existing database. +function ShouldSkipPage(PageID: Integer): Boolean; +begin + Result := False; + if (PageID = AdminPage.ID) and ConfExists and not OverwriteConfig then + Result := True; end; // --- Validation & Overwrite Check --- @@ -241,6 +327,16 @@ begin OverwriteConfig := False; end; end; + + if (CurPageID = AdminPage.ID) then + begin + FinalAdminUsername := AdminUsernameEdit.Text; + if Length(FinalAdminUsername) = 0 then FinalAdminUsername := 'admin'; + FinalAdminPassword := AdminPasswordEdit.Text; + if Length(FinalAdminPassword) = 0 then FinalAdminPassword := 'pass'; + FinalPort := PortEdit.Text; + if Length(FinalPort) = 0 then FinalPort := '2223'; + end; end; // --- Installation Logic --- @@ -259,6 +355,8 @@ begin S := S + 'Service Configuration:' + NewLine; S := S + Space + 'Service Name: ' + FinalServiceName + NewLine; S := S + Space + 'Data Directory: ' + FinalDataDir + NewLine; + S := S + Space + 'Admin Username: ' + FinalAdminUsername + NewLine; + S := S + Space + 'Port: ' + FinalPort + NewLine; Result := S; end; @@ -377,6 +475,14 @@ begin if not CreateDataDir then MsgBox('ERROR : OIBus data directory Setup failed.', mbCriticalError, MB_OK) else begin + // Write init config file only when no existing database is present + if not FileExists(AddBackslash(FinalDataDir) + 'oibus.db') then + SaveStringToFile( + AddBackslash(FinalDataDir) + 'oibus.init.json', + '{"adminUsername":"' + EscapeJsonString(FinalAdminUsername) + '","adminPassword":"' + EscapeJsonString(FinalAdminPassword) + '","port":' + FinalPort + '}', + False + ); + if not CreateLauncherFile() then MsgBox('ERROR : Launcher file creation failed', mbCriticalError, MB_OK) else if not InstallProgram() then diff --git a/build/docker/Dockerfile b/build/docker/Dockerfile index b2703d3ca2..b1f8b83e28 100644 --- a/build/docker/Dockerfile +++ b/build/docker/Dockerfile @@ -22,5 +22,13 @@ RUN mkdir OIBusData # Expose port 2223 for OIBus EXPOSE 2223 +# Override default admin credentials and port on first run (applied only when the DB has no user/settings yet). +# Use ADMIN_USERNAME_FILE / ADMIN_PASSWORD_FILE to point to a Docker Secret file path instead of passing plain values. +ENV ADMIN_USERNAME="" +ENV ADMIN_PASSWORD="" +ENV ADMIN_USERNAME_FILE="" +ENV ADMIN_PASSWORD_FILE="" +ENV DEFAULT_PORT="" + # Start OIBus CMD ["./oibus-launcher", "--config", "./OIBusData", "--ignoreIpFilters", "true", "--ignoreRemoteUpdate", "true"] \ No newline at end of file From 64733945c2b733fb3e635a9b6b9f8191c1bf81a7 Mon Sep 17 00:00:00 2001 From: kbalinthunor Date: Fri, 29 May 2026 12:37:00 +0300 Subject: [PATCH 03/24] feat(engine): add engine name to init config and drop credential fields from user repository --- backend/src/model/oibus-init-config.model.ts | 1 + .../config/engine.repository.spec.ts | 1 - .../repository/config/engine.repository.ts | 5 +++-- .../src/repository/config/user.repository.ts | 12 +++++------ backend/src/service/repository.service.ts | 2 +- backend/src/service/utils.spec.ts | 20 +++++++++++++------ backend/src/service/utils.ts | 2 ++ build/deps/linux-arm64/oibus-setup.sh | 9 ++++++--- build/deps/linux-x64/oibus-setup.sh | 9 ++++++--- build/deps/win-x64/install.bat | 2 +- build/deps/win-x64/setup.iss | 2 +- build/docker/Dockerfile | 1 + 12 files changed, 42 insertions(+), 24 deletions(-) diff --git a/backend/src/model/oibus-init-config.model.ts b/backend/src/model/oibus-init-config.model.ts index 988ca3bfe3..fd3007eaa9 100644 --- a/backend/src/model/oibus-init-config.model.ts +++ b/backend/src/model/oibus-init-config.model.ts @@ -2,4 +2,5 @@ export interface OIBusInitConfig { adminUsername?: string; adminPassword?: string; port?: number; + engineName?: string; } diff --git a/backend/src/repository/config/engine.repository.spec.ts b/backend/src/repository/config/engine.repository.spec.ts index 231ef32893..4e639350c9 100644 --- a/backend/src/repository/config/engine.repository.spec.ts +++ b/backend/src/repository/config/engine.repository.spec.ts @@ -123,7 +123,6 @@ describe('EngineRepository with empty database', () => { mock.restoreAll(); }); - }); }); diff --git a/backend/src/repository/config/engine.repository.ts b/backend/src/repository/config/engine.repository.ts index 71d4fbd457..11d65dd2aa 100644 --- a/backend/src/repository/config/engine.repository.ts +++ b/backend/src/repository/config/engine.repository.ts @@ -49,9 +49,10 @@ export default class EngineRepository { constructor( private readonly database: Database, launcherVersion: string, - defaultPort = DEFAULT_ENGINE_SETTINGS.port + defaultPort = DEFAULT_ENGINE_SETTINGS.port, + defaultName = DEFAULT_ENGINE_SETTINGS.name ) { - this.createDefault({ ...DEFAULT_ENGINE_SETTINGS, port: defaultPort }, launcherVersion); + this.createDefault({ ...DEFAULT_ENGINE_SETTINGS, port: defaultPort, name: defaultName }, launcherVersion); } get(): EngineSettings | null { diff --git a/backend/src/repository/config/user.repository.ts b/backend/src/repository/config/user.repository.ts index 215592bd74..4893fb0fe9 100644 --- a/backend/src/repository/config/user.repository.ts +++ b/backend/src/repository/config/user.repository.ts @@ -22,10 +22,10 @@ const DEFAULT_PASSWORD = 'pass'; export default class UserRepository { constructor( private readonly database: Database, - private readonly defaultLogin = DEFAULT_USER.login, - private readonly defaultPassword = DEFAULT_PASSWORD + defaultLogin = DEFAULT_USER.login, + defaultPassword = DEFAULT_PASSWORD ) { - this.createDefault(); + this.createDefault(defaultLogin, defaultPassword); } list(): Array { @@ -137,14 +137,14 @@ export default class UserRepository { this.database.prepare(query).run(id); } - protected createDefault(): void { + protected createDefault(login: string, password: string): void { const query = `SELECT id FROM ${USERS_TABLE} WHERE login = ?;`; - const result = this.database.prepare(query).get(this.defaultLogin); + const result = this.database.prepare(query).get(login); if (result) { return; } - this.create({ ...DEFAULT_USER, login: this.defaultLogin }, this.defaultPassword, 'system').catch(err => { + this.create({ ...DEFAULT_USER, login }, password, 'system').catch(err => { console.error(err.message); }); } diff --git a/backend/src/service/repository.service.ts b/backend/src/service/repository.service.ts index 9a8b7944a9..2bdc9cd439 100644 --- a/backend/src/service/repository.service.ts +++ b/backend/src/service/repository.service.ts @@ -79,7 +79,7 @@ export default class RepositoryService { this._ipFilterRepository = new IpFilterRepository(this.oibusDatabase); this._scanModeRepository = new ScanModeRepository(this.oibusDatabase); this._certificateRepository = new CertificateRepository(this.oibusDatabase); - this._engineRepository = new EngineRepository(this.oibusDatabase, launcherVersion, initConfig.port); + this._engineRepository = new EngineRepository(this.oibusDatabase, launcherVersion, initConfig.port, initConfig.engineName); this._northConnectorRepository = new NorthConnectorRepository(this.oibusDatabase); this._southConnectorRepository = new SouthConnectorRepository(this.oibusDatabase); this._southItemGroupRepository = new SouthItemGroupRepository(this.oibusDatabase); diff --git a/backend/src/service/utils.spec.ts b/backend/src/service/utils.spec.ts index eb6328cfc0..4b841a3329 100644 --- a/backend/src/service/utils.spec.ts +++ b/backend/src/service/utils.spec.ts @@ -1973,7 +1973,14 @@ describe('Service utils', () => { }); describe('readInitConfig', () => { - const envVarsToClean = ['ADMIN_USERNAME', 'ADMIN_PASSWORD', 'DEFAULT_PORT', 'ADMIN_USERNAME_FILE', 'ADMIN_PASSWORD_FILE']; + const envVarsToClean = [ + 'ENGINE_NAME', + 'ADMIN_USERNAME', + 'ADMIN_PASSWORD', + 'DEFAULT_PORT', + 'ADMIN_USERNAME_FILE', + 'ADMIN_PASSWORD_FILE' + ]; afterEach(() => { mock.restoreAll(); @@ -1984,20 +1991,21 @@ describe('Service utils', () => { it('should read credentials and port from a valid init file', () => { mock.method(fsSync, 'existsSync', () => true); - mock.method(fsSync, 'readFileSync', () => '{"adminUsername":"alice","adminPassword":"s3cr3t","port":3000}'); + mock.method(fsSync, 'readFileSync', () => '{"engineName":"MyOIBus","adminUsername":"alice","adminPassword":"s3cr3t","port":3000}'); const result = utils.readInitConfig(); - assert.deepStrictEqual(result, { adminUsername: 'alice', adminPassword: 's3cr3t', port: 3000 }); + assert.deepStrictEqual(result, { engineName: 'MyOIBus', adminUsername: 'alice', adminPassword: 's3cr3t', port: 3000 }); }); it('should fall back to env vars when init file does not exist', () => { mock.method(fsSync, 'existsSync', () => false); + process.env.ENGINE_NAME = 'MyOIBus'; process.env.ADMIN_USERNAME = 'bob'; process.env.ADMIN_PASSWORD = 'secret'; process.env.DEFAULT_PORT = '4000'; const result = utils.readInitConfig(); - assert.deepStrictEqual(result, { adminUsername: 'bob', adminPassword: 'secret', port: 4000 }); + assert.deepStrictEqual(result, { engineName: 'MyOIBus', adminUsername: 'bob', adminPassword: 'secret', port: 4000 }); }); it('should fall back to env vars when init file is malformed JSON', () => { @@ -2031,10 +2039,10 @@ describe('Service utils', () => { it('should ignore fields with wrong types in the init file', () => { mock.method(fsSync, 'existsSync', () => true); - mock.method(fsSync, 'readFileSync', () => '{"adminUsername":123,"adminPassword":null,"port":"not-a-number"}'); + mock.method(fsSync, 'readFileSync', () => '{"engineName":99,"adminUsername":123,"adminPassword":null,"port":"not-a-number"}'); const result = utils.readInitConfig(); - assert.deepStrictEqual(result, { adminUsername: undefined, adminPassword: undefined, port: undefined }); + assert.deepStrictEqual(result, { engineName: undefined, adminUsername: undefined, adminPassword: undefined, port: undefined }); }); }); }); diff --git a/backend/src/service/utils.ts b/backend/src/service/utils.ts index 09da02d12a..4e53dea204 100644 --- a/backend/src/service/utils.ts +++ b/backend/src/service/utils.ts @@ -78,6 +78,7 @@ export const readInitConfig = (): OIBusInitConfig => { const raw = readFileSync(INIT_CONFIG_FILENAME, 'utf8'); const parsed = JSON.parse(raw) as Record; return { + engineName: typeof parsed.engineName === 'string' ? parsed.engineName : undefined, adminUsername: typeof parsed.adminUsername === 'string' ? parsed.adminUsername : undefined, adminPassword: typeof parsed.adminPassword === 'string' ? parsed.adminPassword : undefined, port: typeof parsed.port === 'number' ? parsed.port : undefined @@ -90,6 +91,7 @@ export const readInitConfig = (): OIBusInitConfig => { const portEnv = process.env.DEFAULT_PORT; const parsedPort = portEnv ? parseInt(portEnv, 10) : NaN; return { + engineName: process.env.ENGINE_NAME || undefined, adminUsername: readSecretOrEnv('ADMIN_USERNAME_FILE', 'ADMIN_USERNAME'), adminPassword: readSecretOrEnv('ADMIN_PASSWORD_FILE', 'ADMIN_PASSWORD'), port: Number.isNaN(parsedPort) ? undefined : parsedPort diff --git a/build/deps/linux-arm64/oibus-setup.sh b/build/deps/linux-arm64/oibus-setup.sh index 9470ae92e9..7c8fa92adb 100755 --- a/build/deps/linux-arm64/oibus-setup.sh +++ b/build/deps/linux-arm64/oibus-setup.sh @@ -79,8 +79,11 @@ json_escape() { printf '%s' "$s" } -# If no existing database, prompt for initial admin credentials and port +# If no existing database, prompt for initial admin credentials, port and engine name if [[ ! -f "$conf_path/oibus.db" ]]; then + read -rp "Enter the OIBus name (default: OIBus): " engine_name + engine_name="${engine_name:=OIBus}" + read -rp "Enter the admin username (default: admin): " admin_username admin_username="${admin_username:=admin}" @@ -91,8 +94,8 @@ if [[ ! -f "$conf_path/oibus.db" ]]; then read -rp "Enter the port OIBus will listen on (default: 2223): " oibus_port oibus_port="${oibus_port:=2223}" - printf '{"adminUsername":"%s","adminPassword":"%s","port":%s}' \ - "$(json_escape "$admin_username")" "$(json_escape "$admin_password")" "$oibus_port" \ + printf '{"engineName":"%s","adminUsername":"%s","adminPassword":"%s","port":%s}' \ + "$(json_escape "$engine_name")" "$(json_escape "$admin_username")" "$(json_escape "$admin_password")" "$oibus_port" \ > "$conf_path/oibus.init.json" chmod 600 "$conf_path/oibus.init.json" fi diff --git a/build/deps/linux-x64/oibus-setup.sh b/build/deps/linux-x64/oibus-setup.sh index 9470ae92e9..7c8fa92adb 100755 --- a/build/deps/linux-x64/oibus-setup.sh +++ b/build/deps/linux-x64/oibus-setup.sh @@ -79,8 +79,11 @@ json_escape() { printf '%s' "$s" } -# If no existing database, prompt for initial admin credentials and port +# If no existing database, prompt for initial admin credentials, port and engine name if [[ ! -f "$conf_path/oibus.db" ]]; then + read -rp "Enter the OIBus name (default: OIBus): " engine_name + engine_name="${engine_name:=OIBus}" + read -rp "Enter the admin username (default: admin): " admin_username admin_username="${admin_username:=admin}" @@ -91,8 +94,8 @@ if [[ ! -f "$conf_path/oibus.db" ]]; then read -rp "Enter the port OIBus will listen on (default: 2223): " oibus_port oibus_port="${oibus_port:=2223}" - printf '{"adminUsername":"%s","adminPassword":"%s","port":%s}' \ - "$(json_escape "$admin_username")" "$(json_escape "$admin_password")" "$oibus_port" \ + printf '{"engineName":"%s","adminUsername":"%s","adminPassword":"%s","port":%s}' \ + "$(json_escape "$engine_name")" "$(json_escape "$admin_username")" "$(json_escape "$admin_password")" "$oibus_port" \ > "$conf_path/oibus.init.json" chmod 600 "$conf_path/oibus.init.json" fi diff --git a/build/deps/win-x64/install.bat b/build/deps/win-x64/install.bat index 50662b853b..95f7c5d6a8 100644 --- a/build/deps/win-x64/install.bat +++ b/build/deps/win-x64/install.bat @@ -68,7 +68,7 @@ if not exist "%DATA_FOLDER_PATH%\oibus.db" ( if "%ADMIN_PASSWORD%"=="" set ADMIN_PASSWORD=pass if "%OIBUS_PORT%"=="" set /P OIBUS_PORT=Enter the port OIBus will listen on (default: 2223): if "%OIBUS_PORT%"=="" set OIBUS_PORT=2223 - powershell -Command "$json = [pscustomobject]@{adminUsername=$env:ADMIN_USERNAME; adminPassword=$env:ADMIN_PASSWORD; port=[int]$env:OIBUS_PORT} | ConvertTo-Json -Compress; [IO.File]::WriteAllText([IO.Path]::Combine($env:DATA_FOLDER_PATH, 'oibus.init.json'), $json)" + powershell -Command "$json = [pscustomobject]@{engineName=$env:SERVICE_NAME; adminUsername=$env:ADMIN_USERNAME; adminPassword=$env:ADMIN_PASSWORD; port=[int]$env:OIBUS_PORT} | ConvertTo-Json -Compress; [IO.File]::WriteAllText([IO.Path]::Combine($env:DATA_FOLDER_PATH, 'oibus.init.json'), $json)" ) echo Stopping %SERVICE_NAME% service... diff --git a/build/deps/win-x64/setup.iss b/build/deps/win-x64/setup.iss index 5853933db5..82c404d95e 100644 --- a/build/deps/win-x64/setup.iss +++ b/build/deps/win-x64/setup.iss @@ -479,7 +479,7 @@ begin if not FileExists(AddBackslash(FinalDataDir) + 'oibus.db') then SaveStringToFile( AddBackslash(FinalDataDir) + 'oibus.init.json', - '{"adminUsername":"' + EscapeJsonString(FinalAdminUsername) + '","adminPassword":"' + EscapeJsonString(FinalAdminPassword) + '","port":' + FinalPort + '}', + '{"engineName":"' + EscapeJsonString(FinalServiceName) + '","adminUsername":"' + EscapeJsonString(FinalAdminUsername) + '","adminPassword":"' + EscapeJsonString(FinalAdminPassword) + '","port":' + FinalPort + '}', False ); diff --git a/build/docker/Dockerfile b/build/docker/Dockerfile index b1f8b83e28..4b3e6d953a 100644 --- a/build/docker/Dockerfile +++ b/build/docker/Dockerfile @@ -24,6 +24,7 @@ EXPOSE 2223 # Override default admin credentials and port on first run (applied only when the DB has no user/settings yet). # Use ADMIN_USERNAME_FILE / ADMIN_PASSWORD_FILE to point to a Docker Secret file path instead of passing plain values. +ENV ENGINE_NAME="" ENV ADMIN_USERNAME="" ENV ADMIN_PASSWORD="" ENV ADMIN_USERNAME_FILE="" From c84c9a525fbd935c72698f17d848f4b94a2bf446 Mon Sep 17 00:00:00 2001 From: burgerni10 Date: Mon, 8 Jun 2026 12:15:25 +0200 Subject: [PATCH 04/24] fix(install): fix variables in Inno Setup scripts --- build/deps/win-x64/setup.iss | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/build/deps/win-x64/setup.iss b/build/deps/win-x64/setup.iss index 82c404d95e..9fc695ad56 100644 --- a/build/deps/win-x64/setup.iss +++ b/build/deps/win-x64/setup.iss @@ -158,7 +158,7 @@ end; procedure InitializeWizard(); var - lblService, lblData: TNewStaticText; + lblService, lblData, lblAdminUser, lblAdminPass, lblPort: TNewStaticText; btnBrowse: TButton; begin // Create the Custom Page @@ -206,7 +206,6 @@ begin // Create Admin Credentials Page AdminPage := CreateCustomPage(ConfigPage.ID, 'Admin Credentials', 'Set the initial admin username, password and port'); - var lblAdminUser: TNewStaticText; lblAdminUser := TNewStaticText.Create(AdminPage); lblAdminUser.Parent := AdminPage.Surface; lblAdminUser.Caption := 'Admin username (default: admin):'; @@ -221,7 +220,6 @@ begin AdminUsernameEdit.Left := 0; AdminUsernameEdit.Width := AdminPage.SurfaceWidth; - var lblAdminPass: TNewStaticText; lblAdminPass := TNewStaticText.Create(AdminPage); lblAdminPass.Parent := AdminPage.Surface; lblAdminPass.Caption := 'Admin password (default: pass):'; @@ -237,7 +235,6 @@ begin AdminPasswordEdit.Left := 0; AdminPasswordEdit.Width := AdminPage.SurfaceWidth; - var lblPort: TNewStaticText; lblPort := TNewStaticText.Create(AdminPage); lblPort.Parent := AdminPage.Surface; lblPort.Caption := 'Port OIBus will listen on (default: 2223):'; From 70e7c65f42d14b9d4ee89d85c2e8b5872fd8efd6 Mon Sep 17 00:00:00 2001 From: burgerni10 Date: Tue, 9 Jun 2026 00:02:42 +0200 Subject: [PATCH 05/24] fix(install): fix install script for default params and document them --- build/deps/linux-arm64/oibus-setup.sh | 50 +++++++++---------- build/deps/linux-x64/oibus-setup.sh | 50 +++++++++---------- build/deps/win-x64/install.bat | 30 ++++++----- build/deps/win-x64/uninstall.bat | 4 +- .../docs/guide/installation/_linux.mdx | 29 +++++++---- .../docs/guide/installation/_windows.mdx | 26 ++++++---- 6 files changed, 101 insertions(+), 88 deletions(-) diff --git a/build/deps/linux-arm64/oibus-setup.sh b/build/deps/linux-arm64/oibus-setup.sh index 7c8fa92adb..f0994c0299 100755 --- a/build/deps/linux-arm64/oibus-setup.sh +++ b/build/deps/linux-arm64/oibus-setup.sh @@ -1,13 +1,16 @@ #!/bin/bash -while getopts b:c:k: flag -do - case "${flag}" in - b) install_dir=${OPTARG};; - c) my_data_directory=${OPTARG};; - k) keep_conf=${OPTARG};; - *) - esac +while [[ $# -gt 0 ]]; do + case "$1" in + -b) install_dir="$2"; shift 2;; + -c) my_data_directory="$2"; shift 2;; + -k) keep_conf="$2"; shift 2;; + -n) engine_name="$2"; shift 2;; + -u) admin_username="$2"; shift 2;; + -p) admin_password="$2"; shift 2;; + -port) oibus_port="$2"; shift 2;; + *) shift;; + esac done @@ -79,24 +82,21 @@ json_escape() { printf '%s' "$s" } -# If no existing database, prompt for initial admin credentials, port and engine name +# If no existing database, write provided initial settings to oibus.init.json. +# Only fields explicitly passed as flags are included; omitted flags are left out +# so OIBus applies its own built-in defaults for those values. if [[ ! -f "$conf_path/oibus.db" ]]; then - read -rp "Enter the OIBus name (default: OIBus): " engine_name - engine_name="${engine_name:=OIBus}" - - read -rp "Enter the admin username (default: admin): " admin_username - admin_username="${admin_username:=admin}" - - read -rsp "Enter the admin password (default: pass): " admin_password - echo - admin_password="${admin_password:=pass}" - - read -rp "Enter the port OIBus will listen on (default: 2223): " oibus_port - oibus_port="${oibus_port:=2223}" - - printf '{"engineName":"%s","adminUsername":"%s","adminPassword":"%s","port":%s}' \ - "$(json_escape "$engine_name")" "$(json_escape "$admin_username")" "$(json_escape "$admin_password")" "$oibus_port" \ - > "$conf_path/oibus.init.json" + json_fields="\"engineName\":\"$(json_escape "${engine_name:-OIBus}")\"" + if [[ -n "$admin_username" ]]; then + json_fields="$json_fields,\"adminUsername\":\"$(json_escape "$admin_username")\"" + fi + if [[ -n "$admin_password" ]]; then + json_fields="$json_fields,\"adminPassword\":\"$(json_escape "$admin_password")\"" + fi + if [[ -n "$oibus_port" ]]; then + json_fields="$json_fields,\"port\":$oibus_port" + fi + printf '{%s}' "$json_fields" > "$conf_path/oibus.init.json" chmod 600 "$conf_path/oibus.init.json" fi diff --git a/build/deps/linux-x64/oibus-setup.sh b/build/deps/linux-x64/oibus-setup.sh index 7c8fa92adb..f0994c0299 100755 --- a/build/deps/linux-x64/oibus-setup.sh +++ b/build/deps/linux-x64/oibus-setup.sh @@ -1,13 +1,16 @@ #!/bin/bash -while getopts b:c:k: flag -do - case "${flag}" in - b) install_dir=${OPTARG};; - c) my_data_directory=${OPTARG};; - k) keep_conf=${OPTARG};; - *) - esac +while [[ $# -gt 0 ]]; do + case "$1" in + -b) install_dir="$2"; shift 2;; + -c) my_data_directory="$2"; shift 2;; + -k) keep_conf="$2"; shift 2;; + -n) engine_name="$2"; shift 2;; + -u) admin_username="$2"; shift 2;; + -p) admin_password="$2"; shift 2;; + -port) oibus_port="$2"; shift 2;; + *) shift;; + esac done @@ -79,24 +82,21 @@ json_escape() { printf '%s' "$s" } -# If no existing database, prompt for initial admin credentials, port and engine name +# If no existing database, write provided initial settings to oibus.init.json. +# Only fields explicitly passed as flags are included; omitted flags are left out +# so OIBus applies its own built-in defaults for those values. if [[ ! -f "$conf_path/oibus.db" ]]; then - read -rp "Enter the OIBus name (default: OIBus): " engine_name - engine_name="${engine_name:=OIBus}" - - read -rp "Enter the admin username (default: admin): " admin_username - admin_username="${admin_username:=admin}" - - read -rsp "Enter the admin password (default: pass): " admin_password - echo - admin_password="${admin_password:=pass}" - - read -rp "Enter the port OIBus will listen on (default: 2223): " oibus_port - oibus_port="${oibus_port:=2223}" - - printf '{"engineName":"%s","adminUsername":"%s","adminPassword":"%s","port":%s}' \ - "$(json_escape "$engine_name")" "$(json_escape "$admin_username")" "$(json_escape "$admin_password")" "$oibus_port" \ - > "$conf_path/oibus.init.json" + json_fields="\"engineName\":\"$(json_escape "${engine_name:-OIBus}")\"" + if [[ -n "$admin_username" ]]; then + json_fields="$json_fields,\"adminUsername\":\"$(json_escape "$admin_username")\"" + fi + if [[ -n "$admin_password" ]]; then + json_fields="$json_fields,\"adminPassword\":\"$(json_escape "$admin_password")\"" + fi + if [[ -n "$oibus_port" ]]; then + json_fields="$json_fields,\"port\":$oibus_port" + fi + printf '{%s}' "$json_fields" > "$conf_path/oibus.init.json" chmod 600 "$conf_path/oibus.init.json" fi diff --git a/build/deps/win-x64/install.bat b/build/deps/win-x64/install.bat index 95f7c5d6a8..15421c79be 100644 --- a/build/deps/win-x64/install.bat +++ b/build/deps/win-x64/install.bat @@ -4,9 +4,9 @@ title Install OIBus as Windows service echo Administrator permissions required. Detecting permission... net session >nul 2>&1 -if NOT %errorLevel% == 0 ( +if ERRORLEVEL 1 ( echo No Administrator permission. Please run Command Prompt as Administrator - goto EOF + exit /b 1 ) set DATA_FOLDER_PATH= @@ -59,17 +59,15 @@ if "%DATA_FOLDER_PATH%"=="" ( goto INPUT ) -if not exist "%DATA_FOLDER_PATH%" mkdir %DATA_FOLDER_PATH% +if not exist "%DATA_FOLDER_PATH%" mkdir "%DATA_FOLDER_PATH%" -if not exist "%DATA_FOLDER_PATH%\oibus.db" ( - if "%ADMIN_USERNAME%"=="" set /P ADMIN_USERNAME=Enter the admin username (default: admin): - if "%ADMIN_USERNAME%"=="" set ADMIN_USERNAME=admin - if "%ADMIN_PASSWORD%"=="" set /P ADMIN_PASSWORD=Enter the admin password (default: pass): - if "%ADMIN_PASSWORD%"=="" set ADMIN_PASSWORD=pass - if "%OIBUS_PORT%"=="" set /P OIBUS_PORT=Enter the port OIBus will listen on (default: 2223): - if "%OIBUS_PORT%"=="" set OIBUS_PORT=2223 - powershell -Command "$json = [pscustomobject]@{engineName=$env:SERVICE_NAME; adminUsername=$env:ADMIN_USERNAME; adminPassword=$env:ADMIN_PASSWORD; port=[int]$env:OIBUS_PORT} | ConvertTo-Json -Compress; [IO.File]::WriteAllText([IO.Path]::Combine($env:DATA_FOLDER_PATH, 'oibus.init.json'), $json)" -) +if exist "%DATA_FOLDER_PATH%\oibus.db" goto SKIP_INIT +set JSON_FIELDS="engineName":"%SERVICE_NAME%" +if not "%ADMIN_USERNAME%"=="" set JSON_FIELDS=%JSON_FIELDS%,"adminUsername":"%ADMIN_USERNAME%" +if not "%ADMIN_PASSWORD%"=="" set JSON_FIELDS=%JSON_FIELDS%,"adminPassword":"%ADMIN_PASSWORD%" +if not "%OIBUS_PORT%"=="" set JSON_FIELDS=%JSON_FIELDS%,"port":%OIBUS_PORT% +(echo {%JSON_FIELDS%})> "%DATA_FOLDER_PATH%\oibus.init.json" +:SKIP_INIT echo Stopping %SERVICE_NAME% service... nssm.exe stop %SERVICE_NAME% >nul 2>&1 @@ -77,14 +75,14 @@ nssm.exe stop %SERVICE_NAME% >nul 2>&1 @echo Installing %SERVICE_NAME% as Windows service... date /T >> install.log time /T >> install.log -nssm.exe install "%SERVICE_NAME%" "%cd%\oibus-launcher.exe --config %DATA_FOLDER_PATH%" -@echo nssm.exe install "%SERVICE_NAME%" "%cd%\oibus-launcher.exe --config %DATA_FOLDER_PATH%" >> install.log +nssm.exe install "%SERVICE_NAME%" "%cd%\oibus-launcher.exe" +@echo nssm.exe install "%SERVICE_NAME%" "%cd%\oibus-launcher.exe" >> install.log nssm.exe set "%SERVICE_NAME%" Application "%cd%\oibus-launcher.exe" @echo nssm.exe set "%SERVICE_NAME%" Application "%cd%\oibus-launcher.exe" >> install.log -nssm.exe set "%SERVICE_NAME%" AppParameters "--config %DATA_FOLDER_PATH%" -@echo nssm.exe set "%SERVICE_NAME%" AppParameters "--config %DATA_FOLDER_PATH%" >> install.log +nssm.exe set "%SERVICE_NAME%" AppParameters "--config \"%DATA_FOLDER_PATH%\"" +@echo nssm.exe set "%SERVICE_NAME%" AppParameters "--config \"%DATA_FOLDER_PATH%\"" >> install.log nssm.exe set "%SERVICE_NAME%" AppDirectory "%cd%" @echo nssm.exe set "%SERVICE_NAME%" AppDirectory "%cd%" >> install.log diff --git a/build/deps/win-x64/uninstall.bat b/build/deps/win-x64/uninstall.bat index b12ae6ccf3..791b32a681 100644 --- a/build/deps/win-x64/uninstall.bat +++ b/build/deps/win-x64/uninstall.bat @@ -4,10 +4,10 @@ title Remove OIBus from Windows service echo Administrator permissions required. Detecting permission... net session >nul 2>&1 -if NOT %errorLevel% == 0 ( +if ERRORLEVEL 1 ( echo No Administrator permission. Please run Command Prompt as Administrator pause - goto EOF + exit /b 1 ) set SERVICE_NAME="OIBus" diff --git a/documentation/docs/guide/installation/_linux.mdx b/documentation/docs/guide/installation/_linux.mdx index a210c388bb..fce5a7d58e 100644 --- a/documentation/docs/guide/installation/_linux.mdx +++ b/documentation/docs/guide/installation/_linux.mdx @@ -56,19 +56,28 @@ The setup script has been tested on **Ubuntu** and **Debian**. For other distrib 2. Run the setup script as root: ```bash -sudo ./oibus-setup.sh +sudo ./oibus-setup.sh [options] ``` -3. Follow the interactive prompts (default values shown in parentheses): +| Flag | Description | Default | +|------|-------------|---------| +| `-b` | Directory where OIBus binaries are installed | prompted if omitted | +| `-c` | Path to the OIBus data folder (cache, logs, configuration) | prompted if omitted | +| `-k` | Keep existing configuration without prompting (`Y` or `N`) | prompted if omitted | +| `-n` | OIBus engine name written to `oibus.init.json` on first install | `OIBus` | +| `-u` | Admin username written to `oibus.init.json` on first install | omitted (OIBus default applies) | +| `-p` | Admin password written to `oibus.init.json` on first install | omitted (OIBus default applies) | +| `-port` | Port OIBus listens on, written to `oibus.init.json` on first install | omitted (OIBus default applies) | + +:::info +`-u`, `-p`, and `-port` are only used when `oibus.db` does not yet exist (i.e. on the very first install). +They are written to an `oibus.init.json` file in the data folder and applied by OIBus on first startup. +Omitted flags are not written to the file — OIBus will use its built-in defaults for those values. +On subsequent installs or updates the file is not recreated, so these flags have no effect. +::: -``` -Administrative permissions are required to proceed. Do you wish to continue? (Y/n) -Enter the directory for OIBus binaries (default: ./OIBus/): -Enter the directory for OIBus data, caches, and logs (default: ./OIBusData/): -Enter a username for your OIBus session (default: admin): -Enter a name for your OIBus instance (default: OIBus): -Enter the port for OIBus (default: 2223): -``` +3. The script will prompt for any required value not supplied as a flag (`-b` and `-c`). + On first install it also asks whether to keep an existing configuration if one is found. 4. On success you will see: diff --git a/documentation/docs/guide/installation/_windows.mdx b/documentation/docs/guide/installation/_windows.mdx index 893eb6b3ac..483844742b 100644 --- a/documentation/docs/guide/installation/_windows.mdx +++ b/documentation/docs/guide/installation/_windows.mdx @@ -104,8 +104,21 @@ import TabItem from '@theme/TabItem'; ``` commandline title="Usage" install.bat -c "[your-data-folder-path]" ``` - Replace `[your-data-folder-path]` with the actual path where you want to store OIBus data (e.g., C:\OIBusData or - D:\OIBusData). + + | Flag | Description | Default | + |------|-------------|---------| + | `-c` | Path to the OIBus data folder (cache, logs, configuration) | prompted if omitted | + | `-n` | Windows service name — use a unique name to run multiple OIBus instances on the same machine | `OIBus` | + | `-u` | Admin username written to `oibus.init.json` on first install | omitted (OIBus default applies) | + | `-p` | Admin password written to `oibus.init.json` on first install | omitted (OIBus default applies) | + | `-port` | Port OIBus listens on, written to `oibus.init.json` on first install | omitted (OIBus default applies) | + + :::info + `-u`, `-p`, and `-port` are only used when `oibus.db` does not yet exist (i.e. on the very first install). + They are written to an `oibus.init.json` file in the data folder and applied by OIBus on first startup. + Omitted flags are not written to the file — OIBus will use its built-in defaults for those values. + On subsequent installs or updates the file is not recreated, so these flags have no effect. + ::: :::tip - If you omit the `-c` argument, the script will prompt you for the data path. @@ -115,7 +128,7 @@ import TabItem from '@theme/TabItem'; **Example:** ``` commandline title="Example with terminal outputs" - install.bat -c "D:\OIBusData" + install.bat -c "D:\OIBusData" -u "admin" -p "password" -port 2223 > Administrator permissions required. Detecting permission... > Stopping OIBus service... > Installing OIBus as Windows service... @@ -130,13 +143,6 @@ import TabItem from '@theme/TabItem'; > "C:\Users\Administrator\Downloads\oibus-win_x64\oibus.exe" --config "D:\OIBusData" ``` - :::tip - Use `-n "Service Name"` to customize the OIBus service name. This method is useful if you need to **run multiple - OIBus services on the same machine**. - - By default, the service name is OIBus. - ::: - Proceed to the [first access guide](./first-access.mdx) to configure OIBus.

Update OIBus

From af02b8ad42ea0e785056d5dc6df660facd1ed56c Mon Sep 17 00:00:00 2001 From: kbalinthunor Date: Tue, 2 Jun 2026 15:14:18 +0300 Subject: [PATCH 06/24] feat(engine): split engine settings into dedicated endpoints --- backend/shared/model/engine.model.ts | 49 +++ .../config/engine.repository.spec.ts | 29 ++ .../repository/config/engine.repository.ts | 66 +++- backend/src/service/oibus.service.spec.ts | 119 +++++++ backend/src/service/oibus.service.ts | 72 ++++- .../config/engine-repository.mock.ts | 11 +- .../__mocks__/service/oibus-service.mock.ts | 11 + backend/src/tests/utils/test-data.ts | 19 ++ backend/src/tests/utils/test-utils.ts | 10 +- .../controllers/engine.controller.spec.ts | 49 ++- .../controllers/engine.controller.ts | 62 +++- .../validators/engine.validator.spec.ts | 115 ++++++- .../validators/oibus-validation-schema.ts | 81 +++-- backend/src/web-server/routes.ts | 154 +++++++++ documentation/docs/api/openapi.json | 305 ++++++++++++++++++ frontend/src/app/app.routes.ts | 11 - .../edit-engine-logger-modal.component.html | 167 ++++++++++ .../edit-engine-logger-modal.component.scss} | 0 ...edit-engine-logger-modal.component.spec.ts | 82 +++++ .../edit-engine-logger-modal.component.ts | 219 +++++++++++++ .../edit-engine-name-modal.component.html | 18 ++ .../edit-engine-name-modal.component.scss | 0 .../edit-engine-name-modal.component.spec.ts | 81 +++++ .../edit-engine-name-modal.component.ts | 43 +++ .../edit-engine-proxy-modal.component.html | 32 ++ .../edit-engine-proxy-modal.component.scss | 0 .../edit-engine-proxy-modal.component.spec.ts | 76 +++++ .../edit-engine-proxy-modal.component.ts | 65 ++++ ...dit-engine-web-server-modal.component.html | 24 ++ ...dit-engine-web-server-modal.component.scss | 0 ...-engine-web-server-modal.component.spec.ts | 96 ++++++ .../edit-engine-web-server-modal.component.ts | 52 +++ .../edit-engine/edit-engine.component.html | 242 -------------- .../edit-engine/edit-engine.component.spec.ts | 212 ------------ .../edit-engine/edit-engine.component.ts | 282 ---------------- .../app/engine/engine-detail.component.html | 118 +++++-- .../engine/engine-detail.component.spec.ts | 6 +- .../src/app/engine/engine-detail.component.ts | 39 ++- .../src/app/services/engine.service.spec.ts | 63 +++- frontend/src/app/services/engine.service.ts | 20 ++ frontend/src/i18n/en.json | 3 + 41 files changed, 2294 insertions(+), 809 deletions(-) create mode 100644 frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.html rename frontend/src/app/engine/{edit-engine/edit-engine.component.scss => edit-engine-logger-modal/edit-engine-logger-modal.component.scss} (100%) create mode 100644 frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.spec.ts create mode 100644 frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.ts create mode 100644 frontend/src/app/engine/edit-engine-name-modal/edit-engine-name-modal.component.html create mode 100644 frontend/src/app/engine/edit-engine-name-modal/edit-engine-name-modal.component.scss create mode 100644 frontend/src/app/engine/edit-engine-name-modal/edit-engine-name-modal.component.spec.ts create mode 100644 frontend/src/app/engine/edit-engine-name-modal/edit-engine-name-modal.component.ts create mode 100644 frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.html create mode 100644 frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.scss create mode 100644 frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.spec.ts create mode 100644 frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.ts create mode 100644 frontend/src/app/engine/edit-engine-web-server-modal/edit-engine-web-server-modal.component.html create mode 100644 frontend/src/app/engine/edit-engine-web-server-modal/edit-engine-web-server-modal.component.scss create mode 100644 frontend/src/app/engine/edit-engine-web-server-modal/edit-engine-web-server-modal.component.spec.ts create mode 100644 frontend/src/app/engine/edit-engine-web-server-modal/edit-engine-web-server-modal.component.ts delete mode 100644 frontend/src/app/engine/edit-engine/edit-engine.component.html delete mode 100644 frontend/src/app/engine/edit-engine/edit-engine.component.spec.ts delete mode 100644 frontend/src/app/engine/edit-engine/edit-engine.component.ts diff --git a/backend/shared/model/engine.model.ts b/backend/shared/model/engine.model.ts index db51e5294f..d363e8721c 100644 --- a/backend/shared/model/engine.model.ts +++ b/backend/shared/model/engine.model.ts @@ -970,6 +970,55 @@ export interface EngineSettingsCommandDTO { }; } +/** + * Engine name command Data Transfer Object. + * Used as the request body for updating only the engine name. + */ +export interface EngineNameCommandDTO { + /** + * The name of the engine. + * @example "OIBus OT" + */ + name: string; +} + +/** + * Engine web server command Data Transfer Object. + * Used as the request body for updating only the web server port. + */ +export interface EngineWebServerCommandDTO { + /** + * The port on which the engine listens. + * @example 8080 + */ + port: number; +} + +/** + * Engine proxy command Data Transfer Object. + * Used as the request body for updating only the proxy settings. + */ +export interface EngineProxyCommandDTO { + /** + * Whether the proxy is enabled. + * @example false + */ + proxyEnabled: boolean; + + /** + * The port for the proxy, if enabled. + * @example null + */ + proxyPort: number | null; +} + +/** + * Engine logger command Data Transfer Object. + * Used as the request body for updating only the logging parameters. + * The log category objects are top-level (no wrapper key). + */ +export type EngineLoggerCommandDTO = EngineSettingsCommandDTO['logParameters']; + /** * Engine settings update result Data Transfer Object. * Returned after updating engine settings to indicate if a redirect is needed. diff --git a/backend/src/repository/config/engine.repository.spec.ts b/backend/src/repository/config/engine.repository.spec.ts index 4e639350c9..f7775a8197 100644 --- a/backend/src/repository/config/engine.repository.spec.ts +++ b/backend/src/repository/config/engine.repository.spec.ts @@ -44,6 +44,35 @@ describe('EngineRepository with populated database', () => { }); }); + it('should update name only', () => { + repository.updateName('my new name', testData.users.list[0].id); + assert.strictEqual(repository.get()!.name, 'my new name'); + }); + + it('should update web server port only', () => { + repository.updateWebServer(testData.engine.webServerCommand, testData.users.list[0].id); + assert.strictEqual(repository.get()!.port, testData.engine.webServerCommand.port); + }); + + it('should update proxy settings with proxy disabled', () => { + repository.updateProxy({ proxyEnabled: false, proxyPort: null }, testData.users.list[0].id); + const result = repository.get()!; + assert.strictEqual(result.proxyEnabled, false); + assert.strictEqual(result.proxyPort, null); + }); + + it('should update proxy settings with proxy enabled', () => { + repository.updateProxy({ proxyEnabled: true, proxyPort: 8080 }, testData.users.list[0].id); + const result = repository.get()!; + assert.strictEqual(result.proxyEnabled, true); + assert.strictEqual(result.proxyPort, 8080); + }); + + it('should update logger settings only', () => { + repository.updateLogger(testData.engine.loggerCommand, testData.users.list[0].id); + assert.deepStrictEqual(repository.get()!.logParameters, testData.engine.loggerCommand); + }); + it('should update version', () => { repository.updateVersion('9.9.99', '9.9.99'); assert.strictEqual(repository.get()!.version, '9.9.99'); diff --git a/backend/src/repository/config/engine.repository.ts b/backend/src/repository/config/engine.repository.ts index 11d65dd2aa..48b6b41793 100644 --- a/backend/src/repository/config/engine.repository.ts +++ b/backend/src/repository/config/engine.repository.ts @@ -1,7 +1,12 @@ import { generateRandomId } from '../../service/utils'; import { Database } from 'better-sqlite3'; import { EngineSettings } from '../../model/engine.model'; -import { EngineSettingsCommandDTO } from '../../../shared/model/engine.model'; +import { + EngineLoggerCommandDTO, + EngineProxyCommandDTO, + EngineSettingsCommandDTO, + EngineWebServerCommandDTO +} from '../../../shared/model/engine.model'; import { version } from '../../../package.json'; import { LogLevel } from '../../../shared/model/logs.model'; @@ -114,6 +119,65 @@ export default class EngineRepository { ); } + updateName(name: string, updatedBy: string): void { + const query = + `UPDATE ${ENGINES_TABLE} SET name = ?, updated_by = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') ` + + `WHERE rowid=(SELECT MIN(rowid) FROM ${ENGINES_TABLE});`; + this.database.prepare(query).run(name, updatedBy); + } + + updateWebServer(command: EngineWebServerCommandDTO, updatedBy: string): void { + const query = + `UPDATE ${ENGINES_TABLE} SET port = ?, updated_by = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') ` + + `WHERE rowid=(SELECT MIN(rowid) FROM ${ENGINES_TABLE});`; + this.database.prepare(query).run(command.port, updatedBy); + } + + updateProxy(command: EngineProxyCommandDTO, updatedBy: string): void { + const query = + `UPDATE ${ENGINES_TABLE} SET proxy_enabled = ?, proxy_port = ?, updated_by = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') ` + + `WHERE rowid=(SELECT MIN(rowid) FROM ${ENGINES_TABLE});`; + this.database.prepare(query).run(+command.proxyEnabled, command.proxyPort, updatedBy); + } + + updateLogger(command: EngineLoggerCommandDTO, updatedBy: string): void { + const query = + `UPDATE ${ENGINES_TABLE} SET ` + + 'log_console_level = ?, ' + + 'log_file_level = ?, ' + + 'log_file_max_file_size = ?, ' + + 'log_file_number_of_files = ?, ' + + 'log_database_level = ?, ' + + 'log_database_max_number_of_logs = ?, ' + + 'log_loki_level = ?, ' + + 'log_loki_interval = ?, ' + + 'log_loki_address = ?, ' + + 'log_loki_username = ?, ' + + 'log_loki_password = ?, ' + + 'log_oia_level = ?, ' + + 'log_oia_interval = ?, ' + + `updated_by = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') ` + + `WHERE rowid=(SELECT MIN(rowid) FROM ${ENGINES_TABLE});`; + this.database + .prepare(query) + .run( + command.console.level, + command.file.level, + command.file.maxFileSize, + command.file.numberOfFiles, + command.database.level, + command.database.maxNumberOfLogs, + command.loki.level, + command.loki.interval, + command.loki.address, + command.loki.username, + command.loki.password, + command.oia.level, + command.oia.interval, + updatedBy + ); + } + updateVersion(version: string, launcherVersion: string): void { const query = `UPDATE ${ENGINES_TABLE} SET oibus_version = ?, oibus_launcher_version = ? WHERE rowid=(SELECT MIN(rowid) FROM ${ENGINES_TABLE});`; this.database.prepare(query).run(version, launcherVersion); diff --git a/backend/src/service/oibus.service.spec.ts b/backend/src/service/oibus.service.spec.ts index b8531f5896..b8c2a9577a 100644 --- a/backend/src/service/oibus.service.spec.ts +++ b/backend/src/service/oibus.service.spec.ts @@ -32,6 +32,8 @@ let mockEncryptionService: Record; // Captured mock proxy instance — set by MockProxyServer constructor in before() let lastProxyRefreshIpFilters: ReturnType; +let lastProxyStart: ReturnType; +let lastProxyStop: ReturnType; let OIBusService: new (...args: Array) => InstanceType; let toEngineSettingsDTO: typeof toEngineSettingsDTOType; @@ -50,6 +52,8 @@ before(() => { setLogger = mock.fn(); constructor() { lastProxyRefreshIpFilters = this.refreshIpFilters; + lastProxyStart = this.start; + lastProxyStop = this.stop; } } mockProxyServer = { __esModule: true, default: MockProxyServer }; @@ -353,6 +357,121 @@ describe('OIBus Service', () => { assert.strictEqual(loggerService.createChildLogger.mock.calls.length, 1); // in constructor only }); + it('should update engine name', async () => { + engineRepository.get.mock.mockImplementation(() => testData.engine.settings); + + await service.updateEngineName(testData.engine.nameCommand, testData.users.list[0].id); + + assert.strictEqual(engineRepository.updateName.mock.calls.length, 1); + assert.deepStrictEqual(engineRepository.updateName.mock.calls[0].arguments, [ + testData.engine.nameCommand.name, + testData.users.list[0].id + ]); + assert.strictEqual(loggerService.stop.mock.calls.length, 1); + assert.strictEqual(loggerService.start.mock.calls.length, 1); + assert.strictEqual(oIAnalyticsMessageService.createFullConfigMessageIfNotPending.mock.calls.length, 1); + }); + + it('should update engine web server port without redirect when port unchanged', async () => { + engineRepository.get.mock.mockImplementation(() => testData.engine.settings); + + const result = await service.updateEngineWebServer({ port: testData.engine.settings.port }, testData.users.list[0].id); + + assert.deepStrictEqual(result, { needsRedirect: false, newPort: null }); + assert.strictEqual(engineRepository.updateWebServer.mock.calls.length, 1); + assert.strictEqual(oIAnalyticsMessageService.createFullConfigMessageIfNotPending.mock.calls.length, 1); + }); + + it('should update engine web server port with redirect when port changed', async () => { + const newSettings = { ...testData.engine.settings, port: 3333 }; + let getCallCount = 0; + engineRepository.get.mock.mockImplementation(() => { + getCallCount++; + if (getCallCount === 1) return testData.engine.settings; + return newSettings; + }); + + mock.timers.reset(); + const result = await service.updateEngineWebServer(testData.engine.webServerCommand, testData.users.list[0].id); + await new Promise(resolve => setImmediate(resolve)); + mock.timers.enable({ + apis: ['Date', 'setInterval', 'setImmediate', 'setTimeout'], + now: new Date(testData.constants.dates.FAKE_NOW).getTime() + }); + + assert.deepStrictEqual(result, { needsRedirect: true, newPort: 3333 }); + }); + + it('should throw error when web server port equals proxy port', async () => { + engineRepository.get.mock.mockImplementation(() => testData.engine.settings); + + await assert.rejects( + () => service.updateEngineWebServer({ port: testData.engine.settings.proxyPort! }, testData.users.list[0].id), + new Error('Web server port and proxy port can not be the same') + ); + assert.strictEqual(engineRepository.updateWebServer.mock.calls.length, 0); + }); + + it('should update engine proxy settings', async () => { + engineRepository.get.mock.mockImplementation(() => ({ ...testData.engine.settings, proxyEnabled: false })); + + await service.updateEngineProxy({ proxyEnabled: false, proxyPort: null }, testData.users.list[0].id); + + assert.strictEqual(engineRepository.updateProxy.mock.calls.length, 1); + assert.strictEqual(oIAnalyticsMessageService.createFullConfigMessageIfNotPending.mock.calls.length, 1); + }); + + it('should throw error when proxy port equals web server port', async () => { + engineRepository.get.mock.mockImplementation(() => testData.engine.settings); + + await assert.rejects( + () => service.updateEngineProxy({ proxyEnabled: true, proxyPort: testData.engine.settings.port }, testData.users.list[0].id), + new Error('Web server port and proxy port can not be the same') + ); + assert.strictEqual(engineRepository.updateProxy.mock.calls.length, 0); + }); + + it('should start proxy server when proxy is enabled', async () => { + const settingsWithProxy = { ...testData.engine.settings, proxyEnabled: true, proxyPort: 9000 }; + engineRepository.get.mock.mockImplementation(() => settingsWithProxy); + + await service.updateEngineProxy({ proxyEnabled: true, proxyPort: 9000 }, testData.users.list[0].id); + + assert.strictEqual(engineRepository.updateProxy.mock.calls.length, 1); + assert.strictEqual(lastProxyStop.mock.calls.length, 1); + assert.strictEqual(lastProxyStart.mock.calls.length, 1); + assert.deepStrictEqual(lastProxyStart.mock.calls[0].arguments, [9000]); + assert.strictEqual(oIAnalyticsMessageService.createFullConfigMessageIfNotPending.mock.calls.length, 1); + }); + + it('should update engine logger settings and encrypt loki password', async () => { + const command = JSON.parse(JSON.stringify(testData.engine.loggerCommand)); + command.loki.password = 'new password'; + engineRepository.get.mock.mockImplementation(() => testData.engine.settings); + + await service.updateEngineLogger(command, testData.users.list[0].id); + + const encryptionMock = mockEncryptionService.encryptionService as EncryptionServiceMock; + assert.strictEqual(encryptionMock.encryptText.mock.calls.length, 1); + assert.strictEqual(engineRepository.updateLogger.mock.calls.length, 1); + assert.strictEqual(loggerService.stop.mock.calls.length, 1); + assert.strictEqual(loggerService.start.mock.calls.length, 1); + assert.strictEqual(oIAnalyticsMessageService.createFullConfigMessageIfNotPending.mock.calls.length, 1); + }); + + it('should update engine logger settings and preserve loki password when empty', async () => { + const command = JSON.parse(JSON.stringify(testData.engine.loggerCommand)); + command.loki.password = ''; + engineRepository.get.mock.mockImplementation(() => testData.engine.settings); + + await service.updateEngineLogger(command, testData.users.list[0].id); + + const encryptionMock = mockEncryptionService.encryptionService as EncryptionServiceMock; + assert.strictEqual(encryptionMock.encryptText.mock.calls.length, 0); + assert.strictEqual(command.loki.password, testData.engine.settings.logParameters.loki.password); + assert.strictEqual(engineRepository.updateLogger.mock.calls.length, 1); + }); + it('should correctly restart OIBus', async () => { const processExitSpy = mock.method(process, 'exit', mock.fn()); diff --git a/backend/src/service/oibus.service.ts b/backend/src/service/oibus.service.ts index 59ac1be2c5..63ddd95d30 100644 --- a/backend/src/service/oibus.service.ts +++ b/backend/src/service/oibus.service.ts @@ -4,10 +4,14 @@ import { CacheSearchParam, CacheSearchResult, DataFolderType, + EngineLoggerCommandDTO, EngineMetrics, + EngineNameCommandDTO, + EngineProxyCommandDTO, EngineSettingsCommandDTO, EngineSettingsDTO, EngineSettingsUpdateResultDTO, + EngineWebServerCommandDTO, FileCacheContent, OIBusContent, OIBusInfo @@ -16,7 +20,13 @@ import JoiValidator from '../web-server/controllers/validators/joi.validator'; import EngineRepository from '../repository/config/engine.repository'; import { EngineSettings } from '../model/engine.model'; import { GetUserInfo } from '../../shared/model/types'; -import { engineSchema } from '../web-server/controllers/validators/oibus-validation-schema'; +import { + engineLoggerSchema, + engineNameSchema, + engineProxySchema, + engineSchema, + engineWebServerSchema +} from '../web-server/controllers/validators/oibus-validation-schema'; import { encryptionService } from './encryption.service'; import LoggerService from './logger/logger.service'; import type { IOIAnalyticsMessageService } from '../model/oianalytics-message.model'; @@ -154,7 +164,6 @@ export default class OIBusService { const settings = this.getEngineSettings(); if ( - !oldEngineSettings || JSON.stringify(oldEngineSettings.logParameters) !== JSON.stringify(settings.logParameters) || oldEngineSettings.name !== settings.name ) { @@ -180,6 +189,64 @@ export default class OIBusService { }; } + async updateEngineName(command: EngineNameCommandDTO, updatedBy: string): Promise { + await this.validator.validate(engineNameSchema, command); + this.engineRepository.updateName(command.name, updatedBy); + const settings = this.getEngineSettings(); + await this.resetLogger(settings); + this.oIAnalyticsMessageService.createFullConfigMessageIfNotPending(); + } + + async updateEngineWebServer(command: EngineWebServerCommandDTO, updatedBy: string): Promise { + await this.validator.validate(engineWebServerSchema, command); + const oldEngineSettings = this.getEngineSettings(); + if (command.port === oldEngineSettings.proxyPort) { + throw new Error('Web server port and proxy port can not be the same'); + } + this.engineRepository.updateWebServer(command, updatedBy); + const settings = this.getEngineSettings(); + const portChanged = command.port !== oldEngineSettings.port; + if (portChanged) { + setImmediate(() => { + this.portChangeEvent.emit('updated', settings.port); + }); + } + this.oIAnalyticsMessageService.createFullConfigMessageIfNotPending(); + return { + needsRedirect: portChanged, + newPort: portChanged ? settings.port : null + }; + } + + async updateEngineProxy(command: EngineProxyCommandDTO, updatedBy: string): Promise { + await this.validator.validate(engineProxySchema, command); + const oldEngineSettings = this.getEngineSettings(); + if (command.proxyEnabled && command.proxyPort === oldEngineSettings.port) { + throw new Error('Web server port and proxy port can not be the same'); + } + this.engineRepository.updateProxy(command, updatedBy); + const settings = this.getEngineSettings(); + await this.proxyServer.stop(); + if (settings.proxyEnabled) { + await this.proxyServer.start(settings.proxyPort!); + } + this.oIAnalyticsMessageService.createFullConfigMessageIfNotPending(); + } + + async updateEngineLogger(command: EngineLoggerCommandDTO, updatedBy: string): Promise { + await this.validator.validate(engineLoggerSchema, command); + const oldEngineSettings = this.getEngineSettings(); + if (!command.loki.password) { + command.loki.password = oldEngineSettings.logParameters.loki.password; + } else { + command.loki.password = await encryptionService.encryptText(command.loki.password); + } + this.engineRepository.updateLogger(command, updatedBy); + const settings = this.getEngineSettings(); + await this.resetLogger(settings); + this.oIAnalyticsMessageService.createFullConfigMessageIfNotPending(); + } + updateOIBusVersion(version: string, launcherVersion: string): void { this.engineRepository.updateVersion(version, launcherVersion); } @@ -325,6 +392,7 @@ export default class OIBusService { } } +/* c8 ignore next */ export const toEngineSettingsDTO = (engineSettings: EngineSettings, getUserInfo: GetUserInfo): EngineSettingsDTO => { return { id: engineSettings.id, diff --git a/backend/src/tests/__mocks__/repository/config/engine-repository.mock.ts b/backend/src/tests/__mocks__/repository/config/engine-repository.mock.ts index 766d202ef0..316d14ee7f 100644 --- a/backend/src/tests/__mocks__/repository/config/engine-repository.mock.ts +++ b/backend/src/tests/__mocks__/repository/config/engine-repository.mock.ts @@ -1,7 +1,12 @@ import { mock } from 'node:test'; import type { Database } from 'better-sqlite3'; import { EngineSettings } from '../../../../model/engine.model'; -import { EngineSettingsCommandDTO } from '../../../../../shared/model/engine.model'; +import { + EngineLoggerCommandDTO, + EngineProxyCommandDTO, + EngineSettingsCommandDTO, + EngineWebServerCommandDTO +} from '../../../../../shared/model/engine.model'; import EngineRepository from '../../../../repository/config/engine.repository'; /** @@ -16,5 +21,9 @@ export default class EngineRepositoryMock extends EngineRepository { } override get = mock.fn((): EngineSettings | null => null); override update = mock.fn((_command: EngineSettingsCommandDTO, _updatedBy: string): void => undefined); + override updateName = mock.fn((_name: string, _updatedBy: string): void => undefined); + override updateWebServer = mock.fn((_command: EngineWebServerCommandDTO, _updatedBy: string): void => undefined); + override updateProxy = mock.fn((_command: EngineProxyCommandDTO, _updatedBy: string): void => undefined); + override updateLogger = mock.fn((_command: EngineLoggerCommandDTO, _updatedBy: string): void => undefined); override updateVersion = mock.fn((_version: string, _launcherVersion: string): void => undefined); } diff --git a/backend/src/tests/__mocks__/service/oibus-service.mock.ts b/backend/src/tests/__mocks__/service/oibus-service.mock.ts index 9e207ff188..a2b995a0da 100644 --- a/backend/src/tests/__mocks__/service/oibus-service.mock.ts +++ b/backend/src/tests/__mocks__/service/oibus-service.mock.ts @@ -5,8 +5,12 @@ import { CacheSearchParam, CacheSearchResult, DataFolderType, + EngineLoggerCommandDTO, + EngineNameCommandDTO, + EngineProxyCommandDTO, EngineSettingsCommandDTO, EngineSettingsUpdateResultDTO, + EngineWebServerCommandDTO, FileCacheContent, OIBusContent, OIBusInfo @@ -24,6 +28,13 @@ export default class OIBusServiceMock { async (_command: EngineSettingsCommandDTO, _updatedBy: string): Promise => ({}) as EngineSettingsUpdateResultDTO ); + updateEngineName = mock.fn(async (_command: EngineNameCommandDTO, _updatedBy: string): Promise => undefined); + updateEngineWebServer = mock.fn( + async (_command: EngineWebServerCommandDTO, _updatedBy: string): Promise => + ({}) as EngineSettingsUpdateResultDTO + ); + updateEngineProxy = mock.fn(async (_command: EngineProxyCommandDTO, _updatedBy: string): Promise => undefined); + updateEngineLogger = mock.fn(async (_command: EngineLoggerCommandDTO, _updatedBy: string): Promise => undefined); updateOIBusVersion = mock.fn((_version: string, _launcherVersion: string): void => undefined); restart = mock.fn(async (): Promise => undefined); stop = mock.fn(async (): Promise => undefined); diff --git a/backend/src/tests/utils/test-data.ts b/backend/src/tests/utils/test-data.ts index bc58a27a1a..7ac21a52a0 100644 --- a/backend/src/tests/utils/test-data.ts +++ b/backend/src/tests/utils/test-data.ts @@ -11,8 +11,12 @@ import { IPFilterCommandDTO } from '../../../shared/model/ip-filter.model'; import { IPFilter } from '../../model/ip-filter.model'; import { EngineSettings } from '../../model/engine.model'; import { + EngineLoggerCommandDTO, EngineMetrics, + EngineNameCommandDTO, + EngineProxyCommandDTO, EngineSettingsCommandDTO, + EngineWebServerCommandDTO, HistoryQueryMetrics, NorthConnectorMetrics, OIBusContent, @@ -1417,6 +1421,17 @@ const engineSettingsCommand: EngineSettingsCommandDTO = { } } }; +const engineNameCommand: EngineNameCommandDTO = { + name: 'updated OIBus' +}; +const engineWebServerCommand: EngineWebServerCommandDTO = { + port: 3333 +}; +const engineProxyCommand: EngineProxyCommandDTO = { + proxyEnabled: true, + proxyPort: 9000 +}; +const engineLoggerCommand: EngineLoggerCommandDTO = engineSettingsCommand.logParameters; const engineMetrics: EngineMetrics = { metricsStart: '2020-01-01T00:00:00.000', processCpuUsageInstant: 0, @@ -2230,6 +2245,10 @@ export default Object.freeze({ securityKey: 'security-key' }, command: engineSettingsCommand, + nameCommand: engineNameCommand, + webServerCommand: engineWebServerCommand, + proxyCommand: engineProxyCommand, + loggerCommand: engineLoggerCommand, metrics: engineMetrics, oIBusInfo }, diff --git a/backend/src/tests/utils/test-utils.ts b/backend/src/tests/utils/test-utils.ts index 1525e752fe..5b5e4e4af0 100644 --- a/backend/src/tests/utils/test-utils.ts +++ b/backend/src/tests/utils/test-utils.ts @@ -120,7 +120,15 @@ export function mockModule(req: NodeJS.Require, modulePath: string, exports: unk } catch { // ensure the module has a cache entry before we replace its exports } - const resolved = req.resolve(modulePath); + let resolved: string; + try { + resolved = req.resolve(modulePath); + } catch { + // Module is not resolvable (e.g. optional native addon not installed in this environment). + // Skip cache injection — when the SUT calls require(modulePath) it will also fail to + // resolve and run its own error-handling path (e.g. return null). + return exports as Record; + } if (req.cache[resolved]) { req.cache[resolved]!.exports = exports; } else { diff --git a/backend/src/web-server/controllers/engine.controller.spec.ts b/backend/src/web-server/controllers/engine.controller.spec.ts index 1eaf26f4c5..9e4444e29a 100644 --- a/backend/src/web-server/controllers/engine.controller.spec.ts +++ b/backend/src/web-server/controllers/engine.controller.spec.ts @@ -1,7 +1,14 @@ import { describe, it, before, beforeEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; -import { EngineSettingsCommandDTO, EngineSettingsUpdateResultDTO } from '../../../shared/model/engine.model'; +import { + EngineLoggerCommandDTO, + EngineNameCommandDTO, + EngineProxyCommandDTO, + EngineSettingsCommandDTO, + EngineSettingsUpdateResultDTO, + EngineWebServerCommandDTO +} from '../../../shared/model/engine.model'; import { CustomExpressRequest } from '../express'; import testData from '../../tests/utils/test-data'; import { mockModule, reloadModule, fixTsoaModuleResolution, createMockServices } from '../../tests/utils/test-utils'; @@ -88,4 +95,44 @@ describe('EngineController', () => { it('should check OIBus status', async () => { await controller.getOIBusStatus(mockRequest as CustomExpressRequest); }); + + it('should update engine name', async () => { + const command: EngineNameCommandDTO = testData.engine.nameCommand; + oIBusService.updateEngineName = mock.fn(async () => undefined); + + await controller.updateEngineName(command, mockRequest as CustomExpressRequest); + + assert.strictEqual(oIBusService.updateEngineName.mock.calls.length, 1); + assert.deepStrictEqual(oIBusService.updateEngineName.mock.calls[0].arguments, [command, testData.users.list[0].id]); + }); + + it('should update engine web server settings', async () => { + const command: EngineWebServerCommandDTO = testData.engine.webServerCommand; + oIBusService.updateEngineWebServer = mock.fn(async () => ({}) as EngineSettingsUpdateResultDTO); + + await controller.updateEngineWebServer(command, mockRequest as CustomExpressRequest); + + assert.strictEqual(oIBusService.updateEngineWebServer.mock.calls.length, 1); + assert.deepStrictEqual(oIBusService.updateEngineWebServer.mock.calls[0].arguments, [command, testData.users.list[0].id]); + }); + + it('should update engine proxy settings', async () => { + const command: EngineProxyCommandDTO = testData.engine.proxyCommand; + oIBusService.updateEngineProxy = mock.fn(async () => undefined); + + await controller.updateEngineProxy(command, mockRequest as CustomExpressRequest); + + assert.strictEqual(oIBusService.updateEngineProxy.mock.calls.length, 1); + assert.deepStrictEqual(oIBusService.updateEngineProxy.mock.calls[0].arguments, [command, testData.users.list[0].id]); + }); + + it('should update engine logger settings', async () => { + const command: EngineLoggerCommandDTO = testData.engine.loggerCommand; + oIBusService.updateEngineLogger = mock.fn(async () => undefined); + + await controller.updateEngineLogger(command, mockRequest as CustomExpressRequest); + + assert.strictEqual(oIBusService.updateEngineLogger.mock.calls.length, 1); + assert.deepStrictEqual(oIBusService.updateEngineLogger.mock.calls[0].arguments, [command, testData.users.list[0].id]); + }); }); diff --git a/backend/src/web-server/controllers/engine.controller.ts b/backend/src/web-server/controllers/engine.controller.ts index c29affbd63..894cf87936 100644 --- a/backend/src/web-server/controllers/engine.controller.ts +++ b/backend/src/web-server/controllers/engine.controller.ts @@ -1,5 +1,14 @@ import { Body, Controller, Get, Post, Put, Request, Route, SuccessResponse, Tags } from 'tsoa'; -import { EngineSettingsCommandDTO, EngineSettingsDTO, EngineSettingsUpdateResultDTO, OIBusInfo } from '../../../shared/model/engine.model'; +import { + EngineLoggerCommandDTO, + EngineNameCommandDTO, + EngineProxyCommandDTO, + EngineSettingsCommandDTO, + EngineSettingsDTO, + EngineSettingsUpdateResultDTO, + EngineWebServerCommandDTO, + OIBusInfo +} from '../../../shared/model/engine.model'; import { CustomExpressRequest } from '../express'; import { toEngineSettingsDTO } from '../../service/oibus.service'; @@ -40,6 +49,54 @@ export class EngineController extends Controller { return await oIBusService.updateEngineSettings(command, request.user.id); } + /** + * Updates only the engine name + * @summary Update engine name + * @param {EngineNameCommandDTO} command.body.required - Engine name to update + */ + @Put('/name') + @SuccessResponse(200, 'Engine name updated successfully') + async updateEngineName(@Body() command: EngineNameCommandDTO, @Request() request: CustomExpressRequest): Promise { + return await request.services.oIBusService.updateEngineName(command, request.user.id); + } + + /** + * Updates only the web server port + * @summary Update web server settings + * @param {EngineWebServerCommandDTO} command.body.required - Web server settings to update + * @returns {EngineSettingsUpdateResultDTO} Information about whether a redirect is needed + */ + @Put('/web-server') + @SuccessResponse(200, 'Engine web server settings updated successfully') + async updateEngineWebServer( + @Body() command: EngineWebServerCommandDTO, + @Request() request: CustomExpressRequest + ): Promise { + return await request.services.oIBusService.updateEngineWebServer(command, request.user.id); + } + + /** + * Updates only the proxy settings + * @summary Update proxy settings + * @param {EngineProxyCommandDTO} command.body.required - Proxy settings to update + */ + @Put('/proxy') + @SuccessResponse(200, 'Engine proxy settings updated successfully') + async updateEngineProxy(@Body() command: EngineProxyCommandDTO, @Request() request: CustomExpressRequest): Promise { + return await request.services.oIBusService.updateEngineProxy(command, request.user.id); + } + + /** + * Updates only the logging parameters + * @summary Update logger settings + * @param {EngineLoggerCommandDTO} command.body.required - Logger settings to update + */ + @Put('/logger') + @SuccessResponse(200, 'Engine logger settings updated successfully') + async updateEngineLogger(@Body() command: EngineLoggerCommandDTO, @Request() request: CustomExpressRequest): Promise { + return await request.services.oIBusService.updateEngineLogger(command, request.user.id); + } + /** * Resets all metrics collected by the OIBus engine * @summary Reset metrics @@ -78,7 +135,8 @@ export class EngineController extends Controller { * @summary Check OIBus status */ @Get('/status') + /* c8 ignore next */ getOIBusStatus(@Request() _request: CustomExpressRequest): void { - return; + // intentionally empty — endpoint only checks that the server is reachable } } diff --git a/backend/src/web-server/controllers/validators/engine.validator.spec.ts b/backend/src/web-server/controllers/validators/engine.validator.spec.ts index d52ce33538..5a2cb4346a 100644 --- a/backend/src/web-server/controllers/validators/engine.validator.spec.ts +++ b/backend/src/web-server/controllers/validators/engine.validator.spec.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import JoiValidator from './joi.validator'; -import { engineSchema } from './oibus-validation-schema'; +import { engineLoggerSchema, engineNameSchema, engineProxySchema, engineSchema, engineWebServerSchema } from './oibus-validation-schema'; interface DataProvider { dto: object; @@ -164,3 +164,116 @@ describe('Engine validator', () => { }); } }); + +describe('Engine name validator', () => { + const validator: JoiValidator = new JoiValidator(); + + it('should accept valid name', async () => { + await assert.doesNotReject(validator.validate(engineNameSchema, { name: 'OIBus' })); + }); + + it('should reject null name', async () => { + await assert.rejects(validator.validate(engineNameSchema, { name: null }), { + message: '"name" must be a string' + }); + }); + + it('should reject empty name', async () => { + await assert.rejects(validator.validate(engineNameSchema, { name: '' }), { + message: '"name" is not allowed to be empty' + }); + }); +}); + +describe('Engine web server validator', () => { + const validator: JoiValidator = new JoiValidator(); + + it('should accept valid port', async () => { + await assert.doesNotReject(validator.validate(engineWebServerSchema, { port: 2223 })); + }); + + it('should reject null port', async () => { + await assert.rejects(validator.validate(engineWebServerSchema, { port: null }), { + message: '"port" must be a number' + }); + }); + + it('should reject invalid port number', async () => { + await assert.rejects(validator.validate(engineWebServerSchema, { port: 99999 }), { + message: '"port" must be a valid port' + }); + }); +}); + +describe('Engine proxy validator', () => { + const validator: JoiValidator = new JoiValidator(); + + it('should accept proxyEnabled false with null proxyPort', async () => { + await assert.doesNotReject(validator.validate(engineProxySchema, { proxyEnabled: false, proxyPort: null })); + }); + + it('should accept proxyEnabled true with valid proxyPort', async () => { + await assert.doesNotReject(validator.validate(engineProxySchema, { proxyEnabled: true, proxyPort: 9000 })); + }); + + it('should reject null proxyEnabled', async () => { + await assert.rejects(validator.validate(engineProxySchema, { proxyEnabled: null, proxyPort: null }), { + message: '"proxyEnabled" must be a boolean' + }); + }); + + it('should reject invalid proxyPort', async () => { + await assert.rejects(validator.validate(engineProxySchema, { proxyEnabled: true, proxyPort: 99999 }), { + message: '"proxyPort" must be a valid port' + }); + }); +}); + +describe('Engine logger validator', () => { + const validator: JoiValidator = new JoiValidator(); + + const validLogParameters = { + console: { level: 'silent' }, + file: { level: 'info', maxFileSize: 50, numberOfFiles: 5 }, + database: { level: 'info', maxNumberOfLogs: 100_000 }, + loki: { level: 'silent', interval: 60, address: '', username: '', password: '' }, + oia: { level: 'silent', interval: 10 } + }; + + it('should accept valid log parameters', async () => { + await assert.doesNotReject(validator.validate(engineLoggerSchema, validLogParameters)); + }); + + it('should reject null console', async () => { + await assert.rejects(validator.validate(engineLoggerSchema, { ...validLogParameters, console: null }), { + message: '"console" must be of type object' + }); + }); + + it('should reject empty log level', async () => { + await assert.rejects(validator.validate(engineLoggerSchema, { ...validLogParameters, console: { level: '' } }), { + message: '"console.level" is not allowed to be empty' + }); + }); + + it('should reject loki interval below minimum', async () => { + await assert.rejects( + validator.validate(engineLoggerSchema, { ...validLogParameters, loki: { ...validLogParameters.loki, interval: 5 } }), + { message: '"loki.interval" must be greater than or equal to 10' } + ); + }); + + it('should reject file maxFileSize below minimum', async () => { + await assert.rejects( + validator.validate(engineLoggerSchema, { ...validLogParameters, file: { ...validLogParameters.file, maxFileSize: 0 } }), + { message: '"file.maxFileSize" must be greater than or equal to 1' } + ); + }); + + it('should reject database maxNumberOfLogs below minimum', async () => { + await assert.rejects( + validator.validate(engineLoggerSchema, { ...validLogParameters, database: { level: 'info', maxNumberOfLogs: 1000 } }), + { message: '"database.maxNumberOfLogs" must be greater than or equal to 100000' } + ); + }); +}); diff --git a/backend/src/web-server/controllers/validators/oibus-validation-schema.ts b/backend/src/web-server/controllers/validators/oibus-validation-schema.ts index 38c9b2dccd..6ea6269e19 100644 --- a/backend/src/web-server/controllers/validators/oibus-validation-schema.ts +++ b/backend/src/web-server/controllers/validators/oibus-validation-schema.ts @@ -26,38 +26,55 @@ const certificateSchema: Joi.ObjectSchema = Joi.object({ }) }); +const logParametersSchema = Joi.object({ + console: Joi.object({ + level: Joi.string().required().allow('silent', 'error', 'warning', 'info', 'debug', 'trace') + }), + file: Joi.object({ + level: Joi.string().required().allow('silent', 'error', 'warning', 'info', 'debug', 'trace'), + maxFileSize: Joi.number().integer().required().min(1), + numberOfFiles: Joi.number().integer().required().min(1) + }), + database: Joi.object({ + level: Joi.string().required().allow('silent', 'error', 'warning', 'info', 'debug', 'trace'), + maxNumberOfLogs: Joi.number().integer().required().min(100_000) + }), + loki: Joi.object({ + level: Joi.string().required().allow('silent', 'error', 'warning', 'info', 'debug', 'trace'), + interval: Joi.number().integer().required().min(10), + address: Joi.string().allow('', null), + username: Joi.string().allow('', null), + password: Joi.string().allow('', null) + }), + oia: Joi.object({ + level: Joi.string().required().allow('silent', 'error', 'warning', 'info', 'debug', 'trace'), + interval: Joi.number().integer().required().min(10) + }) +}); + const engineSchema: Joi.ObjectSchema = Joi.object({ name: Joi.string().required(), port: Joi.number().required().port(), proxyEnabled: Joi.boolean().required(), proxyPort: Joi.number().port().optional().allow(null), - logParameters: Joi.object({ - console: Joi.object({ - level: Joi.string().required().allow('silent', 'error', 'warning', 'info', 'debug', 'trace') - }), - file: Joi.object({ - level: Joi.string().required().allow('silent', 'error', 'warning', 'info', 'debug', 'trace'), - maxFileSize: Joi.number().integer().required().min(1), - numberOfFiles: Joi.number().integer().required().min(1) - }), - database: Joi.object({ - level: Joi.string().required().allow('silent', 'error', 'warning', 'info', 'debug', 'trace'), - maxNumberOfLogs: Joi.number().integer().required().min(100_000) - }), - loki: Joi.object({ - level: Joi.string().required().allow('silent', 'error', 'warning', 'info', 'debug', 'trace'), - interval: Joi.number().integer().required().min(10), - address: Joi.string().allow('', null), - username: Joi.string().allow('', null), - password: Joi.string().allow('', null) - }), - oia: Joi.object({ - level: Joi.string().required().allow('silent', 'error', 'warning', 'info', 'debug', 'trace'), - interval: Joi.number().integer().required().min(10) - }) - }) + logParameters: logParametersSchema +}); + +const engineNameSchema: Joi.ObjectSchema = Joi.object({ + name: Joi.string().required() }); +const engineWebServerSchema: Joi.ObjectSchema = Joi.object({ + port: Joi.number().required().port() +}); + +const engineProxySchema: Joi.ObjectSchema = Joi.object({ + proxyEnabled: Joi.boolean().required(), + proxyPort: Joi.number().port().optional().allow(null) +}); + +const engineLoggerSchema = logParametersSchema; + const registrationSchema: Joi.ObjectSchema = Joi.object({ host: Joi.string().required(), acceptUnauthorized: Joi.boolean().required(), @@ -149,4 +166,16 @@ function cronValidator(value: string, helper: Joi.CustomHelpers) { return cronValidation.isValid ? true : helper.message({ custom: cronValidation.errorMessage }); } -export { scanModeSchema, certificateSchema, engineSchema, registrationSchema, ipFilterSchema, userSchema, transformerSchema }; +export { + scanModeSchema, + certificateSchema, + engineSchema, + engineNameSchema, + engineWebServerSchema, + engineProxySchema, + engineLoggerSchema, + registrationSchema, + ipFilterSchema, + userSchema, + transformerSchema +}; diff --git a/backend/src/web-server/routes.ts b/backend/src/web-server/routes.ts index 87384cecc3..4a9067b43f 100644 --- a/backend/src/web-server/routes.ts +++ b/backend/src/web-server/routes.ts @@ -7529,6 +7529,36 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "EngineNameCommandDTO": { + "dataType": "refObject", + "properties": { + "name": {"dataType":"string","required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "EngineWebServerCommandDTO": { + "dataType": "refObject", + "properties": { + "port": {"dataType":"double","required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "EngineProxyCommandDTO": { + "dataType": "refObject", + "properties": { + "proxyEnabled": {"dataType":"boolean","required":true}, + "proxyPort": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "EngineLoggerCommandDTO": { + "dataType": "refAlias", + "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"oia":{"dataType":"nestedObjectLiteral","nestedProperties":{"interval":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"loki":{"dataType":"nestedObjectLiteral","nestedProperties":{"password":{"dataType":"string","required":true},"username":{"dataType":"string","required":true},"address":{"dataType":"string","required":true},"interval":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"database":{"dataType":"nestedObjectLiteral","nestedProperties":{"maxNumberOfLogs":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"file":{"dataType":"nestedObjectLiteral","nestedProperties":{"numberOfFiles":{"dataType":"double","required":true},"maxFileSize":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"console":{"dataType":"nestedObjectLiteral","nestedProperties":{"level":{"ref":"LogLevel","required":true}},"required":true}},"validators":{}}, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "OIBusInfo": { "dataType": "refObject", "properties": { @@ -11481,6 +11511,130 @@ export function RegisterRoutes(app: Router,opts?:{multer?:ReturnType = { + command: {"in":"body","name":"command","required":true,"ref":"EngineNameCommandDTO"}, + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + }; + app.put('/api/engine/name', + ...(fetchMiddlewares(EngineController)), + ...(fetchMiddlewares(EngineController.prototype.updateEngineName)), + + async function EngineController_updateEngineName(request: ExRequest, response: ExResponse, next: any) { + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args: argsEngineController_updateEngineName, request, response }); + + const controller = new EngineController(); + + await templateService.apiHandler({ + methodName: 'updateEngineName', + controller, + response, + next, + validatedArgs, + successStatus: 200, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + const argsEngineController_updateEngineWebServer: Record = { + command: {"in":"body","name":"command","required":true,"ref":"EngineWebServerCommandDTO"}, + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + }; + app.put('/api/engine/web-server', + ...(fetchMiddlewares(EngineController)), + ...(fetchMiddlewares(EngineController.prototype.updateEngineWebServer)), + + async function EngineController_updateEngineWebServer(request: ExRequest, response: ExResponse, next: any) { + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args: argsEngineController_updateEngineWebServer, request, response }); + + const controller = new EngineController(); + + await templateService.apiHandler({ + methodName: 'updateEngineWebServer', + controller, + response, + next, + validatedArgs, + successStatus: 200, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + const argsEngineController_updateEngineProxy: Record = { + command: {"in":"body","name":"command","required":true,"ref":"EngineProxyCommandDTO"}, + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + }; + app.put('/api/engine/proxy', + ...(fetchMiddlewares(EngineController)), + ...(fetchMiddlewares(EngineController.prototype.updateEngineProxy)), + + async function EngineController_updateEngineProxy(request: ExRequest, response: ExResponse, next: any) { + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args: argsEngineController_updateEngineProxy, request, response }); + + const controller = new EngineController(); + + await templateService.apiHandler({ + methodName: 'updateEngineProxy', + controller, + response, + next, + validatedArgs, + successStatus: 200, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + const argsEngineController_updateEngineLogger: Record = { + command: {"in":"body","name":"command","required":true,"ref":"EngineLoggerCommandDTO"}, + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + }; + app.put('/api/engine/logger', + ...(fetchMiddlewares(EngineController)), + ...(fetchMiddlewares(EngineController.prototype.updateEngineLogger)), + + async function EngineController_updateEngineLogger(request: ExRequest, response: ExResponse, next: any) { + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args: argsEngineController_updateEngineLogger, request, response }); + + const controller = new EngineController(); + + await templateService.apiHandler({ + methodName: 'updateEngineLogger', + controller, + response, + next, + validatedArgs, + successStatus: 200, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa const argsEngineController_resetEngineMetrics: Record = { request: {"in":"request","name":"request","required":true,"dataType":"object"}, }; diff --git a/documentation/docs/api/openapi.json b/documentation/docs/api/openapi.json index c3f4bef574..f4c39d72be 100644 --- a/documentation/docs/api/openapi.json +++ b/documentation/docs/api/openapi.json @@ -31495,6 +31495,196 @@ "type": "object", "additionalProperties": false }, + "EngineNameCommandDTO": { + "description": "Engine name command Data Transfer Object.\nUsed as the request body for updating only the engine name.", + "properties": { + "name": { + "type": "string", + "description": "The name of the engine.", + "example": "OIBus OT" + } + }, + "required": [ + "name" + ], + "type": "object", + "additionalProperties": false + }, + "EngineWebServerCommandDTO": { + "description": "Engine web server command Data Transfer Object.\nUsed as the request body for updating only the web server port.", + "properties": { + "port": { + "type": "number", + "format": "double", + "description": "The port on which the engine listens.", + "example": 8080 + } + }, + "required": [ + "port" + ], + "type": "object", + "additionalProperties": false + }, + "EngineProxyCommandDTO": { + "description": "Engine proxy command Data Transfer Object.\nUsed as the request body for updating only the proxy settings.", + "properties": { + "proxyEnabled": { + "type": "boolean", + "description": "Whether the proxy is enabled.", + "example": false + }, + "proxyPort": { + "type": "number", + "format": "double", + "nullable": true, + "description": "The port for the proxy, if enabled.", + "example": null + } + }, + "required": [ + "proxyEnabled", + "proxyPort" + ], + "type": "object", + "additionalProperties": false + }, + "EngineLoggerCommandDTO": { + "properties": { + "oia": { + "properties": { + "interval": { + "type": "number", + "format": "double", + "description": "The interval in seconds for sending logs to OIA.", + "example": 60 + }, + "level": { + "$ref": "#/components/schemas/LogLevel", + "description": "The log level for OIA output.", + "example": "info" + } + }, + "required": [ + "interval", + "level" + ], + "type": "object", + "description": "OIA logging configuration." + }, + "loki": { + "properties": { + "password": { + "type": "string", + "description": "The password for Loki authentication.", + "example": "pass" + }, + "username": { + "type": "string", + "description": "The username for Loki authentication.", + "example": "user" + }, + "address": { + "type": "string", + "description": "The address of the Loki server.", + "example": "http://loki:3100" + }, + "interval": { + "type": "number", + "format": "double", + "description": "The interval in seconds for sending logs to Loki.", + "example": 60 + }, + "level": { + "$ref": "#/components/schemas/LogLevel", + "description": "The log level for Loki output.", + "example": "error" + } + }, + "required": [ + "password", + "username", + "address", + "interval", + "level" + ], + "type": "object", + "description": "Loki logging configuration." + }, + "database": { + "properties": { + "maxNumberOfLogs": { + "type": "number", + "format": "double", + "description": "The maximum number of logs to keep in the database.", + "example": 10000 + }, + "level": { + "$ref": "#/components/schemas/LogLevel", + "description": "The log level for database output.", + "example": "warn" + } + }, + "required": [ + "maxNumberOfLogs", + "level" + ], + "type": "object", + "description": "Database logging configuration." + }, + "file": { + "properties": { + "numberOfFiles": { + "type": "number", + "format": "double", + "description": "The number of log files to keep.", + "example": 5 + }, + "maxFileSize": { + "type": "number", + "format": "double", + "description": "The maximum size of a log file in bytes.", + "example": 10485760 + }, + "level": { + "$ref": "#/components/schemas/LogLevel", + "description": "The log level for file output.", + "example": "debug" + } + }, + "required": [ + "numberOfFiles", + "maxFileSize", + "level" + ], + "type": "object", + "description": "File logging configuration." + }, + "console": { + "properties": { + "level": { + "$ref": "#/components/schemas/LogLevel", + "description": "The log level for console output.", + "example": "info" + } + }, + "required": [ + "level" + ], + "type": "object", + "description": "Console logging configuration." + } + }, + "required": [ + "oia", + "loki", + "database", + "file", + "console" + ], + "type": "object", + "description": "Engine logger command Data Transfer Object.\nUsed as the request body for updating only the logging parameters.\nThe log category objects are top-level (no wrapper key)." + }, "OIBusInfo": { "description": "Information about the OIBus instance.", "properties": { @@ -36288,6 +36478,121 @@ } } }, + "/api/engine/name": { + "put": { + "operationId": "UpdateEngineName", + "responses": { + "200": { + "description": "Engine name updated successfully" + } + }, + "description": "Updates only the engine name", + "summary": "Update engine name", + "tags": [ + "Engine" + ], + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EngineNameCommandDTO" + } + } + } + } + } + }, + "/api/engine/web-server": { + "put": { + "operationId": "UpdateEngineWebServer", + "responses": { + "200": { + "description": "Engine web server settings updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EngineSettingsUpdateResultDTO" + } + } + } + } + }, + "description": "Updates only the web server port", + "summary": "Update web server settings", + "tags": [ + "Engine" + ], + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EngineWebServerCommandDTO" + } + } + } + } + } + }, + "/api/engine/proxy": { + "put": { + "operationId": "UpdateEngineProxy", + "responses": { + "200": { + "description": "Engine proxy settings updated successfully" + } + }, + "description": "Updates only the proxy settings", + "summary": "Update proxy settings", + "tags": [ + "Engine" + ], + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EngineProxyCommandDTO" + } + } + } + } + } + }, + "/api/engine/logger": { + "put": { + "operationId": "UpdateEngineLogger", + "responses": { + "200": { + "description": "Engine logger settings updated successfully" + } + }, + "description": "Updates only the logging parameters", + "summary": "Update logger settings", + "tags": [ + "Engine" + ], + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EngineLoggerCommandDTO" + } + } + } + } + } + }, "/api/engine/metrics/reset": { "post": { "operationId": "ResetEngineMetrics", diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 35608cb32d..79449372ce 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -2,7 +2,6 @@ import { Routes } from '@angular/router'; import { HomeComponent } from './home/home.component'; import { LogsComponent } from './logs/logs.component'; import { AboutComponent } from './about/about.component'; -import { EditEngineComponent } from './engine/edit-engine/edit-engine.component'; import { SouthListComponent } from './south/south-list.component'; import { NorthListComponent } from './north/north-list.component'; import { EngineDetailComponent } from './engine/engine-detail.component'; @@ -39,11 +38,6 @@ export const ROUTES: Routes = [ path: 'engine/oianalytics', component: OIARegistrationComponent }, - { - path: 'engine/edit', - component: EditEngineComponent, - canDeactivate: [UnsavedChangesGuard] - }, { path: 'north', component: NorthListComponent @@ -84,11 +78,6 @@ export const ROUTES: Routes = [ path: 'south/:southId', component: SouthDetailComponent }, - { - path: 'engine/edit', - component: EditEngineComponent, - canDeactivate: [UnsavedChangesGuard] - }, { path: 'history-queries', component: HistoryQueryListComponent diff --git a/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.html b/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.html new file mode 100644 index 0000000000..930b70e609 --- /dev/null +++ b/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.html @@ -0,0 +1,167 @@ + + + diff --git a/frontend/src/app/engine/edit-engine/edit-engine.component.scss b/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.scss similarity index 100% rename from frontend/src/app/engine/edit-engine/edit-engine.component.scss rename to frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.scss diff --git a/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.spec.ts b/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.spec.ts new file mode 100644 index 0000000000..a9bc48a2ac --- /dev/null +++ b/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.spec.ts @@ -0,0 +1,82 @@ +import { TestBed } from '@angular/core/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { of } from 'rxjs'; +import { page } from 'vitest/browser'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { provideI18nTesting } from '../../../i18n/mock-i18n'; +import { DefaultValidationErrorsComponent } from '../../shared/default-validation-errors/default-validation-errors.component'; +import { EditEngineLoggerModalComponent } from './edit-engine-logger-modal.component'; +import { createMock, MockObject } from '../../../test/vitest-create-mock'; +import { EngineService } from '../../services/engine.service'; +import { NotificationService } from '../../shared/notification.service'; +import { EngineSettingsDTO } from '../../../../../backend/shared/model/engine.model'; + +const engineSettings = { + logParameters: { + console: { level: 'silent' }, + file: { level: 'info', maxFileSize: 50, numberOfFiles: 5 }, + database: { level: 'info', maxNumberOfLogs: 100_000 }, + loki: { level: 'silent', interval: 60, address: '', username: '', password: '' }, + oia: { level: 'silent', interval: 10 } + } +} as EngineSettingsDTO; + +class EditEngineLoggerModalTester { + readonly fixture = TestBed.createComponent(EditEngineLoggerModalComponent); + readonly root = page.elementLocator(this.fixture.nativeElement); + readonly consoleLevelSelect = this.root.getByCss('#console-level'); + readonly fileLevelSelect = this.root.getByCss('#file-level'); + readonly databaseLevelSelect = this.root.getByCss('#database-level'); + readonly oiaLevelSelect = this.root.getByCss('#oia-level'); + readonly saveButton = this.root.getByCss('#save-logger-button'); + readonly cancelButton = this.root.getByCss('#cancel-logger-button'); +} + +describe('EditEngineLoggerModalComponent', () => { + let activeModal: MockObject; + let engineService: MockObject; + let notificationService: MockObject; + + beforeEach(() => { + activeModal = createMock(NgbActiveModal); + engineService = createMock(EngineService); + notificationService = createMock(NotificationService); + + TestBed.configureTestingModule({ + providers: [ + provideI18nTesting(), + { provide: NgbActiveModal, useValue: activeModal }, + { provide: EngineService, useValue: engineService }, + { provide: NotificationService, useValue: notificationService } + ] + }); + + TestBed.createComponent(DefaultValidationErrorsComponent).detectChanges(); + }); + + test('should initialize the form with logger settings', async () => { + const tester = new EditEngineLoggerModalTester(); + tester.fixture.componentInstance.initialize(engineSettings); + tester.fixture.detectChanges(); + await expect.element(tester.consoleLevelSelect).toHaveValue('0: silent'); + }); + + test('should save logger settings and close modal', async () => { + engineService.updateEngineLogger.mockReturnValue(of(undefined)); + const tester = new EditEngineLoggerModalTester(); + tester.fixture.componentInstance.initialize(engineSettings); + tester.fixture.detectChanges(); + await tester.saveButton.click(); + expect(engineService.updateEngineLogger).toHaveBeenCalled(); + expect(notificationService.success).toHaveBeenCalledWith('engine.updated'); + expect(activeModal.close).toHaveBeenCalled(); + }); + + test('should dismiss modal on cancel', async () => { + const tester = new EditEngineLoggerModalTester(); + tester.fixture.detectChanges(); + await tester.cancelButton.click(); + expect(engineService.updateEngineLogger).not.toHaveBeenCalled(); + expect(activeModal.dismiss).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.ts b/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.ts new file mode 100644 index 0000000000..35dd63607c --- /dev/null +++ b/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.ts @@ -0,0 +1,219 @@ +import { Component, inject } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateDirective } from '@ngx-translate/core'; +import { EngineService } from '../../services/engine.service'; +import { NotificationService } from '../../shared/notification.service'; +import { EngineSettingsDTO } from '../../../../../backend/shared/model/engine.model'; +import { OI_FORM_VALIDATION_DIRECTIVES } from '../../shared/form/form-validation-directives'; +import { LOG_LEVELS, LogLevel } from '../../../../../backend/shared/model/logs.model'; + +@Component({ + selector: 'oib-edit-engine-logger-modal', + templateUrl: './edit-engine-logger-modal.component.html', + styleUrl: './edit-engine-logger-modal.component.scss', + imports: [TranslateDirective, ReactiveFormsModule, OI_FORM_VALIDATION_DIRECTIVES] +}) +export class EditEngineLoggerModalComponent { + private modal = inject(NgbActiveModal); + private engineService = inject(EngineService); + private notificationService = inject(NotificationService); + + readonly logLevels = LOG_LEVELS; + + private fb = inject(NonNullableFormBuilder); + + form = this.fb.group({ + logParameters: this.fb.group({ + console: this.fb.group({ + level: ['silent' as LogLevel, Validators.required] + }), + file: this.fb.group({ + level: ['info' as LogLevel, Validators.required], + maxFileSize: [null as number | null, [Validators.required, Validators.min(1), Validators.max(50)]], + numberOfFiles: [null as number | null, [Validators.required, Validators.min(1)]] + }), + database: this.fb.group({ + level: ['info' as LogLevel, Validators.required], + maxNumberOfLogs: [null as number | null, [Validators.required, Validators.min(100_000)]] + }), + loki: this.fb.group({ + level: ['silent' as LogLevel, Validators.required], + interval: [null as number | null, [Validators.required, Validators.min(10)]], + address: ['', Validators.pattern(/http.*/)], + username: null as string | null, + password: null as string | null + }), + oia: this.fb.group({ + level: ['silent' as LogLevel, Validators.required], + interval: [null as number | null, [Validators.required, Validators.min(10)]] + }) + }) + }); + + constructor() { + const logParams = this.form.controls.logParameters; + + logParams.controls.oia.controls.level.valueChanges.subscribe(level => { + const intervalControl = logParams.controls.oia.controls.interval; + if (level === 'silent') { + intervalControl.clearValidators(); + intervalControl.disable(); + } else { + intervalControl.setValidators([Validators.required, Validators.min(10)]); + intervalControl.enable(); + } + intervalControl.updateValueAndValidity(); + }); + + logParams.controls.database.controls.level.valueChanges.subscribe(level => { + const maxLogsControl = logParams.controls.database.controls.maxNumberOfLogs; + if (level === 'silent') { + maxLogsControl.clearValidators(); + maxLogsControl.disable(); + } else { + maxLogsControl.setValidators([Validators.required, Validators.min(100_000)]); + maxLogsControl.enable(); + } + maxLogsControl.updateValueAndValidity(); + }); + + logParams.controls.file.controls.level.valueChanges.subscribe(level => { + const maxFileSizeControl = logParams.controls.file.controls.maxFileSize; + const numberOfFilesControl = logParams.controls.file.controls.numberOfFiles; + if (level === 'silent') { + maxFileSizeControl.clearValidators(); + maxFileSizeControl.disable(); + numberOfFilesControl.clearValidators(); + numberOfFilesControl.disable(); + } else { + maxFileSizeControl.setValidators([Validators.required, Validators.min(1), Validators.max(50)]); + maxFileSizeControl.enable(); + numberOfFilesControl.setValidators([Validators.required, Validators.min(1)]); + numberOfFilesControl.enable(); + } + maxFileSizeControl.updateValueAndValidity(); + numberOfFilesControl.updateValueAndValidity(); + }); + + logParams.controls.loki.controls.level.valueChanges.subscribe(level => { + const intervalControl = logParams.controls.loki.controls.interval; + const addressControl = logParams.controls.loki.controls.address; + if (level === 'silent') { + intervalControl.clearValidators(); + intervalControl.disable(); + addressControl.clearValidators(); + addressControl.disable(); + } else { + intervalControl.setValidators([Validators.required, Validators.min(10)]); + intervalControl.enable(); + addressControl.setValidators([Validators.pattern(/http.*/)]); + addressControl.enable(); + } + intervalControl.updateValueAndValidity(); + addressControl.updateValueAndValidity(); + }); + } + + isLevelSilent(category: 'console' | 'file' | 'database' | 'loki' | 'oia'): boolean { + return this.form.controls.logParameters.controls[category].controls.level.value === 'silent'; + } + + initialize(settings: EngineSettingsDTO) { + this.form.patchValue({ logParameters: settings.logParameters }); + this.initializeValidators(); + } + + private initializeValidators(): void { + const logParams = this.form.controls.logParameters; + + const oiaLevel = logParams.controls.oia.controls.level.value; + const oiaInterval = logParams.controls.oia.controls.interval; + if (oiaLevel === 'silent') { + oiaInterval.clearValidators(); + oiaInterval.disable(); + } else { + oiaInterval.setValidators([Validators.required, Validators.min(10)]); + oiaInterval.enable(); + } + + const dbLevel = logParams.controls.database.controls.level.value; + const maxLogs = logParams.controls.database.controls.maxNumberOfLogs; + if (dbLevel === 'silent') { + maxLogs.clearValidators(); + maxLogs.disable(); + } else { + maxLogs.setValidators([Validators.required, Validators.min(100_000)]); + maxLogs.enable(); + } + + const fileLevel = logParams.controls.file.controls.level.value; + const maxFileSize = logParams.controls.file.controls.maxFileSize; + const numberOfFiles = logParams.controls.file.controls.numberOfFiles; + if (fileLevel === 'silent') { + maxFileSize.clearValidators(); + maxFileSize.disable(); + numberOfFiles.clearValidators(); + numberOfFiles.disable(); + } else { + maxFileSize.setValidators([Validators.required, Validators.min(1), Validators.max(50)]); + maxFileSize.enable(); + numberOfFiles.setValidators([Validators.required, Validators.min(1)]); + numberOfFiles.enable(); + } + + const lokiLevel = logParams.controls.loki.controls.level.value; + const lokiInterval = logParams.controls.loki.controls.interval; + const lokiAddress = logParams.controls.loki.controls.address; + if (lokiLevel === 'silent') { + lokiInterval.clearValidators(); + lokiInterval.disable(); + lokiAddress.clearValidators(); + lokiAddress.disable(); + } else { + lokiInterval.setValidators([Validators.required, Validators.min(10)]); + lokiInterval.enable(); + lokiAddress.setValidators([Validators.pattern(/http.*/)]); + lokiAddress.enable(); + } + } + + save() { + if (!this.form.valid) { + return; + } + const formValue = this.form.getRawValue(); + this.engineService + .updateEngineLogger({ + console: { level: formValue.logParameters.console.level }, + file: { + level: formValue.logParameters.file.level, + maxFileSize: formValue.logParameters.file.maxFileSize!, + numberOfFiles: formValue.logParameters.file.numberOfFiles! + }, + database: { + level: formValue.logParameters.database.level, + maxNumberOfLogs: formValue.logParameters.database.maxNumberOfLogs! + }, + loki: { + level: formValue.logParameters.loki.level, + interval: formValue.logParameters.loki.interval!, + address: formValue.logParameters.loki.address, + username: formValue.logParameters.loki.username ?? '', + password: formValue.logParameters.loki.password ?? '' + }, + oia: { + level: formValue.logParameters.oia.level, + interval: formValue.logParameters.oia.interval! + } + }) + .subscribe(() => { + this.notificationService.success('engine.updated'); + this.modal.close(); + }); + } + + cancel() { + this.modal.dismiss(); + } +} diff --git a/frontend/src/app/engine/edit-engine-name-modal/edit-engine-name-modal.component.html b/frontend/src/app/engine/edit-engine-name-modal/edit-engine-name-modal.component.html new file mode 100644 index 0000000000..e95cd559c8 --- /dev/null +++ b/frontend/src/app/engine/edit-engine-name-modal/edit-engine-name-modal.component.html @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/app/engine/edit-engine-name-modal/edit-engine-name-modal.component.scss b/frontend/src/app/engine/edit-engine-name-modal/edit-engine-name-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/app/engine/edit-engine-name-modal/edit-engine-name-modal.component.spec.ts b/frontend/src/app/engine/edit-engine-name-modal/edit-engine-name-modal.component.spec.ts new file mode 100644 index 0000000000..343c2cf46f --- /dev/null +++ b/frontend/src/app/engine/edit-engine-name-modal/edit-engine-name-modal.component.spec.ts @@ -0,0 +1,81 @@ +import { TestBed } from '@angular/core/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { of } from 'rxjs'; +import { page } from 'vitest/browser'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { provideI18nTesting } from '../../../i18n/mock-i18n'; +import { DefaultValidationErrorsComponent } from '../../shared/default-validation-errors/default-validation-errors.component'; +import { EditEngineNameModalComponent } from './edit-engine-name-modal.component'; +import { createMock, MockObject } from '../../../test/vitest-create-mock'; +import { EngineService } from '../../services/engine.service'; +import { NotificationService } from '../../shared/notification.service'; +import { EngineSettingsDTO } from '../../../../../backend/shared/model/engine.model'; + +const engineSettings = { name: 'OIBus' } as EngineSettingsDTO; + +class EditEngineNameModalTester { + readonly fixture = TestBed.createComponent(EditEngineNameModalComponent); + readonly root = page.elementLocator(this.fixture.nativeElement); + readonly nameInput = this.root.getByCss('#name'); + readonly saveButton = this.root.getByCss('#save-name-button'); + readonly cancelButton = this.root.getByCss('#cancel-name-button'); +} + +describe('EditEngineNameModalComponent', () => { + let activeModal: MockObject; + let engineService: MockObject; + let notificationService: MockObject; + + beforeEach(() => { + activeModal = createMock(NgbActiveModal); + engineService = createMock(EngineService); + notificationService = createMock(NotificationService); + + TestBed.configureTestingModule({ + providers: [ + provideI18nTesting(), + { provide: NgbActiveModal, useValue: activeModal }, + { provide: EngineService, useValue: engineService }, + { provide: NotificationService, useValue: notificationService } + ] + }); + + TestBed.createComponent(DefaultValidationErrorsComponent).detectChanges(); + }); + + test('should initialize the form with engine name', async () => { + const tester = new EditEngineNameModalTester(); + tester.fixture.componentInstance.initialize(engineSettings); + tester.fixture.detectChanges(); + await expect.element(tester.nameInput).toHaveValue(engineSettings.name); + }); + + test('should not save when name is empty', async () => { + const tester = new EditEngineNameModalTester(); + tester.fixture.componentInstance.initialize(engineSettings); + tester.fixture.detectChanges(); + await tester.nameInput.fill(''); + await tester.saveButton.click(); + expect(engineService.updateEngineName).not.toHaveBeenCalled(); + }); + + test('should save the name and close modal', async () => { + engineService.updateEngineName.mockReturnValue(of(undefined)); + const tester = new EditEngineNameModalTester(); + tester.fixture.componentInstance.initialize(engineSettings); + tester.fixture.detectChanges(); + await tester.nameInput.fill('new name'); + await tester.saveButton.click(); + expect(engineService.updateEngineName).toHaveBeenCalledWith({ name: 'new name' }); + expect(notificationService.success).toHaveBeenCalledWith('engine.updated'); + expect(activeModal.close).toHaveBeenCalled(); + }); + + test('should dismiss modal on cancel', async () => { + const tester = new EditEngineNameModalTester(); + tester.fixture.detectChanges(); + await tester.cancelButton.click(); + expect(engineService.updateEngineName).not.toHaveBeenCalled(); + expect(activeModal.dismiss).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/engine/edit-engine-name-modal/edit-engine-name-modal.component.ts b/frontend/src/app/engine/edit-engine-name-modal/edit-engine-name-modal.component.ts new file mode 100644 index 0000000000..0b8901b63f --- /dev/null +++ b/frontend/src/app/engine/edit-engine-name-modal/edit-engine-name-modal.component.ts @@ -0,0 +1,43 @@ +import { Component, inject } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateDirective } from '@ngx-translate/core'; +import { EngineService } from '../../services/engine.service'; +import { NotificationService } from '../../shared/notification.service'; +import { EngineSettingsDTO } from '../../../../../backend/shared/model/engine.model'; +import { OI_FORM_VALIDATION_DIRECTIVES } from '../../shared/form/form-validation-directives'; + +@Component({ + selector: 'oib-edit-engine-name-modal', + templateUrl: './edit-engine-name-modal.component.html', + styleUrl: './edit-engine-name-modal.component.scss', + imports: [TranslateDirective, ReactiveFormsModule, OI_FORM_VALIDATION_DIRECTIVES] +}) +export class EditEngineNameModalComponent { + private modal = inject(NgbActiveModal); + private engineService = inject(EngineService); + private notificationService = inject(NotificationService); + + form = inject(NonNullableFormBuilder).group({ + name: ['', Validators.required] + }); + + initialize(settings: EngineSettingsDTO) { + this.form.patchValue({ name: settings.name }); + } + + save() { + if (!this.form.valid) { + return; + } + const { name } = this.form.getRawValue(); + this.engineService.updateEngineName({ name }).subscribe(() => { + this.notificationService.success('engine.updated'); + this.modal.close(); + }); + } + + cancel() { + this.modal.dismiss(); + } +} diff --git a/frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.html b/frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.html new file mode 100644 index 0000000000..c2e8c7978b --- /dev/null +++ b/frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.html @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.scss b/frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.spec.ts b/frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.spec.ts new file mode 100644 index 0000000000..1e82ca2638 --- /dev/null +++ b/frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.spec.ts @@ -0,0 +1,76 @@ +import { TestBed } from '@angular/core/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { of } from 'rxjs'; +import { page } from 'vitest/browser'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { provideI18nTesting } from '../../../i18n/mock-i18n'; +import { DefaultValidationErrorsComponent } from '../../shared/default-validation-errors/default-validation-errors.component'; +import { EditEngineProxyModalComponent } from './edit-engine-proxy-modal.component'; +import { createMock, MockObject } from '../../../test/vitest-create-mock'; +import { EngineService } from '../../services/engine.service'; +import { NotificationService } from '../../shared/notification.service'; +import { EngineSettingsDTO } from '../../../../../backend/shared/model/engine.model'; + +const engineSettings = { proxyEnabled: false, proxyPort: null } as EngineSettingsDTO; + +class EditEngineProxyModalTester { + readonly fixture = TestBed.createComponent(EditEngineProxyModalComponent); + readonly root = page.elementLocator(this.fixture.nativeElement); + readonly proxyEnabledCheckbox = this.root.getByCss('#proxy-enabled'); + readonly saveButton = this.root.getByCss('#save-proxy-button'); + readonly cancelButton = this.root.getByCss('#cancel-proxy-button'); +} + +describe('EditEngineProxyModalComponent', () => { + let activeModal: MockObject; + let engineService: MockObject; + let notificationService: MockObject; + + beforeEach(() => { + activeModal = createMock(NgbActiveModal); + engineService = createMock(EngineService); + notificationService = createMock(NotificationService); + + TestBed.configureTestingModule({ + providers: [ + provideI18nTesting(), + { provide: NgbActiveModal, useValue: activeModal }, + { provide: EngineService, useValue: engineService }, + { provide: NotificationService, useValue: notificationService } + ] + }); + + TestBed.createComponent(DefaultValidationErrorsComponent).detectChanges(); + }); + + test('should initialize the form with proxy settings', async () => { + const tester = new EditEngineProxyModalTester(); + tester.fixture.componentInstance.initialize(engineSettings); + tester.fixture.detectChanges(); + const proxyEnabled = engineSettings.proxyEnabled; + if (proxyEnabled) { + await expect.element(tester.proxyEnabledCheckbox).toBeChecked(); + } else { + await expect.element(tester.proxyEnabledCheckbox).not.toBeChecked(); + } + }); + + test('should save proxy settings and close modal', async () => { + engineService.updateEngineProxy.mockReturnValue(of(undefined)); + const tester = new EditEngineProxyModalTester(); + tester.fixture.componentInstance.initialize({ ...engineSettings, proxyEnabled: false, proxyPort: null }); + tester.fixture.detectChanges(); + await tester.saveButton.click(); + expect(engineService.updateEngineProxy).toHaveBeenCalledWith({ proxyEnabled: false, proxyPort: null }); + expect(notificationService.success).toHaveBeenCalledWith('engine.updated'); + expect(activeModal.close).toHaveBeenCalled(); + }); + + test('should dismiss modal on cancel', async () => { + const tester = new EditEngineProxyModalTester(); + tester.fixture.detectChanges(); + await tester.cancelButton.click(); + expect(engineService.updateEngineProxy).not.toHaveBeenCalled(); + expect(activeModal.dismiss).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.ts b/frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.ts new file mode 100644 index 0000000000..af7718b8db --- /dev/null +++ b/frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.ts @@ -0,0 +1,65 @@ +import { Component, inject } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateDirective } from '@ngx-translate/core'; +import { EngineService } from '../../services/engine.service'; +import { NotificationService } from '../../shared/notification.service'; +import { EngineSettingsDTO } from '../../../../../backend/shared/model/engine.model'; +import { OI_FORM_VALIDATION_DIRECTIVES } from '../../shared/form/form-validation-directives'; + +@Component({ + selector: 'oib-edit-engine-proxy-modal', + templateUrl: './edit-engine-proxy-modal.component.html', + styleUrl: './edit-engine-proxy-modal.component.scss', + imports: [TranslateDirective, ReactiveFormsModule, OI_FORM_VALIDATION_DIRECTIVES] +}) +export class EditEngineProxyModalComponent { + private modal = inject(NgbActiveModal); + private engineService = inject(EngineService); + private notificationService = inject(NotificationService); + + private fb = inject(NonNullableFormBuilder); + + form = this.fb.group({ + proxyEnabled: [false as boolean, Validators.required], + proxyPort: [null as number | null, Validators.required] + }); + + constructor() { + this.form.controls.proxyEnabled.valueChanges.subscribe(enabled => { + if (enabled) { + this.form.controls.proxyPort.enable(); + } else { + this.form.controls.proxyPort.disable(); + this.form.controls.proxyPort.setValue(null); + } + }); + } + + initialize(settings: EngineSettingsDTO) { + this.form.patchValue({ proxyEnabled: settings.proxyEnabled, proxyPort: settings.proxyPort }); + if (!settings.proxyEnabled) { + this.form.controls.proxyPort.disable(); + } + } + + save() { + if (!this.form.valid) { + return; + } + const formValue = this.form.getRawValue(); + this.engineService + .updateEngineProxy({ + proxyEnabled: formValue.proxyEnabled, + proxyPort: formValue.proxyEnabled ? formValue.proxyPort : null + }) + .subscribe(() => { + this.notificationService.success('engine.updated'); + this.modal.close(); + }); + } + + cancel() { + this.modal.dismiss(); + } +} diff --git a/frontend/src/app/engine/edit-engine-web-server-modal/edit-engine-web-server-modal.component.html b/frontend/src/app/engine/edit-engine-web-server-modal/edit-engine-web-server-modal.component.html new file mode 100644 index 0000000000..d109a3adbe --- /dev/null +++ b/frontend/src/app/engine/edit-engine-web-server-modal/edit-engine-web-server-modal.component.html @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/app/engine/edit-engine-web-server-modal/edit-engine-web-server-modal.component.scss b/frontend/src/app/engine/edit-engine-web-server-modal/edit-engine-web-server-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/app/engine/edit-engine-web-server-modal/edit-engine-web-server-modal.component.spec.ts b/frontend/src/app/engine/edit-engine-web-server-modal/edit-engine-web-server-modal.component.spec.ts new file mode 100644 index 0000000000..581888d4cb --- /dev/null +++ b/frontend/src/app/engine/edit-engine-web-server-modal/edit-engine-web-server-modal.component.spec.ts @@ -0,0 +1,96 @@ +import { TestBed } from '@angular/core/testing'; +import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { of } from 'rxjs'; +import { page } from 'vitest/browser'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { provideI18nTesting } from '../../../i18n/mock-i18n'; +import { DefaultValidationErrorsComponent } from '../../shared/default-validation-errors/default-validation-errors.component'; +import { EditEngineWebServerModalComponent } from './edit-engine-web-server-modal.component'; +import { createMock, MockObject } from '../../../test/vitest-create-mock'; +import { EngineService } from '../../services/engine.service'; +import { NotificationService } from '../../shared/notification.service'; +import { ModalService } from '../../shared/modal.service'; +import { EngineSettingsDTO } from '../../../../../backend/shared/model/engine.model'; + +const engineSettings = { port: 2223 } as EngineSettingsDTO; + +class EditEngineWebServerModalTester { + readonly fixture = TestBed.createComponent(EditEngineWebServerModalComponent); + readonly root = page.elementLocator(this.fixture.nativeElement); + readonly portInput = this.root.getByCss('#port'); + readonly saveButton = this.root.getByCss('#save-web-server-button'); + readonly cancelButton = this.root.getByCss('#cancel-web-server-button'); +} + +describe('EditEngineWebServerModalComponent', () => { + let activeModal: MockObject; + let engineService: MockObject; + let notificationService: MockObject; + let modalService: MockObject; + + beforeEach(() => { + activeModal = createMock(NgbActiveModal); + engineService = createMock(EngineService); + notificationService = createMock(NotificationService); + modalService = createMock(ModalService); + + TestBed.configureTestingModule({ + providers: [ + provideI18nTesting(), + { provide: NgbActiveModal, useValue: activeModal }, + { provide: EngineService, useValue: engineService }, + { provide: NotificationService, useValue: notificationService }, + { provide: ModalService, useValue: modalService }, + { provide: NgbModal, useValue: createMock(NgbModal) } + ] + }); + + TestBed.createComponent(DefaultValidationErrorsComponent).detectChanges(); + }); + + test('should initialize the form with engine port', async () => { + const tester = new EditEngineWebServerModalTester(); + tester.fixture.componentInstance.initialize(engineSettings); + tester.fixture.detectChanges(); + await expect.element(tester.portInput).toHaveValue(engineSettings.port); + }); + + test('should not save when form is invalid', async () => { + const tester = new EditEngineWebServerModalTester(); + tester.fixture.detectChanges(); + await tester.portInput.fill(''); + await tester.saveButton.click(); + expect(engineService.updateEngineWebServer).not.toHaveBeenCalled(); + }); + + test('should save and show success when port did not change', async () => { + engineService.updateEngineWebServer.mockReturnValue(of({ needsRedirect: false, newPort: null })); + const tester = new EditEngineWebServerModalTester(); + tester.fixture.componentInstance.initialize(engineSettings); + tester.fixture.detectChanges(); + await tester.saveButton.click(); + expect(engineService.updateEngineWebServer).toHaveBeenCalledWith({ port: engineSettings.port }); + expect(notificationService.success).toHaveBeenCalledWith('engine.updated'); + expect(activeModal.close).toHaveBeenCalled(); + }); + + test('should open redirect modal when port changed', async () => { + engineService.updateEngineWebServer.mockReturnValue(of({ needsRedirect: true, newPort: 3333 })); + const redirectModalRef = { componentInstance: { initialize: () => {} } }; + modalService.open.mockReturnValue(redirectModalRef as any); + const tester = new EditEngineWebServerModalTester(); + tester.fixture.componentInstance.initialize(engineSettings); + tester.fixture.detectChanges(); + await tester.saveButton.click(); + expect(activeModal.close).toHaveBeenCalled(); + expect(modalService.open).toHaveBeenCalled(); + }); + + test('should dismiss modal on cancel', async () => { + const tester = new EditEngineWebServerModalTester(); + tester.fixture.detectChanges(); + await tester.cancelButton.click(); + expect(engineService.updateEngineWebServer).not.toHaveBeenCalled(); + expect(activeModal.dismiss).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/engine/edit-engine-web-server-modal/edit-engine-web-server-modal.component.ts b/frontend/src/app/engine/edit-engine-web-server-modal/edit-engine-web-server-modal.component.ts new file mode 100644 index 0000000000..b5adfd988e --- /dev/null +++ b/frontend/src/app/engine/edit-engine-web-server-modal/edit-engine-web-server-modal.component.ts @@ -0,0 +1,52 @@ +import { Component, inject } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateDirective } from '@ngx-translate/core'; +import { EngineService } from '../../services/engine.service'; +import { NotificationService } from '../../shared/notification.service'; +import { EngineSettingsDTO } from '../../../../../backend/shared/model/engine.model'; +import { OI_FORM_VALIDATION_DIRECTIVES } from '../../shared/form/form-validation-directives'; +import { ModalService } from '../../shared/modal.service'; +import { PortRedirectModalComponent } from '../../shared/port-redirect-modal/port-redirect-modal.component'; + +@Component({ + selector: 'oib-edit-engine-web-server-modal', + templateUrl: './edit-engine-web-server-modal.component.html', + styleUrl: './edit-engine-web-server-modal.component.scss', + imports: [TranslateDirective, ReactiveFormsModule, OI_FORM_VALIDATION_DIRECTIVES] +}) +export class EditEngineWebServerModalComponent { + private modal = inject(NgbActiveModal); + private engineService = inject(EngineService); + private notificationService = inject(NotificationService); + private modalService = inject(ModalService); + + form = inject(NonNullableFormBuilder).group({ + port: [null as number | null, Validators.required] + }); + + initialize(settings: EngineSettingsDTO) { + this.form.patchValue({ port: settings.port }); + } + + save() { + if (!this.form.valid) { + return; + } + const { port } = this.form.getRawValue(); + this.engineService.updateEngineWebServer({ port: port! }).subscribe(result => { + if (result.needsRedirect && result.newPort) { + this.modal.close(); + const redirectModal = this.modalService.open(PortRedirectModalComponent, { backdrop: 'static', keyboard: false }); + redirectModal.componentInstance.initialize(result.newPort); + } else { + this.notificationService.success('engine.updated'); + this.modal.close(); + } + }); + } + + cancel() { + this.modal.dismiss(); + } +} diff --git a/frontend/src/app/engine/edit-engine/edit-engine.component.html b/frontend/src/app/engine/edit-engine/edit-engine.component.html deleted file mode 100644 index fed965c72b..0000000000 --- a/frontend/src/app/engine/edit-engine/edit-engine.component.html +++ /dev/null @@ -1,242 +0,0 @@ -
-
-
-
-

-
- -
-
- - -
- -
-
- - - -
-
- -
-
- - - -
-
-
-
- - -
- -
-
-
- - -
-
-
- - @if (engineForm.controls.proxyEnabled.value) { -
-
- - - -
-
- } -
-
- - - -
- - -
- -
-
- - -
-
- - - @if (!isLevelSilent('oia')) { -
-
- -
- - -
- -
-
- } -
-
- - -
- -
-
- - -
-
- - - @if (!isLevelSilent('database')) { -
-
- - - -
-
- } -
- - -
- -
-
- - -
-
- - @if (!isLevelSilent('file')) { -
-
- -
- - -
- -
-
- -
-
- - - -
-
- } -
- - - -
- -
-
- - -
-
- - - @if (!isLevelSilent('loki')) { -
-
- -
- - -
- -
-
- } -
- - @if (!isLevelSilent('loki')) { -
- -
-
- - - -
-
-
- -
-
-
- - - -
-
-
-
- - - -
-
-
- } -
- - -
-
-
- - -
-
-
-
-
-
-
-
- - -
-
-
-
-
-
diff --git a/frontend/src/app/engine/edit-engine/edit-engine.component.spec.ts b/frontend/src/app/engine/edit-engine/edit-engine.component.spec.ts deleted file mode 100644 index 30d5096815..0000000000 --- a/frontend/src/app/engine/edit-engine/edit-engine.component.spec.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; -import { provideRouter, Router } from '@angular/router'; -import { of } from 'rxjs'; -import { page } from 'vitest/browser'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import { EditEngineComponent } from './edit-engine.component'; -import { provideI18nTesting } from '../../../i18n/mock-i18n'; -import { EngineSettingsDTO } from '../../../../../backend/shared/model/engine.model'; -import { EngineService } from '../../services/engine.service'; -import { NotificationService } from '../../shared/notification.service'; -import { PortRedirectModalComponent } from '../../shared/port-redirect-modal/port-redirect-modal.component'; -import { MockModalService, provideModalTesting } from '../../shared/mock-modal.service.testing'; -import { UnsavedChangesConfirmationService } from '../../shared/unsaved-changes-confirmation.service'; -import { createMock, MockObject } from '../../../test/vitest-create-mock'; - -class EditEngineComponentTester { - readonly fixture = TestBed.createComponent(EditEngineComponent); - readonly root = page.elementLocator(this.fixture.nativeElement); - readonly componentInstance = this.fixture.componentInstance; - readonly title = this.root.getByRole('heading', { level: 1 }); - readonly name = this.root.getByCss('#name'); - readonly port = this.root.getByCss('#port'); - readonly proxyEnabled = this.root.getByCss('#proxy-enabled'); - readonly proxyPort = this.root.getByCss('#proxy-port'); - readonly consoleLevel = this.root.getByCss('#console-level'); - readonly fileLevel = this.root.getByCss('#file-level'); - readonly fileMaxFileSize = this.root.getByCss('#file-max-file-size'); - readonly fileNumberOfFiles = this.root.getByCss('#file-number-of-files'); - readonly databaseLevel = this.root.getByCss('#database-level'); - readonly databaseMaxNumberOfLogs = this.root.getByCss('#database-max-number-of-logs'); - readonly lokiLevel = this.root.getByCss('#loki-level'); - readonly lokiInterval = this.root.getByCss('#loki-interval'); - readonly lokiAddress = this.root.getByCss('#loki-address'); - readonly lokiUsername = this.root.getByCss('#loki-username'); - readonly lokiPassword = this.root.getByCss('#loki-password'); - readonly oiaLevel = this.root.getByCss('#oia-level'); - readonly oiaInterval = this.root.getByCss('#oia-interval'); - readonly submitButton = this.root.getByCss('#save-button'); - - constructor() { - this.fixture.detectChanges(); - } -} - -describe('EditEngineComponent', () => { - let tester: EditEngineComponentTester; - let engineService: MockObject; - let notificationService: MockObject; - let modalService: MockModalService; - let unsavedChangesConfirmationService: MockObject; - let router: Router; - - const engineSettings: EngineSettingsDTO = { - id: 'id', - name: 'OIBus Test', - port: 2223, - version: '3.3.3', - launcherVersion: '3.5.0', - proxyEnabled: true, - proxyPort: 9000, - logParameters: { - console: { - level: 'silent' - }, - file: { - level: 'trace', - numberOfFiles: 5, - maxFileSize: 10 - }, - database: { - level: 'silent', - maxNumberOfLogs: 100_000 - }, - loki: { - level: 'error', - interval: 60, - address: 'http://loki.oibus.com', - username: 'oibus', - password: 'pass' - }, - oia: { - level: 'silent', - interval: 60 - } - }, - createdBy: { id: '', friendlyName: '' }, - updatedBy: { id: '', friendlyName: '' }, - createdAt: '', - updatedAt: '' - }; - - beforeEach(() => { - engineService = createMock(EngineService); - notificationService = createMock(NotificationService); - unsavedChangesConfirmationService = createMock(UnsavedChangesConfirmationService); - - TestBed.configureTestingModule({ - providers: [ - provideHttpClientTesting(), - provideRouter([]), - provideI18nTesting(), - provideModalTesting(), - { provide: EngineService, useValue: engineService }, - { provide: NotificationService, useValue: notificationService }, - { provide: UnsavedChangesConfirmationService, useValue: unsavedChangesConfirmationService } - ] - }); - - engineService.getEngineSettings.mockReturnValue(of(engineSettings)); - engineService.updateEngineSettings.mockReturnValue( - of({ - needsRedirect: false, - newPort: null - }) - ); - - router = TestBed.inject(Router); - vi.spyOn(router, 'navigate').mockResolvedValue(true); - modalService = TestBed.inject(MockModalService); - - tester = new EditEngineComponentTester(); - }); - - test('should display title and filled form', async () => { - await expect.element(tester.title).toHaveTextContent('Edit engine settings'); - await expect.element(tester.name).toHaveValue(engineSettings.name); - await expect.element(tester.port).toHaveValue(engineSettings.port); - await expect.element(tester.proxyPort).toHaveValue(engineSettings.proxyPort!); - await expect.element(tester.proxyEnabled).toBeChecked(); - await expect.element(tester.consoleLevel).toHaveDisplayValue('Silent'); - await expect.element(tester.fileLevel).toHaveDisplayValue('Trace'); - await expect.element(tester.fileMaxFileSize).toHaveValue(engineSettings.logParameters.file.maxFileSize); - await expect.element(tester.fileNumberOfFiles).toHaveValue(engineSettings.logParameters.file.numberOfFiles); - await expect.element(tester.databaseLevel).toHaveDisplayValue('Silent'); - await expect.element(tester.databaseMaxNumberOfLogs).not.toBeInTheDocument(); - await expect.element(tester.lokiLevel).toHaveDisplayValue('Error'); - await expect.element(tester.lokiInterval).toHaveValue(engineSettings.logParameters.loki.interval); - await expect.element(tester.lokiAddress).toHaveValue(engineSettings.logParameters.loki.address); - await expect.element(tester.lokiUsername).toHaveValue(engineSettings.logParameters.loki.username); - await expect.element(tester.lokiPassword).toHaveValue(engineSettings.logParameters.loki.password); - await expect.element(tester.oiaLevel).toHaveDisplayValue('Silent'); - await expect.element(tester.oiaInterval).not.toBeInTheDocument(); - }); - - test('should update engine settings', async () => { - await tester.name.fill('OIBus Dev'); - await tester.proxyPort.fill('8000'); - await tester.proxyEnabled.click(); - await tester.consoleLevel.selectOptions('Error'); - await tester.fileNumberOfFiles.fill('10'); - await tester.lokiLevel.selectOptions('Silent'); - await tester.oiaLevel.selectOptions('Error'); - await tester.submitButton.click(); - - expect(engineService.updateEngineSettings).toHaveBeenCalledWith({ - name: 'OIBus Dev', - port: 2223, - proxyEnabled: false, - proxyPort: null, - logParameters: { - console: { - level: 'error' - }, - file: { - level: 'trace', - numberOfFiles: 10, - maxFileSize: 10 - }, - database: { - level: 'silent', - maxNumberOfLogs: 100_000 - }, - loki: { - level: 'silent', - interval: 60, - address: 'http://loki.oibus.com', - username: 'oibus', - password: 'pass' - }, - oia: { - level: 'error', - interval: 60 - } - } - }); - expect(notificationService.success).toHaveBeenCalledWith('engine.updated'); - expect(router.navigate).toHaveBeenCalledWith(['/engine']); - }); - - test('should show redirect modal when port changes', async () => { - const redirectModalComponent = createMock(PortRedirectModalComponent); - modalService.mockClosedModal(redirectModalComponent); - const openSpy = vi.spyOn(modalService, 'open'); - engineService.updateEngineSettings.mockReturnValue( - of({ - needsRedirect: true, - newPort: 3333 - }) - ); - - await tester.port.fill('3333'); - await tester.submitButton.click(); - - expect(engineService.updateEngineSettings).toHaveBeenCalled(); - expect(openSpy).toHaveBeenCalledWith(PortRedirectModalComponent, { backdrop: 'static', keyboard: false }); - expect(redirectModalComponent.initialize).toHaveBeenCalledWith(3333); - expect(notificationService.success).not.toHaveBeenCalled(); - expect(router.navigate).not.toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/app/engine/edit-engine/edit-engine.component.ts b/frontend/src/app/engine/edit-engine/edit-engine.component.ts deleted file mode 100644 index 16c9e28230..0000000000 --- a/frontend/src/app/engine/edit-engine/edit-engine.component.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { Component, inject, OnInit, ChangeDetectionStrategy } from '@angular/core'; -import { TranslateDirective } from '@ngx-translate/core'; -import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; -import { Router, RouterLink } from '@angular/router'; -import { Observable } from 'rxjs'; - -import { EngineService } from '../../services/engine.service'; -import { EngineSettingsCommandDTO } from '../../../../../backend/shared/model/engine.model'; -import { NotificationService } from '../../shared/notification.service'; -import { ObservableState, SaveButtonComponent } from '../../shared/save-button/save-button.component'; -import { BoxComponent } from '../../shared/box/box.component'; -import { OI_FORM_VALIDATION_DIRECTIVES } from '../../shared/form/form-validation-directives'; -import { CanComponentDeactivate } from '../../shared/unsaved-changes.guard'; -import { UnsavedChangesConfirmationService } from '../../shared/unsaved-changes-confirmation.service'; -import { LOG_LEVELS, LogLevel } from '../../../../../backend/shared/model/logs.model'; -import { ModalService } from '../../shared/modal.service'; -import { PortRedirectModalComponent } from '../../shared/port-redirect-modal/port-redirect-modal.component'; - -@Component({ - selector: 'oib-edit-engine', - imports: [ReactiveFormsModule, TranslateDirective, BoxComponent, OI_FORM_VALIDATION_DIRECTIVES, SaveButtonComponent, RouterLink], - templateUrl: './edit-engine.component.html', - changeDetection: ChangeDetectionStrategy.Eager, - styleUrl: './edit-engine.component.scss' -}) -export class EditEngineComponent implements OnInit, CanComponentDeactivate { - private notificationService = inject(NotificationService); - private engineService = inject(EngineService); - private router = inject(Router); - private unsavedChangesConfirmation = inject(UnsavedChangesConfirmationService); - private modalService = inject(ModalService); - - readonly logLevels = LOG_LEVELS; - - private fb = inject(NonNullableFormBuilder); - engineForm = this.fb.group({ - name: ['', Validators.required], - port: [null as number | null, Validators.required], - proxyEnabled: [false as boolean, Validators.required], - proxyPort: [null as number | null, Validators.required], - logParameters: this.fb.group({ - console: this.fb.group({ - level: ['silent' as LogLevel, Validators.required] - }), - file: this.fb.group({ - level: ['info' as LogLevel, Validators.required], - maxFileSize: [null as number | null, [Validators.required, Validators.min(1), Validators.max(50)]], - numberOfFiles: [null as number | null, [Validators.required, Validators.min(1)]] - }), - database: this.fb.group({ - level: ['info' as LogLevel, Validators.required], - maxNumberOfLogs: [null as number | null, [Validators.required, Validators.min(100_000)]] - }), - loki: this.fb.group({ - level: ['silent' as LogLevel, Validators.required], - interval: [null as number | null, [Validators.required, Validators.min(10)]], - address: ['', Validators.pattern(/http.*/)], - username: null as string | null, - password: null as string | null - }), - oia: this.fb.group({ - level: ['silent' as LogLevel, Validators.required], - interval: [null as number | null, [Validators.required, Validators.min(10)]] - }) - }) - }); - - state = new ObservableState(); - - constructor() { - this.engineForm.controls.proxyEnabled.valueChanges.subscribe(next => { - if (next) { - this.engineForm.controls.proxyPort.enable(); - } else { - this.engineForm.controls.proxyPort.disable(); - this.engineForm.controls.proxyPort.setValue(null); - } - }); - - // Handle conditional validators based on log levels - const logParams = this.engineForm.controls.logParameters; - - // OIA level changes - logParams.controls.oia.controls.level.valueChanges.subscribe(level => { - const intervalControl = logParams.controls.oia.controls.interval; - if (level === 'silent') { - intervalControl.clearValidators(); - intervalControl.disable(); - } else { - intervalControl.setValidators([Validators.required, Validators.min(10)]); - intervalControl.enable(); - } - intervalControl.updateValueAndValidity(); - }); - - // Database level changes - logParams.controls.database.controls.level.valueChanges.subscribe(level => { - const maxLogsControl = logParams.controls.database.controls.maxNumberOfLogs; - if (level === 'silent') { - maxLogsControl.clearValidators(); - maxLogsControl.disable(); - } else { - maxLogsControl.setValidators([Validators.required, Validators.min(100_000)]); - maxLogsControl.enable(); - } - maxLogsControl.updateValueAndValidity(); - }); - - // File level changes - logParams.controls.file.controls.level.valueChanges.subscribe(level => { - const maxFileSizeControl = logParams.controls.file.controls.maxFileSize; - const numberOfFilesControl = logParams.controls.file.controls.numberOfFiles; - if (level === 'silent') { - maxFileSizeControl.clearValidators(); - maxFileSizeControl.disable(); - numberOfFilesControl.clearValidators(); - numberOfFilesControl.disable(); - } else { - maxFileSizeControl.setValidators([Validators.required, Validators.min(1), Validators.max(50)]); - maxFileSizeControl.enable(); - numberOfFilesControl.setValidators([Validators.required, Validators.min(1)]); - numberOfFilesControl.enable(); - } - maxFileSizeControl.updateValueAndValidity(); - numberOfFilesControl.updateValueAndValidity(); - }); - - // Loki level changes - logParams.controls.loki.controls.level.valueChanges.subscribe(level => { - const intervalControl = logParams.controls.loki.controls.interval; - const addressControl = logParams.controls.loki.controls.address; - if (level === 'silent') { - intervalControl.clearValidators(); - intervalControl.disable(); - addressControl.clearValidators(); - addressControl.disable(); - } else { - intervalControl.setValidators([Validators.required, Validators.min(10)]); - intervalControl.enable(); - addressControl.setValidators([Validators.pattern(/http.*/)]); - addressControl.enable(); - } - intervalControl.updateValueAndValidity(); - addressControl.updateValueAndValidity(); - }); - } - - isLevelSilent(category: 'console' | 'file' | 'database' | 'loki' | 'oia'): boolean { - const level = this.engineForm.controls.logParameters.controls[category].controls.level.value; - return level === 'silent'; - } - - ngOnInit(): void { - this.engineService.getEngineSettings().subscribe(settings => { - this.engineForm.patchValue(settings); - // Initialize validators based on current levels after patching - this.initializeValidators(); - }); - } - - private initializeValidators(): void { - const logParams = this.engineForm.controls.logParameters; - - // Initialize OIA validators - const oiaLevel = logParams.controls.oia.controls.level.value; - const oiaIntervalControl = logParams.controls.oia.controls.interval; - if (oiaLevel === 'silent') { - oiaIntervalControl.clearValidators(); - oiaIntervalControl.disable(); - } else { - oiaIntervalControl.setValidators([Validators.required, Validators.min(10)]); - oiaIntervalControl.enable(); - } - - // Initialize Database validators - const dbLevel = logParams.controls.database.controls.level.value; - const maxLogsControl = logParams.controls.database.controls.maxNumberOfLogs; - if (dbLevel === 'silent') { - maxLogsControl.clearValidators(); - maxLogsControl.disable(); - } else { - maxLogsControl.setValidators([Validators.required, Validators.min(100_000)]); - maxLogsControl.enable(); - } - - // Initialize File validators - const fileLevel = logParams.controls.file.controls.level.value; - const maxFileSizeControl = logParams.controls.file.controls.maxFileSize; - const numberOfFilesControl = logParams.controls.file.controls.numberOfFiles; - if (fileLevel === 'silent') { - maxFileSizeControl.clearValidators(); - maxFileSizeControl.disable(); - numberOfFilesControl.clearValidators(); - numberOfFilesControl.disable(); - } else { - maxFileSizeControl.setValidators([Validators.required, Validators.min(1), Validators.max(50)]); - maxFileSizeControl.enable(); - numberOfFilesControl.setValidators([Validators.required, Validators.min(1)]); - numberOfFilesControl.enable(); - } - - // Initialize Loki validators - const lokiLevel = logParams.controls.loki.controls.level.value; - const lokiIntervalControl = logParams.controls.loki.controls.interval; - const lokiAddressControl = logParams.controls.loki.controls.address; - if (lokiLevel === 'silent') { - lokiIntervalControl.clearValidators(); - lokiIntervalControl.disable(); - lokiAddressControl.clearValidators(); - lokiAddressControl.disable(); - } else { - lokiIntervalControl.setValidators([Validators.required, Validators.min(10)]); - lokiIntervalControl.enable(); - lokiAddressControl.setValidators([Validators.pattern(/http.*/)]); - lokiAddressControl.enable(); - } - } - - canDeactivate(): Observable | boolean { - if (this.engineForm?.dirty) { - return this.unsavedChangesConfirmation.confirmUnsavedChanges(); - } - return true; - } - - save() { - if (!this.engineForm.valid) { - return; - } - - // Use getRawValue() to include disabled field values - const formValue = this.engineForm.getRawValue(); - const updatedSettings: EngineSettingsCommandDTO = { - name: formValue.name!, - port: formValue.port!, - proxyEnabled: formValue.proxyEnabled!, - proxyPort: formValue.proxyEnabled ? formValue.proxyPort || null : null, - logParameters: { - console: { - level: formValue.logParameters!.console!.level! - }, - file: { - level: formValue.logParameters!.file!.level!, - maxFileSize: formValue.logParameters!.file!.maxFileSize!, - numberOfFiles: formValue.logParameters!.file!.numberOfFiles! - }, - database: { - level: formValue.logParameters!.database!.level!, - maxNumberOfLogs: formValue.logParameters!.database!.maxNumberOfLogs! - }, - loki: { - level: formValue.logParameters!.loki!.level!, - interval: formValue.logParameters!.loki!.interval!, - address: formValue.logParameters!.loki!.address!, - username: formValue.logParameters!.loki!.username!, - password: formValue.logParameters!.loki!.password! - }, - oia: { - level: formValue.logParameters!.oia!.level!, - interval: formValue.logParameters!.oia!.interval! - } - } - }; - - this.engineService - .updateEngineSettings(updatedSettings) - .pipe(this.state.pendingUntilFinalization()) - .subscribe(result => { - this.engineForm.markAsPristine(); - - if (result.needsRedirect && result.newPort) { - // Port changed - show redirect modal - const modal = this.modalService.open(PortRedirectModalComponent, { backdrop: 'static', keyboard: false }); - modal.componentInstance.initialize(result.newPort); - } else { - // No port change - show success notification and navigate - this.notificationService.success('engine.updated'); - this.router.navigate(['/engine']); - } - }); - } -} diff --git a/frontend/src/app/engine/engine-detail.component.html b/frontend/src/app/engine/engine-detail.component.html index a15395bd3d..5535f5e185 100644 --- a/frontend/src/app/engine/engine-detail.component.html +++ b/frontend/src/app/engine/engine-detail.component.html @@ -7,12 +7,7 @@
logo
-

- - - - -

+

@@ -34,17 +29,111 @@

@if (metrics()) { } - + + + + +
+ + +
+
- + + +
{{ settings.name }}
+
+ + + + +
+ + +
+
+ + + +
{{ settings.port }}
+
+ + + + +
+ + +
+
+ + + + + @if (settings.proxyEnabled) { + + } @else { + + } + + +
+
+ + + + +
+ + +
+
+ + - - - @if (settings.proxyEnabled) { - - } @else { - - } -
@@ -65,21 +154,10 @@

+
diff --git a/frontend/src/app/engine/engine-detail.component.spec.ts b/frontend/src/app/engine/engine-detail.component.spec.ts index 95e2375790..42cad2fa20 100644 --- a/frontend/src/app/engine/engine-detail.component.spec.ts +++ b/frontend/src/app/engine/engine-detail.component.spec.ts @@ -21,7 +21,7 @@ import { EngineSettingsDTO } from '../../../../backend/shared/model/engine.model class EngineDetailComponentTester { readonly fixture = TestBed.createComponent(EngineDetailComponent); readonly root = page.elementLocator(this.fixture.nativeElement); - readonly generalSettings = this.root.getByCss('tbody.general-settings tr'); + readonly generalSettings = this.root.getByCss('table tr'); readonly restartButton = this.root.getByCss('#restart'); } @@ -100,8 +100,8 @@ describe('EngineDetailComponent', () => { await expect.element(tester.generalSettings.nth(0)).toHaveTextContent('OIBus Test'); await expect.element(tester.generalSettings.nth(1)).toHaveTextContent('2223'); - await expect.element(tester.generalSettings.nth(2)).toHaveTextContent('silent'); - await expect.element(tester.generalSettings.nth(3)).toHaveTextContent('8888'); + await expect.element(tester.generalSettings.nth(2)).toHaveTextContent('8888'); + await expect.element(tester.generalSettings.nth(3)).toHaveTextContent('silent'); }); test('should restart', () => { diff --git a/frontend/src/app/engine/engine-detail.component.ts b/frontend/src/app/engine/engine-detail.component.ts index fbb07afe57..95a85199a8 100644 --- a/frontend/src/app/engine/engine-detail.component.ts +++ b/frontend/src/app/engine/engine-detail.component.ts @@ -8,15 +8,20 @@ import { ScanModeListComponent } from './scan-mode-list/scan-mode-list.component import { IpFilterListComponent } from './ip-filter-list/ip-filter-list.component'; import { NotificationService } from '../shared/notification.service'; import { ConfirmationService } from '../shared/confirmation.service'; -import { switchMap } from 'rxjs'; +import { BehaviorSubject, switchMap } from 'rxjs'; import { ObservableState } from '../shared/save-button/save-button.component'; -import { BoxComponent } from '../shared/box/box.component'; +import { BoxComponent, BoxTitleDirective } from '../shared/box/box.component'; import { EngineMetricsComponent } from './engine-metrics/engine-metrics.component'; import { WindowService } from '../shared/window.service'; import { RouterLink } from '@angular/router'; import { CertificateListComponent } from './certificate-list/certificate-list.component'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { TransformerListComponent } from './transformer-list/transformer-list.component'; +import { ModalService } from '../shared/modal.service'; +import { EditEngineNameModalComponent } from './edit-engine-name-modal/edit-engine-name-modal.component'; +import { EditEngineWebServerModalComponent } from './edit-engine-web-server-modal/edit-engine-web-server-modal.component'; +import { EditEngineProxyModalComponent } from './edit-engine-proxy-modal/edit-engine-proxy-modal.component'; +import { EditEngineLoggerModalComponent } from './edit-engine-logger-modal/edit-engine-logger-modal.component'; @Component({ selector: 'oib-engine-detail', @@ -27,6 +32,7 @@ import { TransformerListComponent } from './transformer-list/transformer-list.co IpFilterListComponent, AsyncPipe, BoxComponent, + BoxTitleDirective, EngineMetricsComponent, RouterLink, NgbTooltip, @@ -42,9 +48,12 @@ export class EngineDetailComponent { private windowService = inject(WindowService); private notificationService = inject(NotificationService); private confirmationService = inject(ConfirmationService); + private modalService = inject(ModalService); private destroyRef = inject(DestroyRef); - readonly engineSettings = toSignal(this.engineService.getEngineSettings()); + private readonly refresh$ = new BehaviorSubject(undefined); + + readonly engineSettings = toSignal(this.refresh$.pipe(switchMap(() => this.engineService.getEngineSettings()))); metrics = signal(null); restarting = new ObservableState(); @@ -59,6 +68,30 @@ export class EngineDetailComponent { this.destroyRef.onDestroy(() => stream.close()); } + openNameModal() { + const modal = this.modalService.open(EditEngineNameModalComponent); + modal.componentInstance.initialize(this.engineSettings()!); + modal.result.subscribe(() => this.refresh$.next()); + } + + openWebServerModal() { + const modal = this.modalService.open(EditEngineWebServerModalComponent); + modal.componentInstance.initialize(this.engineSettings()!); + modal.result.subscribe(() => this.refresh$.next()); + } + + openProxyModal() { + const modal = this.modalService.open(EditEngineProxyModalComponent); + modal.componentInstance.initialize(this.engineSettings()!); + modal.result.subscribe(() => this.refresh$.next()); + } + + openLoggerModal() { + const modal = this.modalService.open(EditEngineLoggerModalComponent); + modal.componentInstance.initialize(this.engineSettings()!); + modal.result.subscribe(() => this.refresh$.next()); + } + restart() { this.confirmationService .confirm({ diff --git a/frontend/src/app/services/engine.service.spec.ts b/frontend/src/app/services/engine.service.spec.ts index 7801b2ed94..e98a8c37f8 100644 --- a/frontend/src/app/services/engine.service.spec.ts +++ b/frontend/src/app/services/engine.service.spec.ts @@ -3,7 +3,14 @@ import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { EngineService } from './engine.service'; -import { EngineSettingsDTO, OIBusInfo } from '../../../../backend/shared/model/engine.model'; +import { + EngineLoggerCommandDTO, + EngineNameCommandDTO, + EngineProxyCommandDTO, + EngineSettingsDTO, + EngineWebServerCommandDTO, + OIBusInfo +} from '../../../../backend/shared/model/engine.model'; import testData from '../../../../backend/src/tests/utils/test-data'; describe('EngineService', () => { @@ -41,6 +48,60 @@ describe('EngineService', () => { expect(done).toBe(true); }); + test('should update engine name', () => { + let done = false; + const command: EngineNameCommandDTO = testData.engine.nameCommand; + + service.updateEngineName(command).subscribe(() => (done = true)); + const testRequest = http.expectOne({ method: 'PUT', url: '/api/engine/name' }); + expect(testRequest.request.body).toEqual(command); + testRequest.flush(null); + expect(done).toBe(true); + }); + + test('should update engine web server settings', () => { + let done = false; + const command: EngineWebServerCommandDTO = testData.engine.webServerCommand; + + service.updateEngineWebServer(command).subscribe(() => (done = true)); + const testRequest = http.expectOne({ method: 'PUT', url: '/api/engine/web-server' }); + expect(testRequest.request.body).toEqual(command); + testRequest.flush({ needsRedirect: false, newPort: null }); + expect(done).toBe(true); + }); + + test('should return redirect info when port changes', () => { + let result: { needsRedirect: boolean; newPort: number | null } | null = null; + const command: EngineWebServerCommandDTO = testData.engine.webServerCommand; + + service.updateEngineWebServer(command).subscribe(r => (result = r)); + const testRequest = http.expectOne({ method: 'PUT', url: '/api/engine/web-server' }); + testRequest.flush({ needsRedirect: true, newPort: 3333 }); + expect(result).toEqual({ needsRedirect: true, newPort: 3333 }); + }); + + test('should update engine proxy settings', () => { + let done = false; + const command: EngineProxyCommandDTO = testData.engine.proxyCommand; + + service.updateEngineProxy(command).subscribe(() => (done = true)); + const testRequest = http.expectOne({ method: 'PUT', url: '/api/engine/proxy' }); + expect(testRequest.request.body).toEqual(command); + testRequest.flush(null); + expect(done).toBe(true); + }); + + test('should update engine logger settings', () => { + let done = false; + const command: EngineLoggerCommandDTO = testData.engine.loggerCommand; + + service.updateEngineLogger(command).subscribe(() => (done = true)); + const testRequest = http.expectOne({ method: 'PUT', url: '/api/engine/logger' }); + expect(testRequest.request.body).toEqual(command); + testRequest.flush(null); + expect(done).toBe(true); + }); + test('should get info', () => { let expectedInfo: OIBusInfo | null = null; const engineInfo = testData.engine.oIBusInfo; diff --git a/frontend/src/app/services/engine.service.ts b/frontend/src/app/services/engine.service.ts index 7b11e57ee9..933db4b019 100644 --- a/frontend/src/app/services/engine.service.ts +++ b/frontend/src/app/services/engine.service.ts @@ -2,9 +2,13 @@ import { HttpClient } from '@angular/common/http'; import { Observable, shareReplay } from 'rxjs'; import { Service, inject } from '@angular/core'; import { + EngineLoggerCommandDTO, + EngineNameCommandDTO, + EngineProxyCommandDTO, EngineSettingsCommandDTO, EngineSettingsDTO, EngineSettingsUpdateResultDTO, + EngineWebServerCommandDTO, OIBusInfo, RegistrationSettingsCommandDTO, RegistrationSettingsDTO @@ -33,6 +37,22 @@ export class EngineService { return this.http.put(`/api/engine`, command); } + updateEngineName(command: EngineNameCommandDTO): Observable { + return this.http.put(`/api/engine/name`, command); + } + + updateEngineWebServer(command: EngineWebServerCommandDTO): Observable { + return this.http.put(`/api/engine/web-server`, command); + } + + updateEngineProxy(command: EngineProxyCommandDTO): Observable { + return this.http.put(`/api/engine/proxy`, command); + } + + updateEngineLogger(command: EngineLoggerCommandDTO): Observable { + return this.http.put(`/api/engine/logger`, command); + } + /** * Reset the Engine metrics */ diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index b66c0ad72d..07db9c0b19 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -538,6 +538,9 @@ "loki": "Loki: {{ level }}", "oia": "OIAnalytics: {{ level }}" }, + "web-server-settings": { + "title": "Web server" + }, "proxy-settings": { "title": "Proxy server", "proxy-enabled": "Enabled", From f0502545c18eafd358aefc0f8e9962869d9c1a3d Mon Sep 17 00:00:00 2001 From: kbalinthunor Date: Tue, 2 Jun 2026 17:29:42 +0300 Subject: [PATCH 07/24] feat(logger): add syslog transport --- backend/package-lock.json | 115 +++++++++++ backend/package.json | 3 + backend/shared/model/engine.model.ts | 57 ++++++ .../entity-migrations/3/3.9/v3.9.0.ts | 22 +++ backend/src/model/engine.model.ts | 6 + .../config/engine.repository.spec.ts | 3 +- .../repository/config/engine.repository.ts | 35 +++- backend/src/service/logger/declarations.d.ts | 35 ++++ .../src/service/logger/logger.service.spec.ts | 108 ++++++++--- backend/src/service/logger/logger.service.ts | 57 +++--- .../service/logger/syslog-transport.spec.ts | 179 ++++++++++++++++++ .../src/service/logger/syslog-transport.ts | 68 +++++++ .../oia/oianalytics-message.service.ts | 6 + backend/src/service/oibus.service.spec.ts | 6 + backend/src/service/oibus.service.ts | 6 + backend/src/tests/utils/test-data.ts | 12 ++ backend/src/tests/utils/test-utils.ts | 4 + .../validators/oibus-validation-schema.ts | 6 + backend/src/web-server/routes.ts | 5 +- documentation/docs/api/openapi.json | 75 ++++++++ .../edit-engine-logger-modal.component.html | 42 ++++ ...edit-engine-logger-modal.component.spec.ts | 3 +- .../edit-engine-logger-modal.component.ts | 14 +- frontend/src/i18n/en.json | 6 + 24 files changed, 814 insertions(+), 59 deletions(-) create mode 100644 backend/src/migration/entity-migrations/3/3.9/v3.9.0.ts create mode 100644 backend/src/service/logger/declarations.d.ts create mode 100644 backend/src/service/logger/syslog-transport.spec.ts create mode 100644 backend/src/service/logger/syslog-transport.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 3347395745..e8ed3270e5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -52,6 +52,8 @@ "pino-loki": "3.0.0", "pino-pretty": "13.1.3", "pino-roll": "4.0.0", + "pino-socket": "^8.0.0", + "pino-syslog": "^4.0.0", "ssh2-sftp-client": "12.1.1", "thread-stream": "4.2.0", "tsoa": "6.6.0", @@ -5826,6 +5828,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-json-parse": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fast-json-parse/-/fast-json-parse-1.0.3.tgz", + "integrity": "sha512-FRWsaZRWEJ1ESVNbDWmsAlqDk96gPQezzLghafp5J4GUKjbCz3OkAHuZs5TuPEtkbVQERysLp9xv6c24fBm8Aw==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -9300,12 +9308,96 @@ "sonic-boom": "^4.0.1" } }, + "node_modules/pino-socket": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pino-socket/-/pino-socket-8.0.0.tgz", + "integrity": "sha512-PGtegfnmcUFJsRQSK26F/Wn37v/GPn8ZgCrd20dFRLUMgXAOrayk9qQlAwgJe/K2hOPJrzNU6sV13SbZgFiSWg==", + "license": "MIT", + "dependencies": { + "backoff": "^2.5.0", + "nopt": "^9.0.0", + "pump": "^3.0.0", + "split2": "^4.0.0", + "through2": "^4.0.2" + }, + "bin": { + "pino-socket": "psock.js" + } + }, + "node_modules/pino-socket/node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/pino-socket/node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/pino-std-serializers": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/pino-syslog": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pino-syslog/-/pino-syslog-4.0.0.tgz", + "integrity": "sha512-gfsgkyCU78AigpFGP/F8lUHqqNaIx2aQY0Kcc6Qoo9fD+bG0NOKf61/B3bg/8a63V70daGeOPWPj9yGM8mOyGA==", + "license": "MIT", + "dependencies": { + "fast-json-parse": "^1.0.2", + "fast-safe-stringify": "^2.0.2", + "luxon": "^3.7.2", + "nopt": "^9.0.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.4", + "sonic-boom": "^4.2.1", + "split2": "^4.1.0", + "through2": "^4.0.2" + }, + "bin": { + "pino-syslog": "app.js" + } + }, + "node_modules/pino-syslog/node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/pino-syslog/node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -10622,6 +10714,29 @@ "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", "license": "MIT" }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 0614d1c95f..20811cf170 100644 --- a/backend/package.json +++ b/backend/package.json @@ -90,6 +90,8 @@ "pino-loki": "3.0.0", "pino-pretty": "13.1.3", "pino-roll": "4.0.0", + "pino-socket": "8.0.0", + "pino-syslog": "4.0.0", "ssh2-sftp-client": "12.1.1", "thread-stream": "4.2.0", "tsoa": "6.6.0", @@ -137,6 +139,7 @@ "dist/frontend/**/*", "dist/backend/src/service/logger/sqlite-transport.js", "dist/backend/src/service/logger/oianalytics-transport.js", + "dist/backend/src/service/logger/syslog-transport.js", "dist/backend/src/migration/**/*.js", "dist/backend/shared/**/*.js", "node_modules/node-opcua-crypto/dist/source/index_web.js", diff --git a/backend/shared/model/engine.model.ts b/backend/shared/model/engine.model.ts index d363e8721c..8d492d9eac 100644 --- a/backend/shared/model/engine.model.ts +++ b/backend/shared/model/engine.model.ts @@ -156,6 +156,34 @@ export interface EngineSettingsDTO extends BaseEntity { */ interval: number; }; + + /** + * Syslog logging configuration. + */ + syslog: { + /** + * The log level for syslog output. + */ + level: LogLevel; + + /** + * The hostname or IP of the syslog server. Empty string disables the transport. + * @example "syslog.example.com" + */ + host: string; + + /** + * The port of the syslog server. + * @example 514 + */ + port: number; + + /** + * The transport protocol. + * @example "udp4" + */ + protocol: 'udp4' | 'tcp'; + }; }; } @@ -967,6 +995,35 @@ export interface EngineSettingsCommandDTO { */ interval: number; }; + + /** + * Syslog logging configuration. + */ + syslog: { + /** + * The log level for syslog output. + * @example "info" + */ + level: LogLevel; + + /** + * The hostname or IP of the syslog server. Empty string disables the transport. + * @example "syslog.example.com" + */ + host: string; + + /** + * The port of the syslog server. + * @example 514 + */ + port: number; + + /** + * The transport protocol. + * @example "udp4" + */ + protocol: 'udp4' | 'tcp'; + }; }; } diff --git a/backend/src/migration/entity-migrations/3/3.9/v3.9.0.ts b/backend/src/migration/entity-migrations/3/3.9/v3.9.0.ts new file mode 100644 index 0000000000..4546ef3a18 --- /dev/null +++ b/backend/src/migration/entity-migrations/3/3.9/v3.9.0.ts @@ -0,0 +1,22 @@ +import { Knex } from 'knex'; + +const ENGINES_TABLE = 'engines'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(ENGINES_TABLE, t => { + t.string('log_syslog_level'); + t.string('log_syslog_host'); + t.integer('log_syslog_port'); + t.string('log_syslog_protocol'); + }); + await knex(ENGINES_TABLE).update({ + log_syslog_level: 'silent', + log_syslog_host: '', + log_syslog_port: 514, + log_syslog_protocol: 'udp4' + }); +} + +export async function down(_knex: Knex): Promise { + return; +} diff --git a/backend/src/model/engine.model.ts b/backend/src/model/engine.model.ts index 3b1d889404..50747fb59a 100644 --- a/backend/src/model/engine.model.ts +++ b/backend/src/model/engine.model.ts @@ -32,6 +32,12 @@ export interface EngineSettings extends BaseEntity { level: LogLevel; interval: number; }; + syslog: { + level: LogLevel; + host: string; + port: number; + protocol: 'udp4' | 'tcp'; + }; }; } diff --git a/backend/src/repository/config/engine.repository.spec.ts b/backend/src/repository/config/engine.repository.spec.ts index f7775a8197..dce8ada9c0 100644 --- a/backend/src/repository/config/engine.repository.spec.ts +++ b/backend/src/repository/config/engine.repository.spec.ts @@ -108,7 +108,8 @@ describe('EngineRepository with empty database', () => { file: { level: 'info', maxFileSize: 50, numberOfFiles: 5 }, database: { level: 'info', maxNumberOfLogs: 100_000 }, loki: { level: 'silent', interval: 60, address: '', username: '', password: '' }, - oia: { level: 'silent', interval: 10 } + oia: { level: 'silent', interval: 10 }, + syslog: { level: 'silent', host: '', port: 514, protocol: 'udp4' } }); }); diff --git a/backend/src/repository/config/engine.repository.ts b/backend/src/repository/config/engine.repository.ts index 48b6b41793..2dbb2a136e 100644 --- a/backend/src/repository/config/engine.repository.ts +++ b/backend/src/repository/config/engine.repository.ts @@ -43,6 +43,12 @@ const DEFAULT_ENGINE_SETTINGS: Omit< oia: { level: 'silent', interval: 10 + }, + syslog: { + level: 'silent', + host: '', + port: 514, + protocol: 'udp4' } } }; @@ -66,7 +72,9 @@ export default class EngineRepository { 'log_console_level, log_file_level, log_file_max_file_size, log_file_number_of_files, ' + 'log_database_level, log_database_max_number_of_logs, ' + 'log_loki_level, log_loki_interval, log_loki_address, log_loki_username, log_loki_password, ' + - `log_oia_level, log_oia_interval, created_by, updated_by, created_at, updated_at FROM ${ENGINES_TABLE};`; + 'log_oia_level, log_oia_interval, ' + + 'log_syslog_level, log_syslog_host, log_syslog_port, log_syslog_protocol, ' + + `created_by, updated_by, created_at, updated_at FROM ${ENGINES_TABLE};`; const results: Array> = this.database.prepare(query).all() as Array>; if (results.length > 0) { @@ -92,6 +100,10 @@ export default class EngineRepository { 'log_loki_password = ?, ' + 'log_oia_level = ?, ' + 'log_oia_interval = ?, ' + + 'log_syslog_level = ?, ' + + 'log_syslog_host = ?, ' + + 'log_syslog_port = ?, ' + + 'log_syslog_protocol = ?, ' + `updated_by = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') ` + `WHERE rowid=(SELECT MIN(rowid) FROM ${ENGINES_TABLE});`; @@ -115,6 +127,10 @@ export default class EngineRepository { command.logParameters.loki.password, command.logParameters.oia.level, command.logParameters.oia.interval, + command.logParameters.syslog.level, + command.logParameters.syslog.host, + command.logParameters.syslog.port, + command.logParameters.syslog.protocol, updatedBy ); } @@ -195,8 +211,10 @@ export default class EngineRepository { `INSERT INTO ${ENGINES_TABLE} (id, name, oibus_version, oibus_launcher_version, port, proxy_enabled, proxy_port,` + 'log_console_level, log_file_level, log_file_max_file_size, log_file_number_of_files, log_database_level, ' + 'log_database_max_number_of_logs, log_loki_level, log_loki_interval, log_loki_address, ' + - `log_loki_username, log_loki_password, log_oia_level, log_oia_interval, created_by, updated_by, created_at, updated_at) ` + - `VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));`; + 'log_loki_username, log_loki_password, log_oia_level, log_oia_interval, ' + + 'log_syslog_level, log_syslog_host, log_syslog_port, log_syslog_protocol, ' + + `created_by, updated_by, created_at, updated_at) ` + + `VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));`; this.database .prepare(query) .run( @@ -220,6 +238,10 @@ export default class EngineRepository { command.logParameters.loki.password, command.logParameters.oia.level, command.logParameters.oia.interval, + command.logParameters.syslog.level, + command.logParameters.syslog.host, + command.logParameters.syslog.port, + command.logParameters.syslog.protocol, 'system', 'system' ); @@ -251,7 +273,6 @@ export default class EngineRepository { level: result.log_database_level as LogLevel, maxNumberOfLogs: result.log_database_max_number_of_logs as number }, - loki: { level: result.log_loki_level as LogLevel, interval: result.log_loki_interval as number, @@ -262,6 +283,12 @@ export default class EngineRepository { oia: { level: result.log_oia_level as LogLevel, interval: result.log_oia_interval as number + }, + syslog: { + level: result.log_syslog_level as LogLevel, + host: result.log_syslog_host as string, + port: result.log_syslog_port as number, + protocol: result.log_syslog_protocol as 'udp4' | 'tcp' } } }; diff --git a/backend/src/service/logger/declarations.d.ts b/backend/src/service/logger/declarations.d.ts new file mode 100644 index 0000000000..52703f8066 --- /dev/null +++ b/backend/src/service/logger/declarations.d.ts @@ -0,0 +1,35 @@ +declare module 'pino-syslog/lib/utils' { + interface SyslogOptions { + appname: string; + facility: number; + newline: boolean; + modern: boolean; + messageOnly: boolean; + includeProperties: Array; + tz: string; + sync: boolean; + enablePipelining: boolean; + pinoLevelToSyslogSeverity: Map; + } + function buildOptions(options?: Record): SyslogOptions; + export { buildOptions, SyslogOptions }; +} + +declare module 'pino-syslog/lib/rfc5424' { + import type { SyslogOptions } from 'pino-syslog/lib/utils'; + function messageBuilderFactory(options: SyslogOptions): (log: unknown) => string; + export { messageBuilderFactory }; +} + +declare module 'pino-socket' { + import type { Writable } from 'node:stream'; + interface PinoSocketOptions { + address?: string; + port?: number; + mode?: 'udp4' | 'tcp'; + reconnect?: boolean; + sourceStream?: false | NodeJS.ReadableStream; + } + function socketTransport(options: PinoSocketOptions): Promise; + export = socketTransport; +} diff --git a/backend/src/service/logger/logger.service.spec.ts b/backend/src/service/logger/logger.service.spec.ts index 233ae7b7fa..d76cec0b12 100644 --- a/backend/src/service/logger/logger.service.spec.ts +++ b/backend/src/service/logger/logger.service.spec.ts @@ -88,22 +88,6 @@ describe('Logger', () => { }, level: engineSettings.logParameters.database.level }, - { - target: 'pino-loki', - options: { - batching: { - interval: engineSettings.logParameters.loki.interval, - maxBufferSize: 50000 - }, - host: engineSettings.logParameters.loki.address, - basicAuth: { - username: engineSettings.logParameters.loki.username, - password: engineSettings.logParameters.loki.password - }, - labels: { name: engineSettings.name } - }, - level: engineSettings.logParameters.loki.level - }, { target: path.join(__dirname, 'oianalytics-transport.js'), options: { @@ -113,6 +97,26 @@ describe('Logger', () => { cryptoSettings: {} }, level: engineSettings.logParameters.oia.level + }, + { + target: path.join(__dirname, 'syslog-transport.js'), + options: { + host: engineSettings.logParameters.syslog.host, + port: engineSettings.logParameters.syslog.port, + protocol: engineSettings.logParameters.syslog.protocol, + appName: engineSettings.name + }, + level: engineSettings.logParameters.syslog.level + }, + { + target: 'pino-loki', + options: { + batching: { interval: engineSettings.logParameters.loki.interval, maxBufferSize: 50000 }, + host: engineSettings.logParameters.loki.address, + basicAuth: { username: engineSettings.logParameters.loki.username, password: engineSettings.logParameters.loki.password }, + labels: { name: engineSettings.name } + }, + level: engineSettings.logParameters.loki.level } ]; @@ -162,6 +166,26 @@ describe('Logger', () => { }, level: specificSettings.logParameters.file.level }, + { + target: path.join(__dirname, 'oianalytics-transport.js'), + options: { + interval: specificSettings.logParameters.oia.interval, + registrationSettings: specificRegistration, + certsFolder: '', + cryptoSettings: {} + }, + level: specificSettings.logParameters.oia.level + }, + { + target: path.join(__dirname, 'syslog-transport.js'), + options: { + host: specificSettings.logParameters.syslog.host, + port: specificSettings.logParameters.syslog.port, + protocol: specificSettings.logParameters.syslog.protocol, + appName: specificSettings.name + }, + level: specificSettings.logParameters.syslog.level + }, { target: 'pino-loki', options: { @@ -177,16 +201,6 @@ describe('Logger', () => { labels: { name: specificSettings.name } }, level: specificSettings.logParameters.loki.level - }, - { - target: path.join(__dirname, 'oianalytics-transport.js'), - options: { - interval: specificSettings.logParameters.oia.interval, - registrationSettings: specificRegistration, - certsFolder: '', - cryptoSettings: {} - }, - level: specificSettings.logParameters.oia.level } ]; @@ -211,6 +225,7 @@ describe('Logger', () => { const specificSettings: EngineSettings = JSON.parse(JSON.stringify(testData.engine.settings)); specificSettings.logParameters.database.maxNumberOfLogs = 0; specificSettings.logParameters.loki.address = ''; + specificSettings.logParameters.syslog.host = ''; const expectedTargets = [ { target: 'pino-pretty', options: { colorize: true, singleLine: true }, level: specificSettings.logParameters.console.level }, @@ -253,4 +268,45 @@ describe('Logger', () => { service.stop(); assert.strictEqual(stopMock.mock.calls.length, 1); }); + + it('should add syslog transport when host is set and level is not silent', async () => { + const specificSettings: EngineSettings = JSON.parse(JSON.stringify(testData.engine.settings)); + specificSettings.logParameters.database.maxNumberOfLogs = 0; + specificSettings.logParameters.loki.address = ''; + specificSettings.logParameters.syslog.host = 'syslog.example.com'; + specificSettings.logParameters.syslog.level = 'info'; + + await service.start(specificSettings, null); + + const targets: Array<{ target: string }> = pinoMock.mock.calls[0].arguments[0].transport.targets; + const syslogTarget = targets.find(t => t.target === path.join(__dirname, 'syslog-transport.js')); + assert.ok(syslogTarget !== undefined, 'syslog-transport.js target should be present'); + }); + + it('should not add syslog transport when host is empty', async () => { + const specificSettings: EngineSettings = JSON.parse(JSON.stringify(testData.engine.settings)); + specificSettings.logParameters.database.maxNumberOfLogs = 0; + specificSettings.logParameters.loki.address = ''; + specificSettings.logParameters.syslog.host = ''; + + await service.start(specificSettings, null); + + const targets: Array<{ target: string }> = pinoMock.mock.calls[0].arguments[0].transport.targets; + const syslogTarget = targets.find(t => t.target === path.join(__dirname, 'syslog-transport.js')); + assert.strictEqual(syslogTarget, undefined, 'syslog-transport.js target should not be present'); + }); + + it('should not add syslog transport when level is silent', async () => { + const specificSettings: EngineSettings = JSON.parse(JSON.stringify(testData.engine.settings)); + specificSettings.logParameters.database.maxNumberOfLogs = 0; + specificSettings.logParameters.loki.address = ''; + specificSettings.logParameters.syslog.host = 'syslog.example.com'; + specificSettings.logParameters.syslog.level = 'silent'; + + await service.start(specificSettings, null); + + const targets: Array<{ target: string }> = pinoMock.mock.calls[0].arguments[0].transport.targets; + const syslogTarget = targets.find(t => t.target === path.join(__dirname, 'syslog-transport.js')); + assert.strictEqual(syslogTarget, undefined, 'syslog-transport.js target should not be present'); + }); }); diff --git a/backend/src/service/logger/logger.service.ts b/backend/src/service/logger/logger.service.ts index 2e1e07bcf0..f4c234b556 100644 --- a/backend/src/service/logger/logger.service.ts +++ b/backend/src/service/logger/logger.service.ts @@ -55,11 +55,12 @@ const computeParentLevel = (targets: ReadonlyArray<{ level: string }>): pino.Lev /** * Manage pino loggers - * Four loggers are supported: + * Five transports are supported: * - Console * - File * - SQLite * - Loki + * - Syslog (RFC 5424 over UDP/TCP via pino-syslog + pino-socket) * @class LoggerService */ class LoggerService { @@ -99,22 +100,43 @@ class LoggerService { }); } + if (registration && registration.status === 'REGISTERED' && engineSettings.logParameters.oia.level !== 'silent') { + targets.push({ + target: path.join(__dirname, './oianalytics-transport.js'), + options: { + interval: engineSettings.logParameters.oia.interval, + registrationSettings: registration, + cryptoSettings: encryptionService.cryptoSettings, + certsFolder: encryptionService.certsFolder + }, + level: engineSettings.logParameters.oia.level + }); + } + + if (engineSettings.logParameters.syslog.host && engineSettings.logParameters.syslog.level !== 'silent') { + targets.push({ + target: path.join(__dirname, 'syslog-transport.js'), + options: { + host: engineSettings.logParameters.syslog.host, + port: engineSettings.logParameters.syslog.port, + protocol: engineSettings.logParameters.syslog.protocol, + appName: engineSettings.name + }, + level: engineSettings.logParameters.syslog.level + }); + } + if (engineSettings.logParameters.loki.address && engineSettings.logParameters.loki.level !== 'silent') { try { + const lokiPassword = engineSettings.logParameters.loki.password + ? await encryptionService.decryptText(engineSettings.logParameters.loki.password) + : ''; targets.push({ target: 'pino-loki', options: { - batching: { - interval: engineSettings.logParameters.loki.interval, - maxBufferSize: 50000 - }, + batching: { interval: engineSettings.logParameters.loki.interval, maxBufferSize: 50000 }, host: engineSettings.logParameters.loki.address, - basicAuth: { - username: engineSettings.logParameters.loki.username, - password: engineSettings.logParameters.loki.password - ? await encryptionService.decryptText(engineSettings.logParameters.loki.password) - : '' - }, + basicAuth: { username: engineSettings.logParameters.loki.username, password: lokiPassword }, labels: { name: engineSettings.name } }, level: engineSettings.logParameters.loki.level @@ -126,19 +148,6 @@ class LoggerService { } } - if (registration && registration.status === 'REGISTERED' && engineSettings.logParameters.oia.level !== 'silent') { - targets.push({ - target: path.join(__dirname, './oianalytics-transport.js'), - options: { - interval: engineSettings.logParameters.oia.interval, - registrationSettings: registration, - cryptoSettings: encryptionService.cryptoSettings, - certsFolder: encryptionService.certsFolder - }, - level: engineSettings.logParameters.oia.level - }); - } - this.logger = pino({ base: undefined, // Most-verbose level across enabled transports. Cheap calls above this diff --git a/backend/src/service/logger/syslog-transport.spec.ts b/backend/src/service/logger/syslog-transport.spec.ts new file mode 100644 index 0000000000..947fa57da3 --- /dev/null +++ b/backend/src/service/logger/syslog-transport.spec.ts @@ -0,0 +1,179 @@ +import { describe, it, before, beforeEach, afterEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { reloadModule } from '../../tests/utils/test-utils'; +import type { SyslogTransportOptions } from './syslog-transport'; + +const nodeRequire = createRequire(import.meta.url); + +type CreateTransport = (opts: SyslogTransportOptions) => Promise; + +let createTransport: CreateTransport; + +// Per-test mocks initialised in beforeEach +let mockBuildOptions: ReturnType; +let mockMessageBuilderFactory: ReturnType; +let mockFormatMessage: ReturnType; +let mockSocketWrite: ReturnType; +let mockSocketDestroy: ReturnType; +let mockSocketTransport: ReturnType; + +// Captured build() handlers +let capturedSourceFn: ((source: AsyncIterable) => Promise) | null = null; +let capturedCloseFn: (() => Promise) | null = null; + +before(() => { + // Mock pino-syslog/lib/utils + nodeRequire('pino-syslog/lib/utils'); + const utilsPath = nodeRequire.resolve('pino-syslog/lib/utils'); + nodeRequire.cache[utilsPath]!.exports = { + buildOptions: (...args: Array) => mockBuildOptions(...args) + }; + + // Mock pino-syslog/lib/rfc5424 + nodeRequire('pino-syslog/lib/rfc5424'); + const rfc5424Path = nodeRequire.resolve('pino-syslog/lib/rfc5424'); + nodeRequire.cache[rfc5424Path]!.exports = { + messageBuilderFactory: (...args: Array) => mockMessageBuilderFactory(...args) + }; + + // Mock pino-socket + nodeRequire('pino-socket'); + const pinoSocketPath = nodeRequire.resolve('pino-socket'); + nodeRequire.cache[pinoSocketPath]!.exports = (...args: Array) => mockSocketTransport(...args); + + // Mock pino-abstract-transport — captures sourceFn and closeFn + const buildMock = (sourceFn: (source: AsyncIterable) => Promise, opts: { close: () => Promise }) => { + capturedSourceFn = sourceFn; + capturedCloseFn = opts.close; + return {}; + }; + nodeRequire('pino-abstract-transport'); + const pinoAbstractPath = nodeRequire.resolve('pino-abstract-transport'); + nodeRequire.cache[pinoAbstractPath]!.exports = { __esModule: true, default: buildMock }; + + createTransport = reloadModule<{ default: CreateTransport }>(nodeRequire, './syslog-transport').default; +}); + +beforeEach(() => { + mockSocketWrite = mock.fn(); + mockSocketDestroy = mock.fn(); + const mockSocket = { write: mockSocketWrite, destroy: mockSocketDestroy }; + mockSocketTransport = mock.fn(async () => mockSocket); + mockFormatMessage = mock.fn((log: unknown) => `formatted:${JSON.stringify(log)}`); + mockMessageBuilderFactory = mock.fn(() => mockFormatMessage); + mockBuildOptions = mock.fn((opts: unknown) => opts); + capturedSourceFn = null; + capturedCloseFn = null; +}); + +afterEach(() => { + mock.restoreAll(); +}); + +describe('SyslogTransport (createTransport)', () => { + const defaultOpts: SyslogTransportOptions = { + host: 'syslog.example.com', + port: 514, + protocol: 'udp4', + appName: 'OIBus' + }; + + it('should initialise the formatter and connect the socket on creation', async () => { + await createTransport(defaultOpts); + + assert.strictEqual(mockBuildOptions.mock.calls.length, 1); + assert.deepStrictEqual(mockBuildOptions.mock.calls[0].arguments[0], { + appname: defaultOpts.appName, + newline: true, + enablePipelining: false + }); + assert.strictEqual(mockMessageBuilderFactory.mock.calls.length, 1); + assert.strictEqual(mockSocketTransport.mock.calls.length, 1); + assert.deepStrictEqual(mockSocketTransport.mock.calls[0].arguments[0], { + address: defaultOpts.host, + port: defaultOpts.port, + mode: defaultOpts.protocol, + reconnect: true, + sourceStream: false + }); + assert.ok(capturedSourceFn !== null); + assert.ok(capturedCloseFn !== null); + }); + + it('should log an error and continue when socket connection fails', async () => { + mockSocketTransport = mock.fn(async () => { + throw new Error('connection refused'); + }); + const consoleSpy = mock.method(console, 'error', () => undefined); + + await createTransport(defaultOpts); + + assert.strictEqual(consoleSpy.mock.calls.length, 1); + assert.match(consoleSpy.mock.calls[0].arguments[0] as string, /^Failed to connect to syslog server at syslog\.example\.com:514/); + mock.restoreAll(); + }); + + it('source handler should format and write each log to the socket', async () => { + await createTransport(defaultOpts); + + const log = { level: 30, msg: 'hello', time: 1000 }; + async function* makeSource() { + yield log; + } + await capturedSourceFn!(makeSource()); + + assert.strictEqual(mockFormatMessage.mock.calls.length, 1); + assert.deepStrictEqual(mockFormatMessage.mock.calls[0].arguments[0], log); + assert.strictEqual(mockSocketWrite.mock.calls.length, 1); + assert.strictEqual(mockSocketWrite.mock.calls[0].arguments[0], `formatted:${JSON.stringify(log)}`); + }); + + it('source handler should silently skip writes when socket is null (connect failed)', async () => { + mockSocketTransport = mock.fn(async () => { + throw new Error('no connection'); + }); + mock.method(console, 'error', () => undefined); + + await createTransport(defaultOpts); + + const log = { level: 30, msg: 'lost' }; + async function* makeSource() { + yield log; + } + await capturedSourceFn!(makeSource()); + + assert.strictEqual(mockSocketWrite.mock.calls.length, 0); + mock.restoreAll(); + }); + + it('close handler should destroy the socket and clear the reference', async () => { + await createTransport(defaultOpts); + + await capturedCloseFn!(); + + assert.strictEqual(mockSocketDestroy.mock.calls.length, 1); + }); + + it('close handler should be a no-op when socket is null (connect failed)', async () => { + mockSocketTransport = mock.fn(async () => { + throw new Error('no connection'); + }); + mock.method(console, 'error', () => undefined); + + await createTransport(defaultOpts); + await capturedCloseFn!(); + + assert.strictEqual(mockSocketDestroy.mock.calls.length, 0); + mock.restoreAll(); + }); + + it('second call to end() should be a no-op (socket already nulled)', async () => { + await createTransport(defaultOpts); + + await capturedCloseFn!(); // destroys socket, sets socket = null + await capturedCloseFn!(); // second call — socket is null, should not throw + + assert.strictEqual(mockSocketDestroy.mock.calls.length, 1); + }); +}); diff --git a/backend/src/service/logger/syslog-transport.ts b/backend/src/service/logger/syslog-transport.ts new file mode 100644 index 0000000000..bc491eb55a --- /dev/null +++ b/backend/src/service/logger/syslog-transport.ts @@ -0,0 +1,68 @@ +import type { Writable } from 'node:stream'; + +import build from 'pino-abstract-transport'; +import { buildOptions } from 'pino-syslog/lib/utils'; +import { messageBuilderFactory } from 'pino-syslog/lib/rfc5424'; +import socketTransport from 'pino-socket'; + +export interface SyslogTransportOptions { + host: string; + port: number; + protocol: 'udp4' | 'tcp'; + appName: string; +} + +class SyslogTransport { + private readonly formatMessage: (log: unknown) => string; + private socket: Writable | null = null; + + constructor(private readonly options: SyslogTransportOptions) { + const syslogOpts = buildOptions({ appname: options.appName, newline: true, enablePipelining: false }); + this.formatMessage = messageBuilderFactory(syslogOpts); + } + + async connect(): Promise { + try { + this.socket = await socketTransport({ + address: this.options.host, + port: this.options.port, + mode: this.options.protocol, + reconnect: true, + sourceStream: false + }); + } catch (error: unknown) { + console.error(`Failed to connect to syslog server at ${this.options.host}:${this.options.port}: ${(error as Error).message}`); + } + } + + send(log: unknown): void { + if (!this.socket) return; + this.socket.write(this.formatMessage(log)); + } + + end(): void { + if (this.socket) { + this.socket.destroy(); + this.socket = null; + } + } +} + +const createTransport = async (opts: SyslogTransportOptions) => { + const transport = new SyslogTransport(opts); + await transport.connect(); + return build( + async source => { + for await (const log of source) { + transport.send(log); + } + }, + { + close: () => { + transport.end(); + } + } + ); +}; + +export default createTransport; diff --git a/backend/src/service/oia/oianalytics-message.service.ts b/backend/src/service/oia/oianalytics-message.service.ts index 73f401a74f..42e1f047fc 100644 --- a/backend/src/service/oia/oianalytics-message.service.ts +++ b/backend/src/service/oia/oianalytics-message.service.ts @@ -389,6 +389,12 @@ export default class OIAnalyticsMessageService { oia: { level: engine.logParameters.oia.level, interval: engine.logParameters.oia.interval + }, + syslog: { + level: engine.logParameters.syslog.level, + host: engine.logParameters.syslog.host, + port: engine.logParameters.syslog.port, + protocol: engine.logParameters.syslog.protocol } } } diff --git a/backend/src/service/oibus.service.spec.ts b/backend/src/service/oibus.service.spec.ts index b8c2a9577a..6bcd94f3dd 100644 --- a/backend/src/service/oibus.service.spec.ts +++ b/backend/src/service/oibus.service.spec.ts @@ -696,6 +696,12 @@ describe('OIBus Service', () => { oia: { level: engineSettings.logParameters.oia.level, interval: engineSettings.logParameters.oia.interval + }, + syslog: { + level: engineSettings.logParameters.syslog.level, + host: engineSettings.logParameters.syslog.host, + port: engineSettings.logParameters.syslog.port, + protocol: engineSettings.logParameters.syslog.protocol } } } diff --git a/backend/src/service/oibus.service.ts b/backend/src/service/oibus.service.ts index 63ddd95d30..9cca0e3f8e 100644 --- a/backend/src/service/oibus.service.ts +++ b/backend/src/service/oibus.service.ts @@ -430,6 +430,12 @@ export const toEngineSettingsDTO = (engineSettings: EngineSettings, getUserInfo: oia: { level: engineSettings.logParameters.oia.level, interval: engineSettings.logParameters.oia.interval + }, + syslog: { + level: engineSettings.logParameters.syslog.level, + host: engineSettings.logParameters.syslog.host, + port: engineSettings.logParameters.syslog.port, + protocol: engineSettings.logParameters.syslog.protocol } } }; diff --git a/backend/src/tests/utils/test-data.ts b/backend/src/tests/utils/test-data.ts index 7ac21a52a0..8c27b693ea 100644 --- a/backend/src/tests/utils/test-data.ts +++ b/backend/src/tests/utils/test-data.ts @@ -1383,6 +1383,12 @@ const engineSettings: EngineSettings = { oia: { level: 'info', interval: 10 + }, + syslog: { + level: 'info', + host: 'syslog.example.com', + port: 514, + protocol: 'udp4' } }, createdBy: '', @@ -1418,6 +1424,12 @@ const engineSettingsCommand: EngineSettingsCommandDTO = { oia: { level: 'silent', interval: 10 + }, + syslog: { + level: 'silent', + host: '', + port: 514, + protocol: 'udp4' } } }; diff --git a/backend/src/tests/utils/test-utils.ts b/backend/src/tests/utils/test-utils.ts index 5b5e4e4af0..e707b5dae5 100644 --- a/backend/src/tests/utils/test-utils.ts +++ b/backend/src/tests/utils/test-utils.ts @@ -467,6 +467,10 @@ const createEngineSettings = async (database: knex.Knex, engineSettings: EngineS log_loki_password: engineSettings.logParameters.loki.password, log_oia_level: engineSettings.logParameters.oia.level, log_oia_interval: engineSettings.logParameters.oia.interval, + log_syslog_level: engineSettings.logParameters.syslog.level, + log_syslog_host: engineSettings.logParameters.syslog.host, + log_syslog_port: engineSettings.logParameters.syslog.port, + log_syslog_protocol: engineSettings.logParameters.syslog.protocol, proxy_enabled: engineSettings.proxyEnabled, proxy_port: engineSettings.proxyPort, oibus_version: engineSettings.version, diff --git a/backend/src/web-server/controllers/validators/oibus-validation-schema.ts b/backend/src/web-server/controllers/validators/oibus-validation-schema.ts index 6ea6269e19..e7f73cbf73 100644 --- a/backend/src/web-server/controllers/validators/oibus-validation-schema.ts +++ b/backend/src/web-server/controllers/validators/oibus-validation-schema.ts @@ -49,6 +49,12 @@ const logParametersSchema = Joi.object({ oia: Joi.object({ level: Joi.string().required().allow('silent', 'error', 'warning', 'info', 'debug', 'trace'), interval: Joi.number().integer().required().min(10) + }), + syslog: Joi.object({ + level: Joi.string().required().allow('silent', 'error', 'warning', 'info', 'debug', 'trace'), + host: Joi.string().allow('', null), + port: Joi.number().integer().required().min(1).max(65535), + protocol: Joi.string().valid('udp4', 'tcp').required() }) }); diff --git a/backend/src/web-server/routes.ts b/backend/src/web-server/routes.ts index 4a9067b43f..481dcc76b6 100644 --- a/backend/src/web-server/routes.ts +++ b/backend/src/web-server/routes.ts @@ -1,3 +1,4 @@ +/* tslint:disable */ /* eslint-disable */ // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import type { TsoaRoute } from '@tsoa/runtime'; @@ -4147,7 +4148,7 @@ const models: TsoaRoute.Models = { "port": {"dataType":"double","required":true}, "proxyEnabled": {"dataType":"boolean","required":true}, "proxyPort": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "logParameters": {"dataType":"nestedObjectLiteral","nestedProperties":{"oia":{"dataType":"nestedObjectLiteral","nestedProperties":{"interval":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"loki":{"dataType":"nestedObjectLiteral","nestedProperties":{"password":{"dataType":"string","required":true},"username":{"dataType":"string","required":true},"address":{"dataType":"string","required":true},"interval":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"database":{"dataType":"nestedObjectLiteral","nestedProperties":{"maxNumberOfLogs":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"file":{"dataType":"nestedObjectLiteral","nestedProperties":{"numberOfFiles":{"dataType":"double","required":true},"maxFileSize":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"console":{"dataType":"nestedObjectLiteral","nestedProperties":{"level":{"ref":"LogLevel","required":true}},"required":true}},"required":true}, + "logParameters": {"dataType":"nestedObjectLiteral","nestedProperties":{"syslog":{"dataType":"nestedObjectLiteral","nestedProperties":{"protocol":{"dataType":"union","subSchemas":[{"dataType":"enum","enums":["udp4"]},{"dataType":"enum","enums":["tcp"]}],"required":true},"port":{"dataType":"double","required":true},"host":{"dataType":"string","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"oia":{"dataType":"nestedObjectLiteral","nestedProperties":{"interval":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"loki":{"dataType":"nestedObjectLiteral","nestedProperties":{"password":{"dataType":"string","required":true},"username":{"dataType":"string","required":true},"address":{"dataType":"string","required":true},"interval":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"database":{"dataType":"nestedObjectLiteral","nestedProperties":{"maxNumberOfLogs":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"file":{"dataType":"nestedObjectLiteral","nestedProperties":{"numberOfFiles":{"dataType":"double","required":true},"maxFileSize":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"console":{"dataType":"nestedObjectLiteral","nestedProperties":{"level":{"ref":"LogLevel","required":true}},"required":true}},"required":true}, }, "additionalProperties": false, }, @@ -7515,7 +7516,7 @@ const models: TsoaRoute.Models = { "launcherVersion": {"dataType":"string","required":true}, "proxyEnabled": {"dataType":"boolean","required":true}, "proxyPort": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "logParameters": {"dataType":"nestedObjectLiteral","nestedProperties":{"oia":{"dataType":"nestedObjectLiteral","nestedProperties":{"interval":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"loki":{"dataType":"nestedObjectLiteral","nestedProperties":{"password":{"dataType":"string","required":true},"username":{"dataType":"string","required":true},"address":{"dataType":"string","required":true},"interval":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"database":{"dataType":"nestedObjectLiteral","nestedProperties":{"maxNumberOfLogs":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"file":{"dataType":"nestedObjectLiteral","nestedProperties":{"numberOfFiles":{"dataType":"double","required":true},"maxFileSize":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"console":{"dataType":"nestedObjectLiteral","nestedProperties":{"level":{"ref":"LogLevel","required":true}},"required":true}},"required":true}, + "logParameters": {"dataType":"nestedObjectLiteral","nestedProperties":{"syslog":{"dataType":"nestedObjectLiteral","nestedProperties":{"protocol":{"dataType":"union","subSchemas":[{"dataType":"enum","enums":["udp4"]},{"dataType":"enum","enums":["tcp"]}],"required":true},"port":{"dataType":"double","required":true},"host":{"dataType":"string","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"oia":{"dataType":"nestedObjectLiteral","nestedProperties":{"interval":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"loki":{"dataType":"nestedObjectLiteral","nestedProperties":{"password":{"dataType":"string","required":true},"username":{"dataType":"string","required":true},"address":{"dataType":"string","required":true},"interval":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"database":{"dataType":"nestedObjectLiteral","nestedProperties":{"maxNumberOfLogs":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"file":{"dataType":"nestedObjectLiteral","nestedProperties":{"numberOfFiles":{"dataType":"double","required":true},"maxFileSize":{"dataType":"double","required":true},"level":{"ref":"LogLevel","required":true}},"required":true},"console":{"dataType":"nestedObjectLiteral","nestedProperties":{"level":{"ref":"LogLevel","required":true}},"required":true}},"required":true}, }, "additionalProperties": false, }, diff --git a/documentation/docs/api/openapi.json b/documentation/docs/api/openapi.json index f4c39d72be..a4989da1c6 100644 --- a/documentation/docs/api/openapi.json +++ b/documentation/docs/api/openapi.json @@ -16640,6 +16640,43 @@ }, "logParameters": { "properties": { + "syslog": { + "properties": { + "protocol": { + "type": "string", + "enum": [ + "udp4", + "tcp" + ], + "description": "The transport protocol.", + "example": "udp4" + }, + "port": { + "type": "number", + "format": "double", + "description": "The port of the syslog server.", + "example": 514 + }, + "host": { + "type": "string", + "description": "The hostname or IP of the syslog server. Empty string disables the transport.", + "example": "syslog.example.com" + }, + "level": { + "$ref": "#/components/schemas/LogLevel", + "description": "The log level for syslog output.", + "example": "info" + } + }, + "required": [ + "protocol", + "port", + "host", + "level" + ], + "type": "object", + "description": "Syslog logging configuration." + }, "oia": { "properties": { "interval": { @@ -16765,6 +16802,7 @@ } }, "required": [ + "syslog", "oia", "loki", "database", @@ -31325,6 +31363,42 @@ }, "logParameters": { "properties": { + "syslog": { + "properties": { + "protocol": { + "type": "string", + "enum": [ + "udp4", + "tcp" + ], + "description": "The transport protocol.", + "example": "udp4" + }, + "port": { + "type": "number", + "format": "double", + "description": "The port of the syslog server.", + "example": 514 + }, + "host": { + "type": "string", + "description": "The hostname or IP of the syslog server. Empty string disables the transport.", + "example": "syslog.example.com" + }, + "level": { + "$ref": "#/components/schemas/LogLevel", + "description": "The log level for syslog output." + } + }, + "required": [ + "protocol", + "port", + "host", + "level" + ], + "type": "object", + "description": "Syslog logging configuration." + }, "oia": { "properties": { "interval": { @@ -31445,6 +31519,7 @@ } }, "required": [ + "syslog", "oia", "loki", "database", diff --git a/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.html b/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.html index 930b70e609..dbe372f13f 100644 --- a/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.html +++ b/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.html @@ -143,6 +143,48 @@ } + + +
+
+
+ + +
+
+
+ @if (!isLevelSilent('syslog')) { +
+
+
+ + +
+
+
+
+ + + +
+
+
+
+ + +
+
+
+ } +
+
diff --git a/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.spec.ts b/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.spec.ts index a9bc48a2ac..175d37f884 100644 --- a/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.spec.ts +++ b/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.spec.ts @@ -17,7 +17,8 @@ const engineSettings = { file: { level: 'info', maxFileSize: 50, numberOfFiles: 5 }, database: { level: 'info', maxNumberOfLogs: 100_000 }, loki: { level: 'silent', interval: 60, address: '', username: '', password: '' }, - oia: { level: 'silent', interval: 10 } + oia: { level: 'silent', interval: 10 }, + syslog: { level: 'silent', host: '', port: 514, protocol: 'udp4' } } } as EngineSettingsDTO; diff --git a/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.ts b/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.ts index 35dd63607c..b19a14022e 100644 --- a/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.ts +++ b/frontend/src/app/engine/edit-engine-logger-modal/edit-engine-logger-modal.component.ts @@ -47,6 +47,12 @@ export class EditEngineLoggerModalComponent { oia: this.fb.group({ level: ['silent' as LogLevel, Validators.required], interval: [null as number | null, [Validators.required, Validators.min(10)]] + }), + syslog: this.fb.group({ + level: ['silent' as LogLevel, Validators.required], + host: ['' as string], + port: [514 as number | null, [Validators.required, Validators.min(1), Validators.max(65535)]], + protocol: ['udp4' as 'udp4' | 'tcp', Validators.required] }) }) }); @@ -115,7 +121,7 @@ export class EditEngineLoggerModalComponent { }); } - isLevelSilent(category: 'console' | 'file' | 'database' | 'loki' | 'oia'): boolean { + isLevelSilent(category: 'console' | 'file' | 'database' | 'loki' | 'oia' | 'syslog'): boolean { return this.form.controls.logParameters.controls[category].controls.level.value === 'silent'; } @@ -205,6 +211,12 @@ export class EditEngineLoggerModalComponent { oia: { level: formValue.logParameters.oia.level, interval: formValue.logParameters.oia.interval! + }, + syslog: { + level: formValue.logParameters.syslog.level, + host: formValue.logParameters.syslog.host, + port: formValue.logParameters.syslog.port!, + protocol: formValue.logParameters.syslog.protocol } }) .subscribe(() => { diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 07db9c0b19..c9ebc1b139 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -573,6 +573,12 @@ "oia": { "level": "OIAnalytics level", "interval": "OIAnalytics interval" + }, + "syslog": { + "level": "Syslog level", + "host": "Syslog host", + "port": "Syslog port", + "protocol": "Syslog protocol" } }, "monitoring": { From a04b06dd09bbff26b1e55f7afcd036a6a559fd5a Mon Sep 17 00:00:00 2001 From: kbalinthunor Date: Mon, 8 Jun 2026 13:02:19 +0300 Subject: [PATCH 08/24] fix(routes): remove deprecated tslint comment from generated routes file --- backend/src/web-server/routes.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/web-server/routes.ts b/backend/src/web-server/routes.ts index 481dcc76b6..ee3d55af03 100644 --- a/backend/src/web-server/routes.ts +++ b/backend/src/web-server/routes.ts @@ -1,4 +1,3 @@ -/* tslint:disable */ /* eslint-disable */ // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import type { TsoaRoute } from '@tsoa/runtime'; From 40ddd0da85753e6263b70701cefe92c4c3cce4f1 Mon Sep 17 00:00:00 2001 From: kbalinthunor Date: Wed, 3 Jun 2026 15:54:22 +0300 Subject: [PATCH 09/24] feat(proxy): forward requests to upstream proxy --- backend/shared/model/engine.model.ts | 36 +++ .../entity-migrations/3/3.9/v3.9.0.spec.ts | 100 +++++++ .../entity-migrations/3/3.9/v3.9.0.ts | 3 + backend/src/migration/migration-service.ts | 1 + backend/src/model/engine.model.ts | 3 + .../repository/config/engine.repository.ts | 15 ++ .../oia/oianalytics-message.service.ts | 3 + backend/src/service/oibus.service.spec.ts | 4 + backend/src/service/oibus.service.ts | 20 +- backend/src/tests/utils/test-data.ts | 6 + backend/src/tests/utils/test-utils.ts | 3 + .../validators/oibus-validation-schema.ts | 8 +- backend/src/web-server/proxy-server.spec.ts | 250 ++++++++++++++++++ backend/src/web-server/proxy-server.ts | 104 +++++++- documentation/docs/api/openapi.json | 42 +++ .../edit-engine-proxy-modal.component.html | 27 ++ .../edit-engine-proxy-modal.component.spec.ts | 16 +- .../edit-engine-proxy-modal.component.ts | 30 ++- frontend/src/i18n/en.json | 5 +- 19 files changed, 666 insertions(+), 10 deletions(-) create mode 100644 backend/src/migration/entity-migrations/3/3.9/v3.9.0.spec.ts diff --git a/backend/shared/model/engine.model.ts b/backend/shared/model/engine.model.ts index 8d492d9eac..354ad3d95d 100644 --- a/backend/shared/model/engine.model.ts +++ b/backend/shared/model/engine.model.ts @@ -55,6 +55,24 @@ export interface EngineSettingsDTO extends BaseEntity { */ proxyPort: number | null; + /** + * The URL of the upstream proxy to forward requests through. + * @example null + */ + forwardProxyUrl: string | null; + + /** + * The username for upstream proxy authentication. + * @example null + */ + forwardProxyUsername: string | null; + + /** + * The password for upstream proxy authentication. + * @example null + */ + forwardProxyPassword: string | null; + /** * Logging parameters for different outputs. */ @@ -889,6 +907,24 @@ export interface EngineSettingsCommandDTO { */ proxyPort: number | null; + /** + * The URL of the upstream proxy to forward requests through. + * @example null + */ + forwardProxyUrl: string | null; + + /** + * The username for upstream proxy authentication. + * @example null + */ + forwardProxyUsername: string | null; + + /** + * The password for upstream proxy authentication. + * @example null + */ + forwardProxyPassword: string | null; + /** * Logging parameters for different outputs. */ diff --git a/backend/src/migration/entity-migrations/3/3.9/v3.9.0.spec.ts b/backend/src/migration/entity-migrations/3/3.9/v3.9.0.spec.ts new file mode 100644 index 0000000000..39ce879b94 --- /dev/null +++ b/backend/src/migration/entity-migrations/3/3.9/v3.9.0.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { readdirSync } from 'node:fs'; +import knex, { Knex } from 'knex'; +import { up } from './v3.9.0'; + +/** + * Collect every entity migration file (excluding specs) under the entity-migrations root, + * sorted lexicographically by filename — the same order OIBus's migration runner uses. + */ +function entityMigrationFiles(): Array<{ file: string; full: string }> { + const root = path.resolve(__dirname, '..', '..'); // .../entity-migrations + const collect = (base: string): Array<{ file: string; full: string }> => { + const out: Array<{ file: string; full: string }> = []; + for (const entry of readdirSync(base, { withFileTypes: true })) { + const full = path.join(base, entry.name); + if (entry.isDirectory()) { + out.push(...collect(full)); + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')) { + out.push({ file: entry.name, full }); + } + } + return out; + }; + return collect(root).sort((a, b) => (a.file > b.file ? 1 : a.file < b.file ? -1 : 0)); +} + +/** Build the real schema as it exists just before v3.9.0 by running every prior migration in order. */ +async function buildPreV390Schema(db: Knex): Promise { + const priorFiles = entityMigrationFiles().filter(f => f.file < 'v3.9.0'); + for (const { full } of priorFiles) { + const migration = (await import(full)) as { up: (k: Knex) => Promise }; + await migration.up(db); + } +} + +async function columnNames(db: Knex, table: string): Promise> { + const cols = (await db.raw(`PRAGMA table_info(${table})`)) as Array<{ name: string }>; + return cols.map(c => c.name); +} + +describe('Entity migration v3.9.0', () => { + let db: Knex; + let tmpDir: string; + let dbFile: string; + + before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oibus-entity-v390-')); + dbFile = path.join(tmpDir, 'test.db'); + }); + + after(async () => { + await db?.destroy(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + beforeEach(async () => { + await db?.destroy(); + await fs.rm(dbFile, { force: true }); + db = knex({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + await buildPreV390Schema(db); + }); + + it('runs end-to-end on a realistic pre-3.9.0 schema', async () => { + await up(db); // must not throw + }); + + it('creates the table with the expected columns', async () => { + await up(db); + const cols = await columnNames(db, 'engines'); + assert.ok(cols.includes('log_syslog_level'), 'engines.log_syslog_level added'); + assert.ok(cols.includes('log_syslog_host'), 'engines.log_syslog_host added'); + assert.ok(cols.includes('log_syslog_port'), 'engines.log_syslog_port added'); + assert.ok(cols.includes('log_syslog_protocol'), 'engines.log_syslog_protocol added'); + assert.ok(cols.includes('forward_proxy_url'), 'engines.forward_proxy_url added'); + assert.ok(cols.includes('forward_proxy_username'), 'engines.forward_proxy_username added'); + assert.ok(cols.includes('forward_proxy_password'), 'engines.forward_proxy_password added'); + }); + + it('populates syslog defaults on existing rows', async () => { + const row = await db('engines').first(); + await up(db); + const updated = await db('engines').where('id', row.id).first(); + assert.strictEqual(updated.log_syslog_level, 'silent'); + assert.strictEqual(updated.log_syslog_host, ''); + assert.strictEqual(updated.log_syslog_port, 514); + assert.strictEqual(updated.log_syslog_protocol, 'udp4'); + }); + + it('leaves forward proxy columns null by default', async () => { + await up(db); + const row = await db('engines').first(); + assert.strictEqual(row.forward_proxy_url, null); + assert.strictEqual(row.forward_proxy_username, null); + assert.strictEqual(row.forward_proxy_password, null); + }); +}); diff --git a/backend/src/migration/entity-migrations/3/3.9/v3.9.0.ts b/backend/src/migration/entity-migrations/3/3.9/v3.9.0.ts index 4546ef3a18..1cdfff98f9 100644 --- a/backend/src/migration/entity-migrations/3/3.9/v3.9.0.ts +++ b/backend/src/migration/entity-migrations/3/3.9/v3.9.0.ts @@ -8,6 +8,9 @@ export async function up(knex: Knex): Promise { t.string('log_syslog_host'); t.integer('log_syslog_port'); t.string('log_syslog_protocol'); + t.string('forward_proxy_url').nullable(); + t.string('forward_proxy_username').nullable(); + t.string('forward_proxy_password').nullable(); }); await knex(ENGINES_TABLE).update({ log_syslog_level: 'silent', diff --git a/backend/src/migration/migration-service.ts b/backend/src/migration/migration-service.ts index a3aeeccb4e..9cce159fed 100644 --- a/backend/src/migration/migration-service.ts +++ b/backend/src/migration/migration-service.ts @@ -157,4 +157,5 @@ export async function migrateDataFolder(dbPath: string): Promise { }); await knexConfig.migrate.latest(); + await knexConfig.destroy(); } diff --git a/backend/src/model/engine.model.ts b/backend/src/model/engine.model.ts index 50747fb59a..4284246ce5 100644 --- a/backend/src/model/engine.model.ts +++ b/backend/src/model/engine.model.ts @@ -8,6 +8,9 @@ export interface EngineSettings extends BaseEntity { launcherVersion: string; proxyEnabled: boolean; proxyPort: number | null; + forwardProxyUrl: string | null; + forwardProxyUsername: string | null; + forwardProxyPassword: string | null; logParameters: { console: { level: LogLevel; diff --git a/backend/src/repository/config/engine.repository.ts b/backend/src/repository/config/engine.repository.ts index 2dbb2a136e..21d31830c8 100644 --- a/backend/src/repository/config/engine.repository.ts +++ b/backend/src/repository/config/engine.repository.ts @@ -20,6 +20,9 @@ const DEFAULT_ENGINE_SETTINGS: Omit< port: 2223, proxyEnabled: false, proxyPort: 9000, + forwardProxyUrl: null, + forwardProxyUsername: null, + forwardProxyPassword: null, logParameters: { console: { level: 'silent' @@ -69,6 +72,7 @@ export default class EngineRepository { get(): EngineSettings | null { const query = 'SELECT id, name, port, oibus_version, oibus_launcher_version, proxy_enabled, proxy_port, ' + + 'forward_proxy_url, forward_proxy_username, forward_proxy_password, ' + 'log_console_level, log_file_level, log_file_max_file_size, log_file_number_of_files, ' + 'log_database_level, log_database_max_number_of_logs, ' + 'log_loki_level, log_loki_interval, log_loki_address, log_loki_username, log_loki_password, ' + @@ -87,6 +91,7 @@ export default class EngineRepository { update(command: EngineSettingsCommandDTO, updatedBy: string): void { const query = `UPDATE ${ENGINES_TABLE} SET name = ?, port = ?, proxy_enabled = ?, proxy_port = ?, ` + + 'forward_proxy_url = ?, forward_proxy_username = ?, forward_proxy_password = ?, ' + 'log_console_level = ?, ' + 'log_file_level = ?, ' + 'log_file_max_file_size = ?, ' + @@ -114,6 +119,9 @@ export default class EngineRepository { command.port, +command.proxyEnabled, command.proxyPort, + command.forwardProxyUrl, + command.forwardProxyUsername, + command.forwardProxyPassword, command.logParameters.console.level, command.logParameters.file.level, command.logParameters.file.maxFileSize, @@ -209,6 +217,7 @@ export default class EngineRepository { const query = `INSERT INTO ${ENGINES_TABLE} (id, name, oibus_version, oibus_launcher_version, port, proxy_enabled, proxy_port,` + + 'forward_proxy_url, forward_proxy_username, forward_proxy_password, ' + 'log_console_level, log_file_level, log_file_max_file_size, log_file_number_of_files, log_database_level, ' + 'log_database_max_number_of_logs, log_loki_level, log_loki_interval, log_loki_address, ' + 'log_loki_username, log_loki_password, log_oia_level, log_oia_interval, ' + @@ -225,6 +234,9 @@ export default class EngineRepository { command.port, +command.proxyEnabled, command.proxyPort, + command.forwardProxyUrl, + command.forwardProxyUsername, + command.forwardProxyPassword, command.logParameters.console.level, command.logParameters.file.level, command.logParameters.file.maxFileSize, @@ -256,6 +268,9 @@ export default class EngineRepository { launcherVersion: result.oibus_launcher_version as string, proxyEnabled: Boolean(result.proxy_enabled), proxyPort: result.proxy_port as number, + forwardProxyUrl: (result.forward_proxy_url as string) || null, + forwardProxyUsername: (result.forward_proxy_username as string) || null, + forwardProxyPassword: (result.forward_proxy_password as string) || null, createdBy: result.created_by as string, updatedBy: result.updated_by as string, createdAt: result.created_at as string, diff --git a/backend/src/service/oia/oianalytics-message.service.ts b/backend/src/service/oia/oianalytics-message.service.ts index 42e1f047fc..a5f3db563c 100644 --- a/backend/src/service/oia/oianalytics-message.service.ts +++ b/backend/src/service/oia/oianalytics-message.service.ts @@ -366,6 +366,9 @@ export default class OIAnalyticsMessageService { port: engine.port, proxyEnabled: engine.proxyEnabled, proxyPort: engine.proxyPort, + forwardProxyUrl: engine.forwardProxyUrl, + forwardProxyUsername: engine.forwardProxyUsername, + forwardProxyPassword: null, logParameters: { console: { level: engine.logParameters.console.level diff --git a/backend/src/service/oibus.service.spec.ts b/backend/src/service/oibus.service.spec.ts index 6bcd94f3dd..e0d55c955a 100644 --- a/backend/src/service/oibus.service.spec.ts +++ b/backend/src/service/oibus.service.spec.ts @@ -117,6 +117,7 @@ describe('OIBus Service', () => { // Reset encryptionService mock in-place const encryptionMock = mockEncryptionService.encryptionService as EncryptionServiceMock; encryptionMock.encryptText.mock.resetCalls(); + encryptionMock.decryptText.mock.resetCalls(); engineRepository.get.mock.mockImplementation(() => testData.engine.settings); engineMetricsRepository.getMetrics.mock.mockImplementation(() => testData.engine.metrics); @@ -672,6 +673,9 @@ describe('OIBus Service', () => { launcherVersion: engineSettings.launcherVersion, proxyEnabled: engineSettings.proxyEnabled, proxyPort: engineSettings.proxyPort, + forwardProxyUrl: engineSettings.forwardProxyUrl, + forwardProxyUsername: engineSettings.forwardProxyUsername, + forwardProxyPassword: '', logParameters: { console: { level: engineSettings.logParameters.console.level diff --git a/backend/src/service/oibus.service.ts b/backend/src/service/oibus.service.ts index 9cca0e3f8e..73e4aadce8 100644 --- a/backend/src/service/oibus.service.ts +++ b/backend/src/service/oibus.service.ts @@ -128,7 +128,11 @@ export default class OIBusService { '::ffff:127.0.0.1', ...this.ipFilterService.list().map(filter => filter.address) ]); - await this.proxyServer.start(settings.proxyPort!); + await this.proxyServer.start(settings.proxyPort!, { + url: settings.forwardProxyUrl, + username: settings.forwardProxyUsername, + password: settings.forwardProxyPassword ? await encryptionService.decryptText(settings.forwardProxyPassword) : null + }); } const startDuration = DateTime.now().toMillis() - start; this.logger.info(`OIBus started in ${startDuration} ms`); @@ -160,6 +164,11 @@ export default class OIBusService { } else { command.logParameters.loki.password = await encryptionService.encryptText(command.logParameters.loki.password); } + if (!command.forwardProxyPassword) { + command.forwardProxyPassword = oldEngineSettings.forwardProxyPassword; + } else { + command.forwardProxyPassword = await encryptionService.encryptText(command.forwardProxyPassword); + } this.engineRepository.update(command, updatedBy); const settings = this.getEngineSettings(); @@ -179,7 +188,11 @@ export default class OIBusService { } await this.proxyServer.stop(); if (settings.proxyEnabled) { - await this.proxyServer.start(settings.proxyPort!); + await this.proxyServer.start(settings.proxyPort!, { + url: settings.forwardProxyUrl, + username: settings.forwardProxyUsername, + password: settings.forwardProxyPassword ? await encryptionService.decryptText(settings.forwardProxyPassword) : null + }); } this.oIAnalyticsMessageService.createFullConfigMessageIfNotPending(); @@ -406,6 +419,9 @@ export const toEngineSettingsDTO = (engineSettings: EngineSettings, getUserInfo: launcherVersion: engineSettings.launcherVersion, proxyEnabled: engineSettings.proxyEnabled, proxyPort: engineSettings.proxyPort, + forwardProxyUrl: engineSettings.forwardProxyUrl, + forwardProxyUsername: engineSettings.forwardProxyUsername, + forwardProxyPassword: '', logParameters: { console: { level: engineSettings.logParameters.console.level diff --git a/backend/src/tests/utils/test-data.ts b/backend/src/tests/utils/test-data.ts index 8c27b693ea..732a06cf18 100644 --- a/backend/src/tests/utils/test-data.ts +++ b/backend/src/tests/utils/test-data.ts @@ -1360,6 +1360,9 @@ const engineSettings: EngineSettings = { launcherVersion: '3.4.9', proxyEnabled: true, proxyPort: 9000, + forwardProxyUrl: 'http://forward-proxy:3128', + forwardProxyUsername: 'proxy-user', + forwardProxyPassword: 'encrypted-proxy-password', logParameters: { console: { level: 'info' @@ -1401,6 +1404,9 @@ const engineSettingsCommand: EngineSettingsCommandDTO = { port: 2223, proxyEnabled: true, proxyPort: 9000, + forwardProxyUrl: null, + forwardProxyUsername: null, + forwardProxyPassword: null, logParameters: { console: { level: 'silent' diff --git a/backend/src/tests/utils/test-utils.ts b/backend/src/tests/utils/test-utils.ts index e707b5dae5..56dc314f33 100644 --- a/backend/src/tests/utils/test-utils.ts +++ b/backend/src/tests/utils/test-utils.ts @@ -473,6 +473,9 @@ const createEngineSettings = async (database: knex.Knex, engineSettings: EngineS log_syslog_protocol: engineSettings.logParameters.syslog.protocol, proxy_enabled: engineSettings.proxyEnabled, proxy_port: engineSettings.proxyPort, + forward_proxy_url: engineSettings.forwardProxyUrl, + forward_proxy_username: engineSettings.forwardProxyUsername, + forward_proxy_password: engineSettings.forwardProxyPassword, oibus_version: engineSettings.version, oibus_launcher_version: engineSettings.launcherVersion, created_by: engineSettings.createdBy, diff --git a/backend/src/web-server/controllers/validators/oibus-validation-schema.ts b/backend/src/web-server/controllers/validators/oibus-validation-schema.ts index e7f73cbf73..fceb5c0160 100644 --- a/backend/src/web-server/controllers/validators/oibus-validation-schema.ts +++ b/backend/src/web-server/controllers/validators/oibus-validation-schema.ts @@ -63,6 +63,9 @@ const engineSchema: Joi.ObjectSchema = Joi.object({ port: Joi.number().required().port(), proxyEnabled: Joi.boolean().required(), proxyPort: Joi.number().port().optional().allow(null), + forwardProxyUrl: Joi.string().uri().optional().allow(null, ''), + forwardProxyUsername: Joi.string().optional().allow(null, ''), + forwardProxyPassword: Joi.string().optional().allow(null, ''), logParameters: logParametersSchema }); @@ -76,7 +79,10 @@ const engineWebServerSchema: Joi.ObjectSchema = Joi.object({ const engineProxySchema: Joi.ObjectSchema = Joi.object({ proxyEnabled: Joi.boolean().required(), - proxyPort: Joi.number().port().optional().allow(null) + proxyPort: Joi.number().port().optional().allow(null), + forwardProxyUrl: Joi.string().uri().optional().allow(null, ''), + forwardProxyUsername: Joi.string().optional().allow(null, ''), + forwardProxyPassword: Joi.string().optional().allow(null, '') }); const engineLoggerSchema = logParametersSchema; diff --git a/backend/src/web-server/proxy-server.spec.ts b/backend/src/web-server/proxy-server.spec.ts index 65e699a45c..8f5b62a285 100644 --- a/backend/src/web-server/proxy-server.spec.ts +++ b/backend/src/web-server/proxy-server.spec.ts @@ -476,4 +476,254 @@ describe('ProxyServer', () => { assert.strictEqual(nodeRequire('node:net').createConnection.mock.calls.length, 1); assert.deepStrictEqual(loggerMock.error.mock.calls[0].arguments, ['Proxy server error: connection error']); }); + + it('should forward http requests to upstream proxy', () => { + proxyServer['forwardProxyUrl'] = 'http://upstream-proxy:3128'; + + const mockWriteHead = mock.fn(); + const mockReqPipe = mock.fn(); + const mockReq = { + method: 'GET', + url: 'http://example.com/', + headers: { host: 'example.com' }, + socket: { remoteAddress: '127.0.0.1' }, + pipe: mockReqPipe + } as unknown as http.IncomingMessage; + const mockRes = { writeHead: mockWriteHead } as unknown as http.ServerResponse; + + let httpRequestCallback: ((res: unknown) => void) | undefined; + const mockUpstreamReqOn = mock.fn(); + const mockUpstreamReq = { on: mockUpstreamReqOn }; + mock.method(nodeRequire('node:http'), 'request', (_opts: unknown, cb: (res: unknown) => void) => { + httpRequestCallback = cb; + return mockUpstreamReq; + }); + + proxyServer['handleHttpRequest'](mockReq, mockRes); + + assert.strictEqual(nodeRequire('node:http').request.mock.calls.length, 1); + const opts = nodeRequire('node:http').request.mock.calls[0].arguments[0] as http.RequestOptions; + assert.strictEqual(opts.host, 'upstream-proxy'); + assert.strictEqual(opts.port, 3128); + assert.strictEqual(opts.method, 'GET'); + assert.strictEqual(opts.path, 'http://example.com/'); + assert.strictEqual((opts.headers as Record)['Proxy-Authorization'], undefined); + assert.strictEqual(mockReqPipe.mock.calls[0].arguments[0], mockUpstreamReq); + + const mockUpstreamResPipe = mock.fn(); + const mockUpstreamRes = { statusCode: 200, headers: { 'content-type': 'text/html' }, pipe: mockUpstreamResPipe }; + httpRequestCallback!(mockUpstreamRes); + assert.deepStrictEqual(mockWriteHead.mock.calls[0].arguments, [200, { 'content-type': 'text/html' }]); + assert.strictEqual(mockUpstreamResPipe.mock.calls[0].arguments[0], mockRes); + }); + + it('should forward http requests to upstream proxy with credentials', () => { + proxyServer['forwardProxyUrl'] = 'http://upstream-proxy:3128'; + proxyServer['forwardProxyUsername'] = 'user'; + proxyServer['forwardProxyPassword'] = 'pass'; + + const mockReqPipe = mock.fn(); + const mockReq = { + method: 'GET', + url: 'http://example.com/', + headers: {}, + socket: { remoteAddress: '127.0.0.1' }, + pipe: mockReqPipe + } as unknown as http.IncomingMessage; + const mockRes = { writeHead: mock.fn() } as unknown as http.ServerResponse; + + const mockUpstreamReq = { on: mock.fn() }; + mock.method(nodeRequire('node:http'), 'request', (_opts: unknown, _cb: unknown) => mockUpstreamReq); + + proxyServer['handleHttpRequest'](mockReq, mockRes); + + const opts = nodeRequire('node:http').request.mock.calls[0].arguments[0] as http.RequestOptions; + const expectedCred = Buffer.from('user:pass').toString('base64'); + assert.strictEqual((opts.headers as Record)['Proxy-Authorization'], `Basic ${expectedCred}`); + }); + + it('should handle upstream proxy http error', () => { + proxyServer['forwardProxyUrl'] = 'http://upstream-proxy:3128'; + + const mockWriteHead = mock.fn(); + const mockEnd = mock.fn(); + const mockReq = { + method: 'GET', + url: 'http://example.com/', + headers: {}, + socket: { remoteAddress: '127.0.0.1' }, + pipe: mock.fn() + } as unknown as http.IncomingMessage; + const mockRes = { writeHead: mockWriteHead, end: mockEnd } as unknown as http.ServerResponse; + + let upstreamErrorCallback: ((err: Error) => void) | undefined; + const mockUpstreamReqOn = mock.fn((_event: string, cb: (err: Error) => void) => { + upstreamErrorCallback = cb; + }); + const mockUpstreamReq = { on: mockUpstreamReqOn }; + mock.method(nodeRequire('node:http'), 'request', (_opts: unknown, _cb: unknown) => mockUpstreamReq); + + proxyServer['handleHttpRequest'](mockReq, mockRes); + + upstreamErrorCallback!(new Error('upstream failed')); + + assert.deepStrictEqual(loggerMock.error.mock.calls[0].arguments, ['Upstream proxy error: upstream failed']); + assert.deepStrictEqual(mockWriteHead.mock.calls[0].arguments, [502]); + assert.strictEqual(mockEnd.mock.calls.length, 1); + }); + + it('should forward https CONNECT to upstream proxy and pipe on 200', () => { + proxyServer['forwardProxyUrl'] = 'http://upstream-proxy:3128'; + + const mockReq = { + method: 'CONNECT', + url: 'example.com:443', + socket: { remoteAddress: '127.0.0.1' }, + httpVersion: '1.1' + } as unknown as http.IncomingMessage; + + const mockClientWrite = mock.fn(); + const mockClientPipe = mock.fn((dest: unknown) => dest); + const mockClientOn = mock.fn(); + const mockClientSocket = { + write: mockClientWrite, + pipe: mockClientPipe, + on: mockClientOn + } as unknown as net.Socket; + + let upstreamConnectCallback: (() => void) | undefined; + let upstreamDataCallback: ((chunk: Buffer) => void) | undefined; + const mockUpstreamWrite = mock.fn(); + const mockUpstreamPipe = mock.fn((dest: unknown) => dest); + const mockUpstreamRemoveListener = mock.fn(); + const mockUpstreamOn = mock.fn((_event: string, cb: unknown) => { + if (_event === 'data') upstreamDataCallback = cb as (chunk: Buffer) => void; + }); + const mockUpstreamSocket = { + write: mockUpstreamWrite, + pipe: mockUpstreamPipe, + on: mockUpstreamOn, + removeListener: mockUpstreamRemoveListener, + end: mock.fn() + }; + + mock.method(nodeRequire('node:net'), 'createConnection', (_opts: unknown, cb: () => void) => { + upstreamConnectCallback = cb; + return mockUpstreamSocket; + }); + + proxyServer['handleHttpsRequest'](mockReq, mockClientSocket, Buffer.from('')); + + assert.deepStrictEqual(nodeRequire('node:net').createConnection.mock.calls[0].arguments[0], { + host: 'upstream-proxy', + port: 3128 + }); + + upstreamConnectCallback!(); + assert.deepStrictEqual( + mockUpstreamWrite.mock.calls[0].arguments[0], + 'CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n' + ); + + upstreamDataCallback!(Buffer.from('HTTP/1.1 200 Connection established\r\n\r\n')); + + assert.ok(mockUpstreamRemoveListener.mock.calls.length > 0); + assert.deepStrictEqual(mockClientWrite.mock.calls[0].arguments[0], 'HTTP/1.1 200 Connection established\r\n\r\n'); + assert.strictEqual(mockClientPipe.mock.calls[0].arguments[0], mockUpstreamSocket); + assert.strictEqual(mockUpstreamPipe.mock.calls[0].arguments[0], mockClientSocket); + }); + + it('should handle upstream proxy rejection of CONNECT', () => { + proxyServer['forwardProxyUrl'] = 'http://upstream-proxy:3128'; + + const mockReq = { + method: 'CONNECT', + url: 'example.com:443', + socket: { remoteAddress: '127.0.0.1' }, + httpVersion: '1.1' + } as unknown as http.IncomingMessage; + + const mockClientWrite = mock.fn(); + const mockClientEnd = mock.fn(); + const mockClientOn = mock.fn(); + const mockClientSocket = { + write: mockClientWrite, + end: mockClientEnd, + on: mockClientOn + } as unknown as net.Socket; + + let upstreamConnectCallback: (() => void) | undefined; + let upstreamDataCallback: ((chunk: Buffer) => void) | undefined; + const mockUpstreamEnd = mock.fn(); + const mockUpstreamRemoveListener = mock.fn(); + const mockUpstreamOn = mock.fn((_event: string, cb: unknown) => { + if (_event === 'data') upstreamDataCallback = cb as (chunk: Buffer) => void; + }); + const mockUpstreamSocket = { + write: mock.fn(), + on: mockUpstreamOn, + removeListener: mockUpstreamRemoveListener, + end: mockUpstreamEnd, + pipe: mock.fn((dest: unknown) => dest) + }; + + mock.method(nodeRequire('node:net'), 'createConnection', (_opts: unknown, cb: () => void) => { + upstreamConnectCallback = cb; + return mockUpstreamSocket; + }); + + proxyServer['handleHttpsRequest'](mockReq, mockClientSocket, Buffer.from('')); + + upstreamConnectCallback!(); + upstreamDataCallback!(Buffer.from('HTTP/1.1 407 Proxy Auth Required\r\n\r\n')); + + assert.strictEqual(loggerMock.error.mock.calls.length, 1); + assert.deepStrictEqual(mockClientWrite.mock.calls[0].arguments[0], 'HTTP/1.1 502 Bad Gateway\r\n\r\n'); + assert.strictEqual(mockClientEnd.mock.calls.length, 1); + assert.strictEqual(mockUpstreamEnd.mock.calls.length, 1); + }); + + it('should handle upstream proxy socket error on HTTPS CONNECT', () => { + proxyServer['forwardProxyUrl'] = 'http://upstream-proxy:3128'; + + const mockReq = { + method: 'CONNECT', + url: 'example.com:443', + socket: { remoteAddress: '127.0.0.1' }, + httpVersion: '1.1' + } as unknown as http.IncomingMessage; + + const mockClientWrite = mock.fn(); + const mockClientEnd = mock.fn(); + const mockClientOn = mock.fn(); + const mockClientSocket = { + write: mockClientWrite, + end: mockClientEnd, + on: mockClientOn + } as unknown as net.Socket; + + let upstreamErrorCallback: ((err: Error) => void) | undefined; + const mockUpstreamRemoveListener = mock.fn(); + const mockUpstreamOn = mock.fn((_event: string, cb: unknown) => { + if (_event === 'error') upstreamErrorCallback = cb as (err: Error) => void; + }); + const mockUpstreamSocket = { + write: mock.fn(), + on: mockUpstreamOn, + removeListener: mockUpstreamRemoveListener, + end: mock.fn(), + pipe: mock.fn((dest: unknown) => dest) + }; + + mock.method(nodeRequire('node:net'), 'createConnection', (_opts: unknown, _cb: () => void) => mockUpstreamSocket); + + proxyServer['handleHttpsRequest'](mockReq, mockClientSocket, Buffer.from('')); + + upstreamErrorCallback!(new Error('upstream socket failed')); + + assert.deepStrictEqual(loggerMock.error.mock.calls[0].arguments, ['Upstream proxy socket error: upstream socket failed']); + assert.deepStrictEqual(mockClientWrite.mock.calls[0].arguments[0], 'HTTP/1.1 502 Bad Gateway\r\n\r\n'); + assert.strictEqual(mockClientEnd.mock.calls.length, 1); + assert.ok(mockUpstreamRemoveListener.mock.calls.length > 0); + }); }); diff --git a/backend/src/web-server/proxy-server.ts b/backend/src/web-server/proxy-server.ts index 4bc26fdb3b..ab43dc8b37 100644 --- a/backend/src/web-server/proxy-server.ts +++ b/backend/src/web-server/proxy-server.ts @@ -5,6 +5,12 @@ import httpProxy from 'http-proxy'; import { testIPOnFilter } from '../service/utils'; import type { ILogger } from '../model/logger.model'; +interface ForwardProxy { + url: string | null; + username: string | null; + password: string | null; +} + /** * Class Server - Provides the web client and establish socket connections. */ @@ -13,6 +19,9 @@ export default class ProxyServer { private webServer: http.Server | null = null; private httpProxy: httpProxy | null = null; private ipFilters: Array = []; + private forwardProxyUrl: string | null = null; + private forwardProxyUsername: string | null = null; + private forwardProxyPassword: string | null = null; constructor( logger: ILogger, @@ -34,7 +43,10 @@ export default class ProxyServer { this.ipFilters = ipFilters; } - start(port: number): void { + start(port: number, forwardProxy?: ForwardProxy): void { + this.forwardProxyUrl = forwardProxy?.url ?? null; + this.forwardProxyUsername = forwardProxy?.username ?? null; + this.forwardProxyPassword = forwardProxy?.password ?? null; this.initHttpProxy(); this.initWebServer(port); } @@ -80,11 +92,48 @@ export default class ProxyServer { this._logger.trace(`Forward ${req.method} request to ${req.url} from IP ${req.socket.remoteAddress}`); + if (this.forwardProxyUrl) { + this.handleHttpRequestViaUpstreamProxy(req, res); + return; + } + this.httpProxy?.web(req, res, { target: req.url }, err => { this._logger.error(`Proxy server error ${err}`); }); } + private handleHttpRequestViaUpstreamProxy(req: http.IncomingMessage, res: http.ServerResponse) { + try { + const proxyUrl = new URL(this.forwardProxyUrl!); + const headers: http.OutgoingHttpHeaders = { ...req.headers }; + if (this.forwardProxyUsername) { + const cred = Buffer.from(`${this.forwardProxyUsername}:${this.forwardProxyPassword ?? ''}`).toString('base64'); + headers['Proxy-Authorization'] = `Basic ${cred}`; + } + const options: http.RequestOptions = { + host: proxyUrl.hostname, + port: Number(proxyUrl.port || 80), + method: req.method, + path: req.url, + headers + }; + const upstreamReq = http.request(options, upstreamRes => { + res.writeHead(upstreamRes.statusCode!, upstreamRes.headers); + upstreamRes.pipe(res); + }); + upstreamReq.on('error', error => { + this._logger.error(`Upstream proxy error: ${error.message}`); + res.writeHead(502); + res.end(); + }); + req.pipe(upstreamReq); + } catch (error: unknown) { + this._logger.error(`Proxy server error: ${(error as Error).message}`); + res.writeHead(500); + res.end(); + } + } + private handleHttpsRequest(req: http.IncomingMessage, clientSocket: stream.Duplex, head: Buffer) { const ipAllowed = this.isIpAddressAllowed(req); if (!ipAllowed) { @@ -93,6 +142,11 @@ export default class ProxyServer { return; } + if (this.forwardProxyUrl) { + this.handleHttpsRequestViaUpstreamProxy(req, clientSocket, head); + return; + } + try { const [targetDomain, targetPort] = req.url!.split(':'); this._logger.trace(`Forward ${req.method} request to ${req.url} from IP ${req.socket.remoteAddress}`); @@ -121,6 +175,54 @@ export default class ProxyServer { } } + private handleHttpsRequestViaUpstreamProxy(req: http.IncomingMessage, clientSocket: stream.Duplex, head: Buffer) { + try { + const proxyUrl = new URL(this.forwardProxyUrl!); + this._logger.trace(`Forward ${req.method} request to ${req.url} via upstream proxy ${this.forwardProxyUrl}`); + + const upstreamSocket = net.createConnection({ host: proxyUrl.hostname, port: Number(proxyUrl.port || 80) }, () => { + let authHeader = ''; + if (this.forwardProxyUsername) { + const cred = Buffer.from(`${this.forwardProxyUsername}:${this.forwardProxyPassword ?? ''}`).toString('base64'); + authHeader = `Proxy-Authorization: Basic ${cred}\r\n`; + } + upstreamSocket.write(`CONNECT ${req.url} HTTP/1.1\r\nHost: ${req.url}\r\n${authHeader}\r\n`); + }); + + let headerBuffer = ''; + const onData = (chunk: Buffer) => { + headerBuffer += chunk.toString('binary'); + const headerEnd = headerBuffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) return; + upstreamSocket.removeListener('data', onData); + if (headerBuffer.startsWith('HTTP/') && headerBuffer.includes(' 200 ')) { + clientSocket.write(`HTTP/${req.httpVersion} 200 Connection established\r\n\r\n`); + upstreamSocket.write(head); + clientSocket.pipe(upstreamSocket).pipe(clientSocket); + } else { + this._logger.error(`Upstream proxy rejected CONNECT to ${req.url}`); + clientSocket.write(`HTTP/${req.httpVersion} 502 Bad Gateway\r\n\r\n`); + clientSocket.end(); + upstreamSocket.end(); + } + }; + upstreamSocket.on('data', onData); + + upstreamSocket.on('error', error => { + upstreamSocket.removeListener('data', onData); + this._logger.error(`Upstream proxy socket error: ${error.message}`); + clientSocket.write(`HTTP/${req.httpVersion} 502 Bad Gateway\r\n\r\n`); + clientSocket.end(); + }); + clientSocket.on('error', error => { + this._logger.error(`Proxy server error on client socket: ${error.message}`); + upstreamSocket.end(); + }); + } catch (error: unknown) { + this._logger.error(`Proxy server error: ${(error as Error).message}`); + } + } + private isIpAddressAllowed(req: http.IncomingMessage) { const clientIpAddress = req.socket.remoteAddress; diff --git a/documentation/docs/api/openapi.json b/documentation/docs/api/openapi.json index a4989da1c6..bedb4c0d54 100644 --- a/documentation/docs/api/openapi.json +++ b/documentation/docs/api/openapi.json @@ -16638,6 +16638,24 @@ "description": "The port for the proxy, if enabled.", "example": null }, + "forwardProxyUrl": { + "type": "string", + "nullable": true, + "description": "The URL of the upstream proxy to forward requests through.", + "example": null + }, + "forwardProxyUsername": { + "type": "string", + "nullable": true, + "description": "The username for upstream proxy authentication.", + "example": null + }, + "forwardProxyPassword": { + "type": "string", + "nullable": true, + "description": "The password for upstream proxy authentication.", + "example": null + }, "logParameters": { "properties": { "syslog": { @@ -16818,6 +16836,9 @@ "port", "proxyEnabled", "proxyPort", + "forwardProxyUrl", + "forwardProxyUsername", + "forwardProxyPassword", "logParameters" ], "type": "object", @@ -31361,6 +31382,24 @@ "description": "The port for the proxy, if enabled.", "example": null }, + "forwardProxyUrl": { + "type": "string", + "nullable": true, + "description": "The URL of the upstream proxy to forward requests through.", + "example": null + }, + "forwardProxyUsername": { + "type": "string", + "nullable": true, + "description": "The username for upstream proxy authentication.", + "example": null + }, + "forwardProxyPassword": { + "type": "string", + "nullable": true, + "description": "The password for upstream proxy authentication.", + "example": null + }, "logParameters": { "properties": { "syslog": { @@ -31542,6 +31581,9 @@ "launcherVersion", "proxyEnabled", "proxyPort", + "forwardProxyUrl", + "forwardProxyUsername", + "forwardProxyPassword", "logParameters" ], "type": "object", diff --git a/frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.html b/frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.html index c2e8c7978b..6bdb857d7e 100644 --- a/frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.html +++ b/frontend/src/app/engine/edit-engine-proxy-modal/edit-engine-proxy-modal.component.html @@ -22,6 +22,33 @@
}
+ @if (form.controls.proxyEnabled.value) { +
+
+
+ + + +
+
+
+
+
+
+ + + +
+
+
+
+ + + +
+
+
+ }

+ +
+ +
+ @for (item of selectedItems(); track item.itemId) { + {{ item.itemName }} + } +
+
} @else {
@@ -151,6 +169,7 @@

} + @@ -165,6 +184,7 @@

{{ log.scopeType | scopeTypesEnum }} {{ log.scopeName }} } + {{ log.itemName }} {{ log.message }} } diff --git a/frontend/src/app/logs/logs.component.spec.ts b/frontend/src/app/logs/logs.component.spec.ts index 0255667728..68bffd4f2f 100644 --- a/frontend/src/app/logs/logs.component.spec.ts +++ b/frontend/src/app/logs/logs.component.spec.ts @@ -52,6 +52,8 @@ describe('LogsComponent', () => { scopeType: 'internal', scopeName: null, scopeId: null, + itemId: null, + itemName: null, message: 'my log 1' }, { @@ -60,6 +62,8 @@ describe('LogsComponent', () => { scopeType: 'south', scopeId: 'southId', scopeName: 'My South', + itemId: null, + itemName: null, message: 'my log 2' } ]); @@ -113,6 +117,7 @@ describe('LogsComponent', () => { messageContent: undefined, scopeTypes: [], scopeIds: [], + itemIds: [], start: '2022-12-31T23:00:00.000Z', end: '2023-02-28T23:00:00.000Z', levels: ['info', 'error'], @@ -120,16 +125,18 @@ describe('LogsComponent', () => { }); await expect.element(tester.logs).toHaveLength(2); - await expect.element(tester.cells(0)).toHaveLength(5); + await expect.element(tester.cells(0)).toHaveLength(6); await expect.element(tester.cells(0).nth(1)).toHaveTextContent('1 Jan 2023, 01:00:00'); await expect.element(tester.cells(0).nth(2)).toHaveTextContent('Internal'); expect(tester.cells(0).nth(3).element().textContent?.trim()).toBe(''); - await expect.element(tester.cells(0).nth(4)).toHaveTextContent('my log 1'); + expect(tester.cells(0).nth(4).element().textContent?.trim()).toBe(''); + await expect.element(tester.cells(0).nth(5)).toHaveTextContent('my log 1'); await expect.element(tester.cells(1).nth(1)).toHaveTextContent('2 Jan 2023, 01:00:00'); await expect.element(tester.cells(1).nth(2)).toHaveTextContent('South'); await expect.element(tester.cells(1).nth(3)).toHaveTextContent('My South'); - await expect.element(tester.cells(1).nth(4)).toHaveTextContent('my log 2'); + expect(tester.cells(1).nth(4).element().textContent?.trim()).toBe(''); + await expect.element(tester.cells(1).nth(5)).toHaveTextContent('my log 2'); }); test('should add selected scope and clear input on typeahead selection', () => { @@ -341,6 +348,7 @@ describe('LogsComponent', () => { levels: ['info'], scopeTypes: ['south'], scopeIds: ['X'], + itemIds: [], page: 0 } }); diff --git a/frontend/src/app/logs/logs.component.ts b/frontend/src/app/logs/logs.component.ts index 016e135e68..ae7628dcf2 100644 --- a/frontend/src/app/logs/logs.component.ts +++ b/frontend/src/app/logs/logs.component.ts @@ -3,7 +3,16 @@ import { TranslateDirective, TranslatePipe } from '@ngx-translate/core'; import { ActivatedRoute, Router } from '@angular/router'; import { PageLoader } from '../shared/page-loader.service'; import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms'; -import { LOG_LEVELS, LogDTO, LogLevel, LogSearchParam, Scope, SCOPE_TYPES, ScopeType } from '../../../../backend/shared/model/logs.model'; +import { + Item, + LOG_LEVELS, + LogDTO, + LogLevel, + LogSearchParam, + Scope, + SCOPE_TYPES, + ScopeType +} from '../../../../backend/shared/model/logs.model'; import { DateTime } from 'luxon'; import { Instant, Page } from '../../../../backend/shared/model/types'; import { ascendingDates } from '../shared/form/validators'; @@ -16,6 +25,7 @@ import { exhaustMap, map, Observable, + of, startWith, Subscription, switchMap, @@ -83,6 +93,7 @@ export class LogsComponent implements OnInit, OnDestroy { end: null as Instant | null, scopeTypes: [[] as Array], scopeIds: null as string | null, + itemIds: null as string | null, levels: [[] as Array], page: null as number | null }, @@ -100,6 +111,7 @@ export class LogsComponent implements OnInit, OnDestroy { readonly levels = LOG_LEVELS.filter(level => level !== 'silent'); readonly scopeTypes = SCOPE_TYPES; selectedScopes = signal>([]); + selectedItems = signal>([]); loading = signal(false); // subscription to reload the page periodically subscription = new Subscription(); @@ -118,6 +130,14 @@ export class LogsComponent implements OnInit, OnDestroy { ); scopeFormatter = (scope: Scope) => scope.scopeName; + itemTypeahead = (text$: Observable) => + text$.pipe( + debounceTime(TYPEAHEAD_DEBOUNCE_TIME), + distinctUntilChanged(), + switchMap(text => this.logService.suggestItems(text)) + ); + itemFormatter = (item: Item) => item.itemName; + private autoReloadPaused$ = toObservable(this.autoReloadPaused); ngOnInit(): void { @@ -128,6 +148,7 @@ export class LogsComponent implements OnInit, OnDestroy { end: searchParams.end || null, scopeTypes: searchParams.scopeTypes, scopeIds: '', + itemIds: '', levels: searchParams.levels, page: searchParams.page }); @@ -137,9 +158,19 @@ export class LogsComponent implements OnInit, OnDestroy { } const queryScopeIds = this.route.snapshot.queryParamMap.getAll('scopeIds'); if (queryScopeIds.length > 0) { - combineLatest(queryScopeIds.map(scopeId => this.logService.getScopeById(scopeId))).subscribe(selectedScopes => { - this.selectedScopes.set(selectedScopes.filter(scope => !!scope)); - }); + combineLatest(queryScopeIds.map(scopeId => this.logService.getScopeById(scopeId).pipe(catchError(() => of(null))))).subscribe( + selectedScopes => { + this.selectedScopes.set(selectedScopes.filter(scope => !!scope) as Array); + } + ); + } + const queryItemIds = this.route.snapshot.queryParamMap.getAll('itemIds'); + if (queryItemIds.length > 0) { + combineLatest(queryItemIds.map(itemId => this.logService.getItemById(itemId).pipe(catchError(() => of(null))))).subscribe( + selectedItems => { + this.selectedItems.set(selectedItems.filter(item => !!item) as Array); + } + ); } this.subscription.add( combineLatest([this.pageLoader.pageLoads$, this.autoReloadPaused$.pipe(startWith(this.autoReloadPaused()))]) @@ -182,8 +213,9 @@ export class LogsComponent implements OnInit, OnDestroy { const start = queryParamMap.get('start') ?? now.minus({ days: 1 }).toISO(); const end = queryParamMap.get('end') || undefined; const levels = queryParamMap.getAll('levels') as Array; + const itemIds = queryParamMap.getAll('itemIds'); const page = queryParamMap.get('page') ? parseInt(queryParamMap.get('page')!, 10) : 0; - return { messageContent, scopeTypes, scopeIds, start, end, levels, page }; + return { messageContent, scopeTypes, scopeIds, itemIds, start, end, levels, page }; } triggerSearch() { @@ -200,6 +232,7 @@ export class LogsComponent implements OnInit, OnDestroy { levels: formValue.levels!, scopeTypes: scopeType ? [scopeType] : formValue.scopeTypes!, scopeIds: scopeId ? [scopeId] : this.selectedScopes()!.map(scope => scope.scopeId), + itemIds: this.selectedItems().map(item => item.itemId), page: 0 }; this.router.navigate([], { queryParams: criteria }); @@ -219,6 +252,16 @@ export class LogsComponent implements OnInit, OnDestroy { this.selectedScopes.update(scopes => scopes.filter(scope => scope.scopeId !== scopeToRemove.scopeId)); } + selectItem(event: NgbTypeaheadSelectItemEvent) { + this.selectedItems.update(items => [...items, event.item]); + this.searchForm.controls.itemIds.setValue(''); + event.preventDefault(); + } + + removeItem(itemToRemove: Item) { + this.selectedItems.update(items => items.filter(item => item.itemId !== itemToRemove.itemId)); + } + getLevelClass(logLevel: LogLevel): string { const foundElement = this.LEGEND.find(element => element.label === `enums.log-levels.${logLevel}`); if (foundElement) { diff --git a/frontend/src/app/services/log.service.spec.ts b/frontend/src/app/services/log.service.spec.ts index 1cc4e2784b..498a8315ce 100644 --- a/frontend/src/app/services/log.service.spec.ts +++ b/frontend/src/app/services/log.service.spec.ts @@ -30,6 +30,8 @@ describe('LogService', () => { scopeType: 'internal', scopeName: null, scopeId: null, + itemId: null, + itemName: null, message: 'my log 1' }, { @@ -38,6 +40,8 @@ describe('LogService', () => { scopeType: 'internal', scopeName: null, scopeId: null, + itemId: null, + itemName: null, message: 'my log 2' } ]); @@ -46,8 +50,9 @@ describe('LogService', () => { .search({ page: 0, messageContent: 'messageContent', - scopeTypes: ['web-server', 'south'], + scopeTypes: ['internal', 'south'], scopeIds: ['id1', 'id2'], + itemIds: ['itemId1', 'itemId2'], start: '2023-01-01T00:00:00.000Z', end: '2023-01-02T00:00:00.000Z', levels: ['info', 'debug'] @@ -56,7 +61,7 @@ describe('LogService', () => { http .expectOne({ - url: '/api/logs?page=0&messageContent=messageContent&start=2023-01-01T00:00:00.000Z&end=2023-01-02T00:00:00.000Z&scopeTypes=web-server,south&scopeIds=id1,id2&levels=info,debug', + url: '/api/logs?page=0&messageContent=messageContent&start=2023-01-01T00:00:00.000Z&end=2023-01-02T00:00:00.000Z&scopeTypes=internal,south&scopeIds=id1,id2&itemIds=itemId1,itemId2&levels=info,debug', method: 'GET' }) .flush(logs); diff --git a/frontend/src/app/services/log.service.ts b/frontend/src/app/services/log.service.ts index 5de4f26c33..29f82fa479 100644 --- a/frontend/src/app/services/log.service.ts +++ b/frontend/src/app/services/log.service.ts @@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { Service, inject } from '@angular/core'; import { Page } from '../../../../backend/shared/model/types'; -import { LogDTO, LogSearchParam, Scope } from '../../../../backend/shared/model/logs.model'; +import { Item, LogDTO, LogSearchParam, Scope } from '../../../../backend/shared/model/logs.model'; /** * Service used to interact with the backend Log repository @@ -34,6 +34,9 @@ export class LogService { if (searchParams.scopeIds) { params['scopeIds'] = searchParams.scopeIds.join(','); } + if (searchParams.itemIds) { + params['itemIds'] = searchParams.itemIds.join(','); + } if (searchParams.levels) { params['levels'] = searchParams.levels.join(','); } @@ -47,4 +50,12 @@ export class LogService { getScopeById(id: string): Observable { return this.http.get(`/api/logs/scopes/${id}`); } + + suggestItems(name: string): Observable> { + return this.http.get>('/api/logs/items/suggest', { params: { name } }); + } + + getItemById(id: string): Observable { + return this.http.get(`/api/logs/items/${id}`); + } } diff --git a/frontend/src/app/shared/scope-types-enum.pipe.spec.ts b/frontend/src/app/shared/scope-types-enum.pipe.spec.ts index 003415fb83..47f2d87af7 100644 --- a/frontend/src/app/shared/scope-types-enum.pipe.spec.ts +++ b/frontend/src/app/shared/scope-types-enum.pipe.spec.ts @@ -11,6 +11,5 @@ describe('ScopeTypesEnumPipe', () => { expect(pipe.transform('north')).toBe('North'); expect(pipe.transform('history-query')).toBe('History query'); expect(pipe.transform('internal')).toBe('Internal'); - expect(pipe.transform('web-server')).toBe('Web server'); }); }); diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index a10651b2a7..dcf541bb16 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -156,8 +156,7 @@ "south": "South", "north": "North", "history-query": "History query", - "internal": "Internal", - "web-server": "Web server" + "internal": "Internal" }, "categories": { "api": "API", @@ -2663,12 +2662,14 @@ "message-content": "Log contains", "scope-type": "Types", "scope": "Scopes", + "item": "Items", "levels": "Levels" }, "date": "Date", "level": "Level", "scope-type": "Type", "scope": "Scope", + "item": "Item", "message": "Message", "none": "No log found", "tooltips": { From 42331480dddc8dc865f234fb4a14b3aee195c5ad Mon Sep 17 00:00:00 2001 From: kbalinthunor Date: Mon, 29 Jun 2026 08:38:54 +0300 Subject: [PATCH 16/24] feat(logger): add group context to logs and translate internal scope ids --- backend/shared/model/logs.model.ts | 38 ++++ backend/src/engine/data-stream-engine.ts | 2 +- backend/src/index.ts | 2 +- .../3/3.9/v3.9.1-group-items.spec.ts | 167 ++++++++++++++++++ .../3/3.9/v3.9.1-group-items.ts | 44 +++++ backend/src/model/logs.model.ts | 8 +- backend/src/repository/logs/log.repository.ts | 26 ++- .../src/repository/logs/logs-database.spec.ts | 4 + backend/src/service/log.service.spec.ts | 28 +++ backend/src/service/log.service.ts | 16 +- backend/src/service/oibus.service.ts | 2 +- backend/src/south/south-connector.ts | 18 +- .../repository/log/log-repository.mock.ts | 4 +- .../__mocks__/service/log-service.mock.ts | 4 +- backend/src/tests/utils/test-data.ts | 8 + .../controllers/log.controller.spec.ts | 41 ++++- .../web-server/controllers/log.controller.ts | 29 ++- backend/src/web-server/routes.ts | 75 ++++++++ documentation/docs/api/openapi.json | 114 ++++++++++++ frontend/src/app/logs/logs.component.html | 28 ++- frontend/src/app/logs/logs.component.ts | 34 +++- frontend/src/app/services/log.service.ts | 13 +- frontend/src/i18n/en.json | 7 + 23 files changed, 692 insertions(+), 20 deletions(-) create mode 100644 backend/src/migration/logs-migrations/3/3.9/v3.9.1-group-items.spec.ts create mode 100644 backend/src/migration/logs-migrations/3/3.9/v3.9.1-group-items.ts diff --git a/backend/shared/model/logs.model.ts b/backend/shared/model/logs.model.ts index 1c434f8009..42b58cb477 100644 --- a/backend/shared/model/logs.model.ts +++ b/backend/shared/model/logs.model.ts @@ -71,6 +71,20 @@ export interface LogDTO { */ itemName: string | null; + /** + * The unique identifier of the group the log is associated with (e.g., a south connector item group ID). + * Can be `null` if the log is not associated with a specific group. + * @example "group123" + */ + groupId: string | null; + + /** + * The human-readable name of the group the log is associated with. + * Can be `null` if the log is not associated with a specific group. + * @example "Temperature sensors" + */ + groupName: string | null; + /** * The log message content, including details about the event or error. * @example "Connection failed to host: timeout after 5s" @@ -96,6 +110,24 @@ export interface Item { itemName: string; } +/** + * Represents a group associated with log entries. + * A group is a set of south connector items queried together. + */ +export interface Group { + /** + * The unique identifier of the group (e.g., south connector item group ID). + * @example "group123" + */ + groupId: string; + + /** + * The human-readable name of the group. + * @example "Temperature sensors" + */ + groupName: string; +} + /** * Represents a scope associated with log entries. * A scope can be a connector, service, or module. @@ -163,6 +195,12 @@ export interface LogSearchParam { */ itemIds: Array; + /** + * An array of group IDs to filter logs by specific item groups. + * @example ["group123", "group456"] + */ + groupIds: Array; + /** * A substring to search for within log messages. * Can be `undefined` to ignore message content filtering. diff --git a/backend/src/engine/data-stream-engine.ts b/backend/src/engine/data-stream-engine.ts index 38f764c4b1..ca401c9eea 100644 --- a/backend/src/engine/data-stream-engine.ts +++ b/backend/src/engine/data-stream-engine.ts @@ -49,7 +49,7 @@ export default class DataStreamEngine { >(); private historyQueries = new Map(); - private readonly _logger = loggerService.createChildLogger('internal', 'engine', 'Engine'); + private readonly _logger = loggerService.createChildLogger('internal', 'engine'); readonly baseFolder: string; constructor( diff --git a/backend/src/index.ts b/backend/src/index.ts index 3bc2c8073c..5385ae0770 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -279,7 +279,7 @@ const CERT_FOLDER = 'certs'; historyQueryService, homeMetricsService, ignoreIpFilters, - loggerService.createChildLogger('internal', 'web-server', 'Web Server') + loggerService.createChildLogger('internal', 'web-server') ); await server.init(); diff --git a/backend/src/migration/logs-migrations/3/3.9/v3.9.1-group-items.spec.ts b/backend/src/migration/logs-migrations/3/3.9/v3.9.1-group-items.spec.ts new file mode 100644 index 0000000000..9cbf9dab8e --- /dev/null +++ b/backend/src/migration/logs-migrations/3/3.9/v3.9.1-group-items.spec.ts @@ -0,0 +1,167 @@ +import { describe, it, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import knex, { Knex } from 'knex'; +import { down, up } from './v3.9.1-group-items'; + +/** + * Create the logs table as it exists immediately before this migration runs + * (after v3.9.0: with item_id/item_name columns, without group_id/group_name). + */ +async function createLogsTable(db: Knex): Promise { + await db.schema.createTable('logs', table => { + table.datetime('timestamp').notNullable(); + table.string('level').notNullable().defaultTo(''); + table.string('scope_type'); + table.text('scope_id'); + table.text('scope_name'); + table.text('item_id'); + table.text('item_name'); + table.text('message').notNullable(); + }); +} + +async function columnExists(db: Knex, columnName: string): Promise { + const info = (await db.raw(`PRAGMA table_info(logs)`)) as Array<{ name: string }>; + return info.some(col => col.name === columnName); +} + +async function indexExists(db: Knex, indexName: string): Promise { + const info = (await db.raw(`PRAGMA index_list(logs)`)) as Array<{ name: string }>; + return info.some(idx => idx.name === indexName); +} + +describe('Logs migration v3.9.1 (group-items)', () => { + let db: Knex; + let tmpDir: string; + let dbFile: string; + + before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oibus-logs-v391-')); + dbFile = path.join(tmpDir, 'test.db'); + }); + + after(async () => { + await db?.destroy(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + beforeEach(async () => { + await db?.destroy(); + await fs.rm(dbFile, { force: true }); + db = knex({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + await createLogsTable(db); + }); + + it('adds group_id column after up', async () => { + assert.equal(await columnExists(db, 'group_id'), false, 'group_id should not exist before migration'); + + await up(db); + + assert.equal(await columnExists(db, 'group_id'), true, 'group_id should exist after migration'); + }); + + it('adds group_name column after up', async () => { + assert.equal(await columnExists(db, 'group_name'), false, 'group_name should not exist before migration'); + + await up(db); + + assert.equal(await columnExists(db, 'group_name'), true, 'group_name should exist after migration'); + }); + + it('new columns are nullable — existing rows get NULL for group_id and group_name', async () => { + await db('logs').insert({ + timestamp: '2025-01-01T00:00:00.000Z', + level: 'info', + scope_type: 'south', + scope_id: 'south-1', + scope_name: 'OPC-UA', + item_id: null, + item_name: null, + message: 'querying items' + }); + + await up(db); + + const row = await db('logs').first(); + assert.strictEqual(row.group_id, null, 'group_id should be NULL for pre-existing rows'); + assert.strictEqual(row.group_name, null, 'group_name should be NULL for pre-existing rows'); + }); + + it('clears scope_name for internal scope rows during up', async () => { + await db('logs').insert([ + { + timestamp: '2025-01-01T00:00:00.000Z', + level: 'info', + scope_type: 'internal', + scope_id: 'engine', + scope_name: 'Engine', + item_id: null, + item_name: null, + message: 'engine started' + }, + { + timestamp: '2025-01-01T00:01:00.000Z', + level: 'info', + scope_type: 'south', + scope_id: 'south-1', + scope_name: 'OPC-UA', + item_id: null, + item_name: null, + message: 'south started' + } + ]); + + await up(db); + + const internalRow = await db('logs').where('scope_type', 'internal').first(); + assert.strictEqual(internalRow.scope_name, null, 'scope_name should be NULL for internal rows after migration'); + + const southRow = await db('logs').where('scope_type', 'south').first(); + assert.strictEqual(southRow.scope_name, 'OPC-UA', 'scope_name should be preserved for non-internal rows'); + }); + + it('creates idx_logs_group_id index after up', async () => { + assert.equal(await indexExists(db, 'idx_logs_group_id'), false, 'index should not exist before migration'); + + await up(db); + + assert.equal(await indexExists(db, 'idx_logs_group_id'), true, 'index should exist after migration'); + }); + + it('removes group_id and group_name columns after down', async () => { + await up(db); + assert.equal(await columnExists(db, 'group_id'), true); + assert.equal(await columnExists(db, 'group_name'), true); + + await down(db); + + assert.equal(await columnExists(db, 'group_id'), false, 'group_id should be removed after down'); + assert.equal(await columnExists(db, 'group_name'), false, 'group_name should be removed after down'); + }); + + it('preserves existing data after down', async () => { + await up(db); + await db('logs').insert({ + timestamp: '2025-01-01T00:00:00.000Z', + level: 'error', + scope_type: 'south', + scope_id: 'south-1', + scope_name: 'OPC-UA', + item_id: 'item-1', + item_name: 'Pressure', + group_id: null, + group_name: null, + message: 'query failed' + }); + + await down(db); + + const row = await db('logs').first(); + assert.strictEqual(row.level, 'error'); + assert.strictEqual(row.item_id, 'item-1'); + assert.strictEqual(row.item_name, 'Pressure'); + }); +}); diff --git a/backend/src/migration/logs-migrations/3/3.9/v3.9.1-group-items.ts b/backend/src/migration/logs-migrations/3/3.9/v3.9.1-group-items.ts new file mode 100644 index 0000000000..5c1ba591a7 --- /dev/null +++ b/backend/src/migration/logs-migrations/3/3.9/v3.9.1-group-items.ts @@ -0,0 +1,44 @@ +import { Knex } from 'knex'; + +const LOG_TABLE = 'logs'; + +/** + * Add group_id and group_name columns so logs can be linked to a south item group. + * Both columns are nullable; logs not associated with a group leave them NULL. + * Also clear scope_name for internal scope rows — the frontend now derives the + * display name from the scope_id via i18n translation keys. + */ +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(LOG_TABLE, table => { + table.text('group_id').nullable(); + table.text('group_name').nullable(); + }); + + await knex(LOG_TABLE).where('scope_type', 'internal').update({ scope_name: null }); + + await knex.raw(`CREATE INDEX IF NOT EXISTS idx_logs_group_id ON ${LOG_TABLE} (group_id) WHERE group_id IS NOT NULL;`); +} + +export async function down(knex: Knex): Promise { + // SQLite does not support DROP COLUMN — use the tmp-table pattern. + await knex.schema.raw(`CREATE TABLE ${LOG_TABLE}_dg_tmp + (timestamp datetime DEFAULT CURRENT_TIMESTAMP NOT NULL, + level varchar(255) NOT NULL DEFAULT '', + scope_type varchar(255), + scope_id varchar(255), + scope_name varchar(255), + item_id text, + item_name text, + message varchar NOT NULL);`); + + await knex.schema.raw(`INSERT INTO ${LOG_TABLE}_dg_tmp + (timestamp, level, scope_type, scope_id, scope_name, item_id, item_name, message) + SELECT timestamp, level, scope_type, scope_id, scope_name, item_id, item_name, message FROM ${LOG_TABLE};`); + + await knex.schema.raw(`DROP TABLE ${LOG_TABLE};`); + await knex.schema.raw(`ALTER TABLE ${LOG_TABLE}_dg_tmp RENAME TO ${LOG_TABLE};`); + + await knex.raw(`CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON ${LOG_TABLE} (timestamp);`); + await knex.raw(`CREATE INDEX IF NOT EXISTS idx_logs_scope ON ${LOG_TABLE} (scope_id, scope_type);`); + await knex.raw(`CREATE INDEX IF NOT EXISTS idx_logs_item_id ON ${LOG_TABLE} (item_id) WHERE item_id IS NOT NULL;`); +} diff --git a/backend/src/model/logs.model.ts b/backend/src/model/logs.model.ts index 9ebd353486..5eb0353d85 100644 --- a/backend/src/model/logs.model.ts +++ b/backend/src/model/logs.model.ts @@ -9,6 +9,8 @@ export interface OIBusLog { scopeName: string | null; itemId: string | null; itemName: string | null; + groupId: string | null; + groupName: string | null; message: string; } @@ -17,8 +19,10 @@ export interface PinoLog { scopeType: ScopeType; scopeId: string | null; scopeName: string | null; - itemId: string | null; - itemName: string | null; + itemId?: string | null; + itemName?: string | null; + groupId?: string | null; + groupName?: string | null; time: Instant; level: string; } diff --git a/backend/src/repository/logs/log.repository.ts b/backend/src/repository/logs/log.repository.ts index f1e20d727a..4b291754e1 100644 --- a/backend/src/repository/logs/log.repository.ts +++ b/backend/src/repository/logs/log.repository.ts @@ -1,5 +1,5 @@ import { Page } from '../../../shared/model/types'; -import { Item, LogLevel, LogSearchParam, Scope, ScopeType } from '../../../shared/model/logs.model'; +import { Group, Item, LogLevel, LogSearchParam, Scope, ScopeType } from '../../../shared/model/logs.model'; import { Database } from 'better-sqlite3'; import { DateTime } from 'luxon'; import { OIBusLog, PinoLog } from '../../model/logs.model'; @@ -45,13 +45,17 @@ export default class LogRepository { whereClause += ` AND item_id IN (${searchParams.itemIds.map(() => '?')})`; queryParams.push(...searchParams.itemIds); } + if (searchParams.groupIds.length > 0) { + whereClause += ` AND group_id IN (${searchParams.groupIds.map(() => '?')})`; + queryParams.push(...searchParams.groupIds); + } if (searchParams.messageContent) { whereClause += ` AND message LIKE '%' || ? || '%'`; queryParams.push(searchParams.messageContent); } const query = - `SELECT timestamp, level, scope_id, scope_type, scope_name, item_id, item_name, message FROM ${LOG_TABLE} ${whereClause}` + + `SELECT timestamp, level, scope_id, scope_type, scope_name, item_id, item_name, group_id, group_name, message FROM ${LOG_TABLE} ${whereClause}` + ` ORDER BY timestamp DESC LIMIT ${PAGE_SIZE}` + ` OFFSET ?;`; const results = this.database @@ -96,11 +100,23 @@ export default class LogRepository { return { itemId: (result as Record).item_id, itemName: (result as Record).item_name }; } + suggestGroups(name: string): Array { + const query = `SELECT DISTINCT group_id AS groupId, group_name AS groupName FROM ${LOG_TABLE} WHERE group_id IS NOT NULL AND group_name LIKE '%' || ? || '%';`; + return this.database.prepare(query).all(name) as Array; + } + + getGroupById(id: string): Group | null { + const query = `SELECT group_id, group_name FROM ${LOG_TABLE} WHERE group_id = ? LIMIT 1;`; + const result = this.database.prepare(query).get(id); + if (!result) return null; + return { groupId: (result as Record).group_id, groupName: (result as Record).group_name }; + } + saveAll = (logsToStore: Array): void => { if (logsToStore.length === 0) return; const stmt = this.database.prepare( - `INSERT INTO ${LOG_TABLE} (timestamp, level, scope_type, scope_id, scope_name, item_id, item_name, message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + `INSERT INTO ${LOG_TABLE} (timestamp, level, scope_type, scope_id, scope_name, item_id, item_name, group_id, group_name, message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ); const insertMany = this.database.transaction((logs: Array) => { for (const log of logs) { @@ -112,6 +128,8 @@ export default class LogRepository { log.scopeName, log.itemId ?? null, log.itemName ?? null, + log.groupId ?? null, + log.groupName ?? null, log.msg ); } @@ -156,6 +174,8 @@ export default class LogRepository { scopeName: result.scope_name || null, itemId: result.item_id || null, itemName: result.item_name || null, + groupId: result.group_id || null, + groupName: result.group_name || null, message: result.message }; } diff --git a/backend/src/repository/logs/logs-database.spec.ts b/backend/src/repository/logs/logs-database.spec.ts index 7cce75fa50..509dd09e3c 100644 --- a/backend/src/repository/logs/logs-database.spec.ts +++ b/backend/src/repository/logs/logs-database.spec.ts @@ -67,6 +67,7 @@ describe('Repository with populated database', () => { scopeIds: [testData.logs.list[0].scopeId as string], scopeTypes: [testData.logs.list[0].scopeType], itemIds: [], + groupIds: [], messageContent: 'message', page: 0, start: testData.constants.dates.DATE_1, @@ -83,6 +84,7 @@ describe('Repository with populated database', () => { scopeIds: [], scopeTypes: ['internal'], itemIds: [], + groupIds: [], messageContent: '', page: 0, start: testData.constants.dates.DATE_1, @@ -97,6 +99,7 @@ describe('Repository with populated database', () => { scopeIds: [], scopeTypes: [], itemIds: [], + groupIds: [], messageContent: '', page: 0, start: testData.constants.dates.DATE_1, @@ -185,6 +188,7 @@ describe('Repository with populated database', () => { scopeIds: [], scopeTypes: [], itemIds: ['item-xyz'], + groupIds: [], messageContent: '', page: 0, start: testData.constants.dates.DATE_1, diff --git a/backend/src/service/log.service.spec.ts b/backend/src/service/log.service.spec.ts index f21fefca51..08f321dbe0 100644 --- a/backend/src/service/log.service.spec.ts +++ b/backend/src/service/log.service.spec.ts @@ -38,6 +38,7 @@ describe('Log Service', () => { scopeIds: [], scopeTypes: [], itemIds: [], + groupIds: [], messageContent: undefined }; const result = service.search(searchParams); @@ -96,6 +97,31 @@ describe('Log Service', () => { assert.deepStrictEqual(logRepository.getItemById.mock.calls[0].arguments, ['itemId']); }); + it('should suggest groups', () => { + logRepository.suggestGroups.mock.mockImplementationOnce(() => []); + + const result = service.suggestGroups('groupName'); + + assert.deepStrictEqual(logRepository.suggestGroups.mock.calls[0].arguments, ['groupName']); + assert.deepStrictEqual(result, []); + }); + + it('should get a group', () => { + logRepository.getGroupById.mock.mockImplementationOnce(() => ({ groupId: 'groupId', groupName: 'groupName' })); + + const result = service.getGroupById('groupId'); + + assert.deepStrictEqual(logRepository.getGroupById.mock.calls[0].arguments, ['groupId']); + assert.deepStrictEqual(result, { groupId: 'groupId', groupName: 'groupName' }); + }); + + it('should throw if group not found', () => { + logRepository.getGroupById.mock.mockImplementationOnce(() => null); + + assert.throws(() => service.getGroupById('groupId'), { message: `Group "groupId" not found` }); + assert.deepStrictEqual(logRepository.getGroupById.mock.calls[0].arguments, ['groupId']); + }); + it('should properly convert to DTO', () => { const log = testData.logs.list[0]; assert.deepStrictEqual(toLogDTO(log), { @@ -106,6 +132,8 @@ describe('Log Service', () => { scopeName: log.scopeName, itemId: log.itemId, itemName: log.itemName, + groupId: log.groupId, + groupName: log.groupName, message: log.message }); }); diff --git a/backend/src/service/log.service.ts b/backend/src/service/log.service.ts index 9c559f6d9e..a0bb0621fe 100644 --- a/backend/src/service/log.service.ts +++ b/backend/src/service/log.service.ts @@ -1,7 +1,7 @@ import JoiValidator from '../web-server/controllers/validators/joi.validator'; import { Page } from '../../shared/model/types'; import { OIBusLog } from '../model/logs.model'; -import { Item, LogDTO, LogSearchParam, Scope } from '../../shared/model/logs.model'; +import { Group, Item, LogDTO, LogSearchParam, Scope } from '../../shared/model/logs.model'; import LogRepository from '../repository/logs/log.repository'; import { NotFoundError } from '../model/types'; @@ -38,6 +38,18 @@ export default class LogService { } return item; } + + suggestGroups(name: string): Array { + return this.logRepository.suggestGroups(name); + } + + getGroupById(groupId: string): Group { + const group = this.logRepository.getGroupById(groupId); + if (!group) { + throw new NotFoundError(`Group "${groupId}" not found`); + } + return group; + } } export const toLogDTO = (log: OIBusLog): LogDTO => { @@ -49,6 +61,8 @@ export const toLogDTO = (log: OIBusLog): LogDTO => { scopeName: log.scopeName, itemId: log.itemId, itemName: log.itemName, + groupId: log.groupId, + groupName: log.groupName, message: log.message }; }; diff --git a/backend/src/service/oibus.service.ts b/backend/src/service/oibus.service.ts index adfe80cce5..d8cf7e120d 100644 --- a/backend/src/service/oibus.service.ts +++ b/backend/src/service/oibus.service.ts @@ -306,7 +306,7 @@ export default class OIBusService { await this.loggerService.stop(); const registration = this.oIAnalyticsRegistrationService.getRegistrationSettings(); await this.loggerService.start(settings, registration); - this.loggerEvent.emit('updated', this.loggerService.createChildLogger('internal', 'web-server', 'Web Server')); + this.loggerEvent.emit('updated', this.loggerService.createChildLogger('internal', 'web-server')); } restart(): void { diff --git a/backend/src/south/south-connector.ts b/backend/src/south/south-connector.ts index 24dfcdc8e3..8f13989b5a 100644 --- a/backend/src/south/south-connector.ts +++ b/backend/src/south/south-connector.ts @@ -406,8 +406,13 @@ export default abstract class SouthConnector null); override suggestItems = mock.fn((_name: string): Array => []); override getItemById = mock.fn((_id: string): Item | null => null); + override suggestGroups = mock.fn((_name: string): Array => []); + override getGroupById = mock.fn((_id: string): Group | null => null); override saveAll = mock.fn((_logsToStore: Array): void => undefined); override count = mock.fn((): number => 0); override delete = mock.fn((_numberOfLogsToDelete: number): number => 0); diff --git a/backend/src/tests/__mocks__/service/log-service.mock.ts b/backend/src/tests/__mocks__/service/log-service.mock.ts index b0875c38cb..caa4db314b 100644 --- a/backend/src/tests/__mocks__/service/log-service.mock.ts +++ b/backend/src/tests/__mocks__/service/log-service.mock.ts @@ -1,6 +1,6 @@ import { mock } from 'node:test'; import { OIBusLog } from '../../../model/logs.model'; -import { Item, LogSearchParam, Scope } from '../../../../shared/model/logs.model'; +import { Group, Item, LogSearchParam, Scope } from '../../../../shared/model/logs.model'; import { Page } from '../../../../shared/model/types'; /** @@ -20,4 +20,6 @@ export default class LogServiceMock { getScopeById = mock.fn((_scopeId: string): Scope => ({}) as Scope); suggestItems = mock.fn((_name: string): Array => []); getItemById = mock.fn((_itemId: string): Item => ({}) as Item); + suggestGroups = mock.fn((_name: string): Array => []); + getGroupById = mock.fn((_groupId: string): Group => ({}) as Group); } diff --git a/backend/src/tests/utils/test-data.ts b/backend/src/tests/utils/test-data.ts index f108c9e735..d1387c2ad8 100644 --- a/backend/src/tests/utils/test-data.ts +++ b/backend/src/tests/utils/test-data.ts @@ -1573,6 +1573,8 @@ const logs: Array = [ scopeName: southConnectors[0].name, itemId: null, itemName: null, + groupId: null, + groupName: null, message: 'debug message log' }, { @@ -1583,6 +1585,8 @@ const logs: Array = [ scopeName: southConnectors[0].name, itemId: null, itemName: null, + groupId: null, + groupName: null, message: 'error message log' }, { @@ -1593,6 +1597,8 @@ const logs: Array = [ scopeName: northConnectors[1].name, itemId: null, itemName: null, + groupId: null, + groupName: null, message: 'info message log' }, { @@ -1603,6 +1609,8 @@ const logs: Array = [ scopeName: null, itemId: null, itemName: null, + groupId: null, + groupName: null, message: 'warn message log' } ]; diff --git a/backend/src/web-server/controllers/log.controller.spec.ts b/backend/src/web-server/controllers/log.controller.spec.ts index e5ce4a13a3..6b94cef9e6 100644 --- a/backend/src/web-server/controllers/log.controller.spec.ts +++ b/backend/src/web-server/controllers/log.controller.spec.ts @@ -1,7 +1,7 @@ import { describe, it, before, beforeEach, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; -import { Item, LogSearchParam, Scope } from '../../../shared/model/logs.model'; +import { Group, Item, LogSearchParam, Scope } from '../../../shared/model/logs.model'; import { CustomExpressRequest } from '../express'; import testData from '../../tests/utils/test-data'; import { mockModule, reloadModule, fixTsoaModuleResolution, createMockServices } from '../../tests/utils/test-utils'; @@ -60,6 +60,7 @@ describe('LogController', () => { scopeTypes: ['south', 'north'], scopeIds: ['scope1', 'scope2'], itemIds: ['item1', 'item2'], + groupIds: [], messageContent }; @@ -73,6 +74,7 @@ describe('LogController', () => { scopeIds, scopeTypes, itemIds, + undefined, messageContent, page, mockRequest as CustomExpressRequest @@ -95,6 +97,7 @@ describe('LogController', () => { scopeTypes: [], scopeIds: [], itemIds: [], + groupIds: [], messageContent: undefined }; @@ -110,6 +113,7 @@ describe('LogController', () => { undefined, undefined, undefined, + undefined, mockRequest as CustomExpressRequest ); @@ -187,4 +191,39 @@ describe('LogController', () => { assert.deepStrictEqual(logService.getItemById.mock.calls[0].arguments[0], itemId); assert.deepStrictEqual(result, item); }); + + it('should suggest groups by name', async () => { + const groups: Array = [{ groupId: 'group-id', groupName: 'Group Name' }]; + const name = 'Group'; + logService.suggestGroups = mock.fn(() => groups); + + const result = await controller.suggestGroups(name, mockRequest as CustomExpressRequest); + + assert.strictEqual(logService.suggestGroups.mock.calls.length, 1); + assert.deepStrictEqual(logService.suggestGroups.mock.calls[0].arguments[0], name); + assert.deepStrictEqual(result, groups); + }); + + it('should suggest groups by name with default parameter', async () => { + const groups: Array = [{ groupId: 'group-id', groupName: 'Group Name' }]; + logService.suggestGroups = mock.fn(() => groups); + + const result = await controller.suggestGroups(undefined, mockRequest as CustomExpressRequest); + + assert.strictEqual(logService.suggestGroups.mock.calls.length, 1); + assert.deepStrictEqual(logService.suggestGroups.mock.calls[0].arguments[0], ''); + assert.deepStrictEqual(result, groups); + }); + + it('should get group by its ID', async () => { + const group: Group = { groupId: 'group-id', groupName: 'Group Name' }; + const groupId = 'group-id'; + logService.getGroupById = mock.fn(() => group); + + const result = await controller.getGroupById(groupId, mockRequest as CustomExpressRequest); + + assert.strictEqual(logService.getGroupById.mock.calls.length, 1); + assert.deepStrictEqual(logService.getGroupById.mock.calls[0].arguments[0], groupId); + assert.deepStrictEqual(result, group); + }); }); diff --git a/backend/src/web-server/controllers/log.controller.ts b/backend/src/web-server/controllers/log.controller.ts index 01af1dc48e..04327e33a4 100644 --- a/backend/src/web-server/controllers/log.controller.ts +++ b/backend/src/web-server/controllers/log.controller.ts @@ -1,6 +1,6 @@ import { Controller, Get, Path, Query, Request, Route, Tags } from 'tsoa'; import { Instant, Page } from '../../../shared/model/types'; -import { Item, LogDTO, LogLevel, LogSearchParam, Scope, ScopeType } from '../../../shared/model/logs.model'; +import { Group, Item, LogDTO, LogLevel, LogSearchParam, Scope, ScopeType } from '../../../shared/model/logs.model'; import { DateTime } from 'luxon'; import { CustomExpressRequest } from '../express'; import { toLogDTO } from '../../service/log.service'; @@ -21,6 +21,7 @@ export class LogController extends Controller { * @param scopeIds Comma-separated list of scope IDs to filter by (e.g. `connector123,connector456`). * @param scopeTypes Comma-separated list of scope types to filter by (e.g. `south,north`). Valid values: `south`, `north`, `history-query`, `internal`. * @param itemIds Comma-separated list of item IDs to filter by (e.g. `item123,item456`). + * @param groupIds Comma-separated list of group IDs to filter by (e.g. `group123,group456`). * @param messageContent Substring to search for within log messages. * @returns {Promise>} Paginated list of log entries */ @@ -32,6 +33,7 @@ export class LogController extends Controller { @Query() scopeIds: string | undefined, @Query() scopeTypes: string | undefined, @Query() itemIds: string | undefined, + @Query() groupIds: string | undefined, @Query() messageContent: string | undefined, @Query() page = 0, @Request() request: CustomExpressRequest @@ -43,6 +45,7 @@ export class LogController extends Controller { const normalizedScopeTypes = scopeTypes ? (scopeTypes.split(',').filter(scopeType => scopeType.trim() !== '') as Array) : []; const normalizedScopeIds = scopeIds ? (scopeIds.split(',').filter(scopeId => scopeId.trim() !== '') as Array) : []; const normalizedItemIds = itemIds ? itemIds.split(',').filter(itemId => itemId.trim() !== '') : []; + const normalizedGroupIds = groupIds ? groupIds.split(',').filter(groupId => groupId.trim() !== '') : []; const searchParams: LogSearchParam = { page: page ? parseInt(page.toString(), 10) : 0, @@ -52,6 +55,7 @@ export class LogController extends Controller { scopeIds: normalizedScopeIds, scopeTypes: normalizedScopeTypes, itemIds: normalizedItemIds, + groupIds: normalizedGroupIds, messageContent: messageContent }; @@ -112,4 +116,27 @@ export class LogController extends Controller { const logService = request.services.logService; return logService.getItemById(itemId); } + + /** + * Returns a list of group suggestions based on the provided name fragment + * @summary Get group suggestions + * @returns {Promise>} Array of matching group objects + */ + @Get('/groups/suggest') + suggestGroups(@Query() name = '', @Request() request: CustomExpressRequest): Array { + const logService = request.services.logService; + return logService.suggestGroups(name); + } + + /** + * Retrieves details for a specific log group by its ID + * @summary Get log group details + * @param {string} groupId.path.required - Group ID + * @returns {Promise} Group object or 404 if not found + */ + @Get('/groups/{groupId}') + getGroupById(@Path() groupId: string, @Request() request: CustomExpressRequest): Group { + const logService = request.services.logService; + return logService.getGroupById(groupId); + } } diff --git a/backend/src/web-server/routes.ts b/backend/src/web-server/routes.ts index 1399b05941..cbcea7a0dd 100644 --- a/backend/src/web-server/routes.ts +++ b/backend/src/web-server/routes.ts @@ -1,3 +1,4 @@ +/* tslint:disable */ /* eslint-disable */ // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import type { TsoaRoute } from '@tsoa/runtime'; @@ -6595,6 +6596,8 @@ const models: TsoaRoute.Models = { "scopeName": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, "itemId": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, "itemName": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, + "groupId": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, + "groupName": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, "message": {"dataType":"string","required":true}, }, "additionalProperties": false, @@ -6630,6 +6633,15 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "Group": { + "dataType": "refObject", + "properties": { + "groupId": {"dataType":"string","required":true}, + "groupName": {"dataType":"string","required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "IPFilterDTO": { "dataType": "refObject", "properties": { @@ -10282,6 +10294,7 @@ export function RegisterRoutes(app: Router,opts?:{multer?:ReturnType = { + name: {"default":"","in":"query","name":"name","dataType":"string"}, + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + }; + app.get('/api/logs/groups/suggest', + ...(fetchMiddlewares(LogController)), + ...(fetchMiddlewares(LogController.prototype.suggestGroups)), + + async function LogController_suggestGroups(request: ExRequest, response: ExResponse, next: any) { + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args: argsLogController_suggestGroups, request, response }); + + const controller = new LogController(); + + await templateService.apiHandler({ + methodName: 'suggestGroups', + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + const argsLogController_getGroupById: Record = { + groupId: {"in":"path","name":"groupId","required":true,"dataType":"string"}, + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + }; + app.get('/api/logs/groups/:groupId', + ...(fetchMiddlewares(LogController)), + ...(fetchMiddlewares(LogController.prototype.getGroupById)), + + async function LogController_getGroupById(request: ExRequest, response: ExResponse, next: any) { + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args: argsLogController_getGroupById, request, response }); + + const controller = new LogController(); + + await templateService.apiHandler({ + methodName: 'getGroupById', + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa const argsIPFilterController_list: Record = { request: {"in":"request","name":"request","required":true,"dataType":"object"}, }; diff --git a/documentation/docs/api/openapi.json b/documentation/docs/api/openapi.json index 13fea4581e..0a260589be 100644 --- a/documentation/docs/api/openapi.json +++ b/documentation/docs/api/openapi.json @@ -28851,6 +28851,18 @@ "description": "The human-readable name of the item the log is associated with.\nCan be `null` if the log is not associated with a specific item.", "example": "Temperature sensor" }, + "groupId": { + "type": "string", + "nullable": true, + "description": "The unique identifier of the group the log is associated with (e.g., a south connector item group ID).\nCan be `null` if the log is not associated with a specific group.", + "example": "group123" + }, + "groupName": { + "type": "string", + "nullable": true, + "description": "The human-readable name of the group the log is associated with.\nCan be `null` if the log is not associated with a specific group.", + "example": "Temperature sensors" + }, "message": { "type": "string", "description": "The log message content, including details about the event or error.", @@ -28865,6 +28877,8 @@ "scopeName", "itemId", "itemName", + "groupId", + "groupName", "message" ], "type": "object", @@ -28957,6 +28971,27 @@ "type": "object", "additionalProperties": false }, + "Group": { + "description": "Represents a group associated with log entries.\nA group is a set of south connector items queried together.", + "properties": { + "groupId": { + "type": "string", + "description": "The unique identifier of the group (e.g., south connector item group ID).", + "example": "group123" + }, + "groupName": { + "type": "string", + "description": "The human-readable name of the group.", + "example": "Temperature sensors" + } + }, + "required": [ + "groupId", + "groupName" + ], + "type": "object", + "additionalProperties": false + }, "IPFilterDTO": { "description": "Data Transfer Object for an IP filter.\nRepresents a configured IP filter with its address and description.", "properties": { @@ -35981,6 +36016,15 @@ "type": "string" } }, + { + "description": "Comma-separated list of group IDs to filter by (e.g. `group123,group456`).", + "in": "query", + "name": "groupIds", + "required": true, + "schema": { + "type": "string" + } + }, { "description": "Substring to search for within log messages.", "in": "query", @@ -36143,6 +36187,76 @@ ] } }, + "/api/logs/groups/suggest": { + "get": { + "operationId": "SuggestGroups", + "responses": { + "200": { + "description": "Array of matching group objects", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Group" + }, + "type": "array" + } + } + } + } + }, + "description": "Returns a list of group suggestions based on the provided name fragment", + "summary": "Get group suggestions", + "tags": [ + "Logs" + ], + "security": [], + "parameters": [ + { + "in": "query", + "name": "name", + "required": false, + "schema": { + "default": "", + "type": "string" + } + } + ] + } + }, + "/api/logs/groups/{groupId}": { + "get": { + "operationId": "GetGroupById", + "responses": { + "200": { + "description": "Group object or 404 if not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Group" + } + } + } + } + }, + "description": "Retrieves details for a specific log group by its ID", + "summary": "Get log group details", + "tags": [ + "Logs" + ], + "security": [], + "parameters": [ + { + "in": "path", + "name": "groupId", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, "/api/ip-filters": { "get": { "operationId": "List", diff --git a/frontend/src/app/logs/logs.component.html b/frontend/src/app/logs/logs.component.html index 099a8ab864..6a6130e674 100644 --- a/frontend/src/app/logs/logs.component.html +++ b/frontend/src/app/logs/logs.component.html @@ -98,6 +98,24 @@

}
+ +
+ +
+ @for (group of selectedGroups(); track group.groupId) { + {{ group.groupName }} + } +
+
} @else {
@@ -170,6 +188,7 @@

} + @@ -182,9 +201,16 @@

{{ log.timestamp | datetime: 'mediumWithSeconds' }} @if (!scopeId() && !scopeType()) { {{ log.scopeType | scopeTypesEnum }} - {{ log.scopeName }} + + @if (log.scopeType === 'internal') { + {{ 'enums.internal-scope-ids.' + log.scopeId | translate }} + } @else { + {{ log.scopeName }} + } + } {{ log.itemName }} + {{ log.groupName }} {{ log.message }} } diff --git a/frontend/src/app/logs/logs.component.ts b/frontend/src/app/logs/logs.component.ts index ae7628dcf2..da81aca800 100644 --- a/frontend/src/app/logs/logs.component.ts +++ b/frontend/src/app/logs/logs.component.ts @@ -4,6 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { PageLoader } from '../shared/page-loader.service'; import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms'; import { + Group, Item, LOG_LEVELS, LogDTO, @@ -94,6 +95,7 @@ export class LogsComponent implements OnInit, OnDestroy { scopeTypes: [[] as Array], scopeIds: null as string | null, itemIds: null as string | null, + groupIds: null as string | null, levels: [[] as Array], page: null as number | null }, @@ -112,6 +114,7 @@ export class LogsComponent implements OnInit, OnDestroy { readonly scopeTypes = SCOPE_TYPES; selectedScopes = signal>([]); selectedItems = signal>([]); + selectedGroups = signal>([]); loading = signal(false); // subscription to reload the page periodically subscription = new Subscription(); @@ -138,6 +141,14 @@ export class LogsComponent implements OnInit, OnDestroy { ); itemFormatter = (item: Item) => item.itemName; + groupTypeahead = (text$: Observable) => + text$.pipe( + debounceTime(TYPEAHEAD_DEBOUNCE_TIME), + distinctUntilChanged(), + switchMap(text => this.logService.suggestGroups(text)) + ); + groupFormatter = (group: Group) => group.groupName; + private autoReloadPaused$ = toObservable(this.autoReloadPaused); ngOnInit(): void { @@ -149,6 +160,7 @@ export class LogsComponent implements OnInit, OnDestroy { scopeTypes: searchParams.scopeTypes, scopeIds: '', itemIds: '', + groupIds: '', levels: searchParams.levels, page: searchParams.page }); @@ -172,6 +184,14 @@ export class LogsComponent implements OnInit, OnDestroy { } ); } + const queryGroupIds = this.route.snapshot.queryParamMap.getAll('groupIds'); + if (queryGroupIds.length > 0) { + combineLatest(queryGroupIds.map(groupId => this.logService.getGroupById(groupId).pipe(catchError(() => of(null))))).subscribe( + selectedGroups => { + this.selectedGroups.set(selectedGroups.filter(group => !!group) as Array); + } + ); + } this.subscription.add( combineLatest([this.pageLoader.pageLoads$, this.autoReloadPaused$.pipe(startWith(this.autoReloadPaused()))]) .pipe( @@ -214,8 +234,9 @@ export class LogsComponent implements OnInit, OnDestroy { const end = queryParamMap.get('end') || undefined; const levels = queryParamMap.getAll('levels') as Array; const itemIds = queryParamMap.getAll('itemIds'); + const groupIds = queryParamMap.getAll('groupIds'); const page = queryParamMap.get('page') ? parseInt(queryParamMap.get('page')!, 10) : 0; - return { messageContent, scopeTypes, scopeIds, itemIds, start, end, levels, page }; + return { messageContent, scopeTypes, scopeIds, itemIds, groupIds, start, end, levels, page }; } triggerSearch() { @@ -233,6 +254,7 @@ export class LogsComponent implements OnInit, OnDestroy { scopeTypes: scopeType ? [scopeType] : formValue.scopeTypes!, scopeIds: scopeId ? [scopeId] : this.selectedScopes()!.map(scope => scope.scopeId), itemIds: this.selectedItems().map(item => item.itemId), + groupIds: this.selectedGroups().map(group => group.groupId), page: 0 }; this.router.navigate([], { queryParams: criteria }); @@ -262,6 +284,16 @@ export class LogsComponent implements OnInit, OnDestroy { this.selectedItems.update(items => items.filter(item => item.itemId !== itemToRemove.itemId)); } + selectGroup(event: NgbTypeaheadSelectItemEvent) { + this.selectedGroups.update(groups => [...groups, event.item]); + this.searchForm.controls.groupIds.setValue(''); + event.preventDefault(); + } + + removeGroup(groupToRemove: Group) { + this.selectedGroups.update(groups => groups.filter(group => group.groupId !== groupToRemove.groupId)); + } + getLevelClass(logLevel: LogLevel): string { const foundElement = this.LEGEND.find(element => element.label === `enums.log-levels.${logLevel}`); if (foundElement) { diff --git a/frontend/src/app/services/log.service.ts b/frontend/src/app/services/log.service.ts index 29f82fa479..e495296563 100644 --- a/frontend/src/app/services/log.service.ts +++ b/frontend/src/app/services/log.service.ts @@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { Service, inject } from '@angular/core'; import { Page } from '../../../../backend/shared/model/types'; -import { Item, LogDTO, LogSearchParam, Scope } from '../../../../backend/shared/model/logs.model'; +import { Group, Item, LogDTO, LogSearchParam, Scope } from '../../../../backend/shared/model/logs.model'; /** * Service used to interact with the backend Log repository @@ -37,6 +37,9 @@ export class LogService { if (searchParams.itemIds) { params['itemIds'] = searchParams.itemIds.join(','); } + if (searchParams.groupIds) { + params['groupIds'] = searchParams.groupIds.join(','); + } if (searchParams.levels) { params['levels'] = searchParams.levels.join(','); } @@ -58,4 +61,12 @@ export class LogService { getItemById(id: string): Observable { return this.http.get(`/api/logs/items/${id}`); } + + suggestGroups(name: string): Observable> { + return this.http.get>('/api/logs/groups/suggest', { params: { name } }); + } + + getGroupById(id: string): Observable { + return this.http.get(`/api/logs/groups/${id}`); + } } diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index dcf541bb16..b762236aff 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -158,6 +158,11 @@ "history-query": "History query", "internal": "Internal" }, + "internal-scope-ids": { + "engine": "Engine", + "web-server": "Web Server", + "node-opcua": "Node OPC-UA" + }, "categories": { "api": "API", "database": "Database", @@ -2663,6 +2668,7 @@ "scope-type": "Types", "scope": "Scopes", "item": "Items", + "group": "Groups", "levels": "Levels" }, "date": "Date", @@ -2670,6 +2676,7 @@ "scope-type": "Type", "scope": "Scope", "item": "Item", + "group": "Group", "message": "Message", "none": "No log found", "tooltips": { From fcb9eb98ca60566e87a96c04260d2dfd23365405 Mon Sep 17 00:00:00 2001 From: kbalinthunor Date: Mon, 29 Jun 2026 09:08:07 +0300 Subject: [PATCH 17/24] fix(logger): fix ci lint and frontend spec failures --- backend/src/web-server/routes.ts | 1 - frontend/src/app/logs/logs.component.html | 2 +- frontend/src/app/logs/logs.component.spec.ts | 14 +++++++++++--- frontend/src/app/services/log.service.spec.ts | 5 +++++ frontend/src/app/services/log.service.ts | 2 +- 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/backend/src/web-server/routes.ts b/backend/src/web-server/routes.ts index cbcea7a0dd..0ca011057a 100644 --- a/backend/src/web-server/routes.ts +++ b/backend/src/web-server/routes.ts @@ -1,4 +1,3 @@ -/* tslint:disable */ /* eslint-disable */ // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import type { TsoaRoute } from '@tsoa/runtime'; diff --git a/frontend/src/app/logs/logs.component.html b/frontend/src/app/logs/logs.component.html index 6a6130e674..6bece7514a 100644 --- a/frontend/src/app/logs/logs.component.html +++ b/frontend/src/app/logs/logs.component.html @@ -202,7 +202,7 @@

@if (!scopeId() && !scopeType()) { {{ log.scopeType | scopeTypesEnum }} - @if (log.scopeType === 'internal') { + @if (log.scopeType === 'internal' && log.scopeId) { {{ 'enums.internal-scope-ids.' + log.scopeId | translate }} } @else { {{ log.scopeName }} diff --git a/frontend/src/app/logs/logs.component.spec.ts b/frontend/src/app/logs/logs.component.spec.ts index 68bffd4f2f..8a7cf25e43 100644 --- a/frontend/src/app/logs/logs.component.spec.ts +++ b/frontend/src/app/logs/logs.component.spec.ts @@ -54,6 +54,8 @@ describe('LogsComponent', () => { scopeId: null, itemId: null, itemName: null, + groupId: null, + groupName: null, message: 'my log 1' }, { @@ -64,6 +66,8 @@ describe('LogsComponent', () => { scopeName: 'My South', itemId: null, itemName: null, + groupId: null, + groupName: null, message: 'my log 2' } ]); @@ -118,6 +122,7 @@ describe('LogsComponent', () => { scopeTypes: [], scopeIds: [], itemIds: [], + groupIds: [], start: '2022-12-31T23:00:00.000Z', end: '2023-02-28T23:00:00.000Z', levels: ['info', 'error'], @@ -125,18 +130,20 @@ describe('LogsComponent', () => { }); await expect.element(tester.logs).toHaveLength(2); - await expect.element(tester.cells(0)).toHaveLength(6); + await expect.element(tester.cells(0)).toHaveLength(7); await expect.element(tester.cells(0).nth(1)).toHaveTextContent('1 Jan 2023, 01:00:00'); await expect.element(tester.cells(0).nth(2)).toHaveTextContent('Internal'); expect(tester.cells(0).nth(3).element().textContent?.trim()).toBe(''); expect(tester.cells(0).nth(4).element().textContent?.trim()).toBe(''); - await expect.element(tester.cells(0).nth(5)).toHaveTextContent('my log 1'); + expect(tester.cells(0).nth(5).element().textContent?.trim()).toBe(''); + await expect.element(tester.cells(0).nth(6)).toHaveTextContent('my log 1'); await expect.element(tester.cells(1).nth(1)).toHaveTextContent('2 Jan 2023, 01:00:00'); await expect.element(tester.cells(1).nth(2)).toHaveTextContent('South'); await expect.element(tester.cells(1).nth(3)).toHaveTextContent('My South'); expect(tester.cells(1).nth(4).element().textContent?.trim()).toBe(''); - await expect.element(tester.cells(1).nth(5)).toHaveTextContent('my log 2'); + expect(tester.cells(1).nth(5).element().textContent?.trim()).toBe(''); + await expect.element(tester.cells(1).nth(6)).toHaveTextContent('my log 2'); }); test('should add selected scope and clear input on typeahead selection', () => { @@ -349,6 +356,7 @@ describe('LogsComponent', () => { scopeTypes: ['south'], scopeIds: ['X'], itemIds: [], + groupIds: [], page: 0 } }); diff --git a/frontend/src/app/services/log.service.spec.ts b/frontend/src/app/services/log.service.spec.ts index 498a8315ce..35da9bfa10 100644 --- a/frontend/src/app/services/log.service.spec.ts +++ b/frontend/src/app/services/log.service.spec.ts @@ -32,6 +32,8 @@ describe('LogService', () => { scopeId: null, itemId: null, itemName: null, + groupId: null, + groupName: null, message: 'my log 1' }, { @@ -42,6 +44,8 @@ describe('LogService', () => { scopeId: null, itemId: null, itemName: null, + groupId: null, + groupName: null, message: 'my log 2' } ]); @@ -53,6 +57,7 @@ describe('LogService', () => { scopeTypes: ['internal', 'south'], scopeIds: ['id1', 'id2'], itemIds: ['itemId1', 'itemId2'], + groupIds: [], start: '2023-01-01T00:00:00.000Z', end: '2023-01-02T00:00:00.000Z', levels: ['info', 'debug'] diff --git a/frontend/src/app/services/log.service.ts b/frontend/src/app/services/log.service.ts index e495296563..ccbd41ac93 100644 --- a/frontend/src/app/services/log.service.ts +++ b/frontend/src/app/services/log.service.ts @@ -37,7 +37,7 @@ export class LogService { if (searchParams.itemIds) { params['itemIds'] = searchParams.itemIds.join(','); } - if (searchParams.groupIds) { + if (searchParams.groupIds?.length) { params['groupIds'] = searchParams.groupIds.join(','); } if (searchParams.levels) { From 221af07052b11880afa129e9c8ed34cbc19dd54e Mon Sep 17 00:00:00 2001 From: kbalinthunor Date: Mon, 29 Jun 2026 09:45:15 +0300 Subject: [PATCH 18/24] fix(logger): fix suggestScopes for internal scopes with null scope_name --- backend/src/repository/logs/log.repository.ts | 6 ++++-- .../src/repository/logs/logs-database.spec.ts | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/backend/src/repository/logs/log.repository.ts b/backend/src/repository/logs/log.repository.ts index 4b291754e1..a10de27978 100644 --- a/backend/src/repository/logs/log.repository.ts +++ b/backend/src/repository/logs/log.repository.ts @@ -77,8 +77,10 @@ export default class LogRepository { } suggestScopes(name: string): Array { - const query = `SELECT DISTINCT scope_id AS scopeId, scope_name as scopeName FROM ${LOG_TABLE} WHERE scope_name LIKE '%' || ? || '%';`; - return this.database.prepare(query).all(name) as Array; + const query = + `SELECT DISTINCT scope_id AS scopeId, scope_name as scopeName FROM ${LOG_TABLE}` + + ` WHERE scope_name LIKE '%' || ? || '%' OR (scope_type = 'internal' AND scope_id LIKE '%' || ? || '%');`; + return this.database.prepare(query).all(name, name) as Array; } getScopeById(id: string): Scope | null { diff --git a/backend/src/repository/logs/logs-database.spec.ts b/backend/src/repository/logs/logs-database.spec.ts index 509dd09e3c..ac96d110e6 100644 --- a/backend/src/repository/logs/logs-database.spec.ts +++ b/backend/src/repository/logs/logs-database.spec.ts @@ -133,6 +133,25 @@ describe('Repository with populated database', () => { assert.strictEqual(repository.getScopeById('bad id'), null); }); + it('should find internal scopes by scope_id when scope_name is null', () => { + repository.saveAll([ + { + msg: 'engine log', + scopeType: 'internal', + scopeId: 'engine', + scopeName: undefined, + time: testData.constants.dates.DATE_1, + level: '30' + } + ]); + + const result = repository.suggestScopes('engine'); + assert.ok( + result.some(s => s.scopeId === 'engine' && s.scopeName === null), + 'expected engine internal scope to be suggested by scope_id' + ); + }); + it('should search items and find by id', () => { repository.saveAll([ { From 2a977614b2de647634c1f133b1a69a2fa019fd46 Mon Sep 17 00:00:00 2001 From: kbalinthunor Date: Tue, 30 Jun 2026 09:20:38 +0300 Subject: [PATCH 19/24] feat(south): replace overlap with startTimeOffset and endTimeOffset --- backend/package-lock.json | 4 +- backend/shared/model/south-connector.model.ts | 67 +- backend/src/engine/history-query.spec.ts | 6 +- backend/src/engine/history-query.ts | 3 +- .../entity-migrations/3/3.9/v3.9.2.spec.ts | 290 ++++ .../entity-migrations/3/3.9/v3.9.2.ts | 44 + backend/src/model/south-connector.model.ts | 12 +- .../config/north-connector.repository.spec.ts | 6 +- .../config/north-connector.repository.ts | 2 +- .../config/south-connector.repository.spec.ts | 56 +- .../config/south-connector.repository.ts | 46 +- .../south-item-group.repository.spec.ts | 73 +- .../config/south-item-group.repository.ts | 20 +- backend/src/service/north.service.spec.ts | 9 +- .../oia/oianalytics-command.service.ts | 3 +- .../oia/oianalytics-message.service.ts | 6 +- .../src/service/south-connector-dto.utils.ts | 6 +- backend/src/service/south.service.spec.ts | 151 +- backend/src/service/south.service.ts | 27 +- backend/src/service/utils.spec.ts | 17 +- backend/src/service/utils.ts | 3 +- backend/src/south/south-ads/south-ads.spec.ts | 6 +- backend/src/south/south-connector.spec.ts | 12 +- backend/src/south/south-connector.ts | 61 +- .../south-folder-scanner.spec.ts | 3 +- backend/src/south/south-ftp/south-ftp.spec.ts | 9 +- .../south-influxdb/south-influxdb.spec.ts | 3 +- .../south/south-modbus/south-modbus.spec.ts | 24 +- .../src/south/south-mssql/south-mssql.spec.ts | 9 +- .../src/south/south-mysql/south-mysql.spec.ts | 18 +- .../src/south/south-odbc/south-odbc.spec.ts | 45 +- .../south-oianalytics.spec.ts | 3 +- .../src/south/south-oledb/south-oledb.spec.ts | 9 +- backend/src/south/south-opc/south-opc.spec.ts | 9 +- .../src/south/south-opcua/south-opcua.spec.ts | 18 +- .../south/south-oracle/south-oracle.spec.ts | 15 +- backend/src/south/south-pi/south-pi.spec.ts | 6 +- .../south-postgresql/south-postgresql.spec.ts | 18 +- .../src/south/south-rest/south-rest.spec.ts | 3 +- .../src/south/south-sftp/south-sftp.spec.ts | 27 +- backend/src/tests/utils/test-data.ts | 30 +- backend/src/tests/utils/test-utils.ts | 3 +- .../south-connector.controller.spec.ts | 15 +- backend/src/web-server/routes.ts | 232 ++- documentation/docs/api/openapi.json | 1324 ++++++++++++----- .../services/south-connector.service.spec.ts | 3 +- .../edit-south/edit-south.component.html | 25 +- .../south/edit-south/edit-south.component.ts | 18 +- .../south-detail/south-detail.component.html | 27 +- .../south-detail/south-detail.component.ts | 27 +- ...edit-south-item-group-modal.component.html | 34 +- ...t-south-item-group-modal.component.spec.ts | 2 +- .../edit-south-item-group-modal.component.ts | 12 +- .../edit-south-item-modal.component.html | 35 +- .../edit-south-item-modal.component.ts | 45 +- .../select-group-modal.component.spec.ts | 2 +- .../south-item-test.component.spec.ts | 3 +- frontend/src/i18n/en.json | 6 +- 58 files changed, 2219 insertions(+), 773 deletions(-) create mode 100644 backend/src/migration/entity-migrations/3/3.9/v3.9.2.spec.ts create mode 100644 backend/src/migration/entity-migrations/3/3.9/v3.9.2.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index e8ed3270e5..a37a2f6bbd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -52,8 +52,8 @@ "pino-loki": "3.0.0", "pino-pretty": "13.1.3", "pino-roll": "4.0.0", - "pino-socket": "^8.0.0", - "pino-syslog": "^4.0.0", + "pino-socket": "8.0.0", + "pino-syslog": "4.0.0", "ssh2-sftp-client": "12.1.1", "thread-stream": "4.2.0", "tsoa": "6.6.0", diff --git a/backend/shared/model/south-connector.model.ts b/backend/shared/model/south-connector.model.ts index 7373159f14..72e4312277 100644 --- a/backend/shared/model/south-connector.model.ts +++ b/backend/shared/model/south-connector.model.ts @@ -265,7 +265,7 @@ export interface SouthConnectorItemTypedDTO extends BaseEntity { /** * Whether this item syncs its historian settings with its group. - * When true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group. + * When true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group. * * @example true */ @@ -290,13 +290,24 @@ export interface SouthConnectorItemTypedDTO extends BaseEntity { readDelay: number | null; /** - * Default overlap in milliseconds for historical queries. + * Offset in milliseconds applied to the start of the history query interval. * Only applicable for connectors with historian capabilities. * When null and item is in a group, inherits from group settings. + * Negative values extend the window backwards (equivalent to the old overlap behaviour). * - * @example 1000 + * @example -1000 */ - overlap: number | null; + startTimeOffset: number | null; + + /** + * Offset in milliseconds applied to the end of the history query interval. + * Only applicable for connectors with historian capabilities. + * When null and item is in a group, inherits from group settings. + * If the resulting end time is not after the effective start time, the query is skipped. + * + * @example 0 + */ + endTimeOffset: number | null; /** * Recovery strategy when the connector reconnects after a long disconnection. @@ -356,12 +367,22 @@ export interface SouthItemGroupDTO extends BaseEntity { historySettings: { /** - * Default overlap in milliseconds for historical queries. + * Offset in milliseconds applied to the start of the history query interval. + * Only applicable for connectors with historian capabilities. + * Negative values extend the window backwards (equivalent to the old overlap behaviour). + * + * @example -1000 + */ + startTimeOffset: number | null; + + /** + * Offset in milliseconds applied to the end of the history query interval. * Only applicable for connectors with historian capabilities. + * If the resulting end time is not after the effective start time, the query is skipped. * - * @example 1000 + * @example 0 */ - overlap: number | null; + endTimeOffset: number | null; /** * Maximum read interval in seconds for historical queries. @@ -419,11 +440,20 @@ export interface SouthItemGroupCommandDTO { historySettings: { /** - * Default overlap in milliseconds for historical queries. + * Offset in milliseconds applied to the start of the history query interval. + * Negative values extend the window backwards (equivalent to the old overlap behaviour). * - * @example 1000 + * @example -1000 */ - overlap: number | null; + startTimeOffset: number | null; + + /** + * Offset in milliseconds applied to the end of the history query interval. + * If the resulting end time is not after the effective start time, the query is skipped. + * + * @example 0 + */ + endTimeOffset: number | null; /** * Maximum read interval in seconds for historical queries. @@ -696,13 +726,24 @@ export interface SouthConnectorItemCommandTypedDTO { readDelay: number | null; /** - * Default overlap in milliseconds for historical queries. + * Offset in milliseconds applied to the start of the history query interval. * Only applicable for connectors with historian capabilities. * When null and item is in a group, inherits from group settings. + * Negative values extend the window backwards (equivalent to the old overlap behaviour). * - * @example 1000 + * @example -1000 + */ + startTimeOffset: number | null; + + /** + * Offset in milliseconds applied to the end of the history query interval. + * Only applicable for connectors with historian capabilities. + * When null and item is in a group, inherits from group settings. + * If the resulting end time is not after the effective start time, the query is skipped. + * + * @example 0 */ - overlap: number | null; + endTimeOffset: number | null; /** * Recovery strategy when the connector reconnects after a long disconnection. diff --git a/backend/src/engine/history-query.spec.ts b/backend/src/engine/history-query.spec.ts index efb353d95a..8a7980b9ac 100644 --- a/backend/src/engine/history-query.spec.ts +++ b/backend/src/engine/history-query.spec.ts @@ -90,7 +90,8 @@ describe('HistoryQuery enabled', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 200, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, recoveryStrategy: null })) ); @@ -132,7 +133,8 @@ describe('HistoryQuery enabled', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 200, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, recoveryStrategy: null })) ); diff --git a/backend/src/engine/history-query.ts b/backend/src/engine/history-query.ts index f34fe03ccb..1ed751268d 100644 --- a/backend/src/engine/history-query.ts +++ b/backend/src/engine/history-query.ts @@ -110,7 +110,8 @@ export default class HistoryQuery { }, maxReadInterval: this.historyConfiguration.queryTimeRange.maxReadInterval, readDelay: this.historyConfiguration.queryTimeRange.readDelay, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, recoveryStrategy: null })), this.historyConfiguration.queryTimeRange.startTime, diff --git a/backend/src/migration/entity-migrations/3/3.9/v3.9.2.spec.ts b/backend/src/migration/entity-migrations/3/3.9/v3.9.2.spec.ts new file mode 100644 index 0000000000..3875a86854 --- /dev/null +++ b/backend/src/migration/entity-migrations/3/3.9/v3.9.2.spec.ts @@ -0,0 +1,290 @@ +import { describe, it, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { readdirSync } from 'node:fs'; +import knex, { Knex } from 'knex'; +import { up, down } from './v3.9.2'; + +function entityMigrationFiles(): Array<{ file: string; full: string }> { + const root = path.resolve(__dirname, '..', '..'); // .../entity-migrations + const collect = (base: string): Array<{ file: string; full: string }> => { + const out: Array<{ file: string; full: string }> = []; + for (const entry of readdirSync(base, { withFileTypes: true })) { + const full = path.join(base, entry.name); + if (entry.isDirectory()) { + out.push(...collect(full)); + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')) { + out.push({ file: entry.name, full }); + } + } + return out; + }; + return collect(root).sort((a, b) => (a.file > b.file ? 1 : a.file < b.file ? -1 : 0)); +} + +async function buildPreV392Schema(db: Knex): Promise { + const priorFiles = entityMigrationFiles().filter(f => f.file < 'v3.9.2'); + for (const { full } of priorFiles) { + const migration = (await import(full)) as { up: (k: Knex) => Promise }; + await migration.up(db); + } +} + +async function columnNames(db: Knex, table: string): Promise> { + const cols = (await db.raw(`PRAGMA table_info(${table})`)) as Array<{ name: string }>; + return cols.map(c => c.name); +} + +async function insertScanMode(db: Knex) { + await db('scan_modes').insert({ + id: 'scan-mode-1', + name: 'Every 10s', + description: '', + cron: '*/10 * * * * *', + created_by: 'admin', + updated_by: 'admin', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + }); +} + +async function insertSouthConnector(db: Knex) { + await db('south_connectors').insert({ + id: 'south-1', + name: 'Test OPCUA', + type: 'opcua', + description: '', + enabled: 1, + settings: '{}', + created_by: 'admin', + updated_by: 'admin', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + }); +} + +describe('Entity migration v3.9.2', () => { + let db: Knex; + let tmpDir: string; + let dbFile: string; + + before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oibus-entity-v392-')); + dbFile = path.join(tmpDir, 'test.db'); + }); + + after(async () => { + await db?.destroy(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + beforeEach(async () => { + await db?.destroy(); + await fs.rm(dbFile, { force: true }); + db = knex({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + await buildPreV392Schema(db); + await db('engines').insert({ + id: 'test-engine-id', + name: 'Test Engine', + port: 2223, + log_console_level: 'silent', + log_file_level: 'silent', + log_file_max_file_size: 50, + log_file_number_of_files: 5, + log_database_level: 'silent', + log_database_max_number_of_logs: 100000, + log_loki_level: 'silent', + log_loki_interval: 60, + log_oia_level: 'silent', + log_oia_interval: 10, + proxy_enabled: 0, + proxy_port: 9000, + oibus_version: '3.9.1', + log_syslog_level: 'silent', + log_syslog_host: '', + log_syslog_port: 514, + log_syslog_protocol: 'udp4', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + }); + }); + + it('runs up end-to-end on a realistic pre-3.9.2 schema', async () => { + await up(db); + }); + + it('up adds start_time_offset and end_time_offset to south_item_groups', async () => { + await up(db); + const cols = await columnNames(db, 'south_item_groups'); + assert.ok(cols.includes('start_time_offset'), 'south_item_groups.start_time_offset added'); + assert.ok(cols.includes('end_time_offset'), 'south_item_groups.end_time_offset added'); + }); + + it('up adds start_time_offset and end_time_offset to south_items', async () => { + await up(db); + const cols = await columnNames(db, 'south_items'); + assert.ok(cols.includes('start_time_offset'), 'south_items.start_time_offset added'); + assert.ok(cols.includes('end_time_offset'), 'south_items.end_time_offset added'); + }); + + it('up drops overlap from south_item_groups and south_items', async () => { + await up(db); + const groupCols = await columnNames(db, 'south_item_groups'); + assert.ok(!groupCols.includes('overlap'), 'south_item_groups.overlap removed'); + const itemCols = await columnNames(db, 'south_items'); + assert.ok(!itemCols.includes('overlap'), 'south_items.overlap removed'); + }); + + it('up maps overlap to -start_time_offset for groups with non-null overlap', async () => { + await insertScanMode(db); + await insertSouthConnector(db); + await db('south_item_groups').insert({ + id: 'group-1', + name: 'Group A', + south_id: 'south-1', + scan_mode_id: 'scan-mode-1', + overlap: 500, + max_read_interval: 3600, + read_delay: 200, + created_by: 'admin', + updated_by: 'admin', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + }); + + await up(db); + + const group = await db('south_item_groups').where('id', 'group-1').first(); + assert.strictEqual(group.start_time_offset, -500, 'start_time_offset = -overlap'); + assert.strictEqual(group.end_time_offset, null, 'end_time_offset defaults to null'); + }); + + it('up maps overlap to -start_time_offset for items with non-null overlap', async () => { + await insertScanMode(db); + await insertSouthConnector(db); + await db('south_items').insert({ + id: 'item-1', + name: 'Item A', + enabled: 1, + connector_id: 'south-1', + scan_mode_id: 'scan-mode-1', + settings: '{}', + sync_with_group: 0, + max_read_interval: null, + read_delay: null, + overlap: 1000, + created_by: 'admin', + updated_by: 'admin', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + }); + + await up(db); + + const item = await db('south_items').where('id', 'item-1').first(); + assert.strictEqual(item.start_time_offset, -1000, 'start_time_offset = -overlap'); + assert.strictEqual(item.end_time_offset, null, 'end_time_offset defaults to null'); + }); + + it('up keeps start_time_offset null when overlap was null', async () => { + await insertScanMode(db); + await insertSouthConnector(db); + await db('south_item_groups').insert({ + id: 'group-1', + name: 'Group A', + south_id: 'south-1', + scan_mode_id: 'scan-mode-1', + overlap: null, + max_read_interval: 3600, + read_delay: 200, + created_by: 'admin', + updated_by: 'admin', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + }); + await db('south_items').insert({ + id: 'item-1', + name: 'Item A', + enabled: 1, + connector_id: 'south-1', + scan_mode_id: 'scan-mode-1', + settings: '{}', + sync_with_group: 0, + max_read_interval: null, + read_delay: null, + overlap: null, + created_by: 'admin', + updated_by: 'admin', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + }); + + await up(db); + + const group = await db('south_item_groups').where('id', 'group-1').first(); + assert.strictEqual(group.start_time_offset, null, 'null overlap → null start_time_offset for group'); + + const item = await db('south_items').where('id', 'item-1').first(); + assert.strictEqual(item.start_time_offset, null, 'null overlap → null start_time_offset for item'); + }); + + it('down restores overlap column and drops start_time_offset and end_time_offset', async () => { + await up(db); + await down(db); + + const groupCols = await columnNames(db, 'south_item_groups'); + assert.ok(groupCols.includes('overlap'), 'south_item_groups.overlap restored'); + assert.ok(!groupCols.includes('start_time_offset'), 'south_item_groups.start_time_offset removed'); + assert.ok(!groupCols.includes('end_time_offset'), 'south_item_groups.end_time_offset removed'); + + const itemCols = await columnNames(db, 'south_items'); + assert.ok(itemCols.includes('overlap'), 'south_items.overlap restored'); + assert.ok(!itemCols.includes('start_time_offset'), 'south_items.start_time_offset removed'); + assert.ok(!itemCols.includes('end_time_offset'), 'south_items.end_time_offset removed'); + }); + + it('down maps start_time_offset back to -overlap', async () => { + await insertScanMode(db); + await insertSouthConnector(db); + await db('south_item_groups').insert({ + id: 'group-1', + name: 'Group A', + south_id: 'south-1', + scan_mode_id: 'scan-mode-1', + overlap: 500, + max_read_interval: 3600, + read_delay: 200, + created_by: 'admin', + updated_by: 'admin', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + }); + await db('south_items').insert({ + id: 'item-1', + name: 'Item A', + enabled: 1, + connector_id: 'south-1', + scan_mode_id: 'scan-mode-1', + settings: '{}', + sync_with_group: 0, + max_read_interval: null, + read_delay: null, + overlap: 1000, + created_by: 'admin', + updated_by: 'admin', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + }); + + await up(db); + await down(db); + + const group = await db('south_item_groups').where('id', 'group-1').first(); + assert.strictEqual(group.overlap, 500, 'group overlap restored to original value'); + + const item = await db('south_items').where('id', 'item-1').first(); + assert.strictEqual(item.overlap, 1000, 'item overlap restored to original value'); + }); +}); diff --git a/backend/src/migration/entity-migrations/3/3.9/v3.9.2.ts b/backend/src/migration/entity-migrations/3/3.9/v3.9.2.ts new file mode 100644 index 0000000000..5688ccdd86 --- /dev/null +++ b/backend/src/migration/entity-migrations/3/3.9/v3.9.2.ts @@ -0,0 +1,44 @@ +import { Knex } from 'knex'; + +const SOUTH_ITEM_GROUPS_TABLE = 'south_item_groups'; +const SOUTH_ITEMS_TABLE = 'south_items'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(SOUTH_ITEM_GROUPS_TABLE, t => { + t.integer('start_time_offset').nullable().defaultTo(null); + t.integer('end_time_offset').nullable().defaultTo(null); + }); + await knex.schema.alterTable(SOUTH_ITEMS_TABLE, t => { + t.integer('start_time_offset').nullable().defaultTo(null); + t.integer('end_time_offset').nullable().defaultTo(null); + }); + + await knex.raw(`UPDATE ${SOUTH_ITEM_GROUPS_TABLE} SET start_time_offset = -overlap WHERE overlap IS NOT NULL`); + await knex.raw(`UPDATE ${SOUTH_ITEMS_TABLE} SET start_time_offset = -overlap WHERE overlap IS NOT NULL`); + + await knex.schema.alterTable(SOUTH_ITEM_GROUPS_TABLE, t => { + t.dropColumn('overlap'); + }); + await knex.schema.alterTable(SOUTH_ITEMS_TABLE, t => { + t.dropColumn('overlap'); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(SOUTH_ITEM_GROUPS_TABLE, t => { + t.integer('overlap').nullable().defaultTo(null); + }); + await knex.schema.alterTable(SOUTH_ITEMS_TABLE, t => { + t.integer('overlap').nullable().defaultTo(null); + }); + await knex.raw(`UPDATE ${SOUTH_ITEM_GROUPS_TABLE} SET overlap = -start_time_offset WHERE start_time_offset IS NOT NULL`); + await knex.raw(`UPDATE ${SOUTH_ITEMS_TABLE} SET overlap = -start_time_offset WHERE start_time_offset IS NOT NULL`); + await knex.schema.alterTable(SOUTH_ITEM_GROUPS_TABLE, t => { + t.dropColumn('start_time_offset'); + t.dropColumn('end_time_offset'); + }); + await knex.schema.alterTable(SOUTH_ITEMS_TABLE, t => { + t.dropColumn('start_time_offset'); + t.dropColumn('end_time_offset'); + }); +} diff --git a/backend/src/model/south-connector.model.ts b/backend/src/model/south-connector.model.ts index 7061c98e85..fbfbb09ac1 100644 --- a/backend/src/model/south-connector.model.ts +++ b/backend/src/model/south-connector.model.ts @@ -18,7 +18,8 @@ export interface SouthConnectorItemEntityLight extends BaseEntity { export interface SouthItemGroupEntityLight extends BaseEntity { name: string; scanMode: ScanMode; - overlap: number | null; + startTimeOffset: number | null; + endTimeOffset: number | null; maxReadInterval: number | null; readDelay: number | null; recoveryStrategy: SouthHistoryRecoveryStrategy | null; @@ -28,7 +29,8 @@ export interface SouthItemGroupEntity extends BaseEntity { name: string; southId: string; scanMode: ScanMode; - overlap: number | null; + startTimeOffset: number | null; + endTimeOffset: number | null; maxReadInterval: number | null; readDelay: number | null; recoveryStrategy: SouthHistoryRecoveryStrategy | null; @@ -39,7 +41,8 @@ export interface SouthItemGroupCommand { name: string; southId: string; scanMode: ScanMode; - overlap: number | null; + startTimeOffset: number | null; + endTimeOffset: number | null; maxReadInterval: number | null; readDelay: number | null; recoveryStrategy: SouthHistoryRecoveryStrategy | null; @@ -64,6 +67,7 @@ export interface SouthConnectorItemEntity extends B syncWithGroup: boolean; maxReadInterval: number | null; readDelay: number | null; - overlap: number | null; + startTimeOffset: number | null; + endTimeOffset: number | null; recoveryStrategy: SouthHistoryRecoveryStrategy | null; } diff --git a/backend/src/repository/config/north-connector.repository.spec.ts b/backend/src/repository/config/north-connector.repository.spec.ts index 336eee856e..29193bba72 100644 --- a/backend/src/repository/config/north-connector.repository.spec.ts +++ b/backend/src/repository/config/north-connector.repository.spec.ts @@ -105,7 +105,8 @@ describe('NorthConnectorRepository', () => { name: 'Transformer Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }, @@ -142,7 +143,8 @@ describe('NorthConnectorRepository', () => { name: group.name, southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, maxReadInterval: 3600, readDelay: 200, items: [], diff --git a/backend/src/repository/config/north-connector.repository.ts b/backend/src/repository/config/north-connector.repository.ts index 3641cdfa66..037686c2a0 100644 --- a/backend/src/repository/config/north-connector.repository.ts +++ b/backend/src/repository/config/north-connector.repository.ts @@ -308,7 +308,7 @@ export default class NorthConnectorRepository { private findGroup(groupId: string): SouthItemGroupEntity { const query = `SELECT g.id, g.created_at, g.updated_at, g.created_by, g.updated_by, g.name, g.south_id, ` + - `g.scan_mode_id, g.overlap, g.max_read_interval, g.read_delay, s.id as scan_mode_id_full, ` + + `g.scan_mode_id, g.start_time_offset, g.end_time_offset, g.max_read_interval, g.read_delay, s.id as scan_mode_id_full, ` + `s.name as scan_mode_name, s.description as scan_mode_description, s.cron as scan_mode_cron, ` + `s.created_at as scan_mode_created_at, s.updated_at as scan_mode_updated_at, s.created_by as scan_mode_created_by, s.updated_by as scan_mode_updated_by ` + `FROM ${SOUTH_ITEM_GROUPS_TABLE} g JOIN ${SCAN_MODE_TABLE} s ON g.scan_mode_id = s.id WHERE g.id = ?;`; diff --git a/backend/src/repository/config/south-connector.repository.spec.ts b/backend/src/repository/config/south-connector.repository.spec.ts index e3acd8bdcf..821f265bd0 100644 --- a/backend/src/repository/config/south-connector.repository.spec.ts +++ b/backend/src/repository/config/south-connector.repository.spec.ts @@ -74,7 +74,8 @@ describe('SouthConnectorRepository', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null + startTimeOffset: null, + endTimeOffset: null }; newSouthConnector.items = [...testData.south.list[1].items, newItem]; repository.saveSouth(newSouthConnector); @@ -86,7 +87,7 @@ describe('SouthConnectorRepository', () => { it('should update a south connector item with non-null historian fields', () => { const connector: SouthConnectorEntity = JSON.parse(JSON.stringify(testData.south.list[0])); connector.items = connector.items.map((item, index) => - index === 0 ? { ...item, maxReadInterval: 3600, readDelay: 200, overlap: 100 } : item + index === 0 ? { ...item, maxReadInterval: 3600, readDelay: 200, startTimeOffset: 100, endTimeOffset: null } : item ); repository.saveSouth(connector); @@ -94,7 +95,7 @@ describe('SouthConnectorRepository', () => { const updatedItem = updatedConnector.items.find(item => item.id === connector.items[0].id)!; assert.strictEqual(updatedItem.maxReadInterval, 3600); assert.strictEqual(updatedItem.readDelay, 200); - assert.strictEqual(updatedItem.overlap, 100); + assert.strictEqual(updatedItem.startTimeOffset, 100); }); it('should delete a south connector', () => { @@ -198,7 +199,8 @@ describe('SouthConnectorRepository', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null + startTimeOffset: null, + endTimeOffset: null }; const itemsToSave: Array> = JSON.parse(JSON.stringify(testData.south.list[0].items)); itemsToSave.push(newItem); @@ -233,7 +235,8 @@ describe('SouthConnectorRepository', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null + startTimeOffset: null, + endTimeOffset: null }; itemsToSave.push(newItem); @@ -256,7 +259,8 @@ describe('SouthConnectorRepository', () => { name: 'Test Group For South', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }, @@ -274,7 +278,8 @@ describe('SouthConnectorRepository', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -300,7 +305,8 @@ describe('SouthConnectorRepository', () => { name: 'Test Group 2 For South', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: 10, + startTimeOffset: 10, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }, @@ -317,7 +323,8 @@ describe('SouthConnectorRepository', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -344,7 +351,8 @@ describe('SouthConnectorRepository', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 200, - overlap: 100, + startTimeOffset: 100, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -358,7 +366,7 @@ describe('SouthConnectorRepository', () => { assert.ok(savedItem); assert.strictEqual(savedItem.maxReadInterval, 3600); assert.strictEqual(savedItem.readDelay, 200); - assert.strictEqual(savedItem.overlap, 100); + assert.strictEqual(savedItem.startTimeOffset, 100); }); it('should save item with empty groups array', () => { @@ -372,7 +380,8 @@ describe('SouthConnectorRepository', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -395,7 +404,8 @@ describe('SouthConnectorRepository', () => { name: 'Move Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }, @@ -423,7 +433,8 @@ describe('SouthConnectorRepository', () => { name: 'Remove Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }, @@ -459,7 +470,8 @@ describe('SouthConnectorRepository', () => { id: 'temp_newgroup', name: 'Temp Created Group', scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, createdBy: '', @@ -478,7 +490,8 @@ describe('SouthConnectorRepository', () => { syncWithGroup: true, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -515,7 +528,8 @@ describe('SouthConnectorRepository', () => { name: 'Test Group For Find', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }, @@ -539,7 +553,8 @@ describe('SouthConnectorRepository', () => { name: 'Group To Update', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }, @@ -554,7 +569,8 @@ describe('SouthConnectorRepository', () => { ...group, name: 'Updated Group Name', scanMode: testData.scanMode.list[1], - overlap: 500, + startTimeOffset: 500, + endTimeOffset: null, maxReadInterval: 3600, readDelay: 200 }; @@ -567,7 +583,7 @@ describe('SouthConnectorRepository', () => { assert.ok(savedGroup, 'Group should still exist after saveSouth'); assert.strictEqual(savedGroup.name, 'Updated Group Name'); assert.strictEqual(savedGroup.scanMode.id, testData.scanMode.list[1].id); - assert.strictEqual(savedGroup.overlap, 500); + assert.strictEqual(savedGroup.startTimeOffset, 500); assert.strictEqual(savedGroup.maxReadInterval, 3600); assert.strictEqual(savedGroup.readDelay, 200); }); diff --git a/backend/src/repository/config/south-connector.repository.ts b/backend/src/repository/config/south-connector.repository.ts index f78ae8dbb0..5949b23e7b 100644 --- a/backend/src/repository/config/south-connector.repository.ts +++ b/backend/src/repository/config/south-connector.repository.ts @@ -28,11 +28,11 @@ const PAGE_SIZE = 50; */ const ITEM_JOIN_SELECT = `SELECT si.id, si.name, si.enabled, si.scan_mode_id, si.settings, si.sync_with_group, ` + - `si.max_read_interval, si.read_delay, si.overlap, si.recovery_strategy, si.created_by, si.updated_by, si.created_at, si.updated_at, ` + + `si.max_read_interval, si.read_delay, si.start_time_offset, si.end_time_offset, si.recovery_strategy, si.created_by, si.updated_by, si.created_at, si.updated_at, ` + `sm.id AS sm_id, sm.name AS sm_name, sm.description AS sm_description, sm.cron AS sm_cron, ` + `sm.created_by AS sm_created_by, sm.updated_by AS sm_updated_by, ` + `sm.created_at AS sm_created_at, sm.updated_at AS sm_updated_at, ` + - `g.id AS g_id, g.name AS g_name, g.overlap AS g_overlap, g.max_read_interval AS g_max_read_interval, ` + + `g.id AS g_id, g.name AS g_name, g.start_time_offset AS g_start_time_offset, g.end_time_offset AS g_end_time_offset, g.max_read_interval AS g_max_read_interval, ` + `g.read_delay AS g_read_delay, g.recovery_strategy AS g_recovery_strategy, g.created_by AS g_created_by, g.updated_by AS g_updated_by, ` + `g.created_at AS g_created_at, g.updated_at AS g_updated_at, ` + `gsm.id AS gsm_id, gsm.name AS gsm_name, gsm.description AS gsm_description, gsm.cron AS gsm_cron, ` + @@ -105,7 +105,8 @@ export default class SouthConnectorRepository { name: groupToCreate.name, southId: south.id, scanMode: groupToCreate.scanMode, - overlap: groupToCreate.overlap, + startTimeOffset: groupToCreate.startTimeOffset, + endTimeOffset: groupToCreate.endTimeOffset, maxReadInterval: groupToCreate.maxReadInterval, readDelay: groupToCreate.readDelay, recoveryStrategy: groupToCreate.recoveryStrategy @@ -122,13 +123,14 @@ export default class SouthConnectorRepository { // Update existing groups (name, scan mode, history settings may have changed via the connector edit form) const updateGroupStmt = this.database.prepare( - `UPDATE ${SOUTH_ITEM_GROUPS_TABLE} SET name = ?, scan_mode_id = ?, overlap = ?, max_read_interval = ?, read_delay = ?, recovery_strategy = ?, updated_by = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?;` + `UPDATE ${SOUTH_ITEM_GROUPS_TABLE} SET name = ?, scan_mode_id = ?, start_time_offset = ?, end_time_offset = ?, max_read_interval = ?, read_delay = ?, recovery_strategy = ?, updated_by = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?;` ); for (const group of south.groups.filter(g => g.id && !g.id.startsWith('temp_'))) { updateGroupStmt.run( group.name, group.scanMode?.id ?? null, - group.overlap ?? null, + group.startTimeOffset ?? null, + group.endTimeOffset ?? null, group.maxReadInterval ?? null, group.readDelay ?? null, group.recoveryStrategy ?? null, @@ -165,10 +167,10 @@ export default class SouthConnectorRepository { south.items.filter(item => item.id).map(item => item.id) ); const insert = this.database.prepare( - `INSERT INTO ${SOUTH_ITEMS_TABLE} (id, name, enabled, connector_id, scan_mode_id, settings, sync_with_group, max_read_interval, read_delay, overlap, recovery_strategy, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));` + `INSERT INTO ${SOUTH_ITEMS_TABLE} (id, name, enabled, connector_id, scan_mode_id, settings, sync_with_group, max_read_interval, read_delay, start_time_offset, end_time_offset, recovery_strategy, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));` ); const update = this.database.prepare( - `UPDATE ${SOUTH_ITEMS_TABLE} SET name = ?, enabled = ?, scan_mode_id = ?, settings = ?, sync_with_group = ?, max_read_interval = ?, read_delay = ?, overlap = ?, recovery_strategy = ?, updated_by = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?;` + `UPDATE ${SOUTH_ITEMS_TABLE} SET name = ?, enabled = ?, scan_mode_id = ?, settings = ?, sync_with_group = ?, max_read_interval = ?, read_delay = ?, start_time_offset = ?, end_time_offset = ?, recovery_strategy = ?, updated_by = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?;` ); const insertGroup = this.database.prepare(`INSERT INTO ${GROUP_ITEMS_TABLE} (group_id, item_id) VALUES (?, ?);`); const deleteGroups = this.database.prepare(`DELETE FROM ${GROUP_ITEMS_TABLE} WHERE item_id = ?;`); @@ -186,7 +188,8 @@ export default class SouthConnectorRepository { +item.syncWithGroup, item.maxReadInterval ?? null, item.readDelay ?? null, - item.overlap ?? null, + item.startTimeOffset ?? null, + item.endTimeOffset ?? null, item.recoveryStrategy ?? null, item.createdBy, item.updatedBy @@ -200,7 +203,8 @@ export default class SouthConnectorRepository { +item.syncWithGroup, item.maxReadInterval ?? null, item.readDelay ?? null, - item.overlap ?? null, + item.startTimeOffset ?? null, + item.endTimeOffset ?? null, item.recoveryStrategy ?? null, item.updatedBy, item.id @@ -350,8 +354,8 @@ export default class SouthConnectorRepository { if (!southItem.id) { southItem.id = generateRandomId(6); const insertQuery = - `INSERT INTO ${SOUTH_ITEMS_TABLE} (id, name, enabled, connector_id, scan_mode_id, settings, sync_with_group, max_read_interval, read_delay, overlap, recovery_strategy, created_by, updated_by, created_at, updated_at) ` + - `VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));`; + `INSERT INTO ${SOUTH_ITEMS_TABLE} (id, name, enabled, connector_id, scan_mode_id, settings, sync_with_group, max_read_interval, read_delay, start_time_offset, end_time_offset, recovery_strategy, created_by, updated_by, created_at, updated_at) ` + + `VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));`; this.database .prepare(insertQuery) .run( @@ -364,13 +368,14 @@ export default class SouthConnectorRepository { +southItem.syncWithGroup, southItem.maxReadInterval ?? null, southItem.readDelay ?? null, - southItem.overlap ?? null, + southItem.startTimeOffset ?? null, + southItem.endTimeOffset ?? null, southItem.recoveryStrategy ?? null, southItem.createdBy, southItem.updatedBy ); } else { - const query = `UPDATE ${SOUTH_ITEMS_TABLE} SET name = ?, enabled = ?, scan_mode_id = ?, settings = ?, sync_with_group = ?, max_read_interval = ?, read_delay = ?, overlap = ?, recovery_strategy = ?, updated_by = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?;`; + const query = `UPDATE ${SOUTH_ITEMS_TABLE} SET name = ?, enabled = ?, scan_mode_id = ?, settings = ?, sync_with_group = ?, max_read_interval = ?, read_delay = ?, start_time_offset = ?, end_time_offset = ?, recovery_strategy = ?, updated_by = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?;`; this.database .prepare(query) .run( @@ -381,7 +386,8 @@ export default class SouthConnectorRepository { +southItem.syncWithGroup, southItem.maxReadInterval ?? null, southItem.readDelay ?? null, - southItem.overlap ?? null, + southItem.startTimeOffset ?? null, + southItem.endTimeOffset ?? null, southItem.recoveryStrategy ?? null, southItem.updatedBy, southItem.id @@ -481,7 +487,7 @@ export default class SouthConnectorRepository { findGroupBySouthId(southId: string): Array { const query = `SELECT g.id, g.created_at, g.updated_at, g.created_by, g.updated_by, g.name, ` + - `g.scan_mode_id, g.overlap, g.max_read_interval, g.read_delay, g.recovery_strategy, s.id as scan_mode_id_full, ` + + `g.scan_mode_id, g.start_time_offset, g.end_time_offset, g.max_read_interval, g.read_delay, g.recovery_strategy, s.id as scan_mode_id_full, ` + `s.name as scan_mode_name, s.description as scan_mode_description, s.cron as scan_mode_cron, ` + `s.created_at as scan_mode_created_at, s.updated_at as scan_mode_updated_at, s.created_by as scan_mode_created_by, s.updated_by as scan_mode_updated_by ` + `FROM ${SOUTH_ITEM_GROUPS_TABLE} g JOIN ${SCAN_MODE_TABLE} s ON g.scan_mode_id = s.id WHERE g.south_id = ? ORDER BY g.name;`; @@ -541,7 +547,8 @@ export function toItemEntityFromJoinedRow(row: Record): id: result.id as string, name: result.name as string, scanMode, - overlap: result.overlap !== null && result.overlap !== undefined ? (result.overlap as number) : null, + startTimeOffset: + result.start_time_offset !== null && result.start_time_offset !== undefined ? (result.start_time_offset as number) : null, + endTimeOffset: result.end_time_offset !== null && result.end_time_offset !== undefined ? (result.end_time_offset as number) : null, maxReadInterval: result.max_read_interval !== null && result.max_read_interval !== undefined ? (result.max_read_interval as number) : null, readDelay: (result.read_delay as number) || 0, diff --git a/backend/src/repository/config/south-item-group.repository.spec.ts b/backend/src/repository/config/south-item-group.repository.spec.ts index f524ed6ad0..5397512304 100644 --- a/backend/src/repository/config/south-item-group.repository.spec.ts +++ b/backend/src/repository/config/south-item-group.repository.spec.ts @@ -32,7 +32,8 @@ describe('South Item Group Repository', () => { name: 'Test Group 1', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }; @@ -44,7 +45,7 @@ describe('South Item Group Repository', () => { assert.strictEqual(found.name, 'Test Group 1'); assert.strictEqual(found.southId, testData.south.list[0].id); assert.strictEqual(found.scanMode.id, testData.scanMode.list[0].id); - assert.strictEqual(found.overlap, null); + assert.strictEqual(found.startTimeOffset, null); }); it('should return null when finding non-existing group', () => { @@ -57,7 +58,8 @@ describe('South Item Group Repository', () => { name: 'Group A', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: 10, + startTimeOffset: 10, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }; @@ -65,7 +67,8 @@ describe('South Item Group Repository', () => { name: 'Group B', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[1], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }; @@ -90,7 +93,8 @@ describe('South Item Group Repository', () => { name: 'Find By Name Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }; @@ -113,7 +117,8 @@ describe('South Item Group Repository', () => { name: 'Group For Different South', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }; @@ -128,7 +133,8 @@ describe('South Item Group Repository', () => { name: 'New Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: 5, + startTimeOffset: 5, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }; @@ -138,7 +144,7 @@ describe('South Item Group Repository', () => { assert.strictEqual(created.name, 'New Group'); assert.strictEqual(created.southId, testData.south.list[0].id); assert.strictEqual(created.scanMode.id, testData.scanMode.list[0].id); - assert.strictEqual(created.overlap, 5); + assert.strictEqual(created.startTimeOffset, 5); }); it('should create a group with custom id', () => { @@ -147,7 +153,8 @@ describe('South Item Group Repository', () => { name: 'Custom Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }; @@ -168,7 +175,8 @@ describe('South Item Group Repository', () => { name: 'Group That Will Fail', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }; @@ -186,7 +194,8 @@ describe('South Item Group Repository', () => { name: 'Original Name', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }; @@ -196,7 +205,8 @@ describe('South Item Group Repository', () => { const updateCommand: Omit = { name: 'Updated Name', scanMode: testData.scanMode.list[1], - overlap: 15, + startTimeOffset: 15, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }; @@ -207,16 +217,17 @@ describe('South Item Group Repository', () => { assert.ok(updated); assert.strictEqual(updated.name, 'Updated Name'); assert.strictEqual(updated.scanMode.id, testData.scanMode.list[1].id); - assert.strictEqual(updated.overlap, 15); + assert.strictEqual(updated.startTimeOffset, 15); assert.strictEqual(updated.southId, testData.south.list[0].id); }); - it('should update a group with null overlap', () => { + it('should update a group with null startTimeOffset', () => { const groupToCreate: SouthItemGroupCommand = { - name: 'Group With Overlap', + name: 'Group With StartTimeOffset', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: 10, + startTimeOffset: 10, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }; @@ -226,7 +237,8 @@ describe('South Item Group Repository', () => { const updateCommand: Omit = { name: 'Updated Name Null', scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }; @@ -235,7 +247,7 @@ describe('South Item Group Repository', () => { const updated = repository.findById(created.id); assert.ok(updated); - assert.strictEqual(updated.overlap, null); + assert.strictEqual(updated.startTimeOffset, null); }); it('should delete a group', () => { @@ -243,7 +255,8 @@ describe('South Item Group Repository', () => { name: 'Group To Delete', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }; @@ -263,7 +276,8 @@ describe('South Item Group Repository', () => { name: 'Test Group', south_id: 'southId1', scan_mode_id: testData.scanMode.list[0].id, - overlap: 20, + start_time_offset: 20, + end_time_offset: 0, max_read_interval: 3600, read_delay: 200, scan_mode_id_full: testData.scanMode.list[0].id, @@ -287,7 +301,7 @@ describe('South Item Group Repository', () => { assert.strictEqual(converted.southId, 'southId1'); assert.strictEqual(converted.scanMode.id, testData.scanMode.list[0].id); assert.strictEqual(converted.scanMode.name, testData.scanMode.list[0].name); - assert.strictEqual(converted.overlap, 20); + assert.strictEqual(converted.startTimeOffset, 20); assert.strictEqual(converted.maxReadInterval, 3600); assert.strictEqual(converted.readDelay, 200); assert.deepStrictEqual(converted.items, [ @@ -303,7 +317,7 @@ describe('South Item Group Repository', () => { ]); }); - it('should convert database result with null overlap', () => { + it('should convert database result with null start_time_offset', () => { const dbResult: Record = { id: 'testId2', created_at: '2024-01-01 00:00:00', @@ -311,7 +325,8 @@ describe('South Item Group Repository', () => { name: 'Test Group 2', south_id: 'southId1', scan_mode_id: testData.scanMode.list[0].id, - overlap: null, + start_time_offset: null, + end_time_offset: null, scan_mode_id_full: testData.scanMode.list[0].id, scan_mode_name: testData.scanMode.list[0].name, scan_mode_description: testData.scanMode.list[0].description, @@ -319,10 +334,10 @@ describe('South Item Group Repository', () => { }; const converted = toSouthItemGroup(dbResult as Record, []); - assert.strictEqual(converted.overlap, null); + assert.strictEqual(converted.startTimeOffset, null); }); - it('should convert database result with undefined overlap', () => { + it('should convert database result with undefined start_time_offset', () => { const dbResult: Record = { id: 'testId3', created_at: '2024-01-01 00:00:00', @@ -330,7 +345,8 @@ describe('South Item Group Repository', () => { name: 'Test Group 3', south_id: 'southId1', scan_mode_id: testData.scanMode.list[0].id, - overlap: undefined, + start_time_offset: undefined, + end_time_offset: undefined, scan_mode_id_full: testData.scanMode.list[0].id, scan_mode_name: testData.scanMode.list[0].name, scan_mode_description: testData.scanMode.list[0].description, @@ -338,7 +354,7 @@ describe('South Item Group Repository', () => { }; const converted = toSouthItemGroup(dbResult as Record, []); - assert.strictEqual(converted.overlap, null); + assert.strictEqual(converted.startTimeOffset, null); }); it('should convert database result with null max_read_interval', () => { @@ -349,7 +365,8 @@ describe('South Item Group Repository', () => { name: 'Test Group 4', south_id: 'southId1', scan_mode_id: testData.scanMode.list[0].id, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, max_read_interval: null, read_delay: 0, scan_mode_id_full: testData.scanMode.list[0].id, diff --git a/backend/src/repository/config/south-item-group.repository.ts b/backend/src/repository/config/south-item-group.repository.ts index 9f68b4179a..0de4869023 100644 --- a/backend/src/repository/config/south-item-group.repository.ts +++ b/backend/src/repository/config/south-item-group.repository.ts @@ -18,7 +18,7 @@ export default class SouthItemGroupRepository { findById(id: string): SouthItemGroupEntity | null { const query = `SELECT g.id, g.created_at, g.updated_at, g.created_by, g.updated_by, g.name, g.south_id, ` + - `g.scan_mode_id, g.overlap, g.max_read_interval, g.read_delay, g.recovery_strategy, s.id as scan_mode_id_full, ` + + `g.scan_mode_id, g.start_time_offset, g.end_time_offset, g.max_read_interval, g.read_delay, g.recovery_strategy, s.id as scan_mode_id_full, ` + `s.name as scan_mode_name, s.description as scan_mode_description, s.cron as scan_mode_cron, ` + `s.created_at as scan_mode_created_at, s.updated_at as scan_mode_updated_at, s.created_by as scan_mode_created_by, s.updated_by as scan_mode_updated_by ` + `FROM ${SOUTH_ITEM_GROUPS_TABLE} g JOIN ${SCAN_MODE_TABLE} s ON g.scan_mode_id = s.id WHERE g.id = ?;`; @@ -29,7 +29,7 @@ export default class SouthItemGroupRepository { findBySouthId(southId: string): Array { const query = `SELECT g.id, g.created_at, g.updated_at, g.created_by, g.updated_by, g.name, g.south_id, ` + - `g.scan_mode_id, g.overlap, g.max_read_interval, g.read_delay, g.recovery_strategy, s.id as scan_mode_id_full, ` + + `g.scan_mode_id, g.start_time_offset, g.end_time_offset, g.max_read_interval, g.read_delay, g.recovery_strategy, s.id as scan_mode_id_full, ` + `s.name as scan_mode_name, s.description as scan_mode_description, s.cron as scan_mode_cron, ` + `s.created_at as scan_mode_created_at, s.updated_at as scan_mode_updated_at, s.created_by as scan_mode_created_by, s.updated_by as scan_mode_updated_by ` + `FROM ${SOUTH_ITEM_GROUPS_TABLE} g JOIN scan_modes s ON g.scan_mode_id = s.id WHERE g.south_id = ? ORDER BY g.name;`; @@ -42,7 +42,7 @@ export default class SouthItemGroupRepository { findByNameAndSouthId(name: string, southId: string): SouthItemGroupEntity | null { const query = `SELECT g.id, g.created_at, g.updated_at, g.created_by, g.updated_by, g.name, g.south_id, ` + - `g.scan_mode_id, g.overlap, g.max_read_interval, g.read_delay, g.recovery_strategy, s.id as scan_mode_id_full, ` + + `g.scan_mode_id, g.start_time_offset, g.end_time_offset, g.max_read_interval, g.read_delay, g.recovery_strategy, s.id as scan_mode_id_full, ` + `s.name as scan_mode_name, s.description as scan_mode_description, s.cron as scan_mode_cron, ` + `s.created_at as scan_mode_created_at, s.updated_at as scan_mode_updated_at, s.created_by as scan_mode_created_by, s.updated_by as scan_mode_updated_by ` + `FROM ${SOUTH_ITEM_GROUPS_TABLE} g JOIN ${SCAN_MODE_TABLE} s ON g.scan_mode_id = s.id WHERE g.name = ? AND g.south_id = ?;`; @@ -51,7 +51,7 @@ export default class SouthItemGroupRepository { } create(command: SouthItemGroupCommand, createdBy: string, id = generateRandomId(6)): SouthItemGroupEntity { - const insertQuery = `INSERT INTO ${SOUTH_ITEM_GROUPS_TABLE} (id, name, south_id, scan_mode_id, overlap, max_read_interval, read_delay, recovery_strategy, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));`; + const insertQuery = `INSERT INTO ${SOUTH_ITEM_GROUPS_TABLE} (id, name, south_id, scan_mode_id, start_time_offset, end_time_offset, max_read_interval, read_delay, recovery_strategy, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));`; this.database .prepare(insertQuery) .run( @@ -59,7 +59,8 @@ export default class SouthItemGroupRepository { command.name, command.southId, command.scanMode.id, - command.overlap ?? null, + command.startTimeOffset ?? null, + command.endTimeOffset ?? null, command.maxReadInterval ?? null, command.readDelay, command.recoveryStrategy ?? null, @@ -75,14 +76,15 @@ export default class SouthItemGroupRepository { update(id: string, command: Omit, updatedBy: string): void { const query = `UPDATE ${SOUTH_ITEM_GROUPS_TABLE} - SET name = ?, scan_mode_id = ?, overlap = ?, max_read_interval = ?, read_delay = ?, recovery_strategy = ?, updated_by = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + SET name = ?, scan_mode_id = ?, start_time_offset = ?, end_time_offset = ?, max_read_interval = ?, read_delay = ?, recovery_strategy = ?, updated_by = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?;`; this.database .prepare(query) .run( command.name, command.scanMode.id, - command.overlap ?? null, + command.startTimeOffset ?? null, + command.endTimeOffset ?? null, command.maxReadInterval ?? null, command.readDelay, command.recoveryStrategy ?? null, @@ -121,7 +123,9 @@ export const toSouthItemGroup = ( name: result.name as string, southId: result.south_id as string, scanMode, - overlap: result.overlap !== null && result.overlap !== undefined ? (result.overlap as number) : null, + startTimeOffset: + result.start_time_offset !== null && result.start_time_offset !== undefined ? (result.start_time_offset as number) : null, + endTimeOffset: result.end_time_offset !== null && result.end_time_offset !== undefined ? (result.end_time_offset as number) : null, maxReadInterval: result.max_read_interval !== null && result.max_read_interval !== undefined ? (result.max_read_interval as number) : null, readDelay: (result.read_delay as number) || 0, diff --git a/backend/src/service/north.service.spec.ts b/backend/src/service/north.service.spec.ts index b95b6cf89b..f4894c48d6 100644 --- a/backend/src/service/north.service.spec.ts +++ b/backend/src/service/north.service.spec.ts @@ -218,7 +218,8 @@ describe('North Service', () => { name: '', southId: firstTransformerSource.southId, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, createdBy: '', @@ -322,7 +323,8 @@ describe('North Service', () => { name: '', southId: firstTransformerSource.southId, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, createdBy: '', @@ -655,7 +657,8 @@ describe('North Service', () => { name: 'Group 1', southId: northEntity.transformers[0].source.south.id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, createdBy: '', diff --git a/backend/src/service/oia/oianalytics-command.service.ts b/backend/src/service/oia/oianalytics-command.service.ts index b04ce06f3b..573eb18c95 100644 --- a/backend/src/service/oia/oianalytics-command.service.ts +++ b/backend/src/service/oia/oianalytics-command.service.ts @@ -1022,7 +1022,8 @@ export default class OIAnalyticsCommandService { syncWithGroup: item.syncWithGroup, maxReadInterval: item.maxReadInterval, readDelay: item.readDelay, - overlap: item.overlap + startTimeOffset: item.startTimeOffset, + endTimeOffset: item.endTimeOffset })) )) as typeof command.commandContent.items; } diff --git a/backend/src/service/oia/oianalytics-message.service.ts b/backend/src/service/oia/oianalytics-message.service.ts index 3a296595f1..dd4abd95ef 100644 --- a/backend/src/service/oia/oianalytics-message.service.ts +++ b/backend/src/service/oia/oianalytics-message.service.ts @@ -525,7 +525,8 @@ export default class OIAnalyticsMessageService { syncWithGroup: item.syncWithGroup, maxReadInterval: item.maxReadInterval, readDelay: item.readDelay, - overlap: item.overlap, + startTimeOffset: item.startTimeOffset, + endTimeOffset: item.endTimeOffset, recoveryStrategy: item.recoveryStrategy })), groups: south.groups.map(group => ({ @@ -535,7 +536,8 @@ export default class OIAnalyticsMessageService { scanModeId: group.scanMode.id }, historySettings: { - overlap: group.overlap, + startTimeOffset: group.startTimeOffset, + endTimeOffset: group.endTimeOffset, maxReadInterval: group.maxReadInterval, readDelay: group.readDelay, recoveryStrategy: group.recoveryStrategy diff --git a/backend/src/service/south-connector-dto.utils.ts b/backend/src/service/south-connector-dto.utils.ts index e384fa2c9f..194d8732dd 100644 --- a/backend/src/service/south-connector-dto.utils.ts +++ b/backend/src/service/south-connector-dto.utils.ts @@ -19,7 +19,8 @@ export const toSouthItemGroupDTO = (entity: SouthItemGroupEntityLight, getUserIn scanMode: toScanModeDTO(entity.scanMode, getUserInfo) }, historySettings: { - overlap: entity.overlap, + startTimeOffset: entity.startTimeOffset, + endTimeOffset: entity.endTimeOffset, maxReadInterval: entity.maxReadInterval, readDelay: entity.readDelay, recoveryStrategy: entity.recoveryStrategy @@ -50,7 +51,8 @@ export const toSouthConnectorItemDTO = ( syncWithGroup: entity.syncWithGroup, maxReadInterval: entity.maxReadInterval, readDelay: entity.readDelay, - overlap: entity.overlap, + startTimeOffset: entity.startTimeOffset, + endTimeOffset: entity.endTimeOffset, recoveryStrategy: entity.recoveryStrategy }; }; diff --git a/backend/src/service/south.service.spec.ts b/backend/src/service/south.service.spec.ts index b029628bb1..a93250fa89 100644 --- a/backend/src/service/south.service.spec.ts +++ b/backend/src/service/south.service.spec.ts @@ -215,7 +215,8 @@ describe('South Service', () => { southId: 'newSouthId', scanMode: testData.scanMode.list[0], items: [], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, createdBy: '', @@ -259,7 +260,8 @@ describe('South Service', () => { southId: 'newSouthId', scanMode: testData.scanMode.list[0], items: [], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, createdBy: '', @@ -595,7 +597,8 @@ describe('South Service', () => { name: 'Test Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, items: [], readDelay: 0, @@ -663,7 +666,8 @@ describe('South Service', () => { name: 'Update Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, items: [], readDelay: 0, @@ -899,7 +903,8 @@ describe('South Service', () => { }, group: null, maxReadInterval: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, readDelay: 0, recoveryStrategy: null, syncWithGroup: true @@ -1014,7 +1019,8 @@ describe('South Service', () => { }, group: null, maxReadInterval: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, readDelay: 0, recoveryStrategy: null, syncWithGroup: true @@ -1068,7 +1074,8 @@ describe('South Service', () => { }, group: { id: '', standardSettings: { name: 'Test Group' } }, maxReadInterval: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, readDelay: 0, recoveryStrategy: null, syncWithGroup: true @@ -1195,7 +1202,8 @@ describe('South Service', () => { name: 'New Import Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, items: [], readDelay: 0, @@ -1234,7 +1242,8 @@ describe('South Service', () => { name: 'Existing Import Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, items: [], readDelay: 0, @@ -1328,7 +1337,8 @@ describe('South Service', () => { name: 'Group 1', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: 5, + startTimeOffset: 5, + endTimeOffset: null, maxReadInterval: 3600, readDelay: 200, recoveryStrategy: null, @@ -1350,9 +1360,10 @@ describe('South Service', () => { scanMode: toScanModeDTO(group.scanMode, getUserInfo) }, historySettings: { + startTimeOffset: group.startTimeOffset, + endTimeOffset: group.endTimeOffset, maxReadInterval: group.maxReadInterval, readDelay: group.readDelay, - overlap: group.overlap, recoveryStrategy: group.recoveryStrategy }, createdBy: getUserInfo(group.createdBy), @@ -1370,7 +1381,8 @@ describe('South Service', () => { name: 'Group 1', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1384,7 +1396,8 @@ describe('South Service', () => { name: 'Group 2', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[1], - overlap: 10, + startTimeOffset: 10, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1410,7 +1423,8 @@ describe('South Service', () => { name: 'Group 1', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1444,7 +1458,8 @@ describe('South Service', () => { name: 'Group 1', southId: 'differentSouthId', scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1470,7 +1485,8 @@ describe('South Service', () => { scanModeId: testData.scanMode.list[0].id }, historySettings: { - overlap: 5, + startTimeOffset: 5, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 } @@ -1481,7 +1497,8 @@ describe('South Service', () => { name: 'New Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: 5, + startTimeOffset: 5, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1508,7 +1525,8 @@ describe('South Service', () => { scanModeId: testData.scanMode.list[0].id }, historySettings: { - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 } @@ -1519,7 +1537,8 @@ describe('South Service', () => { name: 'Group With Null Overlap', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1533,9 +1552,9 @@ describe('South Service', () => { southItemGroupRepository.create.mock.mockImplementation(() => createdGroup); const result = service.createGroup(testData.south.list[0].id, command, 'testUser'); - assert.strictEqual(result.overlap, null); + assert.strictEqual(result.startTimeOffset, null); const createCall = southItemGroupRepository.create.mock.calls[0]; - assert.strictEqual((createCall.arguments[0] as { overlap: unknown }).overlap, null); + assert.strictEqual((createCall.arguments[0] as { overlap: unknown }).startTimeOffset, null); assert.strictEqual(createCall.arguments[1], 'testUser'); }); @@ -1547,7 +1566,8 @@ describe('South Service', () => { scanModeId: testData.scanMode.list[0].id }, historySettings: { - overlap: 5, + startTimeOffset: 5, + endTimeOffset: null, maxReadInterval: 3600, readDelay: 200 } @@ -1558,7 +1578,8 @@ describe('South Service', () => { name: 'Group With Throttling', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: 5, + startTimeOffset: 5, + endTimeOffset: null, maxReadInterval: 3600, readDelay: 200, items: [], @@ -1590,7 +1611,8 @@ describe('South Service', () => { scanModeId: testData.scanMode.list[0].id }, historySettings: { - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 } @@ -1601,7 +1623,8 @@ describe('South Service', () => { name: 'Existing Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1627,7 +1650,8 @@ describe('South Service', () => { scanModeId: testData.scanMode.list[1].id }, historySettings: { - overlap: 15, + startTimeOffset: 15, + endTimeOffset: null, maxReadInterval: 3600, readDelay: 200 } @@ -1638,7 +1662,8 @@ describe('South Service', () => { name: 'Original Name', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1652,7 +1677,8 @@ describe('South Service', () => { ...existingGroup, name: 'Updated Group', scanMode: testData.scanMode.list[1], - overlap: 15, + startTimeOffset: 15, + endTimeOffset: null, maxReadInterval: 3600, readDelay: 200 }; @@ -1683,7 +1709,8 @@ describe('South Service', () => { scanModeId: testData.scanMode.list[0].id }, historySettings: { - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 } @@ -1694,7 +1721,8 @@ describe('South Service', () => { name: 'Original Name', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1708,7 +1736,8 @@ describe('South Service', () => { ...existingGroup, name: 'Updated Name Only', scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 }; @@ -1744,7 +1773,8 @@ describe('South Service', () => { scanModeId: testData.scanMode.list[0].id }, historySettings: { - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 } @@ -1766,7 +1796,8 @@ describe('South Service', () => { scanModeId: testData.scanMode.list[0].id }, historySettings: { - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 } @@ -1777,7 +1808,8 @@ describe('South Service', () => { name: 'Group From Other South', southId: 'differentSouthId', scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1803,7 +1835,8 @@ describe('South Service', () => { scanModeId: testData.scanMode.list[0].id }, historySettings: { - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 } @@ -1814,7 +1847,8 @@ describe('South Service', () => { name: 'Original Name', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1846,7 +1880,8 @@ describe('South Service', () => { scanModeId: testData.scanMode.list[0].id }, historySettings: { - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 } @@ -1857,7 +1892,8 @@ describe('South Service', () => { name: 'Original Name', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1872,7 +1908,8 @@ describe('South Service', () => { name: 'Existing Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1897,7 +1934,8 @@ describe('South Service', () => { name: 'Group To Delete', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1930,7 +1968,8 @@ describe('South Service', () => { name: 'Group From Other South', southId: 'differentSouthId', scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1954,7 +1993,8 @@ describe('South Service', () => { name: 'Target Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -1983,7 +2023,8 @@ describe('South Service', () => { name: 'Group From Other South', southId: 'differentSouthId', scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -2040,7 +2081,8 @@ describe('South Service', () => { name: 'Test Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: 10, + startTimeOffset: 10, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -2054,7 +2096,7 @@ describe('South Service', () => { assert.strictEqual(dto.id, 'group1'); assert.strictEqual(dto.standardSettings.name, 'Test Group'); assert.strictEqual(dto.standardSettings.scanMode.id, testData.scanMode.list[0].id); - assert.strictEqual(dto.historySettings.overlap, 10); + assert.strictEqual(dto.historySettings.startTimeOffset, 10); }); it('should convert SouthItemGroupEntity with null overlap to DTO', () => { @@ -2063,7 +2105,8 @@ describe('South Service', () => { name: 'Test Group 2', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -2074,7 +2117,7 @@ describe('South Service', () => { }; const dto = toSouthItemGroupDTO(entity, id => ({ id, friendlyName: id })); - assert.strictEqual(dto.historySettings.overlap, null); + assert.strictEqual(dto.historySettings.startTimeOffset, null); }); it('should convert item with group to DTO using toSouthConnectorItemCommandDTO', () => { @@ -2083,7 +2126,8 @@ describe('South Service', () => { name: 'Test Group', southId: testData.south.list[0].id, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [], @@ -2105,7 +2149,8 @@ describe('South Service', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -2129,7 +2174,8 @@ describe('South Service', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -2171,7 +2217,8 @@ describe('South Service', () => { syncWithGroup: true, maxReadInterval: 3600, readDelay: 200, - overlap: 100 + startTimeOffset: 100, + endTimeOffset: null }; await copySouthItemCommandToSouthItemEntity( @@ -2187,7 +2234,7 @@ describe('South Service', () => { assert.strictEqual(southItemEntity.syncWithGroup, true); assert.strictEqual(southItemEntity.maxReadInterval, 3600); assert.strictEqual(southItemEntity.readDelay, 200); - assert.strictEqual(southItemEntity.overlap, 100); + assert.strictEqual(southItemEntity.startTimeOffset, 100); }); it('should set syncWithGroup to false when command.syncWithGroup is explicitly false', async () => { diff --git a/backend/src/service/south.service.ts b/backend/src/service/south.service.ts index 307eb82fb4..0bde1cbd32 100644 --- a/backend/src/service/south.service.ts +++ b/backend/src/service/south.service.ts @@ -285,7 +285,8 @@ export default class SouthService { settings: await encryptionService.encryptConnectorSecrets(itemSettings, null, itemSettingsManifest), maxReadInterval: null, readDelay: 0, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, recoveryStrategy: null, createdBy: '', updatedBy: '', @@ -529,7 +530,8 @@ export default class SouthService { syncWithGroup: stringToBoolean(data.syncWithGroup), maxReadInterval: Number(data.maxReadInterval || '0'), readDelay: Number(data.readDelay || '0'), - overlap: Number(data.overlap || '0'), + startTimeOffset: Number(data.startTimeOffset || '0'), + endTimeOffset: data.endTimeOffset !== undefined && data.endTimeOffset !== '' ? Number(data.endTimeOffset) : null, recoveryStrategy: null } as SouthConnectorItemDTO; @@ -613,7 +615,8 @@ export default class SouthService { name: groupName, southId: southConnector.id, scanMode, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, recoveryStrategy: null @@ -683,7 +686,8 @@ export default class SouthService { name: command.standardSettings.name, southId: southConnector.id, scanMode, - overlap: command.historySettings.overlap, + startTimeOffset: command.historySettings.startTimeOffset, + endTimeOffset: command.historySettings.endTimeOffset, maxReadInterval: command.historySettings.maxReadInterval, readDelay: command.historySettings.readDelay, recoveryStrategy: command.historySettings.recoveryStrategy ?? null @@ -710,14 +714,16 @@ export default class SouthService { throw new OIBusValidationError(`A group with name "${command.standardSettings.name}" already exists for this south connector`); } - const overlap = command.historySettings.overlap != null ? command.historySettings.overlap : null; + const startTimeOffset = command.historySettings.startTimeOffset != null ? command.historySettings.startTimeOffset : null; + const endTimeOffset = command.historySettings.endTimeOffset != null ? command.historySettings.endTimeOffset : null; const maxReadInterval = command.historySettings.maxReadInterval != null ? command.historySettings.maxReadInterval : null; const readDelay = command.historySettings.readDelay != null ? command.historySettings.readDelay : 0; const recoveryStrategy = command.historySettings.recoveryStrategy ?? null; const groupEntity: Omit = { name: command.standardSettings.name, scanMode, - overlap, + startTimeOffset, + endTimeOffset, maxReadInterval, readDelay, recoveryStrategy @@ -836,7 +842,8 @@ const copyGroupCommandToGroupEntity = ( ) => { groupEntity.id = command.id!; groupEntity.name = command.standardSettings.name; - groupEntity.overlap = command.historySettings.overlap; + groupEntity.startTimeOffset = command.historySettings.startTimeOffset; + groupEntity.endTimeOffset = command.historySettings.endTimeOffset; groupEntity.maxReadInterval = command.historySettings.maxReadInterval; groupEntity.readDelay = command.historySettings.readDelay; groupEntity.recoveryStrategy = command.historySettings.recoveryStrategy ?? null; @@ -867,7 +874,8 @@ export const copySouthItemCommandToSouthItemEntity = async ( southItemEntity.syncWithGroup = command.syncWithGroup ?? false; southItemEntity.maxReadInterval = command.maxReadInterval != null ? command.maxReadInterval : null; southItemEntity.readDelay = command.readDelay != null ? command.readDelay : null; - southItemEntity.overlap = command.overlap != null ? command.overlap : null; + southItemEntity.startTimeOffset = command.startTimeOffset != null ? command.startTimeOffset : null; + southItemEntity.endTimeOffset = command.endTimeOffset != null ? command.endTimeOffset : null; southItemEntity.recoveryStrategy = command.recoveryStrategy ?? null; southItemEntity.scanMode = command.scanModeId || command.scanModeName ? checkScanMode(scanModes, command.scanModeId, command.scanModeName) : null; @@ -913,9 +921,10 @@ export const toSouthConnectorDTO = ( scanMode: toScanModeDTO(group.scanMode, getUserInfo) }, historySettings: { + startTimeOffset: group.startTimeOffset, + endTimeOffset: group.endTimeOffset, maxReadInterval: group.maxReadInterval, readDelay: group.readDelay, - overlap: group.overlap, recoveryStrategy: group.recoveryStrategy } })), diff --git a/backend/src/service/utils.spec.ts b/backend/src/service/utils.spec.ts index 7fd799a34d..4505452a8b 100644 --- a/backend/src/service/utils.spec.ts +++ b/backend/src/service/utils.spec.ts @@ -1507,7 +1507,8 @@ describe('Service utils', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 200, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, settings: { address: '40001' } } as unknown as SouthConnectorItemDTO; const result = utils.itemToFlattenedCSV([baseItem], ',', { attributes: [] } as unknown as OIBusObjectAttribute); @@ -1527,7 +1528,8 @@ describe('Service utils', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 200, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, settings: { address: '40001' } } as unknown as SouthConnectorItemDTO; const result = utils.itemToFlattenedCSV([baseItem], ',', { attributes: [] } as unknown as OIBusObjectAttribute); @@ -1560,18 +1562,20 @@ describe('Service utils', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 200, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, settings: { address: '40001' } } as unknown as SouthConnectorItemDTO; const itemWithNulls = { ...baseItem, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, group: { id: 'g1', standardSettings: { name: 'Group A', scanMode: { id: 'sm1', name: 'Every 10s', cron: '' } }, - historySettings: { maxReadInterval: 9999, readDelay: 500, overlap: 10 } + historySettings: { maxReadInterval: 9999, readDelay: 500, startTimeOffset: 10, endTimeOffset: null } } } as unknown as SouthConnectorItemDTO; utils.itemToFlattenedCSV([itemWithNulls], ',', { attributes: [] } as unknown as OIBusObjectAttribute); @@ -1944,7 +1948,8 @@ describe('Service utils', () => { name: 'Group 1', southId: 'south1', scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, createdBy: '', diff --git a/backend/src/service/utils.ts b/backend/src/service/utils.ts index 3647411ed6..b5f108f08f 100644 --- a/backend/src/service/utils.ts +++ b/backend/src/service/utils.ts @@ -818,7 +818,8 @@ export const itemToFlattenedCSV = ( columns.add('group'); columns.add('maxReadInterval'); columns.add('readDelay'); - columns.add('overlap'); + columns.add('startTimeOffset'); + columns.add('endTimeOffset'); columns.add('syncWithGroup'); } diff --git a/backend/src/south/south-ads/south-ads.spec.ts b/backend/src/south/south-ads/south-ads.spec.ts index 217db60e34..dfead3ce6e 100644 --- a/backend/src/south/south-ads/south-ads.spec.ts +++ b/backend/src/south/south-ads/south-ads.spec.ts @@ -104,7 +104,8 @@ describe('South ADS', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -122,7 +123,8 @@ describe('South ADS', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', diff --git a/backend/src/south/south-connector.spec.ts b/backend/src/south/south-connector.spec.ts index 5587dd9070..92f2bdc61e 100644 --- a/backend/src/south/south-connector.spec.ts +++ b/backend/src/south/south-connector.spec.ts @@ -296,7 +296,8 @@ describe('SouthConnector', () => { id: 'groupId1', name: 'group1', scanMode: groupScanMode, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: null, createdBy: '', @@ -859,7 +860,8 @@ describe('SouthConnector', () => { scanMode: testData.scanMode.list[0], maxReadInterval: 1800, readDelay: 100, - overlap: 50, + startTimeOffset: 50, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -923,7 +925,8 @@ describe('SouthConnector', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, scanMode: { id: 'subscription', name: 'subscription', @@ -950,7 +953,8 @@ describe('SouthConnector', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, scanMode: { id: 'subscription', name: 'subscription', diff --git a/backend/src/south/south-connector.ts b/backend/src/south/south-connector.ts index 8f13989b5a..60057f29ea 100644 --- a/backend/src/south/south-connector.ts +++ b/backend/src/south/south-connector.ts @@ -526,9 +526,10 @@ export default abstract class SouthConnector southCache.trackedInstant)) { this.cacheService!.saveItemLastValue(this.connector.id, { groupId: southCache.groupId, @@ -716,6 +725,30 @@ export default abstract class SouthConnector) { if (intervals.length > 2) { this.logger.trace( diff --git a/backend/src/south/south-folder-scanner/south-folder-scanner.spec.ts b/backend/src/south/south-folder-scanner/south-folder-scanner.spec.ts index eb48a154d4..cd49dbbe8a 100644 --- a/backend/src/south/south-folder-scanner/south-folder-scanner.spec.ts +++ b/backend/src/south/south-folder-scanner/south-folder-scanner.spec.ts @@ -89,7 +89,8 @@ describe('SouthFolderScanner', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', diff --git a/backend/src/south/south-ftp/south-ftp.spec.ts b/backend/src/south/south-ftp/south-ftp.spec.ts index f7ffeb9968..bafb9ca3b6 100644 --- a/backend/src/south/south-ftp/south-ftp.spec.ts +++ b/backend/src/south/south-ftp/south-ftp.spec.ts @@ -109,7 +109,8 @@ describe('SouthFTP', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -134,7 +135,8 @@ describe('SouthFTP', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -159,7 +161,8 @@ describe('SouthFTP', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', diff --git a/backend/src/south/south-influxdb/south-influxdb.spec.ts b/backend/src/south/south-influxdb/south-influxdb.spec.ts index 9ee2861328..5bd024a141 100644 --- a/backend/src/south/south-influxdb/south-influxdb.spec.ts +++ b/backend/src/south/south-influxdb/south-influxdb.spec.ts @@ -168,7 +168,8 @@ describe('SouthInfluxDB', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', diff --git a/backend/src/south/south-modbus/south-modbus.spec.ts b/backend/src/south/south-modbus/south-modbus.spec.ts index 0178a1d2dc..b9dcc3923c 100644 --- a/backend/src/south/south-modbus/south-modbus.spec.ts +++ b/backend/src/south/south-modbus/south-modbus.spec.ts @@ -183,7 +183,8 @@ describe('South Modbus', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -206,7 +207,8 @@ describe('South Modbus', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -229,7 +231,8 @@ describe('South Modbus', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -248,7 +251,8 @@ describe('South Modbus', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -267,7 +271,8 @@ describe('South Modbus', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -291,7 +296,8 @@ describe('South Modbus', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -602,7 +608,8 @@ describe('South Modbus', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -788,7 +795,8 @@ describe('South Modbus', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', diff --git a/backend/src/south/south-mssql/south-mssql.spec.ts b/backend/src/south/south-mssql/south-mssql.spec.ts index 3af97fa67f..2c994d8be5 100644 --- a/backend/src/south/south-mssql/south-mssql.spec.ts +++ b/backend/src/south/south-mssql/south-mssql.spec.ts @@ -185,7 +185,8 @@ describe('SouthMSSQL', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -472,7 +473,8 @@ describe('SouthMSSQL', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -543,7 +545,8 @@ describe('SouthMSSQL', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', diff --git a/backend/src/south/south-mysql/south-mysql.spec.ts b/backend/src/south/south-mysql/south-mysql.spec.ts index d890d4f226..baccee2913 100644 --- a/backend/src/south/south-mysql/south-mysql.spec.ts +++ b/backend/src/south/south-mysql/south-mysql.spec.ts @@ -175,7 +175,8 @@ describe('SouthMySQL', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -203,7 +204,8 @@ describe('SouthMySQL', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -248,7 +250,8 @@ describe('SouthMySQL', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -544,7 +547,8 @@ describe('SouthMySQL', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -572,7 +576,8 @@ describe('SouthMySQL', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -617,7 +622,8 @@ describe('SouthMySQL', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', diff --git a/backend/src/south/south-odbc/south-odbc.spec.ts b/backend/src/south/south-odbc/south-odbc.spec.ts index c4c6936747..4f5397a158 100644 --- a/backend/src/south/south-odbc/south-odbc.spec.ts +++ b/backend/src/south/south-odbc/south-odbc.spec.ts @@ -169,7 +169,8 @@ describe('SouthODBC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -196,7 +197,8 @@ describe('SouthODBC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -240,7 +242,8 @@ describe('SouthODBC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -585,7 +588,8 @@ describe('SouthODBC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -612,7 +616,8 @@ describe('SouthODBC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -656,7 +661,8 @@ describe('SouthODBC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -787,7 +793,8 @@ describe('SouthODBC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -814,7 +821,8 @@ describe('SouthODBC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -858,7 +866,8 @@ describe('SouthODBC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -1062,7 +1071,8 @@ describe('SouthODBC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -1089,7 +1099,8 @@ describe('SouthODBC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -1133,7 +1144,8 @@ describe('SouthODBC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -1510,7 +1522,8 @@ describe('SouthODBC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -1537,7 +1550,8 @@ describe('SouthODBC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -1581,7 +1595,8 @@ describe('SouthODBC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', diff --git a/backend/src/south/south-oianalytics/south-oianalytics.spec.ts b/backend/src/south/south-oianalytics/south-oianalytics.spec.ts index 8414b56139..d5ad1b100a 100644 --- a/backend/src/south/south-oianalytics/south-oianalytics.spec.ts +++ b/backend/src/south/south-oianalytics/south-oianalytics.spec.ts @@ -100,7 +100,8 @@ const baseConfiguration: SouthConnectorEntity { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -128,7 +129,8 @@ describe('SouthOLEDB', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -172,7 +174,8 @@ describe('SouthOLEDB', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', diff --git a/backend/src/south/south-opc/south-opc.spec.ts b/backend/src/south/south-opc/south-opc.spec.ts index 0022fd199c..78e575230a 100644 --- a/backend/src/south/south-opc/south-opc.spec.ts +++ b/backend/src/south/south-opc/south-opc.spec.ts @@ -80,7 +80,8 @@ describe('South OPC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -96,7 +97,8 @@ describe('South OPC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -112,7 +114,8 @@ describe('South OPC', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', diff --git a/backend/src/south/south-opcua/south-opcua.spec.ts b/backend/src/south/south-opcua/south-opcua.spec.ts index 1737da0f55..c38222484a 100644 --- a/backend/src/south/south-opcua/south-opcua.spec.ts +++ b/backend/src/south/south-opcua/south-opcua.spec.ts @@ -156,7 +156,8 @@ describe('SouthOPCUA', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -179,7 +180,8 @@ describe('SouthOPCUA', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -202,7 +204,8 @@ describe('SouthOPCUA', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -222,7 +225,8 @@ describe('SouthOPCUA', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -242,7 +246,8 @@ describe('SouthOPCUA', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -262,7 +267,8 @@ describe('SouthOPCUA', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', diff --git a/backend/src/south/south-oracle/south-oracle.spec.ts b/backend/src/south/south-oracle/south-oracle.spec.ts index 38deaeb18e..336b85606e 100644 --- a/backend/src/south/south-oracle/south-oracle.spec.ts +++ b/backend/src/south/south-oracle/south-oracle.spec.ts @@ -185,7 +185,8 @@ describe('SouthOracle', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -213,7 +214,8 @@ describe('SouthOracle', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -258,7 +260,8 @@ describe('SouthOracle', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -551,7 +554,8 @@ describe('SouthOracle', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -579,7 +583,8 @@ describe('SouthOracle', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', diff --git a/backend/src/south/south-pi/south-pi.spec.ts b/backend/src/south/south-pi/south-pi.spec.ts index 2c220b816c..15a64031a2 100644 --- a/backend/src/south/south-pi/south-pi.spec.ts +++ b/backend/src/south/south-pi/south-pi.spec.ts @@ -77,7 +77,8 @@ describe('South PI', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -93,7 +94,8 @@ describe('South PI', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', diff --git a/backend/src/south/south-postgresql/south-postgresql.spec.ts b/backend/src/south/south-postgresql/south-postgresql.spec.ts index 0f9e484cf4..a3c1b26c68 100644 --- a/backend/src/south/south-postgresql/south-postgresql.spec.ts +++ b/backend/src/south/south-postgresql/south-postgresql.spec.ts @@ -183,7 +183,8 @@ describe('SouthPostgreSQL', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -210,7 +211,8 @@ describe('SouthPostgreSQL', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -254,7 +256,8 @@ describe('SouthPostgreSQL', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -509,7 +512,8 @@ describe('SouthPostgreSQL', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -536,7 +540,8 @@ describe('SouthPostgreSQL', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -580,7 +585,8 @@ describe('SouthPostgreSQL', () => { syncWithGroup: false, maxReadInterval: 3600, readDelay: 0, - overlap: 0, + startTimeOffset: 0, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', diff --git a/backend/src/south/south-rest/south-rest.spec.ts b/backend/src/south/south-rest/south-rest.spec.ts index e85acde1b1..f836d9f031 100644 --- a/backend/src/south/south-rest/south-rest.spec.ts +++ b/backend/src/south/south-rest/south-rest.spec.ts @@ -114,7 +114,8 @@ describe('SouthRestAPI connector', () => { syncWithGroup: false, maxReadInterval: 900, readDelay: 5, - overlap: 10, + startTimeOffset: 10, + endTimeOffset: null, settings: { endpoint: '/data', method: 'GET', diff --git a/backend/src/south/south-sftp/south-sftp.spec.ts b/backend/src/south/south-sftp/south-sftp.spec.ts index a4b2c37581..f570987b07 100644 --- a/backend/src/south/south-sftp/south-sftp.spec.ts +++ b/backend/src/south/south-sftp/south-sftp.spec.ts @@ -92,7 +92,8 @@ describe('SouthSFTP', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -117,7 +118,8 @@ describe('SouthSFTP', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -142,7 +144,8 @@ describe('SouthSFTP', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -522,7 +525,8 @@ describe('SouthFTP with preserve file and compression', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -547,7 +551,8 @@ describe('SouthFTP with preserve file and compression', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -572,7 +577,8 @@ describe('SouthFTP with preserve file and compression', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -759,7 +765,8 @@ describe('SouthSFTP test connection with private key', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -784,7 +791,8 @@ describe('SouthSFTP test connection with private key', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', @@ -809,7 +817,8 @@ describe('SouthSFTP test connection with private key', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: '', updatedBy: '', createdAt: '', diff --git a/backend/src/tests/utils/test-data.ts b/backend/src/tests/utils/test-data.ts index d1387c2ad8..d55d4dab87 100644 --- a/backend/src/tests/utils/test-data.ts +++ b/backend/src/tests/utils/test-data.ts @@ -555,7 +555,8 @@ const southConnectors: Array { name: 'Group 1', southId, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [] @@ -745,7 +746,8 @@ describe('SouthConnectorController', () => { name: 'Group 1', southId, scanMode: testData.scanMode.list[0], - overlap: null, + startTimeOffset: null, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [] @@ -768,7 +770,8 @@ describe('SouthConnectorController', () => { scanModeId: testData.scanMode.list[0].id }, historySettings: { - overlap: 5, + startTimeOffset: 5, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 } @@ -782,7 +785,8 @@ describe('SouthConnectorController', () => { name: 'New Group', southId, scanMode: testData.scanMode.list[0], - overlap: 5, + startTimeOffset: 5, + endTimeOffset: null, maxReadInterval: null, readDelay: 0, items: [] @@ -806,7 +810,8 @@ describe('SouthConnectorController', () => { scanModeId: testData.scanMode.list[1].id }, historySettings: { - overlap: 10, + startTimeOffset: 10, + endTimeOffset: null, maxReadInterval: null, readDelay: 0 } diff --git a/backend/src/web-server/routes.ts b/backend/src/web-server/routes.ts index 0ca011057a..5166559fbe 100644 --- a/backend/src/web-server/routes.ts +++ b/backend/src/web-server/routes.ts @@ -546,7 +546,7 @@ const models: TsoaRoute.Models = { "createdAt": {"ref":"Instant","required":true}, "updatedAt": {"ref":"Instant","required":true}, "standardSettings": {"dataType":"nestedObjectLiteral","nestedProperties":{"scanMode":{"ref":"ScanModeDTO","required":true},"name":{"dataType":"string","required":true}},"required":true}, - "historySettings": {"dataType":"nestedObjectLiteral","nestedProperties":{"recoveryStrategy":{"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true},"readDelay":{"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true},"maxReadInterval":{"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true},"overlap":{"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}},"required":true}, + "historySettings": {"dataType":"nestedObjectLiteral","nestedProperties":{"recoveryStrategy":{"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true},"readDelay":{"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true},"maxReadInterval":{"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true},"endTimeOffset":{"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true},"startTimeOffset":{"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}},"required":true}, }, "additionalProperties": false, }, @@ -567,7 +567,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -626,7 +627,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -681,7 +683,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -712,7 +715,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -768,7 +772,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -808,7 +813,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -858,7 +864,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -908,7 +915,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -979,7 +987,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1030,7 +1039,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1079,7 +1089,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1140,7 +1151,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1232,7 +1244,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1271,7 +1284,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1364,7 +1378,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1399,7 +1414,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1491,7 +1507,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1527,7 +1544,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1610,7 +1628,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1668,7 +1687,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1760,7 +1780,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1795,7 +1816,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1856,7 +1878,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1895,7 +1918,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -1976,7 +2000,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2041,7 +2066,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2134,7 +2160,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2171,7 +2198,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2227,7 +2255,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2258,7 +2287,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2350,7 +2380,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2387,7 +2418,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2552,7 +2584,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2638,7 +2671,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2694,7 +2728,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2736,7 +2771,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2828,7 +2864,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2858,7 +2895,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2902,7 +2940,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2922,7 +2961,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2933,7 +2973,7 @@ const models: TsoaRoute.Models = { "properties": { "id": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}, "standardSettings": {"dataType":"nestedObjectLiteral","nestedProperties":{"scanModeId":{"dataType":"string","required":true},"name":{"dataType":"string","required":true}},"required":true}, - "historySettings": {"dataType":"nestedObjectLiteral","nestedProperties":{"recoveryStrategy":{"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true},"readDelay":{"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true},"maxReadInterval":{"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true},"overlap":{"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}},"required":true}, + "historySettings": {"dataType":"nestedObjectLiteral","nestedProperties":{"recoveryStrategy":{"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true},"readDelay":{"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true},"maxReadInterval":{"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true},"endTimeOffset":{"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true},"startTimeOffset":{"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}},"required":true}, }, "additionalProperties": false, }, @@ -2966,7 +3006,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -2986,7 +3027,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3020,7 +3062,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3040,7 +3083,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3074,7 +3118,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3094,7 +3139,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3128,7 +3174,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3148,7 +3195,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3182,7 +3230,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3202,7 +3251,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3236,7 +3286,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3256,7 +3307,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3290,7 +3342,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3310,7 +3363,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3344,7 +3398,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3364,7 +3419,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3398,7 +3454,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3418,7 +3475,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3452,7 +3510,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3472,7 +3531,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3506,7 +3566,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3526,7 +3587,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3560,7 +3622,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3580,7 +3643,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3614,7 +3678,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3634,7 +3699,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3668,7 +3734,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3688,7 +3755,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3722,7 +3790,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3742,7 +3811,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3776,7 +3846,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3796,7 +3867,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3830,7 +3902,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3850,7 +3923,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3884,7 +3958,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, @@ -3904,7 +3979,8 @@ const models: TsoaRoute.Models = { "syncWithGroup": {"dataType":"boolean","required":true}, "maxReadInterval": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "readDelay": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, - "overlap": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "startTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, + "endTimeOffset": {"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true}, "recoveryStrategy": {"dataType":"union","subSchemas":[{"ref":"SouthHistoryRecoveryStrategy"},{"dataType":"enum","enums":[null]}],"required":true}, }, "additionalProperties": false, diff --git a/documentation/docs/api/openapi.json b/documentation/docs/api/openapi.json index 0a260589be..4fbddbcd4f 100644 --- a/documentation/docs/api/openapi.json +++ b/documentation/docs/api/openapi.json @@ -1859,19 +1859,27 @@ "description": "Maximum read interval in seconds for historical queries.\nOnly applicable for connectors with historian capabilities.", "example": 3600 }, - "overlap": { + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 + }, + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 } }, "required": [ "recoveryStrategy", "readDelay", "maxReadInterval", - "overlap" + "endTimeOffset", + "startTimeOffset" ], "type": "object" } @@ -1948,7 +1956,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -1965,12 +1973,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -1997,7 +2012,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -2157,7 +2173,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -2174,12 +2190,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -2206,7 +2229,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -2396,7 +2420,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -2413,12 +2437,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -2445,7 +2476,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -2526,7 +2558,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -2543,12 +2575,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -2575,7 +2614,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -2769,7 +2809,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -2786,12 +2826,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -2818,7 +2865,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -2923,7 +2971,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -2940,12 +2988,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -2972,7 +3027,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -3141,7 +3197,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -3158,12 +3214,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -3190,7 +3253,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -3316,7 +3380,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -3333,12 +3397,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -3365,7 +3436,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -3585,7 +3657,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -3602,12 +3674,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -3634,7 +3713,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -3774,7 +3854,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -3791,12 +3871,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -3823,7 +3910,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -3987,7 +4075,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -4004,12 +4092,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -4036,7 +4131,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -4192,7 +4288,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -4209,12 +4305,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -4241,7 +4344,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -4512,7 +4616,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -4529,12 +4633,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -4561,7 +4672,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -4680,7 +4792,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -4697,12 +4809,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -4729,7 +4848,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -5001,7 +5121,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -5018,12 +5138,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -5050,7 +5177,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -5151,7 +5279,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -5168,12 +5296,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -5200,7 +5335,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -5466,7 +5602,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -5483,12 +5619,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -5515,7 +5658,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -5618,7 +5762,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -5635,12 +5779,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -5667,7 +5818,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -5910,7 +6062,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -5927,12 +6079,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -5959,7 +6118,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -6114,7 +6274,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -6131,12 +6291,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -6163,7 +6330,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -6429,7 +6597,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -6446,12 +6614,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -6478,7 +6653,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -6579,7 +6755,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -6596,12 +6772,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -6628,7 +6811,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -6841,7 +7025,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -6858,12 +7042,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -6890,7 +7081,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -6991,7 +7183,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -7008,12 +7200,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -7040,7 +7239,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -7266,7 +7466,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -7283,12 +7483,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -7315,7 +7522,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -7484,7 +7692,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -7501,12 +7709,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -7533,7 +7748,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -7805,7 +8021,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -7822,12 +8038,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -7854,7 +8077,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -7962,7 +8186,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -7979,12 +8203,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -8011,7 +8242,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -8188,7 +8420,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -8205,12 +8437,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -8237,7 +8476,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -8319,7 +8559,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -8336,12 +8576,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -8368,7 +8615,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -8636,7 +8884,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -8653,12 +8901,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -8685,7 +8940,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -8795,7 +9051,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -8812,12 +9068,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -8844,7 +9107,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -9299,7 +9563,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -9316,12 +9580,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -9348,7 +9619,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -9551,7 +9823,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -9568,12 +9840,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -9600,7 +9879,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -9794,7 +10074,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -9811,12 +10091,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -9843,7 +10130,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -9955,7 +10243,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -9972,12 +10260,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -10004,7 +10299,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -10270,7 +10566,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -10287,12 +10583,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -10319,7 +10622,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -10396,7 +10700,7 @@ }, "syncWithGroup": { "type": "boolean", - "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, overlap) are inherited from the group.", + "description": "Whether this item syncs its historian settings with its group.\nWhen true, historian fields (maxReadInterval, readDelay, startTimeOffset, endTimeOffset) are inherited from the group.", "example": true }, "maxReadInterval": { @@ -10413,12 +10717,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -10445,7 +10756,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -10665,12 +10977,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -10695,7 +11014,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -10766,12 +11086,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -10796,7 +11123,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -10856,19 +11184,27 @@ "description": "Maximum read interval in seconds for historical queries.", "example": 3600 }, - "overlap": { + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 + }, + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 } }, "required": [ "recoveryStrategy", "readDelay", "maxReadInterval", - "overlap" + "endTimeOffset", + "startTimeOffset" ], "type": "object" } @@ -11005,12 +11341,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -11035,7 +11378,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -11106,12 +11450,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -11136,7 +11487,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -11266,12 +11618,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -11296,7 +11655,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -11367,12 +11727,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -11397,7 +11764,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -11527,12 +11895,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -11557,7 +11932,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -11628,12 +12004,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -11658,7 +12041,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -11788,12 +12172,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -11818,7 +12209,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -11889,12 +12281,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -11919,7 +12318,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -12049,12 +12449,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -12079,7 +12486,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -12150,12 +12558,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -12180,7 +12595,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -12310,12 +12726,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -12340,7 +12763,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -12411,12 +12835,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -12441,7 +12872,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -12571,12 +13003,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -12601,7 +13040,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -12672,12 +13112,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -12702,7 +13149,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -12832,12 +13280,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -12862,7 +13317,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -12933,12 +13389,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -12963,7 +13426,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -13093,12 +13557,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -13123,7 +13594,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -13194,12 +13666,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -13224,7 +13703,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -13354,12 +13834,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -13384,7 +13871,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -13455,12 +13943,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -13485,7 +13980,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -13615,12 +14111,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -13645,7 +14148,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -13716,12 +14220,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -13746,7 +14257,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -13876,12 +14388,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -13906,7 +14425,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -13977,12 +14497,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -14007,7 +14534,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -14137,12 +14665,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -14167,7 +14702,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -14238,12 +14774,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -14268,7 +14811,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -14398,12 +14942,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -14428,7 +14979,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -14499,12 +15051,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -14529,7 +15088,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -14659,12 +15219,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -14689,7 +15256,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -14760,12 +15328,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -14790,7 +15365,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -14920,12 +15496,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -14950,7 +15533,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -15021,12 +15605,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -15051,7 +15642,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -15181,12 +15773,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -15211,7 +15810,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -15282,12 +15882,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -15312,7 +15919,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -15442,12 +16050,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -15472,7 +16087,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", @@ -15543,12 +16159,19 @@ "description": "Read delay in milliseconds before querying historical data.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", "example": 200 }, - "overlap": { + "startTimeOffset": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Offset in milliseconds applied to the start of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nNegative values extend the window backwards (equivalent to the old overlap behaviour).", + "example": -1000 + }, + "endTimeOffset": { "type": "number", "format": "double", "nullable": true, - "description": "Default overlap in milliseconds for historical queries.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.", - "example": 1000 + "description": "Offset in milliseconds applied to the end of the history query interval.\nOnly applicable for connectors with historian capabilities.\nWhen null and item is in a group, inherits from group settings.\nIf the resulting end time is not after the effective start time, the query is skipped.", + "example": 0 }, "recoveryStrategy": { "allOf": [ @@ -15573,7 +16196,8 @@ "syncWithGroup", "maxReadInterval", "readDelay", - "overlap", + "startTimeOffset", + "endTimeOffset", "recoveryStrategy" ], "type": "object", diff --git a/frontend/src/app/services/south-connector.service.spec.ts b/frontend/src/app/services/south-connector.service.spec.ts index 5d329012a3..dbcec237a2 100644 --- a/frontend/src/app/services/south-connector.service.spec.ts +++ b/frontend/src/app/services/south-connector.service.spec.ts @@ -115,7 +115,8 @@ describe('SouthConnectorService', () => { syncWithGroup: false, maxReadInterval: null, readDelay: null, - overlap: null, + startTimeOffset: null, + endTimeOffset: null, createdBy: { id: '', friendlyName: '' }, updatedBy: { id: '', friendlyName: '' }, createdAt: '', diff --git a/frontend/src/app/south/edit-south/edit-south.component.html b/frontend/src/app/south/edit-south/edit-south.component.html index 98e2cc0344..7804b5867b 100644 --- a/frontend/src/app/south/edit-south/edit-south.component.html +++ b/frontend/src/app/south/edit-south/edit-south.component.html @@ -218,8 +218,15 @@

: {{ group.historySettings.readDelay }}ms

} - @if (group.historySettings.overlap !== null) { -
: {{ group.historySettings.overlap }}ms
+ @if (group.historySettings.startTimeOffset !== null) { +
+ : {{ group.historySettings.startTimeOffset }}ms +
+ } + @if (group.historySettings.endTimeOffset !== null) { +
+ : {{ group.historySettings.endTimeOffset }}ms +
} } } @else { @@ -231,8 +238,11 @@

: {{ tooltipItem.readDelay }}ms } - @if (tooltipItem.overlap !== null) { -
: {{ tooltipItem.overlap }}ms
+ @if (tooltipItem.startTimeOffset !== null) { +
: {{ tooltipItem.startTimeOffset }}ms
+ } + @if (tooltipItem.endTimeOffset !== null) { +
: {{ tooltipItem.endTimeOffset }}ms
} }
⚠ {{ 'south.items.not-synced-notice' | translate }}
@@ -253,8 +263,11 @@

: {{ tooltipItem.readDelay }}ms } - @if (tooltipItem.overlap !== null) { -
: {{ tooltipItem.overlap }}ms
+ @if (tooltipItem.startTimeOffset !== null) { +
: {{ tooltipItem.startTimeOffset }}ms
+ } + @if (tooltipItem.endTimeOffset !== null) { +
: {{ tooltipItem.endTimeOffset }}ms
} } } diff --git a/frontend/src/app/south/edit-south/edit-south.component.ts b/frontend/src/app/south/edit-south/edit-south.component.ts index d143e9ac7e..4244d17745 100644 --- a/frontend/src/app/south/edit-south/edit-south.component.ts +++ b/frontend/src/app/south/edit-south/edit-south.component.ts @@ -197,7 +197,8 @@ export class EditSouthComponent implements CanComponentDeactivate { syncWithGroup: item.syncWithGroup, maxReadInterval: item.maxReadInterval, readDelay: item.readDelay, - overlap: item.overlap, + startTimeOffset: item.startTimeOffset, + endTimeOffset: item.endTimeOffset, recoveryStrategy: item.recoveryStrategy }) as SouthConnectorItemCommandDTO ); @@ -208,7 +209,8 @@ export class EditSouthComponent implements CanComponentDeactivate { scanModeId: group.standardSettings.scanMode.id }, historySettings: { - overlap: group.historySettings.overlap, + startTimeOffset: group.historySettings.startTimeOffset, + endTimeOffset: group.historySettings.endTimeOffset, maxReadInterval: group.historySettings.maxReadInterval, readDelay: group.historySettings.readDelay, recoveryStrategy: group.historySettings.recoveryStrategy @@ -415,7 +417,7 @@ export class EditSouthComponent implements CanComponentDeactivate { importItems() { const modal = this.modalService.open(ImportSouthItemsModalComponent, { size: 'xl', backdrop: 'static' }); const expectedHeaders = ['name', 'enabled', 'scanMode']; - const optionalHeaders: Array = ['group', 'maxReadInterval', 'readDelay', 'overlap', 'syncWithGroup']; + const optionalHeaders: Array = ['group', 'maxReadInterval', 'readDelay', 'startTimeOffset', 'endTimeOffset', 'syncWithGroup']; const settingsAttribute = this.manifest!.items.rootAttribute.attributes.find( attribute => attribute.key === 'settings' )! as OIBusObjectAttribute; @@ -444,9 +446,10 @@ export class EditSouthComponent implements CanComponentDeactivate { syncWithGroup: item.syncWithGroup, maxReadInterval: item.maxReadInterval, readDelay: item.readDelay, - overlap: item.overlap - }) as SouthConnectorItemCommandDTO - ), + startTimeOffset: item.startTimeOffset, + endTimeOffset: item.endTimeOffset, + recoveryStrategy: item.recoveryStrategy + }) as SouthConnectorItemCommandDTO), errors: result.errors })) ); @@ -491,7 +494,8 @@ export class EditSouthComponent implements CanComponentDeactivate { foundGroup.standardSettings.scanModeId = command.group.standardSettings.scanModeId; foundGroup.historySettings.maxReadInterval = command.group.historySettings.maxReadInterval; foundGroup.historySettings.readDelay = command.group.historySettings.readDelay; - foundGroup.historySettings.overlap = command.group.historySettings.overlap; + foundGroup.historySettings.startTimeOffset = command.group.historySettings.startTimeOffset; + foundGroup.historySettings.endTimeOffset = command.group.historySettings.endTimeOffset; return of(foundGroup); } } diff --git a/frontend/src/app/south/south-detail/south-detail.component.html b/frontend/src/app/south/south-detail/south-detail.component.html index 2f3f0ef2d9..3c3160514a 100644 --- a/frontend/src/app/south/south-detail/south-detail.component.html +++ b/frontend/src/app/south/south-detail/south-detail.component.html @@ -218,8 +218,17 @@

: {{ tooltipItem.group.historySettings.readDelay }}ms } - @if (tooltipItem.group.historySettings.overlap !== null) { -
: {{ tooltipItem.group.historySettings.overlap }}ms
+ @if (tooltipItem.group.historySettings.startTimeOffset !== null) { +
+ : + {{ tooltipItem.group.historySettings.startTimeOffset }}ms +
+ } + @if (tooltipItem.group.historySettings.endTimeOffset !== null) { +
+ : + {{ tooltipItem.group.historySettings.endTimeOffset }}ms +
} } } @else { @@ -233,8 +242,11 @@

@if (tooltipItem.readDelay !== null) {
: {{ tooltipItem.readDelay }}ms
} - @if (tooltipItem.overlap !== null) { -
: {{ tooltipItem.overlap }}ms
+ @if (tooltipItem.startTimeOffset !== null) { +
: {{ tooltipItem.startTimeOffset }}ms
+ } + @if (tooltipItem.endTimeOffset !== null) { +
: {{ tooltipItem.endTimeOffset }}ms
} }
⚠ {{ 'south.items.not-synced-notice' | translate }}
@@ -255,8 +267,11 @@

@if (tooltipItem.readDelay !== null) {
: {{ tooltipItem.readDelay }}ms
} - @if (tooltipItem.overlap !== null) { -
: {{ tooltipItem.overlap }}ms
+ @if (tooltipItem.startTimeOffset !== null) { +
: {{ tooltipItem.startTimeOffset }}ms
+ } + @if (tooltipItem.endTimeOffset !== null) { +
: {{ tooltipItem.endTimeOffset }}ms
} } } diff --git a/frontend/src/app/south/south-detail/south-detail.component.ts b/frontend/src/app/south/south-detail/south-detail.component.ts index 50108ccbda..17f5d80ee7 100644 --- a/frontend/src/app/south/south-detail/south-detail.component.ts +++ b/frontend/src/app/south/south-detail/south-detail.component.ts @@ -253,7 +253,9 @@ export class SouthDetailComponent { syncWithGroup: command.syncWithGroup, maxReadInterval: command.maxReadInterval, readDelay: command.readDelay, - overlap: command.overlap + startTimeOffset: command.startTimeOffset, + endTimeOffset: command.endTimeOffset, + recoveryStrategy: command.recoveryStrategy } as SouthConnectorItemCommandDTO); }), switchMap(() => { @@ -298,7 +300,9 @@ export class SouthDetailComponent { syncWithGroup: command.syncWithGroup, maxReadInterval: command.maxReadInterval, readDelay: command.readDelay, - overlap: command.overlap + startTimeOffset: command.startTimeOffset, + endTimeOffset: command.endTimeOffset, + recoveryStrategy: command.recoveryStrategy } as SouthConnectorItemCommandDTO); }), switchMap(() => { @@ -353,7 +357,9 @@ export class SouthDetailComponent { syncWithGroup: command.syncWithGroup, maxReadInterval: command.maxReadInterval, readDelay: command.readDelay, - overlap: command.overlap + startTimeOffset: command.startTimeOffset, + endTimeOffset: command.endTimeOffset, + recoveryStrategy: command.recoveryStrategy } as SouthConnectorItemCommandDTO); }), switchMap(() => { @@ -423,7 +429,7 @@ export class SouthDetailComponent { importItems() { const modal = this.modalService.open(ImportSouthItemsModalComponent, { size: 'xl', backdrop: 'static' }); const expectedHeaders = ['name', 'enabled', 'scanMode']; - const optionalHeaders: Array = ['group', 'maxReadInterval', 'readDelay', 'overlap', 'syncWithGroup']; + const optionalHeaders: Array = ['group', 'maxReadInterval', 'readDelay', 'startTimeOffset', 'endTimeOffset', 'syncWithGroup']; const settingsAttribute = this.manifest!.items.rootAttribute.attributes.find( attribute => attribute.key === 'settings' )! as OIBusObjectAttribute; @@ -454,9 +460,10 @@ export class SouthDetailComponent { syncWithGroup: item.syncWithGroup, maxReadInterval: item.maxReadInterval, readDelay: item.readDelay, - overlap: item.overlap - }) as SouthConnectorItemCommandDTO - ), + startTimeOffset: item.startTimeOffset, + endTimeOffset: item.endTimeOffset, + recoveryStrategy: item.recoveryStrategy + }) as SouthConnectorItemCommandDTO), errors: result.errors })) ); @@ -851,7 +858,8 @@ export class SouthDetailComponent { syncWithGroup: item.syncWithGroup, maxReadInterval: item.maxReadInterval, readDelay: item.readDelay, - overlap: item.overlap, + startTimeOffset: item.startTimeOffset, + endTimeOffset: item.endTimeOffset, recoveryStrategy: item.recoveryStrategy })), groups: this.southConnector!.groups.map(group => ({ @@ -863,7 +871,8 @@ export class SouthDetailComponent { historySettings: { maxReadInterval: group.historySettings.maxReadInterval, readDelay: group.historySettings.readDelay, - overlap: group.historySettings.overlap, + startTimeOffset: group.historySettings.startTimeOffset, + endTimeOffset: group.historySettings.endTimeOffset, recoveryStrategy: group.historySettings.recoveryStrategy } })) diff --git a/frontend/src/app/south/south-items/edit-south-item-group-modal/edit-south-item-group-modal.component.html b/frontend/src/app/south/south-items/edit-south-item-group-modal/edit-south-item-group-modal.component.html index 020f56419a..e858ab45d0 100644 --- a/frontend/src/app/south/south-items/edit-south-item-group-modal/edit-south-item-group-modal.component.html +++ b/frontend/src/app/south/south-items/edit-south-item-group-modal/edit-south-item-group-modal.component.html @@ -23,15 +23,41 @@ - + @if (hasHistorianCapabilities) {
- +
- +
- + +
+ } + + + @if (hasHistorianCapabilities) { +
+ +
+ + +
+
} diff --git a/frontend/src/app/south/south-items/edit-south-item-group-modal/edit-south-item-group-modal.component.spec.ts b/frontend/src/app/south/south-items/edit-south-item-group-modal/edit-south-item-group-modal.component.spec.ts index 29095ddeda..cf479a2c79 100644 --- a/frontend/src/app/south/south-items/edit-south-item-group-modal/edit-south-item-group-modal.component.spec.ts +++ b/frontend/src/app/south/south-items/edit-south-item-group-modal/edit-south-item-group-modal.component.spec.ts @@ -24,7 +24,7 @@ const existingGroup: SouthItemGroupDTO = { name: 'GroupA', scanMode: scanModes[0] }, - historySettings: { overlap: null, maxReadInterval: null, readDelay: null, recoveryStrategy: null } + historySettings: { startTimeOffset: null, endTimeOffset: null, maxReadInterval: null, readDelay: null, recoveryStrategy: null } }; describe('EditSouthItemGroupModalComponent', () => { diff --git a/frontend/src/app/south/south-items/edit-south-item-group-modal/edit-south-item-group-modal.component.ts b/frontend/src/app/south/south-items/edit-south-item-group-modal/edit-south-item-group-modal.component.ts index 6ddeb48edd..a6147081e1 100644 --- a/frontend/src/app/south/south-items/edit-south-item-group-modal/edit-south-item-group-modal.component.ts +++ b/frontend/src/app/south/south-items/edit-south-item-group-modal/edit-south-item-group-modal.component.ts @@ -52,7 +52,8 @@ export class EditSouthItemGroupModalComponent { form: FormGroup<{ name: FormControl; scanModeId: FormControl; - overlap: FormControl; + startTimeOffset: FormControl; + endTimeOffset: FormControl; maxReadInterval: FormControl; readDelay: FormControl; recoveryStrategy: FormControl; @@ -105,7 +106,8 @@ export class EditSouthItemGroupModalComponent { this.form = this.fb.group({ name: ['', [Validators.required, this.checkUniqueness()]], scanModeId: this.fb.control(null, [Validators.required]), - overlap: [0, [Validators.min(0)]], + startTimeOffset: this.fb.control(null, [Validators.min(-2147483648), Validators.max(2147483647)]), + endTimeOffset: this.fb.control(null, [Validators.min(-2147483648), Validators.max(2147483647)]), maxReadInterval: [3600, [Validators.min(0)]], readDelay: [200, [Validators.required, Validators.min(0)]], recoveryStrategy: this.fb.control('oldest') @@ -117,7 +119,8 @@ export class EditSouthItemGroupModalComponent { scanModeId: (this.group as SouthItemGroupCommandDTO).standardSettings.scanModeId || (this.group as SouthItemGroupDTO).standardSettings.scanMode.id, - overlap: this.group.historySettings.overlap!, + startTimeOffset: this.group.historySettings.startTimeOffset ?? null, + endTimeOffset: this.group.historySettings.endTimeOffset ?? null, maxReadInterval: this.group.historySettings.maxReadInterval!, readDelay: this.group.historySettings.readDelay!, recoveryStrategy: this.group.historySettings.recoveryStrategy ?? 'oldest' @@ -149,7 +152,8 @@ export class EditSouthItemGroupModalComponent { scanModeId: formValue.scanModeId! }, historySettings: { - overlap: formValue.overlap! ?? null, + startTimeOffset: formValue.startTimeOffset ?? null, + endTimeOffset: formValue.endTimeOffset ?? null, maxReadInterval: formValue.maxReadInterval! ?? null, readDelay: formValue.readDelay! ?? null, recoveryStrategy: formValue.recoveryStrategy! ?? null diff --git a/frontend/src/app/south/south-items/edit-south-item-modal/edit-south-item-modal.component.html b/frontend/src/app/south/south-items/edit-south-item-modal/edit-south-item-modal.component.html index 42303afa1f..e99e5323b1 100644 --- a/frontend/src/app/south/south-items/edit-south-item-modal/edit-south-item-modal.component.html +++ b/frontend/src/app/south/south-items/edit-south-item-modal/edit-south-item-modal.component.html @@ -119,15 +119,42 @@