diff --git a/docs/design/curated-pack-1471-kubernetes-apps.md b/docs/design/curated-pack-1471-kubernetes-apps.md
new file mode 100644
index 00000000..1b1b2e54
--- /dev/null
+++ b/docs/design/curated-pack-1471-kubernetes-apps.md
@@ -0,0 +1,56 @@
+# Curated Pack — Grafana 1471 "Kubernetes App Metrics"
+
+> Design + living discoveries for the 1471 curated pack. Follows the general
+> Curation Playbook in `curated-dashboard-packs-plan.md`. Stacks on 315
+> (cAdvisor) and 741 (Deployment metrics).
+
+- Source: community **"Kubernetes App Metrics"**,
+
+- gnetId **1471**, only revision **1**.
+- canonical sha256 (rev 1) = `27552f4c9ba5ce3e43ae7c962c98c6979e87e1e4c2f4d463f26df62652cabc52`.
+- Datasource: Prometheus — curated-pack eligible.
+- Schema: **v14 (`rows[]`)**. 13 leaf panels.
+- Metric family: **cAdvisor** (`container_*`, `container_spec_*`) **and**
+ app HTTP (`http_requests_total`, `nginx_http_*`, `haproxy_backend_http_*`).
+- Variables: `$namespace` =
+ `label_values(container_memory_usage_bytes{…container_name!="POD"}, namespace)`;
+ `$container` =
+ `label_values(…{namespace=~"$namespace",container_name!="POD"}, container_name)`.
+ HTTP panels also filter `app="$container"` (app name == container name).
+
+## Goal
+
+Ship a curated pack so 1471 renders in Kibana against a modern cAdvisor scrape
+plus optional native/nginx/haproxy HTTP metrics, with working namespace and
+container controls.
+
+## The core problem
+
+| Dashboard convention | Modern reality | Pack handling |
+|---|---|---|
+| `container_name` / `pod_name` | cAdvisor `container` / `pod` | `label_rewrites` |
+| `kubernetes_io_hostname` | scrape `instance` | rewrite → `instance` |
+| HTTP `kubernetes_namespace` | `namespace` | rewrite → `namespace` |
+| `app="$container"` | app label equals container name | HTTP ES\|QL binds `?container` to `app` |
+| nginx grouped by `status`, native/haproxy by `code` | engine drops nginx | curated ES\|QL; per-doc `series` like `native \| 200` then `STATS rate BY time_bucket, series` so Lens matches Grafana legendFormat |
+| `histogram_quantile(*_bucket)` | no classic-histogram field | `PERCENTILE` of duration gauge |
+| per-pod `by (id, pod_name)` + limit/request lines | Lens XY one breakdown | group by `pod`; drop reference lines |
+
+## Engine vs pack split
+
+Pipeline already handles irate/rate, `rows[]` → sections, flattening
+single-panel legacy rows, and control synthesis. The pack carries the Heapster
+label bridge, populate rewrites, HTTP named series, ratio ES|QL, and
+`LAST_OVER_TIME` gauges (raw `SUM` of gauge docs over-counts).
+
+## Fidelity
+
+- **PERFECT**: request/error rates, pod/host counts, usage-vs-request/limit,
+ avg-per-pod and total CPU/memory.
+- **APPROXIMATE**: response-time percentiles (PERCENTILE vs histogram_quantile);
+ per-pod CPU/memory (reference lines dropped).
+
+## Validation
+
+Shared curated rig `k8s_exporter.py` emits cAdvisor spec/user/system + HTTP
+series (`app` = container name) into `metrics-k8s.prometheus-default`.
diff --git a/docs/design/curated-pack-3831-kubernetes-autoscaler.md b/docs/design/curated-pack-3831-kubernetes-autoscaler.md
new file mode 100644
index 00000000..91a6d50e
--- /dev/null
+++ b/docs/design/curated-pack-3831-kubernetes-autoscaler.md
@@ -0,0 +1,48 @@
+# Curated Pack — Grafana 3831 "Kubernetes Cluster Autoscaler (via Prometheus)"
+
+> Design + living discoveries for the 3831 curated pack. Follows the general
+> Curation Playbook in `curated-dashboard-packs-plan.md`. Stacks on the
+> Kubernetes 315 / 6417 / 741 / 8171 packs.
+
+- Source: community **"Kubernetes Cluster Autoscaler (via Prometheus)"**,
+
+- gnetId **3831**, only revision **1**.
+- canonical sha256 (rev 1) = `fbfc5ff33d138f2a449d4d28b0c0dcd09d351f08a2c78b91e3455d00e2b0b597`.
+- Datasource: Prometheus — curated-pack eligible.
+- Schema: **v14 (`rows[]`)**. 10 leaf panels. No template variables.
+- Metric family: **cluster-autoscaler** `/metrics`
+ (`cluster_autoscaler_nodes_count`, `*_last_activity`, `*_total` counters).
+
+## Goal
+
+Ship a curated pack so 3831 renders in Kibana against a cluster-autoscaler
+scrape. Force ES|QL for every panel (native PROMQL `LAST(value, step)` is
+empty in Lens on prometheus_native ingest) and polish KPI titles for Kibana
+tiles.
+
+## Engine vs pack split
+
+Pipeline already handles `rows[]` → sections, flattening Grafana's placeholder
+"New row", and singlestat reducers. The pack pins metric kinds, emits ES|QL
+for `time()-last_activity` (`DATE_DIFF`), named Activity/Autoscaling series,
+and the ready/total + scaled_up−scaled_down KPIs.
+
+Grafana plots `*_total` counters with bare `sum()` (no `rate()`). Kibana
+matches that with `LAST_OVER_TIME`, not `RATE`.
+
+Grafana's placeholder row `"New row"` is flattened to top-level panels.
+Those must sit at layout `y: 2` (after Info `y=0` and Activity `y=1`) or
+they overlap the Info KPIs. Nodes available is `ready/total * 100` so the
+Kibana number+% tile shows 100%, not 1.00%.
+
+## Fidelity
+
+- **PERFECT**: Total nodes, Nodes available, last-activity tiles, Pod/Node/
+ Autoscaling activity graphs.
+- **APPROXIMATE**: Safe to autoscale (0/1, no Yes/No value map), shortened
+ Unscheduled pods / Net scaled nodes titles.
+
+## Validation
+
+Shared curated rig `k8s_exporter.py` emits `cluster_autoscaler_*` into
+`metrics-k8s.prometheus-default`.
diff --git a/docs/sources/grafana.md b/docs/sources/grafana.md
index f6637867..75b4dfe0 100644
--- a/docs/sources/grafana.md
+++ b/docs/sources/grafana.md
@@ -280,6 +280,32 @@ ES|QL (`time_bucket` + `labels.device`, `$server` bound) because native PROMQL
"No results found". `$server` stays
`label_values(node_boot_time_seconds, instance)`.
+The Kubernetes Cluster Autoscaler (3831) pack targets cluster-autoscaler
+`/metrics`. Grafana plots several `*_total` counters with bare `sum()` (no
+`rate()`), so Kibana keeps `LAST_OVER_TIME` cumulative totals rather than
+`RATE`. Every panel is curated ES|QL because native PROMQL `LAST(value, step)`
+is empty in Lens on prometheus_native ingest. `time()-last_activity` becomes
+`DATE_DIFF` seconds. Nodes available is `ready/total * 100` (Grafana percent
+0–100) so the Kibana number+% tile shows 100%, not 1%. The placeholder
+Grafana row `"New row"` flattens to top-level panels placed at `y: 2` after
+the Info and Activity sections. KPI chrome titles are shortened for Kibana
+tiles (Safe to autoscale, Unscheduled pods, Since scale-down/autoscale, Net
+scaled nodes); the 0/1 safe-to-scale gauge has no Yes/No value map.
+
+The Kubernetes App Metrics (1471) pack is a pre-1.16 cAdvisor + app-HTTP mix.
+Heapster labels (`container_name`, `pod_name`, `kubernetes_io_hostname`) and
+HTTP `kubernetes_namespace` rewrite to canonical `container` / `pod` /
+`instance` / `namespace`. `$container` is used both as the cAdvisor container
+and as HTTP `app` (dashboard convention: `app` equals container name). The
+pack plugin rewrites the populate queries off `container_name`. Request rate
+restores the nginx series the engine drops (status vs code grouping) as
+Grafana-style `native | 200` / `nginx | 500` / `haproxy | 2xx` series (one
+Lens breakdown, not a cartesian Native-by-code legend). nginx/haproxy metrics are
+`live_optional`. Per-pod graphs group by `pod` rather than `(id, pod_name)`
+and drop Grafana's limit/request reference lines (Lens XY has one breakdown).
+Response-time panels approximate `histogram_quantile` with `PERCENTILE` of
+the duration gauge.
+
Each pack is registered in `curated_packs/registry.yaml` with a
`gnet_revision` and `dashboard_sha256` — maintainer-verified provenance pins
recording the exact grafana.com revision the pack authors read, re-checkable
diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/fidelity_manifest.yaml b/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/fidelity_manifest.yaml
new file mode 100644
index 00000000..b544e86d
--- /dev/null
+++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/fidelity_manifest.yaml
@@ -0,0 +1,71 @@
+# 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 1471 (Kubernetes App Metrics)
+# https://grafana.com/grafana/dashboards/1471-kubernetes-apps/
+#
+# PERFECT = same information as Grafana on modern cAdvisor + app HTTP scrape.
+# APPROXIMATE = documented delta (histogram_quantile, dropped reference lines).
+
+# Field-profile portable: pack.yaml authors canonical names; the resolver
+# namespaces per --field-profile. Verified by the cross-profile leakage gate.
+
+schema_version: 1
+gnet_id: 1471
+gnet_revision: 1
+dashboard_title: "Kubernetes App Metrics"
+maintainer: "community"
+
+panels:
+ - title: "Request rate"
+ fidelity: PERFECT
+ notes: "irate of native/nginx/haproxy request counters. Nginx restored (engine dropped it for status vs code grouping). One rate series per source+status (native | 200 / nginx | 500 / haproxy | 2xx) so Lens breaks down on `series` instead of a cartesian source x code legend; $container binds app, $namespace binds namespace."
+ - title: "Error rate"
+ fidelity: PERFECT
+ notes: "5xx / total per native (code=~5xx, handler!=prometheus), nginx (status=~5xx), haproxy (code=5xx) as named series."
+ - title: "Response time percentiles"
+ fidelity: APPROXIMATE
+ notes: "Grafana histogram_quantile on *_bucket; Kibana PERCENTILE of the duration gauge (t-digest scrape samples)."
+ - title: "Number of pods"
+ fidelity: PERFECT
+ notes: "COUNT_DISTINCT(pod) / COUNT_DISTINCT(instance) replacing nested count(count()) by pod_name / kubernetes_io_hostname."
+ - title: "Cpu usage (relative to request)"
+ fidelity: PERFECT
+ notes: "irate(cpu_system|user) / (cpu_shares/1024) as system/user series."
+ - title: "Cpu usage (relative to limit)"
+ fidelity: PERFECT
+ notes: "irate(cpu_usage) / (quota/period). Curated ES|QL; native PROMQL LAST step is empty in Lens."
+ - title: "Memory usage (relative to limit)"
+ fidelity: PERFECT
+ notes: "working memory_usage / spec_memory_limit LAST_OVER_TIME ratio."
+ - title: "Cpu usage (per pod)"
+ fidelity: APPROXIMATE
+ notes: "Per-pod IRATE by labels.pod. Grafana also plotted limit/request reference lines; Lens XY has one breakdown so those lines are dropped."
+ - title: "Memory usage (per pod)"
+ fidelity: APPROXIMATE
+ notes: "Per-pod LAST_OVER_TIME by labels.pod. Limit reference line dropped for the same Lens breakdown reason."
+ - title: "Cpu usage (avg per pod)"
+ fidelity: PERFECT
+ notes: "total CPU / COUNT_DISTINCT(pod) plus per-pod-average limit (quota/period) and request (shares/1024)."
+ - title: "Memory usage (avg per pod)"
+ fidelity: PERFECT
+ notes: "SUM(memory)/COUNT_DISTINCT(pod) and SUM(limit)/COUNT_DISTINCT(pod)."
+ - title: "Cpu usage (total)"
+ fidelity: PERFECT
+ notes: "IRATE cpu plus limit=quota/period and request=shares/1024 as named series."
+ - title: "Memory usage (total)"
+ fidelity: PERFECT
+ notes: "LAST_OVER_TIME memory vs spec limit."
+
+summary:
+ total_panels: 13
+ perfect: 10
+ approximate: 3
+ gap: 0
+ notes: >
+ $namespace / $container populate from container_memory_usage_bytes with
+ canonical container/namespace labels (plugin rewrites container_name).
+ HTTP panels bind $container to app (dashboard convention: app == container
+ name) and $namespace after kubernetes_namespace → namespace. nginx/haproxy
+ series are live_optional. Detailed usage rows stay collapsed like Grafana;
+ Request rate, Error rate, latency, pod count, and usage-vs-request stay open.
diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/pack.yaml b/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/pack.yaml
new file mode 100644
index 00000000..bb6698c1
--- /dev/null
+++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/pack.yaml
@@ -0,0 +1,315 @@
+# 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 1471
+# https://grafana.com/grafana/dashboards/1471-kubernetes-apps/
+#
+# Source: community "Kubernetes App Metrics".
+# Revision 1. schemaVersion 14 (old `rows[]` layout), 13 leaf panels.
+# Variables: $namespace, $container (container_name; also used as HTTP `app`).
+# Metric family: cAdvisor container_* + container_spec_* AND app HTTP
+# (http_requests_total / nginx_http_* / haproxy_backend_http_*).
+#
+# Authored against a pre-1.16 cAdvisor + Heapster-era label lineage:
+# - container_name / pod_name -> modern container / pod
+# - kubernetes_io_hostname -> instance
+# - kubernetes_namespace (HTTP metrics) -> namespace
+# - app="$container" -> HTTP series keyed by app=container
+#
+# Native PROMQL LAST/GROK step is empty in Lens on prometheus_native ingest, so
+# every panel is curated ES|QL. nginx/haproxy are live_optional so a native-only
+# scrape still renders the remaining series.
+#
+# Engine vs pack split:
+# Engine: irate/rate, rows[] → sections, single-panel-row flatten, control
+# synthesis, histogram_quantile → PERCENTILE disclosure.
+# Pack: Heapster label bridge, control populate rewrite, HTTP named series
+# (native/nginx/haproxy, nginx restored), ratio ES|QL, per-pod
+# breakdown on pod not (id,pod_name), LAST_OVER_TIME gauges.
+
+query:
+ metrics_dataset_filter: "prometheus"
+
+ metric_kinds:
+ http_requests_total: counter
+ nginx_http_requests_total: counter
+ haproxy_backend_http_responses_total: counter
+ http_request_duration_seconds_bucket: counter
+ nginx_http_request_duration_seconds_bucket: counter
+ http_request_duration_seconds: gauge
+ nginx_http_request_duration_seconds: gauge
+ container_cpu_usage_seconds_total: counter
+ container_cpu_system_seconds_total: counter
+ container_cpu_user_seconds_total: counter
+ container_memory_usage_bytes: gauge
+ container_spec_cpu_shares: gauge
+ container_spec_cpu_quota: gauge
+ container_spec_cpu_period: gauge
+ container_spec_memory_limit_bytes: gauge
+
+ label_rewrites:
+ container_name: container
+ pod_name: pod
+ kubernetes_io_hostname: instance
+ kubernetes_namespace: namespace
+
+ source_label_names:
+ container: container_name
+ pod: pod_name
+ instance: kubernetes_io_hostname
+
+ live_optional_metrics:
+ - nginx_http_requests_total
+ - haproxy_backend_http_responses_total
+ - nginx_http_request_duration_seconds
+ - nginx_http_request_duration_seconds_bucket
+ - http_request_duration_seconds_bucket
+
+controls:
+ field_overrides:
+ namespace: namespace
+ container: container
+
+panel:
+ query_overrides:
+ - title_match: "Request rate"
+ kibana_type_override: line
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE (?container == "" OR ({{label:app}} RLIKE ?container OR ({{label:app}} IS NULL AND "" RLIKE ?container)))
+ | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace)))
+ | WHERE {{metric:http_requests_total:counter}} IS NOT NULL OR {{metric:nginx_http_requests_total:counter}} IS NOT NULL OR {{metric:haproxy_backend_http_responses_total:counter}} IS NOT NULL
+ | EVAL series = CASE(
+ {{metric:http_requests_total:counter}} IS NOT NULL AND ({{label:handler}} != "prometheus" OR {{label:handler}} IS NULL), CONCAT("native | ", COALESCE(TO_STRING({{label:code}}), "unknown")),
+ {{metric:nginx_http_requests_total:counter}} IS NOT NULL, CONCAT("nginx | ", COALESCE(TO_STRING({{label:status}}), "unknown")),
+ {{metric:haproxy_backend_http_responses_total:counter}} IS NOT NULL, CONCAT("haproxy | ", COALESCE(TO_STRING({{label:code}}), "unknown")),
+ NULL)
+ | WHERE series IS NOT NULL
+ | STATS native = SUM(IRATE({{metric:http_requests_total:counter}})), nginx = SUM(IRATE({{metric:nginx_http_requests_total:counter}})), haproxy = SUM(IRATE({{metric:haproxy_backend_http_responses_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), series
+ | EVAL rate = COALESCE(native, 0) + COALESCE(nginx, 0) + COALESCE(haproxy, 0)
+ | KEEP time_bucket, series, rate
+ | SORT time_bucket ASC
+ status_override: migrated
+ - title_match: "Error rate"
+ kibana_type_override: line
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE (?container == "" OR ({{label:app}} RLIKE ?container OR ({{label:app}} IS NULL AND "" RLIKE ?container)))
+ | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace)))
+ | WHERE {{metric:http_requests_total:counter}} IS NOT NULL OR {{metric:nginx_http_requests_total:counter}} IS NOT NULL OR {{metric:haproxy_backend_http_responses_total:counter}} IS NOT NULL
+ | STATS native_5xx = SUM(CASE(({{label:handler}} != "prometheus" OR {{label:handler}} IS NULL) AND {{label:code}} RLIKE "5[0-9]+", IRATE({{metric:http_requests_total:counter}}), NULL)), native_all = SUM(CASE(({{label:handler}} != "prometheus" OR {{label:handler}} IS NULL), IRATE({{metric:http_requests_total:counter}}), NULL)), nginx_5xx = SUM(CASE({{label:status}} RLIKE "5[0-9]+", IRATE({{metric:nginx_http_requests_total:counter}}), NULL)), nginx_all = SUM(IRATE({{metric:nginx_http_requests_total:counter}})), haproxy_5xx = SUM(CASE({{label:code}} == "5xx", IRATE({{metric:haproxy_backend_http_responses_total:counter}}), NULL)), haproxy_all = SUM(IRATE({{metric:haproxy_backend_http_responses_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend)
+ | EVAL Native = CASE(native_all > 0, native_5xx / native_all, NULL)
+ | EVAL Nginx = CASE(nginx_all > 0, nginx_5xx / nginx_all, NULL)
+ | EVAL HAProxy = CASE(haproxy_all > 0, haproxy_5xx / haproxy_all, NULL)
+ | KEEP time_bucket, Native, Nginx, HAProxy
+ | SORT time_bucket ASC
+ status_override: migrated
+ - title_match: "Response time percentiles"
+ kibana_type_override: line
+ approximation_note: "Grafana histogram_quantile on *_bucket series; Kibana plots PERCENTILE of the duration gauge (t-digest scrape samples, not Prometheus bucket interpolation)."
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE (?container == "" OR ({{label:app}} RLIKE ?container OR ({{label:app}} IS NULL AND "" RLIKE ?container)))
+ | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace)))
+ | WHERE {{metric:http_request_duration_seconds:gauge}} IS NOT NULL OR {{metric:nginx_http_request_duration_seconds:gauge}} IS NOT NULL OR {{metric:http_request_duration_seconds_bucket:counter}} IS NOT NULL
+ | STATS native_p99 = PERCENTILE({{metric:http_request_duration_seconds:gauge}}, 99), native_p90 = PERCENTILE({{metric:http_request_duration_seconds:gauge}}, 90), native_p50 = PERCENTILE({{metric:http_request_duration_seconds:gauge}}, 50), nginx_p99 = PERCENTILE({{metric:nginx_http_request_duration_seconds:gauge}}, 99), nginx_p90 = PERCENTILE({{metric:nginx_http_request_duration_seconds:gauge}}, 90), nginx_p50 = PERCENTILE({{metric:nginx_http_request_duration_seconds:gauge}}, 50) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend)
+ | KEEP time_bucket, native_p99, native_p90, native_p50, nginx_p99, nginx_p90, nginx_p50
+ | SORT time_bucket ASC
+ - title_match: "Number of pods"
+ kibana_type_override: line
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container)))
+ | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace)))
+ | WHERE {{metric:container_memory_usage_bytes:gauge}} IS NOT NULL AND {{label:container}} != "POD"
+ | STATS pods = COUNT_DISTINCT({{label:pod}}), hosts = COUNT_DISTINCT({{label:instance}}) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend)
+ | KEEP time_bucket, pods, hosts
+ | SORT time_bucket ASC
+ status_override: migrated
+ - title_match: "Cpu usage (relative to request)"
+ kibana_type_override: line
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container)))
+ | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace)))
+ | WHERE {{label:container}} != "POD"
+ | WHERE {{metric:container_cpu_system_seconds_total:counter}} IS NOT NULL OR {{metric:container_cpu_user_seconds_total:counter}} IS NOT NULL OR {{metric:container_spec_cpu_shares:gauge}} IS NOT NULL
+ | STATS system_cpu = SUM(IRATE({{metric:container_cpu_system_seconds_total:counter}})), user_cpu = SUM(IRATE({{metric:container_cpu_user_seconds_total:counter}})), shares = SUM(LAST_OVER_TIME({{metric:container_spec_cpu_shares:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend)
+ | EVAL system = CASE(shares > 0, system_cpu / (shares / 1024.0), NULL)
+ | EVAL user = CASE(shares > 0, user_cpu / (shares / 1024.0), NULL)
+ | KEEP time_bucket, system, user
+ | SORT time_bucket ASC
+ status_override: migrated
+ - title_match: "Cpu usage (relative to limit)"
+ kibana_type_override: line
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container)))
+ | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace)))
+ | WHERE {{label:container}} != "POD"
+ | WHERE {{metric:container_cpu_usage_seconds_total:counter}} IS NOT NULL OR {{metric:container_spec_cpu_quota:gauge}} IS NOT NULL
+ | STATS used = SUM(IRATE({{metric:container_cpu_usage_seconds_total:counter}})), quota = SUM(LAST_OVER_TIME({{metric:container_spec_cpu_quota:gauge}})), period = MAX(LAST_OVER_TIME({{metric:container_spec_cpu_period:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend)
+ | EVAL actual = CASE(quota > 0 AND period > 0, used / (quota / period), NULL)
+ | KEEP time_bucket, actual
+ | SORT time_bucket ASC
+ status_override: migrated
+ - title_match: "Memory usage (relative to limit)"
+ kibana_type_override: line
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container)))
+ | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace)))
+ | WHERE {{label:container}} != "POD"
+ | WHERE {{metric:container_memory_usage_bytes:gauge}} IS NOT NULL OR {{metric:container_spec_memory_limit_bytes:gauge}} IS NOT NULL
+ | STATS used = SUM(LAST_OVER_TIME({{metric:container_memory_usage_bytes:gauge}})), total = SUM(LAST_OVER_TIME({{metric:container_spec_memory_limit_bytes:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend)
+ | EVAL actual = CASE(total > 0, used / total, NULL)
+ | KEEP time_bucket, actual
+ | SORT time_bucket ASC
+ status_override: migrated
+ - title_match: "Cpu usage (per pod)"
+ kibana_type_override: line
+ approximation_note: "Dropped the Grafana limit/request reference lines (Lens XY has one breakdown). Per-pod CPU is grouped by pod rather than (id, pod_name)."
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container)))
+ | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace)))
+ | WHERE {{label:container}} != "POD" AND {{label:pod}} IS NOT NULL
+ | WHERE {{metric:container_cpu_usage_seconds_total:counter}} IS NOT NULL OR {{metric:container_spec_cpu_quota:gauge}} IS NOT NULL OR {{metric:container_spec_cpu_period:gauge}} IS NOT NULL OR {{metric:container_spec_cpu_shares:gauge}} IS NOT NULL OR {{metric:container_memory_usage_bytes:gauge}} IS NOT NULL
+ | STATS CPU = SUM(IRATE({{metric:container_cpu_usage_seconds_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:pod}}
+ | KEEP time_bucket, `{{label:pod}}`, CPU
+ | SORT time_bucket ASC
+ - title_match: "Memory usage (per pod)"
+ kibana_type_override: line
+ approximation_note: "Dropped the Grafana per-pod memory limit reference line (Lens XY has one breakdown). Grouped by pod rather than (id, pod_name)."
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container)))
+ | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace)))
+ | WHERE {{label:container}} != "POD" AND {{label:pod}} IS NOT NULL
+ | WHERE {{metric:container_memory_usage_bytes:gauge}} IS NOT NULL OR {{metric:container_spec_memory_limit_bytes:gauge}} IS NOT NULL
+ | STATS Memory = SUM(LAST_OVER_TIME({{metric:container_memory_usage_bytes:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:pod}}
+ | KEEP time_bucket, `{{label:pod}}`, Memory
+ | SORT time_bucket ASC
+ - title_match: "Cpu usage (avg per pod)"
+ kibana_type_override: line
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container)))
+ | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace)))
+ | WHERE {{label:container}} != "POD"
+ | WHERE {{metric:container_cpu_usage_seconds_total:counter}} IS NOT NULL OR {{metric:container_spec_cpu_shares:gauge}} IS NOT NULL OR {{metric:container_spec_cpu_quota:gauge}} IS NOT NULL OR {{metric:container_memory_usage_bytes:gauge}} IS NOT NULL
+ | STATS cpu = SUM(IRATE({{metric:container_cpu_usage_seconds_total:counter}})), quota = SUM(LAST_OVER_TIME({{metric:container_spec_cpu_quota:gauge}})), period = MAX(LAST_OVER_TIME({{metric:container_spec_cpu_period:gauge}})), shares = SUM(LAST_OVER_TIME({{metric:container_spec_cpu_shares:gauge}})), pods = COUNT_DISTINCT({{label:pod}}) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend)
+ | EVAL actual = CASE(pods > 0, cpu / pods, NULL)
+ | EVAL `limit` = CASE(pods > 0 AND period > 0, (quota / period) / pods, NULL)
+ | EVAL request = CASE(pods > 0, (shares / 1024.0) / pods, NULL)
+ | KEEP time_bucket, actual, `limit`, request
+ | SORT time_bucket ASC
+ status_override: migrated
+ - title_match: "Memory usage (avg per pod)"
+ kibana_type_override: line
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container)))
+ | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace)))
+ | WHERE {{label:container}} != "POD"
+ | WHERE {{metric:container_memory_usage_bytes:gauge}} IS NOT NULL OR {{metric:container_spec_memory_limit_bytes:gauge}} IS NOT NULL
+ | STATS used = SUM(LAST_OVER_TIME({{metric:container_memory_usage_bytes:gauge}})), total = SUM(LAST_OVER_TIME({{metric:container_spec_memory_limit_bytes:gauge}})), pods = COUNT_DISTINCT({{label:pod}}) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend)
+ | EVAL actual = CASE(pods > 0, used / pods, NULL)
+ | EVAL `limit` = CASE(pods > 0, total / pods, NULL)
+ | KEEP time_bucket, actual, `limit`
+ | SORT time_bucket ASC
+ status_override: migrated
+ - title_match: "Cpu usage (total)"
+ kibana_type_override: line
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container)))
+ | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace)))
+ | WHERE {{label:container}} != "POD"
+ | WHERE {{metric:container_cpu_usage_seconds_total:counter}} IS NOT NULL OR {{metric:container_spec_cpu_shares:gauge}} IS NOT NULL OR {{metric:container_spec_cpu_quota:gauge}} IS NOT NULL
+ | STATS actual = SUM(IRATE({{metric:container_cpu_usage_seconds_total:counter}})), quota = SUM(LAST_OVER_TIME({{metric:container_spec_cpu_quota:gauge}})), period = MAX(LAST_OVER_TIME({{metric:container_spec_cpu_period:gauge}})), shares = SUM(LAST_OVER_TIME({{metric:container_spec_cpu_shares:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend)
+ | EVAL `limit` = CASE(period > 0, quota / period, NULL)
+ | EVAL request = shares / 1024.0
+ | KEEP time_bucket, actual, `limit`, request
+ | SORT time_bucket ASC
+ status_override: migrated
+ - title_match: "Memory usage (total)"
+ kibana_type_override: line
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container)))
+ | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace)))
+ | WHERE {{label:container}} != "POD"
+ | WHERE {{metric:container_memory_usage_bytes:gauge}} IS NOT NULL OR {{metric:container_spec_memory_limit_bytes:gauge}} IS NOT NULL
+ | STATS actual = SUM(LAST_OVER_TIME({{metric:container_memory_usage_bytes:gauge}})), `limit` = SUM(LAST_OVER_TIME({{metric:container_spec_memory_limit_bytes:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend)
+ | KEEP time_bucket, actual, `limit`
+ | SORT time_bucket ASC
+ status_override: migrated
+
+ layout_overrides:
+ - title_match: "Request rate"
+ hide_title: false
+ legend_position: right
+ size: {w: 24, h: 16}
+ - title_match: "Error rate"
+ hide_title: false
+ legend_position: right
+ size: {w: 24, h: 16}
+ - title_match: "Response time percentiles"
+ hide_title: false
+ legend_position: right
+ size: {w: 48, h: 16}
+ - title_match: "Number of pods"
+ hide_title: false
+ size: {w: 48, h: 12}
+ - title_match: "Cpu usage (relative to request)"
+ hide_title: false
+ legend_position: right
+ size: {w: 48, h: 14}
+ - title_match: "Usage relative to limit"
+ collapsed: true
+ - title_match: "Cpu usage (relative to limit)"
+ hide_title: false
+ size: {w: 24, h: 14}
+ - title_match: "Memory usage (relative to limit)"
+ hide_title: false
+ size: {w: 24, h: 14}
+ - title_match: "Usage per pod"
+ collapsed: true
+ - title_match: "Cpu usage (per pod)"
+ hide_title: false
+ legend_position: right
+ size: {w: 24, h: 14}
+ - title_match: "Memory usage (per pod)"
+ hide_title: false
+ legend_position: right
+ size: {w: 24, h: 14}
+ - title_match: "Usage per pod (average)"
+ collapsed: true
+ - title_match: "Cpu usage (avg per pod)"
+ hide_title: false
+ size: {w: 24, h: 14}
+ - title_match: "Memory usage (avg per pod)"
+ hide_title: false
+ size: {w: 24, h: 14}
+ - title_match: "Usage total"
+ collapsed: true
+ - title_match: "Cpu usage (total)"
+ hide_title: false
+ size: {w: 24, h: 14}
+ - title_match: "Memory usage (total)"
+ hide_title: false
+ size: {w: 24, h: 14}
diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/plugin.py b/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/plugin.py
new file mode 100644
index 00000000..7e440666
--- /dev/null
+++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/plugin.py
@@ -0,0 +1,48 @@
+# 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 1471 (Kubernetes App Metrics) curated pack plugin.
+
+The dashboard's ``$namespace`` / ``$container`` populate queries still use the
+pre-1.16 cAdvisor label ``container_name``. Rewrite them onto the canonical
+``container`` / ``namespace`` labels so the Kibana controls fill from a modern
+scrape. Panel PromQL already goes through ``label_rewrites``; only the
+templating queries need this hook.
+"""
+
+
+_PACK_NAME = "grafana_1471_kubernetes_app_metrics"
+
+
+def register(api):
+ @api["variable_translators"].register("grafana_1471_k8s_controls", priority=5)
+ def rewrite_namespace_and_container_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 "")
+ lname = name.lower()
+ query_text = context.query_text or str(variable.get("query") or "")
+ compact = query_text.replace(" ", "").lower()
+
+ if lname == "namespace" and "label_values(" in compact:
+ rewritten = (
+ 'label_values(container_memory_usage_bytes{container!="POD"}, namespace)'
+ )
+ context.query_text = rewritten
+ context.variable = dict(variable)
+ context.variable["query"] = rewritten
+ return None
+
+ if lname == "container" and "label_values(" in compact:
+ rewritten = (
+ 'label_values(container_memory_usage_bytes'
+ '{namespace=~"$namespace",container!="POD"}, container)'
+ )
+ context.query_text = rewritten
+ context.variable = dict(variable)
+ context.variable["query"] = rewritten
+ return None
+
+ return None
diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_3831_kubernetes_autoscaler/fidelity_manifest.yaml b/observability_migration/adapters/source/grafana/curated_packs/grafana_3831_kubernetes_autoscaler/fidelity_manifest.yaml
new file mode 100644
index 00000000..11c6e874
--- /dev/null
+++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_3831_kubernetes_autoscaler/fidelity_manifest.yaml
@@ -0,0 +1,60 @@
+# 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 3831 (Kubernetes Cluster Autoscaler)
+# https://grafana.com/grafana/dashboards/3831-autoscaler/
+#
+# PERFECT = same information as Grafana on a cluster-autoscaler /metrics scrape.
+# APPROXIMATE = documented Kibana-better title polish; query semantics match.
+
+# Field-profile portable: pack.yaml authors canonical names; the resolver
+# namespaces per --field-profile. Verified by the cross-profile leakage gate.
+
+schema_version: 1
+gnet_id: 3831
+gnet_revision: 1
+dashboard_title: "Kubernetes Cluster Autoscaler (via Prometheus)"
+maintainer: "community"
+
+panels:
+ - title: "Total nodes"
+ fidelity: PERFECT
+ notes: "sum(cluster_autoscaler_nodes_count) as a metric tile. Curated ES|QL LAST_OVER_TIME (native PROMQL LAST step is empty in Lens)."
+ - title: "Nodes available"
+ fidelity: PERFECT
+ notes: "ready/total * 100 as a percent gauge (Grafana percent 0-100). Kibana number+% tiles need percent-points, not a 0-1 ratio."
+ - title: "Is cluster safe to scale?"
+ fidelity: APPROXIMATE
+ notes: "0/1 gauge (Grafana value-mapped Yes/No). Layout title Safe to autoscale so the tile fits Kibana chrome. Kibana metric/gauge has no Yes/No value map."
+ - title: "Number of unscheduled pods"
+ fidelity: APPROXIMATE
+ notes: "Same unschedulable_pods_count sum; layout title Unscheduled pods."
+ - title: "Last scaleDown activity"
+ fidelity: PERFECT
+ notes: "time()-last_activity{activity=scaleDown} as DATE_DIFF seconds, duration-formatted. Layout title Since scale-down."
+ - title: "Last autoscale activity"
+ fidelity: PERFECT
+ notes: "Same DATE_DIFF for activity=autoscaling. Layout title Since autoscale."
+ - title: "Pod activity"
+ fidelity: PERFECT
+ notes: "Cumulative evicted pods (counter without rate, matching Grafana) plus unschedulable gauge, named series."
+ - title: "Node activity"
+ fidelity: PERFECT
+ notes: "ready / unready / notStarted node counts as named series."
+ - title: "Autoscaling activity"
+ fidelity: PERFECT
+ notes: "scaled_up_nodes_total (cumulative), unneeded_nodes_count, total nodes — Grafana's mixed cumulative+gauge graph, named series."
+ - title: "Cluster direction?"
+ fidelity: APPROXIMATE
+ notes: "scaled_up_total - scaled_down_total. Layout title Net scaled nodes."
+
+summary:
+ total_panels: 10
+ perfect: 7
+ approximate: 3
+ gap: 0
+ notes: >
+ No dashboard variables. All ten panels are curated ES|QL so they render on
+ prometheus_native ingest (Lens does not paint native PROMQL LAST/GROK step).
+ KPI titles are shortened for Kibana tiles; Safe to autoscale stays 0/1
+ because Kibana has no Grafana value map.
diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_3831_kubernetes_autoscaler/pack.yaml b/observability_migration/adapters/source/grafana/curated_packs/grafana_3831_kubernetes_autoscaler/pack.yaml
new file mode 100644
index 00000000..992d923d
--- /dev/null
+++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_3831_kubernetes_autoscaler/pack.yaml
@@ -0,0 +1,185 @@
+# 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 3831
+# https://grafana.com/grafana/dashboards/3831-autoscaler/
+#
+# Source: community "Kubernetes Cluster Autoscaler (via Prometheus)".
+# Revision 1. schemaVersion 14 (old `rows[]` layout), 10 leaf panels, no
+# template variables. Metric family: cluster-autoscaler /metrics.
+#
+# Grafana plots several *_total counters with bare sum() (no rate()), so the
+# graphs are cumulative totals — match that with LAST_OVER_TIME, not RATE.
+# Native PROMQL LAST(value, step) is empty in Lens XY/metric on prometheus_native
+# ingest (same Lens gap as 8171 Network), so every panel is curated ES|QL.
+#
+# Engine vs pack split:
+# Engine: rows[] → sections, singlestat reducers, "New row" flatten.
+# Pack: metric_kinds, ES|QL KPIs (incl. time()-last_activity → DATE_DIFF),
+# named Activity/Autoscaling series, Kibana-native KPI titles.
+
+query:
+ metrics_dataset_filter: "prometheus"
+
+ metric_kinds:
+ cluster_autoscaler_nodes_count: gauge
+ cluster_autoscaler_cluster_safe_to_autoscale: gauge
+ cluster_autoscaler_unschedulable_pods_count: gauge
+ cluster_autoscaler_last_activity: gauge
+ cluster_autoscaler_unneeded_nodes_count: gauge
+ cluster_autoscaler_evicted_pods_total: counter
+ cluster_autoscaler_scaled_up_nodes_total: counter
+ cluster_autoscaler_scaled_down_nodes_total: counter
+
+panel:
+ query_overrides:
+ - title_match: "Total nodes"
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE {{metric:cluster_autoscaler_nodes_count:gauge}} IS NOT NULL
+ | STATS computed_value = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_nodes_count:gauge}}))
+ | KEEP computed_value
+ status_override: migrated
+ - title_match: "Nodes available"
+ kibana_type_override: gauge
+ # No primary_format: the query pre-scales ready/total to 0-100 and the
+ # source Grafana `percent` unit already renders number+"%" (percentunit
+ # would imply a 0-1 ratio and double-scale if ever applied to a primary).
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE {{metric:cluster_autoscaler_nodes_count:gauge}} IS NOT NULL
+ | STATS ready = SUM(LAST_OVER_TIME(CASE({{label:state}} == "ready", {{metric:cluster_autoscaler_nodes_count:gauge}}, NULL))), total = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_nodes_count:gauge}}))
+ | EVAL computed_value = CASE(total > 0, ready / total * 100, NULL)
+ | WHERE computed_value IS NOT NULL
+ | KEEP computed_value
+ status_override: migrated
+ - title_match: "Is cluster safe to scale?"
+ kibana_type_override: gauge
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE {{metric:cluster_autoscaler_cluster_safe_to_autoscale:gauge}} IS NOT NULL
+ | STATS computed_value = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_cluster_safe_to_autoscale:gauge}}))
+ | KEEP computed_value
+ status_override: migrated
+ - title_match: "Number of unscheduled pods"
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE {{metric:cluster_autoscaler_unschedulable_pods_count:gauge}} IS NOT NULL
+ | STATS computed_value = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_unschedulable_pods_count:gauge}}))
+ | KEEP computed_value
+ status_override: migrated
+ - title_match: "Last scaleDown activity"
+ primary_format: s
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE {{label:activity}} == "scaleDown"
+ | WHERE {{metric:cluster_autoscaler_last_activity:gauge}} IS NOT NULL
+ | STATS last_ts = MAX(LAST_OVER_TIME({{metric:cluster_autoscaler_last_activity:gauge}}))
+ | EVAL computed_value = DATE_DIFF("seconds", TO_DATETIME(last_ts * 1000), NOW())
+ | KEEP computed_value
+ status_override: migrated
+ - title_match: "Last autoscale activity"
+ primary_format: s
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE {{label:activity}} == "autoscaling"
+ | WHERE {{metric:cluster_autoscaler_last_activity:gauge}} IS NOT NULL
+ | STATS last_ts = MAX(LAST_OVER_TIME({{metric:cluster_autoscaler_last_activity:gauge}}))
+ | EVAL computed_value = DATE_DIFF("seconds", TO_DATETIME(last_ts * 1000), NOW())
+ | KEEP computed_value
+ status_override: migrated
+ - title_match: "Pod activity"
+ kibana_type_override: line
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE {{metric:cluster_autoscaler_evicted_pods_total:counter}} IS NOT NULL OR {{metric:cluster_autoscaler_unschedulable_pods_count:gauge}} IS NOT NULL
+ | STATS `evicted pods` = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_evicted_pods_total:counter}})), `unscheduled pods` = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_unschedulable_pods_count:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend)
+ | KEEP time_bucket, `evicted pods`, `unscheduled pods`
+ | SORT time_bucket ASC
+ status_override: migrated
+ - title_match: "Node activity"
+ kibana_type_override: line
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE {{metric:cluster_autoscaler_nodes_count:gauge}} IS NOT NULL
+ | STATS ready = SUM(LAST_OVER_TIME(CASE({{label:state}} == "ready", {{metric:cluster_autoscaler_nodes_count:gauge}}, NULL))), unready = SUM(LAST_OVER_TIME(CASE({{label:state}} == "unready", {{metric:cluster_autoscaler_nodes_count:gauge}}, NULL))), `not started` = SUM(LAST_OVER_TIME(CASE({{label:state}} == "notStarted", {{metric:cluster_autoscaler_nodes_count:gauge}}, NULL))) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend)
+ | KEEP time_bucket, ready, unready, `not started`
+ | SORT time_bucket ASC
+ status_override: migrated
+ - title_match: "Autoscaling activity"
+ kibana_type_override: line
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE {{metric:cluster_autoscaler_scaled_up_nodes_total:counter}} IS NOT NULL OR {{metric:cluster_autoscaler_unneeded_nodes_count:gauge}} IS NOT NULL OR {{metric:cluster_autoscaler_nodes_count:gauge}} IS NOT NULL
+ | STATS `scaled up total` = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_scaled_up_nodes_total:counter}})), `unneeded nodes` = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_unneeded_nodes_count:gauge}})), `total nodes` = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_nodes_count:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend)
+ | KEEP time_bucket, `scaled up total`, `unneeded nodes`, `total nodes`
+ | SORT time_bucket ASC
+ status_override: migrated
+ - title_match: "Cluster direction?"
+ esql_query: |
+ TS metrics-*
+ | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
+ | WHERE {{metric:cluster_autoscaler_scaled_up_nodes_total:counter}} IS NOT NULL OR {{metric:cluster_autoscaler_scaled_down_nodes_total:counter}} IS NOT NULL
+ | STATS scaled_up = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_scaled_up_nodes_total:counter}})), scaled_down = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_scaled_down_nodes_total:counter}}))
+ | EVAL computed_value = scaled_up - scaled_down
+ | KEEP computed_value
+ status_override: migrated
+
+ layout_overrides:
+ - title_match: "Total nodes"
+ hide_title: false
+ size: {w: 8, h: 10}
+ position: {x: 0, y: 0}
+ - title_match: "Nodes available"
+ hide_title: false
+ size: {w: 8, h: 10}
+ position: {x: 8, y: 0}
+ - title_match: "Is cluster safe to scale?"
+ title: "Safe to autoscale"
+ hide_title: false
+ size: {w: 8, h: 10}
+ position: {x: 16, y: 0}
+ - title_match: "Number of unscheduled pods"
+ title: "Unscheduled pods"
+ hide_title: false
+ size: {w: 8, h: 10}
+ position: {x: 24, y: 0}
+ - title_match: "Last scaleDown activity"
+ title: "Since scale-down"
+ hide_title: false
+ size: {w: 8, h: 10}
+ position: {x: 32, y: 0}
+ - title_match: "Last autoscale activity"
+ title: "Since autoscale"
+ hide_title: false
+ size: {w: 8, h: 10}
+ position: {x: 40, y: 0}
+ - title_match: "Pod activity"
+ hide_title: false
+ size: {w: 24, h: 14}
+ position: {x: 0, y: 0}
+ - title_match: "Node activity"
+ hide_title: false
+ size: {w: 24, h: 14}
+ position: {x: 24, y: 0}
+ # Flattened off Grafana's placeholder "New row". Top-level items share a
+ # y-order with sections (Info=0, Activity=1), so these must sit at y=2
+ # or they overlap the Info KPIs.
+ - title_match: "Autoscaling activity"
+ hide_title: false
+ size: {w: 36, h: 14}
+ position: {x: 0, y: 2}
+ - title_match: "Cluster direction?"
+ title: "Net scaled nodes"
+ hide_title: false
+ size: {w: 12, h: 14}
+ position: {x: 36, y: 2}
diff --git a/observability_migration/adapters/source/grafana/curated_packs/registry.yaml b/observability_migration/adapters/source/grafana/curated_packs/registry.yaml
index f45f5fee..67d1ae6c 100644
--- a/observability_migration/adapters/source/grafana/curated_packs/registry.yaml
+++ b/observability_migration/adapters/source/grafana/curated_packs/registry.yaml
@@ -163,3 +163,21 @@ packs:
gnet_revision: 1
dashboard_sha256: "5d580022bf35bc2cbe42056affd0a1670aa2adb0afd6942f2e642c550a6b29b2"
description: "Kubernetes Nodes (node_exporter 0.16+) — CPU Busy rename, nfsd→node_disk I/O map, instance-pinned $server"
+
+ - gnet_id: 3831
+ name: grafana_3831_kubernetes_autoscaler
+ title_hint: "Kubernetes Cluster Autoscaler (via Prometheus)"
+ tags_hint: ["prometheus"]
+ path: grafana_3831_kubernetes_autoscaler
+ gnet_revision: 1
+ dashboard_sha256: "fbfc5ff33d138f2a449d4d28b0c0dcd09d351f08a2c78b91e3455d00e2b0b597"
+ description: "Kubernetes Cluster Autoscaler — ES|QL KPIs (time()-last_activity), named activity series, Kibana tile titles"
+
+ - gnet_id: 1471
+ name: grafana_1471_kubernetes_app_metrics
+ title_hint: "Kubernetes App Metrics"
+ tags_hint: []
+ path: grafana_1471_kubernetes_app_metrics
+ gnet_revision: 1
+ dashboard_sha256: "27552f4c9ba5ce3e43ae7c962c98c6979e87e1e4c2f4d463f26df62652cabc52"
+ description: "Kubernetes App Metrics — Heapster label remap, $namespace/$container populate, HTTP native/nginx/haproxy series, cAdvisor ratios"
diff --git a/observability_migration/adapters/source/grafana/panels.py b/observability_migration/adapters/source/grafana/panels.py
index 8c281d73..68882f00 100644
--- a/observability_migration/adapters/source/grafana/panels.py
+++ b/observability_migration/adapters/source/grafana/panels.py
@@ -11219,15 +11219,16 @@ def _clear_duplicate_inner_title_label(panel: dict) -> None:
primary = esql.get("primary")
if isinstance(primary, dict):
label = str(primary.get("label") or "").strip()
- if label.casefold() in {title.casefold(), "value", "computed_value"}:
- # Blank, not omitted: Lens falls back to the field name
- # (``value`` / ``computed_value``) when the label key is missing.
+ # Blank, not omitted: Lens falls back to the field name
+ # (``value`` / ``computed_value``) when the label key is missing.
+ # Flattened metric tiles often arrive with no label at all.
+ if not label or label.casefold() in {title.casefold(), "value", "computed_value"}:
primary["label"] = " "
elif chart_type == "gauge":
metric = esql.get("metric")
if isinstance(metric, dict):
label = str(metric.get("label") or "").strip()
- if label.casefold() in {title.casefold(), "value", "computed_value"}:
+ if not label or label.casefold() in {title.casefold(), "value", "computed_value"}:
metric["label"] = " "
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 73081078..f54e8f55 100644
--- a/parity-rig/curated/grafana_763_redis_exporter/docker-compose.yml
+++ b/parity-rig/curated/grafana_763_redis_exporter/docker-compose.yml
@@ -384,8 +384,9 @@ services:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=1d
- # Synthetic Kubernetes exporter (cAdvisor + kube-state-metrics + node_exporter)
- # so the Kubernetes curated packs (Grafana 315 + 6417) validate on real data.
+ # 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.
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 20dd5bbd..08061c1c 100644
--- a/parity-rig/curated/grafana_763_redis_exporter/k8s_exporter.py
+++ b/parity-rig/curated/grafana_763_redis_exporter/k8s_exporter.py
@@ -15,6 +15,10 @@
# including kube_deployment_status_replicas_available. Pod names are
# `{deployment}-{ordinal}` so Grafana's `pod=~"^$Deployment.*$"` prefix
# matches.
+# - Grafana 3831 (Cluster Autoscaler): cluster_autoscaler_* gauges/counters.
+# - Grafana 1471 (App Metrics): container_memory_usage_bytes, container_cpu_user/
+# system, container_spec_*, plus http_requests_total / nginx / haproxy with
+# app=container name so $container binds both cAdvisor and HTTP panels.
#
# Counters are wall-clock monotonic so RATE()/DELTA() render on the rig. The
# `OutOfDisk` node condition is deliberately NOT emitted (removed in k8s 1.12) so
@@ -217,6 +221,109 @@ def render() -> str:
L.append(f'kube_job_status_active{{namespace="{ns}",job_name="backup"}} 1')
L.append(f'kube_job_status_failed{{namespace="{ns}",job_name="backup"}} 0')
+ # ---- cAdvisor extras for Grafana 1471 (usage + spec + user/system) --
+ L.append("# HELP container_memory_usage_bytes Current memory usage")
+ L.append("# TYPE container_memory_usage_bytes gauge")
+ L.append("# HELP container_cpu_user_seconds_total Cumulative user cpu time")
+ L.append("# TYPE container_cpu_user_seconds_total counter")
+ L.append("# HELP container_cpu_system_seconds_total Cumulative system cpu time")
+ L.append("# TYPE container_cpu_system_seconds_total counter")
+ L.append("# HELP container_spec_cpu_shares CPU share (relative weight)")
+ L.append("# TYPE container_spec_cpu_shares gauge")
+ L.append("# HELP container_spec_cpu_quota CPU CFS quota period")
+ L.append("# TYPE container_spec_cpu_quota gauge")
+ L.append("# HELP container_spec_cpu_period CPU CFS period")
+ L.append("# TYPE container_spec_cpu_period gauge")
+ L.append("# HELP container_spec_memory_limit_bytes Memory limit in bytes")
+ L.append("# TYPE container_spec_memory_limit_bytes gauge")
+ idx = 0
+ for ns, pods in WORKLOADS.items():
+ for pod, container in pods:
+ n = _node_for(idx)
+ idx += 1
+ cgroup_id = f"/kubepods/{pod}/{container}"
+ base = (
+ f'id="{cgroup_id}",namespace="{ns}",pod="{pod}",container="{container}",'
+ f'image="registry/{container}:latest",name="k8s_{container}_{pod}",instance="{n}"'
+ )
+ cpu = elapsed * (0.05 + 0.02 * (idx % 5))
+ mem = int((128 + 40 * (idx % 6)) * 1024**2)
+ L.append(f"container_memory_usage_bytes{{{base}}} {mem}")
+ L.append(f"container_cpu_user_seconds_total{{{base}}} {cpu * 0.7:.4f}")
+ L.append(f"container_cpu_system_seconds_total{{{base}}} {cpu * 0.3:.4f}")
+ L.append(f"container_spec_cpu_shares{{{base}}} 256")
+ L.append(f"container_spec_cpu_quota{{{base}}} 50000")
+ L.append(f"container_spec_cpu_period{{{base}}} 100000")
+ L.append(f"container_spec_memory_limit_bytes{{{base}}} {512 * 1024**2}")
+
+ # ---- App HTTP metrics for Grafana 1471 (app == container name) ------
+ L.append("# HELP http_requests_total Total HTTP requests (native app)")
+ L.append("# TYPE http_requests_total counter")
+ L.append("# HELP nginx_http_requests_total Total nginx HTTP requests")
+ L.append("# TYPE nginx_http_requests_total counter")
+ L.append("# HELP haproxy_backend_http_responses_total HAProxy backend responses")
+ L.append("# TYPE haproxy_backend_http_responses_total counter")
+ L.append("# HELP http_request_duration_seconds Native request duration sample")
+ L.append("# TYPE http_request_duration_seconds gauge")
+ L.append("# HELP nginx_http_request_duration_seconds Nginx request duration sample")
+ L.append("# TYPE nginx_http_request_duration_seconds gauge")
+ L.append("# HELP http_request_duration_seconds_bucket Native duration histogram")
+ L.append("# TYPE http_request_duration_seconds_bucket counter")
+ L.append("# HELP nginx_http_request_duration_seconds_bucket Nginx duration histogram")
+ L.append("# TYPE nginx_http_request_duration_seconds_bucket counter")
+ idx = 0
+ for ns, pods in WORKLOADS.items():
+ for pod, container in pods:
+ idx += 1
+ app = f'app="{container}",namespace="{ns}",kubernetes_namespace="{ns}",pod="{pod}"'
+ reqs = elapsed * (20 + 5 * (idx % 4))
+ L.append(f'http_requests_total{{{app},handler="app",code="200"}} {reqs:.0f}')
+ L.append(f'http_requests_total{{{app},handler="app",code="500"}} {reqs * 0.02:.0f}')
+ L.append(f'http_requests_total{{{app},handler="prometheus",code="200"}} {elapsed:.0f}')
+ L.append(f'nginx_http_requests_total{{{app},status="200"}} {reqs * 0.8:.0f}')
+ L.append(f'nginx_http_requests_total{{{app},status="500"}} {reqs * 0.01:.0f}')
+ L.append(f'haproxy_backend_http_responses_total{{{app},code="2xx"}} {reqs * 0.9:.0f}')
+ L.append(f'haproxy_backend_http_responses_total{{{app},code="5xx"}} {reqs * 0.015:.0f}')
+ latency = 0.04 + 0.01 * (idx % 5)
+ L.append(f"http_request_duration_seconds{{{app}}} {latency:.4f}")
+ L.append(f"nginx_http_request_duration_seconds{{{app}}} {latency * 0.8:.4f}")
+ for le, frac in (("0.05", 0.4), ("0.1", 0.8), ("+Inf", 1.0)):
+ L.append(
+ f'http_request_duration_seconds_bucket{{{app},le="{le}"}} {reqs * frac:.0f}'
+ )
+ L.append(
+ f'nginx_http_request_duration_seconds_bucket{{{app},le="{le}"}} {reqs * 0.8 * frac:.0f}'
+ )
+
+ # ---- Cluster autoscaler (Grafana 3831) ------------------------------
+ L.append("# HELP cluster_autoscaler_nodes_count Number of nodes in the cluster")
+ L.append("# TYPE cluster_autoscaler_nodes_count gauge")
+ L.append("# HELP cluster_autoscaler_cluster_safe_to_autoscale Whether CA considers the cluster safe")
+ L.append("# TYPE cluster_autoscaler_cluster_safe_to_autoscale gauge")
+ L.append("# HELP cluster_autoscaler_unschedulable_pods_count Unschedulable pods")
+ L.append("# TYPE cluster_autoscaler_unschedulable_pods_count gauge")
+ L.append("# HELP cluster_autoscaler_last_activity Unix time of last CA activity")
+ L.append("# TYPE cluster_autoscaler_last_activity gauge")
+ L.append("# HELP cluster_autoscaler_unneeded_nodes_count Nodes marked unneeded")
+ L.append("# TYPE cluster_autoscaler_unneeded_nodes_count gauge")
+ L.append("# HELP cluster_autoscaler_evicted_pods_total Pods evicted by CA")
+ L.append("# TYPE cluster_autoscaler_evicted_pods_total counter")
+ L.append("# HELP cluster_autoscaler_scaled_up_nodes_total Nodes added by CA")
+ L.append("# TYPE cluster_autoscaler_scaled_up_nodes_total counter")
+ L.append("# HELP cluster_autoscaler_scaled_down_nodes_total Nodes removed by CA")
+ L.append("# TYPE cluster_autoscaler_scaled_down_nodes_total counter")
+ L.append(f'cluster_autoscaler_nodes_count{{state="ready"}} {len(NODES)}')
+ L.append('cluster_autoscaler_nodes_count{state="unready"} 0')
+ L.append('cluster_autoscaler_nodes_count{state="notStarted"} 0')
+ L.append("cluster_autoscaler_cluster_safe_to_autoscale 1")
+ L.append("cluster_autoscaler_unschedulable_pods_count 1")
+ L.append(f"cluster_autoscaler_last_activity{{activity=\"scaleDown\"}} {now - 180:.0f}")
+ L.append(f"cluster_autoscaler_last_activity{{activity=\"autoscaling\"}} {now - 45:.0f}")
+ L.append("cluster_autoscaler_unneeded_nodes_count 0")
+ L.append(f"cluster_autoscaler_evicted_pods_total {int(elapsed / 30)}")
+ L.append(f"cluster_autoscaler_scaled_up_nodes_total {5 + int(elapsed / 120)}")
+ L.append("cluster_autoscaler_scaled_down_nodes_total 2")
+
# ---- node_exporter: filesystem (modern *_bytes names) ---------------
L.append("# HELP node_filesystem_size_bytes Filesystem size in bytes")
L.append("# TYPE node_filesystem_size_bytes gauge")
diff --git a/tests/test_curated_packs.py b/tests/test_curated_packs.py
index dc9f64fe..56fd4b4d 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}
+ new_pack_ids = {7362, 9628, 14114, 12485, 315, 6417, 741, 8171, 3831, 1471}
mismatches = []
for entry in load_curated_registry():
gnet_id = int(entry["gnet_id"])
@@ -5351,3 +5351,532 @@ def test_8171_layout_renames_idle_cpu_to_cpu_busy():
]
_apply_panel_layout_overrides_recursively(panels, resolved.panel_layout_overrides)
assert panels[0]["title"] == "CPU Busy"
+
+
+# ---------------------------------------------------------------------------
+# Grafana 3831 — Kubernetes Cluster Autoscaler
+# ---------------------------------------------------------------------------
+
+
+def _resolve_3831():
+ dashboard = {
+ "gnetId": 3831,
+ "title": "Kubernetes Cluster Autoscaler (via Prometheus)",
+ "tags": ["prometheus"],
+ }
+ resolved = resolve_pack_for_dashboard(dashboard, RulePackConfig())
+ return resolved, SchemaResolver(resolved)
+
+
+def _translate_3831(panel, *, section_title=""):
+ resolved, resolver = _resolve_3831()
+ yaml_panel, result = translate_panel(
+ panel,
+ datasource_index="metrics-*",
+ esql_index="metrics-*",
+ rule_pack=resolved,
+ resolver=resolver,
+ section_title=section_title,
+ )
+ query = (yaml_panel.get("esql") or {}).get("query") or ""
+ return result, query, yaml_panel
+
+
+def test_3831_registry_entry_present():
+ entry = find_curated_pack(gnet_id=3831, title="", tags=[])
+ assert entry is not None
+ assert entry["name"] == "grafana_3831_kubernetes_autoscaler"
+ assert entry["gnet_revision"] == 1
+
+
+def test_3831_finds_by_title_fallback():
+ entry = find_curated_pack(
+ gnet_id=None,
+ title="Kubernetes Cluster Autoscaler (via Prometheus)",
+ tags=[],
+ )
+ assert entry is not None
+ assert entry["gnet_id"] == 3831
+
+
+def test_3831_classifies_autoscaler_metric_kinds():
+ resolved, _ = _resolve_3831()
+ assert resolved.metric_kinds["cluster_autoscaler_nodes_count"] == "gauge"
+ assert resolved.metric_kinds["cluster_autoscaler_last_activity"] == "gauge"
+ assert resolved.metric_kinds["cluster_autoscaler_evicted_pods_total"] == "counter"
+ assert resolved.metric_kinds["cluster_autoscaler_scaled_up_nodes_total"] == "counter"
+ assert resolved.metric_kinds["cluster_autoscaler_scaled_down_nodes_total"] == "counter"
+
+
+def test_3831_nodes_available_is_ready_over_total_ratio():
+ panel = {
+ "id": 6,
+ "type": "singlestat",
+ "title": "Nodes available",
+ "format": "percent",
+ "targets": [
+ {
+ "expr": (
+ 'sum(cluster_autoscaler_nodes_count{state="ready"})'
+ "/sum(cluster_autoscaler_nodes_count)*100"
+ ),
+ "refId": "A",
+ }
+ ],
+ "gridPos": {"x": 0, "y": 0, "w": 4, "h": 4},
+ }
+ result, query, yaml_panel = _translate_3831(panel, section_title="Info")
+ assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons
+ assert "cluster_autoscaler_nodes_count" in query
+ assert 'state == "ready"' in query or 'state` == "ready"' in query
+ assert "computed_value" in query
+ assert "* 100" in query
+ assert yaml_panel.get("esql", {}).get("type") == "gauge"
+
+
+def test_3831_last_scaledown_uses_date_diff_not_promql_time():
+ panel = {
+ "id": 7,
+ "type": "singlestat",
+ "title": "Last scaleDown activity",
+ "format": "s",
+ "targets": [
+ {
+ "expr": 'sum(time()-cluster_autoscaler_last_activity{activity="scaleDown"})',
+ "refId": "A",
+ }
+ ],
+ "gridPos": {"x": 0, "y": 0, "w": 4, "h": 4},
+ }
+ result, query, _ = _translate_3831(panel, section_title="Info")
+ assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons
+ assert "DATE_DIFF" in query
+ assert "scaleDown" in query
+ assert "PROMQL" not in query
+ assert "cluster_autoscaler_last_activity" in query
+
+
+def test_3831_pod_activity_names_evicted_and_unscheduled():
+ panel = {
+ "id": 11,
+ "type": "graph",
+ "title": "Pod activity",
+ "targets": [
+ {
+ "expr": "sum(cluster_autoscaler_evicted_pods_total)",
+ "legendFormat": "evicted pods",
+ "refId": "A",
+ },
+ {
+ "expr": "sum(cluster_autoscaler_unschedulable_pods_count)",
+ "legendFormat": "unscheduled pods",
+ "refId": "B",
+ },
+ ],
+ "gridPos": {"x": 0, "y": 0, "w": 12, "h": 7},
+ }
+ result, query, _ = _translate_3831(panel, section_title="Activity")
+ assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons
+ assert "`evicted pods`" in query or "evicted pods" in query
+ assert "`unscheduled pods`" in query or "unscheduled pods" in query
+ assert "LAST_OVER_TIME" in query
+ assert "RATE(" not in query
+
+
+def test_3831_cluster_direction_subtracts_scale_down():
+ panel = {
+ "id": 13,
+ "type": "singlestat",
+ "title": "Cluster direction?",
+ "targets": [
+ {
+ "expr": (
+ "sum(cluster_autoscaler_scaled_up_nodes_total)"
+ "-sum(cluster_autoscaler_scaled_down_nodes_total)"
+ ),
+ "refId": "A",
+ }
+ ],
+ "gridPos": {"x": 0, "y": 0, "w": 4, "h": 4},
+ }
+ result, query, _ = _translate_3831(panel)
+ assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons
+ assert "cluster_autoscaler_scaled_up_nodes_total" in query
+ assert "cluster_autoscaler_scaled_down_nodes_total" in query
+ assert "scaled_up - scaled_down" in query or "computed_value" in query
+ assert "PROMQL" not in query
+
+
+def test_3831_layout_renames_kpi_titles():
+ resolved, _ = _resolve_3831()
+ panels = [
+ {
+ "title": "Is cluster safe to scale?",
+ "esql": {"type": "gauge", "query": "FROM metrics-*"},
+ "position": {"x": 16, "y": 0},
+ "size": {"w": 8, "h": 8},
+ },
+ {
+ "title": "Cluster direction?",
+ "esql": {
+ "type": "metric",
+ "query": "FROM metrics-*",
+ "primary": {"field": "computed_value"},
+ },
+ "position": {"x": 32, "y": 0},
+ "size": {"w": 16, "h": 12},
+ },
+ ]
+ _apply_panel_layout_overrides_recursively(panels, resolved.panel_layout_overrides)
+ assert panels[0]["title"] == "Safe to autoscale"
+ assert panels[1]["title"] == "Net scaled nodes"
+ assert panels[0]["size"]["h"] == 10
+ assert panels[1]["size"]["w"] == 12
+ assert panels[1]["position"]["y"] == 2
+ assert panels[1]["esql"]["primary"]["label"] == " "
+
+
+def test_3831_kpi_row_fills_48_cols():
+ resolved, _ = _resolve_3831()
+ by_title = {item["title_match"]: item for item in resolved.panel_layout_overrides}
+ xs = [
+ by_title[name]["position"]["x"]
+ for name in (
+ "Total nodes",
+ "Nodes available",
+ "Is cluster safe to scale?",
+ "Number of unscheduled pods",
+ "Last scaleDown activity",
+ "Last autoscale activity",
+ )
+ ]
+ assert xs == [0, 8, 16, 24, 32, 40]
+
+
+# ---------------------------------------------------------------------------
+# Grafana 1471 — Kubernetes App Metrics
+# ---------------------------------------------------------------------------
+
+
+def _resolve_1471():
+ dashboard = {
+ "gnetId": 1471,
+ "title": "Kubernetes App Metrics",
+ "tags": [],
+ }
+ resolved = resolve_pack_for_dashboard(dashboard, RulePackConfig())
+ return resolved, SchemaResolver(resolved)
+
+
+def _translate_1471(panel, *, section_title=""):
+ resolved, resolver = _resolve_1471()
+ yaml_panel, result = translate_panel(
+ panel,
+ datasource_index="metrics-*",
+ esql_index="metrics-*",
+ rule_pack=resolved,
+ resolver=resolver,
+ section_title=section_title,
+ )
+ query = (yaml_panel.get("esql") or {}).get("query") or ""
+ return result, query, yaml_panel
+
+
+def test_1471_registry_entry_present():
+ entry = find_curated_pack(gnet_id=1471, title="", tags=[])
+ assert entry is not None
+ assert entry["name"] == "grafana_1471_kubernetes_app_metrics"
+ assert entry["gnet_revision"] == 1
+
+
+def test_1471_finds_by_title_fallback():
+ entry = find_curated_pack(gnet_id=None, title="Kubernetes App Metrics", tags=[])
+ assert entry is not None
+ assert entry["gnet_id"] == 1471
+
+
+def test_1471_rewrites_heapster_and_http_labels():
+ resolved, _ = _resolve_1471()
+ assert resolved.label_rewrites["container_name"] == "container"
+ assert resolved.label_rewrites["pod_name"] == "pod"
+ assert resolved.label_rewrites["kubernetes_io_hostname"] == "instance"
+ assert resolved.label_rewrites["kubernetes_namespace"] == "namespace"
+ assert resolved.source_label_names["container"] == "container_name"
+ assert resolved.metric_kinds["container_memory_usage_bytes"] == "gauge"
+ assert resolved.metric_kinds["http_requests_total"] == "counter"
+ assert "nginx_http_requests_total" in resolved.live_optional_metrics
+
+
+def test_1471_request_rate_keeps_nginx_and_binds_app():
+ panel = {
+ "id": 3,
+ "type": "graph",
+ "title": "Request rate",
+ "targets": [
+ {
+ "expr": (
+ 'sum(irate(http_requests_total{app="$container", handler!="prometheus",'
+ ' kubernetes_namespace="$namespace"}[30s])) by (kubernetes_namespace,app,code)'
+ ),
+ "legendFormat": "native | {{code}}",
+ "refId": "A",
+ },
+ {
+ "expr": (
+ 'sum(irate(nginx_http_requests_total{app="$container",'
+ ' kubernetes_namespace="$namespace"}[30s])) by (kubernetes_namespace,app,status)'
+ ),
+ "legendFormat": "nginx | {{status}}",
+ "refId": "B",
+ },
+ {
+ "expr": (
+ 'sum(irate(haproxy_backend_http_responses_total{app="$container",'
+ ' kubernetes_namespace="$namespace"}[30s])) by (app,kubernetes_namespace,code)'
+ ),
+ "legendFormat": "haproxy | {{code}}",
+ "refId": "C",
+ },
+ ],
+ "gridPos": {"x": 0, "y": 0, "w": 12, "h": 7},
+ }
+ result, query, yaml_panel = _translate_1471(panel, section_title="Request rate")
+ assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons
+ assert "nginx_http_requests_total" in query
+ assert "haproxy_backend_http_responses_total" in query
+ assert "?container" in query
+ assert "?namespace" in query
+ assert "kubernetes_namespace" not in query
+ assert "PROMQL" not in query
+ # One rate series per Grafana legendFormat ("native | 200"), grouped so
+ # Lens breakdown is `series` rather than a cartesian Native x code legend.
+ assert "BY time_bucket" in query
+ assert "native | " in query
+ breakdown = (yaml_panel.get("esql") or {}).get("breakdown") or {}
+ assert breakdown.get("field") == "series"
+ metrics = (yaml_panel.get("esql") or {}).get("metrics") or []
+ assert [m.get("field") for m in metrics] == ["rate"]
+
+
+def test_1471_error_rate_is_named_5xx_ratio():
+ panel = {
+ "id": 15,
+ "type": "graph",
+ "title": "Error rate",
+ "targets": [
+ {
+ "expr": (
+ 'sum(irate(haproxy_backend_http_responses_total{app="$container",'
+ ' kubernetes_namespace="$namespace",code="5xx"}[30s]))'
+ ' / sum(irate(haproxy_backend_http_responses_total{app="$container",'
+ ' kubernetes_namespace="$namespace"}[30s]))'
+ ),
+ "legendFormat": "haproxy",
+ "refId": "A",
+ }
+ ],
+ "gridPos": {"x": 12, "y": 0, "w": 12, "h": 7},
+ }
+ result, query, _ = _translate_1471(panel, section_title="Request rate")
+ assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons
+ assert "HAProxy" in query
+ assert "native_5xx" in query or "Native" in query
+ assert 'code == "5xx"' in query or "5[0-9]+" in query
+
+
+def test_1471_response_time_override_is_percentile_not_histogram():
+ panel = {
+ "id": 5,
+ "type": "graph",
+ "title": "Response time percentiles",
+ "targets": [
+ {
+ "expr": (
+ "histogram_quantile(0.99, sum(rate("
+ 'http_request_duration_seconds_bucket{app="$container",'
+ ' kubernetes_namespace="$namespace"}[30s])) by (app,kubernetes_namespace,le))'
+ ),
+ "legendFormat": "native | 0.99",
+ "refId": "A",
+ }
+ ],
+ "gridPos": {"x": 0, "y": 0, "w": 24, "h": 7},
+ }
+ result, query, yaml_panel = _translate_1471(panel, section_title="Response time")
+ assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons
+ assert result.status != "not_feasible"
+ assert "PERCENTILE" in query
+ assert "native_p99" in query
+ assert "PROMQL" not in query
+ assert yaml_panel.get("esql", {}).get("type") == "line"
+
+
+def test_1471_number_of_pods_uses_canonical_labels():
+ panel = {
+ "id": 7,
+ "type": "graph",
+ "title": "Number of pods",
+ "targets": [
+ {
+ "expr": (
+ 'count(count(container_memory_usage_bytes{container_name="$container",'
+ ' namespace="$namespace"}) by (pod_name))'
+ ),
+ "legendFormat": "pods",
+ "refId": "A",
+ },
+ {
+ "expr": (
+ 'count(count(container_memory_usage_bytes{container_name="$container",'
+ ' namespace="$namespace"}) by (kubernetes_io_hostname))'
+ ),
+ "legendFormat": "hosts",
+ "refId": "B",
+ },
+ ],
+ "gridPos": {"x": 0, "y": 0, "w": 24, "h": 7},
+ }
+ result, query, _ = _translate_1471(panel, section_title="Pod count")
+ assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons
+ assert "COUNT_DISTINCT" in query
+ assert "k8s.pod.name" in query
+ assert "pod_name" not in query
+ assert "kubernetes_io_hostname" not in query
+ assert "hosts" in query and "pods" in query
+
+
+def test_1471_per_pod_cpu_groups_by_pod_not_cgroup_id():
+ panel = {
+ "id": 13,
+ "type": "graph",
+ "title": "Cpu usage (per pod)",
+ "targets": [
+ {
+ "expr": (
+ 'sum(irate(container_cpu_usage_seconds_total{container_name="$container",'
+ ' namespace="$namespace"}[30s])) by (id,pod_name)'
+ ),
+ "legendFormat": "{{pod_name}}",
+ "refId": "A",
+ },
+ {
+ "expr": (
+ 'sum(container_spec_cpu_quota{container_name="$container", namespace="$namespace"}'
+ " / container_spec_cpu_period{container_name=\"$container\", namespace=\"$namespace\"})"
+ " by (namespace,container_name) / count(container_memory_usage_bytes"
+ '{container_name="$container", namespace="$namespace"}) by (namespace,container_name)'
+ ),
+ "legendFormat": "limit",
+ "refId": "B",
+ },
+ ],
+ "gridPos": {"x": 0, "y": 0, "w": 12, "h": 7},
+ }
+ result, query, _ = _translate_1471(panel, section_title="Usage per pod")
+ assert result.status == "migrated_with_warnings"
+ assert "k8s.pod.name" in query
+ assert "CPU" in query
+ assert "BY time_bucket" in query
+ assert any("limit" in r.lower() or "breakdown" in r.lower() for r in result.reasons)
+
+
+def test_1471_plugin_rewrites_namespace_and_container_populate():
+ dashboard = {
+ "gnetId": 1471,
+ "title": "Kubernetes App Metrics",
+ "tags": [],
+ "templating": {
+ "list": [
+ {
+ "name": "namespace",
+ "type": "query",
+ "query": (
+ 'label_values(container_memory_usage_bytes'
+ '{namespace=~".+",container_name!="POD"},namespace)'
+ ),
+ },
+ {
+ "name": "container",
+ "type": "query",
+ "query": (
+ 'label_values(container_memory_usage_bytes'
+ '{namespace=~"$namespace",container_name!="POD"},container_name)'
+ ),
+ },
+ ]
+ },
+ "rows": [
+ {
+ "title": "Pod count",
+ "panels": [
+ {
+ "id": 7,
+ "type": "graph",
+ "title": "Number of pods",
+ "targets": [
+ {
+ "expr": (
+ 'count(count(container_memory_usage_bytes'
+ '{container_name="$container", namespace="$namespace"})'
+ " by (pod_name))"
+ ),
+ "refId": "A",
+ }
+ ],
+ "span": 12,
+ }
+ ],
+ }
+ ],
+ }
+ 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 "namespace" in by_name, controls
+ assert "container" in by_name, controls
+ ns_q = str(by_name["namespace"].get("query") or "")
+ c_q = str(by_name["container"].get("query") or "")
+ assert "container_name" not in ns_q
+ assert "container_name" not in c_q
+ assert "container_memory_usage_bytes" in ns_q
+ assert "k8s.container.name" in c_q or "container" in c_q
+
+
+def test_1471_memory_total_uses_last_over_time():
+ panel = {
+ "id": 2,
+ "type": "graph",
+ "title": "Memory usage (total)",
+ "targets": [
+ {
+ "expr": (
+ 'sum(container_memory_usage_bytes{container_name="$container",'
+ ' namespace="$namespace"}) by (namespace,container_name)'
+ ),
+ "legendFormat": "actual",
+ "refId": "A",
+ },
+ {
+ "expr": (
+ 'sum(container_spec_memory_limit_bytes{container_name="$container",'
+ ' namespace="$namespace"}) by (namespace,container_name)'
+ ),
+ "legendFormat": "limit",
+ "refId": "B",
+ },
+ ],
+ "gridPos": {"x": 0, "y": 0, "w": 12, "h": 7},
+ }
+ result, query, _ = _translate_1471(panel, section_title="Usage total")
+ assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons
+ assert "LAST_OVER_TIME" in query
+ assert "container_memory_usage_bytes" in query
+ assert "container_spec_memory_limit_bytes" in query
+ assert "`limit`" in query or "limit" in query