2326 lines
80 KiB
Perl
2326 lines
80 KiB
Perl
#!/usr/bin/env perl
|
|
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
# System diagnostics: CPU, memory, disk, network, GPU, services, security,
|
|
# performance, recent issues, and a health score with a letter grade.
|
|
#
|
|
# The data comes from /proc, /sys and system tools, and every source has a graceful
|
|
# fallback, so a missing tool is reported as unavailable rather than as a fault.
|
|
#
|
|
# Perl builtins only: no module has to be installed. What that costs here:
|
|
#
|
|
# * the command runner, which forks and keeps stdout and stderr apart in the
|
|
# scratch directory;
|
|
# * the section collectors, which run as forked children because the sections
|
|
# are independent and each is dominated by waiting, each writing its result
|
|
# as a Perl literal that the parent reads back;
|
|
# * a JSON encoder for --json, with two details Perl's data model forces: the
|
|
# values rounded to a decimal are held as numeric strings so 0.0 does not
|
|
# come out as 0, and the boolean fields are listed by path because Perl has
|
|
# no boolean type. Object keys are written in sorted order, so the document
|
|
# is stable from run to run.
|
|
# * file system sizes, which Perl's builtins do not expose, so `stat -f` is
|
|
# the transport, printing exactly the four numbers the arithmetic uses.
|
|
#
|
|
# One visible consequence of forking the section collectors is that they appear in
|
|
# the report's own top-process lists: the
|
|
# readings are real, the workers just happen to be the busiest thing at that moment.
|
|
#
|
|
# GPU detection reports AMD through rocm-smi, and any card at all through lspci when
|
|
# no driver tooling is present. No vendor tooling beyond AMD's is queried: the
|
|
# NVIDIA driver stack is banned by AGENTS.md, so nothing here reaches for it, and a
|
|
# card from that vendor is described by lspci like any other hardware.
|
|
#
|
|
# External binaries used: who, ip, ss, iostat, ps, stat, systemctl, getenforce,
|
|
# firewall-cmd, journalctl, last, lspci, rocm-smi, curl and uname.
|
|
#
|
|
# Linux only. Progress goes to stderr, the report to stdout.
|
|
#
|
|
# Usage:
|
|
# system-diag.pl # full report
|
|
# system-diag.pl --section cpu,memory # only selected sections
|
|
# system-diag.pl --external-ip # also detect external IPs (3rd-party)
|
|
# system-diag.pl --json # machine-readable output
|
|
# system-diag.pl --version
|
|
|
|
use strict;
|
|
use warnings;
|
|
|
|
my $VERSION = '2.0.0';
|
|
|
|
# Colour follows NO_COLOR (https://no-color.org): it, or a stdout that is not a
|
|
# terminal, disables every escape.
|
|
my ($BOLD, $RED, $GREEN, $YELLOW, $CYAN, $MAGENTA, $DIM, $RESET) = (
|
|
"\033[1m", "\033[31m", "\033[32m", "\033[33m",
|
|
"\033[36m", "\033[35m", "\033[2m", "\033[0m",
|
|
);
|
|
if (defined $ENV{NO_COLOR} || !-t STDOUT) {
|
|
($BOLD, $RED, $GREEN, $YELLOW, $CYAN, $MAGENTA, $DIM, $RESET) = ('') x 8;
|
|
}
|
|
|
|
# Letter grade to colour, applied when rendering only.
|
|
my %GRADE_COLOURS = (A => $GREEN, B => $GREEN, C => $YELLOW, D => $YELLOW, F => $RED);
|
|
|
|
my $WIDTH = 60;
|
|
|
|
my @ALL_SECTIONS = qw(
|
|
overview cpu memory disk network gpu services security performance issues
|
|
);
|
|
|
|
# The GPU card properties in the order their sources list them: lspci reports model,
|
|
# vendor and driver, and rocm-smi reports the card id and then its own properties.
|
|
my @CARD_KEY_ORDER = ('model', 'vendor', 'driver', 'id', 'Card series', 'Card model');
|
|
|
|
# Paths whose value is written as true or false, not as 1 or 0.
|
|
my @JSON_BOOLEANS = (
|
|
'disk.container',
|
|
'gpu.fallback',
|
|
'gpu.unavailable',
|
|
'security.firewalld.available',
|
|
'security.firewalld.active',
|
|
);
|
|
my %JSON_BOOLEAN = map { $_ => 1 } @JSON_BOOLEANS;
|
|
|
|
# Paths whose value is written as a string even when it looks like a number:
|
|
# every property of a GPU card is parsed text, and a per-core CPU index is written
|
|
# as a string. Without this they would go out unquoted and a reader would take them
|
|
# for numbers.
|
|
my @JSON_STRING_PATHS = (
|
|
qr/^gpu\.cards\./,
|
|
qr/^cpu\.per_core\.cpu$/,
|
|
);
|
|
|
|
my $TMP_DIR; # private scratch directory, created only when needed
|
|
my $PARENT_PID = $$; # a forked child must never clean up for the parent
|
|
my $RUN_SEQ = 0; # per-call suffix for the runner's files
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Progress, on stderr so stdout stays clean
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub _status {
|
|
my ($msg) = @_;
|
|
print STDERR " $msg...";
|
|
return;
|
|
}
|
|
|
|
sub _status_done {
|
|
my ($msg) = @_;
|
|
$msg = 'done' unless defined $msg;
|
|
print STDERR " $msg\n";
|
|
return;
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Commands, files and small helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub find_exe {
|
|
my ($name) = @_;
|
|
return undef unless defined $name && length $name;
|
|
if (index($name, '/') >= 0) {
|
|
return (-f $name && -x _) ? $name : undef;
|
|
}
|
|
for my $dir (split /:/, ($ENV{PATH} // '')) {
|
|
next unless length $dir;
|
|
my $path = "$dir/$name";
|
|
return $path if -f $path && -x _;
|
|
}
|
|
return undef;
|
|
}
|
|
|
|
sub scratch_dir {
|
|
return $TMP_DIR if defined $TMP_DIR;
|
|
my $base = $ENV{TMPDIR} // '/tmp';
|
|
for my $attempt (0 .. 9) {
|
|
my $dir = "$base/system-diag.$$" . ($attempt ? ".$attempt" : '');
|
|
if (mkdir($dir, 0700)) {
|
|
$TMP_DIR = $dir;
|
|
return $dir;
|
|
}
|
|
}
|
|
die "cannot create a scratch directory under $base\n";
|
|
}
|
|
|
|
sub remove_scratch {
|
|
return unless defined $TMP_DIR;
|
|
# The collectors fork, and a forked child inherits the END block: only the
|
|
# process that created the directory may remove it.
|
|
return unless $$ == $PARENT_PID;
|
|
if (opendir(my $dh, $TMP_DIR)) {
|
|
for my $entry (readdir($dh)) {
|
|
next if $entry eq '.' || $entry eq '..';
|
|
unlink("$TMP_DIR/$entry");
|
|
}
|
|
closedir($dh);
|
|
}
|
|
rmdir($TMP_DIR);
|
|
undef $TMP_DIR;
|
|
return;
|
|
}
|
|
|
|
sub slurp {
|
|
my ($path) = @_;
|
|
open(my $fh, '<', $path) or return '';
|
|
my $text = do { local $/ = undef; <$fh> };
|
|
close($fh);
|
|
return defined $text ? $text : '';
|
|
}
|
|
|
|
# Run a command and return { rc, out, err }. The locale is forced to C for
|
|
# English output, and a timeout or a missing binary yields empty output rather
|
|
# than an exception, which is the contract every caller below expects.
|
|
sub run {
|
|
my ($cmd, $timeout) = @_;
|
|
$timeout = 15 unless defined $timeout;
|
|
|
|
my $exe = find_exe($cmd->[0]);
|
|
return { rc => 127, out => '', err => '' } unless defined $exe;
|
|
|
|
my $dir = scratch_dir();
|
|
$RUN_SEQ++;
|
|
my $out_file = "$dir/out.$$.$RUN_SEQ";
|
|
my $err_file = "$dir/err.$$.$RUN_SEQ";
|
|
|
|
my $pid = fork();
|
|
die "cannot fork: $!\n" unless defined $pid;
|
|
if ($pid == 0) {
|
|
if (open(STDOUT, '>', $out_file) && open(STDERR, '>', $err_file)) {
|
|
$ENV{LANG} = 'C';
|
|
$ENV{LC_ALL} = 'C';
|
|
exec { $exe } @$cmd;
|
|
}
|
|
exit 126;
|
|
}
|
|
|
|
my $timed_out = 0;
|
|
eval {
|
|
local $SIG{ALRM} = sub { die "alarm\n" };
|
|
alarm($timeout);
|
|
waitpid($pid, 0);
|
|
alarm(0);
|
|
1;
|
|
} or do { $timed_out = 1; alarm(0) };
|
|
|
|
my $rc;
|
|
if ($timed_out) {
|
|
kill('TERM', $pid);
|
|
select(undef, undef, undef, 0.1);
|
|
kill('KILL', $pid);
|
|
waitpid($pid, 0);
|
|
$rc = 124;
|
|
}
|
|
else {
|
|
$rc = $? >> 8;
|
|
}
|
|
|
|
my $out = slurp($out_file);
|
|
my $err = slurp($err_file);
|
|
unlink($out_file, $err_file);
|
|
return { rc => $rc, out => $timed_out ? '' : $out, err => $timed_out ? '' : $err };
|
|
}
|
|
|
|
sub read_file {
|
|
my ($path) = @_;
|
|
open(my $fh, '<', $path) or return '';
|
|
my $text = do { local $/ = undef; <$fh> };
|
|
close($fh);
|
|
return '' unless defined $text;
|
|
$text =~ s/^\s+//;
|
|
$text =~ s/\s+$//;
|
|
return $text;
|
|
}
|
|
|
|
sub read_lines {
|
|
my ($path) = @_;
|
|
open(my $fh, '<', $path) or return [];
|
|
my @lines;
|
|
while (my $line = <$fh>) {
|
|
$line =~ s/^\s+//;
|
|
$line =~ s/\s+$//;
|
|
push @lines, $line;
|
|
}
|
|
close($fh);
|
|
return \@lines;
|
|
}
|
|
|
|
sub uname {
|
|
my ($flag) = @_;
|
|
my $p = run(['uname', $flag], 5);
|
|
my $value = $p->{out};
|
|
$value =~ s/^\s+//;
|
|
$value =~ s/\s+$//;
|
|
return $value;
|
|
}
|
|
|
|
# A textual usage bar: filled and empty cells with the percentage at the end.
|
|
sub _bar {
|
|
my ($value, $maximum, $width) = @_;
|
|
$maximum = 100.0 unless defined $maximum;
|
|
$width = 20 unless defined $width;
|
|
my $ratio = $maximum > 0 ? $value / $maximum : 0.0;
|
|
$ratio = 0.0 if $ratio < 0.0;
|
|
$ratio = 1.0 if $ratio > 1.0;
|
|
my $filled = int($ratio * $width);
|
|
my $colour = $ratio < 0.80 ? $GREEN : $ratio < 0.90 ? $YELLOW : $RED;
|
|
my $bar = $colour . ("█" x $filled) . $DIM . ("░" x ($width - $filled)) . $RESET;
|
|
return $bar . ' ' . sprintf('%.0f%%', $ratio * 100);
|
|
}
|
|
|
|
# Seconds since the epoch in the local calendar, so a UTC offset can be derived.
|
|
sub _days_from_civil {
|
|
my ($year, $month, $day) = @_;
|
|
$year -= $month <= 2 ? 1 : 0;
|
|
my $era = int(($year >= 0 ? $year : $year - 399) / 400);
|
|
my $yoe = $year - $era * 400;
|
|
my $doy = int((153 * ($month + ($month > 2 ? -3 : 9)) + 2) / 5) + $day - 1;
|
|
my $doe = $yoe * 365 + int($yoe / 4) - int($yoe / 100) + $doy;
|
|
return $era * 146097 + $doe - 719468;
|
|
}
|
|
|
|
# A +hhmm offset in the form strftime's %z prints. Perl's localtime reports
|
|
# no offset, so the same instant is read as both local and UTC calendar time and
|
|
# the difference is the offset.
|
|
sub utc_offset {
|
|
my ($t) = @_;
|
|
my @lt = localtime($t);
|
|
my @gt = gmtime($t);
|
|
my $offset = (_days_from_civil($lt[5] + 1900, $lt[4] + 1, $lt[3]) * 1440 + $lt[2] * 60 + $lt[1])
|
|
- (_days_from_civil($gt[5] + 1900, $gt[4] + 1, $gt[3]) * 1440 + $gt[2] * 60 + $gt[1]);
|
|
my $sign = $offset < 0 ? '-' : '+';
|
|
$offset = -$offset if $offset < 0;
|
|
return sprintf('%s%02d%02d', $sign, int($offset / 60), $offset % 60);
|
|
}
|
|
|
|
sub timestamp_fields {
|
|
my ($t) = @_;
|
|
my @lt = localtime($t);
|
|
return sprintf('%04d-%02d-%02dT%02d:%02d:%02d%s',
|
|
$lt[5] + 1900, $lt[4] + 1, $lt[3], $lt[2], $lt[1], $lt[0], utc_offset($t));
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JSON, written by hand
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub json_encode {
|
|
my ($value) = @_;
|
|
my $out = '';
|
|
json_render($value, '', 0, \$out);
|
|
return $out . "\n";
|
|
}
|
|
|
|
sub json_quote {
|
|
my ($text) = @_;
|
|
my $bytes = $text;
|
|
# Non-ASCII escapes as \uXXXX, so the byte string is decoded to characters
|
|
# before escaping.
|
|
utf8::decode($bytes);
|
|
my $out = '"';
|
|
for my $char (split //, $bytes) {
|
|
my $code = ord($char);
|
|
if ($char eq '"') { $out .= '\\"' }
|
|
elsif ($char eq '\\') { $out .= '\\\\' }
|
|
elsif ($char eq "\b") { $out .= '\\b' }
|
|
elsif ($char eq "\f") { $out .= '\\f' }
|
|
elsif ($char eq "\n") { $out .= '\\n' }
|
|
elsif ($char eq "\r") { $out .= '\\r' }
|
|
elsif ($char eq "\t") { $out .= '\\t' }
|
|
elsif ($code < 0x20) { $out .= sprintf('\\u%04x', $code) }
|
|
elsif ($code > 0x7e) {
|
|
if ($code > 0xffff) {
|
|
my $rest = $code - 0x10000;
|
|
$out .= sprintf('\\u%04x\\u%04x', 0xd800 + ($rest >> 10), 0xdc00 + ($rest & 0x3ff));
|
|
}
|
|
else {
|
|
$out .= sprintf('\\u%04x', $code);
|
|
}
|
|
}
|
|
else { $out .= $char }
|
|
}
|
|
return $out . '"';
|
|
}
|
|
|
|
sub json_render {
|
|
my ($value, $path, $indent, $out) = @_;
|
|
if (!defined $value) {
|
|
$$out .= 'null';
|
|
return;
|
|
}
|
|
if (ref $value eq 'HASH') {
|
|
my @keys = sort keys %$value;
|
|
if (!@keys) {
|
|
$$out .= '{}';
|
|
return;
|
|
}
|
|
$$out .= "{\n";
|
|
for my $index (0 .. $#keys) {
|
|
my $key = $keys[$index];
|
|
my $child_path = length $path ? "$path.$key" : $key;
|
|
$$out .= (' ' x (2 * ($indent + 1))) . json_quote($key) . ': ';
|
|
json_render($value->{$key}, $child_path, $indent + 1, $out);
|
|
$$out .= ',' if $index < $#keys;
|
|
$$out .= "\n";
|
|
}
|
|
$$out .= (' ' x (2 * $indent)) . '}';
|
|
return;
|
|
}
|
|
if (ref $value eq 'ARRAY') {
|
|
if (!@$value) {
|
|
$$out .= '[]';
|
|
return;
|
|
}
|
|
$$out .= "[\n";
|
|
for my $index (0 .. $#$value) {
|
|
$$out .= ' ' x (2 * ($indent + 1));
|
|
json_render($value->[$index], $path, $indent + 1, $out);
|
|
$$out .= ',' if $index < $#$value;
|
|
$$out .= "\n";
|
|
}
|
|
$$out .= (' ' x (2 * $indent)) . ']';
|
|
return;
|
|
}
|
|
if ($JSON_BOOLEAN{$path}) {
|
|
$$out .= $value ? 'true' : 'false';
|
|
return;
|
|
}
|
|
if (grep { $path =~ $_ } @JSON_STRING_PATHS) {
|
|
$$out .= json_quote("$value");
|
|
return;
|
|
}
|
|
if ($value =~ /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?$/) {
|
|
$$out .= $value;
|
|
return;
|
|
}
|
|
$$out .= json_quote($value);
|
|
return;
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Section: system overview
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub parse_os_release {
|
|
my %info;
|
|
for my $line (@{ read_lines('/etc/os-release') }) {
|
|
next unless index($line, '=') >= 0;
|
|
my ($key, $value) = split /=/, $line, 2;
|
|
$key =~ s/^\s+//;
|
|
$key =~ s/\s+$//;
|
|
$value = '' unless defined $value;
|
|
$value =~ s/^\s+//;
|
|
$value =~ s/\s+$//;
|
|
$value =~ s/^"//;
|
|
$value =~ s/"$//;
|
|
$info{$key} = $value;
|
|
}
|
|
return \%info;
|
|
}
|
|
|
|
sub uptime_human {
|
|
my ($seconds) = @_;
|
|
$seconds = int($seconds);
|
|
my $days = int($seconds / 86400);
|
|
my $rem = $seconds % 86400;
|
|
my $hours = int($rem / 3600);
|
|
my $minutes = int(($rem % 3600) / 60);
|
|
|
|
my @parts;
|
|
push @parts, $days . ' day' . ($days != 1 ? 's' : '') if $days;
|
|
push @parts, $hours . ' hour' . ($hours != 1 ? 's' : '') if $hours;
|
|
push @parts, $minutes . ' minute' . ($minutes != 1 ? 's' : '') if $minutes || !@parts;
|
|
return join(', ', @parts);
|
|
}
|
|
|
|
sub boot_time {
|
|
for my $line (@{ read_lines('/proc/stat') }) {
|
|
next unless index($line, 'btime ') == 0;
|
|
my @fields = split ' ', $line;
|
|
next unless @fields >= 2 && $fields[1] =~ /^\d+$/;
|
|
my @lt = localtime($fields[1] + 0);
|
|
return sprintf('%04d-%02d-%02d %02d:%02d:%02d',
|
|
$lt[5] + 1900, $lt[4] + 1, $lt[3], $lt[2], $lt[1], $lt[0]);
|
|
}
|
|
return 'unknown';
|
|
}
|
|
|
|
sub load_average {
|
|
my $content = read_file('/proc/loadavg');
|
|
my @parts = split ' ', $content;
|
|
return (0.0, 0.0, 0.0) unless @parts >= 3;
|
|
for my $value (@parts[0 .. 2]) {
|
|
return (0.0, 0.0, 0.0) unless $value =~ /^\d+(?:\.\d+)?$/;
|
|
}
|
|
return ($parts[0] + 0, $parts[1] + 0, $parts[2] + 0);
|
|
}
|
|
|
|
sub cpu_count {
|
|
# os.cpu_count() is the online processor count, which /sys reports as a list of
|
|
# ranges.
|
|
my $online = read_file('/sys/devices/system/cpu/online');
|
|
if (length $online) {
|
|
my $count = 0;
|
|
for my $part (split /,/, $online) {
|
|
if ($part =~ /^(\d+)-(\d+)$/) { $count += $2 - $1 + 1 }
|
|
elsif ($part =~ /^\d+$/) { $count += 1 }
|
|
}
|
|
return $count if $count > 0;
|
|
}
|
|
my $processors = 0;
|
|
for my $line (@{ read_lines('/proc/cpuinfo') }) {
|
|
$processors++ if index($line, 'processor') == 0;
|
|
}
|
|
return $processors > 0 ? $processors : 1;
|
|
}
|
|
|
|
sub logged_users {
|
|
my $p = run(['who'], 15);
|
|
my %users;
|
|
for my $line (split /\n/, $p->{out}) {
|
|
next unless length $line;
|
|
my @fields = split ' ', $line;
|
|
$users{ $fields[0] } = 1 if @fields;
|
|
}
|
|
return scalar keys %users;
|
|
}
|
|
|
|
sub collect_overview {
|
|
my $os_info = parse_os_release();
|
|
my $os_name = $os_info->{PRETTY_NAME} // $os_info->{NAME} // 'unknown';
|
|
my ($load1, $load5, $load15) = load_average();
|
|
|
|
my $uptime_seconds = 0.0;
|
|
my $uptime_raw = read_file('/proc/uptime');
|
|
if (length $uptime_raw) {
|
|
my @parts = split ' ', $uptime_raw;
|
|
$uptime_seconds = $parts[0] + 0 if @parts && $parts[0] =~ /^\d+(?:\.\d+)?$/;
|
|
}
|
|
|
|
my $hostname = read_file('/proc/sys/kernel/hostname');
|
|
return {
|
|
hostname => length $hostname ? $hostname : uname('-n'),
|
|
os => $os_name,
|
|
kernel => read_file('/proc/sys/kernel/osrelease'),
|
|
uptime_seconds => $uptime_seconds,
|
|
uptime_human => uptime_human($uptime_seconds),
|
|
load_1min => $load1,
|
|
load_5min => $load5,
|
|
load_15min => $load15,
|
|
cpu_cores => cpu_count(),
|
|
users_logged => logged_users(),
|
|
boot_time => boot_time(),
|
|
};
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Section: CPU
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub cpu_model {
|
|
for my $line (@{ read_lines('/proc/cpuinfo') }) {
|
|
next unless index($line, 'model name') == 0;
|
|
my (undef, $value) = split /:/, $line, 2;
|
|
$value = '' unless defined $value;
|
|
$value =~ s/^\s+//;
|
|
$value =~ s/\s+$//;
|
|
return $value;
|
|
}
|
|
return 'unknown';
|
|
}
|
|
|
|
sub cpu_cores_threads {
|
|
my $cpuinfo = read_file('/proc/cpuinfo');
|
|
my %cores;
|
|
my $threads = 0;
|
|
my $physical_id = '0';
|
|
for my $line (split /\n/, $cpuinfo) {
|
|
if (index($line, 'physical id') == 0) {
|
|
my (undef, $value) = split /:/, $line, 2;
|
|
$value = '' unless defined $value;
|
|
$value =~ s/^\s+//;
|
|
$value =~ s/\s+$//;
|
|
$physical_id = $value;
|
|
}
|
|
elsif (index($line, 'core id') == 0) {
|
|
my (undef, $value) = split /:/, $line, 2;
|
|
$value = '' unless defined $value;
|
|
$value =~ s/^\s+//;
|
|
$value =~ s/\s+$//;
|
|
$cores{"$physical_id:$value"} = 1;
|
|
}
|
|
elsif (index($line, 'processor') == 0) {
|
|
$threads++;
|
|
}
|
|
}
|
|
my $phys = keys %cores ? scalar(keys %cores) : cpu_count();
|
|
return ($phys, $threads ? $threads : cpu_count());
|
|
}
|
|
|
|
sub cpu_frequency {
|
|
for my $line (@{ read_lines('/proc/cpuinfo') }) {
|
|
next unless index(lc($line), 'cpu mhz') >= 0;
|
|
my (undef, $value) = split /:/, $line, 2;
|
|
$value = '' unless defined $value;
|
|
$value =~ s/^\s+//;
|
|
$value =~ s/\s+$//;
|
|
return "$value MHz";
|
|
}
|
|
my $freq = read_file('/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq');
|
|
if (length $freq && $freq =~ /^\d+$/) {
|
|
return sprintf('%.0f MHz', $freq / 1000);
|
|
}
|
|
return 'unknown';
|
|
}
|
|
|
|
sub cpu_temperature {
|
|
my $base = '/sys/class/hwmon';
|
|
return 'unknown' unless -d $base;
|
|
my @entries;
|
|
if (opendir(my $dh, $base)) {
|
|
@entries = sort grep { $_ ne '.' && $_ ne '..' } readdir($dh);
|
|
closedir($dh);
|
|
}
|
|
for my $entry (@entries) {
|
|
my $name = lc(read_file("$base/$entry/name"));
|
|
next unless $name eq 'coretemp' || $name eq 'k10temp'
|
|
|| $name eq 'cpu_thermal' || $name eq 'acpitz';
|
|
for my $index (1 .. 4) {
|
|
my $temp_raw = read_file("$base/$entry/temp${index}_input");
|
|
next unless length $temp_raw;
|
|
return sprintf('%.1f', $temp_raw / 1000.0) . "°C" if $temp_raw =~ /^-?\d+$/;
|
|
}
|
|
}
|
|
return 'unknown';
|
|
}
|
|
|
|
sub parse_cpu_fields {
|
|
my ($line) = @_;
|
|
my @fields = split ' ', $line;
|
|
return () if @fields < 9;
|
|
my @values = @fields[1 .. 8];
|
|
for my $value (@values) {
|
|
return () unless defined $value && $value =~ /^\d+$/;
|
|
}
|
|
return @values;
|
|
}
|
|
|
|
sub read_cpu_times {
|
|
my @aggregate;
|
|
my @per_core;
|
|
for my $line (@{ read_lines('/proc/stat') }) {
|
|
if (index($line, 'cpu ') == 0) {
|
|
@aggregate = parse_cpu_fields($line);
|
|
}
|
|
elsif (substr($line, 0, 3) eq 'cpu' && substr($line, 3, 1) =~ /^\d$/) {
|
|
my @fields = parse_cpu_fields($line);
|
|
push @per_core, \@fields if @fields;
|
|
}
|
|
}
|
|
return (\@aggregate, \@per_core);
|
|
}
|
|
|
|
sub usage_percent {
|
|
my ($times1, $times2) = @_;
|
|
return '0.0' unless @$times1 >= 5 && @$times2 >= 5;
|
|
# iowait counts as idle: a process waiting on disk is not using the CPU.
|
|
my $idle_delta = ($times2->[3] + $times2->[4]) - ($times1->[3] + $times1->[4]);
|
|
my $total_delta = 0;
|
|
$total_delta += $times2->[$_] - $times1->[$_] for 0 .. $#$times2;
|
|
return '0.0' if $total_delta <= 0;
|
|
return sprintf('%.1f', (1 - $idle_delta / $total_delta) * 100);
|
|
}
|
|
|
|
sub cpu_usage {
|
|
my ($sample_interval) = @_;
|
|
$sample_interval = 0.2 unless defined $sample_interval;
|
|
my ($agg1, $cores1) = read_cpu_times();
|
|
select(undef, undef, undef, $sample_interval);
|
|
my ($agg2, $cores2) = read_cpu_times();
|
|
|
|
my @per_core;
|
|
my $count = @$cores1 < @$cores2 ? scalar(@$cores1) : scalar(@$cores2);
|
|
for my $index (0 .. $count - 1) {
|
|
push @per_core, {
|
|
cpu => "$index",
|
|
usage_pct => usage_percent($cores1->[$index], $cores2->[$index]),
|
|
};
|
|
}
|
|
return (usage_percent($agg1, $agg2), \@per_core);
|
|
}
|
|
|
|
sub collect_cpu {
|
|
my ($phys_cores, $threads) = cpu_cores_threads();
|
|
my ($usage, $per_core) = cpu_usage();
|
|
|
|
return {
|
|
model => cpu_model(),
|
|
architecture => uname('-m'),
|
|
physical_cores => $phys_cores,
|
|
threads => $threads,
|
|
frequency => cpu_frequency(),
|
|
temperature => cpu_temperature(),
|
|
usage_pct => $usage,
|
|
per_core => $per_core,
|
|
};
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Section: memory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub parse_meminfo {
|
|
my %mem;
|
|
for my $line (@{ read_lines('/proc/meminfo') }) {
|
|
my @parts = split ' ', $line;
|
|
next unless @parts >= 2;
|
|
my $key = $parts[0];
|
|
$key =~ s/:$//;
|
|
$mem{$key} = $parts[1] + 0 if $parts[1] =~ /^\d+$/;
|
|
}
|
|
return \%mem;
|
|
}
|
|
|
|
sub top_mem_processes {
|
|
my ($count) = @_;
|
|
$count = 5 unless defined $count;
|
|
my $p = run(['ps', '-eo', 'pid,user,rss,comm', '--sort=-rss', '--no-headers'], 5);
|
|
my @procs;
|
|
my @lines = split /\n/, $p->{out};
|
|
for my $line (@lines[0 .. ($count - 1 > $#lines ? $#lines : $count - 1)]) {
|
|
next unless defined $line;
|
|
my @parts = split ' ', $line, 4;
|
|
next unless @parts >= 4;
|
|
next unless $parts[0] =~ /^\d+$/ && $parts[2] =~ /^\d+$/;
|
|
push @procs, {
|
|
pid => $parts[0] + 0,
|
|
user => $parts[1],
|
|
rss_mb => sprintf('%.1f', $parts[2] / 1024),
|
|
command => $parts[3],
|
|
};
|
|
}
|
|
return \@procs;
|
|
}
|
|
|
|
sub collect_memory {
|
|
my $mem = parse_meminfo();
|
|
my %m = map { $_ => ($mem->{$_} // 0) } keys %$mem;
|
|
|
|
my $total = int(($m{MemTotal} // 0) / 1024);
|
|
my $free = int(($m{MemFree} // 0) / 1024);
|
|
my $available = int(($m{MemAvailable} // 0) / 1024);
|
|
my $used = $available ? $total - $available : $total - $free;
|
|
|
|
my $swap_total = int(($m{SwapTotal} // 0) / 1024);
|
|
my $swap_free = int(($m{SwapFree} // 0) / 1024);
|
|
my $swap_used = $swap_total - $swap_free;
|
|
|
|
return {
|
|
total_mb => $total,
|
|
used_mb => $used,
|
|
free_mb => $free,
|
|
available_mb => $available,
|
|
usage_pct => $total > 0 ? sprintf('%.1f', $used / $total * 100) : '0.0',
|
|
swap_total_mb => $swap_total,
|
|
swap_used_mb => $swap_used,
|
|
swap_usage_pct => $swap_total > 0 ? sprintf('%.1f', $swap_used / $swap_total * 100) : '0.0',
|
|
top_processes => top_mem_processes(),
|
|
buffers_mb => int(($m{Buffers} // 0) / 1024),
|
|
cached_mb => int(($m{Cached} // 0) / 1024),
|
|
};
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Section: disk
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub skip_filesystem {
|
|
my ($fs_type, $device) = @_;
|
|
my %excluded = map { $_ => 1 } qw(
|
|
tmpfs devtmpfs squashfs overlay proc sysfs devpts cgroup cgroup2 pstore bpf
|
|
debugfs tracefs securityfs hugetlbfs fuse.gvfsd-fuse fuse.portal ramfs
|
|
efivarfs configfs selinuxfs systemd-1 mqueue fusectl sunrpc binfmt_misc
|
|
autofs rpc_pipefs nsfs
|
|
);
|
|
return 1 if $excluded{$fs_type};
|
|
return 1 if index($device, 'tmpfs') == 0 || index($device, 'devtmpfs') == 0;
|
|
# Network filesystems: stat() blocks in uninterruptible I/O while the server is
|
|
# unreachable, which would stall the whole report.
|
|
for my $prefix ('nfs', 'cifs', 'smbfs', 'ncpfs', 'sshfs', '9p', 'afs') {
|
|
return 1 if index($fs_type, $prefix) == 0;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
# The four numbers statvfs would have given, through stat(1), which reports the
|
|
# same fields: total blocks, free blocks, blocks available to unprivileged users,
|
|
# and the fundamental block size.
|
|
sub filesystem_stats {
|
|
my ($mountpoint) = @_;
|
|
my $p = run(['stat', '-f', '-c', '%b %f %a %S', '--', $mountpoint], 5);
|
|
return undef if $p->{rc} != 0;
|
|
my @fields = split ' ', $p->{out};
|
|
return undef unless @fields >= 4;
|
|
for my $value (@fields[0 .. 3]) {
|
|
return undef unless $value =~ /^\d+$/;
|
|
}
|
|
return {
|
|
blocks => $fields[0] + 0,
|
|
frsize => $fields[3] + 0,
|
|
avail => $fields[2] + 0,
|
|
};
|
|
}
|
|
|
|
# The kernel octal-escapes space, tab, newline and backslash in the mount point
|
|
# field of /proc/mounts, and stat() needs the decoded path.
|
|
sub unescape_mount_point {
|
|
my ($text) = @_;
|
|
$text =~ s/\\(040|011|012|134)/chr(oct($1))/eg;
|
|
return $text;
|
|
}
|
|
|
|
sub mounts {
|
|
my @fs_list;
|
|
my %seen_devices;
|
|
for my $line (@{ read_lines('/proc/mounts') }) {
|
|
my @parts = split ' ', $line;
|
|
next if @parts < 6;
|
|
my ($device, $mountpoint, $fs_type) = @parts[0 .. 2];
|
|
next if skip_filesystem($fs_type, $device);
|
|
$mountpoint = unescape_mount_point($mountpoint);
|
|
my @st = stat($mountpoint);
|
|
next unless @st;
|
|
my $st_dev = $st[0];
|
|
next if $seen_devices{$st_dev}++;
|
|
my $fs = filesystem_stats($mountpoint);
|
|
next unless defined $fs;
|
|
my $total = $fs->{blocks} * $fs->{frsize};
|
|
my $available = $fs->{avail} * $fs->{frsize};
|
|
my $used = $total - $available;
|
|
|
|
push @fs_list, {
|
|
device => $device,
|
|
mountpoint => $mountpoint,
|
|
fstype => $fs_type,
|
|
size_gb => sprintf('%.1f', $total / (1024**3)),
|
|
used_gb => sprintf('%.1f', $used / (1024**3)),
|
|
available_gb => sprintf('%.1f', $available / (1024**3)),
|
|
use_pct => $total > 0 ? sprintf('%.1f', $used / $total * 100) : '0.0',
|
|
};
|
|
}
|
|
return \@fs_list;
|
|
}
|
|
|
|
sub disk_io_stats {
|
|
my $p = run(['iostat', '-d', '-x', '1', '2'], 10);
|
|
my $out = $p->{out};
|
|
return undef unless length $out;
|
|
|
|
# Split into report blocks, each starting at a Device header.
|
|
my @blocks;
|
|
my @current;
|
|
for my $line (split /\n/, $out) {
|
|
if (index($line, 'Device') >= 0 && index($line, 'r/s') >= 0) {
|
|
push @blocks, [@current] if @current;
|
|
@current = ($line);
|
|
}
|
|
elsif (@current) {
|
|
push @current, $line;
|
|
}
|
|
}
|
|
push @blocks, [@current] if @current;
|
|
return undef unless @blocks;
|
|
|
|
my $lines = $blocks[-1];
|
|
my @header = split ' ', $lines->[0];
|
|
my %columns;
|
|
for my $index (0 .. $#header) {
|
|
$columns{ $header[$index] } = $index;
|
|
}
|
|
|
|
my $value = sub {
|
|
my ($parts, @names) = @_;
|
|
for my $name (@names) {
|
|
my $index = $columns{$name};
|
|
next unless defined $index && $index < @$parts;
|
|
return $parts->[$index] =~ /^-?\d+(?:\.\d+)?$/ ? $parts->[$index] + 0 : 0.0;
|
|
}
|
|
return 0.0;
|
|
};
|
|
|
|
# Throughput in MB/s: the kB/s column current sysstat prints is converted, but
|
|
# the MB/s column older sysstat printed is already in the unit asked for, so
|
|
# dividing that one as well would report a value 1024 times too small.
|
|
my $rate_mb_s = sub {
|
|
my ($parts, $kb_name, $mb_name) = @_;
|
|
for my $candidate ([$kb_name, 1024], [$mb_name, 1]) {
|
|
my ($name, $divisor) = @$candidate;
|
|
my $index = $columns{$name};
|
|
next unless defined $index && $index < @$parts;
|
|
return $parts->[$index] =~ /^-?\d+(?:\.\d+)?$/ ? $parts->[$index] / $divisor : 0.0;
|
|
}
|
|
return 0.0;
|
|
};
|
|
|
|
my @devices;
|
|
for my $line (@$lines[1 .. $#$lines]) {
|
|
my @parts = split ' ', $line;
|
|
next unless @parts;
|
|
next if $parts[0] eq 'Device';
|
|
my $reads = $value->(\@parts, 'r/s');
|
|
my $writes = $value->(\@parts, 'w/s');
|
|
push @devices, {
|
|
device => $parts[0],
|
|
tps => sprintf('%.2f', $reads + $writes),
|
|
read_mb_s => sprintf('%.2f', $rate_mb_s->(\@parts, 'rkB/s', 'rMB/s')),
|
|
write_mb_s => sprintf('%.2f', $rate_mb_s->(\@parts, 'wkB/s', 'wMB/s')),
|
|
};
|
|
}
|
|
|
|
return @devices ? { devices => \@devices } : undef;
|
|
}
|
|
|
|
sub in_container {
|
|
return 1 if -e '/run/.containerenv' || -e '/.dockerenv';
|
|
for my $line (@{ read_lines('/proc/mounts') }) {
|
|
my @parts = split ' ', $line;
|
|
return 1 if @parts >= 3 && $parts[1] eq '/' && $parts[2] eq 'overlay';
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
sub collect_disk {
|
|
my $mounts = mounts();
|
|
my $total_gb = 0;
|
|
my $used_gb = 0;
|
|
for my $mount (@$mounts) {
|
|
$total_gb += $mount->{size_gb};
|
|
$used_gb += $mount->{used_gb};
|
|
}
|
|
my @critical = grep { $_->{use_pct} > 90 } @$mounts;
|
|
|
|
return {
|
|
filesystems => $mounts,
|
|
total_gb => sprintf('%.1f', $total_gb),
|
|
used_gb => sprintf('%.1f', $used_gb),
|
|
usage_pct => $total_gb > 0 ? sprintf('%.1f', $used_gb / $total_gb * 100) : '0.0',
|
|
critical_fs => \@critical,
|
|
io_stats => disk_io_stats(),
|
|
container => in_container(),
|
|
};
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Section: network
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub net_interfaces {
|
|
my @ifaces;
|
|
my $net_path = '/sys/class/net';
|
|
return \@ifaces unless -d $net_path;
|
|
my @entries;
|
|
if (opendir(my $dh, $net_path)) {
|
|
@entries = sort grep { $_ ne '.' && $_ ne '..' } readdir($dh);
|
|
closedir($dh);
|
|
}
|
|
|
|
for my $iface (@entries) {
|
|
next if $iface eq 'lo';
|
|
my $operstate = read_file("$net_path/$iface/operstate");
|
|
my $speed_raw = read_file("$net_path/$iface/speed");
|
|
my $speed = length($speed_raw) && $speed_raw ne '-1' ? "$speed_raw Mbps" : 'unknown';
|
|
|
|
my $p = run(['ip', '-br', 'addr', 'show', $iface], 5);
|
|
my (@ipv4, @ipv6);
|
|
my $out = $p->{out};
|
|
if (length $out) {
|
|
my @parts = split ' ', $out;
|
|
my $skip_next = 0;
|
|
for my $part (@parts[2 .. $#parts]) {
|
|
next unless defined $part;
|
|
if ($part eq 'peer') {
|
|
$skip_next = 1; # the next token is the remote end's address
|
|
next;
|
|
}
|
|
if ($skip_next) { $skip_next = 0; next }
|
|
if (index($part, '.') >= 0 && index($part, '/') >= 0) {
|
|
my ($address) = split /\//, $part, 2;
|
|
push @ipv4, $address;
|
|
}
|
|
elsif (index($part, ':') >= 0 && index($part, '/') >= 0) {
|
|
my ($address) = split /\//, $part, 2;
|
|
push @ipv6, $address;
|
|
}
|
|
}
|
|
}
|
|
|
|
push @ifaces, {
|
|
name => $iface,
|
|
state => length($operstate) ? uc($operstate) : 'UNKNOWN',
|
|
speed => $speed,
|
|
ipv4 => \@ipv4,
|
|
ipv6 => \@ipv6,
|
|
};
|
|
}
|
|
return \@ifaces;
|
|
}
|
|
|
|
sub connection_summary {
|
|
my $p = run(['ss', '-s'], 5);
|
|
my %result;
|
|
for my $line (split /\n/, $p->{out}) {
|
|
if (index($line, 'TCP:') >= 0) {
|
|
$result{tcp_established} = $1 + 0 if $line =~ /estab\s+(\d+)/;
|
|
}
|
|
if (index($line, 'Total:') >= 0) {
|
|
$result{total} = $1 + 0 if $line =~ /(\d+)/;
|
|
}
|
|
}
|
|
return \%result;
|
|
}
|
|
|
|
sub default_routes {
|
|
my %routes = (v6 => '', v4 => '');
|
|
my $p = run(['ip', '-6', 'route', 'show', 'default'], 5);
|
|
my $out = $p->{out};
|
|
$out =~ s/^\s+//;
|
|
$out =~ s/\s+$//;
|
|
$routes{v6} = $out if length $out;
|
|
$p = run(['ip', '-4', 'route', 'show', 'default'], 5);
|
|
$out = $p->{out};
|
|
$out =~ s/^\s+//;
|
|
$out =~ s/\s+$//;
|
|
$routes{v4} = $out if length $out;
|
|
return \%routes;
|
|
}
|
|
|
|
# Whether a string is an IPv4 or an IPv6 address.
|
|
sub is_address {
|
|
my ($text) = @_;
|
|
return 0 unless length $text;
|
|
if (index($text, ':') >= 0) {
|
|
return _valid_ipv6($text);
|
|
}
|
|
my @octets = split /\./, $text, -1;
|
|
return 0 unless @octets == 4;
|
|
for my $octet (@octets) {
|
|
return 0 unless $octet =~ /^\d{1,3}$/ && $octet <= 255;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
sub _valid_ipv6 {
|
|
my ($ip) = @_;
|
|
$ip =~ s/%.*//;
|
|
return 0 unless $ip =~ /^[0-9a-fA-F:.]+$/ && index($ip, ':') >= 0;
|
|
my ($head, $tail) = ($ip, '');
|
|
my $shorthand = 0;
|
|
if (index($ip, '::') >= 0) {
|
|
($head, $tail) = split /::/, $ip, 2;
|
|
$shorthand = 1;
|
|
}
|
|
my @head = length($head // '') ? split(/:/, $head, -1) : ();
|
|
my @tail = length($tail // '') ? split(/:/, $tail, -1) : ();
|
|
if (@tail && $tail[-1] =~ /\./) {
|
|
my $quad = pop @tail;
|
|
my @octets = split /\./, $quad, -1;
|
|
return 0 unless @octets == 4;
|
|
for my $octet (@octets) {
|
|
return 0 unless $octet =~ /^\d{1,3}$/ && $octet <= 255;
|
|
}
|
|
push @tail, '0', '0';
|
|
}
|
|
return 0 if @head + @tail > ($shorthand ? 7 : 8);
|
|
my @groups = $shorthand
|
|
? (@head, (0) x (8 - @head - @tail), @tail)
|
|
: (@head, @tail);
|
|
return 0 unless @groups == 8;
|
|
for my $group (@groups) {
|
|
return 0 unless $group =~ /^[0-9a-fA-F]{1,4}$/;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
sub external_ip {
|
|
my %result = (v6 => 'unknown', v4 => 'unknown');
|
|
for my $item (['v6', '-6'], ['v4', '-4']) {
|
|
my ($family, $flag) = @$item;
|
|
# -f fails on HTTP errors; without it a captive portal or an error page
|
|
# would be accepted as the external address.
|
|
my $p = run(['curl', $flag, '--max-time', '5', '-fsS', 'https://icanhazip.com'], 8);
|
|
my $body = $p->{out};
|
|
$body =~ s/^\s+//;
|
|
$body =~ s/\s+$//;
|
|
next unless is_address($body);
|
|
$result{$family} = $body;
|
|
}
|
|
return \%result;
|
|
}
|
|
|
|
sub collect_network {
|
|
my ($include_external) = @_;
|
|
my %data = (
|
|
interfaces => net_interfaces(),
|
|
connections => connection_summary(),
|
|
routes => default_routes(),
|
|
);
|
|
$data{external_ip} = external_ip() if $include_external;
|
|
return \%data;
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Section: GPU
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# The vendor side of the GPU section reports both sources together: AMD in detail
|
|
# through rocm-smi, which brings memory and utilisation, and AMD, Intel and Huawei
|
|
# cards through lspci, which needs no vendor tooling at all. A compute device with
|
|
# no display class, such as an Instinct or an Ascend, is reached through its PCI
|
|
# identifier instead, and an AMD card rocm-smi has already described is not listed
|
|
# twice.
|
|
|
|
# Matches rocm-smi data lines across ROCm versions, with or without the bracketed
|
|
# card index: "GPU[0]\t\t: Card series:\t…" and "GPU\t\t: …".
|
|
my $ROCM_LINE = qr/^GPU(?:\[(\d+)\])?\s*:\s*([^:]+?)\s*:\s*(.+)$/;
|
|
|
|
sub rocm_smi_bin {
|
|
my $found = find_exe('rocm-smi');
|
|
return $found if defined $found;
|
|
my $default = '/opt/rocm/bin/rocm-smi';
|
|
return -f $default ? $default : undef;
|
|
}
|
|
|
|
sub rocm_vram {
|
|
my ($binary) = @_;
|
|
my $p = run([$binary, '--showmeminfo', 'vram'], 10);
|
|
my $out = $p->{out};
|
|
return undef unless length $out;
|
|
|
|
my ($total_bytes, $used_bytes) = (0, 0);
|
|
for my $line (split /\n/, $out) {
|
|
my $stripped = $line;
|
|
$stripped =~ s/^\s+//;
|
|
$stripped =~ s/\s+$//;
|
|
next unless $stripped =~ $ROCM_LINE;
|
|
my $key = $2;
|
|
$key =~ s/^\s+//;
|
|
$key =~ s/\s+$//;
|
|
my $raw = $3;
|
|
my @fields = split ' ', $raw;
|
|
next unless @fields && $fields[0] =~ /^\d+$/;
|
|
my $value = $fields[0] + 0;
|
|
# Keys are matched exactly: "VRAM Total Used Memory (B)" contains both words
|
|
# "Total" and "Used", so substring matching would double-count.
|
|
if ($key eq 'VRAM Total Used Memory (B)') { $used_bytes += $value }
|
|
elsif ($key eq 'VRAM Total Memory (B)') { $total_bytes += $value }
|
|
}
|
|
|
|
return undef if $total_bytes <= 0;
|
|
my $mib = 1024 * 1024;
|
|
return {
|
|
total_mb => int($total_bytes / $mib),
|
|
used_mb => int($used_bytes / $mib),
|
|
free_mb => int(($total_bytes - $used_bytes) / $mib),
|
|
};
|
|
}
|
|
|
|
sub amd_gpu {
|
|
my $binary = rocm_smi_bin();
|
|
return undef unless defined $binary;
|
|
my $p = run([$binary, '--showproductname', '--showuse'], 10);
|
|
my $out = $p->{out};
|
|
return undef unless length $out;
|
|
|
|
# Every rocm-smi data line is prefixed with "GPU[N] :", so the pairs are grouped
|
|
# by card: looping per line would make one card per line.
|
|
my %cards;
|
|
for my $line (split /\n/, $out) {
|
|
my $stripped = $line;
|
|
$stripped =~ s/^\s+//;
|
|
$stripped =~ s/\s+$//;
|
|
next unless $stripped =~ $ROCM_LINE;
|
|
my $card_id = defined $1 && length $1 ? $1 : '0';
|
|
my $key = $2;
|
|
$key =~ s/^\s+//;
|
|
$key =~ s/\s+$//;
|
|
my $value = $3;
|
|
$value =~ s/^\s+//;
|
|
$value =~ s/\s+$//;
|
|
$cards{$card_id} = { id => $card_id } unless exists $cards{$card_id};
|
|
$cards{$card_id}{$key} = $value;
|
|
}
|
|
|
|
return undef unless %cards;
|
|
my %info = (vendor => 'AMD', cards => [ map { $cards{$_} } sort keys %cards ]);
|
|
my $vram = rocm_vram($binary);
|
|
$info{vram} = $vram if $vram;
|
|
return \%info;
|
|
}
|
|
|
|
sub lspci_gpu {
|
|
return { unavailable => 1 } unless defined find_exe('lspci');
|
|
my $p = run(['lspci'], 10);
|
|
my $out = $p->{out};
|
|
return { unavailable => 1 } unless length $out;
|
|
my @cards;
|
|
for my $line (split /\n/, $out) {
|
|
my $lower = lc($line);
|
|
next unless index($lower, 'vga') >= 0 || index($lower, 'display') >= 0
|
|
|| index($lower, '3d') >= 0;
|
|
my @parts = split /: /, $line, 3;
|
|
my $model = @parts >= 2 ? $parts[-1] : $line;
|
|
$model =~ s/^\s+//;
|
|
$model =~ s/\s+$//;
|
|
my $vendor = '';
|
|
# The vendor names are matched as words, never as substrings: "ati" is
|
|
# inside "Corporation", so a substring match would report every Intel card
|
|
# as AMD.
|
|
$vendor = 'AMD' if $lower =~ /\b(?:amd|radeon|ati)\b/;
|
|
$vendor = 'Intel' if !length $vendor && $lower =~ /\bintel\b/;
|
|
$vendor = 'Huawei' if !length $vendor && $lower =~ /\b(?:huawei|hisilicon|ascend)\b/;
|
|
push @cards, { model => $model, vendor => $vendor, driver => '(not loaded)' };
|
|
}
|
|
if (!@cards) {
|
|
# Probe for compute devices that carry no display class, such as an AMD
|
|
# Instinct or a Huawei Ascend: they answer to the vendor's PCI identifier and
|
|
# would otherwise be missed entirely.
|
|
for my $probe (['1002:', 'AMD'], ['19e5:', 'Huawei']) {
|
|
my ($id, $vendor) = @$probe;
|
|
my $p2 = run(['lspci', '-d', $id], 10);
|
|
for my $line (split /\n/, $p2->{out}) {
|
|
my $stripped = $line;
|
|
$stripped =~ s/^\s+//;
|
|
$stripped =~ s/\s+$//;
|
|
next unless length $stripped;
|
|
push @cards, { model => $stripped, vendor => $vendor, driver => '(not loaded)' };
|
|
}
|
|
}
|
|
}
|
|
return undef unless @cards;
|
|
return {
|
|
vendor => length($cards[0]{vendor}) ? $cards[0]{vendor} : 'unknown',
|
|
cards => \@cards,
|
|
fallback => 1,
|
|
};
|
|
}
|
|
|
|
# Both sources are reported together: the cards rocm-smi describes come first,
|
|
# with their memory and utilisation, and every other card lspci can see is
|
|
# appended, so a machine with an AMD card and an Intel or Huawei one reports all
|
|
# of them. The fallback flag still means what it means: nothing but lspci
|
|
# answered.
|
|
sub collect_gpu {
|
|
my $amd = amd_gpu();
|
|
my $lspci = lspci_gpu();
|
|
my $extra = [];
|
|
if ($lspci && !$lspci->{unavailable}) {
|
|
# An AMD card rocm-smi already described must not be listed twice, but with no
|
|
# rocm-smi answer lspci's AMD card is the only one there is.
|
|
$extra = [ grep { !$amd || !length $_->{vendor} || $_->{vendor} ne 'AMD' }
|
|
@{ $lspci->{cards} } ];
|
|
}
|
|
|
|
my @cards = (($amd ? @{ $amd->{cards} } : ()), @$extra);
|
|
return $lspci unless @cards;
|
|
|
|
# The AMD cards come from rocm-smi, which does not label them with a vendor, so
|
|
# the summary takes AMD from its source and the rest from the cards themselves.
|
|
my @vendor_list = $amd ? ('AMD') : ();
|
|
push @vendor_list, map { $_->{vendor} // '' } @$extra;
|
|
my %seen_vendor;
|
|
my @vendors = grep { length && !$seen_vendor{$_}++ } @vendor_list;
|
|
my %info = (
|
|
vendor => @vendors ? join(', ', @vendors) : 'unknown',
|
|
cards => \@cards,
|
|
);
|
|
$info{vram} = $amd->{vram} if $amd && $amd->{vram};
|
|
$info{fallback} = 1 unless $amd;
|
|
return \%info;
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Section: services
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub failed_units {
|
|
return undef unless defined find_exe('systemctl');
|
|
# --plain is required: without it systemd prefixes failed units with a bullet
|
|
# even when the output is piped, which breaks field parsing.
|
|
my $p = run(['systemctl', '--failed', '--no-legend', '--no-pager', '--plain'], 10);
|
|
my $out = $p->{out};
|
|
$out =~ s/^\s+//;
|
|
$out =~ s/\s+$//;
|
|
my @units;
|
|
for my $line (split /\n/, $out) {
|
|
# The columns are unit, load, active, sub, description: the unit's own state
|
|
# is the sub column, not the load column.
|
|
my @parts = split ' ', $line, 5;
|
|
next if @parts < 4;
|
|
push @units, { unit => $parts[0], state => $parts[3] };
|
|
}
|
|
return \@units;
|
|
}
|
|
|
|
sub check_service {
|
|
my ($name) = @_;
|
|
my $p = run(['systemctl', 'is-active', $name, '--no-pager'], 5);
|
|
my $status = $p->{out};
|
|
$status =~ s/^\s+//;
|
|
$status =~ s/\s+$//;
|
|
return { name => $name, status => length $status ? $status : 'not-found' };
|
|
}
|
|
|
|
sub collect_services {
|
|
my @key_services = qw(sshd firewalld nginx postgresql);
|
|
my %svc_status;
|
|
$svc_status{$_} = check_service($_) for @key_services;
|
|
return {
|
|
failed_units => failed_units(),
|
|
key_services => \%svc_status,
|
|
};
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Section: security
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub selinux_status {
|
|
my $p = run(['getenforce'], 5);
|
|
my $out = $p->{out};
|
|
$out =~ s/^\s+//;
|
|
$out =~ s/\s+$//;
|
|
return length $out ? $out : 'unknown';
|
|
}
|
|
|
|
sub firewalld_info {
|
|
my %result = (available => 0, active => 0, default_zone => '', zones => []);
|
|
my $p = run(['firewall-cmd', '--state'], 5);
|
|
my $state = $p->{out};
|
|
$state =~ s/^\s+//;
|
|
$state =~ s/\s+$//;
|
|
return \%result unless length $state;
|
|
$result{available} = 1;
|
|
$result{active} = $state eq 'running' ? 1 : 0;
|
|
|
|
$p = run(['firewall-cmd', '--get-default-zone'], 5);
|
|
my $zone = $p->{out};
|
|
$zone =~ s/^\s+//;
|
|
$zone =~ s/\s+$//;
|
|
$result{default_zone} = $zone if length $zone;
|
|
|
|
$p = run(['firewall-cmd', '--get-active-zones'], 5);
|
|
my $out = $p->{out};
|
|
if (length $out) {
|
|
for my $line (split /\n/, $out) {
|
|
my $stripped = $line;
|
|
$stripped =~ s/^\s+//;
|
|
$stripped =~ s/\s+$//;
|
|
# Zone names sit at column 0; member lines are indented, so the
|
|
# indentation has to be checked before stripping.
|
|
next unless length $stripped;
|
|
next if index($line, ' ') == 0 || index($line, "\t") == 0;
|
|
push @{ $result{zones} }, $stripped;
|
|
}
|
|
}
|
|
return \%result;
|
|
}
|
|
|
|
sub failed_ssh_attempts {
|
|
my $p = run(['journalctl', '-u', 'sshd', '--since', 'today', '--no-pager'], 10);
|
|
my $out = $p->{out};
|
|
if (!length $out) {
|
|
# Fall back to the "ssh" unit name used by some distributions.
|
|
$p = run(['journalctl', '-u', 'ssh', '--since', 'today', '--no-pager'], 10);
|
|
$out = $p->{out};
|
|
}
|
|
my $lower = lc($out);
|
|
my $count = () = $lower =~ /failed password/g;
|
|
return $count;
|
|
}
|
|
|
|
sub last_logins {
|
|
my ($count) = @_;
|
|
$count = 3 unless defined $count;
|
|
my $p = run(['last', '-n', "$count", '--no-hostname'], 5);
|
|
my @logins;
|
|
for my $line (split /\n/, $p->{out}) {
|
|
next unless length $line;
|
|
my $lower = lc($line);
|
|
next if index($lower, 'reboot') >= 0 || index($lower, 'wtmp') >= 0;
|
|
$line =~ s/^\s+//;
|
|
$line =~ s/\s+$//;
|
|
push @logins, $line;
|
|
last if @logins >= $count;
|
|
}
|
|
return \@logins;
|
|
}
|
|
|
|
sub open_ports_count {
|
|
my $p = run(['ss', '-tlnp'], 5);
|
|
my $out = $p->{out};
|
|
$out =~ s/^\s+//;
|
|
$out =~ s/\s+$//;
|
|
my @lines = grep { length && index($_, 'LISTEN') >= 0 } split /\n/, $out;
|
|
return scalar @lines;
|
|
}
|
|
|
|
sub collect_security {
|
|
return {
|
|
selinux => selinux_status(),
|
|
firewalld => firewalld_info(),
|
|
failed_ssh_attempts => failed_ssh_attempts(),
|
|
last_logins => last_logins(),
|
|
open_ports_count => open_ports_count(),
|
|
};
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Section: performance
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub process_counts {
|
|
my ($total, $zombies) = (0, 0);
|
|
my @entries;
|
|
if (opendir(my $dh, '/proc')) {
|
|
@entries = grep { /^\d+$/ } readdir($dh);
|
|
closedir($dh);
|
|
}
|
|
for my $entry (@entries) {
|
|
$total++;
|
|
my $status = read_file("/proc/$entry/status");
|
|
for my $line (split /\n/, $status) {
|
|
next unless index($line, 'State:') == 0;
|
|
$zombies++ if index($line, 'Z') >= 0;
|
|
last;
|
|
}
|
|
}
|
|
return { total => $total, zombies => $zombies };
|
|
}
|
|
|
|
sub top_cpu_processes {
|
|
my ($count) = @_;
|
|
$count = 5 unless defined $count;
|
|
my $p = run(['ps', '-eo', 'pid,user,%cpu,comm', '--sort=-%cpu', '--no-headers'], 5);
|
|
my @procs;
|
|
my @lines = split /\n/, $p->{out};
|
|
for my $line (@lines[0 .. ($count - 1 > $#lines ? $#lines : $count - 1)]) {
|
|
next unless defined $line;
|
|
my @parts = split ' ', $line, 4;
|
|
next unless @parts >= 4;
|
|
next unless $parts[0] =~ /^\d+$/ && $parts[2] =~ /^-?\d+(?:\.\d+)?$/;
|
|
push @procs, {
|
|
pid => $parts[0] + 0,
|
|
user => $parts[1],
|
|
cpu_pct => sprintf('%.1f', $parts[2]),
|
|
command => $parts[3],
|
|
};
|
|
}
|
|
return \@procs;
|
|
}
|
|
|
|
sub context_switches {
|
|
my %result = (ctxt => 0, intr => 0);
|
|
for my $line (@{ read_lines('/proc/stat') }) {
|
|
if (index($line, 'ctxt ') == 0) {
|
|
my @fields = split ' ', $line;
|
|
$result{ctxt} = $fields[1] + 0 if @fields >= 2 && $fields[1] =~ /^\d+$/;
|
|
}
|
|
if (index($line, 'intr ') == 0) {
|
|
my @fields = split ' ', $line;
|
|
$result{intr} = $fields[1] + 0 if @fields >= 2 && $fields[1] =~ /^\d+$/;
|
|
}
|
|
}
|
|
return \%result;
|
|
}
|
|
|
|
sub collect_performance {
|
|
my $counts = process_counts();
|
|
my ($load1) = load_average();
|
|
my $cores = cpu_count();
|
|
|
|
return {
|
|
processes_total => $counts->{total},
|
|
zombies => $counts->{zombies},
|
|
top_cpu_processes => top_cpu_processes(),
|
|
context_switches => context_switches(),
|
|
load_vs_cores_ratio => sprintf('%.2f', $load1 / $cores),
|
|
};
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Section: recent issues
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub _journal_lines {
|
|
my ($cmd, $timeout, $keep_tail) = @_;
|
|
my $p = run($cmd, $timeout);
|
|
my $out = $p->{out};
|
|
$out =~ s/^\s+//;
|
|
$out =~ s/\s+$//;
|
|
my @lines;
|
|
for my $line (split /\n/, $out) {
|
|
my $stripped = $line;
|
|
$stripped =~ s/^\s+//;
|
|
$stripped =~ s/\s+$//;
|
|
next unless length $stripped;
|
|
next if index($stripped, '-- No entries --') >= 0;
|
|
next if index($stripped, 'No entries') >= 0;
|
|
push @lines, $stripped;
|
|
}
|
|
return [] unless @lines;
|
|
# Most journals keep their first ten lines and the OOM journal its last five,
|
|
# without running off the end of a short answer.
|
|
my $count = $keep_tail ? 5 : 10;
|
|
if ($#lines > $count - 1) {
|
|
return $keep_tail ? [@lines[$#lines - $count + 1 .. $#lines]] : [@lines[0 .. $count - 1]];
|
|
}
|
|
return \@lines;
|
|
}
|
|
|
|
sub collect_issues {
|
|
my $recent_errors = _journal_lines(
|
|
['journalctl', '-p', 'err', '-n', '10', '--no-pager'], 10, 0);
|
|
my $kernel_oops = _journal_lines(
|
|
['journalctl', '-k', '--no-pager', '--grep', 'Oops|BUG', '--output', 'short'], 10, 0);
|
|
$kernel_oops = [@$kernel_oops[0 .. 4]] if @$kernel_oops > 5;
|
|
|
|
# Also check for a kernel panic.
|
|
if (!@$kernel_oops) {
|
|
$kernel_oops = _journal_lines(
|
|
['journalctl', '-k', '--no-pager', '--grep', 'panic', '--output', 'short'], 10, 0);
|
|
$kernel_oops = [@$kernel_oops[0 .. 4]] if @$kernel_oops > 5;
|
|
}
|
|
|
|
my $oom_events = _journal_lines(
|
|
['journalctl', '-k', '--no-pager', '--grep', 'Out of memory', '--output', 'short'],
|
|
10, 1);
|
|
|
|
return {
|
|
recent_errors => $recent_errors,
|
|
kernel_oops => $kernel_oops,
|
|
oom_events => $oom_events,
|
|
};
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Health score
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Each factor is worth a fixed maximum: CPU 10, memory 10, disk 15, SELinux 10,
|
|
# failed services 15, load 10, zombies 10, swap 10, firewall 10. A factor whose
|
|
# data source is unavailable is excluded and the score is renormalised against the
|
|
# remaining maximum, so missing tooling never reads as a security failure, while an
|
|
# explicitly disabled control still scores nothing.
|
|
sub compute_health {
|
|
my ($data) = @_;
|
|
my $earned = 0;
|
|
my $possible = 0;
|
|
my (%breakdown, %maxima, @skipped);
|
|
|
|
my $factor = sub {
|
|
my ($name, $points, $maximum) = @_;
|
|
if (!defined $points) {
|
|
push @skipped, $name;
|
|
return;
|
|
}
|
|
$breakdown{$name} = $points;
|
|
$maxima{$name} = $maximum;
|
|
$earned += $points;
|
|
$possible += $maximum;
|
|
return;
|
|
};
|
|
|
|
# Each section is read through a lexical first. A chained lookup such as
|
|
# $data->{cpu}{usage_pct} would autovivify $data->{cpu} into an empty hash when
|
|
# the section was never collected, and the report would then print a section
|
|
# full of unknowns instead of treating it as absent.
|
|
my $cpu_data = $data->{cpu} // {};
|
|
my $cpu_pct = $cpu_data->{usage_pct};
|
|
my $cpu_points;
|
|
if (defined $cpu_pct) {
|
|
$cpu_points = $cpu_pct < 80 ? 10 : $cpu_pct < 90 ? 8 : $cpu_pct < 95 ? 4 : 0;
|
|
}
|
|
$factor->('cpu_usage', $cpu_points, 10);
|
|
|
|
my $mem_data = $data->{memory} // {};
|
|
my $mem_pct = $mem_data->{usage_pct};
|
|
my $mem_points;
|
|
if (defined $mem_pct) {
|
|
$mem_points = $mem_pct < 85 ? 10 : $mem_pct < 90 ? 8 : $mem_pct < 95 ? 4 : 0;
|
|
}
|
|
$factor->('memory_usage', $mem_points, 10);
|
|
|
|
my $disk_data = $data->{disk} // {};
|
|
my $critical = $disk_data->{critical_fs};
|
|
my $disk_points;
|
|
if (defined $critical) {
|
|
$disk_points = !@$critical ? 15 : @$critical == 1 ? 10 : 0;
|
|
}
|
|
$factor->('disk_usage', $disk_points, 15);
|
|
|
|
# "unknown" means getenforce is not installed, so the factor is not assessable.
|
|
my $security_data = $data->{security} // {};
|
|
my $selinux = $security_data->{selinux};
|
|
$selinux = 'unknown' unless defined $selinux;
|
|
my $selinux_points;
|
|
if ($selinux ne 'unknown') {
|
|
$selinux_points = $selinux eq 'Enforcing' ? 10 : $selinux eq 'Permissive' ? 5 : 0;
|
|
}
|
|
$factor->('selinux', $selinux_points, 10);
|
|
|
|
# undef means systemctl is not installed, so the factor is not assessable.
|
|
my $services_data = $data->{services} // {};
|
|
my $failed = $services_data->{failed_units};
|
|
my $svc_points;
|
|
if (defined $failed) {
|
|
my $count = scalar @$failed;
|
|
$svc_points = $count == 0 ? 15 : $count <= 2 ? 10 : $count <= 5 ? 5 : 0;
|
|
}
|
|
$factor->('services', $svc_points, 15);
|
|
|
|
my $overview_data = $data->{overview} // {};
|
|
my $load1 = $overview_data->{load_1min};
|
|
my $cores = $overview_data->{cpu_cores} // 1;
|
|
$cores = 1 unless $cores;
|
|
my $load_points;
|
|
if (defined $load1) {
|
|
$load_points = $load1 < $cores ? 10 : $load1 < $cores * 2 ? 5 : 0;
|
|
}
|
|
$factor->('load', $load_points, 10);
|
|
|
|
my $performance_data = $data->{performance} // {};
|
|
my $zombies = $performance_data->{zombies};
|
|
my $zombie_points;
|
|
if (defined $zombies) {
|
|
$zombie_points = $zombies == 0 ? 10 : $zombies <= 3 ? 5 : 0;
|
|
}
|
|
$factor->('zombies', $zombie_points, 10);
|
|
|
|
my $swap_pct = $mem_data->{swap_usage_pct};
|
|
my $swap_points;
|
|
if (defined $swap_pct) {
|
|
$swap_points = $swap_pct < 50 ? 10 : $swap_pct < 75 ? 5 : 0;
|
|
}
|
|
$factor->('swap', $swap_points, 10);
|
|
|
|
# available false means firewall-cmd is not installed, so not assessable.
|
|
my $fw = $security_data->{firewalld} // {};
|
|
my $fw_points;
|
|
if ($fw->{available}) {
|
|
$fw_points = $fw->{active} ? 10 : 0;
|
|
}
|
|
$factor->('firewall', $fw_points, 10);
|
|
|
|
# Nothing assessable at all, which a subset run or a failed collection can
|
|
# produce, must not read as a failing grade.
|
|
if ($possible <= 0) {
|
|
return {
|
|
score => undef,
|
|
max_score => 100,
|
|
grade => 'N/A',
|
|
breakdown => \%breakdown,
|
|
maxima => \%maxima,
|
|
factors_skipped => \@skipped,
|
|
};
|
|
}
|
|
|
|
my $score = sprintf('%.0f', 100 * $earned / $possible);
|
|
my $letter = $score >= 90 ? 'A' : $score >= 80 ? 'B' : $score >= 70 ? 'C'
|
|
: $score >= 60 ? 'D' : 'F';
|
|
|
|
return {
|
|
score => $score + 0,
|
|
max_score => 100,
|
|
grade => $letter,
|
|
breakdown => \%breakdown,
|
|
maxima => \%maxima,
|
|
factors_skipped => \@skipped,
|
|
};
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Human-readable report
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub section_header {
|
|
my ($title) = @_;
|
|
print "\n${BOLD}── $title ──$RESET\n";
|
|
return;
|
|
}
|
|
|
|
sub kv {
|
|
my ($label, $value, $colour) = @_;
|
|
$colour = '' unless defined $colour;
|
|
if (length $colour) {
|
|
printf " %-22s %s%s%s\n", $label, $colour, $value, $RESET;
|
|
}
|
|
else {
|
|
printf " %-22s %s\n", $label, $value;
|
|
}
|
|
return;
|
|
}
|
|
|
|
sub kv_status {
|
|
my ($label, $ok, $ok_text, $fail_text) = @_;
|
|
$ok_text = 'yes' unless defined $ok_text;
|
|
$fail_text = 'no' unless defined $fail_text;
|
|
if ($ok) {
|
|
printf " %-22s %s%s%s\n", $label, $GREEN, $ok_text, $RESET;
|
|
}
|
|
else {
|
|
printf " %-22s %s%s%s\n", $label, $RED, $fail_text, $RESET;
|
|
}
|
|
return;
|
|
}
|
|
|
|
sub print_report {
|
|
my ($data) = @_;
|
|
my $health = $data->{health} // {};
|
|
my $grade_letter = defined $health->{grade} ? $health->{grade} : '?';
|
|
my $grade_colour = $GRADE_COLOURS{$grade_letter} // $RESET;
|
|
my $health_score_text = !defined $health->{score}
|
|
? 'not assessed'
|
|
: '(' . ($health->{score} // '?') . '/' . ($health->{max_score} // 100) . ')';
|
|
|
|
# Sections in canonical order; the numbers adapt to --section subsets.
|
|
my @order = grep { exists $data->{$_} } @ALL_SECTIONS;
|
|
my %position;
|
|
$position{ $order[$_] } = $_ for 0 .. $#order;
|
|
|
|
my $bar = "═" x $WIDTH;
|
|
|
|
# Header
|
|
print "\n";
|
|
print "${BOLD}${bar}$RESET\n";
|
|
my $overview = $data->{overview} // {};
|
|
print " ${BOLD}System Diagnostics v$VERSION$RESET\n";
|
|
print "${BOLD}${bar}$RESET\n";
|
|
print ' Hostname: ' . ($overview->{hostname} // 'unknown') . "\n";
|
|
my @now = localtime(time());
|
|
printf " Date: %04d-%02d-%02d %02d:%02d:%02d\n",
|
|
$now[5] + 1900, $now[4] + 1, $now[3], $now[2], $now[1], $now[0];
|
|
print " Health: ${grade_colour}${BOLD}${grade_letter}$RESET $health_score_text\n";
|
|
|
|
# System overview
|
|
if (exists $data->{overview}) {
|
|
section_header(($position{overview} + 1) . '. System Overview');
|
|
my $o = $data->{overview};
|
|
kv('OS', $o->{os} // 'unknown');
|
|
kv('Kernel', $o->{kernel} // 'unknown');
|
|
kv('Uptime', $o->{uptime_human} // 'unknown');
|
|
kv('Boot time', $o->{boot_time} // 'unknown');
|
|
|
|
my $load1 = $o->{load_1min} // 0;
|
|
my $load5 = $o->{load_5min} // 0;
|
|
my $load15 = $o->{load_15min} // 0;
|
|
my $cores = $o->{cpu_cores} // 1;
|
|
my $load_col = $load1 < $cores ? $GREEN : $load1 < $cores * 2 ? $YELLOW : $RED;
|
|
kv('Load avg (1/5/15m)',
|
|
sprintf('%s%.2f / %.2f / %.2f%s', $load_col, $load1, $load5, $load15, $RESET));
|
|
kv('Users logged in', $o->{users_logged} // 0);
|
|
}
|
|
|
|
# CPU
|
|
if (exists $data->{cpu}) {
|
|
section_header(($position{cpu} + 1) . '. CPU');
|
|
my $c = $data->{cpu};
|
|
kv('Model', $c->{model} // 'unknown');
|
|
kv('Architecture', $c->{architecture} // 'unknown');
|
|
kv('Cores / Threads', ($c->{physical_cores} // 0) . ' physical / '
|
|
. ($c->{threads} // 0) . ' logical');
|
|
kv('Frequency', $c->{frequency} // 'unknown');
|
|
kv('Temperature', $c->{temperature} // 'unknown');
|
|
my $usage = $c->{usage_pct} // 0;
|
|
my $usage_col = $usage < 80 ? $GREEN : $usage < 90 ? $YELLOW : $RED;
|
|
kv('CPU usage', "$usage_col$usage%$RESET " . _bar($usage));
|
|
|
|
my $per_core = $c->{per_core} // [];
|
|
if (@$per_core) {
|
|
print " ${BOLD}Per-core usage:$RESET\n";
|
|
for my $core (@$per_core) {
|
|
my $core_usage = $core->{usage_pct};
|
|
my $core_col = $core_usage < 80 ? $GREEN : $core_usage < 90 ? $YELLOW : $RED;
|
|
printf " cpu%-4s %s%5.1f%%%s %s\n",
|
|
$core->{cpu}, $core_col, $core_usage, $RESET, _bar($core_usage);
|
|
}
|
|
}
|
|
}
|
|
|
|
# Memory
|
|
if (exists $data->{memory}) {
|
|
section_header(($position{memory} + 1) . '. Memory');
|
|
my $m = $data->{memory};
|
|
kv('Total', ($m->{total_mb} // 0) . ' MiB');
|
|
kv('Used', ($m->{used_mb} // 0) . ' MiB ' . _bar($m->{usage_pct} // 0));
|
|
kv('Available', ($m->{available_mb} // 0) . ' MiB');
|
|
kv('Buffers', ($m->{buffers_mb} // 0) . ' MiB');
|
|
kv('Cached', ($m->{cached_mb} // 0) . ' MiB');
|
|
|
|
my $swap_total = $m->{swap_total_mb} // 0;
|
|
my $swap_used = $m->{swap_used_mb} // 0;
|
|
my $swap_pct = $m->{swap_usage_pct} // 0;
|
|
my $swap_col = $swap_pct < 50 ? $GREEN : $swap_pct < 75 ? $YELLOW : $RED;
|
|
kv('Swap', "${swap_col}$swap_used / $swap_total MiB$RESET " . _bar($swap_pct));
|
|
|
|
my $top_mem = $m->{top_processes} // [];
|
|
if (@$top_mem) {
|
|
print "\n ${BOLD}Top 5 memory consumers:$RESET\n";
|
|
printf " %-8s %-10s %8s COMMAND\n", 'PID', 'USER', 'RSS';
|
|
for my $proc (@$top_mem) {
|
|
printf " %-8d %-10s %6.1fM %s\n",
|
|
$proc->{pid}, $proc->{user}, $proc->{rss_mb}, $proc->{command};
|
|
}
|
|
}
|
|
}
|
|
|
|
# Disk
|
|
if (exists $data->{disk}) {
|
|
section_header(($position{disk} + 1) . '. Disk');
|
|
my $d = $data->{disk};
|
|
kv('Total space', sprintf('%.1f GiB', $d->{total_gb} // 0));
|
|
kv('Used', sprintf('%.1f GiB ', $d->{used_gb} // 0) . _bar($d->{usage_pct} // 0));
|
|
|
|
my $fss = $d->{filesystems} // [];
|
|
if (@$fss) {
|
|
print "\n ${BOLD}Filesystems:$RESET\n";
|
|
my $header = sprintf(' %-20s %-18s %7s %7s %s', 'Device', 'Mount', 'Size', 'Used', 'Use%');
|
|
print "$header\n";
|
|
print ' ' . ('-' x (length($header) - 2)) . "\n";
|
|
for my $fs (@$fss) {
|
|
my $use_pct = $fs->{use_pct};
|
|
my $pct_col = $use_pct < 80 ? $GREEN : $use_pct < 90 ? $YELLOW : $RED;
|
|
my $dev = length($fs->{device}) > 19 ? substr($fs->{device}, 0, 19) : $fs->{device};
|
|
my $mnt = length($fs->{mountpoint}) > 17 ? substr($fs->{mountpoint}, 0, 17) : $fs->{mountpoint};
|
|
printf " %-20s %-18s %6.0fG %6.0fG %s%4.1f%%%s\n",
|
|
$dev, $mnt, $fs->{size_gb}, $fs->{used_gb}, $pct_col, $use_pct, $RESET;
|
|
}
|
|
}
|
|
|
|
if (!@$fss && $d->{container}) {
|
|
print " ${DIM}(container detected, overlay root filesystem not shown)$RESET\n";
|
|
}
|
|
|
|
my $critical = $d->{critical_fs} // [];
|
|
if (@$critical) {
|
|
print "\n ${RED}⚠ Warning: " . scalar(@$critical)
|
|
. " filesystem(s) above 90% usage!$RESET\n";
|
|
}
|
|
|
|
my $io_stats = $d->{io_stats};
|
|
if ($io_stats) {
|
|
print "\n ${BOLD}I/O Stats (iostat):$RESET\n";
|
|
for my $dev (@{ $io_stats->{devices} // [] }) {
|
|
printf " %-10s tps: %8.1f read: %8.2f MB/s write: %8.2f MB/s\n",
|
|
$dev->{device}, $dev->{tps}, $dev->{read_mb_s}, $dev->{write_mb_s};
|
|
}
|
|
}
|
|
}
|
|
|
|
# Network
|
|
if (exists $data->{network}) {
|
|
section_header(($position{network} + 1) . '. Network');
|
|
my $n = $data->{network};
|
|
|
|
for my $iface (@{ $n->{interfaces} // [] }) {
|
|
my $state_col = $iface->{state} eq 'UP' ? $GREEN : $RED;
|
|
my $v6 = @{ $iface->{ipv6} } ? join(', ', @{ $iface->{ipv6} }) : "${DIM}(none)$RESET";
|
|
my $v4 = @{ $iface->{ipv4} } ? join(', ', @{ $iface->{ipv4} }) : "${DIM}(none)$RESET";
|
|
printf " %-8s %s%-5s%s speed: %s\n",
|
|
$iface->{name}, $state_col, $iface->{state}, $RESET,
|
|
$iface->{speed} // 'unknown';
|
|
print " IPv6: $v6\n";
|
|
print " IPv4: $v4\n";
|
|
}
|
|
|
|
my $conns = $n->{connections} // {};
|
|
if (%$conns) {
|
|
kv('TCP established', $conns->{tcp_established} // '?');
|
|
kv('Total connections', $conns->{total} // '?');
|
|
}
|
|
|
|
my $routes = $n->{routes} // {};
|
|
kv('Default IPv6 route', $routes->{v6}) if $routes->{v6};
|
|
kv('Default IPv4 route', $routes->{v4}) if $routes->{v4};
|
|
|
|
my $ext_ip = $n->{external_ip} // {};
|
|
kv('External IPv6', $ext_ip->{v6}) if ($ext_ip->{v6} // 'unknown') ne 'unknown';
|
|
kv('External IPv4', $ext_ip->{v4}) if ($ext_ip->{v4} // 'unknown') ne 'unknown';
|
|
}
|
|
|
|
# GPU
|
|
if (exists $data->{gpu}) {
|
|
section_header(($position{gpu} + 1) . '. GPU');
|
|
my $g = $data->{gpu};
|
|
if ($g && $g->{unavailable}) {
|
|
print " ${DIM}GPU detection unavailable (no rocm-smi/lspci)$RESET\n";
|
|
}
|
|
elsif ($g) {
|
|
print " ${YELLOW}(hardware detected, GPU drivers not loaded)$RESET\n"
|
|
if $g->{fallback};
|
|
print ' Vendor: ' . ($g->{vendor} // 'unknown') . "\n";
|
|
for my $card (@{ $g->{cards} // [] }) {
|
|
# A card's properties print in the order their source listed them,
|
|
# which is not recoverable from a Perl hash: a card from
|
|
# lspci carries model, vendor and driver, one from rocm-smi carries id
|
|
# followed by the properties rocm-smi printed, so the known keys are
|
|
# laid out in those two orders and anything unexpected follows,
|
|
# sorted.
|
|
my %known = map { $_ => 1 } @CARD_KEY_ORDER;
|
|
my @keys = grep { exists $card->{$_} } @CARD_KEY_ORDER;
|
|
push @keys, sort grep { !$known{$_} } keys %$card;
|
|
for my $key (@keys) {
|
|
kv(ucfirst($key), $card->{$key});
|
|
}
|
|
}
|
|
my $vram = $g->{vram};
|
|
if ($vram) {
|
|
my $total = $vram->{total_mb} // 0;
|
|
my $used = $vram->{used_mb} // 0;
|
|
my $free = $vram->{free_mb} // 0;
|
|
my $pct = $total > 0 ? $used / $total * 100 : 0;
|
|
my $colour = $pct < 80 ? $GREEN : $pct < 90 ? $YELLOW : $RED;
|
|
kv('VRAM Used/Total', sprintf('%s%d/%d MiB%s (%.0f%%)', $colour, $used, $total, $RESET, $pct));
|
|
kv('VRAM Free', "$free MiB");
|
|
}
|
|
}
|
|
else {
|
|
print " ${DIM}No GPU detected$RESET\n";
|
|
}
|
|
}
|
|
|
|
# Services
|
|
if (exists $data->{services}) {
|
|
section_header(($position{services} + 1) . '. Services');
|
|
my $s = $data->{services};
|
|
|
|
my $failed = $s->{failed_units};
|
|
if (!defined $failed) {
|
|
print " ${DIM}Failed units: unavailable (systemctl not installed)$RESET\n";
|
|
}
|
|
elsif (@$failed) {
|
|
print " ${RED}Failed units: " . scalar(@$failed) . "$RESET\n";
|
|
for my $unit (@$failed) {
|
|
print " ${RED}✗$RESET $unit->{unit} ($unit->{state})\n";
|
|
}
|
|
}
|
|
else {
|
|
print " ${GREEN}No failed units$RESET\n";
|
|
}
|
|
|
|
print "\n";
|
|
my $ks = $s->{key_services} // {};
|
|
for my $name (sort keys %$ks) {
|
|
my $status = $ks->{$name}{status} // 'unknown';
|
|
if ($status eq 'active') {
|
|
printf " %s●%s %-15s %s%s%s\n", $GREEN, $RESET, $name, $GREEN, $status, $RESET;
|
|
}
|
|
elsif ($status eq 'inactive') {
|
|
printf " %s○%s %-15s %s%s%s\n", $YELLOW, $RESET, $name, $YELLOW, $status, $RESET;
|
|
}
|
|
elsif ($status eq 'failed') {
|
|
printf " %s✗%s %-15s %s%s%s\n", $RED, $RESET, $name, $RED, $status, $RESET;
|
|
}
|
|
else {
|
|
printf " %s─%s %-15s %s%s%s\n", $DIM, $RESET, $name, $DIM, $status, $RESET;
|
|
}
|
|
}
|
|
}
|
|
|
|
# Security
|
|
if (exists $data->{security}) {
|
|
section_header(($position{security} + 1) . '. Security');
|
|
my $sc = $data->{security};
|
|
|
|
my $selinux = $sc->{selinux} // 'unknown';
|
|
my $sel_col = $selinux eq 'Enforcing' ? $GREEN
|
|
: $selinux eq 'Permissive' ? $YELLOW
|
|
: $selinux eq 'Disabled' ? $RED
|
|
: $DIM; # unknown: SELinux tooling not installed
|
|
kv('SELinux', "$sel_col$selinux$RESET");
|
|
|
|
my $fw = $sc->{firewalld} // {};
|
|
if ($fw->{available}) {
|
|
kv_status('Firewalld active', $fw->{active} ? 1 : 0, 'active', 'inactive');
|
|
kv('Default zone', $fw->{default_zone}) if $fw->{default_zone};
|
|
kv('Active zones', join(', ', @{ $fw->{zones} // [] })) if @{ $fw->{zones} // [] };
|
|
}
|
|
else {
|
|
kv('Firewalld', "${DIM}not installed$RESET");
|
|
}
|
|
|
|
kv('Failed SSH (today)', $sc->{failed_ssh_attempts} // 0);
|
|
kv('Open TCP ports', $sc->{open_ports_count} // 0);
|
|
|
|
my $logins = $sc->{last_logins} // [];
|
|
if (@$logins) {
|
|
print "\n ${BOLD}Last logins:$RESET\n";
|
|
print " $_\n" for @$logins;
|
|
}
|
|
}
|
|
|
|
# Performance
|
|
if (exists $data->{performance}) {
|
|
section_header(($position{performance} + 1) . '. Performance');
|
|
my $p = $data->{performance};
|
|
|
|
kv('Processes total', $p->{processes_total} // 0);
|
|
my $zombies = $p->{zombies} // 0;
|
|
my $zombie_col = $zombies == 0 ? $GREEN : $RED;
|
|
kv('Zombie processes', "$zombie_col$zombies$RESET");
|
|
|
|
my $ctxt = $p->{context_switches} // {};
|
|
kv('Ctxt switches (boot)', $ctxt->{ctxt} // '?');
|
|
kv('Interrupts (boot)', $ctxt->{intr} // '?');
|
|
|
|
my $load_ratio = $p->{load_vs_cores_ratio} // 0;
|
|
my $ratio_col = $load_ratio < 1 ? $GREEN : $load_ratio < 2 ? $YELLOW : $RED;
|
|
kv('Load / cores ratio', sprintf('%s%.2f%s', $ratio_col, $load_ratio, $RESET));
|
|
|
|
my $top_cpu = $p->{top_cpu_processes} // [];
|
|
if (@$top_cpu) {
|
|
print "\n ${BOLD}Top 5 CPU consumers (lifetime average):$RESET\n";
|
|
printf " %-8s %-10s %6s COMMAND\n", 'PID', 'USER', 'CPU%';
|
|
for my $proc (@$top_cpu) {
|
|
printf " %-8d %-10s %5.1f%% %s\n",
|
|
$proc->{pid}, $proc->{user}, $proc->{cpu_pct}, $proc->{command};
|
|
}
|
|
}
|
|
}
|
|
|
|
# Recent issues
|
|
if (exists $data->{issues}) {
|
|
section_header(($position{issues} + 1) . '. Recent Issues');
|
|
my $i = $data->{issues};
|
|
|
|
my $errors = $i->{recent_errors} // [];
|
|
if (@$errors) {
|
|
print " ${BOLD}Last " . scalar(@$errors) . " errors from journal:$RESET\n";
|
|
for my $line (@$errors) {
|
|
my $display = length($line) > 120 ? substr($line, 0, 120) . "…" : $line;
|
|
print " ${DIM}$display$RESET\n";
|
|
}
|
|
}
|
|
else {
|
|
print " ${GREEN}No recent errors in journal$RESET\n";
|
|
}
|
|
|
|
my $oops = $i->{kernel_oops} // [];
|
|
if (@$oops) {
|
|
print "\n ${RED}⚠ Kernel OOPS / panic messages:$RESET\n";
|
|
for my $line (@$oops) {
|
|
my $display = length($line) > 120 ? substr($line, 0, 120) . "…" : $line;
|
|
print " ${RED}$display$RESET\n";
|
|
}
|
|
}
|
|
else {
|
|
print "\n ${GREEN}No kernel OOPS detected$RESET\n";
|
|
}
|
|
|
|
my $oom = $i->{oom_events} // [];
|
|
if (@$oom) {
|
|
print "\n ${RED}⚠ OOM events:$RESET\n";
|
|
for my $line (@$oom) {
|
|
my $display = length($line) > 120 ? substr($line, 0, 120) . "…" : $line;
|
|
print " ${RED}$display$RESET\n";
|
|
}
|
|
}
|
|
else {
|
|
print "\n ${GREEN}No OOM events in kernel log$RESET\n";
|
|
}
|
|
}
|
|
|
|
# Health score
|
|
section_header((scalar(@order) + 1) . '. Health Score');
|
|
my $bd = $health->{breakdown} // {};
|
|
my @labels = (
|
|
['cpu_usage', 'CPU usage < 80%'],
|
|
['memory_usage', 'Memory usage < 85%'],
|
|
['disk_usage', 'No disk > 90%'],
|
|
['selinux', 'SELinux enforcing'],
|
|
['services', 'No failed services'],
|
|
['load', 'Load < cores'],
|
|
['zombies', 'No zombie processes'],
|
|
['swap', 'Swap usage < 50%'],
|
|
['firewall', 'Firewall active'],
|
|
);
|
|
my %label_for = map { $_->[0] => $_->[1] } @labels;
|
|
my $maxima = $health->{maxima} // {};
|
|
for my $item (@labels) {
|
|
my ($key, $label) = @$item;
|
|
next unless exists $bd->{$key};
|
|
my $points = $bd->{$key};
|
|
my $max_points = $maxima->{$key} // 10;
|
|
my $bar_line = ("█" x ($points > 0 ? $points : 0))
|
|
. ("░" x ($max_points - ($points > 0 ? $points : 0)));
|
|
printf " %-24s %s %s/%s\n", $label, $bar_line, $points, $max_points;
|
|
}
|
|
my $skipped = $health->{factors_skipped} // [];
|
|
if (@$skipped) {
|
|
my $names = join(', ', map { $label_for{$_} // $_ } @$skipped);
|
|
print " ${DIM}Not assessed (data unavailable, score renormalised):$RESET\n";
|
|
print " ${DIM}$names$RESET\n";
|
|
}
|
|
|
|
# Footer
|
|
print "\n";
|
|
print "${BOLD}${bar}$RESET\n";
|
|
# An absent score prints the string None: the key exists with a null value,
|
|
# so no defined-or default would apply and the text is what shows.
|
|
my $score_text = defined $health->{score} ? $health->{score} : 'None';
|
|
print " ${BOLD}Health Score: ${grade_colour}${grade_letter}$RESET "
|
|
. '(' . $score_text . '/' . ($health->{max_score} // 100) . ")\n";
|
|
print "${BOLD}${bar}$RESET\n";
|
|
print "\n";
|
|
return;
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Collection, in forked children
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub perl_literal {
|
|
my ($value) = @_;
|
|
return 'undef' unless defined $value;
|
|
if (ref $value eq 'HASH') {
|
|
return '{ ' . join(', ', map { perl_literal($_) . ' => ' . perl_literal($value->{$_}) }
|
|
sort keys %$value) . ' }';
|
|
}
|
|
if (ref $value eq 'ARRAY') {
|
|
return '[' . join(', ', map { perl_literal($_) } @$value) . ']';
|
|
}
|
|
# A whole number stays a number, so the collector hashes keep their integers and
|
|
# their boolean flags usable. Everything else is quoted: a value rounded to
|
|
# a decimal has to survive this round trip as the text it is, or 18.0
|
|
# would come back as 18 and be written to JSON as an integer. A leading zero
|
|
# must be quoted too, because unquoted it would read back as octal: 010 comes
|
|
# back as 8 and 08 fails to parse at all.
|
|
return $value if !ref $value && $value =~ /^(?:0|-?[1-9][0-9]*)$/;
|
|
my $text = $value;
|
|
$text =~ s/([\\'])/\\$1/g;
|
|
return "'$text'";
|
|
}
|
|
|
|
sub read_literal {
|
|
my ($file) = @_;
|
|
return (undef, 'result file was not written') unless -f $file;
|
|
open(my $fh, '<', $file) or return (undef, "cannot read the result: $!");
|
|
my $text = do { local $/ = undef; <$fh> };
|
|
close($fh);
|
|
return (undef, 'empty result') unless defined $text && length $text;
|
|
my $value = eval $text;
|
|
return (undef, 'unreadable result') if $@;
|
|
return ($value, '');
|
|
}
|
|
|
|
# The sections are independent and each is dominated by waiting, so they run as
|
|
# forked children, one per section. A child writes its result as a Perl literal
|
|
# into the private scratch directory and the parent reads it back after reaping;
|
|
# progress is reported in completion order.
|
|
sub collect_all {
|
|
my ($sections, $external_ip) = @_;
|
|
my %data = (
|
|
version => $VERSION,
|
|
timestamp => timestamp_fields(time()),
|
|
);
|
|
|
|
my @collectors = (
|
|
['overview', 'system overview', sub { collect_overview() }],
|
|
['cpu', 'CPU', sub { collect_cpu() }],
|
|
['memory', 'memory', sub { collect_memory() }],
|
|
['disk', 'disk', sub { collect_disk() }],
|
|
['network', 'network', sub { collect_network($external_ip) }],
|
|
['gpu', 'GPU', sub { collect_gpu() }],
|
|
['services', 'services', sub { collect_services() }],
|
|
['security', 'security', sub { collect_security() }],
|
|
['performance', 'performance', sub { collect_performance() }],
|
|
['issues', 'recent issues', sub { collect_issues() }],
|
|
);
|
|
|
|
my %seen;
|
|
my @plan;
|
|
for my $section (@$sections) {
|
|
next if $seen{$section}++;
|
|
for my $entry (@collectors) {
|
|
push @plan, $entry if $entry->[0] eq $section;
|
|
}
|
|
}
|
|
return \%data unless @plan;
|
|
|
|
my $dir = scratch_dir();
|
|
my (%pid_of, %file_of, %label_of);
|
|
for my $entry (@plan) {
|
|
my ($section, $label, $code) = @$entry;
|
|
my $file = "$dir/$section";
|
|
$file_of{$section} = $file;
|
|
$label_of{$section} = $label;
|
|
my $pid = fork();
|
|
die "cannot fork: $!\n" unless defined $pid;
|
|
if ($pid == 0) {
|
|
# A child must not inherit the parent's interrupt handling.
|
|
$SIG{INT} = 'DEFAULT';
|
|
$SIG{TERM} = 'DEFAULT';
|
|
my $ok = eval {
|
|
open(my $fh, '>', $file) or die "cannot write $file: $!\n";
|
|
print {$fh} perl_literal($code->()), "\n";
|
|
close($fh);
|
|
1;
|
|
};
|
|
if (!$ok) {
|
|
my $error = $@ || 'unknown error';
|
|
$error =~ s/\s+\z//;
|
|
if (open(my $fh, '>', $file)) {
|
|
print {$fh} perl_literal({ error => $error }), "\n";
|
|
close($fh);
|
|
}
|
|
}
|
|
exit($ok ? 0 : 1);
|
|
}
|
|
$pid_of{$pid} = $section;
|
|
}
|
|
|
|
for (1 .. scalar @plan) {
|
|
my $pid = waitpid(-1, 0);
|
|
last if $pid <= 0;
|
|
my $section = $pid_of{$pid};
|
|
next unless defined $section;
|
|
my $exit_status = $? >> 8;
|
|
my $label = $label_of{$section};
|
|
my ($result, $read_error) = read_literal($file_of{$section});
|
|
|
|
_status("Collecting $label");
|
|
# A child that exits well and wrote the literal undef collected honestly:
|
|
# collect_gpu returns undef when there is no GPU, which is a result, not a
|
|
# failure. Only a nonzero exit or an unreadable file is a failure.
|
|
if ($exit_status != 0 || (!defined $result && length $read_error)) {
|
|
my $message = $read_error;
|
|
$message = $result->{error} if ref $result eq 'HASH' && $result->{error};
|
|
$message = 'collection failed' unless defined $message && length $message;
|
|
$data{$section} = { error => $message };
|
|
_status_done('error');
|
|
print STDERR " [$label] $message\n";
|
|
next;
|
|
}
|
|
$data{$section} = $result;
|
|
_status_done();
|
|
}
|
|
|
|
remove_scratch();
|
|
return \%data;
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command line
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub usage {
|
|
my $name = $0;
|
|
$name =~ s{.*/}{};
|
|
return <<"USAGE";
|
|
Usage: $name [options]
|
|
|
|
System diagnostics: CPU, memory, disk, network, GPU, services, security, performance
|
|
|
|
Options:
|
|
--section LIST Comma-separated sections to run. Available: @{[ join(', ', @ALL_SECTIONS) ]} [default: all]
|
|
--external-ip Also detect external IP addresses via icanhazip.com (sends a request to a third-party service)
|
|
--json Output machine-readable JSON to stdout
|
|
--version Show the version and exit
|
|
-h, --help Show this help and exit
|
|
USAGE
|
|
}
|
|
|
|
sub parse_args {
|
|
my %opt = (section => '', external_ip => 0, json => 0);
|
|
my @argv = @ARGV;
|
|
while (defined(my $arg = shift @argv)) {
|
|
if ($arg eq '--section') {
|
|
my $value = shift @argv;
|
|
if (!defined $value) {
|
|
print STDERR "argument --section: expected one argument\n";
|
|
exit 2;
|
|
}
|
|
$opt{section} = $value;
|
|
next;
|
|
}
|
|
if ($arg =~ /^--section=(.*)$/s) { $opt{section} = $1; next }
|
|
if ($arg eq '--external-ip') { $opt{external_ip} = 1; next }
|
|
if ($arg eq '--json') { $opt{json} = 1; next }
|
|
if ($arg eq '--help' || $arg eq '-h') { print usage(); exit 0 }
|
|
if ($arg eq '--version') {
|
|
my $name = $0;
|
|
$name =~ s{.*/}{};
|
|
print "$name $VERSION\n";
|
|
exit 0;
|
|
}
|
|
print STDERR "unrecognised argument: $arg\n";
|
|
print STDERR usage();
|
|
exit 2;
|
|
}
|
|
return %opt;
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
sub main {
|
|
my %opt = parse_args();
|
|
|
|
if ($^O ne 'linux') {
|
|
print STDERR "${RED}Error: system-diag.pl currently supports Linux only "
|
|
. "(detected platform: $^O).$RESET\n";
|
|
exit 1;
|
|
}
|
|
|
|
my @sections;
|
|
if (length $opt{section}) {
|
|
my @requested = map {
|
|
my $name = $_;
|
|
$name =~ s/^\s+//;
|
|
$name =~ s/\s+$//;
|
|
lc $name;
|
|
} split /,/, $opt{section};
|
|
my @invalid = grep {
|
|
my $name = $_;
|
|
!grep { $_ eq $name } @ALL_SECTIONS;
|
|
} @requested;
|
|
if (@invalid) {
|
|
print STDERR "${RED}Error: unknown section(s): " . join(', ', @invalid) . "$RESET\n";
|
|
print STDERR 'Available: ' . join(', ', @ALL_SECTIONS) . "\n";
|
|
exit 1;
|
|
}
|
|
@sections = @requested;
|
|
}
|
|
else {
|
|
@sections = @ALL_SECTIONS;
|
|
}
|
|
|
|
my $data = collect_all(\@sections, $opt{external_ip});
|
|
$data->{health} = compute_health($data);
|
|
|
|
if ($opt{json}) {
|
|
print json_encode($data);
|
|
}
|
|
else {
|
|
print_report($data);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
$SIG{INT} = sub {
|
|
print STDERR "\nInterrupted.\n";
|
|
remove_scratch();
|
|
exit 130;
|
|
};
|
|
$SIG{TERM} = sub {
|
|
remove_scratch();
|
|
exit 143;
|
|
};
|
|
END {
|
|
remove_scratch();
|
|
}
|
|
|
|
# Only when this file is the program: a test harness may require it and call the
|
|
# pure functions directly.
|
|
exit(main()) unless caller;
|