@theotherwillembotha/node-red-loki 0.0.55 → 0.1.0

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/build/Nodes.html CHANGED
@@ -8,6 +8,128 @@
8
8
 
9
9
  -->
10
10
 
11
+ <script type="text/javascript">
12
+ RED.nodes.registerType('LokiServiceConfigNode', {
13
+ category: 'config',
14
+ label: function() {
15
+ return this.name
16
+ },
17
+ paletteLabel: 'Loki Service',
18
+ defaults: {
19
+ name: {
20
+ value: 'Loki Service',
21
+ required: true
22
+ },
23
+ url: {
24
+ value: 'http://localhost:3100',
25
+ required: true
26
+ },
27
+ userid: {
28
+ required: false
29
+ },
30
+ authtoken: {
31
+ required: false
32
+ },
33
+ tenantid: {
34
+ required: false
35
+ },
36
+ },
37
+ oneditprepare: function() {
38
+ // **** LokiServiceConfigNode **** //
39
+ {
40
+ $("#loki-service-test-btn").on("click", function() {
41
+ let $btn = $(this);
42
+ let $status = $("#loki-service-test-status");
43
+ $btn.prop("disabled", true);
44
+ $status.text("Testing...").css("color", "gray");
45
+ $.ajax({
46
+ url: "lokiservice/testconnection",
47
+ type: "POST",
48
+ contentType: "application/json; charset=utf-8",
49
+ data: JSON.stringify({
50
+ url: $("#node-config-input-url").val(),
51
+ userid: $("#node-config-input-userid").val(),
52
+ authtoken: $("#node-config-input-authtoken").val(),
53
+ tenantid: $("#node-config-input-tenantid").val(),
54
+ }),
55
+ success: function(response) {
56
+ if (response.success) {
57
+ $status.text("\u2714 Connected").css("color", "green");
58
+ } else {
59
+ let detail = response.error ? " \u2014 " + response.error : "";
60
+ $status.text("\u2718 Failed" + detail).css("color", "red");
61
+ }
62
+ },
63
+ error: function(jqXHR, textStatus, errorThrown) {
64
+ $status.text("\u2718 Failed \u2014 " + errorThrown).css("color", "red");
65
+ },
66
+ complete: function() {
67
+ $btn.prop("disabled", false);
68
+ }
69
+ });
70
+ });
71
+ }
72
+ },
73
+ oneditsave: function() {
74
+ // **** LokiServiceConfigNode **** //
75
+ {}
76
+ },
77
+ oneditcancel: function() {},
78
+ oneditdelete: function() {},
79
+ });
80
+
81
+ </script>
82
+
83
+
84
+ <script type="text/html" data-template-name='LokiServiceConfigNode'>
85
+ <div id='section_LokiServiceConfigNode'>
86
+ <div class="form-row">
87
+ <label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
88
+ <input type="text" id="node-config-input-name" />
89
+ </div>
90
+
91
+ <div class="form-row">
92
+ <label for="node-config-input-url"><i class="fa fa-server"></i> URL</label>
93
+ <input type="text" id="node-config-input-url" placeholder="http://localhost:3100" />
94
+ </div>
95
+
96
+ <div class="form-row">
97
+ <label for="node-config-input-tenantid"><i class="fa fa-building-o"></i> Tenant ID</label>
98
+ <input type="text" id="node-config-input-tenantid" placeholder="(optional)" />
99
+ </div>
100
+
101
+ <div class="form-row">
102
+ <label for="node-config-input-userid"><i class="fa fa-user"></i> Username</label>
103
+ <input type="text" id="node-config-input-userid" placeholder="(optional)" />
104
+ </div>
105
+
106
+ <div class="form-row">
107
+ <label for="node-config-input-authtoken"><i class="fa fa-key"></i> Auth Token</label>
108
+ <input type="password" id="node-config-input-authtoken" placeholder="(optional)" />
109
+ </div>
110
+
111
+ <div class="form-row" style="margin-top:12px;">
112
+ <label></label>
113
+ <button id="loki-service-test-btn" type="button" class="red-ui-button">
114
+ <i class="fa fa-plug"></i> Test Connection
115
+ </button>
116
+ <span id="loki-service-test-status" style="margin-left:10px; font-size:12px;"></span>
117
+ </div>
118
+
119
+ </div>
120
+ </script>
121
+
122
+ <script type="text/markdown" data-help-name='LokiServiceConfigNode'>
123
+ Shared connection configuration for a Grafana Loki instance. Referenced by the Loki Logger and Loki Query nodes.
124
+
125
+ ### Properties
126
+
127
+ : *name* (string) : Display label for this config node.
128
+ : *url* (string) : Base URL of the Loki instance, including port (e.g. `http://localhost:3100`).
129
+ : *tenant ID* (string) : Tenant identifier for multi-tenant deployments (`X-Scope-OrgID` header). Leave blank for single-tenant setups.
130
+ : *username* (string) : Username for basic authentication. Leave blank if auth is not required.
131
+ : *auth token* (string) : Password or token for basic authentication.
132
+ </script>
11
133
  <script type="text/javascript">
12
134
  RED.nodes.registerType('LokiLoggerConfigNode', {
13
135
  category: 'config',
@@ -28,10 +150,10 @@
28
150
  value: 'info',
29
151
  required: true
30
152
  },
31
- loki_host: {},
32
- loki_tenantid: {},
33
- loki_userid: {},
34
- loki_authtoken: {},
153
+ lokiservice: {
154
+ type: 'LokiServiceConfigNode',
155
+ required: true
156
+ },
35
157
  },
36
158
  oneditprepare: function() {
37
159
  // **** LokiLoggerConfigNode **** //
@@ -94,20 +216,8 @@
94
216
  </div>
95
217
 
96
218
  <div class="form-row">
97
- <label class="towb_editorlabel" for="node-config-input-loki_host"><i class="fa fa-server"></i> Host</label>
98
- <input class="required" type="text" id="node-config-input-loki_host" />
99
- </div>
100
- <div class="form-row">
101
- <label class="towb_editorlabel" for="node-config-input-loki_tenantid"><i class="fa fa-building-o"></i> Tenant ID</label>
102
- <input class="" type="text" id="node-config-input-loki_tenantid" />
103
- </div>
104
- <div class="form-row">
105
- <label class="towb_editorlabel" for="node-config-input-loki_userid"><i class="fa fa-user"></i> Username</label>
106
- <input class="" type="text" id="node-config-input-loki_userid" />
107
- </div>
108
- <div class="form-row">
109
- <label class="towb_editorlabel" for="node-config-input-loki_authtoken"><i class="fa fa-key"></i> Auth Token</label>
110
- <input class="" type="password" id="node-config-input-loki_authtoken" />
219
+ <label class="towb_editorlabel" for="node-config-input-lokiservice"><i class="fa fa-server"></i> Loki Service</label>
220
+ <input type="text" id="node-config-input-lokiservice" />
111
221
  </div>
112
222
 
113
223
  <div class="form-row">
@@ -123,13 +233,10 @@
123
233
  Pushes log messages to a Grafana Loki instance using the Loki HTTP API.
124
234
 
125
235
  ### Properties
126
- : *name* (string) : A descriptive name for this logger configuration.
127
- : *level* (level) : The minimum log level. Messages below this threshold are suppressed. Options: DEBUG, INFO, WARNING, ERROR.
128
- : *host* (string) : The base URL of the Loki instance, including port (e.g. `http://localhost:3100`).
129
- : *tenant ID* (string) : The tenant identifier for multi-tenant Loki deployments (`X-Scope-OrgID` header). Leave blank for single-tenant deployments.
130
- : *username* (string) : Username for Loki authentication.
131
- : *auth token* (string) : Authentication token or password for Loki.
132
- : *template* (mustache) : A Mustache/Handlebars template that formats the log message body.
236
+ : *name* (string) : A descriptive name for this logger configuration.
237
+ : *loki service* (config) : The Loki Service config node that provides the connection URL and credentials.
238
+ : *level* (level) : The minimum log level. Messages below this threshold are suppressed. Options: DEBUG, INFO, WARNING, ERROR.
239
+ : *template* (mustache) : A Mustache/Handlebars template that formats the log message body.
133
240
 
134
241
  ### Mustache Templating
135
242
  Mustache/Handlebars templates build the message string pushed to Loki.
@@ -142,12 +249,947 @@ Mustache/Handlebars templates build the message string pushed to Loki.
142
249
  : *json* (helper) : Serialises an object to a JSON string using `JSON.stringify`.
143
250
 
144
251
  #### Examples
145
- - `{{{json msg}}}` — serialises the full message object as JSON.
252
+ - `{{{json msg}}}` - serialises the full message object as JSON.
146
253
 
147
254
  #### References
148
- - [Grafana Loki — HTTP API](https://grafana.com/docs/loki/latest/reference/loki-http-api/)
149
- - [Mustache — Wikipedia](https://en.wikipedia.org/wiki/Mustache_(template_system))
150
- - [Mustache — Playground](https://jgonggrijp.gitlab.io/wontache/playground.html)
255
+ - [Grafana Loki - HTTP API](https://grafana.com/docs/loki/latest/reference/loki-http-api/)
256
+ - [Mustache - Wikipedia](https://en.wikipedia.org/wiki/Mustache_(template_system))
257
+ - [Mustache - Playground](https://jgonggrijp.gitlab.io/wontache/playground.html)
258
+ </script>
259
+ <!-- LokiQueryNode -->
260
+ <script type="text/javascript">
261
+ window.PluginCore = window.PluginCore || {};
262
+ /**
263
+ * PluginCore.createLogQLEditor(elementId, initialValue)
264
+ *
265
+ * Creates a Monaco editor pre-configured with the LogQL language:
266
+ * syntax highlighting for stream selectors, pipeline stages, metric
267
+ * functions, and aggregation operators; plus snippet completions.
268
+ *
269
+ * The LogQL language is registered with Monaco lazily on first call.
270
+ *
271
+ * @param {string} elementId - DOM id of the container element.
272
+ * @param {string} initialValue - Starting content of the editor.
273
+ * @returns {{ getValue(): string, dispose(): void \}}
274
+ */
275
+ PluginCore.createLogQLEditor = function(elementId, initialValue) {
276
+ // Register the LogQL language with Monaco once per session.
277
+ if (!PluginCore._logqlReady) {
278
+ PluginCore._logqlReady = true;
279
+ monaco.languages.register({
280
+ id: 'logql'
281
+ });
282
+ monaco.languages.setLanguageConfiguration('logql', {
283
+ brackets: [
284
+ ['{', '}'],
285
+ ['[', ']'],
286
+ ['(', ')']
287
+ ],
288
+ autoClosingPairs: [{
289
+ open: '{',
290
+ close: '}'
291
+ }, {
292
+ open: '[',
293
+ close: ']'
294
+ }, {
295
+ open: '(',
296
+ close: ')'
297
+ }, {
298
+ open: '"',
299
+ close: '"'
300
+ }, {
301
+ open: "'",
302
+ close: "'"
303
+ }, {
304
+ open: '`',
305
+ close: '`'
306
+ }],
307
+ surroundingPairs: [{
308
+ open: '{',
309
+ close: '}'
310
+ }, {
311
+ open: '[',
312
+ close: ']'
313
+ }, {
314
+ open: '(',
315
+ close: ')'
316
+ }, {
317
+ open: '"',
318
+ close: '"'
319
+ }, {
320
+ open: "'",
321
+ close: "'"
322
+ }]
323
+ });
324
+ monaco.languages.setMonarchTokensProvider('logql', {
325
+ defaultToken: '',
326
+ tokenPostfix: '.logql',
327
+ keywords: ['by', 'without', 'on', 'ignoring', 'group_left', 'group_right', 'bool', 'or', 'and', 'unless', 'offset'],
328
+ metricFns: ['rate', 'count_over_time', 'bytes_rate', 'bytes_over_time', 'absent_over_time', 'sum_over_time', 'avg_over_time', 'min_over_time', 'max_over_time', 'first_over_time', 'last_over_time', 'stddev_over_time', 'stdvar_over_time', 'quantile_over_time', 'label_replace', 'vector', 'sort', 'sort_desc'],
329
+ aggregations: ['sum', 'avg', 'min', 'max', 'count', 'stddev', 'stdvar', 'bottomk', 'topk'],
330
+ pipelineStages: ['json', 'logfmt', 'pattern', 'regexp', 'unpack', 'line_format', 'label_format', 'drop', 'keep', 'decolorize', 'unwrap', 'ip', 'duration', 'bytes', 'distinct'],
331
+ tokenizer: {
332
+ root: [
333
+ // Stream selector open brace
334
+ [/\{/, {
335
+ token: 'delimiter.curly',
336
+ bracket: '@open',
337
+ next: '@streamSelector'
338
+ }],
339
+ // Range vector e.g. [5m]
340
+ [/\[\d+(ms|s|m|h|d|w|y)\]/, 'number.duration'],
341
+ // Pipeline filter operators
342
+ [/[|!][=~]/, 'operator.filter'],
343
+ // Pipe
344
+ [/\|/, 'operator.pipe'],
345
+ // Comparison operators
346
+ [/[><=!]+/, 'operator'],
347
+ // Numbers
348
+ [/\d+(\.\d+)?/, 'number'],
349
+ // Identifiers -> map to keyword/function/aggregation/pipeline categories
350
+ [/[a-zA-Z_]\w*/, {
351
+ cases: {
352
+ '@keywords': 'keyword',
353
+ '@aggregations': 'type.aggregate',
354
+ '@metricFns': 'type.function',
355
+ '@pipelineStages': 'type.pipeline',
356
+ '@default': 'identifier'
357
+ }
358
+ }],
359
+ // String literals
360
+ [/"/, {
361
+ token: 'string.quote',
362
+ bracket: '@open',
363
+ next: '@dblString'
364
+ }],
365
+ [/'/, {
366
+ token: 'string.quote',
367
+ bracket: '@open',
368
+ next: '@sglString'
369
+ }],
370
+ [/`/, {
371
+ token: 'string.quote',
372
+ bracket: '@open',
373
+ next: '@rawString'
374
+ }]
375
+ ],
376
+ streamSelector: [
377
+ [/\}/, {
378
+ token: 'delimiter.curly',
379
+ bracket: '@close',
380
+ next: '@pop'
381
+ }],
382
+ [/[a-zA-Z_]\w*/, 'attribute.name'],
383
+ [/=~|!=|=|!~/, 'operator'],
384
+ [/"/, {
385
+ token: 'string.quote',
386
+ bracket: '@open',
387
+ next: '@dblString'
388
+ }],
389
+ [/'/, {
390
+ token: 'string.quote',
391
+ bracket: '@open',
392
+ next: '@sglString'
393
+ }],
394
+ [/`/, {
395
+ token: 'string.quote',
396
+ bracket: '@open',
397
+ next: '@rawString'
398
+ }],
399
+ [/,/, 'delimiter']
400
+ ],
401
+ dblString: [
402
+ [/[^"\\]+/, 'string'],
403
+ [/\\./, 'string.escape'],
404
+ [/"/, {
405
+ token: 'string.quote',
406
+ bracket: '@close',
407
+ next: '@pop'
408
+ }]
409
+ ],
410
+ sglString: [
411
+ [/[^'\\]+/, 'string'],
412
+ [/\\./, 'string.escape'],
413
+ [/'/, {
414
+ token: 'string.quote',
415
+ bracket: '@close',
416
+ next: '@pop'
417
+ }]
418
+ ],
419
+ rawString: [
420
+ [/[^`]+/, 'string'],
421
+ [/`/, {
422
+ token: 'string.quote',
423
+ bracket: '@close',
424
+ next: '@pop'
425
+ }]
426
+ ]
427
+ }
428
+ });
429
+ monaco.languages.registerCompletionItemProvider('logql', {
430
+ triggerCharacters: ['{', '|', ' ', '('],
431
+ provideCompletionItems: function(model, position) {
432
+ var word = model.getWordUntilPosition(position);
433
+ var range = {
434
+ startLineNumber: position.lineNumber,
435
+ endLineNumber: position.lineNumber,
436
+ startColumn: word.startColumn,
437
+ endColumn: word.endColumn
438
+ };
439
+ var mk = monaco.languages.CompletionItemKind;
440
+ var SR = monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet;
441
+ var suggestions = [];
442
+ // Stream selector template
443
+ suggestions.push({
444
+ label: '{label="value"}',
445
+ kind: mk.Snippet,
446
+ detail: 'Log stream selector',
447
+ documentation: 'Select log streams matching a set of label matchers.',
448
+ insertText: '{${1:app}="${2:nodered}"}',
449
+ insertTextRules: SR,
450
+ range: range
451
+ });
452
+ // Metric query functions
453
+ [{
454
+ l: 'rate',
455
+ d: 'Per-second rate of log entries',
456
+ s: 'rate({${1:app}="${2}"} [${3:5m}])'
457
+ }, {
458
+ l: 'count_over_time',
459
+ d: 'Count of log entries over time range',
460
+ s: 'count_over_time({${1:app}="${2}"} [${3:5m}])'
461
+ }, {
462
+ l: 'bytes_rate',
463
+ d: 'Per-second byte rate of log entries',
464
+ s: 'bytes_rate({${1:app}="${2}"} [${3:5m}])'
465
+ }, {
466
+ l: 'bytes_over_time',
467
+ d: 'Total bytes of log entries over range',
468
+ s: 'bytes_over_time({${1:app}="${2}"} [${3:5m}])'
469
+ }, {
470
+ l: 'absent_over_time',
471
+ d: 'Returns 1 if no entries found in range',
472
+ s: 'absent_over_time({${1:app}="${2}"} [${3:5m}])'
473
+ }, {
474
+ l: 'sum_over_time',
475
+ d: 'Sum of unwrapped values over range',
476
+ s: 'sum_over_time({${1:app}="${2}"} | unwrap ${3:field} [${4:5m}])'
477
+ }, {
478
+ l: 'avg_over_time',
479
+ d: 'Average of unwrapped values over range',
480
+ s: 'avg_over_time({${1:app}="${2}"} | unwrap ${3:field} [${4:5m}])'
481
+ }, {
482
+ l: 'min_over_time',
483
+ d: 'Minimum of unwrapped values over range',
484
+ s: 'min_over_time({${1:app}="${2}"} | unwrap ${3:field} [${4:5m}])'
485
+ }, {
486
+ l: 'max_over_time',
487
+ d: 'Maximum of unwrapped values over range',
488
+ s: 'max_over_time({${1:app}="${2}"} | unwrap ${3:field} [${4:5m}])'
489
+ }, {
490
+ l: 'quantile_over_time',
491
+ d: 'Quantile of unwrapped values',
492
+ s: 'quantile_over_time(${1:0.99}, {${2:app}="${3}"} | unwrap ${4:field} [${5:5m}])'
493
+ }, {
494
+ l: 'first_over_time',
495
+ d: 'First unwrapped value over range',
496
+ s: 'first_over_time({${1:app}="${2}"} | unwrap ${3:field} [${4:5m}])'
497
+ }, {
498
+ l: 'last_over_time',
499
+ d: 'Last unwrapped value over range',
500
+ s: 'last_over_time({${1:app}="${2}"} | unwrap ${3:field} [${4:5m}])'
501
+ }, {
502
+ l: 'label_replace',
503
+ d: 'Replace label value using regex',
504
+ s: 'label_replace(${1:expr}, "${2:dst_label}", "${3:$1}", "${4:src_label}", "${5:(.*)}")'
505
+ }].forEach(function(f) {
506
+ suggestions.push({
507
+ label: f.l,
508
+ kind: mk.Function,
509
+ detail: f.d,
510
+ insertText: f.s,
511
+ insertTextRules: SR,
512
+ range: range
513
+ });
514
+ });
515
+ // Aggregation operators
516
+ [{
517
+ l: 'sum',
518
+ d: 'Sum over dimensions'
519
+ }, {
520
+ l: 'avg',
521
+ d: 'Average over dimensions'
522
+ }, {
523
+ l: 'min',
524
+ d: 'Minimum over dimensions'
525
+ }, {
526
+ l: 'max',
527
+ d: 'Maximum over dimensions'
528
+ }, {
529
+ l: 'count',
530
+ d: 'Count over dimensions'
531
+ }, {
532
+ l: 'stddev',
533
+ d: 'Standard deviation'
534
+ }, {
535
+ l: 'stdvar',
536
+ d: 'Standard variance'
537
+ }, {
538
+ l: 'bottomk',
539
+ d: 'Smallest k elements',
540
+ s: 'bottomk(${1:5}, ${2:expr})'
541
+ }, {
542
+ l: 'topk',
543
+ d: 'Largest k elements',
544
+ s: 'topk(${1:5}, ${2:expr})'
545
+ }].forEach(function(a) {
546
+ var snippet = a.s || (a.l + '(${1:by (${2:label})}) (${3:expr})');
547
+ suggestions.push({
548
+ label: a.l,
549
+ kind: mk.Keyword,
550
+ detail: a.d,
551
+ insertText: snippet,
552
+ insertTextRules: SR,
553
+ range: range
554
+ });
555
+ });
556
+ // Pipeline stages
557
+ [{
558
+ l: '|= "..."',
559
+ s: '|= "${1}"',
560
+ d: 'Filter: line contains string'
561
+ }, {
562
+ l: '!= "..."',
563
+ s: '!= "${1}"',
564
+ d: 'Filter: line does not contain string'
565
+ }, {
566
+ l: '|~ "..."',
567
+ s: '|~ "${1}"',
568
+ d: 'Filter: line matches regex'
569
+ }, {
570
+ l: '!~ "..."',
571
+ s: '!~ "${1}"',
572
+ d: 'Filter: line does not match regex'
573
+ }, {
574
+ l: '| json',
575
+ s: '| json',
576
+ d: 'Parse JSON log line'
577
+ }, {
578
+ l: '| logfmt',
579
+ s: '| logfmt',
580
+ d: 'Parse logfmt log line'
581
+ }, {
582
+ l: '| pattern "..."',
583
+ s: '| pattern "${1:<_> level=<level> <_>}"',
584
+ d: 'Parse using pattern'
585
+ }, {
586
+ l: '| regexp "..."',
587
+ s: '| regexp "${1:(?P<level>[a-z]+)}"',
588
+ d: 'Parse using named regex groups'
589
+ }, {
590
+ l: '| line_format "..."',
591
+ s: '| line_format "${1:{{.level\}} {{.msg\}}}"',
592
+ d: 'Reformat the log line'
593
+ }, {
594
+ l: '| label_format',
595
+ s: '| label_format ${1:new_label}=${2:old_label}',
596
+ d: 'Rename or transform labels'
597
+ }, {
598
+ l: '| unwrap',
599
+ s: '| unwrap ${1:label}',
600
+ d: 'Extract numeric value for metric queries'
601
+ }, {
602
+ l: '| unpack',
603
+ s: '| unpack',
604
+ d: 'Unpack JSON log fields into labels'
605
+ }, {
606
+ l: '| drop',
607
+ s: '| drop ${1:label}',
608
+ d: 'Drop specified labels'
609
+ }, {
610
+ l: '| keep',
611
+ s: '| keep ${1:label}',
612
+ d: 'Keep only specified labels'
613
+ }, {
614
+ l: '| decolorize',
615
+ s: '| decolorize',
616
+ d: 'Strip ANSI colour codes'
617
+ }].forEach(function(p) {
618
+ suggestions.push({
619
+ label: p.l,
620
+ kind: mk.Operator,
621
+ detail: p.d,
622
+ insertText: p.s,
623
+ insertTextRules: SR,
624
+ range: range
625
+ });
626
+ });
627
+ // Keywords
628
+ ['by', 'without', 'on', 'or', 'and', 'unless', 'bool', 'offset', 'ignoring'].forEach(function(kw) {
629
+ suggestions.push({
630
+ label: kw,
631
+ kind: mk.Keyword,
632
+ insertText: kw,
633
+ range: range
634
+ });
635
+ });
636
+ return {
637
+ suggestions: suggestions
638
+ };
639
+ }
640
+ });
641
+ }
642
+ var editor = monaco.editor.create(document.getElementById(elementId), {
643
+ value: initialValue || '',
644
+ language: 'logql',
645
+ automaticLayout: true,
646
+ minimap: {
647
+ enabled: false
648
+ },
649
+ lineNumbers: 'off',
650
+ wordWrap: 'on',
651
+ scrollBeyondLastLine: false,
652
+ fontSize: 13,
653
+ overviewRulerLanes: 0,
654
+ folding: false,
655
+ padding: {
656
+ top: 6,
657
+ bottom: 6
658
+ }
659
+ });
660
+ return {
661
+ getValue: function() {
662
+ return editor.getValue();
663
+ },
664
+ dispose: function() {
665
+ editor.dispose();
666
+ }
667
+ };
668
+ };
669
+
670
+ </script>
671
+ <!-- logger -->
672
+ <style>
673
+ .editorgroupborder {
674
+ border-width: 1px;
675
+ border-style: solid;
676
+ border-color: lightgray;
677
+ padding: 3px;
678
+ margin-top: 2px;
679
+ margin-bottom: 2px;
680
+ }
681
+
682
+ .editorsectionheading {
683
+ font-weight: 600;
684
+ margin-bottom: 1px !important;
685
+ margin-top: 6px !important;
686
+ }
687
+
688
+ .nomargin {
689
+ margin-bottom: 1px !important;
690
+ margin-top: 1px !important;
691
+ }
692
+
693
+ .slidecontainer {
694
+ width: 100%;
695
+ }
696
+
697
+ .slider {
698
+ -webkit-appearance: none;
699
+ appearance: none;
700
+ width: 100%;
701
+ height: 25px;
702
+ background: #d3d3d3;
703
+ outline: none;
704
+ opacity: 0.7;
705
+ -webkit-transition: .2s;
706
+ transition: opacity .2s;
707
+ }
708
+
709
+ .slider:hover {
710
+ opacity: 1;
711
+ }
712
+
713
+ .slider::-webkit-slider-thumb {
714
+ -webkit-appearance: none;
715
+ appearance: none;
716
+ width: 25px;
717
+ height: 25px;
718
+ background: #04AA6D;
719
+ cursor: pointer;
720
+ }
721
+
722
+ .slider::-moz-range-thumb {
723
+ width: 25px;
724
+ height: 25px;
725
+ background: #04AA6D;
726
+ cursor: pointer;
727
+ }
728
+
729
+ .towb_editorlabel {
730
+ width: 120px !important;
731
+ }
732
+
733
+ input.towb_editorfield,
734
+ select.towb_editorfield,
735
+ textarea.towb_editorfield {
736
+ width: 70% !important;
737
+ }
738
+
739
+ input.towb_editorfield_short,
740
+ select.towb_editorfield_short {
741
+ width: 30% !important;
742
+ }
743
+
744
+ </style>
745
+ <script type="text/javascript">
746
+ // CSS
747
+ dropdownStyles = `
748
+ .loggertype-dropdown {
749
+ position: absolute;
750
+ background: white;
751
+ border: 1px solid #ccc;
752
+ border-radius: 4px;
753
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
754
+ max-height: 150px;
755
+ overflow-y: auto;
756
+ z-index: 1000;
757
+ min-width: 150px;
758
+ }
759
+ .loggertype-dropdown .dropdown-item {
760
+ padding: 6px 12px;
761
+ cursor: pointer;
762
+ }
763
+ .loggertype-dropdown .dropdown-item:hover {
764
+ background: #f0f0f0;
765
+ }
766
+ `;
767
+ $('<style>').text(dropdownStyles).appendTo('head');
768
+
769
+ </script>
770
+ <script type="text/javascript">
771
+ RED.nodes.registerType('LokiQueryNode', {
772
+ category: 'loki',
773
+ icon: 'loki.png',
774
+ color: '#8D7974',
775
+ labelStyle: 'node_label_white',
776
+ label: function() {
777
+ return this.name
778
+ },
779
+ paletteLabel: 'Loki Query',
780
+ inputs: 1,
781
+ inputLabels: (i) => 'input',
782
+ outputs: 1,
783
+ outputLabels: (i) => ['logLines'][i],
784
+ defaults: {
785
+ name: {
786
+ value: 'Loki Query',
787
+ required: true
788
+ },
789
+ lokiservice: {
790
+ type: 'LokiServiceConfigNode',
791
+ required: true
792
+ },
793
+ query: {
794
+ value: '{app="nodered"}',
795
+ required: true
796
+ },
797
+ start: {
798
+ value: '1h',
799
+ required: true
800
+ },
801
+ start_type: {
802
+ value: 'relative',
803
+ required: false
804
+ },
805
+ end: {
806
+ value: '',
807
+ required: false
808
+ },
809
+ end_type: {
810
+ value: 'now',
811
+ required: false
812
+ },
813
+ outputpath: {
814
+ value: 'payload',
815
+ required: false
816
+ },
817
+ batchMode: {
818
+ required: false
819
+ },
820
+ logEnabled: {
821
+ value: false,
822
+ required: true
823
+ },
824
+ logger: {
825
+ required: false,
826
+ value: '',
827
+ type: 'DelegatedConfigReferenceNode'
828
+ },
829
+ logTemplateOverrideEnabled: {
830
+ value: false
831
+ },
832
+ logTemplateOverride: {
833
+ value: 'message:{{msg}}'
834
+ },
835
+ },
836
+ oneditprepare: function() {
837
+ // **** LokiQueryNode **** //
838
+ {
839
+ let durationRegex = /^\d+(ms|s|m|h|d|w)$/i;
840
+ let timeTypes = [{
841
+ value: "relative",
842
+ label: "Relative",
843
+ icon: "fa fa-clock-o",
844
+ validate: function(v) {
845
+ return durationRegex.test(v);
846
+ },
847
+ hasValue: true
848
+ }, {
849
+ value: "absolute",
850
+ label: "Date/Time",
851
+ icon: "fa fa-calendar",
852
+ validate: function(v) {
853
+ return !isNaN(Date.parse(v));
854
+ },
855
+ hasValue: true
856
+ }];
857
+ $("#node-input-start").typedInput({
858
+ types: timeTypes,
859
+ default: "relative",
860
+ typeField: "#node-input-start_type"
861
+ });
862
+ $("#node-input-end").typedInput({
863
+ types: [{
864
+ value: "now",
865
+ label: "Now",
866
+ icon: "fa fa-circle",
867
+ hasValue: false
868
+ }].concat(timeTypes),
869
+ default: "now",
870
+ typeField: "#node-input-end_type"
871
+ });
872
+ $("#node-input-outputpath").typedInput({
873
+ types: ["msg"]
874
+ });
875
+ $("#node-input-batchMode").prop("checked", this.batchMode === true);
876
+ this.queryEditor = PluginCore.createLogQLEditor("node-input-query-editor", this.query || "");
877
+ }
878
+ // **** logger **** //
879
+ {
880
+ let node = this;
881
+ // controls.
882
+ let logEnabled = $("#node-input-logEnabled");
883
+ let logTemplateEnabled = $("#node-input-logTemplateOverrideEnabled");
884
+ let logTemplateEnabledSection = $("#section_logTemplateOverrideEnabled");
885
+ let logEnabledSection = $("#section_logEnabled");
886
+ // functions.
887
+ let getLoggerTypes = async function() {
888
+ let types = [];
889
+ await $.ajax({
890
+ url: "nodetypeservice/find?tag=LoggerType",
891
+ type: "GET",
892
+ contentType: "application/json; charset=utf-8",
893
+ data: JSON.stringify({
894
+ id: node.id
895
+ }),
896
+ success: (response) => {
897
+ types = response;
898
+ },
899
+ error: (jqXHR, textStatus, errorThrown) => {
900
+ console.log(jqXHR, textStatus, errorThrown);
901
+ },
902
+ });
903
+ return types;
904
+ }
905
+ let getLoggers = async () => {
906
+ let loggers = [];
907
+ // get the list of types again.
908
+ let loggerTypes = (await getLoggerTypes()).map(type => type.id);
909
+ // add all of the known loggers.
910
+ RED.nodes.eachConfig(cfg => {
911
+ if (loggerTypes.includes(cfg.type)) {
912
+ loggers.push({
913
+ id: cfg.id,
914
+ name: cfg.label(),
915
+ type: cfg.type
916
+ });
917
+ }
918
+ });
919
+ return loggers;
920
+ };
921
+ let updateLoggerSelectorList = async () => {
922
+ loggerSelector.empty();
923
+ (await getLoggers()).forEach(logger => {
924
+ $('<option value="' + logger.id + '">' + logger.name + '</option>').appendTo(loggerSelector);
925
+ });
926
+ $('<option value="_ADD_">none</option>').appendTo(loggerSelector);
927
+ }
928
+ logEnabled.on('change', function() {
929
+ if (logEnabled.prop("checked")) {
930
+ logEnabledSection.show();
931
+ node._def.defaults.logger.required = true;
932
+ } else {
933
+ logEnabledSection.hide()
934
+ node._def.defaults.logger.required = false;
935
+ }
936
+ });
937
+ logTemplateEnabled.on('change', function() {
938
+ if (logTemplateEnabled.prop("checked")) {
939
+ logTemplateEnabledSection.show();
940
+ node._def.defaults.logTemplateOverride.required = true;
941
+ } else {
942
+ logTemplateEnabledSection.hide()
943
+ node._def.defaults.logTemplateOverride.required = false;
944
+ }
945
+ });
946
+ node.logTemplateOverrideEditor = RED.editor.createEditor({
947
+ id: 'node-input-logTemplateOverrideEditor',
948
+ mode: 'ace/mode/handlebars',
949
+ value: node.logTemplateOverride
950
+ });
951
+ let loggerSelector = $("#logger-selector");
952
+ let loggerEditButton = $("#logger-edit-btn");
953
+ let loggerAddButton = $("#logger-add-btn");
954
+ loggerEditButton.on('click', async function(event) {
955
+ event.stopPropagation();
956
+ if (loggerEditButton.hasClass("disabled")) {
957
+ return;
958
+ }
959
+ // figure out what type of logger this is.
960
+ let selectedLogger = (await getLoggers()).find(logger => logger.id === loggerSelector.val());
961
+ RED.editor.editConfig("#logger-selector", selectedLogger.type, selectedLogger.id);
962
+ });
963
+ loggerAddButton.on('click', function(event) {
964
+ event.stopPropagation();
965
+ let $btn = $(event.target);
966
+ // Remove any existing dropdown
967
+ $('.loggertype-dropdown').remove();
968
+ getLoggerTypes().then(loggerTypes => {
969
+ let $dropdown = $('<div class="loggertype-dropdown red-ui-editor"></div>');
970
+ loggerTypes.forEach(loggerType => {
971
+ $('<div class="dropdown-item"></div>').text(loggerType.name).data('value', loggerType.id).css({
972
+ padding: '4px 8px',
973
+ cursor: 'pointer'
974
+ }).appendTo($dropdown);
975
+ });
976
+ // Append first so outerWidth() is measurable, then position so the
977
+ // right edge of the dropdown aligns with the right edge of the button.
978
+ $('body').append($dropdown);
979
+ $dropdown.css({
980
+ top: $btn.offset().top + $btn.outerHeight(),
981
+ left: $btn.offset().left + $btn.outerWidth() - $dropdown.outerWidth()
982
+ });
983
+ $(document).on('mousedown.dropdown', function(e) {
984
+ if (!$(e.target).closest('.loggertype-dropdown').length) {
985
+ $dropdown.remove();
986
+ $(document).off('mousedown.dropdown'); // Clean up the listener
987
+ }
988
+ });
989
+ // Handle item selection
990
+ $dropdown.on('click', '.dropdown-item', function(e) {
991
+ e.stopPropagation();
992
+ const selectedValue = $(this).data('value');
993
+ const selectedText = $(this).text();
994
+ // Do whatever you need with the selection
995
+ console.log('Selected:', selectedValue, selectedText);
996
+ $dropdown.remove();
997
+ RED.editor.editConfig("#logger-selector", selectedValue, "_ADD_");
998
+ });
999
+ })
1000
+ });
1001
+ loggerSelector.on("focus", updateLoggerSelectorList);
1002
+ loggerSelector.on('change', function(event) {
1003
+ if ("_ADD_" === loggerSelector.val()) {
1004
+ loggerEditButton.addClass("disabled")
1005
+ } else {
1006
+ loggerEditButton.removeClass("disabled")
1007
+ }
1008
+ });
1009
+ // lastly, make sure that we set the correct value on the logger selector.
1010
+ updateLoggerSelectorList().then(() => {
1011
+ loggerSelector.val(node.logger);
1012
+ loggerSelector.trigger('change');
1013
+ });
1014
+ }
1015
+ },
1016
+ oneditsave: function() {
1017
+ // **** LokiQueryNode **** //
1018
+ {
1019
+ this.query = this.queryEditor.getValue();
1020
+ this.batchMode = $("#node-input-batchMode").is(":checked");
1021
+ this.queryEditor.dispose();
1022
+ delete this.queryEditor;
1023
+ }
1024
+ // **** logger **** //
1025
+ {
1026
+ node = this;
1027
+ var selectedLogger = $("#logger-selector").val();
1028
+ node.logger = (selectedLogger && selectedLogger !== "_ADD_") ? selectedLogger : '';
1029
+ node.logTemplateOverride = node.logTemplateOverrideEditor.getValue();
1030
+ node.logTemplateOverrideEditor.destroy();
1031
+ delete node.logTemplateOverrideEditor;
1032
+ }
1033
+ },
1034
+ oneditcancel: function() {
1035
+ // **** LokiQueryNode **** //
1036
+ {
1037
+ if (this.queryEditor) {
1038
+ this.queryEditor.dispose();
1039
+ delete this.queryEditor;
1040
+ }
1041
+ }
1042
+ },
1043
+ oneditdelete: function() {},
1044
+ });
1045
+
1046
+ </script>
1047
+
1048
+
1049
+ <script type="text/html" data-template-name='LokiQueryNode'>
1050
+ <div id='section_LokiQueryNode'>
1051
+ <div class="form-row">
1052
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
1053
+ <input type="text" id="node-input-name" />
1054
+ </div>
1055
+
1056
+ <div class="form-row">
1057
+ <label for="node-input-lokiservice"><i class="fa fa-server"></i> Loki Service</label>
1058
+ <input type="text" id="node-input-lokiservice" />
1059
+ </div>
1060
+
1061
+ <div class="form-row">
1062
+ <label for="node-input-query-editor"><i class="fa fa-search"></i> Query</label>
1063
+ <div id="node-input-query-editor" style="height:120px; min-height:80px; border:1px solid #ccc; border-radius:3px;"></div>
1064
+ </div>
1065
+
1066
+ <div class="form-row">
1067
+ <label for="node-input-start"><i class="fa fa-clock-o"></i> Start</label>
1068
+ <input type="text" id="node-input-start" />
1069
+ <input type="hidden" id="node-input-start_type" />
1070
+ </div>
1071
+
1072
+ <div class="form-row">
1073
+ <label for="node-input-end"><i class="fa fa-clock-o"></i> End</label>
1074
+ <input type="text" id="node-input-end" />
1075
+ <input type="hidden" id="node-input-end_type" />
1076
+ </div>
1077
+
1078
+ <div class="form-row">
1079
+ <label for="node-input-outputpath"><i class="fa fa-arrow-right"></i> Output Path</label>
1080
+ <input type="text" id="node-input-outputpath" />
1081
+ </div>
1082
+
1083
+ <div class="form-row">
1084
+ <label for="node-input-batchMode"><i class="fa fa-list"></i> Batch Mode</label>
1085
+ <input type="checkbox" id="node-input-batchMode" style="width:auto; margin-top:4px;" />
1086
+ <span style="margin-left:6px; color:#888; font-size:0.9em;">Collect all results into a single array message</span>
1087
+ </div>
1088
+
1089
+ </div>
1090
+
1091
+ <div id='section_logger'>
1092
+ <div id="section_loggerconfig">
1093
+ <div class="form-row editorsectionheading">
1094
+ <i class="w-16 fa fa-eye"></i> <span>Logging</span>
1095
+ </div>
1096
+ <div class="editorgroupborder">
1097
+ <div class="form-row nomargin logEnableCheck">
1098
+ <input type="checkbox" id="node-input-logEnabled" placeholder="logEnabled" value="false" style="margin:8px 0 10px 102px; width:20px;">
1099
+ <label style="width:auto" for="node-input-logEnabled"><span>Enable Logging</span></label>
1100
+ </div>
1101
+
1102
+ <div id="section_logEnabled">
1103
+ <div class="form-row nomargin">
1104
+ <label for="logger-selector">Logger</label>
1105
+ <div style="width: 70%; display: inline-flex;">
1106
+ <select id="logger-selector" style="flex-grow: 1;" class="">
1107
+ <option value="_ADD_">none</option>
1108
+ </select>
1109
+ <a id="logger-edit-btn" class="red-ui-button disabled" style="margin-left: 10px;">
1110
+ <i class="fa fa-pencil"></i>
1111
+ </a>
1112
+ <a id="logger-add-btn" class="red-ui-button" style="margin-left: 10px;">
1113
+ <i class="fa fa-plus"></i>
1114
+ </a>
1115
+ </div>
1116
+ </div>
1117
+
1118
+ <div class="form-row nomargin">
1119
+ <input type="checkbox" id="node-input-logTemplateOverrideEnabled" placeholder="" value="false" style="margin:8px 0 10px 102px; width:20px;">
1120
+ <label style="width:auto" for="node-input-logTemplateOverrideEnabled"><span>Override Template</span></label>
1121
+ </div>
1122
+
1123
+ <div id="section_logTemplateOverrideEnabled">
1124
+ <div class="form-row">
1125
+ <label class="towb_editorlabel" for="node-input-logTemplateOverrideEditor"><i class="fa fa-code"></i> Template</label>
1126
+ <div style="height: 250px; min-height:150px;" class="node-text-editor" id="node-input-logTemplateOverrideEditor"></div>
1127
+ </div>
1128
+ </div>
1129
+ </div>
1130
+ </div>
1131
+ </div>
1132
+
1133
+ </div>
1134
+ </script>
1135
+
1136
+ <script type="text/markdown" data-help-name='LokiQueryNode'>
1137
+ Runs a LogQL query against a Grafana Loki instance and emits log lines as messages.
1138
+
1139
+ ### Properties
1140
+
1141
+ : *name* (string) : Display label for this node.
1142
+ : *loki service* (config) : The Loki Service config node that provides the connection URL and credentials.
1143
+ : *query* (LogQL) : The LogQL query to execute. A Monaco editor with LogQL syntax highlighting and completions is provided.
1144
+ : *start* (time) : Start of the query time range. Required. Accepts relative durations or absolute date/times.
1145
+ : *end* (time) : End of the query time range. Defaults to **Now**.
1146
+ : *output path* (msg) : Where in the output message to place each log line object. Defaults to `msg.payload`.
1147
+ : *batch mode* (bool) : When enabled, all results are collected into a single array message. When disabled (default), one message is emitted per log line.
1148
+
1149
+ ### Time formats
1150
+
1151
+ The *start* and *end* fields accept:
1152
+
1153
+ - **Relative** - `1h`, `30m`, `7d`, `2w` (subtracted from now at query time)
1154
+ - **Date/Time** - `2024-01-01T00:00:00Z` or any parseable date string
1155
+
1156
+ ### Query editor
1157
+
1158
+ The query editor provides:
1159
+ - Syntax highlighting for stream selectors `\{app="nodered"\}`, pipeline stages, metric functions, and aggregation operators.
1160
+ - Snippet completions for all major LogQL constructs (trigger with `Ctrl+Space`).
1161
+
1162
+ ### Inputs
1163
+
1164
+ : *msg* : Any message. Used as a trigger.
1165
+ : *msg.query* : *(optional)* Overrides the configured LogQL query.
1166
+ : *msg.start* : *(optional)* Overrides the configured start time (parsed as relative or date string).
1167
+ : *msg.end* : *(optional)* Overrides the configured end time.
1168
+
1169
+ ### Outputs
1170
+
1171
+ **Individual mode** (default): one message per log line - a copy of the input message with the output path set to:
1172
+
1173
+ ```json
1174
+ \{
1175
+ "timestamp": "2024-01-01T00:00:00.000Z",
1176
+ "line": "the log message text",
1177
+ "labels": \{ "app": "nodered", "level": "error" \}
1178
+ \}
1179
+ ```
1180
+
1181
+ **Batch mode**: a single message with the output path set to an array of the above objects.
1182
+
1183
+ ### Notes
1184
+
1185
+ - Results are capped at **1 000 log lines** or **10 MB** of response data, whichever is reached first.
1186
+ - Lines are emitted in the order returned by Loki (oldest first within each stream).
1187
+
1188
+ ### Logging
1189
+ : *enable logging* (boolean) : if checked, then the node will produce logging output to the specified logger.
1190
+ : *logger* (logconfig) : the endppoint that the node will log events to.
1191
+ : *override template* (logconfig) : if checked, a custom logging template can be provided for the log message.
1192
+ : *logger template* (mustache) : a template in mustache format to generate a message for logging purposes (see LoggerConfig node for more information)
151
1193
  </script>
152
1194
 
153
1195