Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ These S.M.A.R.T hard drive self-tests can help you detect and replace failing ha
- **Mobile-Optimized Interface** - Bottom tab bar (Home, Drives, ZFS, Workload, Settings), health overview home tab, card-based data views, and responsive layouts below 960px
- **API Timeout Configuration** - Adjust timeouts for slow storage systems
- **Performance Benchmarking** - fio-based benchmarks for throughput, IOPS, and latency with historical tracking
- **ATA Self-Test History** - Persist and display recent ATA SMART self-test log entries on device detail pages
- **SMART Self-Test History** - Persist and display recent ATA and SCSI/SAS SMART self-test log entries on device detail pages
- **Scheduled Reports** [WIP] - Automated health reports on daily/weekly/monthly schedules with HTML emails and PDF export
- **Missed Ping Digest** - Batch notification when multiple collectors go unreachable
- **HTML Email Notifications** - Rich HTML formatting with plain-text fallback for SMTP notifications, including reports, test notifications, collector errors, missed ping digests, heartbeat, performance degradation, replacement risk, and MDADM degradation alerts
Expand Down Expand Up @@ -519,7 +519,7 @@ Use the duration selector to view day, week, month, or year ranges.

## Workload Insights

Scrutiny computes drive workload statistics from existing S.M.A.R.T attribute history. No additional collector configuration is required -- once at least two data points exist for a device, workload insights are available.
Scrutiny computes drive workload statistics from existing S.M.A.R.T attribute and device-log history. No additional collector configuration is required -- once at least two data points exist for a device, workload insights are available.

### What's Computed

Expand All @@ -534,7 +534,7 @@ Scrutiny computes drive workload statistics from existing S.M.A.R.T attribute hi

### Computation Details

1. Scrutiny queries cumulative SMART counters (Total LBAs Written/Read for ATA, Data Units Written/Read for NVMe) from InfluxDB
1. Scrutiny queries cumulative read/write counters from InfluxDB: Total LBAs Written/Read for ATA, Data Units Written/Read for NVMe, and gigabytes-processed fields from SCSI/SAS Read/Write Error Counter log pages
2. The delta between the first and last data points in the selected time range is used to compute daily rates
3. Intensity is classified by total daily I/O: idle (<1 GB/day), light (1-20 GB), medium (20-100 GB), heavy (>100 GB)
4. SSD endurance is estimated from wear-leveling or percentage-used SMART attributes combined with power-on hours
Expand All @@ -545,7 +545,7 @@ Scrutiny computes drive workload statistics from existing S.M.A.R.T attribute hi

- **ATA**: Uses SMART attributes 241/242 (Total LBAs Written/Read) or DeviceStats 1.24/1.40 (Logical Sectors Written/Read)
- **NVMe**: Uses Data Units Written/Read counters
- **SCSI**: Limited support (cumulative byte counters are not stored as SMART attributes)
- **SCSI/SAS**: Uses the "gigabytes processed" fields from the SCSI Read/Write Error Counter log pages as cumulative read/write counters. SAS SSDs that report the Solid State Media log page's "Percentage used endurance indicator" (`endurance_used`) also get endurance/percentage-used tracking, matching NVMe. Not all SAS drives report these fields, in which case workload/endurance data is unavailable for that device.

### Viewing Workload Data

Expand All @@ -561,18 +561,19 @@ Some SSDs report misleading wear percentages through SMART. To supplement those

This value is user-supplied and does not replace the existing SMART wear indicators.

## ATA SMART Self-Test History
## SMART Self-Test History

Scrutiny now retains ATA SMART self-test log entries that are already present in uploaded `smartctl` payloads. On ATA drive detail pages, the web UI shows a **SMART Self-Tests** card with the recorded test type, pass/attention status, controller detail string, and power-on age.
Scrutiny now retains ATA and SCSI/SAS SMART self-test log entries that are already present in uploaded `smartctl` payloads. On ATA and SCSI/SAS drive detail pages, the web UI shows a **SMART Self-Tests** card with the recorded test type, pass/attention status, controller detail string, and power-on age.

- Data is read from normal SMART uploads; no separate self-test collector is required
- Only ATA devices show this section today
- Scrutiny keeps the most recent 21 entries per physical ATA device identity using collection time and controller log position, including when lifetime hours wrap
- Raw controller hours remain available. Absolute power-on ages are shown only when the current Power-On Hours and log order identify one rollover epoch; otherwise the UI shows "Unknown (may be wrapped)"
- Existing history remains intact during upgrade. Ages stay unknown until a new collection provides enough context; already pruned or overwritten records cannot be recovered
- Both ATA (`AtaSmartSelfTestLog`) and SCSI/SAS (`scsi_self_test_N` log entries) devices show this section
- Scrutiny keeps the most recent 21 entries per physical device identity using collection time and controller log position, including when ATA lifetime hours wrap
- Raw controller hours remain available. SCSI/SAS entries report smartctl's absolute, non-wrapping `accumulated_power_on_hours`, so their power-on age is always known. ATA entries use a 16-bit lifetime-hours counter that can wrap; for those, an absolute power-on age is shown only when the current Power-On Hours and log order identify one rollover epoch, otherwise the UI shows "Unknown (may be wrapped)"
- Existing history remains intact during upgrade. ATA ages stay unknown until a new collection provides enough context; already pruned or overwritten records cannot be recovered
- Rolling back to a web release that uses the old raw-lifetime uniqueness key requires restoring a pre-upgrade database backup
- The API route `GET /api/device/{id}/selftest` returns the same history used by the device detail page
- This feature records and displays history only; it does not trigger or schedule drive self-tests from the web UI
- See [PR #848](https://github.com/Starosdev/scrutiny/pull/848) for background on SCSI/SAS self-test history and endurance handling

## SMART Attribute Overrides

Expand Down
1 change: 1 addition & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ curl -X POST -H "Content-Type: application/json" \
| `smart-scsi2.json` | SCSI | SCSI drive (variant) |
| `smart-scsi-failed.json` | SCSI | SCSI drive with failures |
| `smart-scsi-sas-env-temp.json` | SCSI | SCSI SAS drive with environment temp |
| `smart-scsi-sas-ssd.json` | SCSI | SAS SSD with `endurance_used` and `gigabytes_processed` |
| `smart-megaraid0.json` | ATA | MegaRAID virtual disk |
| `smart-sat.json` | ATA | SAT (SCSI-to-ATA Translation) device |

Expand Down
9 changes: 8 additions & 1 deletion collector/pkg/detect/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,14 @@ func (d *Detect) SmartCtlInfo(device *models.Device) error {
device.ModelName = availableDeviceInfo.ModelName
device.InterfaceSpeed = availableDeviceInfo.InterfaceSpeed.Current.String
device.SerialNumber = availableDeviceInfo.SerialNumber
device.Firmware = availableDeviceInfo.FirmwareVersion
// Only overwrite a previously known firmware/revision when this run actually
// reported one; smartctl omits both firmware_version and scsi_revision when
// the drive doesn't answer that inquiry (e.g. transient errors, permissions,
// or invocation args that didn't include -i/-x), and we don't want to blank
// out a value we already have on file.
if firmware := availableDeviceInfo.Firmware(); firmware != "" {
device.Firmware = firmware
}
device.RotationSpeed = availableDeviceInfo.RotationRate
device.Capacity = availableDeviceInfo.Capacity()
device.FormFactor = availableDeviceInfo.FormFactor.Name
Expand Down
36 changes: 36 additions & 0 deletions collector/pkg/detect/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,42 @@ func TestDetect_SmartCtlInfo(t *testing.T) {
assert.Equal(t, "jmb39x-q,0", someDevice.DeviceType)
})

// SCSI/SAS drives don't populate "firmware_version" in smartctl's JSON
// output; the equivalent value is reported under "scsi_revision" instead.
// SmartCtlInfo must fall back to it so device.Firmware is not left blank.
t.Run("should populate firmware from scsi_revision for a SAS SSD", func(t *testing.T) {
fakeShell, fakeConfig, someLogger := setupSmartCtlInfoArgvMocks(t, "sdp",
[]string{"--info", "--json", "/dev/sdp"},
"testdata/smartctl_info_sas_ssd.json", nil, nil)

d := detect.Detect{Logger: someLogger, Shell: fakeShell, Config: fakeConfig}
someDevice := &models.Device{DeviceName: "sdp"}

require.NoError(t, d.SmartCtlInfo(someDevice))

assert.Equal(t, "HPE VO003840JWZJK", someDevice.ModelName)
assert.Equal(t, "ABCDEFGHIJKLMNOP", someDevice.SerialNumber)
assert.Equal(t, "HPD5", someDevice.Firmware)
assert.Equal(t, "SCSI", someDevice.DeviceProtocol)
})

// A run where smartctl reports neither firmware_version nor scsi_revision
// (e.g. transient error, permissions, or args without -i/-x) must not blank
// out a firmware value already on file for the device.
t.Run("should not clear a previously known firmware when missing from payload", func(t *testing.T) {
fakeShell, fakeConfig, someLogger := setupSmartCtlInfoArgvMocks(t, "sdp",
[]string{"--info", "--json", "/dev/sdp"},
"testdata/smartctl_info_sas_ssd_no_revision.json", nil, nil)

d := detect.Detect{Logger: someLogger, Shell: fakeShell, Config: fakeConfig}
someDevice := &models.Device{DeviceName: "sdp", Firmware: "HPD5"}

require.NoError(t, d.SmartCtlInfo(someDevice))

assert.Equal(t, "HPE VO003840JWZJK", someDevice.ModelName)
assert.Equal(t, "HPD5", someDevice.Firmware)
})

t.Run("should reject output on a fatal exit status", func(t *testing.T) {
fakeShell, fakeConfig, someLogger := setupSmartCtlInfoMocks(t, "sda", "jmb39x-q,0",
"testdata/smartctl_info_jmb39x_exit4.json", exitErrorWithCode(t, 2))
Expand Down
70 changes: 70 additions & 0 deletions collector/pkg/detect/testdata/smartctl_info_sas_ssd.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"json_format_version": [
1,
0
],
"smartctl": {
"version": [
7,
5
],
"pre_release": false,
"svn_revision": "5714",
"platform_info": "x86_64-linux-7.0.14-9-pve",
"build_info": "(local build)",
"argv": [
"smartctl",
"--info",
"--json",
"--device",
"scsi",
"/dev/sdp"
],
"exit_status": 0
},
"local_time": {
"time_t": 1789148192,
"asctime": "Fri Sep 11 13:36:32 2026 EDT"
},
"device": {
"name": "/dev/sdp",
"info_name": "/dev/sdp",
"type": "scsi",
"protocol": "SCSI"
},
"scsi_vendor": "HPE",
"scsi_product": "VO003840JWZJK",
"model_name": "HPE VO003840JWZJK",
"scsi_model_name": "HPE VO003840JWZJK",
"scsi_revision": "HPD5",
"scsi_version": "SPC-5",
"user_capacity": {
"blocks": 7501476528,
"bytes": 3840755982336
},
"logical_block_size": 512,
"physical_block_size": 4096,
"rotation_rate": 0,
"form_factor": {
"scsi_value": 3,
"name": "2.5 inches"
},
"logical_unit_id": "0x50000f0b005750f0",
"serial_number": "ABCDEFGHIJKLMNOP",
"device_type": {
"scsi_terminology": "Peripheral Device Type [PDT]",
"scsi_value": 0,
"name": "disk"
},
"scsi_transport_protocol": {
"name": "SAS (SPL-4)",
"value": 6
},
"smart_support": {
"available": true,
"enabled": true
},
"smart_status": {
"passed": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"json_format_version": [
1,
0
],
"smartctl": {
"version": [
7,
5
],
"pre_release": false,
"svn_revision": "5714",
"platform_info": "x86_64-linux-7.0.14-9-pve",
"build_info": "(local build)",
"argv": [
"smartctl",
"-H",
"-l",
"error",
"--json",
"--device",
"scsi",
"/dev/sdp"
],
"exit_status": 0
},
"local_time": {
"time_t": 1789148192,
"asctime": "Fri Sep 11 13:36:32 2026 EDT"
},
"device": {
"name": "/dev/sdp",
"info_name": "/dev/sdp",
"type": "scsi",
"protocol": "SCSI"
},
"model_name": "HPE VO003840JWZJK",
"user_capacity": {
"blocks": 7501476528,
"bytes": 3840755982336
},
"logical_block_size": 512,
"physical_block_size": 4096,
"rotation_rate": 0,
"serial_number": "ABCDEFGHIJKLMNOP",
"smart_support": {
"available": true,
"enabled": true
},
"smart_status": {
"passed": true
}
}
16 changes: 16 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1809,6 +1809,15 @@ components:
type: string
firmware_version:
type: string
description: >-
Firmware/revision string reported by smartctl. Only populated for
ATA/SAT and NVMe devices; SCSI/SAS devices report this value under
scsi_revision instead.
scsi_revision:
type: string
description: >-
SCSI/SAS equivalent of firmware_version, reported by smartctl for
plain SCSI/SAS devices (e.g. "-i"/"-x" output).
wwn:
type: object
properties:
Expand Down Expand Up @@ -1915,6 +1924,13 @@ components:
type: object
additionalProperties:
$ref: "#/components/schemas/ScsiTemperatureData"
endurance_used:
type: object
description: SAS SSD "Percentage used endurance indicator" from the Solid State Media log page (0x11). Only reported by solid-state SAS drives.
properties:
current_percent:
type: integer
format: int64
seagate_farm_log:
$ref: "#/components/schemas/SeagateFarmLog"
additionalProperties: true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,35 @@ func selfTestLifetimeBounds(entries []collector.AtaSmartSelfTestLogEntry, powerO
return minimum, maximum
}

// selfTestAbsoluteBounds treats each entry's LifetimeHours as an already-absolute hour count
// with no rollover ambiguity. Unlike ATA's 16-bit lifetime-hours field, SCSI/SAS self-test log
// entries report smartctl's "accumulated_power_on_hours" value, which does not wrap at 65536,
// so entries at or above that value still have a single, exact effective age.
func selfTestAbsoluteBounds(entries []collector.AtaSmartSelfTestLogEntry) ([]int64, []int64) {
minimum := make([]int64, len(entries))
maximum := make([]int64, len(entries))
for i, entry := range entries {
if entry.LifetimeHours < 0 {
minimum[i], maximum[i] = 0, -1
continue
}
hours := int64(entry.LifetimeHours)
minimum[i], maximum[i] = hours, hours
}
return minimum, maximum
}

func (sr *scrutinyRepository) syncDeviceSelfTests(ctx context.Context, device *models.Device, collectorSmartData *collector.SmartInfo, powerOnHours int64) error {
if collectorSmartData.Device.Protocol != pkg.DeviceProtocolAta {
if collectorSmartData.Device.Protocol != pkg.DeviceProtocolAta && collectorSmartData.Device.Protocol != pkg.DeviceProtocolScsi {
return nil
}
entries := collectorSmartData.AtaSmartSelfTestLog.Entries()
// SelfTestEntries() normalizes both the ATA self-test log (a JSON array) and
// the SCSI/SAS self-test log (individually numbered "scsi_self_test_N" keys)
// into the same shape; the dedup logic below is protocol-agnostic. Resolving
// effective lifetime hours is not: ATA's 16-bit counter can wrap and needs
// epoch resolution against powerOnHours, while SCSI/SAS reports an absolute,
// non-wrapping accumulated power-on-hours value that needs no such resolution.
entries := collectorSmartData.SelfTestEntries()
if len(entries) == 0 {
return nil
}
Expand All @@ -75,7 +99,12 @@ func (sr *scrutinyRepository) syncDeviceSelfTests(ctx context.Context, device *m
if observedAt <= 0 {
observedAt = time.Now().Unix()
}
minimum, maximum := selfTestLifetimeBounds(entries, powerOnHours)
var minimum, maximum []int64
if collectorSmartData.Device.Protocol == pkg.DeviceProtocolScsi {
minimum, maximum = selfTestAbsoluteBounds(entries)
} else {
minimum, maximum = selfTestLifetimeBounds(entries, powerOnHours)
}

return sr.gormClient.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var existing []models.DeviceSelfTest
Expand Down
Loading
Loading