From 5773347bdac14812d65cc80eab60a2bc956c7a4e Mon Sep 17 00:00:00 2001 From: Josh Holtrop Date: Tue, 14 Apr 2026 09:12:30 -0400 Subject: [PATCH] Add virtual machines list --- assets/page.erb | 5 +-- bin/malpd | 93 +++++++++++++++++++++++++++++++++++++++++++++++++ cgi-bin/malp.rb | 84 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 177 insertions(+), 5 deletions(-) diff --git a/assets/page.erb b/assets/page.erb index ba37226..ceca20a 100644 --- a/assets/page.erb +++ b/assets/page.erb @@ -182,8 +182,9 @@ @media (max-width: 500px) { .grid { grid-template-columns: 1fr; } } /* span modifiers */ - .span-2 { grid-column: span 2; } - .span-3 { grid-column: span 3; } + .span-2 { grid-column: span 2; } + .span-3 { grid-column: span 3; } + .span-all { grid-column: 1 / -1; } .card:hover { border-color: #2d3748; } diff --git a/bin/malpd b/bin/malpd index c4cb9e3..ae7ff0a 100755 --- a/bin/malpd +++ b/bin/malpd @@ -115,6 +115,98 @@ def read_filesystems result end +def parse_mem(s) + return 0 unless s + n, unit = s.split + mult = case unit + when "KiB" then 1024 + when "MiB" then 1024 ** 2 + when "GiB" then 1024 ** 3 + when "TiB" then 1024 ** 4 + else 1 + end + n.to_i * mult +end + +def read_vm_dominfo(name) + out = `virsh --connect qemu:///system dominfo #{Shellwords.escape(name)} 2>/dev/null` + info = {} + out.each_line do |line| + key, value = line.split(":", 2).map { |s| s&.strip } + next unless key && value + case key + when "State" then info["state"] = value + when "CPU(s)" then info["vcpus"] = value.to_i + when "Max memory" then info["memory"] = parse_mem(value) + when "Used memory" then info["used_memory"] = parse_mem(value) + when "Autostart" then info["autostart"] = (value == "enable") + end + end + info +end + +def read_vm_disks(name) + out = `virsh --connect qemu:///system domblklist #{Shellwords.escape(name)} --details 2>/dev/null` + targets = [] + out.each_line do |line| + parts = line.strip.split(/\s+/) + next if parts.size < 3 + next if parts[0] == "Type" || parts[0].start_with?("-") + next unless parts[1] == "disk" + targets << parts[2] + end + total_cap = 0 + total_alloc = 0 + targets.each do |tgt| + blk = `virsh --connect qemu:///system domblkinfo #{Shellwords.escape(name)} #{Shellwords.escape(tgt)} --bytes 2>/dev/null` + blk.each_line do |l| + k, v = l.split(":", 2).map { |s| s&.strip } + next unless k && v + total_cap += v.to_i if k == "Capacity" + total_alloc += v.to_i if k == "Allocation" + end + end + { "disk_total" => total_cap, "disk_used" => total_alloc } +end + +def read_vm_os(name) + xml = `virsh --connect qemu:///system dumpxml #{Shellwords.escape(name)} 2>/dev/null` + return nil if xml.empty? + if xml =~ %r{([^<]+)} + return $1.strip + end + nil +end + +def read_virtual_machines + list_out = `virsh --connect qemu:///system list --all --name 2>/dev/null` + return [] unless $?.success? + names = list_out.split("\n").map(&:strip).reject(&:empty?) + return [] if names.empty? + + names.map do |name| + dom = read_vm_dominfo(name) + disk = read_vm_disks(name) + { + "type" => "vm", + "name" => name, + "state" => dom["state"], + "vcpus" => dom["vcpus"], + "memory" => dom["memory"], + "used_memory" => dom["used_memory"], + "autostart" => dom["autostart"], + "os" => read_vm_os(name), + "disk_total" => disk["disk_total"], + "disk_used" => disk["disk_used"] + } + end +end + def handle_info(conn) info = [] @@ -142,6 +234,7 @@ def handle_info(conn) end info.concat(read_filesystems) + info.concat(read_virtual_machines) if conn conn.puts JSON.generate(info) diff --git a/cgi-bin/malp.rb b/cgi-bin/malp.rb index deb31e2..7050b69 100755 --- a/cgi-bin/malp.rb +++ b/cgi-bin/malp.rb @@ -109,6 +109,87 @@ if cgi.params.key?("content") exit end + drives = info.select { |e| e["type"] == "drive" } + filesystems = info.select { |e| e["type"] == "filesystem" } + vms = info.select { |e| e["type"] == "vm" } + + 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 << %(
) + html << %() + html << <<~HTML +
+
+ Libvirt + #{running} / #{total} Running +
+
+
+ VM + Resources + Disk + State +
+ HTML + + vms.each do |vm| + name = CGI.escapeHTML(vm["name"].to_s) + os = vm["os"] ? CGI.escapeHTML(vm["os"]) : nil + state = vm["state"].to_s + state_cls = case state + when "running" then "ok" + when "paused", "in shutdown", "pmsuspended" then "warn" + when "shut off", "crashed" then "bad" + else "warn" + end + state_label = CGI.escapeHTML(state.split.map(&:capitalize).join(" ")) + + vcpus = vm["vcpus"].to_i + mem_b = vm["memory"].to_i + res = vcpus > 0 || mem_b > 0 ? "#{vcpus} CPU#{vcpus > 1 ? "s" : ""} · #{human_capacity(mem_b)}" : "—" + + cap = vm["disk_total"].to_i + used = vm["disk_used"].to_i + dpct_f = cap > 0 ? (used * 100.0 / cap) : 0.0 + dpct = dpct_f.round + disk_cls = dpct_f >= 90 ? "bad" : dpct_f >= 75 ? "warn" : "ok" + + disk_html = if cap > 0 + %(
+
#{dpct}% · #{CGI.escapeHTML(human_capacity(used))} / #{CGI.escapeHTML(human_capacity(cap))}
+
+
) + else + %(
) + end + + os_html = os ? %(
#{os}
) : "" + + html << <<~HTML +
+
+
+
+
+
#{name}
+ #{os_html} +
+
+
#{CGI.escapeHTML(res)}
+ #{disk_html} +
#{state_label}
+
+
+ HTML + end + + html << %(
) + html << %(
) + end + thousands = ->(n) { n.to_s.gsub(/(\d)(?=(\d{3})+$)/, '\1,') } drive_stats = lambda do |dt, smart| @@ -157,9 +238,6 @@ if cgi.params.key?("content") stats end - drives = info.select { |e| e["type"] == "drive" } - filesystems = info.select { |e| e["type"] == "filesystem" } - if drives.any? html << %(
) html << %()