Diff -- /var/www/vhosts/3dshawn.com/admin.3dshawn.com/MODS/MetaAdmin/Stats.pm

O Operator
Diff

/var/www/vhosts/3dshawn.com/admin.3dshawn.com/MODS/MetaAdmin/Stats.pm

added on local at 2026-07-10 10:11:47

Added
+0
lines
Removed
-0
lines
Context
367
unchanged
Blobs
from 7e20eaaa93e5
to 7e20eaaa93e5
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
11package MODS::MetaAdmin::Stats;
22#======================================================================
33# Meta-Admin -- server stats reader. Plain shell + /proc reads, no
44# CPAN deps. Returns plain hashrefs that server.cgi feeds into
55# hand-rolled SVG graphs.
66#======================================================================
77use strict;
88use warnings;
99
1010sub new { return bless({}, shift); }
1111
1212sub _shell {
1313 my $cmd = shift;
1414 my $out = `$cmd 2>/dev/null`;
1515 $out = '' unless defined $out;
1616 chomp $out;
1717 return $out;
1818}
1919
2020# _shell_du: run `du -sb <path>` via sudo if a narrow sudoers entry
2121# is installed (see /etc/sudoers.d/meta_admin_stats). Falls back to
2222# unprivileged du if sudo -n fails -- caller still gets a number,
2323# just under-reported for paths owned by other Plesk users.
2424sub _shell_du {
2525 my $path = shift;
2626 return 0 unless defined $path && length $path;
2727 return 0 if $path =~ /[`;\$\|]/;
2828 my $out = `sudo -n /usr/bin/du -sb '$path' 2>/dev/null | awk '{print \$1}'`;
2929 chomp $out;
3030 if (!$out || $out !~ /^\d+$/) {
3131 # Sudo unavailable -- best-effort as the CGI user.
3232 $out = `du -sb '$path' 2>/dev/null | awk '{print \$1}'`;
3333 chomp $out;
3434 }
3535 return ($out && $out =~ /^\d+$/) ? ($out + 0) : 0;
3636}
3737
3838#---------------------------------------------------------------------
3939# Disk on the root partition. Returns { total_b, used_b, avail_b }.
4040#---------------------------------------------------------------------
4141sub disk_root {
4242 my $self = shift;
4343 return $self->disk_for('/');
4444}
4545
4646#---------------------------------------------------------------------
4747# Disk stats for any mount path. Returns { total_b, used_b, avail_b }.
4848# Used for the "Sites partition" module -- portfolio sites live on
4949# /var so the meaningful headline number is /var, not /.
5050#---------------------------------------------------------------------
5151sub disk_for {
5252 my ($self, $path) = @_;
5353 $path //= '/';
5454 return { total_b => 0, used_b => 0, avail_b => 0 }
5555 if $path =~ /[`;\$\|]/; # shell-injection belt-and-suspenders
5656 my $line = _shell("df -B1 '$path' | tail -1");
5757 my @p = split /\s+/, $line;
5858 return { total_b => 0, used_b => 0, avail_b => 0 } unless @p >= 4;
5959 return {
6060 total_b => $p[1] + 0,
6161 used_b => $p[2] + 0,
6262 avail_b => $p[3] + 0,
6363 };
6464}
6565
6666#---------------------------------------------------------------------
6767# Where is /var space going? du -sb on a handful of well-known
6868# top-level subdirs so the caller can render a stacked-bar or
6969# categorized-list "what's eating /var" breakdown. Returns arrayref
7070# of { label, path, used_b, color } biggest-first, with an implicit
7171# "Other" bucket = /var - sum(named).
7272#---------------------------------------------------------------------
7373sub var_breakdown {
7474 my $self = shift;
7575 # Non-overlapping top-level probes -- each path is a direct child of
7676 # /var so there's no double-counting into the "Other" bucket.
7777 my @probes = (
7878 { label => 'Databases + Plesk state', path => '/var/lib', color => '#7c3aed' },
7979 { label => 'Web hosting', path => '/var/www', color => '#b53048' },
8080 { label => 'Mail spool', path => '/var/qmail', color => '#06b6d4' },
8181 { label => 'Plesk cache', path => '/var/cache', color => '#059669' },
8282 { label => 'Logs', path => '/var/log', color => '#92400e' },
8383 { label => 'Imunify360 security', path => '/var/imunify360', color => '#f97316' },
8484 { label => 'Spool + tmp', path => '/var/spool', color => '#a855f7' },
8585 );
8686 my @out;
8787 my $named_sum = 0;
8888 foreach my $p (@probes) {
8989 my $b = _shell_du($p->{path});
9090 next unless $b > 0;
9191 push @out, { %$p, used_b => $b };
9292 $named_sum += $b;
9393 }
9494 @out = sort { $b->{used_b} <=> $a->{used_b} } @out;
9595
9696 # "Other" bucket = /var total-used minus everything we named.
9797 my $var = $self->disk_for('/var');
9898 my $other = ($var->{used_b} || 0) - $named_sum;
9999 push @out, {
100100 label => 'Other',
101101 path => '/var (uncategorized)',
102102 used_b => ($other > 0 ? $other : 0),
103103 color => '#4a1a20',
104104 } if $other > 0;
105105
106106 return \@out;
107107}
108108
109109#---------------------------------------------------------------------
110110# All mounted filesystems (skip RAM-backed pseudo-FS like tmpfs/devtmpfs).
111111# Returns arrayref of { mount, fstype, device, total_b, used_b, avail_b,
112112# used_pct }, ordered biggest-first. Empty on failure.
113113#---------------------------------------------------------------------
114114sub partitions {
115115 my $self = shift;
116116 # -P = POSIX (one row per FS, no wrapping), -T = include Type column,
117117 # -B1 = bytes. Columns: source fstype 1-blocks used avail capacity mount.
118118 my $out = _shell('df -PTB1 2>/dev/null');
119119 my @rows;
120120 foreach my $line (split /\n/, $out) {
121121 next if $line =~ /^Filesystem\s+Type/; # header
122122 my @p = split /\s+/, $line;
123123 next unless @p >= 7;
124124 my ($src, $fs, $total, $used, $avail, undef, $mnt) = @p;
125125 # Real filesystems only -- skip RAM / cgroup / overlay pseudo-mounts.
126126 next if $fs =~ /^(tmpfs|devtmpfs|cgroup|cgroup2|proc|sysfs|debugfs|tracefs|securityfs|pstore|autofs|overlay|squashfs|efivarfs|fuse\.\w+|nsfs|mqueue|hugetlbfs|configfs|bpf|ramfs|none)$/;
127127 next if $src eq 'none';
128128 $total += 0; $used += 0; $avail += 0;
129129 next unless $total > 0;
130130 my $pct = int(($used / $total) * 100 + 0.5);
131131 push @rows, {
132132 device => $src,
133133 fstype => $fs,
134134 mount => $mnt,
135135 total_b => $total,
136136 used_b => $used,
137137 avail_b => $avail,
138138 used_pct => $pct,
139139 };
140140 }
141141 @rows = sort { $b->{total_b} <=> $a->{total_b} } @rows;
142142 return \@rows;
143143}
144144
145145#---------------------------------------------------------------------
146146# Physical disk layout. lsblk in JSON when available (util-linux 2.27+),
147147# else fall back to plain text. Returns arrayref of top-level disks:
148148# { name, size_b, model, is_raid_member }. Empty on failure.
149149#---------------------------------------------------------------------
150150sub physical_disks {
151151 my $self = shift;
152152 my $out = _shell('lsblk -b -d -n -o NAME,SIZE,TYPE,MODEL 2>/dev/null');
153153 my @disks;
154154 foreach my $line (split /\n/, $out) {
155155 # Split with limit=4 so MODEL can contain spaces
156156 my ($name, $size, $type, $model) = split /\s+/, $line, 4;
157157 next unless $name && $type && $type eq 'disk';
158158 $model //= ''; $model =~ s/\s+$//;
159159 push @disks, {
160160 name => $name,
161161 size_b => ($size || 0) + 0,
162162 model => $model || '',
163163 };
164164 }
165165 return \@disks;
166166}
167167
168168#---------------------------------------------------------------------
169169# site_footprints -- combined per-site disk + RAM view for the Websites
170170# Folders / Sites RAM modules on /server.cgi. Returns arrayref of
171171# { slug, display_name, brand_color, vhost_path, owner_user,
172172# disk_b, ram_b } sorted by disk usage desc.
173173#
174174# RAM per site is the sum of RSS (bytes) of every currently-live
175175# process owned by that vhost's Linux user (from stat -c %U). CGI
176176# sites hold no RAM between requests -- if you see 0 for one, the
177177# CGI just isn't executing right now.
178178#---------------------------------------------------------------------
179179sub site_footprints {
180180 my ($self, $sites) = @_;
181181 $sites ||= [];
182182
183183 # One ps sweep, aggregate RSS (kB) by owner user.
184184 my %ram_by_user;
185185 foreach my $line (split /\n/, _shell('ps -eo user:32,rss --no-headers 2>/dev/null')) {
186186 $line =~ s/^\s+//;
187187 my ($user, $rss_kb) = split /\s+/, $line, 2;
188188 next unless $user && defined $rss_kb && $rss_kb =~ /^\d+$/;
189189 $ram_by_user{$user} += $rss_kb + 0;
190190 }
191191
192192 my @out;
193193 foreach my $srow (@$sites) {
194194 my $path = $srow->{vhost_path} or next;
195195 next unless $path =~ m{^/};
196196 next if $path =~ /[`;\$\|]/; # belt-and-suspenders shell-injection guard
197197
198198 # stat first -- if it fails the vhost user disallows traversal
199199 # from this Perl process, so du will also fail. Mark disk/ram
200200 # as undef (renderer shows "n/a" + a footnote).
201201 my $stat_out = _shell("stat -c '%U' '$path' 2>&1");
202202 my $owner = ($stat_out =~ /^([A-Za-z_][A-Za-z0-9_-]{0,31})$/) ? $1 : '';
203203 my $unreachable = !$owner;
204204
205205 my ($du_b, $ram_b);
206206 if ($unreachable) {
207207 $du_b = undef;
208208 $ram_b = undef;
209209 } else {
210210 my $du_out = _shell("du -sb '$path' 2>/dev/null | awk '{print \$1}'");
211211 $du_b = ($du_out =~ /^\d+$/) ? ($du_out + 0) : 0;
212212 $ram_b = exists $ram_by_user{$owner}
213213 ? ($ram_by_user{$owner} * 1024) : 0;
214214 }
215215
216216 push @out, {
217217 slug => $srow->{slug},
218218 display_name => $srow->{display_name},
219219 brand_color => $srow->{brand_color} || '#807aa8',
220220 vhost_path => $path,
221221 owner_user => $owner,
222222 disk_b => $du_b,
223223 ram_b => $ram_b,
224224 unreachable => $unreachable,
225225 };
226226 }
227227 @out = sort {
228228 ($b->{disk_b} // -1) <=> ($a->{disk_b} // -1)
229229 } @out;
230230 return \@out;
231231}
232232
233233#---------------------------------------------------------------------
234234# Per-vhost disk: du -sb on each path. Slow-ish on large vhosts but
235235# bounded (the dirs we're crawling are tens of MB to single-digit GB).
236236#---------------------------------------------------------------------
237237sub disk_per_vhost {
238238 my ($self, $paths) = @_;
239239 $paths ||= [];
240240 my @out;
241241 foreach my $row (@$paths) {
242242 my $path = $row->{vhost_path} or next;
243243 next unless $path =~ m{^/};
244244 next if $path =~ /[`;\$\|]/; # belt-and-suspenders on shell injection
245245 my $b = _shell("du -sb '$path' 2>/dev/null | awk '{print \$1}'");
246246 $b = ($b =~ /^\d+$/) ? ($b + 0) : 0;
247247 push @out, {
248248 slug => $row->{slug},
249249 display_name => $row->{display_name},
250250 brand_color => $row->{brand_color} || '#807aa8',
251251 vhost_path => $path,
252252 used_b => $b,
253253 };
254254 }
255255 @out = sort { $b->{used_b} <=> $a->{used_b} } @out;
256256 return \@out;
257257}
258258
259259#---------------------------------------------------------------------
260260# Memory. /proc/meminfo in KB.
261261#---------------------------------------------------------------------
262262sub memory {
263263 my $self = shift;
264264 my %m;
265265 if (open my $fh, '<', '/proc/meminfo') {
266266 while (<$fh>) {
267267 chomp;
268268 if (/^(\S+):\s+(\d+)/) {
269269 $m{$1} = $2 * 1024; # to bytes
270270 }
271271 }
272272 close $fh;
273273 }
274274 my $total = $m{MemTotal} || 0;
275275 my $avail = $m{MemAvailable} || $m{MemFree} || 0;
276276 my $used = $total - $avail;
277277 my $cached = $m{Cached} || 0;
278278 my $buf = $m{Buffers} || 0;
279279 my $swap_t = $m{SwapTotal} || 0;
280280 my $swap_f = $m{SwapFree} || 0;
281281 my $swap_u = $swap_t - $swap_f;
282282 return {
283283 total_b => $total, used_b => $used, avail_b => $avail,
284284 cached_b => $cached, buffers_b => $buf,
285285 swap_total_b => $swap_t, swap_used_b => $swap_u,
286286 };
287287}
288288
289289#---------------------------------------------------------------------
290290# Load + uptime + cpu count.
291291#---------------------------------------------------------------------
292292sub cpu {
293293 my $self = shift;
294294 my %r = ( cores => 0, load1 => 0, load5 => 0, load15 => 0, uptime_s => 0 );
295295 if (open my $fh, '<', '/proc/loadavg') {
296296 my $line = <$fh>; close $fh;
297297 chomp $line;
298298 my @p = split /\s+/, $line;
299299 $r{load1} = ($p[0] || 0) + 0;
300300 $r{load5} = ($p[1] || 0) + 0;
301301 $r{load15} = ($p[2] || 0) + 0;
302302 }
303303 if (open my $fh, '<', '/proc/cpuinfo') {
304304 while (<$fh>) { $r{cores}++ if /^processor\s*:/; }
305305 close $fh;
306306 }
307307 if (open my $fh, '<', '/proc/uptime') {
308308 my $line = <$fh>; close $fh;
309309 my ($up) = split /\s+/, $line;
310310 $r{uptime_s} = ($up || 0) + 0;
311311 }
312312 return \%r;
313313}
314314
315315#---------------------------------------------------------------------
316316# Hostname + kernel + distro fingerprint.
317317#---------------------------------------------------------------------
318318sub host {
319319 my $self = shift;
320320 return {
321321 host => _shell('hostname'),
322322 kernel => _shell('uname -r'),
323323 os => _shell('grep PRETTY_NAME /etc/os-release | head -1 | sed -e \'s/^[^=]*=//\' -e \'s/"//g\''),
324324 };
325325}
326326
327327#---------------------------------------------------------------------
328328# Top 5 processes by CPU usage. Returns arrayref of { pid, user, cpu,
329329# mem, cmd }.
330330#---------------------------------------------------------------------
331331sub top_processes {
332332 my $self = shift;
333333 my $out = _shell("ps -eo pid,user,pcpu,pmem,comm --sort=-pcpu | head -6 | tail -5");
334334 my @rows;
335335 foreach my $line (split /\n/, $out) {
336336 $line =~ s/^\s+//;
337337 my ($pid, $user, $cpu, $mem, $cmd) = split /\s+/, $line, 5;
338338 next unless defined $pid && $pid =~ /^\d+$/;
339339 push @rows, {
340340 pid => $pid, user => $user, cpu => ($cpu+0), mem => ($mem+0), cmd => $cmd,
341341 };
342342 }
343343 return \@rows;
344344}
345345
346346sub human_size {
347347 my $b = shift; $b = 0 unless defined $b && $b =~ /^-?\d+(\.\d+)?$/;
348348 my $sign = $b < 0 ? '-' : '';
349349 my $a = abs($b);
350350 if ($a >= 1024**4) { return sprintf('%s%.1f TB', $sign, $a / 1024**4); }
351351 if ($a >= 1024**3) { return sprintf('%s%.1f GB', $sign, $a / 1024**3); }
352352 if ($a >= 1024**2) { return sprintf('%s%.1f MB', $sign, $a / 1024**2); }
353353 if ($a >= 1024) { return sprintf('%s%.1f KB', $sign, $a / 1024); }
354354 return sprintf('%s%d B', $sign, $a);
355355}
356356
357357sub human_uptime {
358358 my $s = shift; $s = 0 unless $s;
359359 my $d = int($s / 86400);
360360 my $h = int(($s % 86400) / 3600);
361361 my $m = int(($s % 3600) / 60);
362362 return sprintf('%dd %dh %dm', $d, $h, $m) if $d;
363363 return sprintf('%dh %dm', $h, $m) if $h;
364364 return sprintf('%dm', $m);
365365}
366366
3673671;