nix

configuration files that power my machines
Log | Files | Refs | README | LICENSE

zfs_exporter.jsonnet (25100B)


      1 // ZFS Exporter dashboard (pdf/zfs_exporter)
      2 
      3 local g = import 'github.com/grafana/grafonnet/gen/grafonnet-latest/main.libsonnet';
      4 
      5 local dashboard = g.dashboard;
      6 local var = dashboard.variable;
      7 local ts = g.panel.timeSeries;
      8 local stat = g.panel.stat;
      9 local gauge = g.panel.gauge;
     10 local barGauge = g.panel.barGauge;
     11 local table = g.panel.table;
     12 local row = g.panel.row;
     13 local prometheus = g.query.prometheus;
     14 
     15 // ---------------------------------------------------------------------------
     16 // Shared selectors and datasource reference
     17 // ---------------------------------------------------------------------------
     18 local dsRef = { type: 'prometheus', uid: '${datasource}' };
     19 local poolSel = '{instance=~"$instance",pool=~"$pool"}';
     20 local dsSel = '{type="filesystem",instance=~"$instance",pool=~"$pool",name=~"$dataset"}';
     21 
     22 // ---------------------------------------------------------------------------
     23 // Threshold step sets and value mappings
     24 // ---------------------------------------------------------------------------
     25 local greenOnly = [{ color: 'green', value: null }];
     26 local usedSteps = [
     27   { color: 'green', value: null },
     28   { color: 'yellow', value: 75 },
     29   { color: 'orange', value: 85 },
     30   { color: 'red', value: 90 },
     31 ];
     32 local healthyStep = [{ color: 'red', value: null }, { color: 'green', value: 1 }];
     33 local fragSteps = [
     34   { color: 'green', value: null },
     35   { color: 'yellow', value: 0.4 },
     36   { color: 'red', value: 0.7 },
     37 ];
     38 local compSteps = [
     39   { color: 'blue', value: null },
     40   { color: 'green', value: 1.1 },
     41   { color: 'super-light-green', value: 2 },
     42 ];
     43 
     44 local healthMap = [{
     45   type: 'value',
     46   options: {
     47     '0': { text: 'ONLINE', color: 'green', index: 0 },
     48     '1': { text: 'DEGRADED', color: 'orange', index: 1 },
     49     '2': { text: 'FAULTED', color: 'red', index: 2 },
     50     '3': { text: 'OFFLINE', color: 'red', index: 3 },
     51     '4': { text: 'UNAVAIL', color: 'red', index: 4 },
     52     '5': { text: 'REMOVED', color: 'red', index: 5 },
     53     '6': { text: 'SUSPENDED', color: 'red', index: 6 },
     54   },
     55 }];
     56 local okFailMap = [{
     57   type: 'value',
     58   options: {
     59     '0': { text: 'FAILED', color: 'red', index: 0 },
     60     '1': { text: 'OK', color: 'green', index: 1 },
     61   },
     62 }];
     63 local rwroMap = [{
     64   type: 'value',
     65   options: {
     66     '0': { text: 'read-write', color: 'green', index: 0 },
     67     '1': { text: 'read-only', color: 'orange', index: 1 },
     68   },
     69 }];
     70 local upDownMap = [{
     71   type: 'value',
     72   options: {
     73     '0': { text: 'DOWN', color: 'red', index: 0 },
     74     '1': { text: 'UP', color: 'green', index: 1 },
     75   },
     76 }];
     77 
     78 // ---------------------------------------------------------------------------
     79 // Query helper
     80 // ---------------------------------------------------------------------------
     81 local prom(expr, legend=null, refId='A', instant=false) =
     82   prometheus.new('${datasource}', expr)
     83   + prometheus.withRefId(refId)
     84   + prometheus.withEditorMode('code')
     85   + (if legend != null then prometheus.withLegendFormat(legend) else {})
     86   + (
     87     if instant then
     88       prometheus.withInstant(true)
     89       + prometheus.withFormat('table')
     90       + prometheus.withRange(false)
     91     else prometheus.withRange(true)
     92   );
     93 
     94 // ---------------------------------------------------------------------------
     95 // Panel helpers
     96 // ---------------------------------------------------------------------------
     97 local tsPanel(title, gp, targets, unit='bytes', steps=greenOnly, mappings=[], min=null, max=null, decimals=null, desc=null, fill=10, stack='none', interp='smooth', threshStyle='off', calcs=['lastNotNull', 'max', 'mean']) =
     98   ts.new(title)
     99   + ts.panelOptions.withGridPos(gp.h, gp.w, gp.x, gp.y)
    100   + { datasource: dsRef }
    101   + ts.queryOptions.withTargets(targets)
    102   + ts.standardOptions.withUnit(unit)
    103   + ts.standardOptions.color.withMode('palette-classic')
    104   + ts.standardOptions.thresholds.withMode('absolute')
    105   + ts.standardOptions.thresholds.withSteps(steps)
    106   + ts.standardOptions.withMappings(mappings)
    107   + ts.fieldConfig.defaults.custom.withDrawStyle('line')
    108   + ts.fieldConfig.defaults.custom.withFillOpacity(fill)
    109   + ts.fieldConfig.defaults.custom.withGradientMode('opacity')
    110   + ts.fieldConfig.defaults.custom.withLineWidth(2)
    111   + ts.fieldConfig.defaults.custom.withLineInterpolation(interp)
    112   + ts.fieldConfig.defaults.custom.withShowPoints('never')
    113   + ts.fieldConfig.defaults.custom.withSpanNulls(false)
    114   + ts.fieldConfig.defaults.custom.withStacking({ mode: stack, group: 'A' })
    115   + ts.fieldConfig.defaults.custom.thresholdsStyle.withMode(threshStyle)
    116   + ts.options.legend.withDisplayMode('table')
    117   + ts.options.legend.withPlacement('bottom')
    118   + ts.options.legend.withShowLegend(true)
    119   + ts.options.legend.withCalcs(calcs)
    120   + ts.options.tooltip.withMode('multi')
    121   + ts.options.tooltip.withSort('desc')
    122   + (if min != null then ts.standardOptions.withMin(min) else {})
    123   + (if max != null then ts.standardOptions.withMax(max) else {})
    124   + (if decimals != null then ts.standardOptions.withDecimals(decimals) else {})
    125   + (if desc != null then ts.panelOptions.withDescription(desc) else {});
    126 
    127 local statPanel(title, gp, targets, unit='none', mappings=[], steps=greenOnly, colorMode='value', textMode='auto', graphMode='area', decimals=null, calc='lastNotNull', desc=null) =
    128   stat.new(title)
    129   + stat.panelOptions.withGridPos(gp.h, gp.w, gp.x, gp.y)
    130   + { datasource: dsRef }
    131   + stat.queryOptions.withTargets(targets)
    132   + stat.standardOptions.withUnit(unit)
    133   + stat.standardOptions.withMappings(mappings)
    134   + stat.standardOptions.color.withMode('thresholds')
    135   + stat.standardOptions.thresholds.withMode('absolute')
    136   + stat.standardOptions.thresholds.withSteps(steps)
    137   + stat.options.withColorMode(colorMode)
    138   + stat.options.withGraphMode(graphMode)
    139   + stat.options.withTextMode(textMode)
    140   + stat.options.withJustifyMode('auto')
    141   + stat.options.withOrientation('auto')
    142   + stat.options.reduceOptions.withCalcs([calc])
    143   + stat.options.reduceOptions.withFields('')
    144   + stat.options.reduceOptions.withValues(false)
    145   + (if decimals != null then stat.standardOptions.withDecimals(decimals) else {})
    146   + (if desc != null then stat.panelOptions.withDescription(desc) else {});
    147 
    148 local gaugePanel(title, gp, targets, unit='percent', steps=usedSteps, min=0, max=100, desc=null) =
    149   gauge.new(title)
    150   + gauge.panelOptions.withGridPos(gp.h, gp.w, gp.x, gp.y)
    151   + { datasource: dsRef }
    152   + gauge.queryOptions.withTargets(targets)
    153   + gauge.standardOptions.withUnit(unit)
    154   + gauge.standardOptions.withMin(min)
    155   + gauge.standardOptions.withMax(max)
    156   + gauge.standardOptions.color.withMode('thresholds')
    157   + gauge.standardOptions.thresholds.withMode('absolute')
    158   + gauge.standardOptions.thresholds.withSteps(steps)
    159   + gauge.options.withShowThresholdLabels(false)
    160   + gauge.options.withShowThresholdMarkers(true)
    161   + gauge.options.withOrientation('auto')
    162   + gauge.options.reduceOptions.withCalcs(['lastNotNull'])
    163   + gauge.options.reduceOptions.withFields('')
    164   + gauge.options.reduceOptions.withValues(false)
    165   + (if desc != null then gauge.panelOptions.withDescription(desc) else {});
    166 
    167 local barGaugePanel(title, gp, targets, unit='bytes', colorMode='continuous-GrYlRd', steps=greenOnly, display='gradient', orientation='horizontal', min=null, max=null, decimals=null, desc=null) =
    168   barGauge.new(title)
    169   + barGauge.panelOptions.withGridPos(gp.h, gp.w, gp.x, gp.y)
    170   + { datasource: dsRef }
    171   + barGauge.queryOptions.withTargets(targets)
    172   + barGauge.standardOptions.withUnit(unit)
    173   + barGauge.standardOptions.color.withMode(colorMode)
    174   + barGauge.standardOptions.thresholds.withMode('absolute')
    175   + barGauge.standardOptions.thresholds.withSteps(steps)
    176   + barGauge.options.withDisplayMode(display)
    177   + barGauge.options.withOrientation(orientation)
    178   + barGauge.options.withShowUnfilled(true)
    179   + barGauge.options.withValueMode('color')
    180   + barGauge.options.reduceOptions.withCalcs(['lastNotNull'])
    181   + barGauge.options.reduceOptions.withFields('')
    182   + barGauge.options.reduceOptions.withValues(false)
    183   + (if min != null then barGauge.standardOptions.withMin(min) else {})
    184   + (if max != null then barGauge.standardOptions.withMax(max) else {})
    185   + (if decimals != null then barGauge.standardOptions.withDecimals(decimals) else {})
    186   + (if desc != null then barGauge.panelOptions.withDescription(desc) else {});
    187 
    188 local tablePanel(title, gp, targets, transformations, overrides, sortBy, footer, desc) =
    189   table.new(title)
    190   + table.panelOptions.withGridPos(gp.h, gp.w, gp.x, gp.y)
    191   + { datasource: dsRef }
    192   + table.panelOptions.withDescription(desc)
    193   + table.queryOptions.withTargets(targets)
    194   + table.queryOptions.withTransformations(transformations)
    195   + table.standardOptions.withUnit('bytes')
    196   + table.standardOptions.color.withMode('thresholds')
    197   + table.standardOptions.thresholds.withMode('absolute')
    198   + table.standardOptions.thresholds.withSteps(greenOnly)
    199   + table.standardOptions.withOverrides(overrides)
    200   + { fieldConfig+: { defaults+: { custom+: {
    201     align: 'auto',
    202     cellOptions: { type: 'auto' },
    203     filterable: true,
    204     inspect: false,
    205   } } } }
    206   + table.options.withShowHeader(true)
    207   + table.options.withCellHeight('sm')
    208   + table.options.withFooter(footer)
    209   + table.options.withSortBy(sortBy);
    210 
    211 local rowPanel(title, y) =
    212   row.new(title)
    213   + row.withGridPos(y)
    214   + row.withCollapsed(false);
    215 
    216 local override(name, properties) = {
    217   matcher: { id: 'byName', options: name },
    218   properties: properties,
    219 };
    220 
    221 // ---------------------------------------------------------------------------
    222 // Variables
    223 // ---------------------------------------------------------------------------
    224 local datasourceVar =
    225   var.datasource.new('datasource', 'prometheus')
    226   + var.datasource.generalOptions.withLabel('Data source')
    227   + var.datasource.withRegex('')
    228   + { current: { selected: false, text: 'VictoriaMetrics', value: 'DS_PROMETHEUS' } };
    229 
    230 local queryVar(name, label, expr, valueLabel) =
    231   var.query.new(name)
    232   + var.query.withDatasourceFromVariable(datasourceVar)
    233   + var.query.queryTypes.withLabelValues(valueLabel, expr)
    234   + var.query.generalOptions.withLabel(label)
    235   + var.query.selectionOptions.withMulti(true)
    236   + var.query.selectionOptions.withIncludeAll(true)
    237   + var.query.withRefresh('time')
    238   + var.query.withSort(1);
    239 
    240 local instanceVar = queryVar('instance', 'Instance', 'zfs_pool_health', 'instance');
    241 local poolVar = queryVar('pool', 'Pool', 'zfs_pool_health{instance=~"$instance"}', 'pool');
    242 local datasetVar = queryVar('dataset', 'Dataset', 'zfs_dataset_used_bytes{type="filesystem",instance=~"$instance",pool=~"$pool"}', 'name');
    243 
    244 // ---------------------------------------------------------------------------
    245 // Panels
    246 // ---------------------------------------------------------------------------
    247 local panels = [
    248   //=== Pool Overview =======================================================
    249   rowPanel('Pool Overview', 0),
    250 
    251   statPanel('Pools', { h: 4, w: 3, x: 0, y: 1 },
    252             [prom('count(zfs_pool_health%s)' % poolSel)],
    253             unit='none', graphMode='none',
    254             desc='Number of ZFS pools matched by the current filters.'),
    255 
    256   statPanel('Pools Healthy', { h: 4, w: 3, x: 3, y: 1 },
    257             [prom('count(zfs_pool_health%s == 0) or vector(0)' % poolSel)],
    258             unit='none', graphMode='none', steps=healthyStep,
    259             desc='Pools currently in the ONLINE (healthy) state.'),
    260 
    261   statPanel('Total Capacity', { h: 4, w: 3, x: 6, y: 1 },
    262             [prom('sum(zfs_pool_size_bytes%s)' % poolSel)],
    263             unit='bytes', graphMode='none'),
    264 
    265   statPanel('Allocated', { h: 4, w: 3, x: 9, y: 1 },
    266             [prom('sum(zfs_pool_allocated_bytes%s)' % poolSel)],
    267             unit='bytes'),
    268 
    269   statPanel('Free', { h: 4, w: 3, x: 12, y: 1 },
    270             [prom('sum(zfs_pool_free_bytes%s)' % poolSel)],
    271             unit='bytes'),
    272 
    273   gaugePanel('Used %', { h: 4, w: 3, x: 15, y: 1 },
    274              [prom('sum(zfs_pool_allocated_bytes%s) / sum(zfs_pool_size_bytes%s) * 100' % [poolSel, poolSel])],
    275              desc='Aggregate space used across all matched pools.'),
    276 
    277   statPanel('Datasets', { h: 4, w: 3, x: 18, y: 1 },
    278             [prom('count(zfs_dataset_used_bytes{type="filesystem",instance=~"$instance",pool=~"$pool"})')],
    279             unit='none', graphMode='none'),
    280 
    281   statPanel('Exporter', { h: 4, w: 3, x: 21, y: 1 },
    282             [prom('min(up{job="zfs_exporter",instance=~"$instance"})')],
    283             unit='none', graphMode='none', mappings=upDownMap,
    284             colorMode='background', textMode='value',
    285             desc='Scrape state of the zfs_exporter target(s).'),
    286 
    287   //=== Pool Health & Capacity ==============================================
    288   rowPanel('Pool Health & Capacity', 5),
    289 
    290   tablePanel(
    291     'Pool Status', { h: 9, w: 15, x: 0, y: 6 },
    292     [
    293       prom('max by (pool) (zfs_pool_health%s)' % poolSel, refId='A', instant=true),
    294       prom('max by (pool) (zfs_pool_size_bytes%s)' % poolSel, refId='B', instant=true),
    295       prom('max by (pool) (zfs_pool_allocated_bytes%s)' % poolSel, refId='C', instant=true),
    296       prom('max by (pool) (zfs_pool_free_bytes%s)' % poolSel, refId='D', instant=true),
    297       prom('max by (pool) (zfs_pool_allocated_bytes%s / zfs_pool_size_bytes%s * 100)' % [poolSel, poolSel], refId='E', instant=true),
    298       prom('max by (pool) (zfs_pool_fragmentation_ratio%s)' % poolSel, refId='F', instant=true),
    299       prom('max by (pool) (zfs_pool_deduplication_ratio%s)' % poolSel, refId='G', instant=true),
    300       prom('max by (pool) (zfs_pool_readonly%s)' % poolSel, refId='H', instant=true),
    301     ],
    302     [
    303       { id: 'joinByField', options: { byField: 'pool', mode: 'outer' } },
    304       { id: 'organize', options: {
    305         excludeByName: { Time: true, 'Time 1': true, 'Time 2': true, 'Time 3': true, 'Time 4': true, 'Time 5': true, 'Time 6': true, 'Time 7': true, 'Time 8': true },
    306         renameByName: {
    307           pool: 'Pool',
    308           'Value #A': 'Health',
    309           'Value #B': 'Size',
    310           'Value #C': 'Allocated',
    311           'Value #D': 'Free',
    312           'Value #E': 'Used %',
    313           'Value #F': 'Fragmentation',
    314           'Value #G': 'Dedup',
    315           'Value #H': 'Read-only',
    316         },
    317         indexByName: { pool: 0, 'Value #A': 1, 'Value #B': 2, 'Value #C': 3, 'Value #D': 4, 'Value #E': 5, 'Value #F': 6, 'Value #G': 7, 'Value #H': 8 },
    318       } },
    319     ],
    320     [
    321       override('Health', [
    322         { id: 'mappings', value: healthMap },
    323         { id: 'custom.cellOptions', value: { type: 'color-text' } },
    324         { id: 'custom.width', value: 110 },
    325       ]),
    326       override('Used %', [
    327         { id: 'unit', value: 'percent' },
    328         { id: 'decimals', value: 1 },
    329         { id: 'custom.cellOptions', value: { type: 'gauge', mode: 'gradient' } },
    330         { id: 'min', value: 0 },
    331         { id: 'max', value: 100 },
    332         { id: 'thresholds', value: { mode: 'absolute', steps: usedSteps } },
    333       ]),
    334       override('Fragmentation', [
    335         { id: 'unit', value: 'percentunit' },
    336         { id: 'decimals', value: 1 },
    337         { id: 'custom.cellOptions', value: { type: 'color-text' } },
    338         { id: 'thresholds', value: { mode: 'absolute', steps: fragSteps } },
    339       ]),
    340       override('Dedup', [
    341         { id: 'unit', value: 'none' },
    342         { id: 'decimals', value: 2 },
    343       ]),
    344       override('Read-only', [
    345         { id: 'mappings', value: rwroMap },
    346         { id: 'custom.cellOptions', value: { type: 'color-text' } },
    347       ]),
    348     ],
    349     [{ displayName: 'Used %', desc: true }],
    350     { show: false, reducer: ['sum'], countRows: false, fields: '' },
    351     'Per-pool health, capacity, fragmentation and dedup at a glance.'
    352   ),
    353 
    354   barGaugePanel(
    355     'Capacity Used per Pool', { h: 9, w: 9, x: 15, y: 6 },
    356     [prom('zfs_pool_allocated_bytes%s / zfs_pool_size_bytes%s * 100' % [poolSel, poolSel], legend='{{pool}}')],
    357     unit='percent', colorMode='thresholds', steps=usedSteps, display='lcd',
    358     min=0, max=100, decimals=1,
    359     desc='Fill percentage per pool. ZFS performance degrades sharply above ~80%.'
    360   ),
    361 
    362   //=== Capacity Trends =====================================================
    363   rowPanel('Capacity Trends', 15),
    364 
    365   tsPanel('Pool Used % Over Time', { h: 8, w: 12, x: 0, y: 16 },
    366           [prom('zfs_pool_allocated_bytes%s / zfs_pool_size_bytes%s * 100' % [poolSel, poolSel], legend='{{pool}}')],
    367           unit='percent', min=0, max=100, steps=usedSteps, threshStyle='dashed',
    368           desc='Capacity fill trend with the 90% danger threshold marked.'),
    369 
    370   tsPanel('Pool Free Space Trend', { h: 8, w: 12, x: 12, y: 16 },
    371           [
    372             prom('zfs_pool_free_bytes%s' % poolSel, legend='{{pool}} free', refId='A'),
    373             prom('zfs_pool_allocated_bytes%s' % poolSel, legend='{{pool}} allocated', refId='B'),
    374           ],
    375           unit='bytes',
    376           desc='Absolute free vs. allocated space — useful for eyeballing time-to-full.'),
    377 
    378   tsPanel('Fragmentation', { h: 7, w: 8, x: 0, y: 24 },
    379           [prom('zfs_pool_fragmentation_ratio%s' % poolSel, legend='{{pool}}')],
    380           unit='percentunit', min=0, max=1,
    381           desc='Free-space fragmentation ratio per pool.'),
    382 
    383   tsPanel('Deduplication Ratio', { h: 7, w: 8, x: 8, y: 24 },
    384           [prom('zfs_pool_deduplication_ratio%s' % poolSel, legend='{{pool}}')],
    385           unit='none', decimals=2,
    386           desc='Dedup ratio (1.00 = no deduplication).'),
    387 
    388   tsPanel('Freeing & Leaked Bytes', { h: 7, w: 8, x: 16, y: 24 },
    389           [
    390             prom('zfs_pool_freeing_bytes%s' % poolSel, legend='{{pool}} freeing', refId='A'),
    391             prom('zfs_pool_leaked_bytes%s' % poolSel, legend='{{pool}} leaked', refId='B'),
    392           ],
    393           unit='bytes',
    394           desc='Space pending free after destroys, and leaked (unaccounted) bytes — both should trend to 0.'),
    395 
    396   //=== Datasets ============================================================
    397   rowPanel('Datasets', 31),
    398 
    399   tablePanel(
    400     'Dataset Usage', { h: 11, w: 15, x: 0, y: 32 },
    401     [
    402       prom('max by (name, pool) (zfs_dataset_used_bytes%s)' % dsSel, refId='A', instant=true),
    403       prom('max by (name) (zfs_dataset_available_bytes%s)' % dsSel, refId='B', instant=true),
    404       prom('max by (name) (zfs_dataset_referenced_bytes%s)' % dsSel, refId='C', instant=true),
    405       prom('max by (name) (zfs_dataset_logical_used_bytes%s)' % dsSel, refId='D', instant=true),
    406       prom('max by (name) (zfs_dataset_quota_bytes%s)' % dsSel, refId='E', instant=true),
    407       prom('max by (name) (zfs_dataset_used_bytes%s / (zfs_dataset_used_bytes%s + zfs_dataset_available_bytes%s) * 100)' % [dsSel, dsSel, dsSel], refId='F', instant=true),
    408       prom('max by (name) (zfs_dataset_logical_used_bytes%s / zfs_dataset_used_by_dataset_bytes%s)' % [dsSel, dsSel], refId='G', instant=true),
    409     ],
    410     [
    411       { id: 'joinByField', options: { byField: 'name', mode: 'outer' } },
    412       { id: 'organize', options: {
    413         excludeByName: { Time: true, 'Time 1': true, 'Time 2': true, 'Time 3': true, 'Time 4': true, 'Time 5': true, 'Time 6': true, 'Time 7': true },
    414         renameByName: {
    415           name: 'Dataset',
    416           pool: 'Pool',
    417           'Value #A': 'Used',
    418           'Value #B': 'Available',
    419           'Value #C': 'Referenced',
    420           'Value #D': 'Logical Used',
    421           'Value #E': 'Quota',
    422           'Value #F': 'Fill %',
    423           'Value #G': 'Compression',
    424         },
    425         indexByName: { name: 0, pool: 1, 'Value #A': 2, 'Value #F': 3, 'Value #B': 4, 'Value #C': 5, 'Value #D': 6, 'Value #E': 7, 'Value #G': 8 },
    426       } },
    427       { id: 'sortBy', options: { fields: '', sort: [{ field: 'Used', desc: true }] } },
    428     ],
    429     [
    430       override('Dataset', [{ id: 'custom.width', value: 240 }]),
    431       override('Pool', [{ id: 'custom.width', value: 80 }]),
    432       override('Used', [
    433         { id: 'unit', value: 'bytes' },
    434         { id: 'custom.cellOptions', value: { type: 'gauge', mode: 'gradient', valueDisplayMode: 'text' } },
    435       ]),
    436       override('Fill %', [
    437         { id: 'unit', value: 'percent' },
    438         { id: 'decimals', value: 1 },
    439         { id: 'custom.cellOptions', value: { type: 'color-background', mode: 'gradient' } },
    440         { id: 'thresholds', value: { mode: 'absolute', steps: usedSteps } },
    441       ]),
    442       override('Compression', [
    443         { id: 'unit', value: 'none' },
    444         { id: 'decimals', value: 2 },
    445         { id: 'custom.cellOptions', value: { type: 'color-text' } },
    446         { id: 'thresholds', value: { mode: 'absolute', steps: [{ color: 'text', value: null }, { color: 'green', value: 1.1 }] } },
    447       ]),
    448     ],
    449     [{ displayName: 'Used', desc: true }],
    450     { show: true, reducer: ['sum'], countRows: false, fields: ['Used', 'Logical Used'] },
    451     'Per-dataset space accounting, quota fill and compression ratio.'
    452   ),
    453 
    454   barGaugePanel(
    455     'Top Datasets by Used Space', { h: 11, w: 9, x: 15, y: 32 },
    456     [prom('topk(15, max by (name) (zfs_dataset_used_bytes%s))' % dsSel, legend='{{name}}')],
    457     unit='bytes', display='gradient',
    458     desc='Largest datasets by space consumed (incl. descendants).'
    459   ),
    460 
    461   //=== Dataset Trends ======================================================
    462   rowPanel('Dataset Trends', 43),
    463 
    464   tsPanel('Dataset Used Over Time', { h: 8, w: 12, x: 0, y: 44 },
    465           [prom('topk(20, max by (name) (zfs_dataset_used_bytes%s))' % dsSel, legend='{{name}}')],
    466           unit='bytes',
    467           desc='Growth of the top datasets over the selected window.'),
    468 
    469   tsPanel('Quota Fill', { h: 8, w: 12, x: 12, y: 44 },
    470           [prom('zfs_dataset_used_bytes%s / (zfs_dataset_quota_bytes%s > 0) * 100' % [dsSel, dsSel], legend='{{name}}')],
    471           unit='percent', min=0, max=100, steps=usedSteps, threshStyle='dashed',
    472           desc='Used space as a percentage of quota, for datasets that have a quota set.'),
    473 
    474   barGaugePanel(
    475     'Compression Ratio (logical / physical)', { h: 8, w: 12, x: 0, y: 52 },
    476     [prom('topk(15, max by (name) (zfs_dataset_logical_used_bytes%s / zfs_dataset_used_by_dataset_bytes%s))' % [dsSel, dsSel], legend='{{name}}')],
    477     unit='none', colorMode='thresholds', steps=compSteps, display='gradient', decimals=2,
    478     desc="How much each dataset's data shrinks on disk. 1.00 = incompressible."
    479   ),
    480 
    481   tsPanel('Written Since Last Snapshot', { h: 8, w: 12, x: 12, y: 52 },
    482           [prom('topk(15, max by (name) (zfs_dataset_written_bytes%s))' % dsSel, legend='{{name}}')],
    483           unit='bytes', fill=25, interp='linear',
    484           desc='Referenced bytes written since the previous snapshot — a proxy for recent write activity (resets on snapshot).'),
    485 
    486   //=== Exporter Health =====================================================
    487   rowPanel('Exporter Health', 60),
    488 
    489   statPanel('Collectors Healthy', { h: 5, w: 4, x: 0, y: 61 },
    490             [prom('count(zfs_scrape_collector_success{instance=~"$instance"} == 1) or vector(0)')],
    491             unit='none', graphMode='none', steps=healthyStep,
    492             desc='ZFS exporter sub-collectors reporting success.'),
    493 
    494   statPanel('Collector Status', { h: 5, w: 8, x: 4, y: 61 },
    495             [prom('zfs_scrape_collector_success{instance=~"$instance"}', legend='{{collector}}')],
    496             unit='none', graphMode='none', mappings=okFailMap,
    497             colorMode='background', textMode='value_and_name',
    498             desc='Success/failure per exporter collector.'),
    499 
    500   tsPanel('Collector Scrape Duration', { h: 5, w: 12, x: 12, y: 61 },
    501           [prom('zfs_scrape_collector_duration_seconds{instance=~"$instance"}', legend='{{collector}}')],
    502           unit='s',
    503           desc='Time each collector takes to gather metrics.'),
    504 
    505   statPanel('Goroutines', { h: 4, w: 4, x: 0, y: 66 },
    506             [prom('go_goroutines{job="zfs_exporter",instance=~"$instance"}')],
    507             unit='none'),
    508 
    509   statPanel('Heap In Use', { h: 4, w: 4, x: 4, y: 66 },
    510             [prom('go_memstats_heap_inuse_bytes{job="zfs_exporter",instance=~"$instance"}')],
    511             unit='bytes'),
    512 
    513   statPanel('Go Version', { h: 4, w: 4, x: 8, y: 66 },
    514             [prom('go_info{job="zfs_exporter",instance=~"$instance"}', legend='{{version}}')],
    515             unit='none', graphMode='none', textMode='name', colorMode='none'),
    516 
    517   tsPanel('Exporter Memory', { h: 4, w: 12, x: 12, y: 66 },
    518           [
    519             prom('go_memstats_heap_inuse_bytes{job="zfs_exporter",instance=~"$instance"}', legend='heap in-use', refId='A'),
    520             prom('go_memstats_stack_inuse_bytes{job="zfs_exporter",instance=~"$instance"}', legend='stack in-use', refId='B'),
    521             prom('go_memstats_sys_bytes{job="zfs_exporter",instance=~"$instance"}', legend='sys', refId='C'),
    522           ],
    523           unit='bytes', calcs=['lastNotNull', 'max']),
    524 ];
    525 
    526 // Assign stable, unique panel IDs.
    527 local panelsWithIds = std.mapWithIndex(function(i, p) p + { id: i + 1 }, panels);
    528 
    529 // ---------------------------------------------------------------------------
    530 // Dashboard
    531 // ---------------------------------------------------------------------------
    532 dashboard.new('ZFS Exporter')
    533 + dashboard.withUid('zfs-exporter')
    534 + dashboard.withTags(['zfs', 'storage', 'freebsd'])
    535 + dashboard.withRefresh('30s')
    536 + dashboard.withTimezone('browser')
    537 + dashboard.withVariables([datasourceVar, instanceVar, poolVar, datasetVar])
    538 + dashboard.withPanels(panelsWithIds)
    539 + { graphTooltip: 1, time: { from: 'now-24h', to: 'now' } }