diff options
| author | Tim Keller <tjk@tjkeller.xyz> | 2026-08-27 10:13:13 -0500 |
|---|---|---|
| committer | Tim Keller <tjk@tjkeller.xyz> | 2026-08-27 10:13:13 -0500 |
| commit | a7f38335e5fed5a98bfaad64ab40544124337224 (patch) | |
| tree | d70221c4093fbe15a428d407893e740ef05711fc /dashboards/gen_zfs.py | |
| download | telemetry-flake-a7f38335e5fed5a98bfaad64ab40544124337224.tar.xz telemetry-flake-a7f38335e5fed5a98bfaad64ab40544124337224.zip | |
initial commit
Diffstat (limited to 'dashboards/gen_zfs.py')
| -rw-r--r-- | dashboards/gen_zfs.py | 617 |
1 files changed, 617 insertions, 0 deletions
diff --git a/dashboards/gen_zfs.py b/dashboards/gen_zfs.py new file mode 100644 index 0000000..4edb38b --- /dev/null +++ b/dashboards/gen_zfs.py @@ -0,0 +1,617 @@ +#!/usr/bin/env python3 +"""Generate the "ZFS Pools" Grafana dashboard. + +Run this and commit the result: + + python3 dashboards/gen_zfs.py > dashboards/zfs-pools.json + +Hand-writing ~2000 lines of Grafana panel JSON invites subtle inconsistencies, so +panels are built from a handful of helpers instead -- every timeseries goes through one +code path and is therefore structurally identical by construction. + +The queries here are deliberately limited to metrics we actually produce: + +* zfs_pool_* / zfs_dataset_* / zfs_snapshot_* -- collectors/zfs-metrics.py +* node_zfs_arc_* / node_zfs_zpool_dataset_* -- node_exporter's zfs collector +* node_systemd_* -- node_exporter's systemd collector + +If you add a panel, add the metric to the collector first and keep this list honest. +""" + +import json + +from panels import (DS, OK_BAD, bargauge, gauge, gp, row, stat, table, timeseries) + + +STATE_MAP = [{ + "type": "value", + "options": { + "0": {"text": "ONLINE", "color": "green", "index": 0}, + "1": {"text": "DEGRADED", "color": "orange", "index": 1}, + "2": {"text": "FAULTED", "color": "red", "index": 2}, + "3": {"text": "OFFLINE", "color": "text", "index": 3}, + "4": {"text": "UNAVAIL", "color": "red", "index": 4}, + "5": {"text": "REMOVED", "color": "purple", "index": 5}, + "6": {"text": "SUSPENDED", "color": "red", "index": 6}, + "7": {"text": "AVAIL", "color": "blue", "index": 7}, + "8": {"text": "UNKNOWN", "color": "text", "index": 8}, + }, +}] + +BOOL_MAP = [{ + "type": "value", + "options": { + "0": {"text": "idle", "color": "green", "index": 0}, + "1": {"text": "RUNNING", "color": "blue", "index": 1}, + }, +}] + +POOL = 'pool=~"$pool"' +panels = [] + +# ====================================================================================== +# Pool Overview +# ====================================================================================== +panels.append(row("Pool Overview", 0)) + +panels.append(stat( + "Pool Health", [(f'zfs_pool_health_code{{{POOL}}}', "{{pool}}")], + gp(0, 1, 4, 4), mappings=STATE_MAP, colormode="background", + desc="Health as reported by `zpool list -o health`.", +)) +panels.append(stat( + "Data Errors", [(f'sum(zfs_pool_data_errors{{{POOL}}}) or vector(0)', "errors")], + gp(4, 1, 4, 4), thresholds=OK_BAD, colormode="background", + desc="Permanent, unrepairable errors from `zpool status`. Anything above zero " + "means data has been lost and the affected files need restoring.", +)) +panels.append(stat( + "vdev Errors", + [(f'sum(zfs_pool_vdev_errors_total{{{POOL}}}) or vector(0)', "errors")], + gp(8, 1, 4, 4), thresholds=OK_BAD, colormode="background", + desc="Total read + write + checksum errors across all vdevs. Cleared by " + "`zpool clear`.", +)) +panels.append(stat( + "Last Scrub", + [(f'time() - max(zfs_pool_last_scrub_end_timestamp_seconds{{{POOL}}})', "ago")], + gp(12, 1, 4, 4), unit="s", colormode="background", + thresholds={"mode": "absolute", "steps": [ + {"color": "green", "value": None}, + {"color": "yellow", "value": 35 * 86400}, + {"color": "red", "value": 70 * 86400}, + ]}, + desc="Time since the most recent completed scrub. Turns amber past ~5 weeks, " + "which is a monthly scrub schedule having missed a run.", +)) +panels.append(stat( + "Scrub / Resilver", [ + (f'max(zfs_pool_scrub_in_progress{{{POOL}}})', "scrub"), + (f'max(zfs_pool_resilver_in_progress{{{POOL}}})', "resilver"), + ], + gp(16, 1, 4, 4), mappings=BOOL_MAP, colormode="background", +)) +panels.append(stat( + "Locked Datasets", + [(f'count(zfs_dataset_encrypted{{{POOL}}} == 1 and ' + f'zfs_dataset_key_available{{{POOL}}} == 0) or vector(0)', "locked")], + gp(20, 1, 4, 4), thresholds=OK_BAD, colormode="background", + desc="Encrypted datasets whose key is not loaded. These are unreadable and are " + "silently skipped by anything that walks the filesystem, including backups.", +)) + +panels.append(gauge( + "Space Used", [(f'zfs_pool_capacity_ratio{{{POOL}}}', "{{pool}}")], + gp(0, 5, 5, 4), + desc="How full the pool is: allocated / total. ZFS write performance degrades " + "sharply past ~80%, and the pool becomes effectively read-only near 100%. " + "The byte figures behind this are in 'Capacity by Pool' below.", +)) +panels.append(gauge( + "Fragmentation", [(f'zfs_pool_fragmentation_ratio{{{POOL}}}', "{{pool}}")], + gp(5, 5, 5, 4), + thresholds={"mode": "absolute", "steps": [ + {"color": "green", "value": None}, + {"color": "yellow", "value": 0.5}, + {"color": "red", "value": 0.75}, + ]}, + desc="Free-space fragmentation.", +)) +panels.append(stat( + "Free Space", [(f'zfs_pool_free_bytes{{{POOL}}}', "{{pool}}")], + gp(10, 5, 5, 4), unit="bytes", +)) +panels.append(stat( + "Snapshots Overdue", + [(f'count((time() - zfs_snapshot_newest_timestamp_seconds{{{POOL}}}) > on(label) ' + f'group_left() (2 * zfs_snapshot_interval_seconds)) or vector(0)', "overdue")], + gp(15, 5, 4, 4), thresholds=OK_BAD, colormode="background", + desc="Dataset/label pairs whose newest snapshot is more than two intervals old — " + "i.e. the auto-snapshot timer has missed at least one run.", +)) +panels.append(stat( + "Retention Depth", + [(f'time() - min(zfs_pool_snapshot_oldest_timestamp_seconds{{{POOL}}})', "oldest")], + gp(19, 5, 5, 4), unit="s", + desc="Age of the oldest snapshot on the pool — how far back you can actually " + "restore from.", +)) + +panels.append(timeseries( + "Pool Space Used / Free", [ + (f'zfs_pool_allocated_bytes{{{POOL}}}', "{{pool}} used"), + (f'zfs_pool_free_bytes{{{POOL}}}', "{{pool}} free"), + ], + gp(0, 9, 12, 8), unit="bytes", stack=True, minval=0, + desc="Stacked, so the top of each pool's band is its total size and the split " + "between the bands is the usage fraction the gauge above shows.", +)) +panels.append(timeseries( + "Fragmentation", + [(f'zfs_pool_fragmentation_ratio{{{POOL}}}', "{{pool}}")], + gp(12, 9, 12, 8), unit="percentunit", minval=0, maxval=1, + desc="Free-space fragmentation — how scattered the pool's remaining free space " + "is, not how scattered the data is. It rises as a pool fills and stays high " + "afterwards; only rewriting data (send/recv, or deleting a lot) brings it " + "down. Kept on its own axis because it is unrelated to space used, and " + "plotting the two together invites reading one as the other.", +)) + +panels.append(table( + "Capacity by Pool", [ + (f'zfs_pool_size_bytes{{{POOL}}}', None), + (f'max by (pool) (zfs_pool_allocated_bytes{{{POOL}}})', None), + (f'max by (pool) (zfs_pool_free_bytes{{{POOL}}})', None), + (f'max by (pool) (zfs_pool_capacity_ratio{{{POOL}}})', None), + (f'max by (pool) (zfs_pool_fragmentation_ratio{{{POOL}}})', None), + (f'max by (pool) (zfs_pool_snapshot_used_bytes{{{POOL}}})', None), + (f'max by (pool) (zfs_pool_dedup_ratio{{{POOL}}})', None), + ], + gp(0, 17, 24, 8), keep="pool|Value.*", + renames={ + "__join__": "pool", "pool": "Pool", + "Value #A": "Size", "Value #B": "Used", "Value #C": "Free", + "Value #D": "Used %", "Value #E": "Fragmentation", + "Value #F": "Snapshots", "Value #G": "Dedup", + }, + order=["pool", "Value #A", "Value #B", "Value #C", "Value #D", "Value #E", + "Value #F", "Value #G"], + sort_by="Used %", + overrides=[ + # Anchored: byRegexp is a substring match, so a bare "Used" would also + # capture "Used %" and fight the percent override below. + {"matcher": {"id": "byRegexp", "options": "^(Size|Used|Free|Snapshots)$"}, + "properties": [{"id": "unit", "value": "bytes"}]}, + {"matcher": {"id": "byName", "options": "Used %"}, + "properties": [ + {"id": "unit", "value": "percentunit"}, + {"id": "max", "value": 1}, + {"id": "thresholds", "value": {"mode": "absolute", "steps": [ + {"color": "green", "value": None}, + {"color": "yellow", "value": 0.8}, + {"color": "red", "value": 0.9}, + ]}}, + {"id": "custom.cellOptions", + "value": {"type": "gauge", "mode": "gradient"}}, + ]}, + {"matcher": {"id": "byName", "options": "Fragmentation"}, + "properties": [{"id": "unit", "value": "percentunit"}]}, + ], + desc="Per-pool space in bytes. 'Size' is the raw pool size zpool reports, which " + "for raidz is before parity overhead — so 'Free' is the number to trust for " + "how much you can still write. 'Snapshots' is space held only by snapshots.", +)) + +# ====================================================================================== +# Integrity & Errors +# ====================================================================================== +panels.append(row("Integrity & Errors", 25)) + +panels.append(table( + "vdev State", [(f'zfs_pool_vdev_state_code{{{POOL}}}', None)], + gp(0, 26, 12, 9), keep="pool|vdev|Value", + renames={"pool": "Pool", "vdev": "vdev", "Value": "State"}, + mappings=STATE_MAP, + overrides=[{ + "matcher": {"id": "byName", "options": "State"}, + "properties": [{"id": "custom.cellOptions", + "value": {"type": "color-background", "mode": "basic"}}], + }], + desc="Every vdev and leaf device in the selected pool(s).", +)) +panels.append(timeseries( + "vdev Error Counters", + [(f'zfs_pool_vdev_errors_total{{{POOL}}}', "{{pool}}/{{vdev}} {{type}}")], + gp(12, 26, 12, 9), minval=0, + custom={"lineWidth": 2, "fillOpacity": 0, "showPoints": "auto"}, + desc="Cumulative per-vdev errors. A counter that climbs is a disk on its way out.", +)) + +panels.append(table( + "Scrub & Resilver History", [ + (f'zfs_pool_last_scrub_end_timestamp_seconds{{{POOL}}} * 1000', None), + (f'zfs_pool_last_scrub_duration_seconds{{{POOL}}}', None), + (f'zfs_pool_last_scrub_errors{{{POOL}}}', None), + (f'zfs_pool_last_scrub_repaired_bytes{{{POOL}}}', None), + # zpool status reports one scan per pool, so a pool whose most recent scan was + # a resilver has no scrub record at all and would otherwise vanish from this + # table. The outer join keeps it, with only this column filled. + (f'zfs_pool_last_resilver_end_timestamp_seconds{{{POOL}}} * 1000', None), + ], + gp(0, 35, 12, 7), keep="pool|Value.*", + renames={ + "__join__": "pool", + "pool": "Pool", + "Value #A": "Scrub Finished", "Value #B": "Duration", + "Value #C": "Errors", "Value #D": "Repaired", + "Value #E": "Resilver Finished", + }, + overrides=[ + {"matcher": {"id": "byName", "options": "Scrub Finished"}, + "properties": [{"id": "unit", "value": "dateTimeAsIso"}]}, + {"matcher": {"id": "byName", "options": "Resilver Finished"}, + "properties": [{"id": "unit", "value": "dateTimeAsIso"}]}, + {"matcher": {"id": "byName", "options": "Duration"}, + "properties": [{"id": "unit", "value": "s"}]}, + {"matcher": {"id": "byName", "options": "Repaired"}, + "properties": [{"id": "unit", "value": "bytes"}]}, + {"matcher": {"id": "byName", "options": "Errors"}, + "properties": [ + {"id": "custom.cellOptions", + "value": {"type": "color-background", "mode": "basic"}}, + {"id": "thresholds", "value": OK_BAD}, + ]}, + ], + desc="Result of the last completed scrub per pool. Errors here are blocks that " + "could not be repaired from redundancy.", +)) +panels.append(timeseries( + "Pool Data Errors", [(f'zfs_pool_data_errors{{{POOL}}}', "{{pool}}")], + gp(12, 35, 12, 7), minval=0, + custom={"lineWidth": 2, "fillOpacity": 10, "showPoints": "auto"}, + desc="Permanent data errors over time.", +)) + +# ====================================================================================== +# Snapshots & Retention +# ====================================================================================== +panels.append(row("Snapshots & Retention", 42)) + +panels.append(bargauge( + "Time Since Last Snapshot", + [(f'time() - max by (label) (zfs_pool_snapshot_newest_timestamp_seconds{{{POOL}}})', + "{{label}}")], + gp(0, 43, 8, 8), unit="s", + thresholds={"mode": "absolute", "steps": [ + {"color": "green", "value": None}, + {"color": "yellow", "value": 2 * 3600}, + {"color": "red", "value": 2 * 86400}, + ]}, + desc="Age of the newest snapshot of each label. Compare against the label's own " + "cadence — 'frequent' is every 15 minutes, 'monthly' every month, so the " + "colour thresholds here are only a coarse hint.", +)) +panels.append(bargauge( + "Snapshots vs Retention Target", + [(f'min by (label) (zfs_snapshot_count{{{POOL}}} / on(label) group_left() ' + f'zfs_snapshot_retention_target)', "{{label}}")], + gp(8, 43, 8, 8), unit="percentunit", maxv=1.2, + thresholds={"mode": "absolute", "steps": [ + {"color": "red", "value": None}, + {"color": "yellow", "value": 0.5}, + {"color": "green", "value": 0.9}, + ]}, + desc="Worst dataset's snapshot count as a fraction of the configured " + "services.zfs.autoSnapshot retention. 100% means the full set is on disk; a " + "freshly-created dataset legitimately sits below target until it fills up.", +)) +panels.append(bargauge( + "Oldest Snapshot", + [(f'time() - min by (label) ' + f'(zfs_pool_snapshot_oldest_timestamp_seconds{{{POOL}}})', "{{label}}")], + gp(16, 43, 8, 8), unit="s", + desc="How far back each label reaches.", +)) + +panels.append(timeseries( + "Snapshot Count by Label", + [(f'sum by (label) (zfs_snapshot_count{{{POOL}}})', "{{label}}")], + gp(0, 51, 12, 8), minval=0, + desc="Total snapshots per label. A steady sawtooth is healthy rotation; a " + "monotonic decline means snapshots are expiring but not being replaced.", +)) +panels.append(timeseries( + "Snapshot Space by Label", + [(f'sum by (label) (zfs_pool_snapshot_used_bytes{{{POOL}}})', "{{label}}")], + gp(12, 51, 12, 8), unit="bytes", stack=True, minval=0, + desc="Space held down by snapshots — the cost of your retention policy.", +)) + +panels.append(stat( + "Snapshot / Scrub Job Failures", + [('sum(node_systemd_unit_state{name=~"zfs-(snapshot|scrub).*",state="failed"}) ' + 'or vector(0)', "failed units")], + gp(0, 59, 6, 6), thresholds=OK_BAD, colormode="background", + desc="systemd units for the auto-snapshot and auto-scrub timers currently in a " + "failed state.", +)) +panels.append(bargauge( + "Time Since Timer Last Fired", + [('time() - node_systemd_timer_last_trigger_seconds{name=~"zfs-.*"}', "{{name}}")], + gp(6, 59, 18, 6), unit="s", + desc="When each zfs-* systemd timer last triggered. This is the job side of the " + "picture; the snapshot ages above are the result side.", +)) + +# ====================================================================================== +# Datasets +# ====================================================================================== +panels.append(row("Datasets", 65)) + +panels.append(timeseries( + "Dataset Usage Over Time", + [(f'zfs_dataset_used_bytes{{{POOL}}}', "{{dataset}}")], + gp(0, 66, 24, 9), unit="bytes", minval=0, + custom={"lineWidth": 2, "fillOpacity": 0, "showPoints": "auto"}, + desc="Space each dataset is responsible for, including its snapshots and " + "descendants — so a parent's line contains its children's. Unstacked and " + "unfilled so a single dataset's growth is readable against the others rather " + "than riding on top of them. The legend carries last and max per dataset; the " + "instant breakdown is in 'Capacity by Dataset' below.", +)) + +panels.append(table( + "Capacity by Dataset", [ + # Only this query keeps `pool` as its own column; the rest are reduced to the + # join key so joinByField has nothing to collide on. `dataset` is the full + # path ("ingens/media"), so it is unique on its own. + (f'zfs_dataset_used_bytes{{{POOL}}}', None), + (f'max by (dataset) (zfs_dataset_used_by_dataset_bytes{{{POOL}}})', None), + (f'max by (dataset) (zfs_dataset_used_by_snapshots_bytes{{{POOL}}})', None), + (f'max by (dataset) (zfs_dataset_referenced_bytes{{{POOL}}})', None), + (f'max by (dataset) (zfs_dataset_logical_used_bytes{{{POOL}}})', None), + (f'max by (dataset) (zfs_dataset_available_bytes{{{POOL}}})', None), + (f'max by (dataset) (zfs_dataset_compression_ratio{{{POOL}}})', None), + # Quota is 0 when unset, which would read as "no space allowed"; blank it. + (f'max by (dataset) (zfs_dataset_quota_bytes{{{POOL}}} > 0)', None), + ], + gp(0, 75, 24, 8), keep="pool|dataset|Value.*", + renames={ + "__join__": "dataset", "dataset": "Dataset", "pool": "Pool", + "Value #A": "Used", "Value #B": "Own Data", "Value #C": "Snapshots", + "Value #D": "Referenced", "Value #E": "Logical", "Value #F": "Available", + "Value #G": "Ratio", "Value #H": "Quota", + }, + order=["pool", "dataset", "Value #A", "Value #B", "Value #C", "Value #D", + "Value #E", "Value #F", "Value #G", "Value #H"], + sort_by="Used", + overrides=[ + {"matcher": {"id": "byRegexp", + "options": "^(Used|Own Data|Snapshots|Referenced|Logical|" + "Available|Quota)$"}, + "properties": [{"id": "unit", "value": "bytes"}]}, + {"matcher": {"id": "byName", "options": "Quota"}, + "properties": [{"id": "noValue", "value": "none"}]}, + {"matcher": {"id": "byName", "options": "Ratio"}, + "properties": [{"id": "decimals", "value": 2}]}, + ], + desc="Per-dataset space, separate from the per-pool figures above. 'Used' is the " + "total a dataset is responsible for; 'Own Data' and 'Snapshots' are the two " + "halves of that. 'Logical' is the size before compression, so Logical / " + "Referenced is where the compression ratio comes from. 'Available' is shared " + "with every sibling dataset unless a quota says otherwise.", +)) + +panels.append(table( + "Compression", + [(f'zfs_dataset_compression_ratio{{{POOL}}} * on(pool, dataset) ' + f'group_left(compression) zfs_dataset_compression{{{POOL}}}', None)], + gp(0, 83, 12, 8), keep="pool|dataset|compression|Value", + order=["pool", "dataset", "compression", "Value"], + renames={"pool": "Pool", "dataset": "Dataset", + "compression": "Algorithm", "Value": "Ratio"}, + sort_by="Ratio", + desc="group_left() attaches the algorithm label from the info metric onto the " + "ratio, so this needs only one query and no join transformation.", +)) +panels.append(table( + "Encryption", + [(f'zfs_dataset_keystatus_code{{{POOL}}} * on(pool, dataset) ' + f'group_left(encryption) zfs_dataset_encryption{{{POOL}}}', None)], + gp(12, 83, 12, 8), keep="pool|dataset|encryption|Value", + order=["pool", "dataset", "encryption", "Value"], + renames={"pool": "Pool", "dataset": "Dataset", + "encryption": "Suite", "Value": "Key"}, + mappings=[{"type": "value", "options": { + "0": {"text": "—", "color": "text", "index": 0}, + "1": {"text": "unlocked", "color": "green", "index": 1}, + "2": {"text": "LOCKED", "color": "red", "index": 2}, + "3": {"text": "unknown", "color": "orange", "index": 3}, + }}], + overrides=[{ + "matcher": {"id": "byName", "options": "Key"}, + "properties": [{"id": "custom.cellOptions", + "value": {"type": "color-background", "mode": "basic"}}], + }], + desc="Encryption suite per dataset and the state of its key. Datasets that are " + "not encrypted show suite 'off' and a dash — only an encrypted dataset can " + "be locked.", +)) + +# ====================================================================================== +# Performance +# ====================================================================================== +panels.append(row("Performance", 91)) + +panels.append(timeseries( + "Dataset Throughput", [ + (f'rate(node_zfs_zpool_dataset_nread{{zpool=~"$pool"}}[$__rate_interval])', + "{{zpool}}/{{dataset}} read"), + (f'-rate(node_zfs_zpool_dataset_nwritten{{zpool=~"$pool"}}[$__rate_interval])', + "{{zpool}}/{{dataset}} write"), + ], + gp(0, 92, 12, 8), unit="Bps", + desc="Per-dataset throughput from node_exporter's objset kstats. Writes are drawn " + "negative.", +)) +panels.append(timeseries( + "Dataset IOPS", [ + (f'rate(node_zfs_zpool_dataset_reads{{zpool=~"$pool"}}[$__rate_interval])', + "{{zpool}}/{{dataset}} read"), + (f'-rate(node_zfs_zpool_dataset_writes{{zpool=~"$pool"}}[$__rate_interval])', + "{{zpool}}/{{dataset}} write"), + ], + gp(12, 92, 12, 8), unit="iops", + desc="Writes are drawn negative.", +)) + +panels.append(timeseries( + "ARC Size", [ + ("node_zfs_arc_size", "size"), + ("node_zfs_arc_c_max", "target max (c_max)"), + ("node_zfs_arc_c_min", "target min (c_min)"), + ], + gp(0, 100, 8, 8), unit="bytes", minval=0, +)) +panels.append(timeseries( + "ARC Hit Ratio", + [("rate(node_zfs_arc_hits[$__rate_interval]) / clamp_min(" + "rate(node_zfs_arc_hits[$__rate_interval]) + " + "rate(node_zfs_arc_misses[$__rate_interval]), 1)", "hit ratio")], + gp(8, 100, 8, 8), unit="percentunit", minval=0, + desc="clamp_min avoids a divide-by-zero while the pool is idle.", +)) +panels.append(timeseries( + "ARC Hits / Misses", [ + ("rate(node_zfs_arc_hits[$__rate_interval])", "hits"), + ("rate(node_zfs_arc_misses[$__rate_interval])", "misses"), + ], + gp(16, 100, 8, 8), unit="ops", minval=0, +)) + +# ====================================================================================== +# Collapsed: advanced / diagnostic +# ====================================================================================== +panels.append(row("Pool Internals", 108, collapsed=True, children=[ + timeseries( + "Reclaim & Leaks", [ + (f'zfs_pool_freeing_bytes{{{POOL}}}', "{{pool}} freeing"), + (f'zfs_pool_leaked_bytes{{{POOL}}}', "{{pool}} leaked"), + (f'zfs_pool_checkpoint_bytes{{{POOL}}}', "{{pool}} checkpoint"), + ], + gp(0, 109, 8, 7), unit="bytes", minval=0, + desc="`freeing` is space still being reclaimed in the background after a " + "delete. `leaked` should always be zero.", + ), + timeseries( + "Dedup Ratio", [(f'zfs_pool_dedup_ratio{{{POOL}}}', "{{pool}}")], + gp(8, 109, 8, 7), minval=0, + desc="1.00 unless deduplication is enabled, which it usually should not be.", + ), + timeseries( + "Pool Health History", [ + (f'zfs_pool_health_code{{{POOL}}}', "{{pool}}"), + ], + # Bounded to the state-code range so the y-axis reads as states, not 0-100. + gp(16, 109, 8, 7), mappings=STATE_MAP, minval=0, maxval=8, + custom={"lineWidth": 2, "fillOpacity": 0, "lineInterpolation": "stepAfter"}, + desc="Health code over time — useful for spotting a pool that briefly " + "degraded and recovered. zfs_pool_scan_state is also collected if you " + "want to plot scrub/resilver phases alongside it.", + ), + table( + "Dataset Properties", [ + (f'zfs_dataset_recordsize_bytes{{{POOL}}}', None), + (f'zfs_dataset_quota_bytes{{{POOL}}}', None), + (f'zfs_dataset_refquota_bytes{{{POOL}}}', None), + (f'zfs_dataset_autosnapshot_enabled{{{POOL}}}', None), + ], + gp(0, 116, 24, 8), keep="dataset|Value.*", + renames={ + "__join__": "dataset", "dataset": "Dataset", + "Value #A": "Record Size", "Value #B": "Quota", + "Value #C": "Ref Quota", "Value #D": "Auto-snapshot", + }, + overrides=[ + {"matcher": {"id": "byName", "options": "Record Size"}, + "properties": [{"id": "unit", "value": "bytes"}]}, + {"matcher": {"id": "byName", "options": "Quota"}, + "properties": [{"id": "unit", "value": "bytes"}]}, + {"matcher": {"id": "byName", "options": "Ref Quota"}, + "properties": [{"id": "unit", "value": "bytes"}]}, + {"matcher": {"id": "byName", "options": "Auto-snapshot"}, + "properties": [{"id": "mappings", "value": [{"type": "value", "options": { + "0": {"text": "no", "color": "orange", "index": 0}, + "1": {"text": "yes", "color": "green", "index": 1}, + }}]}]}, + ], + desc="A quota of 0 means unlimited. Auto-snapshot reflects the " + "com.sun:auto-snapshot property that zfs-auto-snapshot honours.", + ), +])) + +panels.append(row("Collector Health", 109, collapsed=True, children=[ + timeseries( + "ZFS Collector Duration", + [("zfs_metrics_collection_duration_seconds", "collection")], + gp(0, 110, 8, 7), unit="s", minval=0, + desc="How long collectors/zfs-metrics.py takes. Listing every snapshot is the " + "slow part; if this approaches the 1 minute timer interval, raise " + "OnUnitActiveSec.", + ), + timeseries( + "Textfile Age", + [("time() - node_textfile_mtime_seconds", "{{file}}")], + gp(8, 110, 8, 7), unit="s", minval=0, + desc="Age of each .prom file. If this climbs without bound the collector has " + "stopped writing and every ZFS metric above is stale.", + ), + timeseries( + "Scrape Health", [ + ('up{job="node"}', "node exporter up"), + ("node_textfile_scrape_error", "textfile parse error"), + ], + gp(16, 110, 8, 7), minval=0, + desc="node_textfile_scrape_error goes to 1 if a .prom file is malformed, in " + "which case node_exporter drops it entirely.", + ), +])) + +dashboard = { + "title": "ZFS Pools", + "uid": "zfs-pools", + "description": "ZFS capacity, health, integrity, snapshot retention, dataset " + "properties and performance. Fed by node_exporter's zfs and systemd " + "collectors plus collectors/zfs-metrics.py from lgtm.nix.", + "tags": ["zfs", "storage"], + "timezone": "browser", + "schemaVersion": 41, + "version": 1, + "editable": True, + "graphTooltip": 1, + "refresh": "1m", + "time": {"from": "now-24h", "to": "now"}, + "templating": {"list": [{ + "name": "pool", + "label": "Pool", + "type": "query", + "datasource": DS, + "query": {"qryType": 1, "query": "label_values(zfs_pool_health_code, pool)", + "refId": "PrometheusVariableQueryEditor-VariableQuery"}, + "refresh": 1, + "includeAll": True, + "allValue": ".*", + "multi": True, + "current": {"text": ["All"], "value": ["$__all"]}, + "options": [], + "sort": 1, + }]}, + "annotations": {"list": [{ + "builtIn": 1, "datasource": {"type": "datasource", "uid": "grafana"}, + "enable": True, "hide": True, "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", "type": "dashboard", + }]}, + "panels": panels, +} + +print(json.dumps(dashboard, indent=2)) |
