@yarkivaev/scada 2.3.112 → 2.3.114

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -274,3 +274,15 @@ Depend on a pinned version (`"@yarkivaev/scada": "…#v2.3.46"`), not a local pa
274
274
  ## CI/CD
275
275
 
276
276
  GitHub Actions via `yarkivaev/npm-workflows` (lint, test, version check). This repo publishes an npm package only; container images are built in downstream deploy repos.
277
+
278
+ ### Ingest delivery and quarantine
279
+
280
+ `STOMP_PREFETCH` sets a positive, bounded segment delivery window (default 128).
281
+ `SEGMENTS_DEAD_LETTER_ENABLED=true` opts into rejecting malformed segment records
282
+ without requeue; transient persistence failures are retried. Enable this only with
283
+ a durable DLQ and routing policy. In the deployment DSL, configure
284
+ `profiles.<profile>.modules.message_bus.dead_letter.queue: scada.ingest.dlq`.
285
+ The scada chart declares the queue and applies an ingest-only dead-letter policy,
286
+ without changing existing queue arguments. The profile `env` is passed to both
287
+ site-server and supervisor-sink. DLQ records retain the original body and RabbitMQ
288
+ `x-death` metadata for inspection and deliberate replay; they are not auto-expired.
@@ -2,5 +2,5 @@ apiVersion: v2
2
2
  name: scada
3
3
  description: Generic SCADA site — site-server, supervisor-sink, collectors, Postgres, RabbitMQ, optional ClickHouse
4
4
  type: application
5
- version: 0.2.1
6
- appVersion: "2.3.112"
5
+ version: 0.2.4
6
+ appVersion: "2.3.114"
@@ -2,6 +2,31 @@
2
2
  app.kubernetes.io/name: scada
3
3
  app.kubernetes.io/instance: {{ .Release.Name }}
4
4
  app.kubernetes.io/managed-by: {{ .Release.Service }}
5
+ {{- include "scada.extraLabels" . }}
6
+ {{- end }}
7
+
8
+ {{- define "scada.extraLabels" -}}
9
+ {{- range $key, $val := (.Values.extraLabels | default dict) }}
10
+ {{ $key }}: {{ $val | quote }}
11
+ {{- end }}
12
+ {{- end }}
13
+
14
+ {{- define "scada.match" -}}
15
+ app: {{ .app }}
16
+ {{- range $key, $val := (.ctx.Values.extraLabels | default dict) }}
17
+ {{ $key }}: {{ $val | quote }}
18
+ {{- end }}
19
+ {{- end }}
20
+
21
+ {{- define "scada.claim" -}}
22
+ accessModes: ["ReadWriteOnce"]
23
+ {{- if .class }}
24
+ storageClassName: {{ .class | quote }}
25
+ {{- end }}
26
+ resources:
27
+ requests:
28
+ storage: {{ .size | quote }}
29
+ volumeMode: Filesystem
5
30
  {{- end }}
6
31
 
7
32
  {{- define "scada.device" -}}
@@ -12,9 +12,14 @@ spec:
12
12
  - name: http
13
13
  port: 8123
14
14
  targetPort: http
15
+ - name: native
16
+ port: 9000
17
+ targetPort: native
18
+ - name: metrics
19
+ port: 9363
20
+ targetPort: metrics
15
21
  selector:
16
- app: clickhouse
17
- app.kubernetes.io/instance: {{ .Release.Name }}
22
+ {{- include "scada.match" (dict "app" "clickhouse" "ctx" .) | nindent 4 }}
18
23
  ---
19
24
  apiVersion: apps/v1
20
25
  kind: StatefulSet
@@ -29,13 +34,12 @@ spec:
29
34
  replicas: 1
30
35
  selector:
31
36
  matchLabels:
32
- app: clickhouse
33
- app.kubernetes.io/instance: {{ .Release.Name }}
37
+ {{- include "scada.match" (dict "app" "clickhouse" "ctx" .) | nindent 6 }}
34
38
  template:
35
39
  metadata:
36
40
  labels:
37
41
  {{- include "scada.labels" . | nindent 8 }}
38
- app: clickhouse
42
+ {{- include "scada.match" (dict "app" "clickhouse" "ctx" .) | nindent 8 }}
39
43
  spec:
40
44
  containers:
41
45
  - name: clickhouse
@@ -43,6 +47,10 @@ spec:
43
47
  ports:
44
48
  - name: http
45
49
  containerPort: 8123
50
+ - name: native
51
+ containerPort: 9000
52
+ - name: metrics
53
+ containerPort: 9363
46
54
  volumeMounts:
47
55
  - name: data
48
56
  mountPath: /var/lib/clickhouse
@@ -50,8 +58,5 @@ spec:
50
58
  - metadata:
51
59
  name: data
52
60
  spec:
53
- accessModes: ["ReadWriteOnce"]
54
- resources:
55
- requests:
56
- storage: 20Gi
61
+ {{- include "scada.claim" (dict "size" .Values.clickhouse.storage "class" .Values.clickhouse.storageClass) | nindent 8 }}
57
62
  {{- end }}
@@ -62,14 +62,13 @@ spec:
62
62
  replicas: 1
63
63
  selector:
64
64
  matchLabels:
65
- app: collector
66
- app.kubernetes.io/instance: {{ $.Release.Name }}
65
+ {{- include "scada.match" (dict "app" "collector" "ctx" $) | nindent 6 }}
67
66
  scada/command: {{ $slug }}
68
67
  template:
69
68
  metadata:
70
69
  labels:
71
70
  {{- include "scada.labels" $ | nindent 8 }}
72
- app: collector
71
+ {{- include "scada.match" (dict "app" "collector" "ctx" $) | nindent 8 }}
73
72
  scada/command: {{ $slug }}
74
73
  spec:
75
74
  {{- with $.Values.pullSecret }}
@@ -13,8 +13,7 @@ spec:
13
13
  port: 5432
14
14
  targetPort: postgres
15
15
  selector:
16
- app: postgres
17
- app.kubernetes.io/instance: {{ .Release.Name }}
16
+ {{- include "scada.match" (dict "app" "postgres" "ctx" .) | nindent 4 }}
18
17
  ---
19
18
  apiVersion: apps/v1
20
19
  kind: StatefulSet
@@ -29,13 +28,12 @@ spec:
29
28
  replicas: 1
30
29
  selector:
31
30
  matchLabels:
32
- app: postgres
33
- app.kubernetes.io/instance: {{ .Release.Name }}
31
+ {{- include "scada.match" (dict "app" "postgres" "ctx" .) | nindent 6 }}
34
32
  template:
35
33
  metadata:
36
34
  labels:
37
35
  {{- include "scada.labels" . | nindent 8 }}
38
- app: postgres
36
+ {{- include "scada.match" (dict "app" "postgres" "ctx" .) | nindent 8 }}
39
37
  spec:
40
38
  containers:
41
39
  - name: postgres
@@ -56,6 +54,8 @@ spec:
56
54
  secretKeyRef:
57
55
  name: {{ .Values.postgres.secret }}
58
56
  key: password
57
+ - name: PGDATA
58
+ value: {{ .Values.postgres.data | quote }}
59
59
  volumeMounts:
60
60
  - name: data
61
61
  mountPath: /var/lib/postgresql/data
@@ -63,8 +63,5 @@ spec:
63
63
  - metadata:
64
64
  name: data
65
65
  spec:
66
- accessModes: ["ReadWriteOnce"]
67
- resources:
68
- requests:
69
- storage: 10Gi
66
+ {{- include "scada.claim" (dict "size" .Values.postgres.storage "class" .Values.postgres.storageClass) | nindent 8 }}
70
67
  {{- end }}
@@ -35,6 +35,11 @@
35
35
  {{- if $outgoing }}
36
36
  {{- $queue = append $queue (dict "name" "scada.telemetry.outbox" "vhost" "/" "durable" true "auto_delete" false) -}}
37
37
  {{- end }}
38
+ {{- $policies := list -}}
39
+ {{- with .Values.rabbitmq.deadLetter }}
40
+ {{- $queue = append $queue (dict "name" .queue "vhost" "/" "durable" true "auto_delete" false) -}}
41
+ {{- $policies = append $policies (dict "name" "ingest-dlq" "vhost" "/" "pattern" "^scada\\.(segments|operations|telemetry)\\.ingest$" "apply-to" "queues" "priority" 10 "definition" (dict "dead-letter-exchange" "" "dead-letter-routing-key" .queue)) -}}
42
+ {{- end }}
38
43
  {{- $binding := list -}}
39
44
  {{- range $key := $ingest }}
40
45
  {{- $binding = append $binding (dict "source" "scada.telemetry" "vhost" "/" "destination" "scada.telemetry.ingest" "destination_type" "queue" "routing_key" $key) -}}
@@ -54,6 +59,7 @@
54
59
  "permissions" (list (dict "user" "guest" "vhost" "/" "configure" ".*" "write" ".*" "read" ".*"))
55
60
  "exchanges" $exchange
56
61
  "queues" $queue
62
+ "policies" $policies
57
63
  "bindings" $binding
58
64
  -}}
59
65
  apiVersion: v1
@@ -85,19 +91,38 @@ metadata:
85
91
  {{- include "scada.labels" . | nindent 4 }}
86
92
  app: rabbitmq
87
93
  spec:
94
+ {{- $svc := .Values.rabbitmq.service | default dict }}
95
+ type: {{ $svc.type | default "ClusterIP" }}
88
96
  ports:
89
97
  - name: amqp
90
98
  port: 5672
91
99
  targetPort: amqp
100
+ {{- with $svc.nodePorts }}
101
+ {{- if .amqp }}
102
+ nodePort: {{ .amqp }}
103
+ {{- end }}
104
+ {{- end }}
92
105
  - name: mqtt
93
106
  port: 1883
94
107
  targetPort: mqtt
95
108
  - name: stomp
96
109
  port: 61613
97
110
  targetPort: stomp
111
+ {{- with $svc.nodePorts }}
112
+ {{- if .stomp }}
113
+ nodePort: {{ .stomp }}
114
+ {{- end }}
115
+ {{- end }}
116
+ - name: management
117
+ port: 15672
118
+ targetPort: management
119
+ {{- with $svc.nodePorts }}
120
+ {{- if .management }}
121
+ nodePort: {{ .management }}
122
+ {{- end }}
123
+ {{- end }}
98
124
  selector:
99
- app: rabbitmq
100
- app.kubernetes.io/instance: {{ .Release.Name }}
125
+ {{- include "scada.match" (dict "app" "rabbitmq" "ctx" .) | nindent 4 }}
101
126
  ---
102
127
  apiVersion: apps/v1
103
128
  kind: StatefulSet
@@ -112,13 +137,12 @@ spec:
112
137
  replicas: 1
113
138
  selector:
114
139
  matchLabels:
115
- app: rabbitmq
116
- app.kubernetes.io/instance: {{ .Release.Name }}
140
+ {{- include "scada.match" (dict "app" "rabbitmq" "ctx" .) | nindent 6 }}
117
141
  template:
118
142
  metadata:
119
143
  labels:
120
144
  {{- include "scada.labels" . | nindent 8 }}
121
- app: rabbitmq
145
+ {{- include "scada.match" (dict "app" "rabbitmq" "ctx" .) | nindent 8 }}
122
146
  spec:
123
147
  containers:
124
148
  - name: rabbitmq
@@ -130,9 +154,18 @@ spec:
130
154
  containerPort: 1883
131
155
  - name: stomp
132
156
  containerPort: 61613
157
+ - name: management
158
+ containerPort: 15672
133
159
  volumeMounts:
134
160
  - name: config
135
- mountPath: /etc/rabbitmq
161
+ mountPath: /etc/rabbitmq/enabled_plugins
162
+ subPath: enabled_plugins
163
+ - name: config
164
+ mountPath: /etc/rabbitmq/rabbitmq.conf
165
+ subPath: rabbitmq.conf
166
+ - name: config
167
+ mountPath: /etc/rabbitmq/definitions.json
168
+ subPath: definitions.json
136
169
  - name: data
137
170
  mountPath: /var/lib/rabbitmq
138
171
  volumes:
@@ -143,8 +176,5 @@ spec:
143
176
  - metadata:
144
177
  name: data
145
178
  spec:
146
- accessModes: ["ReadWriteOnce"]
147
- resources:
148
- requests:
149
- storage: 5Gi
179
+ {{- include "scada.claim" (dict "size" .Values.rabbitmq.storage "class" .Values.rabbitmq.storageClass) | nindent 8 }}
150
180
  {{- end }}
@@ -10,13 +10,12 @@ spec:
10
10
  replicas: 1
11
11
  selector:
12
12
  matchLabels:
13
- app: site-server
14
- app.kubernetes.io/instance: {{ .Release.Name }}
13
+ {{- include "scada.match" (dict "app" "site-server" "ctx" .) | nindent 6 }}
15
14
  template:
16
15
  metadata:
17
16
  labels:
18
17
  {{- include "scada.labels" . | nindent 8 }}
19
- app: site-server
18
+ {{- include "scada.match" (dict "app" "site-server" "ctx" .) | nindent 8 }}
20
19
  spec:
21
20
  {{- with .Values.pullSecret }}
22
21
  imagePullSecrets:
@@ -85,8 +84,7 @@ spec:
85
84
  port: {{ .Values.service.internalPort }}
86
85
  targetPort: internal-api
87
86
  selector:
88
- app: site-server
89
- app.kubernetes.io/instance: {{ .Release.Name }}
87
+ {{- include "scada.match" (dict "app" "site-server" "ctx" .) | nindent 4 }}
90
88
  {{- if gt (int .Values.service.nodePort) 0 }}
91
89
  ---
92
90
  apiVersion: v1
@@ -105,6 +103,5 @@ spec:
105
103
  targetPort: http
106
104
  nodePort: {{ .Values.service.nodePort }}
107
105
  selector:
108
- app: site-server
109
- app.kubernetes.io/instance: {{ .Release.Name }}
106
+ {{- include "scada.match" (dict "app" "site-server" "ctx" .) | nindent 4 }}
110
107
  {{- end }}
@@ -10,13 +10,12 @@ spec:
10
10
  replicas: 1
11
11
  selector:
12
12
  matchLabels:
13
- app: supervisor-sink
14
- app.kubernetes.io/instance: {{ .Release.Name }}
13
+ {{- include "scada.match" (dict "app" "supervisor-sink" "ctx" .) | nindent 6 }}
15
14
  template:
16
15
  metadata:
17
16
  labels:
18
17
  {{- include "scada.labels" . | nindent 8 }}
19
- app: supervisor-sink
18
+ {{- include "scada.match" (dict "app" "supervisor-sink" "ctx" .) | nindent 8 }}
20
19
  spec:
21
20
  {{- with .Values.pullSecret }}
22
21
  imagePullSecrets:
@@ -33,6 +32,10 @@ spec:
33
32
  - name: STATE_HTTP_PORT
34
33
  value: {{ .Values.service.internalPort | quote }}
35
34
  {{- include "scada.linkEnv" . | nindent 12 }}
35
+ {{- range .Values.env }}
36
+ - name: {{ .name }}
37
+ value: {{ .value | quote }}
38
+ {{- end }}
36
39
  readinessProbe:
37
40
  tcpSocket:
38
41
  port: state
@@ -60,5 +63,4 @@ spec:
60
63
  port: {{ .Values.service.internalPort }}
61
64
  targetPort: state
62
65
  selector:
63
- app: supervisor-sink
64
- app.kubernetes.io/instance: {{ .Release.Name }}
66
+ {{- include "scada.match" (dict "app" "supervisor-sink" "ctx" .) | nindent 4 }}
@@ -6,6 +6,7 @@ machines: []
6
6
  sites: []
7
7
  collectors: []
8
8
  env: []
9
+ extraLabels: {}
9
10
  service:
10
11
  port: 3000
11
12
  internalPort: 8081
@@ -15,12 +16,22 @@ postgres:
15
16
  image: postgres:16
16
17
  secret: scada-postgres
17
18
  database: scada
19
+ storage: 10Gi
20
+ storageClass: ""
21
+ data: /var/lib/postgresql/data/pgdata
18
22
  rabbitmq:
19
23
  enabled: true
24
+ deadLetter: null
20
25
  image: rabbitmq:3.13-management
26
+ storage: 5Gi
27
+ storageClass: ""
28
+ service:
29
+ type: ClusterIP
21
30
  clickhouse:
22
31
  enabled: false
23
32
  image: clickhouse/clickhouse-server:24.8
33
+ storage: 20Gi
34
+ storageClass: ""
24
35
  resources:
25
36
  requests:
26
37
  cpu: 100m
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.112",
3
+ "version": "2.3.114",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,7 +31,7 @@
31
31
  "@clickhouse/client": "^1.0.0",
32
32
  "@modelcontextprotocol/sdk": "^1.24.3",
33
33
  "@yarkivaev/simple-server": "1.0.2",
34
- "@yarkivaev/source-to-sink": "1.3.8",
34
+ "@yarkivaev/source-to-sink": "1.3.10",
35
35
  "amqplib": "^0.10.9",
36
36
  "pg": "^8.0.0",
37
37
  "stompit": "^1.0.0",
@@ -16,7 +16,8 @@ function bindOne(name, spec, secret) {
16
16
  return ['postgres', { enabled: spec.enabled !== false, secret: secret || 'scada-postgres' }];
17
17
  }
18
18
  if (name === 'message_bus') {
19
- return ['rabbitmq', { enabled: spec.enabled !== false }];
19
+ return ['rabbitmq', { enabled: spec.enabled !== false,
20
+ ...(spec.dead_letter ? { deadLetter: spec.dead_letter } : {}) }];
20
21
  }
21
22
  if (name === 'telemetry_store') {
22
23
  return ['clickhouse', { enabled: spec.enabled !== false }];
@@ -23,6 +23,10 @@ const appRoot = path.dirname(fileURLToPath(import.meta.url));
23
23
  */
24
24
  export function readDeploymentConfig(env) {
25
25
  const root = path.join(appRoot, '../..');
26
+ const prefetch = Number(env.STOMP_PREFETCH || '128');
27
+ if (!Number.isSafeInteger(prefetch) || prefetch < 1) {
28
+ throw new Error('STOMP_PREFETCH must be a positive safe integer');
29
+ }
26
30
  return {
27
31
  stomp: env.STOMP_URL,
28
32
  postgres: env.POSTGRES_URL,
@@ -39,6 +43,8 @@ export function readDeploymentConfig(env) {
39
43
  requestTimeoutMs: parseRequestTimeoutMs(env.REQUEST_TIMEOUT_MS),
40
44
  pipelines: env.SINK_PIPELINES !== 'false',
41
45
  pipeline: {
46
+ prefetch,
47
+ deadLetter: env.SEGMENTS_DEAD_LETTER_ENABLED === 'true',
42
48
  size: parseInt(env.BATCH_SIZE || '50', 10),
43
49
  interval: parseInt(env.FLUSH_INTERVAL || '2', 10),
44
50
  threshold: parseInt(env.CIRCUIT_THRESHOLD || '5', 10),
@@ -40,7 +40,7 @@ function isHeartbeat(parsed) {
40
40
  * @param {object} codec - downstream codec
41
41
  * @returns {Promise<void>}
42
42
  */
43
- async function flushLane(lane, codec) {
43
+ async function flushLane(lane, codec, deadLetter) {
44
44
  const batch = lane.pending;
45
45
  if (!batch) {
46
46
  return;
@@ -57,9 +57,11 @@ async function flushLane(lane, codec) {
57
57
  }
58
58
  } catch (error) {
59
59
  for (const settle of batch.settles) {
60
- settle.nack();
60
+ settle.nack(!(deadLetter && error.permanent));
61
+ }
62
+ if (!error.permanent) {
63
+ throw error;
61
64
  }
62
- throw error;
63
65
  }
64
66
  }
65
67
 
@@ -78,7 +80,12 @@ async function bufferHeartbeat(ctx, envelope, parsed) {
78
80
  lane.pending.payload = envelope.payload;
79
81
  lane.pending.settles.push(envelope.settle);
80
82
  } else {
81
- await api.flush(lane);
83
+ try {
84
+ await api.flush(lane);
85
+ } catch (error) {
86
+ envelope.settle.nack(true);
87
+ throw error;
88
+ }
82
89
  const pending = {
83
90
  key,
84
91
  destination: envelope.destination,
@@ -88,7 +95,7 @@ async function bufferHeartbeat(ctx, envelope, parsed) {
88
95
  const timer = setTimeout(() => {
89
96
  lane.queue = lane.queue.then(() => {
90
97
  return api.flush(lane);
91
- });
98
+ }).catch(() => { return undefined; });
92
99
  }, intervalMs);
93
100
  Object.assign(lane, { pending, timer });
94
101
  }
@@ -110,12 +117,12 @@ async function step(ctx, envelope, parsed) {
110
117
  await bufferHeartbeat(ctx, envelope, parsed);
111
118
  return;
112
119
  }
113
- await ctx.api.flush(ctx.lane);
114
120
  try {
121
+ await ctx.api.flush(ctx.lane);
115
122
  await ctx.codec.accept({ destination: envelope.destination, payload: envelope.payload });
116
123
  envelope.settle.ack();
117
124
  } catch (error) {
118
- envelope.settle.nack();
125
+ envelope.settle.nack(!(ctx.deadLetter && error.permanent));
119
126
  throw error;
120
127
  }
121
128
  }
@@ -148,9 +155,18 @@ export default function segmentCoalesce(codec, options = {}) {
148
155
  const lanes = new Map();
149
156
  const api = {
150
157
  accept(envelope) {
151
- const parsed = JSON.parse(envelope.payload);
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(envelope.payload);
161
+ if (!parsed || typeof parsed !== 'object') {
162
+ throw new Error('Segment must be an object');
163
+ }
164
+ } catch {
165
+ envelope.settle.nack(!options.deadLetter);
166
+ return Promise.resolve();
167
+ }
152
168
  const lane = laneState(lanes, parsed.machine);
153
- const ctx = { api, lane, codec, size, intervalMs };
169
+ const ctx = { api, lane, codec, size, intervalMs, deadLetter: options.deadLetter };
154
170
  lane.queue = lane.queue.then(() => {
155
171
  return step(ctx, envelope, parsed);
156
172
  }).catch(() => {
@@ -159,7 +175,7 @@ export default function segmentCoalesce(codec, options = {}) {
159
175
  return lane.queue;
160
176
  },
161
177
  async flush(lane) {
162
- await flushLane(lane, codec);
178
+ await flushLane(lane, codec, options.deadLetter);
163
179
  }
164
180
  };
165
181
  return api;
@@ -32,12 +32,19 @@ export default function segmentCodec(collector) {
32
32
  return {
33
33
  async accept(raw) {
34
34
  try {
35
- const parsed = JSON.parse(raw.payload);
35
+ let parsed;
36
+ let record;
37
+ try {
38
+ parsed = JSON.parse(raw.payload);
39
+ record = parsed.type === 'split-family' ? familyRecord(parsed) : segmentNormalize(parsed);
40
+ } catch (error) {
41
+ error.permanent = true;
42
+ throw error;
43
+ }
36
44
  if (parsed.type === 'split-family') {
37
- await collector.accept(familyRecord(parsed));
45
+ await collector.accept(record);
38
46
  return;
39
47
  }
40
- const record = segmentNormalize(parsed);
41
48
  await collector.accept(record);
42
49
  } catch (error) {
43
50
  processingErrorLog('segment_codec', error, { destination: raw.destination, payload: raw.payload });
@@ -60,6 +60,7 @@ export default function segmentPipeline(stomp, postgres, pool, config) {
60
60
  const dispatch = segmentDispatch(continued, retagSink(pool), splitSink, closeOrphanOpen(pool), pool);
61
61
  const coalesce = segmentCoalesce(segmentCodec(dispatch), {
62
62
  size: config.size,
63
+ deadLetter: config.deadLetter,
63
64
  interval: config.interval
64
65
  });
65
66
  const destination = config.segmentsDestination || segmentsIngestDestination;
@@ -68,7 +69,8 @@ export default function segmentPipeline(stomp, postgres, pool, config) {
68
69
  passcode: config.passcode,
69
70
  host: config.host,
70
71
  serial: false,
71
- manualAck: true
72
+ manualAck: true,
73
+ prefetch: config.prefetch
72
74
  });
73
75
  const silence = silentOpenWatch(closeSilentOpen(pool), budget, {
74
76
  intervalMs: (config.poll || 5) * 1000