{{'device.tag-scale' | translate}}
diff --git a/client/src/app/device/tag-options/tag-options.component.ts b/client/src/app/device/tag-options/tag-options.component.ts
index be1ebff50..c6f0f3f16 100644
--- a/client/src/app/device/tag-options/tag-options.component.ts
+++ b/client/src/app/device/tag-options/tag-options.component.ts
@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, Inject, OnDestroy, OnInit } from '@angular/core';
-import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
+import { AbstractControl, UntypedFormBuilder, UntypedFormGroup, ValidationErrors, ValidatorFn, Validators } from '@angular/forms';
import { MatDialogRef as MatDialogRef, MAT_DIALOG_DATA as MAT_DIALOG_DATA } from '@angular/material/dialog';
import { FuxaServer, TagDaq, TagDeadband, TagDeadbandModeType, TagScale, TagScaleModeType } from '../../_models/device';
import { Utils} from '../../_helpers/utils';
@@ -56,6 +56,7 @@ export class TagOptionsComponent implements OnInit, OnDestroy {
scaleWriteFunction: null,
scaleReadExpression: null,
scaleWriteExpression: null,
+ unsPath: [null, [this.validateUnsPathUnique()]],
});
this.formGroup.controls.enabled.valueChanges.subscribe(enabled => {
@@ -88,6 +89,7 @@ export class TagOptionsComponent implements OnInit, OnDestroy {
//let scaleWriteParams = { value: null, valid: true };
let scaleReadExpression = { value: null, valid: true };
let scaleWriteExpression = { value: null, valid: true };
+ let unsPath = { value: null, valid: true, initialized: false };
for (let i = 0; i < this.data.tags.length; i++) {
if (!this.data.tags[i].daq) {
continue;
@@ -153,6 +155,14 @@ export class TagOptionsComponent implements OnInit, OnDestroy {
const tagParams = JSON.parse(this.data.tags[i].scaleWriteParams) as ScriptParam[];
const notValid = this.initializeScriptParams(script, tagParams, this.configedWriteParams);
}
+
+ const currentUnsPath = this.normalizeUnsPath(this.data.tags[i].unsPath);
+ if (!unsPath.initialized) {
+ unsPath.value = currentUnsPath;
+ unsPath.initialized = true;
+ } else if (unsPath.value !== currentUnsPath) {
+ unsPath.valid = false;
+ }
}
let values = {};
if (enabled.valid && enabled.value !== null) {
@@ -192,6 +202,9 @@ export class TagOptionsComponent implements OnInit, OnDestroy {
if (scaleWriteFunction.valid && scaleWriteFunction.value) {
values = {...values, scaleWriteFunction: scaleWriteFunction.value};
}
+ if (unsPath.valid && unsPath.initialized) {
+ values = {...values, unsPath: unsPath.value};
+ }
this.formGroup.patchValue(values);
if (this.data.device?.id === FuxaServer.id) {
@@ -274,7 +287,8 @@ export class TagOptionsComponent implements OnInit, OnDestroy {
scaleReadFunction: this.formGroup.value.scaleReadFunction,
scaleReadParams: readParamsStr,
scaleWriteFunction: this.formGroup.value.scaleWriteFunction,
- scaleWriteParams: writeParamsStr
+ scaleWriteParams: writeParamsStr,
+ unsPath: this.normalizeUnsPath(this.formGroup.value.unsPath)
});
}
@@ -368,6 +382,30 @@ export class TagOptionsComponent implements OnInit, OnDestroy {
}
return true;
}
+
+ private validateUnsPathUnique(): ValidatorFn {
+ return (control: AbstractControl): ValidationErrors | null => {
+ const currentUnsPath = this.normalizeUnsPath(control?.value);
+ if (!currentUnsPath || !this.data?.device?.tags) {
+ return null;
+ }
+ const currentTagIds = new Set((this.data?.tags || []).map(t => t.id));
+ const exists = Object.values(this.data.device.tags).some((tag: any) =>
+ tag &&
+ !currentTagIds.has(tag.id) &&
+ this.normalizeUnsPath(tag.unsPath) === currentUnsPath
+ );
+ return exists ? { unsPathExists: true } : null;
+ };
+ }
+
+ private normalizeUnsPath(value: any): string | null {
+ if (Utils.isNullOrUndefined(value)) {
+ return null;
+ }
+ const normalized = String(value).trim();
+ return normalized.length ? normalized : null;
+ }
}
export interface TagOptionType {
@@ -379,4 +417,5 @@ export interface TagOptionType {
scaleReadParams?: string;
scaleWriteFunction?: string;
scaleWriteParams?: string;
+ unsPath?: string | null;
}
diff --git a/client/src/app/editor/app-settings/app-settings.component.html b/client/src/app/editor/app-settings/app-settings.component.html
index 82e5231cf..951b16834 100644
--- a/client/src/app/editor/app-settings/app-settings.component.html
+++ b/client/src/app/editor/app-settings/app-settings.component.html
@@ -203,6 +203,34 @@
+
+
+ {{'dlg.app-settings-daqstore-queryconn' | translate}}
+
+
+ {{'dlg.app-settings-daqstore-questdb-host' | translate}}
+
+
+
+ {{'dlg.app-settings-daqstore-database' | translate}}
+
+
+
+ {{'dlg.app-settings-daqstore-username' | translate}}
+
+
+
+ {{'dlg.app-settings-daqstore-password' | translate}}
+
+
+
+ {{'dlg.app-settings-daqstore-ingestconn' | translate}}
+
+
+ {{'dlg.app-settings-daqstore-configurl' | translate}}
+
+
+
diff --git a/client/src/assets/i18n/de.json b/client/src/assets/i18n/de.json
index f730dc8b8..3f5ec9e8e 100644
--- a/client/src/assets/i18n/de.json
+++ b/client/src/assets/i18n/de.json
@@ -834,6 +834,8 @@
"device.tag-daq-changed": "Wert speichern, wenn geändert",
"device.tag-daq-interval": "Intervall für Speichern (Sek.)",
"device.tag-format": "Ziffern formatieren (2 = #.##)",
+
+ "device.tag-uns-path": "UNS Path",
"device.tag-scale": "Skalierungsmodus",
"device.tag-scale-mode-undefined": "Keine Skalierung",
"device.tag-scale-mode-undefined-tooltip": "Tag-Wert",
@@ -1240,6 +1242,11 @@
"dlg.app-settings-daqstore": "DAQ-Speicher",
"dlg.app-settings-daqstore-type": "Datenbanktyp",
"dlg.app-settings-daqstore-url": "URL",
+ "dlg.app-settings-daqstore-configurl": "Config URL",
+ "dlg.app-settings-daqstore-queryconn": "Query connection (PGWire)",
+
+ "dlg.app-settings-daqstore-questdb-host": "QuestDB Host",
+ "dlg.app-settings-daqstore-ingestconn": "Ingestion connection (ILP)",
"dlg.app-settings-daqstore-token": "Token",
"dlg.app-settings-daqstore-bucket": "Bucket",
"dlg.app-settings-daqstore-organization": "Organisation",
@@ -1306,3 +1313,6 @@
"msg.report-build-error": "Senden zum Erstellen des Berichts fehlgeschlagen!",
"msg.device-tags-request-result": "Lade {{value}} von {{current}}"
}
+
+
+
diff --git a/client/src/assets/i18n/en.json b/client/src/assets/i18n/en.json
index d6bd98cc9..7699e70f1 100644
--- a/client/src/assets/i18n/en.json
+++ b/client/src/assets/i18n/en.json
@@ -1154,6 +1154,8 @@
"device.tag-format": "Format digits (2 = #.##)",
"device.tag-deadband": "Deadband",
+
+ "device.tag-uns-path": "UNS Path",
"device.tag-scale": "Scale Mode / Convertion",
"device.tag-scale-mode-undefined": "No Scaling",
"device.tag-scale-mode-undefined-tooltip": "Tag Value",
@@ -1812,6 +1814,11 @@
"dlg.app-settings-daqstore": "DAQ storage",
"dlg.app-settings-daqstore-type": "Database type",
"dlg.app-settings-daqstore-url": "URL",
+ "dlg.app-settings-daqstore-configurl": "Config URL",
+ "dlg.app-settings-daqstore-queryconn": "Query connection (PGWire)",
+
+ "dlg.app-settings-daqstore-questdb-host": "QuestDB Host",
+ "dlg.app-settings-daqstore-ingestconn": "Ingestion connection (ILP)",
"dlg.app-settings-daqstore-token": "Token",
"dlg.app-settings-daqstore-bucket": "Bucket",
"dlg.app-settings-daqstore-organization": "Organization",
@@ -1931,3 +1938,6 @@
"msg.operation-unauthorized": "Operation Unauthorized!",
"msg.secret-code-required": "Secret code required to sign authentication tokens"
}
+
+
+
diff --git a/client/src/assets/i18n/es.json b/client/src/assets/i18n/es.json
index 9de460df5..54c2741da 100644
--- a/client/src/assets/i18n/es.json
+++ b/client/src/assets/i18n/es.json
@@ -544,6 +544,9 @@
"device-tag-dialog-title": "Etiqueta seleccionada",
"device.tag-options-title": "Opciones de etiquetas",
+
+
+ "device.tag-uns-path": "UNS Path",
"device.tag-daq-enabled": "Registration Enabled",
"device.tag-daq-changed": "Guardar valor si cambia",
"device.tag-daq-interval": "Intervalo para guardar valor (segundos)",
@@ -736,6 +739,12 @@
"dlg.app-settings-server-port": "Servidor esta escuchando el puerto",
"dlg.app-settings-alarms-clear": "Borrar todas las alarmas y el historias",
"dlg.app-settings-auth-token": "Autenticación con token",
+ "dlg.app-settings-daqstore-url": "URL",
+ "dlg.app-settings-daqstore-configurl": "Config URL",
+ "dlg.app-settings-daqstore-queryconn": "Query connection (PGWire)",
+
+ "dlg.app-settings-daqstore-questdb-host": "QuestDB Host",
+ "dlg.app-settings-daqstore-ingestconn": "Ingestion connection (ILP)",
"dlg.app-auth-disabled": "Deshabilitado",
"dlg.app-auth-expiration-15m": "Habilitado con token expira en 15 min.",
"dlg.app-auth-expiration-1h": "Habilitado con token expira en 1 hora.",
@@ -774,3 +783,6 @@
"msg.editor-mode-locked": "El editor ya esta abierto!",
"msg.alarms-clear-success": "Todas las alarmas han sido canceladas!"
}
+
+
+
diff --git a/client/src/assets/i18n/fr.json b/client/src/assets/i18n/fr.json
index c51af46be..746ddaa27 100755
--- a/client/src/assets/i18n/fr.json
+++ b/client/src/assets/i18n/fr.json
@@ -1009,6 +1009,8 @@
"device.tag-format": "Format du nombre (2 = #.##)",
"device.tag-deadband": "Zone morte",
+
+ "device.tag-uns-path": "UNS Path",
"device.tag-scale": "Mode mise à l'échelle / Conversion",
"device.tag-scale-mode-undefined": "Pas de mise à l'échelle",
"device.tag-scale-mode-undefined-tooltip": "Valeur du tag",
@@ -1618,6 +1620,11 @@
"dlg.app-settings-daqstore": "Stockage DAQ",
"dlg.app-settings-daqstore-type": "Type de base de données",
"dlg.app-settings-daqstore-url": "URL",
+ "dlg.app-settings-daqstore-configurl": "Config URL",
+ "dlg.app-settings-daqstore-queryconn": "Query connection (PGWire)",
+
+ "dlg.app-settings-daqstore-questdb-host": "QuestDB Host",
+ "dlg.app-settings-daqstore-ingestconn": "Ingestion connection (ILP)",
"dlg.app-settings-daqstore-token": "Jeton",
"dlg.app-settings-daqstore-bucket": "Seau",
"dlg.app-settings-daqstore-organization": "Organisation",
@@ -1715,3 +1722,6 @@
"msg.texts-text-remove": "Souhaitez-vous supprimer le texte '{{value}}' ?",
"msg.operation-unauthorized": "Opération non autorisée !"
}
+
+
+
diff --git a/client/src/assets/i18n/ja.json b/client/src/assets/i18n/ja.json
index dfb6be4a4..df55c4d84 100644
--- a/client/src/assets/i18n/ja.json
+++ b/client/src/assets/i18n/ja.json
@@ -1031,6 +1031,8 @@
"device.tag-format": "桁数フォーマット (2 = #.##)",
"device.tag-deadband": "デッドバンド",
+
+ "device.tag-uns-path": "UNS Path",
"device.tag-scale": "スケーリングモード / 変換",
"device. tag-scale-mode-undefined": "スケーリングなし",
"device.tag-scale-mode-undefined-tooltip": "タグ値",
@@ -1652,6 +1654,11 @@
"dlg.app-settings-daqstore": "DAQストレージ",
"dlg.app-settings-daqstore-type": "データベースタイプ",
"dlg.app-settings-daqstore-url": "URL",
+ "dlg.app-settings-daqstore-configurl": "Config URL",
+ "dlg.app-settings-daqstore-queryconn": "Query connection (PGWire)",
+
+ "dlg.app-settings-daqstore-questdb-host": "QuestDB Host",
+ "dlg.app-settings-daqstore-ingestconn": "Ingestion connection (ILP)",
"dlg.app-settings-daqstore-token": "トークン",
"dlg.app-settings-daqstore-bucket": "バケット",
"dlg.app-settings-daqstore-organization": "組織",
@@ -1748,4 +1755,6 @@
"msg.text-name-exist": "テキスト名が既に存在します!",
"msg.texts-text-remove": "テキスト '{{value}}' を削除しますか?",
"msg.operation-unauthorized": "操作が許可されていません!"
-}
\ No newline at end of file
+}
+
+
diff --git a/client/src/assets/i18n/ko.json b/client/src/assets/i18n/ko.json
index 47dfa4d04..5305bb971 100644
--- a/client/src/assets/i18n/ko.json
+++ b/client/src/assets/i18n/ko.json
@@ -544,6 +544,9 @@
"device-tag-dialog-title": "태그 선택",
"device.tag-options-title": "태그 옵션",
+
+
+ "device.tag-uns-path": "UNS Path",
"device.tag-daq-enabled": "등록 활성화",
"device.tag-daq-changed": "값 변경 가능시 저장",
"device.tag-daq-interval": "값 차이 저장 (sec.)",
@@ -734,6 +737,12 @@
"dlg.app-settings-server-port": "서버가 포팅 수신 대기중",
"dlg.app-settings-alarms-clear": "모든 알림과 기록 삭제",
"dlg.app-settings-auth-token": "토큰 권한",
+ "dlg.app-settings-daqstore-url": "URL",
+ "dlg.app-settings-daqstore-configurl": "Config URL",
+ "dlg.app-settings-daqstore-queryconn": "Query connection (PGWire)",
+
+ "dlg.app-settings-daqstore-questdb-host": "QuestDB Host",
+ "dlg.app-settings-daqstore-ingestconn": "Ingestion connection (ILP)",
"dlg.app-auth-disabled": "비활성화",
"dlg.app-auth-expiration-15m": "15분 후 토큰 만료로 활성화.",
"dlg.app-auth-expiration-1h": "1시간 후 토큰 만료로 활성화.",
@@ -772,3 +781,6 @@
"msg.editor-mode-locked": "편집자가 이미 열려있습니다!",
"msg.alarms-clear-success": "모든 알림이 취소되었습니다!"
}
+
+
+
diff --git a/client/src/assets/i18n/pt.json b/client/src/assets/i18n/pt.json
index 298abcad7..be4e4b396 100644
--- a/client/src/assets/i18n/pt.json
+++ b/client/src/assets/i18n/pt.json
@@ -625,6 +625,12 @@
"dlg.app-language-zh-cn": "Chinês",
"dlg.app-settings-server-port": "Servidor está à escuta no porto",
"dlg.app-settings-auth-token": "Autentificar com Token",
+ "dlg.app-settings-daqstore-url": "URL",
+ "dlg.app-settings-daqstore-configurl": "Config URL",
+ "dlg.app-settings-daqstore-queryconn": "Query connection (PGWire)",
+
+ "dlg.app-settings-daqstore-questdb-host": "QuestDB Host",
+ "dlg.app-settings-daqstore-ingestconn": "Ingestion connection (ILP)",
"dlg.app-auth-disabled": "Desativado",
"dlg.app-auth-expiration-15m": "Ativar token com expiração em 15 minutos.",
"dlg.app-auth-expiration-1h": "Ativar token com expiração em 1 horas.",
@@ -656,5 +662,6 @@
"msg.project-save-ask": "De certeza que quer sair do Projeto?",
"msg.login-username-required": "Entra o Utilizador",
"msg.login-password-required": "Entra uma palavra-passe",
- "msg.signin-failed": "Não autorizado"
-}
+ "msg.signin-failed": "Não autorizado",
+ "device.tag-uns-path": "UNS Path"
+}
\ No newline at end of file
diff --git a/client/src/assets/i18n/ru.json b/client/src/assets/i18n/ru.json
index 0676f8952..75997a688 100644
--- a/client/src/assets/i18n/ru.json
+++ b/client/src/assets/i18n/ru.json
@@ -782,6 +782,9 @@
"device-tag-dialog-title": "Выбор тега",
"device.tag-options-title": "Свойства тегов",
+
+
+ "device.tag-uns-path": "UNS Path",
"device.tag-daq-enabled": "Регистрация включена",
"device.tag-daq-changed": "Сохранять значения по изменению",
"device.tag-daq-interval": "Интервал сохранения значений (сек.)",
@@ -1170,6 +1173,11 @@
"dlg.app-settings-daqstore": "Хранилище данных",
"dlg.app-settings-daqstore-type": "СУБД",
"dlg.app-settings-daqstore-url": "URL",
+ "dlg.app-settings-daqstore-configurl": "Config URL",
+ "dlg.app-settings-daqstore-queryconn": "Query connection (PGWire)",
+
+ "dlg.app-settings-daqstore-questdb-host": "QuestDB Host",
+ "dlg.app-settings-daqstore-ingestconn": "Ingestion connection (ILP)",
"dlg.app-settings-daqstore-token": "Token",
"dlg.app-settings-daqstore-bucket": "Bucket",
"dlg.app-settings-daqstore-organization": "Организация",
@@ -1226,3 +1234,6 @@
"msg.report-build-error": "Send to build Report failed!",
"msg.device-tags-request-result": "Загружено {{value}} из {{current}}"
}
+
+
+
diff --git a/client/src/assets/i18n/sv.json b/client/src/assets/i18n/sv.json
index 8d7b01e4c..6afafc837 100644
--- a/client/src/assets/i18n/sv.json
+++ b/client/src/assets/i18n/sv.json
@@ -1003,6 +1003,8 @@
"device.tag-format": "Format decimaler (2 = #.##)",
"device.tag-deadband": "Deadband",
+
+ "device.tag-uns-path": "UNS Path",
"device.tag-scale": "Skalningsläge / Konvertering",
"device.tag-scale-mode-undefined": "Ingen skalning",
"device.tag-scale-mode-undefined-tooltip": "Taggvärde",
@@ -1599,6 +1601,11 @@
"dlg.app-settings-daqstore": "DAQ-lagring",
"dlg.app-settings-daqstore-type": "Databastyp",
"dlg.app-settings-daqstore-url": "URL",
+ "dlg.app-settings-daqstore-configurl": "Config URL",
+ "dlg.app-settings-daqstore-queryconn": "Query connection (PGWire)",
+
+ "dlg.app-settings-daqstore-questdb-host": "QuestDB Host",
+ "dlg.app-settings-daqstore-ingestconn": "Ingestion connection (ILP)",
"dlg.app-settings-daqstore-token": "Token",
"dlg.app-settings-daqstore-bucket": "Bucket",
"dlg.app-settings-daqstore-organization": "Organisation",
@@ -1693,4 +1700,6 @@
"msg.text-name-exist": "Textnamnet finns redan!",
"msg.texts-text-remove": "Vill du ta bort texten '{{value}}'?",
"msg.operation-unauthorized": "Operation obehörig!"
-}
\ No newline at end of file
+}
+
+
diff --git a/client/src/assets/i18n/tr.json b/client/src/assets/i18n/tr.json
index 0643a97d5..6a2888651 100644
--- a/client/src/assets/i18n/tr.json
+++ b/client/src/assets/i18n/tr.json
@@ -638,6 +638,12 @@
"dlg.app-language-tr": "Turkish",
"dlg.app-settings-server-port": "Sunucuya erişmek için port",
"dlg.app-settings-auth-token": "Anahtar şifre işe yetkilendir",
+ "dlg.app-settings-daqstore-url": "URL",
+ "dlg.app-settings-daqstore-configurl": "Config URL",
+ "dlg.app-settings-daqstore-queryconn": "Query connection (PGWire)",
+
+ "dlg.app-settings-daqstore-questdb-host": "QuestDB Host",
+ "dlg.app-settings-daqstore-ingestconn": "Ingestion connection (ILP)",
"dlg.app-auth-disabled": "Etkisiz",
"dlg.app-auth-expiration-15m": "Anahtar şifre 15dk içinde geçerliliğini yitirecek.",
@@ -669,5 +675,6 @@
"msg.project-save-ask": "Projeden çıkmak istiyor musunuz?",
"msg.login-username-required": "Kullanıcı adı girin",
"msg.login-password-required": "Şifre girin",
- "msg.signin-failed": "Yetkisiz giriş."
+ "msg.signin-failed": "Yetkisiz giriş.",
+ "device.tag-uns-path": "UNS Path"
}
\ No newline at end of file
diff --git a/client/src/assets/i18n/ua.json b/client/src/assets/i18n/ua.json
index d93e644fa..ad0451fbd 100644
--- a/client/src/assets/i18n/ua.json
+++ b/client/src/assets/i18n/ua.json
@@ -578,6 +578,12 @@
"dlg.app-language-ua": "Ukrainian",
"dlg.app-settings-server-port": "Порт сервера",
"dlg.app-settings-auth-token": "Авторизація з токеном",
+ "dlg.app-settings-daqstore-url": "URL",
+ "dlg.app-settings-daqstore-configurl": "Config URL",
+ "dlg.app-settings-daqstore-queryconn": "Query connection (PGWire)",
+
+ "dlg.app-settings-daqstore-questdb-host": "QuestDB Host",
+ "dlg.app-settings-daqstore-ingestconn": "Ingestion connection (ILP)",
"dlg.app-auth-disabled": "Вимкнено",
"dlg.app-auth-expiration-15m": "Увімкнено. Автоматичний вихід через 15хв.",
"dlg.app-auth-expiration-1h": "Увімкнено. Автоматичний вихід через 1год.",
@@ -610,5 +616,6 @@
"msg.project-save-ask": "При закритті вікна всі не збережені зміни пропадуть!",
"msg.login-username-required": "Користувач",
"msg.login-password-required": "Пароль",
- "msg.signin-failed": "Не авторизований"
-}
+ "msg.signin-failed": "Не авторизований",
+ "device.tag-uns-path": "UNS Path"
+}
\ No newline at end of file
diff --git a/client/src/assets/i18n/zh-cn.json b/client/src/assets/i18n/zh-cn.json
index 04b0e768e..b57a97eec 100644
--- a/client/src/assets/i18n/zh-cn.json
+++ b/client/src/assets/i18n/zh-cn.json
@@ -1010,6 +1010,8 @@
"device.tag-format": "格式化数字(2 = #.##)",
"device.tag-deadband": "死区",
+
+ "device.tag-uns-path": "UNS Path",
"device.tag-scale": "刻度模式",
"device.tag-scale-mode-undefined": "无缩放",
"device.tag-scale-mode-undefined-tooltip": "标签值",
@@ -1621,6 +1623,11 @@
"dlg.app-settings-daqstore": "DAQ存储",
"dlg.app-settings-daqstore-type": "数据库类型",
"dlg.app-settings-daqstore-url": "URL",
+ "dlg.app-settings-daqstore-configurl": "Config URL",
+ "dlg.app-settings-daqstore-queryconn": "Query connection (PGWire)",
+
+ "dlg.app-settings-daqstore-questdb-host": "QuestDB Host",
+ "dlg.app-settings-daqstore-ingestconn": "Ingestion connection (ILP)",
"dlg.app-settings-daqstore-token":"令牌",
"dlg.app-settings-daqstore-bucket": "Bucket",
"dlg.app-settings-daqstore-organization": "组织",
@@ -1718,3 +1725,6 @@
"msg.texts-text-remove": "您是否想删除文本'{{value}}'?",
"msg.operation-unauthorized": "操作未经授权!"
}
+
+
+
diff --git a/client/src/assets/i18n/zh-tw.json b/client/src/assets/i18n/zh-tw.json
index 7a288cfb0..ec201dfe1 100644
--- a/client/src/assets/i18n/zh-tw.json
+++ b/client/src/assets/i18n/zh-tw.json
@@ -1,4 +1,4 @@
-{
+{
"with param": "示例 {{value}}",
"app.home": "首頁",
@@ -1009,6 +1009,8 @@
"device.tag-format": "數字格式化(2 = #.##)",
"device.tag-deadband": "死區",
+
+ "device.tag-uns-path": "UNS Path",
"device.tag-scale": "縮放模式",
"device.tag-scale-mode-undefined": "無縮放",
"device.tag-scale-mode-undefined-tooltip": "標籤值",
@@ -1619,6 +1621,11 @@
"dlg.app-settings-daqstore": "DAQ 儲存",
"dlg.app-settings-daqstore-type": "資料庫類型",
"dlg.app-settings-daqstore-url": "URL",
+ "dlg.app-settings-daqstore-configurl": "Config URL",
+ "dlg.app-settings-daqstore-queryconn": "Query connection (PGWire)",
+
+ "dlg.app-settings-daqstore-questdb-host": "QuestDB Host",
+ "dlg.app-settings-daqstore-ingestconn": "Ingestion connection (ILP)",
"dlg.app-settings-daqstore-token":"權杖",
"dlg.app-settings-daqstore-bucket": "Bucket",
"dlg.app-settings-daqstore-organization": "組織",
@@ -1716,3 +1723,6 @@
"msg.texts-text-remove": "您是否要刪除文字 '{{value}}'?",
"msg.operation-unauthorized": "操作未授權!"
}
+
+
+
diff --git a/server/package-lock.json b/server/package-lock.json
index 010790393..d97ccea81 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,15 +1,16 @@
{
"name": "fuxa-server",
- "version": "1.3.0-2700",
+ "version": "1.3.0-2727",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fuxa-server",
- "version": "1.3.0-2700",
+ "version": "1.3.0-2727",
"license": "MIT",
"dependencies": {
"@influxdata/influxdb-client": "1.25.0",
+ "@questdb/nodejs-client": "^3.0.0",
"@tdengine/rest": "3.0.2",
"ads-client": "2.1.0",
"app-root-path": "2.2.1",
@@ -40,6 +41,7 @@
"nopt": "5.0.0",
"odbc": "2.4.9",
"pdfmake": "0.2.5",
+ "pg": "^8.18.0",
"socket.io": "4.8.1",
"sqlite3": "5.1.5",
"swagger-ui-express": "^5.0.1",
@@ -1197,6 +1199,12 @@
"tsyringe": "^4.8.0"
}
},
+ "node_modules/@questdb/nodejs-client": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@questdb/nodejs-client/-/nodejs-client-3.0.0.tgz",
+ "integrity": "sha512-lBKd732rRpS/pqyWgCJFVXi9N1YoWDAUZzp6BngBVxH92As/NDXigZmCYKQVKpAEGYx14606CH5AoJ23qf3oqA==",
+ "license": "Apache-2.0"
+ },
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
@@ -3531,6 +3539,7 @@
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
+ "peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -7541,6 +7550,105 @@
"resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="
},
+ "node_modules/pg": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
+ "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "pg-connection-string": "^2.11.0",
+ "pg-pool": "^3.11.0",
+ "pg-protocol": "^1.11.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.3.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+ "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
+ "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.11.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
+ "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
+ "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/pgpass/node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/picomatch/-/picomatch-2.3.1.tgz",
@@ -7566,6 +7674,45 @@
"node": ">= 0.4"
}
},
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/precond": {
"version": "0.2.3",
"resolved": "https://registry.npmmirror.com/precond/-/precond-0.2.3.tgz",
@@ -8839,6 +8986,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"devOptional": true,
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
diff --git a/server/package.json b/server/package.json
index 29867e3bf..712db4caa 100644
--- a/server/package.json
+++ b/server/package.json
@@ -16,6 +16,7 @@
},
"dependencies": {
"@influxdata/influxdb-client": "1.25.0",
+ "@questdb/nodejs-client": "^3.0.0",
"@tdengine/rest": "3.0.2",
"ads-client": "2.1.0",
"app-root-path": "2.2.1",
@@ -46,6 +47,7 @@
"nopt": "5.0.0",
"odbc": "2.4.9",
"pdfmake": "0.2.5",
+ "pg": "^8.18.0",
"socket.io": "4.8.1",
"sqlite3": "5.1.5",
"swagger-ui-express": "^5.0.1",
diff --git a/server/runtime/storage/daqstorage.js b/server/runtime/storage/daqstorage.js
index ea4b254a4..fddfbfd9c 100644
--- a/server/runtime/storage/daqstorage.js
+++ b/server/runtime/storage/daqstorage.js
@@ -21,6 +21,7 @@ var logger;
var daqDB = {}; // list of daqDB node: SQlite one pro device, influxDB only one
var currentStorateDB;
var runtime;
+var questDbModule = null;
function init(_settings, _log, _runtime) {
settings = _settings;
@@ -42,7 +43,7 @@ function reset() {
function addDaqNode(_id, fncgetprop) {
var id = _id;
const dbType = _getDbType();
- if (dbType === DaqStoreTypeEnum.influxDB || dbType === DaqStoreTypeEnum.influxDB18 || dbType === DaqStoreTypeEnum.TDengine) {
+ if (dbType === DaqStoreTypeEnum.influxDB || dbType === DaqStoreTypeEnum.influxDB18 || dbType === DaqStoreTypeEnum.TDengine || dbType === DaqStoreTypeEnum.questDB) {
id = dbType;
}
if (!daqDB[id]) {
@@ -50,6 +51,14 @@ function addDaqNode(_id, fncgetprop) {
daqDB[id] = InfluxDB.create(settings, logger, currentStorateDB);
} else if(id === DaqStoreTypeEnum.TDengine){
daqDB[id] = TDengine.create(settings, logger, currentStorateDB);
+ } else if(id === DaqStoreTypeEnum.questDB){
+ const QuestDB = _getQuestDbModule();
+ if (QuestDB) {
+ daqDB[id] = QuestDB.create(settings, logger, currentStorateDB);
+ } else {
+ logger.warn('daqstorage: QuestDB storage selected but package dependencies are not installed. Falling back to SQLite.');
+ daqDB[id] = SqliteDB.create(settings, logger, id, currentStorateDB);
+ }
} else {
daqDB[id] = SqliteDB.create(settings, logger, id, currentStorateDB);
}
@@ -160,16 +169,33 @@ function _getDaqNode(tagid) {
function _getDbType() {
if (settings.daqstore && settings.daqstore.type) {
+ if (settings.daqstore.type === 'QuestDB') {
+ return DaqStoreTypeEnum.questDB;
+ }
return settings.daqstore.type;
}
return DaqStoreTypeEnum.SQlite;
}
+function _getQuestDbModule() {
+ if (questDbModule) {
+ return questDbModule;
+ }
+ try {
+ questDbModule = require("./questdb");
+ } catch (err) {
+ questDbModule = null;
+ logger.warn(`daqstorage: QuestDB module unavailable (${err.message})`);
+ }
+ return questDbModule;
+}
+
var DaqStoreTypeEnum = {
SQlite: 'SQlite',
influxDB: 'influxDB',
influxDB18: 'influxDB18',
TDengine: 'TDengine',
+ questDB: 'questDB',
}
function _getValue(value) {
@@ -187,4 +213,4 @@ module.exports = {
getNodesValues: getNodesValues,
checkRetention: checkRetention,
getCurrentStorageFnc: getCurrentStorageFnc,
-};
\ No newline at end of file
+};
diff --git a/server/runtime/storage/questdb/index.js b/server/runtime/storage/questdb/index.js
new file mode 100644
index 000000000..2cad732cc
--- /dev/null
+++ b/server/runtime/storage/questdb/index.js
@@ -0,0 +1,224 @@
+'use strict'
+
+const { Pool } = require('pg');
+const { Sender } = require('@questdb/nodejs-client');
+let utils = require('../../utils');
+
+function QuestDB(_settings, _log, _currentStorage) {
+ let settings = _settings; // Application settings
+ const logger = _log; // Application logger
+ const currentStorage = _currentStorage; // Database to set the last value (current)
+ let pool = null;
+ let sender = null;
+ const tableName = getTableName();
+ let writeQueue = Promise.resolve();
+ let initPromise = Promise.resolve();
+
+ this.setCall = function (_fncGetProp) {
+ fncGetTagProp = _fncGetProp;
+ return this.addDaqValues;
+ }
+ var fncGetTagProp = null;
+
+ this.init = async function () {
+ try {
+ pool = new Pool(getQueryClientConfig());
+ sender = await Sender.fromConfig(getIngestConfigString());
+ await ensureSchema();
+ logger.info('QuestDB connected');
+ } catch (error) {
+ logger.error(`questdb-init failed! ${error}`);
+ }
+ }
+
+ this.addDaqValues = function (tagsValues, deviceName, deviceId) {
+ var dataToRestore = [];
+ var rowsToWrite = [];
+
+ for (const tagid in tagsValues) {
+ let tag = tagsValues[tagid];
+ if (!tag.daq || utils.isNullOrUndefined(tag.value) || Number.isNaN(tag.value)) {
+ if (tag.daq && tag.daq.restored) {
+ dataToRestore.push({ id: tag.id, deviceId: deviceId, value: tag.value });
+ }
+ if (tag.daq && !tag.daq.enabled) {
+ continue;
+ }
+ }
+
+ rowsToWrite.push({
+ tagid,
+ deviceId,
+ deviceName: deviceName || '',
+ unsPath: normalizeUnsPath(tag.unsPath),
+ value: tag.value,
+ timestamp: tag.timestamp || Date.now(),
+ });
+ }
+
+ if (rowsToWrite.length) {
+ writeQueue = writeQueue.then(async () => {
+ await initPromise;
+ if (!sender) {
+ return;
+ }
+
+ for (const row of rowsToWrite) {
+ const parsedValue = normalizeValue(row.value);
+ let line = sender
+ .table(tableName)
+ .symbol('tag_id', row.tagid)
+ .symbol('device_id', row.deviceId)
+ .stringColumn('device_name', row.deviceName);
+ if (!utils.isNullOrUndefined(row.unsPath)) {
+ line = line.stringColumn('uns_path', row.unsPath);
+ }
+
+ if (!utils.isNullOrUndefined(parsedValue.numberValue)) {
+ line = line.floatColumn('number_value', parsedValue.numberValue);
+ }
+ if (!utils.isNullOrUndefined(parsedValue.stringValue)) {
+ line = line.stringColumn('string_value', parsedValue.stringValue);
+ }
+ await line.at(Number(row.timestamp), 'ms');
+ }
+ await sender.flush();
+ }).catch((error) => {
+ logger.error(`questdb-addDaqValues failed! ${error}`);
+ });
+ }
+
+ if (dataToRestore.length && currentStorage) {
+ currentStorage.setValues(dataToRestore);
+ }
+ }
+
+ this.getDaqValue = function (tagid, fromts, tots) {
+ return new Promise(function (resolve, reject) {
+ initPromise.then(() => {
+ if (!pool) {
+ resolve([]);
+ return;
+ }
+
+ const query = `SELECT timestamp, number_value, string_value
+ FROM ${tableName}
+ WHERE tag_id = $1
+ AND timestamp >= $2
+ AND timestamp < $3
+ ORDER BY timestamp`;
+ const params = [tagid, new Date(fromts), new Date(tots)];
+
+ pool.query(query, params)
+ .then((result) => {
+ let data = [];
+ result.rows.forEach((row) => {
+ const value = !utils.isNullOrUndefined(row.number_value) ? Number(row.number_value) : row.string_value;
+ data.push({ dt: new Date(row.timestamp).getTime(), value });
+ });
+ resolve(data)
+ })
+ .catch((error) => {
+ logger.error(`questdb-getDaqValue failed! ${error}`)
+ reject(error)
+ })
+ }).catch((error) => {
+ logger.error(`questdb-getDaqValue failed! ${error}`)
+ reject(error)
+ });
+ })
+ }
+
+ this.close = function () {
+ if (sender) {
+ sender.close().catch((error) => {
+ logger.error(`questdb-close sender failed! ${error}`);
+ });
+ sender = null;
+ }
+ if (pool) {
+ pool.end().catch((error) => {
+ logger.error(`questdb-close pool failed! ${error}`);
+ });
+ pool = null;
+ }
+ }
+
+ this.getDaqMap = function (tagid) {
+ var dummy = {};
+ dummy[tagid] = true;
+ return dummy;
+ }
+
+ async function ensureSchema() {
+ if (!pool) {
+ return;
+ }
+ await pool.query(`CREATE TABLE IF NOT EXISTS ${tableName} (
+ timestamp TIMESTAMP,
+ tag_id SYMBOL,
+ number_value FLOAT,
+ string_value STRING,
+ device_id SYMBOL,
+ device_name STRING,
+ uns_path SYMBOL
+ ) TIMESTAMP(timestamp) PARTITION BY DAY`);
+ }
+
+ function getIngestConfigString() {
+ return settings.daqstore.configurationString || 'http::addr=localhost:9000;';
+ }
+
+ function getQueryClientConfig() {
+ return {
+ host: settings.daqstore.host || '127.0.0.1',
+ port: 8812, // Standard port
+ database: 'qdb', // Standard database
+ user: settings.daqstore.credentials?.username || 'admin',
+ password: settings.daqstore.credentials?.password || 'quest',
+ max: 10, // Pool with 10 connections
+ idleTimeoutMillis: 30000, // 30s
+ };
+ }
+
+ function getTableName() {
+ const name = (settings.daqstore.tableName || 'meters').trim();
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
+ return name.toLowerCase();
+ }
+ logger.warn(`questdb invalid tableName "${name}", fallback to meters`);
+ return 'meters';
+ }
+
+ function normalizeValue(value) {
+ if (utils.isNullOrUndefined(value)) {
+ return { numberValue: null, stringValue: null };
+ }
+ if (utils.isBoolean(value)) {
+ return {
+ numberValue: value ? 1 : 0,
+ stringValue: null
+ };
+ }
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return { numberValue: value, stringValue: null };
+ }
+ return { numberValue: null, stringValue: String(value) };
+ }
+
+ function normalizeUnsPath(value) {
+ if (utils.isNullOrUndefined(value)) {
+ return null;
+ }
+ const normalized = String(value).trim();
+ return normalized.length ? normalized : null;
+ }
+
+ initPromise = this.init();
+}
+
+module.exports = {
+ create: function (data, logger, currentStorage) {
+ return new QuestDB(data, logger, currentStorage);
+ }
+};