From 4e865d94061a6c46aac09cc51322d20291eacd2f Mon Sep 17 00:00:00 2001 From: subham sarkar Date: Tue, 8 Sep 2026 14:02:46 +0530 Subject: [PATCH] feat(grafana): add Kubernetes 1621 and 747 curated packs 1621 is the all-/dev/* filesystem fork of 315 with a working Node control on instance. 747 replaces markdown $Pod_ip/$container with live tiles and keeps root-cgroup series out of pod-scoped KPIs. --- .../curated-pack-1621-kubernetes-cadvisor.md | 50 ++ ...curated-pack-747-kubernetes-pod-metrics.md | 55 +++ docs/sources/grafana.md | 25 + .../fidelity_manifest.yaml | 95 ++++ .../pack.yaml | 349 ++++++++++++++ .../plugin.py | 59 +++ .../fidelity_manifest.yaml | 82 ++++ .../pack.yaml | 343 +++++++++++++ .../plugin.py | 59 +++ .../grafana/curated_packs/registry.yaml | 20 + .../adapters/source/grafana/panels.py | 42 +- .../docker-compose.yml | 2 +- .../k8s_exporter.py | 40 +- tests/test_curated_packs.py | 450 +++++++++++++++++- tests/test_field_profile_portability.py | 5 +- 15 files changed, 1648 insertions(+), 28 deletions(-) create mode 100644 docs/design/curated-pack-1621-kubernetes-cadvisor.md create mode 100644 docs/design/curated-pack-747-kubernetes-pod-metrics.md create mode 100644 observability_migration/adapters/source/grafana/curated_packs/grafana_1621_kubernetes_cadvisor/fidelity_manifest.yaml create mode 100644 observability_migration/adapters/source/grafana/curated_packs/grafana_1621_kubernetes_cadvisor/pack.yaml create mode 100644 observability_migration/adapters/source/grafana/curated_packs/grafana_1621_kubernetes_cadvisor/plugin.py create mode 100644 observability_migration/adapters/source/grafana/curated_packs/grafana_747_kubernetes_pod_metrics/fidelity_manifest.yaml create mode 100644 observability_migration/adapters/source/grafana/curated_packs/grafana_747_kubernetes_pod_metrics/pack.yaml create mode 100644 observability_migration/adapters/source/grafana/curated_packs/grafana_747_kubernetes_pod_metrics/plugin.py diff --git a/docs/design/curated-pack-1621-kubernetes-cadvisor.md b/docs/design/curated-pack-1621-kubernetes-cadvisor.md new file mode 100644 index 00000000..373f14b8 --- /dev/null +++ b/docs/design/curated-pack-1621-kubernetes-cadvisor.md @@ -0,0 +1,50 @@ +# Curated Pack — Grafana 1621 "Kubernetes cluster monitoring (via Prometheus)" + +> Design + living discoveries for the 1621 curated pack. Follows the general +> Curation Playbook in `curated-dashboard-packs-plan.md`. Stacks on 315 +> (same dashboard family; 1621 is the all-partitions filesystem fork). + +- Source: community **"Kubernetes cluster monitoring (via Prometheus)"**, + +- gnetId **1621**, only revision **1**. +- canonical sha256 (rev 1) = `d714551536ca794e3088ac535e59b3d2f93dd705e3b60f84d6f3c42da40fe9d4`. +- Datasource: Prometheus — curated-pack eligible. +- Schema: **v12 (`rows[]`)**. 21 panels across 13 rows (same layout as 315). +- Metric family: **cAdvisor** (`container_*` + `machine_*`). +- Variables: `$Node` = `label_values(kubernetes_io_hostname)` (includeAll `.*`). + +## Goal + +Ship a curated pack so 1621 renders in Kibana against a modern cAdvisor scrape +with **every `/dev/*` filesystem partition aggregated** (the reason this +dashboard exists vs 315) and a working `$Node` control via `instance`. + +## 1621 vs 315 + +| | 315 | 1621 | +|---|---|---| +| Cluster filesystem device matcher | `^/dev/[sv]d[a-z][1-9]$` | `^/dev/.*$` | +| `$Node` | dropped (ignored hostname) | rewritten to `instance`; multi-select populate from `machine_cpu_cores` | +| Title | identical | identical — 1621 is **gnetId-only** in the registry | + +## Engine vs pack split + +Same split as 315 for the pre-1.16 label bridge, k8s-only container series, +Received/Sent names, cgroup-id All-processes, and systemd honest empty. Pack +adds filesystem per-device `LAST_OVER_TIME` then `SUM`, Node bind on every +live panel, and a 48-col named Memory/CPU/Filesystem KPI strip. + +## Fidelity + +- **PERFECT**: Network I/O pressure; filesystem used/total/% (all `/dev/*`); + per-pod CPU/memory/network. +- **APPROXIMATE**: cluster memory/CPU ratios (need `machine_*` + `id="/"`); + Containers (docker/rkt dropped); All-processes (cgroup id). +- **GAP**: System services (`systemd_service_name`). + +## Validation + +Shared curated rig `k8s_exporter.py` now emits two root-cgroup devices +(`/dev/sda1`, `/dev/nvme0n1p1`) into +`metrics-k8s.prometheus-default`. 315's regex matches only sda1; 1621 sums +both. diff --git a/docs/design/curated-pack-747-kubernetes-pod-metrics.md b/docs/design/curated-pack-747-kubernetes-pod-metrics.md new file mode 100644 index 00000000..6b342689 --- /dev/null +++ b/docs/design/curated-pack-747-kubernetes-pod-metrics.md @@ -0,0 +1,55 @@ +# Curated Pack — Grafana 747 "Kubernetes Pod Metrics" + +> Design + living discoveries for the 747 curated pack. Follows the general +> Curation Playbook in `curated-dashboard-packs-plan.md`. Stacks on 315/1621 +> (cAdvisor) and 6417 (kube-state-metrics). + +- Source: community **"Kubernetes Pod Metrics"**, + +- gnetId **747**, latest revision **2**. +- canonical sha256 (rev 2) = `47837bfea31e6156decd5c152379addfd9c3f9a9991a0cc96d764f91388b2d55`. +- Datasource: Prometheus — curated-pack eligible. +- Schema: **v12 (`rows[]`)**. 18 leaf panels. +- Metric family: **cAdvisor** (`container_*` + `machine_*`) **and** + **kube-state-metrics** (`kube_pod_info`, `kube_pod_status_phase`, + `kube_pod_container_info`, `kube_pod_container_status_restarts`). +- Variables: `$Node` (hostname), `$Pod` (`label_values(kube_pod_info, pod)`), + hidden `$Pod_ip` / `$phase` / `$container`. + +## Goal + +Ship a curated pack so 747 is a pod-scoped Kibana dashboard: working Pod/Node +controls, live IP/phase/container tiles instead of markdown `$var` +interpolation, and cAdvisor graphs that group by canonical `pod`. + +## The core problem + +| Dashboard convention | Modern reality | Pack handling | +|---|---|---| +| `pod_name` / `io_kubernetes_pod_name` | cAdvisor `pod` | `label_rewrites` + ES\|QL overrides | +| `kubernetes_io_hostname` | scrape `instance` | rewrite → `instance`; plugin populate | +| markdown `# $Pod_ip` (hidden var) | Kibana markdown does not interpolate controls | datatable of pod → IP; engine allows query_overrides on `text` panels | +| `kube_pod_container_status_restarts` | `*_total` counter | `metric_map` | +| `pod=~"$Pod.*$"` prefix | Grafana regex interpolation | exact `MV_CONTAINS` plus `pod IS NOT NULL` (root cgroup must not leak in) | +| CPU Total `format=bytes` + node-wide container rate | not capacity, not bytes | Kibana title **Node CPU** | +| `$Pod` single-select + All | hydrates to first pod | multi-select so first paint is All | + +## Engine vs pack split + +Pipeline already handles rate/gauge, `rows[]` → sections, hidden-variable +skip, and `^$Pod$` exact matchers. The pack carries the Heapster label +bridge, text→ES|QL overrides (skip the markdown early-return +when a curated `esql_query` is present), Node/Pod multi-select, Received/Sent +names, named Used/Total tiles, and `pod IS NOT NULL` so MV_CONTAINS cannot +pull in the root cgroup. + +## Fidelity + +- **PERFECT**: IP/status/container tiles, restarts, network pressure, pod + graphs, Memory/CPU used, Node CPU, working-set tile. +- **APPROXIMATE**: pod/node % ratios (cross-metric); All-processes (cgroup id). + +## Validation + +Shared curated rig `k8s_exporter.py` emits `pod_ip` on `kube_pod_info` and +`kube_pod_container_info` into `metrics-k8s.prometheus-default`. diff --git a/docs/sources/grafana.md b/docs/sources/grafana.md index fb2c5636..cbc79ff6 100644 --- a/docs/sources/grafana.md +++ b/docs/sources/grafana.md @@ -234,6 +234,31 @@ non-existent breakdown column. The cluster-total KPI strip additionally needs th node `machine_*` metrics and the root-cgroup (`id="/"`) + `container_fs_*` series to populate. +The Kubernetes cluster-monitoring 1621 pack is the community fork of 315 whose +Cluster Filesystem usage sums every cAdvisor `device=~"^/dev/.*$"` partition +instead of 315's `^/dev/[sv]d[a-z][1-9]$` (which misses nvme and extra disks). +Filesystem KPIs `LAST_OVER_TIME` per device then `SUM`. Unlike 315, `$Node` is +kept: `kubernetes_io_hostname` rewrites to canonical `instance`, the plugin +populates from `label_values(machine_cpu_cores, instance)` and marks the control +multi-select so first paint is Grafana All. Duplicate Used/Total tiles are +renamed Memory/CPU/Filesystem used/total on a 48-col strip. Title fallback +cannot distinguish 1621 from 315 (identical grafana.com titles); detection is +gnetId-only. + +The Kubernetes Pod Metrics (747) pack is pod-scoped cAdvisor plus +kube-state-metrics. Heapster `pod_name` / `io_kubernetes_pod_name` rewrite to +canonical `pod`; `$Node` follows the 741/1621 instance bridge. Hidden Grafana +variables (`$Pod_ip`, `$phase`, `$container`) only interpolated markdown +(`# $Pod_ip`); Kibana cannot interpolate those, so curated ES|QL datatables +list `pod` → `pod_ip` / `container`, and the phase tile keeps the 9628-style +metric legend. `$Pod` is multi-select on +`kube_pod_info`. Restarts map `kube_pod_container_status_restarts` → `*_total`. +Pod-scoped queries also require the canonical `pod` label so `MV_CONTAINS` +cannot include the root cgroup (`id="/"`) the way PromQL `pod=~` would reject +unlabeled series. Grafana's CPU Total tile queried node-wide container CPU rate with +`format=bytes`; Kibana titles it Node CPU and shows a number. Network +butterflies are named Received/Sent; All-processes panels group by cgroup `id`. + The Kubernetes Cluster (kube-state-metrics 6417) pack targets the KSM + `node_exporter` family and was authored against an older lineage: `metric_map` bridges the renamed names (`node_filesystem_size`/`_free` → diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_1621_kubernetes_cadvisor/fidelity_manifest.yaml b/observability_migration/adapters/source/grafana/curated_packs/grafana_1621_kubernetes_cadvisor/fidelity_manifest.yaml new file mode 100644 index 00000000..a667ef09 --- /dev/null +++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_1621_kubernetes_cadvisor/fidelity_manifest.yaml @@ -0,0 +1,95 @@ +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. +# SPDX-License-Identifier: Elastic-2.0 +# +# Fidelity manifest — Grafana dashboard 1621 (Kubernetes cluster monitoring via Prometheus) +# https://grafana.com/grafana/dashboards/1621 +# +# Fork of 315 whose Cluster Filesystem usage sums every cAdvisor `/dev/*` +# partition instead of `/dev/[sv]d[a-z][1-9]`. `$Node` is kept (via instance) +# rather than dropped. + +schema_version: 1 +gnet_id: 1621 +gnet_revision: 1 +dashboard_title: "Kubernetes cluster monitoring (via Prometheus)" +maintainer: "community" + +panels: + - title: "Network I/O pressure" + fidelity: PERFECT + notes: "sum(rate(receive/transmit)) butterfly named Received/Sent; bound to $Node via labels.instance." + + - title: "Cluster memory usage" + fidelity: APPROXIMATE + notes: "root-cgroup working set / machine_memory_bytes. requires: machine_memory_bytes + id=/ series. Cross-metric ratio." + - title: "Cluster CPU usage (1m avg)" + fidelity: APPROXIMATE + notes: "root-cgroup CPU rate / machine_cpu_cores. requires: machine_cpu_cores + id=/ series." + - title: "Cluster filesystem usage" + fidelity: PERFECT + notes: "1621 differentiator vs 315: SUM of every device starting with /dev/ (nvme, extra disks included). Per-device LAST_OVER_TIME then SUM. requires: container_fs_* with a /dev/ device label." + - title: "Memory used" + fidelity: APPROXIMATE + notes: "sum(container_memory_working_set_bytes{id=/}). Kibana title renamed from duplicate Used." + - title: "Memory total" + fidelity: APPROXIMATE + notes: "sum(machine_memory_bytes)." + - title: "CPU used" + fidelity: APPROXIMATE + notes: "sum(rate(container_cpu_usage_seconds_total{id=/}))." + - title: "CPU total" + fidelity: APPROXIMATE + notes: "sum(machine_cpu_cores)." + - title: "Filesystem used" + fidelity: PERFECT + notes: "All /dev/* partitions (not 315's [sv]d[a-z][1-9] regex)." + - title: "Filesystem total" + fidelity: PERFECT + notes: "All /dev/* partition limits." + + - title: "Pods CPU usage (1m avg)" + fidelity: PERFECT + notes: "Curated ES|QL groups by canonical pod; Node bound via instance." + - title: "Pods memory usage" + fidelity: PERFECT + notes: "LAST_OVER_TIME working set by pod." + - title: "Pods network I/O (1m avg)" + fidelity: PERFECT + notes: "Received/Sent by pod (native butterfly would emit Value B)." + + - title: "Containers CPU usage (1m avg)" + fidelity: APPROXIMATE + notes: "k8s pod/container series only; docker/rkt dropped." + - title: "Containers memory usage" + fidelity: APPROXIMATE + notes: "As above." + - title: "Containers network I/O (1m avg)" + fidelity: APPROXIMATE + notes: "As above; Received/Sent by pod." + + - title: "System services CPU usage (1m avg)" + fidelity: GAP + notes: "systemd_service_name absent on modern cAdvisor; impossible-filter override → honest empty." + - title: "System services memory usage" + fidelity: GAP + notes: "Same systemd gap." + - title: "All processes CPU usage (1m avg)" + fidelity: APPROXIMATE + notes: "Grouped by cgroup id excluding /." + - title: "All processes memory usage" + fidelity: APPROXIMATE + notes: "As above." + - title: "All processes network I/O (1m avg)" + fidelity: APPROXIMATE + notes: "As above; Received/Sent." + +summary: + total_panels: 21 + perfect: 7 + approximate: 12 + gap: 2 + notes: > + Same cAdvisor lineage as 315, but filesystem KPIs aggregate every /dev/* + partition (the reason this dashboard exists) and $Node filters via + instance instead of being dropped. Duplicate Used/Total titles are renamed + Memory/CPU/Filesystem used/total on a 48-col Kibana KPI strip. diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_1621_kubernetes_cadvisor/pack.yaml b/observability_migration/adapters/source/grafana/curated_packs/grafana_1621_kubernetes_cadvisor/pack.yaml new file mode 100644 index 00000000..0f3c25a7 --- /dev/null +++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_1621_kubernetes_cadvisor/pack.yaml @@ -0,0 +1,349 @@ +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. +# SPDX-License-Identifier: Elastic-2.0 +# +# Curated pack — Grafana dashboard 1621 +# https://grafana.com/grafana/dashboards/1621 +# +# Source: community "Kubernetes cluster monitoring (via Prometheus)". +# Revision 1. schemaVersion 12 (old `rows[]` layout), 21 panels across 13 rows. +# Same dashboard as 315 except Cluster Filesystem usage aggregates every +# cAdvisor `device=~"^/dev/.*$"` partition instead of 315's +# `^/dev/[sv]d[a-z][1-9]$` (misses nvme / extra disks). +# Metric family: cAdvisor container_* + machine_* (NOT kube-state-metrics). +# +# Unlike 315 this pack keeps `$Node` working: kubernetes_io_hostname rewrites +# to canonical instance and plugin.py populates from machine_cpu_cores. + +query: + metrics_dataset_filter: "prometheus" + + metric_kinds: + container_cpu_usage_seconds_total: counter + container_network_receive_bytes_total: counter + container_network_transmit_bytes_total: counter + container_memory_working_set_bytes: gauge + container_fs_usage_bytes: gauge + container_fs_limit_bytes: gauge + machine_cpu_cores: gauge + machine_memory_bytes: gauge + + # Source→canonical only; the resolver namespaces per --field-profile. + label_rewrites: + pod_name: pod + container_name: container + kubernetes_io_hostname: instance + + source_label_names: + pod: pod_name + container: container_name + instance: kubernetes_io_hostname + + # Dead cAdvisor selector labels. Do NOT ignore kubernetes_io_hostname — it + # is rewritten to instance so $Node actually filters. + ignored_labels: + - image + - name + +controls: + field_overrides: + Node: instance + node: instance + +panel: + query_overrides: + # --- Cluster KPI strip ------------------------------------------------ + - title_match: "Cluster memory usage" + panel_id: 4 + primary_format: percentunit + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | STATS used = SUM(CASE({{label:id}} == "/", LAST_OVER_TIME({{metric:container_memory_working_set_bytes:gauge}}), NULL)), total = SUM(LAST_OVER_TIME({{metric:machine_memory_bytes:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | EVAL computed_value = CASE(total > 0, used / total, NULL) + | WHERE computed_value IS NOT NULL + | SORT time_bucket DESC + | LIMIT 1 + | KEEP computed_value + status_override: migrated + - title_match: "Cluster CPU usage (1m avg)" + panel_id: 6 + primary_format: percentunit + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | STATS used = SUM(CASE({{label:id}} == "/", RATE({{metric:container_cpu_usage_seconds_total:counter}}), NULL)), total = SUM(LAST_OVER_TIME({{metric:machine_cpu_cores:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | EVAL computed_value = CASE(total > 0, used / total, NULL) + | WHERE computed_value IS NOT NULL + | SORT time_bucket DESC + | LIMIT 1 + | KEEP computed_value + status_override: migrated + # 1621's differentiator vs 315: aggregate every /dev/* partition, not just + # /dev/[sv]d[a-z][1-9]. Per-device LAST_OVER_TIME then SUM so mixed-device + # series do not collapse incorrectly. + - title_match: "Cluster filesystem usage" + panel_id: 7 + primary_format: percentunit + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE {{label:id}} == "/" AND STARTS_WITH(TO_STRING({{label:device}}), "/dev/") + | STATS usage = SUM(LAST_OVER_TIME({{metric:container_fs_usage_bytes:gauge}})), limit = SUM(LAST_OVER_TIME({{metric:container_fs_limit_bytes:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:device}} + | STATS usage = SUM(usage), limit = SUM(limit) BY time_bucket + | EVAL computed_value = CASE(limit > 0, usage / limit, NULL) + | WHERE computed_value IS NOT NULL + | SORT time_bucket DESC + | LIMIT 1 + | KEEP computed_value + status_override: migrated + - title_match: "Used" + panel_id: 9 + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE {{label:id}} == "/" + | STATS computed_value = SUM(LAST_OVER_TIME({{metric:container_memory_working_set_bytes:gauge}})) + | KEEP computed_value + status_override: migrated + - title_match: "Total" + panel_id: 10 + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | STATS computed_value = SUM(LAST_OVER_TIME({{metric:machine_memory_bytes:gauge}})) + | KEEP computed_value + status_override: migrated + - title_match: "Used" + panel_id: 11 + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE {{label:id}} == "/" + | STATS computed_value = SUM(RATE({{metric:container_cpu_usage_seconds_total:counter}})) + | KEEP computed_value + status_override: migrated + - title_match: "Total" + panel_id: 12 + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | STATS computed_value = SUM(LAST_OVER_TIME({{metric:machine_cpu_cores:gauge}})) + | KEEP computed_value + status_override: migrated + - title_match: "Used" + panel_id: 13 + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE {{label:id}} == "/" AND STARTS_WITH(TO_STRING({{label:device}}), "/dev/") + | STATS usage = SUM(LAST_OVER_TIME({{metric:container_fs_usage_bytes:gauge}})) BY {{label:device}} + | STATS computed_value = SUM(usage) + | KEEP computed_value + status_override: migrated + - title_match: "Total" + panel_id: 14 + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE {{label:id}} == "/" AND STARTS_WITH(TO_STRING({{label:device}}), "/dev/") + | STATS limit = SUM(LAST_OVER_TIME({{metric:container_fs_limit_bytes:gauge}})) BY {{label:device}} + | STATS computed_value = SUM(limit) + | KEEP computed_value + status_override: migrated + + - title_match: "Network I/O pressure" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE {{metric:container_network_receive_bytes_total:counter}} IS NOT NULL OR {{metric:container_network_transmit_bytes_total:counter}} IS NOT NULL + | STATS Received = SUM(RATE({{metric:container_network_receive_bytes_total:counter}})), Sent = 0 - SUM(RATE({{metric:container_network_transmit_bytes_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | KEEP time_bucket, Received, Sent + | SORT time_bucket ASC + status_override: migrated + + - title_match: "Containers CPU usage (1m avg)" + approximation_note: "Dropped obsolete docker (name!~^k8s_) and rkt (rkt_container_name) runtime series; modern cAdvisor only emits the k8s pod/container series." + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE {{metric:container_cpu_usage_seconds_total:counter}} IS NOT NULL AND {{label:container}} IS NOT NULL AND {{label:container}} != "POD" + | STATS value = SUM(RATE({{metric:container_cpu_usage_seconds_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:pod}}, {{label:container}} + | KEEP time_bucket, `{{label:pod}}`, `{{label:container}}`, value + | SORT time_bucket ASC + - title_match: "Containers memory usage" + approximation_note: "Dropped obsolete docker and rkt runtime series; modern cAdvisor only emits the k8s pod/container series." + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE {{metric:container_memory_working_set_bytes:gauge}} IS NOT NULL AND {{label:container}} IS NOT NULL AND {{label:container}} != "POD" + | STATS value = SUM(LAST_OVER_TIME({{metric:container_memory_working_set_bytes:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:pod}}, {{label:container}} + | KEEP time_bucket, `{{label:pod}}`, `{{label:container}}`, value + | SORT time_bucket ASC + - title_match: "Containers network I/O (1m avg)" + approximation_note: "Dropped obsolete docker and rkt runtime series; only the k8s pod series remain (received positive, sent negative)." + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE {{metric:container_network_receive_bytes_total:counter}} IS NOT NULL OR {{metric:container_network_transmit_bytes_total:counter}} IS NOT NULL + | STATS Received = SUM(RATE({{metric:container_network_receive_bytes_total:counter}})), Sent = 0 - SUM(RATE({{metric:container_network_transmit_bytes_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:pod}} + | KEEP time_bucket, `{{label:pod}}`, Received, Sent + | SORT time_bucket ASC + - title_match: "Pods network I/O (1m avg)" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE {{label:pod}} IS NOT NULL AND ({{metric:container_network_receive_bytes_total:counter}} IS NOT NULL OR {{metric:container_network_transmit_bytes_total:counter}} IS NOT NULL) + | STATS Received = SUM(RATE({{metric:container_network_receive_bytes_total:counter}})), Sent = 0 - SUM(RATE({{metric:container_network_transmit_bytes_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:pod}} + | KEEP time_bucket, `{{label:pod}}`, Received, Sent + | SORT time_bucket ASC + status_override: migrated + - title_match: "All processes CPU usage (1m avg)" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE {{metric:container_cpu_usage_seconds_total:counter}} IS NOT NULL AND {{label:id}} IS NOT NULL AND {{label:id}} != "/" + | STATS value = SUM(RATE({{metric:container_cpu_usage_seconds_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:id}} + | KEEP time_bucket, `{{label:id}}`, value + | SORT time_bucket ASC + status_override: migrated + - title_match: "All processes memory usage" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE {{metric:container_memory_working_set_bytes:gauge}} IS NOT NULL AND {{label:id}} IS NOT NULL AND {{label:id}} != "/" + | STATS value = SUM(LAST_OVER_TIME({{metric:container_memory_working_set_bytes:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:id}} + | KEEP time_bucket, `{{label:id}}`, value + | SORT time_bucket ASC + status_override: migrated + - title_match: "All processes network I/O (1m avg)" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE {{label:id}} IS NOT NULL AND {{label:id}} != "/" AND ({{metric:container_network_receive_bytes_total:counter}} IS NOT NULL OR {{metric:container_network_transmit_bytes_total:counter}} IS NOT NULL) + | STATS Received = SUM(RATE({{metric:container_network_receive_bytes_total:counter}})), Sent = 0 - SUM(RATE({{metric:container_network_transmit_bytes_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:id}} + | KEEP time_bucket, `{{label:id}}`, Received, Sent + | SORT time_bucket ASC + status_override: migrated + - title_match: "Pods CPU usage (1m avg)" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE {{metric:container_cpu_usage_seconds_total:counter}} IS NOT NULL AND {{label:pod}} IS NOT NULL + | STATS value = SUM(RATE({{metric:container_cpu_usage_seconds_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:pod}} + | KEEP time_bucket, `{{label:pod}}`, value + | SORT time_bucket ASC + status_override: migrated + - title_match: "Pods memory usage" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE {{metric:container_memory_working_set_bytes:gauge}} IS NOT NULL AND {{label:pod}} IS NOT NULL + | STATS value = SUM(LAST_OVER_TIME({{metric:container_memory_working_set_bytes:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:pod}} + | KEEP time_bucket, `{{label:pod}}`, value + | SORT time_bucket ASC + status_override: migrated + - title_match: "System services CPU usage (1m avg)" + approximation_note: "systemd system-service series (systemd_service_name) are a pre-1.16 cAdvisor relabel artifact; modern cAdvisor does not emit them, so this panel is an honest empty (data_gap)." + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{metric:container_cpu_usage_seconds_total:counter}} IS NOT NULL AND {{label:container}} == "__systemd_service__" + | STATS value = SUM(RATE({{metric:container_cpu_usage_seconds_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:container}} + | KEEP time_bucket, `{{label:container}}`, value + | SORT time_bucket ASC + - title_match: "System services memory usage" + approximation_note: "systemd system-service series are a pre-1.16 cAdvisor relabel artifact; modern cAdvisor does not emit them, so this panel is an honest empty (data_gap)." + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{metric:container_memory_working_set_bytes:gauge}} IS NOT NULL AND {{label:container}} == "__systemd_service__" + | STATS value = SUM(LAST_OVER_TIME({{metric:container_memory_working_set_bytes:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:container}} + | KEEP time_bucket, `{{label:container}}`, value + | SORT time_bucket ASC + + layout_overrides: + - title_match: "Network I/O pressure" + size: {w: 48, h: 12} + - title_match: "Cluster memory usage" + panel_id: 4 + position: {x: 0, y: 0} + size: {w: 16, h: 12} + - title_match: "Cluster CPU usage (1m avg)" + panel_id: 6 + position: {x: 16, y: 0} + size: {w: 16, h: 12} + - title_match: "Cluster filesystem usage" + panel_id: 7 + position: {x: 32, y: 0} + size: {w: 16, h: 12} + - title_match: "Used" + panel_id: 9 + title: "Memory used" + position: {x: 0, y: 12} + size: {w: 8, h: 8} + - title_match: "Total" + panel_id: 10 + title: "Memory total" + position: {x: 8, y: 12} + size: {w: 8, h: 8} + - title_match: "Used" + panel_id: 11 + title: "CPU used" + position: {x: 16, y: 12} + size: {w: 8, h: 8} + - title_match: "Total" + panel_id: 12 + title: "CPU total" + position: {x: 24, y: 12} + size: {w: 8, h: 8} + - title_match: "Used" + panel_id: 13 + title: "Filesystem used" + position: {x: 32, y: 12} + size: {w: 8, h: 8} + - title_match: "Total" + panel_id: 14 + title: "Filesystem total" + position: {x: 40, y: 12} + size: {w: 8, h: 8} + - title_match: "Pods CPU usage (1m avg)" + size: {w: 48, h: 12} + - title_match: "Pods memory usage" + size: {w: 48, h: 12} + - title_match: "Pods network I/O (1m avg)" + size: {w: 48, h: 12} + - title_match: "Containers CPU usage (1m avg)" + size: {w: 48, h: 12} + - title_match: "Containers memory usage" + size: {w: 48, h: 12} + - title_match: "Containers network I/O (1m avg)" + size: {w: 48, h: 12} + - title_match: "All processes CPU usage (1m avg)" + size: {w: 48, h: 12} + - title_match: "All processes memory usage" + size: {w: 48, h: 12} + - title_match: "All processes network I/O (1m avg)" + size: {w: 48, h: 12} + - title_match: "System services CPU usage (1m avg)" + size: {w: 48, h: 12} + - title_match: "System services memory usage" + size: {w: 48, h: 12} diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_1621_kubernetes_cadvisor/plugin.py b/observability_migration/adapters/source/grafana/curated_packs/grafana_1621_kubernetes_cadvisor/plugin.py new file mode 100644 index 00000000..dc4dc239 --- /dev/null +++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_1621_kubernetes_cadvisor/plugin.py @@ -0,0 +1,59 @@ +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. +# SPDX-License-Identifier: Elastic-2.0 + +"""Grafana 1621 (Kubernetes cluster monitoring via Prometheus) curated pack plugin. + +``$Node`` is ``label_values(kubernetes_io_hostname)``. That Heapster-era node +label is not on a modern cAdvisor scrape; ``machine_cpu_cores`` carries +``instance`` (the pack rewrites ``kubernetes_io_hostname`` → canonical +``instance``), so populate from that instead of leaving the control empty. + +The source variable is single-select with ``includeAll`` / ``allValue: .*``. +Kibana live-hydration of a single-select VALUES_FROM_QUERY control picks the +first concrete node, so first paint would be one node instead of Grafana All. +Mark it multi-select so upload hydration pre-selects every option (Kibana's +stand-in for Grafana All); panel ES|QL uses ``MV_CONTAINS``. +""" + + +_PACK_NAME = "grafana_1621_kubernetes_cadvisor" +_NODE_QUERY = "label_values(machine_cpu_cores, instance)" + + +def _mark_multi_select(pack): + names = set(getattr(pack, "_multi_select_param_names", None) or ()) + names.add("Node") + setattr(pack, "_multi_select_param_names", names) + + +def register(api): + @api["query_preprocessors"].register("grafana_1621_k8s_multi_select", priority=1) + def mark_node_multi(context): + pack = getattr(context, "rule_pack", None) + if getattr(pack, "_curated_pack_name", "") != _PACK_NAME: + return None + _mark_multi_select(pack) + return None + + @api["variable_translators"].register("grafana_1621_k8s_controls", priority=5) + def rewrite_node_control(context): + pack = getattr(context, "rule_pack", None) + if getattr(pack, "_curated_pack_name", "") != _PACK_NAME: + return None + variable = context.variable or {} + name = str(variable.get("name") or "") + query_text = context.query_text or str(variable.get("query") or "") + compact = query_text.replace(" ", "").lower() + + if name != "Node" or "label_values(" not in compact: + return None + + variable = dict(variable) + variable["query"] = _NODE_QUERY + variable["multi"] = True + variable["includeAll"] = True + variable["allValue"] = ".*" + context.variable = variable + context.query_text = _NODE_QUERY + _mark_multi_select(pack) + return None diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_747_kubernetes_pod_metrics/fidelity_manifest.yaml b/observability_migration/adapters/source/grafana/curated_packs/grafana_747_kubernetes_pod_metrics/fidelity_manifest.yaml new file mode 100644 index 00000000..9bf2e20e --- /dev/null +++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_747_kubernetes_pod_metrics/fidelity_manifest.yaml @@ -0,0 +1,82 @@ +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. +# SPDX-License-Identifier: Elastic-2.0 +# +# Fidelity manifest — Grafana dashboard 747 (Kubernetes Pod Metrics) +# https://grafana.com/grafana/dashboards/747-pod-metrics/ + +schema_version: 1 +gnet_id: 747 +gnet_revision: 2 +dashboard_title: "Kubernetes Pod Metrics" +maintainer: "community" + +panels: + - title: "Pod IP Address" + fidelity: PERFECT + notes: "Grafana markdown `# $Pod_ip`. Kibana datatable of pod → pod_ip from kube_pod_info so All-selected shows every address (metric tiles clipped)." + - title: "Pod Status" + fidelity: PERFECT + notes: "Active kube_pod_status_phase==1 shown as the metric-tile legend instead of interpolating hidden $phase." + - title: "Pod Container" + fidelity: PERFECT + notes: "Datatable of pod → container from kube_pod_container_info." + - title: "Container restarts" + fidelity: PERFECT + notes: "Old kube_pod_container_status_restarts mapped to *_total; exact Pod bind (better than Grafana's pod=~$Pod.*$ prefix)." + + - title: "Network I/O pressure" + fidelity: PERFECT + notes: "Received/Sent butterfly scoped to selected pod(s)." + + - title: "Pod memory usage" + fidelity: APPROXIMATE + notes: "pod working set / machine_memory_bytes. Cross-metric ratio; Node binds instance, Pod binds pod." + - title: "Pod CPU usage" + fidelity: APPROXIMATE + notes: "pod CPU rate / machine_cpu_cores." + - title: "Memory used" + fidelity: PERFECT + notes: "sum(working set) for selected pod(s). Kibana title renamed from duplicate Used." + - title: "Memory total" + fidelity: APPROXIMATE + notes: "sum(machine_memory_bytes) for selected node(s)." + - title: "CPU used" + fidelity: PERFECT + notes: "pod CPU rate. Grafana format=bytes was wrong; Kibana shows cores (short number)." + - title: "Node CPU" + fidelity: PERFECT + notes: "Source title Total queried node-wide container CPU rate, not machine_cpu_cores. Kibana title says Node CPU so the number is not mistaken for capacity." + - title: "Pod memory working bytes" + fidelity: PERFECT + notes: "Same query as Memory used; kept as a named tile rather than Grafana's 1px-tall full-width strip." + + - title: "Pods CPU usage" + fidelity: PERFECT + notes: "by pod (Heapster io_kubernetes_pod_name rewritten). Curated ES|QL breakdown aligned." + - title: "Pods memory usage" + fidelity: PERFECT + notes: "LAST_OVER_TIME working set by pod." + - title: "Pods network I/O" + fidelity: PERFECT + notes: "Received/Sent by pod." + - title: "All processes CPU usage" + fidelity: APPROXIMATE + notes: "Grouped by cgroup id excluding /." + - title: "All processes memory usage" + fidelity: APPROXIMATE + notes: "As above." + - title: "All processes network I/O" + fidelity: APPROXIMATE + notes: "As above; Received/Sent." + +summary: + total_panels: 18 + perfect: 12 + approximate: 6 + gap: 0 + notes: > + Hidden Grafana variables ($Pod_ip / $phase / $container) become live + metric tiles. $Node populates from machine_cpu_cores instance; $Pod stays + on kube_pod_info and is multi-select so first paint is Grafana All. + Duplicate Used/Total titles are renamed. Grafana's CPU Total (node-wide + container rate mis-titled and format=bytes) is Node CPU. diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_747_kubernetes_pod_metrics/pack.yaml b/observability_migration/adapters/source/grafana/curated_packs/grafana_747_kubernetes_pod_metrics/pack.yaml new file mode 100644 index 00000000..4f6b4e39 --- /dev/null +++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_747_kubernetes_pod_metrics/pack.yaml @@ -0,0 +1,343 @@ +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. +# SPDX-License-Identifier: Elastic-2.0 +# +# Curated pack — Grafana dashboard 747 +# https://grafana.com/grafana/dashboards/747-pod-metrics/ +# +# Source: community "Kubernetes Pod Metrics". +# Revision 2. schemaVersion 12 (old `rows[]` layout), 18 leaf panels. +# Metric family: cAdvisor container_* + machine_* AND kube-state-metrics +# (kube_pod_info, kube_pod_status_phase, kube_pod_container_info, +# kube_pod_container_status_restarts). +# +# Authored against a pre-1.16 cAdvisor + Heapster-era label lineage: +# - pod_name / io_kubernetes_pod_name -> modern pod +# - kubernetes_io_hostname -> scrape instance +# - kube_pod_container_status_restarts -> *_total (counter) +# - markdown `# $Pod_ip` / `$phase` / `$container` (hidden vars, hide=2) +# -> live Kibana metric tiles grouped by those labels +# +# Engine vs pack split: +# Engine: rate()/gauge, rows[] -> sections, singlestat reducers, +# ^$Pod$ exact matchers, hidden-variable skip, text->markdown. +# Pack: Heapster label bridge, Node via instance, Pod/Node multi-select, +# text-panel ES|QL metric tiles, Received/Sent names, cgroup-id +# All-processes, LAST_OVER_TIME gauges, named Used/Total tiles. + +query: + metrics_dataset_filter: "prometheus" + + metric_kinds: + container_cpu_usage_seconds_total: counter + container_network_receive_bytes_total: counter + container_network_transmit_bytes_total: counter + kube_pod_container_status_restarts: counter + kube_pod_container_status_restarts_total: counter + container_memory_working_set_bytes: gauge + machine_cpu_cores: gauge + machine_memory_bytes: gauge + kube_pod_info: gauge + kube_pod_status_phase: gauge + kube_pod_container_info: gauge + + metric_map: + kube_pod_container_status_restarts: kube_pod_container_status_restarts_total + + label_rewrites: + pod_name: pod + io_kubernetes_pod_name: pod + kubernetes_io_hostname: instance + + source_label_names: + pod: pod_name + instance: kubernetes_io_hostname + + ignored_labels: + - image + - name + +controls: + field_overrides: + Node: instance + node: instance + Pod: pod + pod: pod + +panel: + query_overrides: + # Grafana markdown `# $Pod_ip` cannot interpolate Kibana controls. + # A datatable of pod → IP is the Kibana-native replacement (metric tiles + # clip after ~3 values). `pod IS NOT NULL` is required: MV_CONTAINS on a + # null label does not reject the root cgroup the way PromQL `pod=~` does. + - title_match: "Pod IP Address" + kibana_type_override: datatable + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:pod}} IS NOT NULL + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}})) + | WHERE {{metric:kube_pod_info:gauge}} IS NOT NULL AND {{label:pod_ip}} IS NOT NULL + | STATS ip = VALUES({{label:pod_ip}}) BY {{label:pod}} + | RENAME `{{label:pod}}` AS pod + | KEEP pod, ip + | SORT pod ASC + status_override: migrated + - title_match: "Pod Status" + kibana_type_override: metric + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:pod}} IS NOT NULL + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}})) + | WHERE {{metric:kube_pod_status_phase:gauge}} IS NOT NULL AND {{label:phase}} IS NOT NULL + | STATS computed_value = MAX(LAST_OVER_TIME({{metric:kube_pod_status_phase:gauge}})) BY {{label:phase}} + | WHERE computed_value == 1 + | KEEP `{{label:phase}}`, computed_value + status_override: migrated + - title_match: "Pod Container" + kibana_type_override: datatable + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:pod}} IS NOT NULL + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}})) + | WHERE {{metric:kube_pod_container_info:gauge}} IS NOT NULL AND {{label:container}} IS NOT NULL + | STATS container = VALUES({{label:container}}) BY {{label:pod}} + | RENAME `{{label:pod}}` AS pod + | KEEP pod, container + | SORT pod ASC + status_override: migrated + - title_match: "Container restarts" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:pod}} IS NOT NULL + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}})) + | WHERE {{metric:kube_pod_container_status_restarts_total:counter}} IS NOT NULL + | STATS computed_value = SUM(LAST_OVER_TIME({{metric:kube_pod_container_status_restarts_total:counter}})) + | KEEP computed_value + status_override: migrated + + - title_match: "Network I/O pressure" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:pod}} IS NOT NULL + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}})) + | WHERE {{metric:container_network_receive_bytes_total:counter}} IS NOT NULL OR {{metric:container_network_transmit_bytes_total:counter}} IS NOT NULL + | STATS Received = SUM(RATE({{metric:container_network_receive_bytes_total:counter}})), Sent = 0 - SUM(RATE({{metric:container_network_transmit_bytes_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | KEEP time_bucket, Received, Sent + | SORT time_bucket ASC + status_override: migrated + + - title_match: "Pod memory usage" + section_match: "Total usage" + panel_id: 4 + primary_format: percentunit + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}}) OR {{label:pod}} IS NULL) + | STATS used = SUM(CASE({{label:pod}} IS NOT NULL, LAST_OVER_TIME({{metric:container_memory_working_set_bytes:gauge}}), NULL)), total = SUM(LAST_OVER_TIME({{metric:machine_memory_bytes:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | EVAL computed_value = CASE(total > 0, used / total, NULL) + | WHERE computed_value IS NOT NULL + | SORT time_bucket DESC + | LIMIT 1 + | KEEP computed_value + status_override: migrated + - title_match: "Pod CPU usage" + section_match: "Total usage" + panel_id: 6 + primary_format: percentunit + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}}) OR {{label:pod}} IS NULL) + | STATS used = SUM(CASE({{label:pod}} IS NOT NULL, RATE({{metric:container_cpu_usage_seconds_total:counter}}), NULL)), total = SUM(LAST_OVER_TIME({{metric:machine_cpu_cores:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | EVAL computed_value = CASE(total > 0, used / total, NULL) + | WHERE computed_value IS NOT NULL + | SORT time_bucket DESC + | LIMIT 1 + | KEEP computed_value + status_override: migrated + - title_match: "Used" + panel_id: 36 + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:pod}} IS NOT NULL + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}})) + | STATS computed_value = SUM(LAST_OVER_TIME({{metric:container_memory_working_set_bytes:gauge}})) + | KEEP computed_value + status_override: migrated + - title_match: "Total" + panel_id: 37 + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | STATS computed_value = SUM(LAST_OVER_TIME({{metric:machine_memory_bytes:gauge}})) + | KEEP computed_value + status_override: migrated + - title_match: "Used" + panel_id: 38 + primary_format: short + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:pod}} IS NOT NULL + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}})) + | STATS computed_value = SUM(RATE({{metric:container_cpu_usage_seconds_total:counter}})) + | KEEP computed_value + status_override: migrated + # Grafana titles this "Total" but the PromQL is node-wide container CPU + # rate (not machine_cpu_cores). Keep that semantics; Kibana title says so. + - title_match: "Total" + panel_id: 39 + primary_format: short + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (MV_CONTAINS(TO_STRING(?Node), ".*") OR MV_CONTAINS(TO_STRING(?Node), {{label:instance}})) + | STATS computed_value = SUM(RATE({{metric:container_cpu_usage_seconds_total:counter}})) + | KEEP computed_value + status_override: migrated + - title_match: "Pod memory working bytes" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:pod}} IS NOT NULL + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}})) + | STATS computed_value = SUM(LAST_OVER_TIME({{metric:container_memory_working_set_bytes:gauge}})) + | KEEP computed_value + status_override: migrated + + - title_match: "Pods CPU usage" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:pod}} IS NOT NULL + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}})) + | WHERE {{metric:container_cpu_usage_seconds_total:counter}} IS NOT NULL + | STATS value = SUM(RATE({{metric:container_cpu_usage_seconds_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:pod}} + | KEEP time_bucket, `{{label:pod}}`, value + | SORT time_bucket ASC + status_override: migrated + - title_match: "Pods memory usage" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:pod}} IS NOT NULL + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}})) + | WHERE {{metric:container_memory_working_set_bytes:gauge}} IS NOT NULL + | STATS value = SUM(LAST_OVER_TIME({{metric:container_memory_working_set_bytes:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:pod}} + | KEEP time_bucket, `{{label:pod}}`, value + | SORT time_bucket ASC + status_override: migrated + - title_match: "Pods network I/O" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:pod}} IS NOT NULL + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}})) + | WHERE {{metric:container_network_receive_bytes_total:counter}} IS NOT NULL OR {{metric:container_network_transmit_bytes_total:counter}} IS NOT NULL + | STATS Received = SUM(RATE({{metric:container_network_receive_bytes_total:counter}})), Sent = 0 - SUM(RATE({{metric:container_network_transmit_bytes_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:pod}} + | KEEP time_bucket, `{{label:pod}}`, Received, Sent + | SORT time_bucket ASC + status_override: migrated + - title_match: "All processes CPU usage" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:pod}} IS NOT NULL + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}})) + | WHERE {{metric:container_cpu_usage_seconds_total:counter}} IS NOT NULL AND {{label:id}} IS NOT NULL AND {{label:id}} != "/" + | STATS value = SUM(RATE({{metric:container_cpu_usage_seconds_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:id}} + | KEEP time_bucket, `{{label:id}}`, value + | SORT time_bucket ASC + status_override: migrated + - title_match: "All processes memory usage" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:pod}} IS NOT NULL + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}})) + | WHERE {{metric:container_memory_working_set_bytes:gauge}} IS NOT NULL AND {{label:id}} IS NOT NULL AND {{label:id}} != "/" + | STATS value = SUM(LAST_OVER_TIME({{metric:container_memory_working_set_bytes:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:id}} + | KEEP time_bucket, `{{label:id}}`, value + | SORT time_bucket ASC + status_override: migrated + - title_match: "All processes network I/O" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:pod}} IS NOT NULL + | WHERE (MV_CONTAINS(TO_STRING(?Pod), ".*") OR MV_CONTAINS(TO_STRING(?Pod), {{label:pod}})) + | WHERE {{label:id}} IS NOT NULL AND {{label:id}} != "/" AND ({{metric:container_network_receive_bytes_total:counter}} IS NOT NULL OR {{metric:container_network_transmit_bytes_total:counter}} IS NOT NULL) + | STATS Received = SUM(RATE({{metric:container_network_receive_bytes_total:counter}})), Sent = 0 - SUM(RATE({{metric:container_network_transmit_bytes_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:id}} + | KEEP time_bucket, `{{label:id}}`, Received, Sent + | SORT time_bucket ASC + status_override: migrated + + layout_overrides: + - title_match: "Pod IP Address" + position: {x: 0, y: 0} + size: {w: 16, h: 12} + - title_match: "Pod Status" + position: {x: 16, y: 0} + size: {w: 8, h: 12} + - title_match: "Pod Container" + position: {x: 24, y: 0} + size: {w: 16, h: 12} + - title_match: "Container restarts" + position: {x: 40, y: 0} + size: {w: 8, h: 12} + - title_match: "Network I/O pressure" + size: {w: 48, h: 12} + - title_match: "Pod memory usage" + section_match: "Total usage" + panel_id: 4 + position: {x: 0, y: 0} + size: {w: 24, h: 12} + - title_match: "Pod CPU usage" + section_match: "Total usage" + panel_id: 6 + position: {x: 24, y: 0} + size: {w: 24, h: 12} + - title_match: "Used" + panel_id: 36 + title: "Memory used" + position: {x: 0, y: 12} + size: {w: 8, h: 8} + - title_match: "Total" + panel_id: 37 + title: "Memory total" + position: {x: 8, y: 12} + size: {w: 8, h: 8} + - title_match: "Used" + panel_id: 38 + title: "CPU used" + position: {x: 16, y: 12} + size: {w: 8, h: 8} + - title_match: "Total" + panel_id: 39 + title: "Node CPU" + position: {x: 24, y: 12} + size: {w: 8, h: 8} + - title_match: "Pod memory working bytes" + position: {x: 32, y: 12} + size: {w: 16, h: 8} + - title_match: "Pods CPU usage" + size: {w: 48, h: 12} + - title_match: "Pods memory usage" + size: {w: 48, h: 12} + - title_match: "Pods network I/O" + size: {w: 48, h: 12} + - title_match: "All processes CPU usage" + size: {w: 48, h: 12} + - title_match: "All processes memory usage" + size: {w: 48, h: 12} + - title_match: "All processes network I/O" + size: {w: 48, h: 12} diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_747_kubernetes_pod_metrics/plugin.py b/observability_migration/adapters/source/grafana/curated_packs/grafana_747_kubernetes_pod_metrics/plugin.py new file mode 100644 index 00000000..08d71ec9 --- /dev/null +++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_747_kubernetes_pod_metrics/plugin.py @@ -0,0 +1,59 @@ +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. +# SPDX-License-Identifier: Elastic-2.0 + +"""Grafana 747 (Kubernetes Pod Metrics) curated pack plugin. + +``$Node`` is ``label_values(kubernetes_io_hostname)``. Populate from +``machine_cpu_cores`` / canonical ``instance`` instead (same bridge as 741 / +1621). ``$Pod`` is already ``label_values(kube_pod_info, pod)``. + +Both are single-select with Grafana All (``.*``). Mark them multi-select so +first paint selects every concrete option instead of hydrating to the first +pod/node. Hidden ``$Pod_ip`` / ``$phase`` / ``$container`` stay skipped; the +pack replaces those markdown interpolations with a pod→IP / pod→container +datatable and a live phase metric tile. +""" + + +_PACK_NAME = "grafana_747_kubernetes_pod_metrics" +_NODE_QUERY = "label_values(machine_cpu_cores, instance)" +_MULTI = ("Node", "Pod") + + +def _mark_multi_select(pack): + names = set(getattr(pack, "_multi_select_param_names", None) or ()) + names.update(_MULTI) + setattr(pack, "_multi_select_param_names", names) + + +def register(api): + @api["query_preprocessors"].register("grafana_747_k8s_multi_select", priority=1) + def mark_pod_and_node_multi(context): + pack = getattr(context, "rule_pack", None) + if getattr(pack, "_curated_pack_name", "") != _PACK_NAME: + return None + _mark_multi_select(pack) + return None + + @api["variable_translators"].register("grafana_747_k8s_controls", priority=5) + def rewrite_node_and_pod_controls(context): + pack = getattr(context, "rule_pack", None) + if getattr(pack, "_curated_pack_name", "") != _PACK_NAME: + return None + variable = context.variable or {} + name = str(variable.get("name") or "") + query_text = context.query_text or str(variable.get("query") or "") + compact = query_text.replace(" ", "").lower() + if name not in _MULTI or "label_values(" not in compact: + return None + + variable = dict(variable) + if name == "Node": + variable["query"] = _NODE_QUERY + context.query_text = _NODE_QUERY + variable["multi"] = True + variable["includeAll"] = True + variable["allValue"] = ".*" + context.variable = variable + _mark_multi_select(pack) + return None diff --git a/observability_migration/adapters/source/grafana/curated_packs/registry.yaml b/observability_migration/adapters/source/grafana/curated_packs/registry.yaml index 67d1ae6c..85da3c32 100644 --- a/observability_migration/adapters/source/grafana/curated_packs/registry.yaml +++ b/observability_migration/adapters/source/grafana/curated_packs/registry.yaml @@ -137,6 +137,26 @@ packs: dashboard_sha256: "6fb5e045bc6d860f0f22ce7e145f4da04d2e25fd4fbf9fda29355cef6d63aeae" description: "cAdvisor cluster monitoring — pre-1.16 label remap (pod_name/container_name → pod/container), obsolete rkt/docker/systemd series dropped as gaps, machine_*/container_fs_* KPIs" + - gnet_id: 1621 + name: grafana_1621_kubernetes_cadvisor + # Title is identical to 315; leave title_hint empty so title-only fallback + # keeps resolving 315 (the original). 1621 is gnetId-only. + title_hint: "" + tags_hint: ["kubernetes"] + path: grafana_1621_kubernetes_cadvisor + gnet_revision: 1 + dashboard_sha256: "d714551536ca794e3088ac535e59b3d2f93dd705e3b60f84d6f3c42da40fe9d4" + description: "cAdvisor cluster monitoring (315 fork) — filesystem KPIs sum every /dev/* partition, $Node via instance, named Memory/CPU/Filesystem tiles" + + - gnet_id: 747 + name: grafana_747_kubernetes_pod_metrics + title_hint: "Kubernetes Pod Metrics" + tags_hint: ["kubernetes"] + path: grafana_747_kubernetes_pod_metrics + gnet_revision: 2 + dashboard_sha256: "47837bfea31e6156decd5c152379addfd9c3f9a9991a0cc96d764f91388b2d55" + description: "Pod-scoped cAdvisor + kube-state-metrics — Heapster label remap, hidden $Pod_ip/$phase/$container → metric tiles, $Node via instance, restarts_total map" + - gnet_id: 6417 name: grafana_6417_kubernetes_ksm title_hint: "Kubernetes Cluster (Prometheus)" diff --git a/observability_migration/adapters/source/grafana/panels.py b/observability_migration/adapters/source/grafana/panels.py index 68882f00..baea8431 100644 --- a/observability_migration/adapters/source/grafana/panels.py +++ b/observability_migration/adapters/source/grafana/panels.py @@ -4095,20 +4095,34 @@ def translate_panel(panel, datasource_index="metrics-*", esql_index=None, rule_p ) if panel_type == "text": - content = _normalized_text_panel_content(panel) - yaml_panel["markdown"] = {"content": content or "*(migrated text panel)*"} - if not str(panel.get("title") or "").strip(): - yaml_panel["hide_title"] = True - panel_result = PanelResult(title, panel_type, "markdown", "migrated", 1.0) - return yaml_panel, _enrich_panel_result( - panel_result, - panel=panel, - datasource=datasource, - query_language="text", - notes=panel_notes, - inventory=panel_inventory, - yaml_panel=yaml_panel, - ) + # Curated packs may replace Grafana markdown that only interpolates + # template variables (``# $Pod_ip``) with a live ES|QL metric tile. + # Without an override, keep the existing markdown path. + _text_override = None + if rule_pack.panel_query_overrides: + _text_override = _select_panel_pack_override( + rule_pack.panel_query_overrides, + title, + section_title=section_title, + panel_id=panel.get("id") or panel.get("panelId") or "", + ) + if not ( + _text_override and str(_text_override.get("esql_query") or "").strip() + ): + content = _normalized_text_panel_content(panel) + yaml_panel["markdown"] = {"content": content or "*(migrated text panel)*"} + if not str(panel.get("title") or "").strip(): + yaml_panel["hide_title"] = True + panel_result = PanelResult(title, panel_type, "markdown", "migrated", 1.0) + return yaml_panel, _enrich_panel_result( + panel_result, + panel=panel, + datasource=datasource, + query_language="text", + notes=panel_notes, + inventory=panel_inventory, + yaml_panel=yaml_panel, + ) if panel_analysis.get("mixed_datasource"): reasons = ["Mixed datasource or query-language panel targets require manual redesign"] diff --git a/parity-rig/curated/grafana_763_redis_exporter/docker-compose.yml b/parity-rig/curated/grafana_763_redis_exporter/docker-compose.yml index f54e8f55..09fa124f 100644 --- a/parity-rig/curated/grafana_763_redis_exporter/docker-compose.yml +++ b/parity-rig/curated/grafana_763_redis_exporter/docker-compose.yml @@ -386,7 +386,7 @@ services: # Synthetic Kubernetes exporter (cAdvisor + kube-state-metrics + node_exporter # + cluster-autoscaler + app HTTP) so the Kubernetes curated packs - # (Grafana 315, 6417, 741, 3831, 1471) validate on real data. + # (Grafana 315, 1621, 747, 6417, 741, 3831, 1471) validate on real data. k8s_exporter: image: python:3.11-slim container_name: redis-rig-k8s-exporter diff --git a/parity-rig/curated/grafana_763_redis_exporter/k8s_exporter.py b/parity-rig/curated/grafana_763_redis_exporter/k8s_exporter.py index 08061c1c..cecb5c33 100644 --- a/parity-rig/curated/grafana_763_redis_exporter/k8s_exporter.py +++ b/parity-rig/curated/grafana_763_redis_exporter/k8s_exporter.py @@ -6,8 +6,10 @@ # # Emits a small, coherent cluster in modern shape so the Kubernetes curated # packs can be validated on real (rig-ingested) data: -# - Grafana 315 (cAdvisor): container_* + machine_* + container_fs_* with the -# modern `pod`/`container` labels and a root-cgroup `id="/"` series. +# - Grafana 315 / 1621 (cAdvisor): container_* + machine_* + container_fs_* with the +# modern `pod`/`container` labels and a root-cgroup `id="/"`. 1621 filesystem +# KPIs need several `/dev/*` partitions (sda + nvme + sdb); 315's tighter +# `[sv]d[a-z][1-9]` regex only matches the scsi/virtio disks. # - Grafana 6417 (kube-state-metrics + node_exporter): kube_* in the modern # resource-split shape (kube_node_status_allocatable{resource=...}, etc.), # plus node_filesystem_*_bytes. @@ -86,14 +88,20 @@ def render() -> str: f'container_memory_working_set_bytes{{id="/",instance="{n}",node="{n}"}} ' f'{int((5.0 + ni) * 1024**3)}' ) - L.append( - f'container_fs_usage_bytes{{id="/",device="/dev/sda1",instance="{n}"}} ' - f'{int((30 + 5 * ni) * 1024**3)}' - ) - L.append( - f'container_fs_limit_bytes{{id="/",device="/dev/sda1",instance="{n}"}} ' - f'{(100 * 1024**3)}' - ) + # Several partitions so 1621 (device=~^/dev/.*) sums scsi + nvme while + # 315's ^/dev/[sv]d[a-z][1-9]$ still matches only sda1. + for device, used_gib, limit_gib in ( + ("/dev/sda1", 30 + 5 * ni, 100), + ("/dev/nvme0n1p1", 10 + ni, 50), + ): + L.append( + f'container_fs_usage_bytes{{id="/",device="{device}",instance="{n}"}} ' + f"{int(used_gib * 1024**3)}" + ) + L.append( + f'container_fs_limit_bytes{{id="/",device="{device}",instance="{n}"}} ' + f"{int(limit_gib * 1024**3)}" + ) # ---- cAdvisor: per pod/container series ----------------------------- idx = 0 @@ -140,6 +148,8 @@ def render() -> str: # ---- kube-state-metrics: pods --------------------------------------- L.append("# HELP kube_pod_info Information about pod") L.append("# TYPE kube_pod_info gauge") + L.append("# HELP kube_pod_container_info Information about a container in a pod") + L.append("# TYPE kube_pod_container_info gauge") L.append("# HELP kube_pod_status_phase The pods current phase") L.append("# TYPE kube_pod_status_phase gauge") L.append("# HELP kube_pod_container_status_running Whether the container is running") @@ -157,7 +167,15 @@ def render() -> str: for pod, container in pods: n = _node_for(idx) idx += 1 - L.append(f'kube_pod_info{{namespace="{ns}",pod="{pod}",node="{n}"}} 1') + pod_ip = f"10.244.0.{idx}" + L.append( + f'kube_pod_info{{namespace="{ns}",pod="{pod}",node="{n}",' + f'pod_ip="{pod_ip}",host_ip="10.0.0.{1 + (idx % 2)}"}} 1' + ) + L.append( + f'kube_pod_container_info{{namespace="{ns}",pod="{pod}",' + f'container="{container}",image="registry/{container}:latest"}} 1' + ) # kube-state-metrics emits every phase as a 0/1 series; 0-valued # non-Running phases keep the Pending/Failed/Succeeded/Unknown # tiles at 0 instead of Lens N/A. diff --git a/tests/test_curated_packs.py b/tests/test_curated_packs.py index 56fd4b4d..1eb5831e 100644 --- a/tests/test_curated_packs.py +++ b/tests/test_curated_packs.py @@ -180,7 +180,7 @@ def test_registry_pins_match_community_corpus_when_revision_aligns(): for entry in corpus["dashboards"] } # New packs in this PR. 9628 is pack rev 1 vs corpus rev 8 — no join. - new_pack_ids = {7362, 9628, 14114, 12485, 315, 6417, 741, 8171, 3831, 1471} + new_pack_ids = {7362, 9628, 14114, 12485, 315, 1621, 747, 6417, 741, 8171, 3831, 1471} mismatches = [] for entry in load_curated_registry(): gnet_id = int(entry["gnet_id"]) @@ -5880,3 +5880,451 @@ def test_1471_memory_total_uses_last_over_time(): assert "container_memory_usage_bytes" in query assert "container_spec_memory_limit_bytes" in query assert "`limit`" in query or "limit" in query + + +# --------------------------------------------------------------------------- +# Grafana 1621 — Kubernetes cluster monitoring (cAdvisor, /dev/* filesystem) +# --------------------------------------------------------------------------- + + +def _resolve_1621(): + dashboard = { + "gnetId": 1621, + "title": "Kubernetes cluster monitoring (via Prometheus)", + "tags": ["kubernetes"], + } + resolved = resolve_pack_for_dashboard(dashboard, RulePackConfig()) + return resolved, SchemaResolver(resolved) + + +def test_1621_registry_entry_present(): + entry = find_curated_pack(gnet_id=1621, title="", tags=[]) + assert entry is not None + assert entry["name"] == "grafana_1621_kubernetes_cadvisor" + assert entry["gnet_revision"] == 1 + assert entry["dashboard_sha256"] == ( + "d714551536ca794e3088ac535e59b3d2f93dd705e3b60f84d6f3c42da40fe9d4" + ) + + +def test_1621_is_not_the_315_pack(): + """Same title as 315; gnetId 1621 must not resolve the 315 pack.""" + resolved, _ = _resolve_1621() + assert getattr(resolved, "_curated_pack_name", "") == "grafana_1621_kubernetes_cadvisor" + resolved_315, _ = _resolve_315() + assert getattr(resolved_315, "_curated_pack_name", "") == "grafana_315_kubernetes_cadvisor" + + +def test_1621_keeps_node_via_instance_not_ignored_hostname(): + resolved, _ = _resolve_1621() + assert resolved.label_rewrites["kubernetes_io_hostname"] == "instance" + assert "kubernetes_io_hostname" not in resolved.ignored_labels + assert resolved.control_field_overrides.get("Node") == "instance" + + +def test_1621_filesystem_aggregates_all_dev_partitions(): + resolved, resolver = _resolve_1621() + panel = { + "id": 7, + "type": "singlestat", + "title": "Cluster filesystem usage", + "targets": [ + { + "expr": ( + 'sum (container_fs_usage_bytes{device=~"^/dev/.*$",id="/"}' + ") / sum (container_fs_limit_bytes{device=~\"^/dev/.*$\",id=\"/\"}) * 100" + ), + "refId": "A", + } + ], + "gridPos": {"x": 0, "y": 0, "w": 8, "h": 4}, + } + yaml_panel, result = translate_panel( + panel, + datasource_index="metrics-*", + esql_index="metrics-*", + rule_pack=resolved, + resolver=resolver, + ) + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + query = (yaml_panel.get("esql") or {}).get("query") or "" + assert "STARTS_WITH" in query + assert "/dev/" in query + assert "[sv]d" not in query + assert "?Node" in query + assert "container_fs_usage_bytes" in query + assert "container_fs_limit_bytes" in query + + +def test_1621_pods_cpu_binds_node_and_groups_by_pod(): + resolved, resolver = _resolve_1621() + panel = { + "id": 17, + "type": "graph", + "title": "Pods CPU usage (1m avg)", + "targets": [ + { + "expr": ( + "sum (rate (container_cpu_usage_seconds_total" + '{image!="",name=~"^k8s_.*",kubernetes_io_hostname=~"^$Node$"}[1m]))' + " by (pod_name)" + ), + "legendFormat": "{{ pod_name }}", + "refId": "A", + } + ], + "gridPos": {"x": 0, "y": 0, "w": 12, "h": 7}, + } + yaml_panel, result = translate_panel( + panel, + datasource_index="metrics-*", + esql_index="metrics-*", + rule_pack=resolved, + resolver=resolver, + ) + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + query = (yaml_panel.get("esql") or {}).get("query") or "" + assert "k8s.pod.name" in query + assert "RATE(container_cpu_usage_seconds_total)" in query + assert "?Node" in query + assert "kubernetes_io_hostname" not in query + + +def test_1621_layout_renames_duplicate_used_total(): + resolved, _ = _resolve_1621() + by_id = {str(item.get("panel_id") or ""): item for item in resolved.panel_layout_overrides} + assert by_id["9"]["title"] == "Memory used" + assert by_id["10"]["title"] == "Memory total" + assert by_id["11"]["title"] == "CPU used" + assert by_id["12"]["title"] == "CPU total" + assert by_id["13"]["title"] == "Filesystem used" + assert by_id["14"]["title"] == "Filesystem total" + assert by_id["4"]["position"] == {"x": 0, "y": 0} + assert by_id["6"]["position"] == {"x": 16, "y": 0} + assert by_id["7"]["position"] == {"x": 32, "y": 0} + + +def test_1621_plugin_rewrites_node_to_multi_select_instance(): + dashboard = { + "gnetId": 1621, + "title": "Kubernetes cluster monitoring (via Prometheus)", + "tags": ["kubernetes"], + "templating": { + "list": [ + { + "name": "Node", + "type": "query", + "query": "label_values(kubernetes_io_hostname)", + "includeAll": True, + "allValue": ".*", + "multi": False, + } + ] + }, + "rows": [ + { + "title": "Total usage", + "panels": [ + { + "id": 12, + "type": "singlestat", + "title": "Total", + "targets": [{"expr": "sum(machine_cpu_cores)", "refId": "A"}], + "span": 2, + } + ], + } + ], + } + resolved = resolve_pack_for_dashboard(dashboard, RulePackConfig()) + result = translate_dashboard( + dashboard, + datasource_index="metrics-*", + esql_index="metrics-*", + rule_pack=resolved, + ) + payload = result.dashboard_ir.to_yaml_dict() + controls = payload.get("controls") or [] + by_name = {c.get("variable_name"): c for c in controls} + assert "Node" in by_name, controls + node = by_name["Node"] + assert node.get("multiple") is True + assert "machine_cpu_cores" in str(node.get("query") or "") + assert "kubernetes_io_hostname" not in str(node.get("query") or "") + + +# --------------------------------------------------------------------------- +# Grafana 747 — Kubernetes Pod Metrics +# --------------------------------------------------------------------------- + + +def _resolve_747(): + dashboard = { + "gnetId": 747, + "title": "Kubernetes Pod Metrics", + "tags": ["kubernetes"], + } + resolved = resolve_pack_for_dashboard(dashboard, RulePackConfig()) + return resolved, SchemaResolver(resolved) + + +def test_747_registry_entry_present(): + entry = find_curated_pack(gnet_id=747, title="", tags=[]) + assert entry is not None + assert entry["name"] == "grafana_747_kubernetes_pod_metrics" + assert entry["gnet_revision"] == 2 + + +def test_747_maps_restarts_and_heapster_labels(): + resolved, _ = _resolve_747() + + def _target(name): + entry = resolved.metric_map[name] + return getattr(entry, "target", str(entry)) + + assert _target("kube_pod_container_status_restarts").endswith( + "kube_pod_container_status_restarts_total" + ) + assert resolved.metric_kinds["kube_pod_container_status_restarts_total"] == "counter" + assert resolved.label_rewrites["pod_name"] == "pod" + assert resolved.label_rewrites["io_kubernetes_pod_name"] == "pod" + assert resolved.label_rewrites["kubernetes_io_hostname"] == "instance" + + +def test_747_text_panel_becomes_pod_ip_datatable(): + resolved, resolver = _resolve_747() + panel = { + "id": 34, + "type": "text", + "title": "Pod IP Address", + "content": "# $Pod_ip", + "mode": "markdown", + "gridPos": {"x": 0, "y": 0, "w": 6, "h": 4}, + } + yaml_panel, result = translate_panel( + panel, + datasource_index="metrics-*", + esql_index="metrics-*", + rule_pack=resolved, + resolver=resolver, + ) + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + assert "markdown" not in yaml_panel + query = (yaml_panel.get("esql") or {}).get("query") or "" + assert "kube_pod_info" in query + assert "pod_ip" in query + assert "?Pod" in query + assert "IS NOT NULL" in query + assert yaml_panel.get("esql", {}).get("type") == "datatable" + + +def test_747_restarts_uses_counter_total_and_exact_pod(): + resolved, resolver = _resolve_747() + panel = { + "id": 41, + "type": "singlestat", + "title": "Container restarts", + "targets": [ + { + "expr": 'kube_pod_container_status_restarts{pod=~"$Pod.*$"}', + "refId": "A", + } + ], + "gridPos": {"x": 0, "y": 0, "w": 6, "h": 3}, + } + yaml_panel, result = translate_panel( + panel, + datasource_index="metrics-*", + esql_index="metrics-*", + rule_pack=resolved, + resolver=resolver, + ) + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + query = (yaml_panel.get("esql") or {}).get("query") or "" + assert "kube_pod_container_status_restarts_total" in query + assert "kube_pod_container_status_restarts{" not in query + assert "?Pod" in query + assert "IS NOT NULL" in query + + +def test_747_pods_cpu_groups_by_canonical_pod(): + resolved, resolver = _resolve_747() + panel = { + "id": 17, + "type": "graph", + "title": "Pods CPU usage", + "targets": [ + { + "expr": ( + "sum (rate (container_cpu_usage_seconds_total" + '{image!="",name=~"^k8s_.*",pod_name=~"^$Pod$"}[1m]))' + " by (io_kubernetes_pod_name)" + ), + "legendFormat": "{{ io_kubernetes_pod_name }}", + "refId": "A", + } + ], + "gridPos": {"x": 0, "y": 0, "w": 12, "h": 7}, + } + yaml_panel, result = translate_panel( + panel, + datasource_index="metrics-*", + esql_index="metrics-*", + rule_pack=resolved, + resolver=resolver, + ) + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + query = (yaml_panel.get("esql") or {}).get("query") or "" + assert "k8s.pod.name" in query + assert "io_kubernetes_pod_name" not in query + assert "?Pod" in query + + +def test_747_network_names_received_and_sent(): + resolved, resolver = _resolve_747() + panel = { + "id": 16, + "type": "graph", + "title": "Pods network I/O", + "targets": [ + { + "expr": ( + "sum (rate (container_network_receive_bytes_total" + '{pod_name=~"^$Pod$"}[1m])) by (io_kubernetes_pod_name)' + ), + "legendFormat": "-> {{ io_kubernetes_pod_name }}", + "refId": "A", + }, + { + "expr": ( + "- sum (rate (container_network_transmit_bytes_total" + '{pod_name=~"^$Pod$"}[1m])) by (io_kubernetes_pod_name)' + ), + "legendFormat": "<- {{ io_kubernetes_pod_name }}", + "refId": "B", + }, + ], + "gridPos": {"x": 0, "y": 0, "w": 12, "h": 7}, + } + yaml_panel, result = translate_panel( + panel, + datasource_index="metrics-*", + esql_index="metrics-*", + rule_pack=resolved, + resolver=resolver, + ) + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + query = (yaml_panel.get("esql") or {}).get("query") or "" + assert "Received" in query and "Sent" in query + + +def test_747_plugin_rewrites_node_and_marks_pod_multi(): + dashboard = { + "gnetId": 747, + "title": "Kubernetes Pod Metrics", + "tags": ["kubernetes"], + "templating": { + "list": [ + { + "name": "Node", + "type": "query", + "query": "label_values(kubernetes_io_hostname)", + "includeAll": True, + "allValue": ".*", + "multi": False, + }, + { + "name": "Pod", + "type": "query", + "query": "label_values(kube_pod_info, pod)", + "includeAll": True, + "allValue": ".*", + "multi": False, + }, + { + "name": "Pod_ip", + "type": "query", + "query": "label_values(kube_pod_info, pod_ip)", + "hide": 2, + }, + ] + }, + "rows": [ + { + "title": "Pod Info", + "panels": [ + { + "id": 41, + "type": "singlestat", + "title": "Container restarts", + "targets": [ + { + "expr": ( + 'kube_pod_container_status_restarts{pod=~"$Pod.*$"}' + ), + "refId": "A", + } + ], + "span": 3, + } + ], + } + ], + } + resolved = resolve_pack_for_dashboard(dashboard, RulePackConfig()) + result = translate_dashboard( + dashboard, + datasource_index="metrics-*", + esql_index="metrics-*", + rule_pack=resolved, + ) + payload = result.dashboard_ir.to_yaml_dict() + controls = payload.get("controls") or [] + by_name = {c.get("variable_name"): c for c in controls} + assert "Node" in by_name and "Pod" in by_name, controls + assert "Pod_ip" not in by_name + assert by_name["Node"].get("multiple") is True + assert by_name["Pod"].get("multiple") is True + assert "machine_cpu_cores" in str(by_name["Node"].get("query") or "") + assert "kube_pod_info" in str(by_name["Pod"].get("query") or "") + + +def test_747_memory_used_requires_pod_label(): + """PromQL pod=~ excludes series with no pod; MV_CONTAINS(null) does not.""" + resolved, resolver = _resolve_747() + panel = { + "id": 36, + "type": "singlestat", + "title": "Used", + "targets": [ + { + "expr": 'sum (container_memory_working_set_bytes{pod_name=~"^$Pod$"})', + "refId": "A", + } + ], + "gridPos": {"x": 0, "y": 0, "w": 6, "h": 3}, + } + yaml_panel, result = translate_panel( + panel, + datasource_index="metrics-*", + esql_index="metrics-*", + rule_pack=resolved, + resolver=resolver, + ) + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + query = (yaml_panel.get("esql") or {}).get("query") or "" + assert "k8s.pod.name IS NOT NULL" in query + assert "container_memory_working_set_bytes" in query + assert "?Pod" in query + + +def test_747_layout_renames_cpu_total_to_node_cpu(): + resolved, _ = _resolve_747() + by_id = {str(item.get("panel_id") or ""): item for item in resolved.panel_layout_overrides} + assert by_id["36"]["title"] == "Memory used" + assert by_id["37"]["title"] == "Memory total" + assert by_id["38"]["title"] == "CPU used" + assert by_id["39"]["title"] == "Node CPU" + assert by_id["4"]["position"] == {"x": 0, "y": 0} + assert by_id["6"]["position"] == {"x": 24, "y": 0} + diff --git a/tests/test_field_profile_portability.py b/tests/test_field_profile_portability.py index d3f18199..8f9db4d9 100644 --- a/tests/test_field_profile_portability.py +++ b/tests/test_field_profile_portability.py @@ -354,7 +354,8 @@ def test_control_field_override_is_canonical(profile, expected): ("8171", "kubernetes-nodes"), ("6417", "kubernetes-cluster-prometheus"), ("315", "kubernetes-cluster-monitoring-via-prometheus-315"), - ("315-1621", "kubernetes-cluster-monitoring-via-prometheus-1621"), + ("1621", "kubernetes-cluster-monitoring-via-prometheus-1621"), + ("747", "pod-metrics"), ("1471", "kubernetes-apps"), ("3831", "kubernetes-cluster-autoscaler-via-prometheus"), ] @@ -752,6 +753,8 @@ def test_k8s_pack_canonical_labels_namespace_under_native(): 1471: ["pod", "container", "namespace", "instance"], 3831: [], 315: ["pod", "container", "namespace"], + 1621: ["pod", "container", "instance"], + 747: ["pod", "container", "instance"], 6417: ["instance", "namespace"], 741: ["pod", "container", "instance"], 8171: ["instance"],