diff options
| author | Tim Keller <tjk@tjkeller.xyz> | 2026-09-02 21:01:46 -0500 |
|---|---|---|
| committer | Tim Keller <tjk@tjkeller.xyz> | 2026-09-02 21:01:46 -0500 |
| commit | e88a880435ebdda6e50bf16a493abb85b4e80d15 (patch) | |
| tree | cc59b2810868bcc90bb2772380945fc024f7f5ee /dashboards | |
| parent | a74c2877571afbfd04584b8821c754e9050b9e72 (diff) | |
| download | telemetry-flake-flake-modules.tar.xz telemetry-flake-flake-modules.zip | |
add NVMe support, and give the self-test collector the device listHEADmasterflake-modules
Two configurations the modules claimed to support and did not.
NVMe drives had no per-drive section. smartctl:bus routes a drive by metric
presence -- ATA attribute table, or SCSI grown defect list -- and NVMe emits
neither, confirmed against a live smartctl_exporter 0.14.0 on real hardware.
With no bus label the smartctl:row join dropped them, so they appeared in Fleet
Overview and All Drives but could not be opened. Adds an nvme arm keyed on
smartctl_device_percentage_used, verdict rules over the SMART/Health
Information log, and a third repeating section.
The verdict is BAD on a critical warning, media errors, or available spare
below the drive's own threshold, and CAUTION at 90% of rated endurance.
num_err_log_entries is deliberately not in it: the NVMe drive in this machine
is healthy and sits at 2200, because it counts every command that ever
returned an error.
smart.devices reached the exporter but not the self-test collector, which
always ran its own `smartctl --scan` -- exactly what cannot see drives behind a
RAID controller, which is the only reason to set the option. The collector now
takes the same PATH[;TYPE] syntax and passes -d through.
That means it also has to reproduce the exporter's device label, which is not
the basename: /dev/bus/0 as megaraid,1 is bus_0_megaraid_1. Ported
buildDeviceLabel with its /dev/disk/by-{id,path}/ prefix stripping and the
comma rule, and the scan-time scsi -> auto fallback. Getting this wrong fails
silently -- every join in rules/smartctl.yml simply misses.
Verified: promtool; a Prometheus fixture covering all three transports plus a
megaraid-labelled device, asserting bus and verdict per drive and resolving
$ata_device/$sas_device/$nvme_device the way Grafana does; the grid layout;
the ExecStart with and without smart.devices; and the collector against this
machine's NVMe drive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKczN1f66kmLxNBLX37voQ
Diffstat (limited to 'dashboards')
| -rw-r--r-- | dashboards/gen_smart.py | 154 |
1 files changed, 133 insertions, 21 deletions
diff --git a/dashboards/gen_smart.py b/dashboards/gen_smart.py index 76ca8fe..d7b0671 100644 --- a/dashboards/gen_smart.py +++ b/dashboards/gen_smart.py @@ -9,15 +9,20 @@ it produces: A CrystalDiskInfo-style view: a Good/Caution/Bad verdict per drive, the full raw SMART attribute table, and self-test history. -The fleet is mixed, so there are two repeating sections: +The fleet is mixed, so there are three repeating sections, because the three transports +report health in three unrelated ways: * SATA drives expose the classic ATA attribute table (value / worst / thresh / raw). * SAS drives expose none of it. Their health lives in SCSI error counters instead -- grown defect list, uncorrected errors, corrected-error breakdown. +* NVMe drives expose neither, only the SMART/Health Information log -- a critical + warning bitfield, media errors, endurance used and the spare block pool. Drives are routed into the right section by *metric presence* rather than by the `protocol` label, which is more robust: only ATA drives have smartctl_device_attribute, -and only SCSI drives have smartctl_scsi_grown_defect_list. +only SCSI drives have smartctl_scsi_grown_defect_list, and the exporter emits +smartctl_device_percentage_used only for nvme. A drive matching none of the three gets +no section at all, so adding a transport means adding an arm to smartctl:bus. Metrics used: @@ -63,6 +68,7 @@ PASS_MAP = [{ HOST = 'host=~"$host"' ATA = f'{HOST}, device=~"$ata_device"' SAS = f'{HOST}, device=~"$sas_device"' +NVME = f'{HOST}, device=~"$nvme_device"' # The per-drive sections come from smartctl:row, one preformatted line per drive. # @@ -149,16 +155,17 @@ panels.append(stat( "Healthy", [(f'count(smartctl:health_code{{{HOST}}} == 0) or vector(0)', "drives")], gp(0, 1, 4, 4), colormode="background", thresholds={"mode": "absolute", "steps": [{"color": "green", "value": None}]}, - desc="Drives with no failing attribute, no SCSI uncorrected errors and a passing " - "SMART self-assessment.", + desc="Drives with no failing attribute, no SCSI uncorrected errors, no NVMe " + "critical warning and a passing SMART self-assessment.", )) panels.append(stat( "Caution", [(f'count(smartctl:health_code{{{HOST}}} == 1) or vector(0)', "drives")], gp(4, 1, 4, 4), colormode="background", thresholds={"mode": "absolute", "steps": [ {"color": "green", "value": None}, {"color": "orange", "value": 1}]}, - desc="Reallocated / pending / uncorrectable sectors on SATA, or a non-empty grown " - "defect list on SAS. Not failing yet — worth watching the trend.", + desc="Reallocated / pending / uncorrectable sectors on SATA, a non-empty grown " + "defect list on SAS, or 90%+ of rated endurance used on NVMe. Not failing yet " + "— worth watching the trend.", )) panels.append(stat( "Failing", [(f'count(smartctl:health_code{{{HOST}}} == 2) or vector(0)', "drives")], @@ -436,30 +443,133 @@ panels.append(timeseries( )) # ====================================================================================== +# NVMe — repeats once per NVMe drive +# ====================================================================================== +panels.append(row("${nvme_device:text}", 53, repeat="nvme_device")) + +panels.append(stat( + "Health", [(f'smartctl:health_code{{{NVME}}}', "{{device}}")], + gp(0, 54, 6, 4), mappings=HEALTH_MAP, thresholds=HEALTH_THRESHOLDS, + colormode="background", +)) +panels.append(stat( + "Temperature", + [(f'smartctl_device_temperature{{{NVME}, temperature_type="current"}}', "temp")], + gp(6, 54, 3, 4), unit="celsius", + thresholds={"mode": "absolute", "steps": [ + {"color": "green", "value": None}, + {"color": "yellow", "value": 50}, + {"color": "red", "value": 70}, + ]}, + desc="NVMe drives run hotter than spinning disks by design and throttle rather " + "than fail, so the thresholds here are higher than on the other sections.", +)) +panels.append(stat( + "Powered On", [(power_on(NVME), "hours")], + gp(9, 54, 3, 4), unit="s", +)) +panels.append(stat( + # smartctl reports user_capacity only for a single-namespace drive; with several + # namespaces the exporter publishes nvme_total_capacity instead and leaves this at + # zero, so filter the zero out rather than displaying it. + "Capacity", + [(f'max by (device) (smartctl_device_capacity_bytes{{{NVME}}} > 0 ' + f'or smartctl_device_nvme_capacity_bytes{{{NVME}}})', "bytes")], + gp(12, 54, 3, 4), unit="bytes", +)) +panels.append(stat( + "Wear", [(f'smartctl_device_percentage_used{{{NVME}}}', "used")], + gp(15, 54, 3, 4), unit="percent", colormode="background", + thresholds={"mode": "absolute", "steps": [ + {"color": "green", "value": None}, + {"color": "yellow", "value": 80}, + {"color": "red", "value": 100}, + ]}, + desc="The controller's estimate of consumed write endurance. 100% means the drive " + "has reached its rated lifetime, not that it is about to fail — plenty run " + "well past it. Watch how fast it climbs rather than the number.", +)) +panels.append(stat( + "Available Spare", [(f'smartctl_device_available_spare{{{NVME}}}', "remaining")], + gp(18, 54, 3, 4), unit="percent", colormode="background", + thresholds={"mode": "absolute", "steps": [ + {"color": "red", "value": None}, + {"color": "orange", "value": 15}, + {"color": "green", "value": 25}, + ]}, + desc="Spare blocks left for remapping, as a percentage of the factory pool. Falling " + "below the drive's own threshold — typically 5–10%, and reported separately as " + "smartctl_device_available_spare_threshold — sets a critical warning and is " + "treated as a failure here.", +)) +panels.append(stat( + "Media Errors", [(f'smartctl_device_media_errors{{{NVME}}}', "errors")], + gp(21, 54, 3, 4), thresholds=OK_BAD, colormode="background", + desc="Unrecovered data integrity errors — the NVMe equivalent of SAS uncorrected " + "errors. Should be zero.", +)) + +panels.append(drive_info( + "Drive — $nvme_device", NVME, gp(0, 58, 24, 4), + label_cols=["model_name", "serial_number", "firmware_version", "interface", + "protocol", "form_factor"], + renames={"model_name": "Model", "serial_number": "Serial", + "firmware_version": "Firmware", "interface": "Interface", + "protocol": "Bus", "form_factor": "Form"}, + desc="Identity of this drive. NVMe reports no rotation rate, so RPM always reads " + "SSD, and no model family or ATA/SATA version.", +)) + +panels.append(timeseries( + "Wear & Spare — $nvme_device", [ + (f'smartctl_device_percentage_used{{{NVME}}}', "endurance used"), + (f'smartctl_device_available_spare{{{NVME}}}', "spare remaining"), + (f'smartctl_device_available_spare_threshold{{{NVME}}}', "spare threshold"), + ], + gp(0, 62, 12, 8), unit="percent", minval=0, + custom={"lineWidth": 2, "fillOpacity": 0}, + desc="The two lines that matter are spare remaining and its threshold: the drive " + "declares itself failing when they cross.", +)) +panels.append(timeseries( + "Errors — $nvme_device", [ + (f'smartctl_device_critical_warning{{{NVME}}}', "critical warning"), + (f'smartctl_device_media_errors{{{NVME}}}', "media errors"), + (f'smartctl_device_num_err_log_entries{{{NVME}}}', "error log entries"), + ], + gp(12, 62, 12, 8), minval=0, + custom={"lineWidth": 2, "fillOpacity": 0, "showPoints": "auto"}, + desc="Critical warning is a bitfield — any non-zero value is the drive asking to be " + "replaced. Error log entries count every command that ever returned an error, " + "including entirely harmless ones: a healthy consumer SSD can sit at a few " + "thousand. It is context, not a fault, which is why the verdict ignores it.", +)) + +# ====================================================================================== # Self-tests # ====================================================================================== -panels.append(row("Self-Tests", 53)) +panels.append(row("Self-Tests", 70)) panels.append(stat( "Drives Never Tested", [(f'count(smartctl_device_smart_status{{{HOST}}}) - ' f'count(count by (device) (smart_selftest_last_lifetime_hours{{{HOST}}})) or ' f'count(smartctl_device_smart_status{{{HOST}}})', "drives")], - gp(0, 54, 4, 5), thresholds=OK_BAD, colormode="background", + gp(0, 71, 4, 5), thresholds=OK_BAD, colormode="background", desc="Drives with no self-test log entries at all. smartd only runs tests if its " "config includes an -s schedule; merely enabling the service does not.", )) panels.append(stat( "Failed Self-Tests", [(f'count(smart_selftest_last_passed{{{HOST}}} == 0) or vector(0)', "tests")], - gp(4, 54, 4, 5), thresholds=OK_BAD, colormode="background", + gp(4, 71, 4, 5), thresholds=OK_BAD, colormode="background", )) panels.append(stat( "Oldest Long Test", [(f'max({power_on()} / 3600 - on(device) group_right() ' f'smart_selftest_last_lifetime_hours{{{HOST}, test_type="extended"}})', "hours ago")], - gp(8, 54, 4, 5), unit="h", + gp(8, 71, 4, 5), unit="h", thresholds={"mode": "absolute", "steps": [ {"color": "green", "value": None}, {"color": "yellow", "value": 24 * 14}, @@ -495,7 +605,7 @@ panels.append(table( (_keyed(power_on() + ' / 3600 - on(device) group_right() ' f'smart_selftest_last_lifetime_hours{{{HOST}}}'), None), ], - gp(12, 54, 12, 10), keep="drive_test|device|test_type|Value.*", + gp(12, 71, 12, 10), keep="drive_test|device|test_type|Value.*", renames={ "__join__": "drive_test", "device": "Device", "test_type": "Test", "Value #A": "Last Result", "Value #B": "At Hours", @@ -523,26 +633,26 @@ panels.append(table( # ====================================================================================== # Collapsed: trends and diagnostics # ====================================================================================== -panels.append(row("Attribute Trends", 64, collapsed=True, children=[ +panels.append(row("Attribute Trends", 81, collapsed=True, children=[ timeseries( "Reallocated Sectors (all SATA drives)", [(f'smartctl_device_attribute{{{HOST}, attribute_id="5", ' f'attribute_value_type="raw"}}', "{{device}}")], - gp(0, 65, 8, 7), minval=0, + gp(0, 82, 8, 7), minval=0, custom={"lineWidth": 2, "fillOpacity": 0, "showPoints": "auto"}, ), timeseries( "Pending & Uncorrectable Sectors", [(f'smartctl_device_attribute{{{HOST}, attribute_id=~"197|198", ' f'attribute_value_type="raw"}}', "{{device}} {{attribute_name}}")], - gp(8, 65, 8, 7), minval=0, + gp(8, 82, 8, 7), minval=0, custom={"lineWidth": 2, "fillOpacity": 0, "showPoints": "auto"}, ), timeseries( "CRC Errors (cabling)", [(f'smartctl_device_attribute{{{HOST}, attribute_id="199", ' f'attribute_value_type="raw"}}', "{{device}}")], - gp(16, 65, 8, 7), minval=0, + gp(16, 82, 8, 7), minval=0, custom={"lineWidth": 2, "fillOpacity": 0, "showPoints": "auto"}, desc="Attribute 199 counts errors on the SATA link rather than the platters. " "Growth here usually means a cable or backplane, not a dying disk.", @@ -552,21 +662,21 @@ panels.append(row("Attribute Trends", 64, collapsed=True, children=[ (f'smartctl_device_bytes_written{{{HOST}}}', "{{device}} written"), (f'smartctl_device_bytes_read{{{HOST}}}', "{{device}} read"), ], - gp(0, 72, 12, 7), unit="bytes", minval=0, + gp(0, 89, 12, 7), unit="bytes", minval=0, ), timeseries( "Health Code History", [(f'smartctl:health_code{{{HOST}}}', "{{device}}")], - gp(12, 72, 12, 7), mappings=HEALTH_MAP, minval=0, maxval=2, + gp(12, 89, 12, 7), mappings=HEALTH_MAP, minval=0, maxval=2, custom={"lineWidth": 2, "fillOpacity": 0, "lineInterpolation": "stepAfter"}, ), ])) -panels.append(row("Collector Health", 65, collapsed=True, children=[ +panels.append(row("Collector Health", 82, collapsed=True, children=[ timeseries( "smartctl Exit Status", [(f'smartctl_device_smartctl_exit_status{{{HOST}}}', "{{device}}")], - gp(0, 66, 8, 7), minval=0, + gp(0, 83, 8, 7), minval=0, desc="Non-zero means smartctl itself had trouble reading the device. Bit 0 is " "a command-line problem, bit 1 a device-open failure, bit 3 a failing " "drive.", @@ -574,14 +684,14 @@ panels.append(row("Collector Health", 65, collapsed=True, children=[ timeseries( "Self-Test Collector Duration", [(f'smart_selftest_collection_duration_seconds{{{HOST}}}', "collection")], - gp(8, 66, 8, 7), unit="s", minval=0, + gp(8, 83, 8, 7), unit="s", minval=0, ), timeseries( "Devices Seen", [ (f'smartctl_devices{{{HOST}}}', "exporter"), (f'smart_selftest_devices{{{HOST}}}', "self-test collector"), ], - gp(16, 66, 8, 7), minval=0, + gp(16, 83, 8, 7), minval=0, desc="These should agree. A divergence means one of the two cannot see some " "drives.", ), @@ -610,6 +720,8 @@ print(json.dumps(dashboard( label="SATA drive", regex=ROW_REGEX), query_var("sas_device", DEVICE_QUERY.format(bus="sas"), label="SAS drive", regex=ROW_REGEX), + query_var("nvme_device", DEVICE_QUERY.format(bus="nvme"), + label="NVMe drive", regex=ROW_REGEX), ], tags=["smart", "storage", "hardware"], ), indent=2)) |
