Add drive SMART attributes and health status

This commit is contained in:
Josh Holtrop 2026-04-13 21:57:00 -04:00
parent 40109b5c62
commit 23d7e79605
3 changed files with 128 additions and 6 deletions

View File

@ -39,8 +39,13 @@
.card { .card {
background: #161b27; background: #161b27;
border-radius: 12px; border-radius: 12px;
padding: 2rem; padding: 1rem 1.1rem;
border: 1px solid #1e2433; border: 1px solid #1e2433;
transition: border-color 0.2s;
}
.card.auth {
padding: 2rem;
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
box-sizing: border-box; box-sizing: border-box;
@ -52,10 +57,17 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
color: #94a3b8; color: #94a3b8;
}
.card.auth .card-title {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
text-align: center; text-align: center;
} }
#content {
align-self: stretch;
}
.form-group { .form-group {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -355,7 +367,7 @@
</header> </header>
<% if authenticated %> <% if authenticated %>
<div id="content"><div class="spinner"></div></div> <div id="content" class="grid"><div class="spinner"></div></div>
<script> <script>
fetch("?content", { credentials: "same-origin" }) fetch("?content", { credentials: "same-origin" })
.then(function(response) { .then(function(response) {
@ -372,7 +384,7 @@
<% end %> <% end %>
<% unless authenticated %> <% unless authenticated %>
<div class="card"> <div class="card auth">
<div class="card-title">Sign In</div> <div class="card-title">Sign In</div>
<form method="post"> <form method="post">
<div class="form-group"> <div class="form-group">

View File

@ -24,6 +24,45 @@ def drive_type(name)
rotational == "1" ? "hdd" : "ssd" rotational == "1" ? "hdd" : "ssd"
end end
def read_smart(name)
out = `smartctl -j -A -H /dev/#{name} 2>/dev/null`
return nil if out.empty?
data = JSON.parse(out) rescue nil
return nil unless data
result = {}
passed = data.dig("smart_status", "passed")
result["passed"] = passed unless passed.nil?
if (t = data.dig("temperature", "current"))
result["temperature"] = t
end
if (poh = data.dig("power_on_time", "hours"))
result["power_on_hours"] = poh
end
if name.start_with?("nvme")
log = data["nvme_smart_health_information_log"] || {}
result["percentage_used"] = log["percentage_used"] if log.key?("percentage_used")
result["media_errors"] = log["media_errors"] if log.key?("media_errors")
result["critical_warning"] = log["critical_warning"] if log.key?("critical_warning")
else
attrs = data.dig("ata_smart_attributes", "table") || []
attrs.each do |a|
raw = a.dig("raw", "value")
next if raw.nil?
case a["id"]
when 5 then result["reallocated_sectors"] = raw
when 10 then result["spin_retry"] = raw
when 197 then result["pending_sectors"] = raw
when 199 then result["crc_errors"] = raw
end
end
end
result
end
def handle_info(conn) def handle_info(conn)
info = [] info = []
@ -37,12 +76,17 @@ def handle_info(conn)
model = (File.read("#{block_path}/device/model").strip rescue "Unknown") model = (File.read("#{block_path}/device/model").strip rescue "Unknown")
bytes = File.read("#{block_path}/size").strip.to_i * 512 bytes = File.read("#{block_path}/size").strip.to_i * 512
info << { entry = {
"type" => "drive", "type" => "drive",
"drivetype" => drive_type(name), "drivetype" => drive_type(name),
"name" => name,
"model" => model, "model" => model,
"capacity" => human_capacity(bytes) "capacity" => human_capacity(bytes)
} }
if (smart = read_smart(name))
entry["smart"] = smart
end
info << entry
end end
if conn if conn

View File

@ -97,18 +97,84 @@ if cgi.params.key?("content")
exit exit
end end
thousands = ->(n) { n.to_s.gsub(/(\d)(?=(\d{3})+$)/, '\1,') }
drive_stats = lambda do |dt, smart|
stats = []
case smart["passed"]
when true then stats << ["ok", "SMART PASSED"]
when false then stats << ["bad", "SMART FAILED"]
end
if (t = smart["temperature"])
cls = t >= 60 ? "bad" : t >= 50 ? "warn" : "ok"
stats << [cls, "🌡 #{t} °C"]
end
if (poh = smart["power_on_hours"])
stats << ["ok", "POH #{thousands.(poh)} h"]
end
if dt == "nvme"
if (p = smart["percentage_used"])
cls = p >= 90 ? "bad" : p >= 70 ? "warn" : "ok"
stats << [cls, "Wear #{p}%"]
end
if (m = smart["media_errors"])
stats << [m > 0 ? "bad" : "ok", "Media err #{m}"]
end
if (cw = smart["critical_warning"]) && cw != 0
stats << ["bad", "Crit warn 0x#{cw.to_s(16)}"]
end
else
if (r = smart["reallocated_sectors"])
stats << [r > 0 ? "warn" : "ok", "Realloc #{r}"]
end
if (s = smart["spin_retry"])
stats << [s > 0 ? "warn" : "ok", "Spin retry #{s}"]
end
if (p = smart["pending_sectors"])
stats << [p > 0 ? "warn" : "ok", "Pending #{p}"]
end
if (c = smart["crc_errors"])
stats << [c > 0 ? "warn" : "ok", "CRC #{c}"]
end
end
stats
end
info.each do |entry| info.each do |entry|
case entry["type"] case entry["type"]
when "drive" when "drive"
dt = entry["drivetype"] dt = entry["drivetype"]
name = CGI.escapeHTML(entry["name"].to_s)
model = CGI.escapeHTML(entry["model"]) model = CGI.escapeHTML(entry["model"])
capacity = CGI.escapeHTML(entry["capacity"]) capacity = CGI.escapeHTML(entry["capacity"])
smart = entry["smart"]
stats = smart ? drive_stats.(dt, smart) : []
card_cls = if stats.any? { |c, _| c == "bad" } then "bad"
elsif stats.any? { |c, _| c == "warn" } then "warn"
else "ok"
end
stats_html = stats.map { |c, txt|
%(<span class="stat #{c}">#{CGI.escapeHTML(txt)}</span>)
}.join
stats_block = stats.empty? ? "" :
%(<div class="drive-stats" style="margin-top:0.6rem;">#{stats_html}</div>)
title = name.empty? ? model : "#{name} · #{model}"
html << <<~HTML html << <<~HTML
<div class="card ok"> <div class="card #{card_cls} span-2">
<div class="card-header"> <div class="card-header">
<span class="card-title">#{model} <span class="drive-type #{dt}">#{dt.upcase}</span></span> <span class="card-title">#{title} <span class="drive-type #{dt}">#{dt.upcase}</span></span>
</div> </div>
<div class="card-sub">#{capacity}</div> <div class="card-sub">#{capacity}</div>
#{stats_block}
</div> </div>
HTML HTML
end end