Add drive SMART attributes and health status
This commit is contained in:
parent
40109b5c62
commit
23d7e79605
@ -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">
|
||||||
|
|||||||
46
bin/malpd
46
bin/malpd
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user