Compare commits

..
11 Commits
3 changed files with 471 additions and 101 deletions

No files matched your search

+27 -11
View File
@@ -11,17 +11,25 @@
font-family: 'Segoe UI', system-ui, sans-serif;
min-height: 100vh;
margin: 0;
padding: 2rem;
padding: 1rem 2rem 2rem;
display: flex;
flex-direction: column;
align-items: center;
}
.header-controls {
position: fixed;
top: 1rem;
right: 2rem;
display: flex;
align-items: center;
gap: 1rem;
}
header {
width: 100%;
max-width: 400px;
text-align: center;
margin-bottom: 2rem;
text-align: left;
margin-bottom: 0rem;
padding-bottom: 1rem;
border-bottom: 1px solid #1e2433;
}
@@ -32,6 +40,7 @@
letter-spacing: 0.05em;
color: #94a3b8;
text-transform: uppercase;
margin: 0;
}
header h1 span { color: #e2e8f0; }
@@ -124,9 +133,7 @@
}
.logout-form {
position: fixed;
top: 1rem;
right: 1rem;
margin: 0;
}
.logout-btn {
@@ -143,6 +150,7 @@
}
.spinner {
grid-column: 1 / -1;
width: 24px;
height: 24px;
margin: 1rem auto;
@@ -379,10 +387,13 @@
<body>
<% if authenticated %>
<form method="post" class="logout-form">
<input type="hidden" name="action" value="logout">
<button type="submit" class="logout-btn">Log Out</button>
</form>
<div class="header-controls">
<div class="last-updated" id="last-updated"></div>
<form method="post" class="logout-form">
<input type="hidden" name="action" value="logout">
<button type="submit" class="logout-btn">Log Out</button>
</form>
</div>
<% end %>
<header>
@@ -399,6 +410,11 @@
})
.then(function(html) {
document.getElementById("content").innerHTML = html;
var now = new Date();
var pad = function(n) { return String(n).padStart(2, "0"); };
var stamp = now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate()) +
" " + pad(now.getHours()) + ":" + pad(now.getMinutes());
document.getElementById("last-updated").textContent = "Updated: " + stamp;
})
.catch(function() {
document.getElementById("content").innerHTML = "Error";
+147 -3
View File
@@ -4,6 +4,7 @@ require "socket"
require "fileutils"
require "json"
require "shellwords"
require "etc"
SOCKET_PATH = "/run/malpd/malpd.sock"
@@ -146,8 +147,10 @@ def read_vm_dominfo(name)
end
def read_vm_info(name)
ssh_out = `ssh -Ti /root/.ssh/malp-vm-key malp@#{name}`
JSON.parse(ssh_out)
ssh_out = `ssh -o ConnectTimeout=1 -Ti /root/.ssh/malp-vm-key malp@#{name} 2>/dev/null`
if ssh_out != ""
JSON.parse(ssh_out)
end
end
def read_virtual_machines
@@ -158,7 +161,7 @@ def read_virtual_machines
names.map do |name|
dom_info = read_vm_dominfo(name)
vm_info = read_vm_info(name)
vm_info = read_vm_info(name) || {}
{
"type" => "vm",
"name" => name,
@@ -166,6 +169,143 @@ def read_virtual_machines
end
end
def read_os_id
return nil unless File.exist?("/etc/os-release")
File.readlines("/etc/os-release").each do |line|
if line =~ /^ID=(.+)$/
return $1.strip.delete('"').downcase
end
end
nil
end
def apt_updates
out = `apt-get -s -o Debug::NoLocking=true upgrade 2>/dev/null`
count = 0
out.each_line do |line|
count += 1 if line.start_with?("Inst ")
end
count
end
def apt_reboot_pending
File.exist?("/var/run/reboot-required")
end
def dnf_updates
out = `dnf -q check-update 2>/dev/null`
status = $?.exitstatus
return 0 if status == 0
return nil unless status == 100
count = 0
out.each_line do |line|
line = line.strip
next if line.empty?
next if line.start_with?("Obsoleting", "Last metadata")
count += 1 if line.split(/\s+/).size >= 3
end
count
end
def dnf_reboot_pending
`dnf needs-restarting -r >/dev/null 2>&1`
$?.exitstatus == 1
end
def read_server_info
info = { "type" => "server" }
if File.exist?("/etc/os-release")
File.readlines("/etc/os-release").each do |line|
if line =~ /^PRETTY_NAME="(.+)"$/
info["os_name"] = $1
break
end
end
end
case read_os_id
when "ubuntu", "debian"
info["updates"] = apt_updates
info["reboot_pending"] = apt_reboot_pending
when "almalinux", "rocky", "rhel", "centos", "fedora"
info["updates"] = dnf_updates
info["reboot_pending"] = dnf_reboot_pending
end
out = `stat -c %W / 2>/dev/null`.strip
if out =~ /^\d+$/ && out.to_i > 0
info["install_date"] = Time.at(out.to_i).strftime("%Y-%m-%d")
elsif File.exist?("/etc/machine-id")
info["install_date"] = File.mtime("/etc/machine-id").strftime("%Y-%m-%d")
end
if File.exist?("/proc/cpuinfo")
cpuinfo = File.read("/proc/cpuinfo")
if cpuinfo =~ /^model name\s*:\s*(.+)$/
info["cpu_model"] = $1.strip
end
cores = cpuinfo.scan(/^processor\s*:/).size
info["cpu_cores"] = cores if cores > 0
end
if File.exist?("/proc/meminfo")
mem = {}
File.read("/proc/meminfo").each_line do |line|
if line =~ /^(\w+):\s+(\d+)/
mem[$1] = $2.to_i * 1024
end
end
if (total = mem["MemTotal"])
available = mem["MemAvailable"] ||
(mem["MemFree"].to_i + mem["Buffers"].to_i + mem["Cached"].to_i)
info["mem_total"] = total
info["mem_used"] = total - available
end
end
info
end
def read_backups
paths = [
["rsync", "/backup/last-backup"],
["gmail", "/backup/gmail/new"]
]
entries = paths.map do |label, path|
mtime = (File.mtime(path).to_i rescue nil)
{ "label" => label, "path" => path, "mtime" => mtime }
end
{ "type" => "backup", "entries" => entries }
end
def read_mail
users = []
["root", "josh"].each do |user|
begin
home = Etc.getpwnam(user).dir
rescue ArgumentError
next
end
count = 0
mbox = ["/var/mail/#{user}", "/var/spool/mail/#{user}"].find { |p| File.exist?(p) }
if mbox
content = File.read(mbox) rescue ""
count = content.scan(/^From /).size
end
users << {
"user" => user,
"count" => count,
"dead_letter" => File.exist?(File.join(home, "dead.letter"))
}
end
{ "type" => "mail", "users" => users }
end
def read_ups
out = `apcaccess 2>/dev/null`
return nil unless $?.success? && !out.empty?
@@ -232,6 +372,10 @@ def handle_info(conn)
info.concat(read_filesystems)
info.concat(read_virtual_machines)
info << read_server_info
info << read_mail
info << read_backups
if (ups = read_ups)
info << ups
end
+297 -87
View File
@@ -7,6 +7,14 @@ require "json"
require "securerandom"
require "socket"
def human_age(seconds)
return "unknown" if seconds.nil?
return "just now" if seconds < 60
return "#{seconds / 60} min ago" if seconds < 3600
return "#{seconds / 3600} h ago" if seconds < 86400
"#{seconds / 86400} d ago"
end
def human_capacity(bytes)
units = ["B", "KB", "MB", "GB", "TB", "PB"]
i = 0
@@ -112,13 +120,185 @@ if cgi.params.key?("content")
drives = info.select { |e| e["type"] == "drive" }
filesystems = info.select { |e| e["type"] == "filesystem" }
vms = info.select { |e| e["type"] == "vm" }
server = info.find { |e| e["type"] == "server" }
html << %(<div>)
if server
srv_stats = []
if updates = server["updates"]
cls = updates == 0 ? "ok" : "warn"
srv_stats << [cls, "#{updates} update#{updates == 1 ? "" : "s"}"]
end
if server["reboot_pending"]
srv_stats << ["warn", "Reboot pending"]
end
srv_cls = srv_stats.any? { |c, _| c == "bad" } ? "bad" :
srv_stats.any? { |c, _| c == "warn" } ? "warn" : "ok"
html << %(<div class="section-label">Server</div>)
html << %(<div class="card #{srv_cls}"><div class="card-header"><span class="card-title">Server Info</span></div><div class="subitems">)
if server["os_name"]
html << <<~HTML
<div class="subitem">
<div class="subitem-left"><div class="dot ok"></div><span class="subitem-name">OS</span></div>
<span class="subitem-value ok">#{CGI.escapeHTML(server["os_name"])}</span>
</div>
HTML
end
if server["install_date"]
html << <<~HTML
<div class="subitem">
<div class="subitem-left"><div class="dot ok"></div><span class="subitem-name">Installed</span></div>
<span class="subitem-value ok">#{CGI.escapeHTML(server["install_date"])}</span>
</div>
HTML
end
cpu_model = server["cpu_model"]
cpu_cores = server["cpu_cores"]
if cpu_model || cpu_cores
cpu_text = [
cpu_model,
cpu_cores ? "#{cpu_cores} thread#{cpu_cores == 1 ? "" : "s"}" : nil
].compact.join(" · ")
html << <<~HTML
<div class="subitem">
<div class="subitem-left"><div class="dot ok"></div><span class="subitem-name">CPU</span></div>
<span class="subitem-value ok">#{CGI.escapeHTML(cpu_text)}</span>
</div>
HTML
end
mem_total = server["mem_total"].to_i
mem_used = server["mem_used"].to_i
if mem_total > 0
mem_pct_f = mem_used * 100.0 / mem_total
mem_pct = mem_pct_f.round
mem_cls = mem_pct_f >= 90 ? "bad" : mem_pct_f >= 75 ? "warn" : "ok"
html << <<~HTML
<div class="subitem" style="flex-direction:column;align-items:stretch;gap:0.35rem;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div class="subitem-left"><div class="dot #{mem_cls}"></div><span class="subitem-name">Memory</span></div>
<span class="subitem-value #{mem_cls}">#{mem_pct}% · #{CGI.escapeHTML(human_capacity(mem_used))} / #{CGI.escapeHTML(human_capacity(mem_total))}</span>
</div>
<div class="bar-track"><div class="bar-fill #{mem_cls}" style="width:#{mem_pct}%"></div></div>
</div>
HTML
end
html << %(</div>)
if srv_stats.any?
srv_stats_html = srv_stats.map { |c, txt|
%(<span class="stat #{c}">#{CGI.escapeHTML(txt)}</span>)
}.join
html << %(<div class="drive-stats" style="margin-top:0.45rem;padding:0 0.6rem;">#{srv_stats_html}</div>)
end
html << %(</div>)
end
if filesystems.any?
total_size = filesystems.sum { |e| e["size"].to_i }
total_used = filesystems.sum { |e| e["used"].to_i }
total_pct_f = total_size > 0 ? (total_used * 100.0 / total_size) : 0.0
total_pct = total_pct_f.round
total_cls = total_pct_f >= 90 ? "bad" : total_pct_f >= 75 ? "warn" : "ok"
fs_rows = filesystems.map do |entry|
mount = CGI.escapeHTML(entry["mount"].to_s)
source = CGI.escapeHTML(entry["source"].to_s)
fstype = CGI.escapeHTML(entry["fstype"].to_s)
size_b = entry["size"].to_i
used_b = entry["used"].to_i
pct_f = size_b > 0 ? (used_b * 100.0 / size_b) : 0.0
pct = pct_f.round
space_cls = pct_f >= 90 ? "bad" : pct_f >= 75 ? "warn" : "ok"
used_h = CGI.escapeHTML(human_capacity(used_b))
size_h = CGI.escapeHTML(human_capacity(size_b))
fs_stats = []
inode_cls = "ok"
if (itot = entry["inode_total"].to_i) > 0
iused = entry["inode_used"].to_i
ipct_f = iused * 100.0 / itot
inode_cls = ipct_f >= 90 ? "bad" : ipct_f >= 75 ? "warn" : "ok"
fs_stats << [inode_cls, "Inodes #{ipct_f.round}%"]
end
fs_stats << ["warn", "READ-ONLY"] if entry["readonly"]
severities = [space_cls, inode_cls]
row_cls = severities.include?("bad") ? "bad" :
severities.include?("warn") ? "warn" : "ok"
stats_html = fs_stats.map { |c, txt|
%(<span class="stat #{c}">#{CGI.escapeHTML(txt)}</span>)
}.join
stats_block = fs_stats.empty? ? "" :
%(<div class="drive-stats">#{stats_html}</div>)
row_html = <<~HTML
<div class="subitem" style="flex-direction:column;align-items:stretch;gap:0.35rem;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div class="subitem-left">
<div class="dot #{space_cls}"></div>
<span class="subitem-name">#{mount} · #{source}</span>
<span class="card-capacity">#{fstype}</span>
</div>
<span class="subitem-value #{space_cls}">#{pct}% · #{used_h} / #{size_h}</span>
</div>
<div class="bar-track"><div class="bar-fill #{space_cls}" style="width:#{pct}%"></div></div>
#{stats_block}
</div>
HTML
[row_html, row_cls]
end
severities = fs_rows.map { |_, c| c }
card_cls = severities.include?("bad") ? "bad" :
severities.include?("warn") ? "warn" : "ok"
total_used_h = CGI.escapeHTML(human_capacity(total_used))
total_size_h = CGI.escapeHTML(human_capacity(total_size))
html << %(<div class="section-label">File Systems</div>)
html << <<~HTML
<div class="card #{card_cls}">
<div class="card-header">
<span class="card-title">File Systems</span>
<span class="badge #{total_cls}">#{total_pct}%</span>
</div>
<div class="subitems">
HTML
fs_rows.each { |row_html, _| html << row_html }
html << %(</div>)
html << <<~HTML
<div style="margin-top:0.75rem;padding-top:0.6rem;border-top:1px solid #1e2433;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.3rem;">
<span style="font-size:0.72rem;color:#64748b;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;">Total</span>
<span class="subitem-value #{total_cls}">#{total_pct}% · #{total_used_h} / #{total_size_h}</span>
</div>
<div class="bar-track" style="height:6px;"><div class="bar-fill #{total_cls}" style="width:#{total_pct}%"></div></div>
</div>
HTML
html << %(</div>)
end
html << %(</div>)
html << %(<div class="span-3">)
if vms.any?
running = vms.count { |v| v["state"].to_s == "running" }
total = vms.size
hdr_cls = running == total ? "ok" : running == 0 ? "bad" : "warn"
html << %(<div class="span-3">)
html << %(<div class="section-label">Virtual Machines</div>)
html << <<~HTML
<div class="card #{hdr_cls}">
@@ -202,7 +382,6 @@ if cgi.params.key?("content")
end
html << %(</div></div>)
html << %(</div>)
end
thousands = ->(n) { n.to_s.gsub(/(\d)(?=(\d{3})+$)/, '\1,') }
@@ -253,8 +432,18 @@ if cgi.params.key?("content")
stats
end
upses = info.select { |e| e["type"] == "ups" }
mail = info.find { |e| e["type"] == "mail" }
mail_visible = mail && (mail["users"] || []).any?
backup = info.find { |e| e["type"] == "backup" }
backup_visible = backup && (backup["entries"] || []).any?
if drives.any? || upses.any? || mail_visible || backup_visible
html << %(<div style="display:grid;grid-template-columns:2fr 1fr;gap:1rem;align-items:start;margin-top:1rem;">)
end
if drives.any?
html << %(<div class="span-2">)
html << %(<div>)
html << %(<div class="section-label">Storage Drives</div>)
html << %(<div class="stack">)
drives.each do |entry|
@@ -291,111 +480,132 @@ if cgi.params.key?("content")
html << %(</div>)
end
if filesystems.any?
if upses.any? || mail_visible || backup_visible
html << %(<div>)
html << %(<div class="section-label">File Systems</div>)
html << %(<div class="stack">)
filesystems.each do |entry|
mount = CGI.escapeHTML(entry["mount"].to_s)
source = CGI.escapeHTML(entry["source"].to_s)
fstype = CGI.escapeHTML(entry["fstype"].to_s)
size_b = entry["size"].to_i
used_b = entry["used"].to_i
pct_f = size_b > 0 ? (used_b * 100.0 / size_b) : 0.0
pct = pct_f.round
space_cls = pct_f >= 90 ? "bad" : pct_f >= 75 ? "warn" : "ok"
used_h = CGI.escapeHTML(human_capacity(used_b))
size_h = CGI.escapeHTML(human_capacity(size_b))
fs_stats = []
inode_cls = "ok"
if (itot = entry["inode_total"].to_i) > 0
iused = entry["inode_used"].to_i
ipct_f = iused * 100.0 / itot
inode_cls = ipct_f >= 90 ? "bad" : ipct_f >= 75 ? "warn" : "ok"
fs_stats << [inode_cls, "Inodes #{ipct_f.round}%"]
upses.each do |ups|
status = ups["status"].to_s
online = status.include?("ONLINE")
card_cls = online ? "ok" : "bad"
model = ups["model"] ? "APC " + CGI.escapeHTML(ups["model"]) : "UPS"
stats = []
stats << [online ? "ok" : "bad", CGI.escapeHTML(status)] unless status.empty?
if (charge = ups["charge"])
cls = charge >= 80 ? "ok" : charge >= 50 ? "warn" : "bad"
stats << [cls, "Charge #{charge.round}%"]
end
if (timeleft = ups["timeleft"])
cls = timeleft >= 10 ? "ok" : timeleft >= 5 ? "warn" : "bad"
stats << [cls, "#{timeleft.round(1)} min left"]
end
if (load = ups["load"])
cls = load >= 90 ? "bad" : load >= 70 ? "warn" : "ok"
stats << [cls, "Load #{load.round}%"]
end
if (lv = ups["line_voltage"])
stats << ["ok", "Line #{lv.round} V"]
end
if (bv = ups["battery_voltage"])
stats << ["ok", "Batt #{bv.round(1)} V"]
end
fs_stats << ["warn", "READ-ONLY"] if entry["readonly"]
severities = [space_cls, inode_cls]
card_cls = severities.include?("bad") ? "bad" :
severities.include?("warn") ? "warn" : "ok"
stats_html = fs_stats.map { |c, txt|
%(<span class="stat #{c}">#{CGI.escapeHTML(txt)}</span>)
stats_html = stats.map { |c, txt|
%(<span class="stat #{c}">#{txt}</span>)
}.join
stats_block = fs_stats.empty? ? "" :
stats_block = stats.empty? ? "" :
%(<div class="drive-stats" style="margin-top:0.6rem;">#{stats_html}</div>)
html << %(<div class="section-label">UPS</div>)
html << <<~HTML
<div class="card #{card_cls}">
<div class="card-header">
<span class="card-title">#{mount} <span class="card-capacity">#{fstype}</span></span>
<span class="badge #{space_cls}">#{pct}%</span>
<span class="card-title">#{model}</span>
<span class="badge #{card_cls}">#{online ? "Online" : CGI.escapeHTML(status)}</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.5rem;margin-bottom:0.3rem;">
<span style="font-size:0.72rem;color:#64748b;font-weight:600;">#{source}</span>
<span class="subitem-value #{space_cls}">#{used_h} / #{size_h}</span>
</div>
<div class="bar-track" style="height:6px;"><div class="bar-fill #{space_cls}" style="width:#{pct}%"></div></div>
#{stats_block}
</div>
HTML
end
html << %(</div>)
if mail_visible
users = mail["users"]
has_warn = users.any? { |u| u["count"].to_i > 0 || u["dead_letter"] }
mail_card_cls = has_warn ? "warn" : "ok"
html << %(<div class="section-label">Mail</div>)
html << %(<div class="card #{mail_card_cls}"><div class="card-header"><span class="card-title">Local Mail</span></div><div class="subitems">)
users.each do |u|
count = u["count"].to_i
cls = count > 0 ? "warn" : "ok"
label = count == 1 ? "1 message" : "#{count} messages"
html << <<~HTML
<div class="subitem">
<div class="subitem-left"><div class="dot #{cls}"></div><span class="subitem-name">#{CGI.escapeHTML(u["user"].to_s)}</span></div>
<span class="subitem-value #{cls}">#{label}</span>
</div>
HTML
end
html << %(</div>)
dead = users.select { |u| u["dead_letter"] }
if dead.any?
stats_html = dead.map { |u|
%(<span class="stat warn">dead.letter in ~#{CGI.escapeHTML(u["user"].to_s)}</span>)
}.join
html << %(<div class="drive-stats" style="margin-top:0.6rem;">#{stats_html}</div>)
end
html << %(</div>)
end
if backup_visible
now = Time.now.to_i
classified = backup["entries"].map do |e|
mtime = e["mtime"]
cls = if mtime.nil?
"bad"
else
age = now - mtime
age < 2 * 86400 ? "ok" : age < 7 * 86400 ? "warn" : "bad"
end
[e, cls]
end
backup_card_cls = if classified.any? { |_, c| c == "bad" } then "bad"
elsif classified.any? { |_, c| c == "warn" } then "warn"
else "ok"
end
html << %(<div class="section-label">Backups</div>)
html << %(<div class="card #{backup_card_cls}"><div class="card-header"><span class="card-title">Backups</span></div><div class="subitems">)
classified.each do |e, cls|
age_label = e["mtime"] ? human_age(now - e["mtime"]) : "missing"
html << <<~HTML
<div class="subitem">
<div class="subitem-left"><div class="dot #{cls}"></div><span class="subitem-name">#{CGI.escapeHTML(e["label"].to_s)}</span></div>
<span class="subitem-value #{cls}">#{CGI.escapeHTML(age_label)}</span>
</div>
HTML
end
html << %(</div></div>)
end
html << %(</div>)
end
upses = info.select { |e| e["type"] == "ups" }
upses.each do |ups|
status = ups["status"].to_s
online = status.include?("ONLINE")
card_cls = online ? "ok" : "bad"
model = ups["model"] ? "APC " + CGI.escapeHTML(ups["model"]) : "UPS"
stats = []
stats << [online ? "ok" : "bad", CGI.escapeHTML(status)] unless status.empty?
if (charge = ups["charge"])
cls = charge >= 80 ? "ok" : charge >= 50 ? "warn" : "bad"
stats << [cls, "Charge #{charge.round}%"]
end
if (timeleft = ups["timeleft"])
cls = timeleft >= 10 ? "ok" : timeleft >= 5 ? "warn" : "bad"
stats << [cls, "#{timeleft.round(1)} min left"]
end
if (load = ups["load"])
cls = load >= 90 ? "bad" : load >= 70 ? "warn" : "ok"
stats << [cls, "Load #{load.round}%"]
end
if (lv = ups["line_voltage"])
stats << ["ok", "Line #{lv.round} V"]
end
if (bv = ups["battery_voltage"])
stats << ["ok", "Batt #{bv.round(1)} V"]
end
stats_html = stats.map { |c, txt|
%(<span class="stat #{c}">#{txt}</span>)
}.join
stats_block = stats.empty? ? "" :
%(<div class="drive-stats" style="margin-top:0.6rem;">#{stats_html}</div>)
html << %(<div>)
html << %(<div class="section-label">UPS</div>)
html << <<~HTML
<div class="card #{card_cls}">
<div class="card-header">
<span class="card-title">#{model}</span>
<span class="badge #{card_cls}">#{online ? "Online" : CGI.escapeHTML(status)}</span>
</div>
#{stats_block}
</div>
HTML
if drives.any? || upses.any? || mail_visible || backup_visible
html << %(</div>)
end
html << %(</div>)
cgi.out("type" => "text/html", "charset" => "UTF-8") do
html
end