From a76f22281e57c3b7ad273919dda4a172fc498453 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Tue, 26 May 2026 18:01:58 +0200 Subject: [PATCH] feat(dashboardapi): no-data-duplication rule for parent dashboards (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When generateDashboardsForGraph builds a root dashboard for a parent (e.g. pumpingStation) and a set of child dashboards (e.g. measurements), it now removes any non-row panel from the root whose meta.emittedFields are fully covered by panels declared in any child dashboard. Result: the parent shows only metrics its children don't already plot, eliminating redundant rendering of the same series in two dashboards. - config/pumpingStation.json: 11 non-row panels annotated with meta.emittedFields (Direction, Time Left, Flow Source, Fill %, Level (x2), Volume, Net Flow Rate, Inflow+Outflow, Heights, Volume Limits). - src/specificClass.js: generateDashboardsForGraph runs the parent-panel filter after composing children; row panels always kept; panels without emittedFields declaration always kept (no silent removal). - test/basic/slice39-no-duplication.basic.test.js: 4 cases — annotation presence, child-covered removal, no-overlap preservation, row preservation. Closes #39 --- config/pumpingStation.json | 684 ++++++++++++++++-- src/specificClass.js | 26 + .../slice39-no-duplication.basic.test.js | 102 +++ 3 files changed, 735 insertions(+), 77 deletions(-) create mode 100644 test/basic/slice39-no-duplication.basic.test.js diff --git a/config/pumpingStation.json b/config/pumpingStation.json index b238b2f..ca12ba1 100644 --- a/config/pumpingStation.json +++ b/config/pumpingStation.json @@ -3,7 +3,10 @@ "list": [ { "builtIn": 1, - "datasource": { "type": "grafana", "uid": "-- Grafana --" }, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", @@ -17,155 +20,682 @@ "id": null, "links": [], "panels": [ - { "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Status", "type": "row" }, { - "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, - "fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } }, "overrides": [] }, - "gridPos": { "h": 4, "w": 5, "x": 0, "y": 1 }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "title": "Status", + "type": "row" + }, + { + "datasource": { + "type": "influxdb", + "uid": "cdzg44tv250jkd" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 0, + "y": 1 + }, "id": 2, - "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "colorMode": "value", + "graphMode": "none" + }, "targets": [ - { "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"direction\")\n |> last()", "refId": "A" } + { + "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"direction\")\n |> last()", + "refId": "A" + } ], "title": "Direction", - "type": "stat" + "type": "stat", + "meta": { + "emittedFields": [ + "direction" + ] + } }, { - "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, - "fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 300 }, { "color": "red", "value": 600 }] } }, "overrides": [] }, - "gridPos": { "h": 4, "w": 5, "x": 5, "y": 1 }, + "datasource": { + "type": "influxdb", + "uid": "cdzg44tv250jkd" + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 300 + }, + { + "color": "red", + "value": 600 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 5, + "y": 1 + }, "id": 3, - "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "colorMode": "value", + "graphMode": "area" + }, "targets": [ - { "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"timeleft\")\n |> last()", "refId": "A" } + { + "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"timeleft\")\n |> last()", + "refId": "A" + } ], "title": "Time Left", - "type": "stat" + "type": "stat", + "meta": { + "emittedFields": [ + "timeLeft" + ] + } }, { - "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, - "fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "purple", "value": null }] } }, "overrides": [] }, - "gridPos": { "h": 4, "w": 4, "x": 10, "y": 1 }, + "datasource": { + "type": "influxdb", + "uid": "cdzg44tv250jkd" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "purple", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 10, + "y": 1 + }, "id": 4, - "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "colorMode": "value", + "graphMode": "none" + }, "targets": [ - { "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"flowSource\")\n |> last()", "refId": "A" } + { + "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"flowSource\")\n |> last()", + "refId": "A" + } ], "title": "Flow Source", - "type": "stat" + "type": "stat", + "meta": { + "emittedFields": [ + "flowSource" + ] + } }, { - "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, - "fieldConfig": { "defaults": { "min": 0, "max": 100, "unit": "percent", "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "orange", "value": 20 }, { "color": "green", "value": 40 }, { "color": "orange", "value": 80 }, { "color": "red", "value": 95 }] } }, "overrides": [] }, - "gridPos": { "h": 4, "w": 5, "x": 14, "y": 1 }, + "datasource": { + "type": "influxdb", + "uid": "cdzg44tv250jkd" + }, + "fieldConfig": { + "defaults": { + "min": 0, + "max": 100, + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "orange", + "value": 20 + }, + { + "color": "green", + "value": 40 + }, + { + "color": "orange", + "value": 80 + }, + { + "color": "red", + "value": 95 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 14, + "y": 1 + }, "id": 5, - "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "showThresholdLabels": false, "showThresholdMarkers": true }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, "targets": [ - { "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^volumePercent\\.predicted\\.atequipment/)\n |> last()", "refId": "A" } + { + "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^volumePercent\\.predicted\\.atequipment/)\n |> last()", + "refId": "A" + } ], "title": "Fill %", - "type": "gauge" + "type": "gauge", + "meta": { + "emittedFields": [ + "volumePercent" + ] + } }, { - "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, - "fieldConfig": { "defaults": { "unit": "m", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } }, "overrides": [] }, - "gridPos": { "h": 4, "w": 5, "x": 19, "y": 1 }, + "datasource": { + "type": "influxdb", + "uid": "cdzg44tv250jkd" + }, + "fieldConfig": { + "defaults": { + "unit": "m", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 19, + "y": 1 + }, "id": 6, - "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "colorMode": "value", + "graphMode": "area" + }, "targets": [ - { "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.predicted\\.atequipment/)\n |> last()", "refId": "A" } + { + "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.predicted\\.atequipment/)\n |> last()", + "refId": "A" + } ], "title": "Level", - "type": "stat" + "type": "stat", + "meta": { + "emittedFields": [ + "level" + ] + } }, - { "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, "id": 7, "title": "Basin", "type": "row" }, { - "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, - "fieldConfig": { "defaults": { "unit": "m", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 5 + }, + "id": 7, + "title": "Basin", + "type": "row" + }, + { + "datasource": { + "type": "influxdb", + "uid": "cdzg44tv250jkd" + }, + "fieldConfig": { + "defaults": { + "unit": "m", + "custom": { + "drawStyle": "line", + "lineWidth": 2, + "fillOpacity": 10 + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, "id": 8, - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, "targets": [ - { "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.(predicted|measured)\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" } + { + "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.(predicted|measured)\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", + "refId": "A" + } ], "title": "Level", - "type": "timeseries" + "type": "timeseries", + "meta": { + "emittedFields": [ + "level" + ] + } }, { - "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, - "fieldConfig": { "defaults": { "unit": "m\u00b3", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "datasource": { + "type": "influxdb", + "uid": "cdzg44tv250jkd" + }, + "fieldConfig": { + "defaults": { + "unit": "m\u00b3", + "custom": { + "drawStyle": "line", + "lineWidth": 2, + "fillOpacity": 10 + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, "id": 9, - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, "targets": [ - { "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^volume\\.predicted\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" } + { + "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^volume\\.predicted\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", + "refId": "A" + } ], "title": "Volume", - "type": "timeseries" + "type": "timeseries", + "meta": { + "emittedFields": [ + "volume" + ] + } }, - { "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, "id": 10, "title": "Flow", "type": "row" }, { - "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, - "fieldConfig": { "defaults": { "unit": "m\u00b3/h", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 14 + }, + "id": 10, + "title": "Flow", + "type": "row" + }, + { + "datasource": { + "type": "influxdb", + "uid": "cdzg44tv250jkd" + }, + "fieldConfig": { + "defaults": { + "unit": "m\u00b3/h", + "custom": { + "drawStyle": "line", + "lineWidth": 2, + "fillOpacity": 10 + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 15 + }, "id": 11, - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, "targets": [ - { "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^netFlowRate\\.predicted\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" } + { + "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^netFlowRate\\.predicted\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", + "refId": "A" + } ], "title": "Net Flow Rate", - "type": "timeseries" + "type": "timeseries", + "meta": { + "emittedFields": [ + "flow.net", + "flow" + ] + } }, { - "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, - "fieldConfig": { "defaults": { "unit": "m\u00b3/h", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "datasource": { + "type": "influxdb", + "uid": "cdzg44tv250jkd" + }, + "fieldConfig": { + "defaults": { + "unit": "m\u00b3/h", + "custom": { + "drawStyle": "line", + "lineWidth": 2, + "fillOpacity": 10 + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 15 + }, "id": 12, - "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, "targets": [ - { "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^flow\\.(predicted|measured)\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" } + { + "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^flow\\.(predicted|measured)\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", + "refId": "A" + } ], "title": "Inflow + Outflow", - "type": "timeseries" + "type": "timeseries", + "meta": { + "emittedFields": [ + "flow.in", + "flow.out" + ] + } }, - { "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }, "id": 13, "title": "Configuration", "type": "row" }, { - "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, - "fieldConfig": { "defaults": { "unit": "m", "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } }, "overrides": [] }, - "gridPos": { "h": 4, "w": 12, "x": 0, "y": 24 }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 23 + }, + "id": 13, + "title": "Configuration", + "type": "row" + }, + { + "datasource": { + "type": "influxdb", + "uid": "cdzg44tv250jkd" + }, + "fieldConfig": { + "defaults": { + "unit": "m", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 12, + "x": 0, + "y": 24 + }, "id": 14, - "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "colorMode": "value", + "graphMode": "none" + }, "targets": [ - { "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"heightInlet\" or r._field==\"heightOverflow\" or r._field==\"volEmptyBasin\"))\n |> last()", "refId": "A" } + { + "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"heightInlet\" or r._field==\"heightOverflow\" or r._field==\"volEmptyBasin\"))\n |> last()", + "refId": "A" + } ], "title": "Heights", - "type": "stat" + "type": "stat", + "meta": { + "emittedFields": [ + "heights.min", + "heights.max" + ] + } }, { - "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, - "fieldConfig": { "defaults": { "unit": "m\u00b3", "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } }, "overrides": [] }, - "gridPos": { "h": 4, "w": 12, "x": 12, "y": 24 }, + "datasource": { + "type": "influxdb", + "uid": "cdzg44tv250jkd" + }, + "fieldConfig": { + "defaults": { + "unit": "m\u00b3", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 12, + "x": 12, + "y": 24 + }, "id": 15, - "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "colorMode": "value", + "graphMode": "none" + }, "targets": [ - { "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"maxVol\" or r._field==\"minVol\" or r._field==\"maxVolOverflow\" or r._field==\"minVolOut\" or r._field==\"minVolIn\"))\n |> last()", "refId": "A" } + { + "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"maxVol\" or r._field==\"minVol\" or r._field==\"maxVolOverflow\" or r._field==\"minVolOut\" or r._field==\"minVolIn\"))\n |> last()", + "refId": "A" + } ], "title": "Volume Limits", - "type": "stat" + "type": "stat", + "meta": { + "emittedFields": [ + "volume.min", + "volume.max" + ] + } } ], "schemaVersion": 39, - "tags": ["EVOLV", "pumpingStation", "template"], + "tags": [ + "EVOLV", + "pumpingStation", + "template" + ], "templating": { "list": [ - { "name": "dbase", "type": "custom", "label": "dbase", "query": "cdzg44tv250jkd", "current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false }, "options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }], "hide": 2 }, - { "name": "measurement", "type": "custom", "query": "template", "current": { "text": "template", "value": "template", "selected": false }, "options": [{ "text": "template", "value": "template", "selected": true }] }, - { "name": "bucket", "type": "custom", "query": "lvl2", "current": { "text": "lvl2", "value": "lvl2", "selected": false }, "options": [{ "text": "lvl2", "value": "lvl2", "selected": true }] } + { + "name": "dbase", + "type": "custom", + "label": "dbase", + "query": "cdzg44tv250jkd", + "current": { + "text": "cdzg44tv250jkd", + "value": "cdzg44tv250jkd", + "selected": false + }, + "options": [ + { + "text": "cdzg44tv250jkd", + "value": "cdzg44tv250jkd", + "selected": true + } + ], + "hide": 2 + }, + { + "name": "measurement", + "type": "custom", + "query": "template", + "current": { + "text": "template", + "value": "template", + "selected": false + }, + "options": [ + { + "text": "template", + "value": "template", + "selected": true + } + ] + }, + { + "name": "bucket", + "type": "custom", + "query": "lvl2", + "current": { + "text": "lvl2", + "value": "lvl2", + "selected": false + }, + "options": [ + { + "text": "lvl2", + "value": "lvl2", + "selected": true + } + ] + } ] }, - "time": { "from": "now-6h", "to": "now" }, + "time": { + "from": "now-6h", + "to": "now" + }, "timezone": "", "title": "template", "uid": null, "version": 1 -} +} \ No newline at end of file diff --git a/src/specificClass.js b/src/specificClass.js index 430540b..d04139d 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -228,6 +228,32 @@ class DashboardApi { if (childDash) results.push(childDash); } + // No-data-duplication rule (PRD F-5, #39): remove root panels whose + // emittedFields are fully covered by panels on child dashboards. The + // parent then shows only metrics its children don't already plot, + // avoiding redundant rendering of the same series in two places. + if (children.length > 0 && rootDash.dashboard) { + const childCoveredFields = new Set(); + for (const dash of results.slice(1)) { + for (const f of this.collectEmittedFields(dash.dashboard)) childCoveredFields.add(f); + } + const before = rootDash.dashboard.panels.length; + rootDash.dashboard.panels = rootDash.dashboard.panels.filter((p) => { + if (p.type === 'row') return true; // never drop rows + const fields = p?.meta?.emittedFields; + if (!Array.isArray(fields) || fields.length === 0) return true; // no declaration, keep + return !fields.every((f) => childCoveredFields.has(f)); + }); + if (this.logger?.debug && before !== rootDash.dashboard.panels.length) { + this.logger.debug({ + event: 'parent-panels-deduped', + before, + after: rootDash.dashboard.panels.length, + rootTitle: rootDash.title, + }); + } + } + // Add links from the root dashboard to children dashboards (when possible) if (children.length > 0) { rootDash.dashboard.links = Array.isArray(rootDash.dashboard.links) ? rootDash.dashboard.links : []; diff --git a/test/basic/slice39-no-duplication.basic.test.js b/test/basic/slice39-no-duplication.basic.test.js new file mode 100644 index 0000000..3c0296f --- /dev/null +++ b/test/basic/slice39-no-duplication.basic.test.js @@ -0,0 +1,102 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const DashboardApi = require('../../src/specificClass.js'); + +function makeChild(id, softwareType) { + return { + config: { + general: { id, name: id }, + functionality: { softwareType, positionVsParent: 'downstream' }, + }, + }; +} + +function makeRoot(softwareType, children) { + const map = new Map(); + for (const c of children) { + map.set(c.config.general.id, { + child: c, + softwareType: c.config.functionality.softwareType, + position: 'downstream', + }); + } + return { + config: { + general: { id: 'root-1', name: 'PS-North' }, + functionality: { softwareType, positionVsParent: 'atequipment' }, + }, + childRegistrationUtils: { registeredChildren: map }, + }; +} + +test('pumpingStation template has emittedFields on every non-row panel', () => { + const api = new DashboardApi({}); + const dash = api.loadTemplate('pumpingStation'); + const annotated = dash.panels.filter((p) => p.type !== 'row' && p?.meta?.emittedFields); + const nonRowPanels = dash.panels.filter((p) => p.type !== 'row'); + assert.equal(annotated.length, nonRowPanels.length, + `expected all ${nonRowPanels.length} non-row panels annotated, got ${annotated.length}`); +}); + +test('child-covered fields remove duplicate parent panels', () => { + const api = new DashboardApi({}); + + // Parent + 1 child with a fake template that emits 'level' (matches one of + // the pumpingStation parent's panels). The parent's "Level" panel should + // be removed when the child covers it. + const child1 = makeChild('child-1', 'measurement'); + const root = makeRoot('pumpingStation', [child1]); + + // Pre-count parent panels with the 'level' emitted field. + const parentTemplate = api.loadTemplate('pumpingStation'); + const parentLevelPanels = parentTemplate.panels.filter( + (p) => p?.meta?.emittedFields?.includes('level') + ); + assert.ok(parentLevelPanels.length > 0, 'parent has level panels in template'); + + // Monkey-patch the child's dashboard to claim it covers 'level'. + const origLoad = api.loadTemplate.bind(api); + api.loadTemplate = function (type) { + const dash = origLoad(type); + if (type === 'measurement' && dash) { + // Inject emittedFields = ['level'] on first non-row panel. + const firstPanel = dash.panels.find((p) => p.type !== 'row'); + if (firstPanel) (firstPanel.meta ||= {}).emittedFields = ['level']; + } + return dash; + }; + + const result = api.generateDashboardsForGraph(root); + const rootResult = result[0]; + const rootLevelPanels = rootResult.dashboard.panels.filter( + (p) => p?.meta?.emittedFields?.includes('level') + ); + assert.equal(rootLevelPanels.length, 0, + 'level panel(s) should be removed from parent when child covers them'); +}); + +test('parent panels are kept when no child covers their fields', () => { + const api = new DashboardApi({}); + const child1 = makeChild('child-1', 'measurement'); // measurement.json has no emittedFields + const root = makeRoot('pumpingStation', [child1]); + const result = api.generateDashboardsForGraph(root); + const rootResult = result[0]; + const beforeTemplate = api.loadTemplate('pumpingStation'); + const beforeNonRow = beforeTemplate.panels.filter((p) => p.type !== 'row').length; + const afterNonRow = rootResult.dashboard.panels.filter((p) => p.type !== 'row').length; + assert.equal(afterNonRow, beforeNonRow, + 'no panels should be removed when no child declares overlapping fields'); +}); + +test('row panels are never removed (structural)', () => { + const api = new DashboardApi({}); + const child1 = makeChild('child-1', 'measurement'); + const root = makeRoot('pumpingStation', [child1]); + const result = api.generateDashboardsForGraph(root); + const rootRows = result[0].dashboard.panels.filter((p) => p.type === 'row'); + const templateRows = api.loadTemplate('pumpingStation').panels.filter((p) => p.type === 'row'); + assert.equal(rootRows.length, templateRows.length, 'all row panels preserved'); +});