#!/usr/bin/perl -w =head1 NAME logwatcher - display current stats and logfile to screen =head1 SYNOPSIS logwatcher =head1 DESCRIPTION This program displays a snapshot of the system performance, including network traffic and a "tail -f"-like version of the system log. =head1 COMPATIBILITY If it works on anything besides a linux box with a kernel revision somewhere around the 2.2.x series, i will be stunned. =head1 AUTHOR Tkil =head1 COPYRIGHT This program is free software; it may be used, copied, modified, and distributed under the same terms as Perl itself. =head2 REVISION HISTORY $Log: logwatcher,v $ Revision 1.5 1999/10/13 20:16:17 tkil modified numeric displays, various adjustments. Revision 1.4 1999/10/13 04:02:44 tkil cleaned up some leftover debugging and erroneous comments Revision 1.3 1999/10/13 01:59:15 tkil got SIGWINCH handling just about right. probably still not as graceful as it could be, but at least it now makes sense. Revision 1.2 1999/10/12 22:36:04 tkil Added documentation, made sure rcs checkin went sanely. =head1 TO DO =over 4 =item * add config file handling This is for things like name of logfile(s), name of interfaces, number of interfaces, whether or not to ignore loopback. =item * view multiple logfiles =item * maybe switch to File::Tail =back =cut # pragmata use strict; # standard modules use Sys::Hostname qw(hostname); use IO::Seekable; use POSIX qw(strftime); # CPAN modules use Curses; # ====================================================================== # constants # which log file to follow? it wouldn't be too hard to make this # thing follow more log files (e.g. add "/var/log/secure"), but then # again, you can just dump everything to messages, so... use constant SYSLOG_FILE => "/var/log/messages"; # just to avoid random numbers in the conversion routines use constant SEC_PER_DAY => 24*60*60; use constant SEC_PER_HOUR => 60*60; use constant STATE_RUNNING => 0; use constant STATE_NEEDS_RESIZE => 1; use constant STATE_QUIT => 2; my $resize_executable = "/usr/X11R6/bin/resize"; # ====================================================================== # globals my $hostname = hostname; # windows used for the curses routines. my $stats_win; # machine & interface statistics my $log_win; # for scrolling a tail of SYSLOG_FILE my $time_win; # put a "current time" bit under log scroll # what state is the program in? my $prog_state = STATE_NEEDS_RESIZE; my $ignore_loopback = 1; # should we ignore the "lo" interface? # we can name interfaces. this is mostly intended to make the the # display more obvious on firewall boxes. my %interface_name = (eth0 => "external", eth1 => "internal", lo => "loopback"); # dimensions of screen my ($scr_height, $scr_width); # ====================================================================== # utility functions # currently, this function only handles expanding filenames that start # with a tilde. it should probably be expanded to be able to # absolutize any given path. hrm... someday. sub expand_filename { my $orig = shift; my $rv = $orig; # if it starts with a tilde, try to expand it as a username. if ($orig =~ m|^~([^/]*)(/.+)?|) { my ($username, $rest) = ($1, (defined $2 ? $2 : '')); my @pw_info = ($username ne '') ? getpwnam $username : getpwuid $>; return undef unless @pw_info; my $home_dir = $pw_info[7]; $home_dir =~ s|/$||; $rv = $home_dir . $rest; } return $rv; } # places text a certain spot on the given window. an optional length # allows for blanking out anything that was left over from previous # writes. (but i could probably just use "cleareol" for much of that) sub put_text { my ($win, $x, $y, $text, $len) = @_; if (defined $len) { # pad to end of field $text .= ' ' x ($len - length($text)); } addstr $win, $y, $x, $text; } # an old standby of mine... sub iso_timestamp { my $t = shift || time; my ($sec, $min, $hour, $mday, $mon, $year, $wday,$yday,$isdst) = localtime $t; # read the documentation for "localtime" here. and *never* try to # do C< $year = "19" . $year >!!! $year += 1900; $mon += 1; # the concatenated formatting string is just to help you connect # format codes with their contents. it should be done at compile # time, so there should be no cost at runtime. return sprintf ("%04d-%02d-%02d %02d:%02d:%02d", $year, $mon, $mday, $hour, $min, $sec); } # read in a whole file. this is simply easier than doing a polite # open/read/close cycle everywhere we need to get the contents of # these little files. sub get_contents { my $filename = shift; local *F; open F, "< $filename" or return; my $rv = do { local $/; }; close F or return; return $rv; } # convert (possibly fractional) seconds to day/hour/minute/seconds # representation. for screen calculation purposes, the final string # is always going to be 19 characters long. sub secs_to_dhms { my $secs = shift; my $days = int($secs / SEC_PER_DAY); $secs -= $days * SEC_PER_DAY; my $hours = int($secs / SEC_PER_HOUR); $secs -= $hours * SEC_PER_HOUR; my $minutes = int($secs / 60); $secs -= $minutes * 60; return sprintf "%3dd %02dh %02dm %05.2fs", $days, $hours, $minutes, $secs; } # take a value, and reduce it to be less than 1024. returns that # value and the si prefix for that magnitude. sub si_prefix { my $amount = shift; my $orig = $amount; my $sign = $amount < 0 ? -1 : 1; $amount = -$amount if $sign == -1; my @prefixes = (' ', 'k', 'M', 'G'); while ($amount > 1024) { shift @prefixes; $amount /= 1024; } $amount = -$amount if $sign == -1; my $pref = shift @prefixes; # $log_win->addstr # (qq/si_prefix: "$orig" -> (amount => "$amount", prefix => "$pref")\n/); return ($amount, $pref); } # return a centered, padded version of text. sub center_pad { my ($text, $width, $pad) = (@_); $text = ' ' . $text . ' '; $width = $scr_width unless defined $width; $pad = ' ' unless defined $pad; my $lpad = int(($width - length($text)) / 2); $text = $pad x $lpad . $text; $text .= $pad x ($width - length($text)); return $text; } # convert a number to have spaces every thousand or so. sub space { my $n = shift; my $s = reverse $n; my $rv = ''; while (length($s) > 3) { $rv .= substr($s, 0, 3, '') . ' '; } return scalar reverse($rv . $s); } # ====================================================================== # draw specific bits of info # ---------------------------------------------------------------------- # print a header for the whole screen. should only need to be called # once, although resizing / reallocating windows might deserve another # call... sub draw_hostname { my $text = "stats for $hostname"; put_text $stats_win, 0, 0, center_pad($text, $scr_width, "="); } # ---------------------------------------------------------------------- # display a nice banner at the top of the scrolling log. sub draw_logname { my $text = "log: " . SYSLOG_FILE; put_text $log_win, 0, 0, center_pad($text, $scr_width, "-"); $log_win->move(1, 0); } # ---------------------------------------------------------------------- # update time in two locations on the screen. should be called every # second or so. sub draw_cur_time { put_text $stats_win, $scr_width-24, 2, "now: " . iso_timestamp(); put_text $time_win, 0, 0, strftime("%b %d %H:%M:%S <- now", localtime); } # ---------------------------------------------------------------------- # draw_uptime # # grabs information from /proc/uptime and /proc/loadavg, displaying # them prettily. also does the once-off calculation of "$up_since". # since we only need to calculate this once, we want it to stick around. my $up_since; sub draw_up_since { return unless defined $up_since; put_text $stats_win, $scr_width-29, 1, "up since: $up_since"; } # sigh, this is a hack. sub reset_draw_up_since { undef $up_since; } sub draw_uptime { # /proc/uptime has uptime and idle time, in seconds. my $uptime = get_contents("/proc/uptime"); my ($up, $idle) = split ' ', $uptime; # calculate $up_since if we haven't done so already. unless (defined $up_since) { $up_since = iso_timestamp(time() - int($up)); draw_up_since; } # load average is for last 1, 5, and 15 minutes respectively; it # also has a fraction of number of runnable processes over total # number of processes, and what the last used PID is. my $loadavg = get_contents("/proc/loadavg"); my ($m1, $m5, $m15, $running_frac, $last_pid) = split ' ', $loadavg; my ($n_running, $n_total) = split '/', $running_frac; # format the uptimes a little more readably my $up_str = secs_to_dhms $up; my $idle_str = secs_to_dhms $idle; # and make the load average info readable. my $load_str = "Load: $m1/1m $m5/5m $m15/15m"; my $procs_str = "Procs: $n_total ($n_running running, last pid is $last_pid) "; put_text $stats_win, 0, 1, "Uptime: $up_str", 50; put_text $stats_win, 2, 2, "Idle: $idle_str"; put_text $stats_win, 2, 3, $load_str; put_text $stats_win, 1, 4, $procs_str; } # ---------------------------------------------------------------------- # draw_log # # do a running "tail -f" type update to the screen, watching the end # of the log file for growth. this technique is described much more # succinctly at the end of the "perlfaq5" man page. # last place we looked (for "tail -f" emulation) my $last_log_pos; # last inode of log file -- this is to keep track when the logs are # rotated. my $last_log_inode; # did we remove a newline, the last time we copied semething from the # log file to the screen? my $last_log_needs_nl = 0; sub reset_draw_log { $last_log_pos = 0; } sub draw_log { # we need to check the inode to see if the logfile has been rotated # out from under us my $log_inode = (stat SYSLOG_FILE)[1]; if (! defined $last_log_inode || $last_log_inode != $log_inode) { # looks like it's been changed. reset and reload. close LOG if defined $last_log_inode; open LOG, SYSLOG_FILE or die "couldn't open log file '" . SYSLOG_FILE . "': $!"; $last_log_inode = $log_inode; seek LOG, 0, SEEK_END; $last_log_pos = tell LOG; $last_log_pos -= $scr_width*$scr_height; $last_log_pos = 0 if $last_log_pos < 0; } # go to the end, see if the end has moved since we last looked. seek LOG, 0, SEEK_END; my $new_log_pos = tell LOG; if ($new_log_pos > $last_log_pos) { # yay, new data to copy and print. my $buf; seek LOG, $last_log_pos, SEEK_SET; read LOG, $buf, $new_log_pos - $last_log_pos; $last_log_pos = $new_log_pos; # handle trailing newline issues. this is just to make better use # of screen real estate. $buf = "\n" . $buf if $last_log_needs_nl; $last_log_needs_nl = $buf =~ s/\s+$//s; $log_win->addstr($buf); } } # ---------------------------------------------------------------------- # draw_interfaces # # put up some stats for each interface listed in /proc/net/dev. # persistent storage for draw_interfaces, so we can calculate rates # per interface my (%last_in_packets, %last_in_bytes, %last_out_packets, %last_out_bytes); # we only need to define this list of fields once, so we do it outside # the subroutine. these correspond to the entries in /proc/net/dev as # of linux kernel version 2.2.5; i haven't tried it on any other # systems yet. probably the only way to make this even reasonably # portable is by reading the first two lines of the file, and # comparing them against formats we know we can parse. my @if_fields = qw(in_bytes in_packets in_errs in_drop in_fifo in_frame in_compressed in_multicast out_bytes out_packets out_errs out_drop out_fifo out_collisions out_carrier out_compressed); # and the header is static, so we just create it once. my $if_header = <<'EOH'; ---interface--- -----------bytes------------ --------packets--------- -err- EOH chomp $if_header; sub draw_interfaces { my @stat_lines = split /\n/, get_contents("/proc/net/dev"); splice @stat_lines, 0, 2; # drop the first two, they're just labels. my $cur_line = 6; # where to start drawing. put_text $stats_win, 0, $cur_line++, $if_header; INTERFACE: foreach (@stat_lines) { my ($if, $rest) = split /:/; $if =~ s/^\s+//; # trim whitespace $if =~ s/\s+$//; next INTERFACE if ($if eq 'lo' && $ignore_loopback); # parse each line to get all the statistics. my %f; @f{@if_fields} = split ' ', $rest; # if we have historical data for this interface, we can calculate rates. my ($in_packet_rate, $out_packet_rate, $in_byte_rate, $out_byte_rate) = (0, 0, 0, 0); if (defined $last_in_packets{$if}) { $in_packet_rate = $f{in_packets} - $last_in_packets{$if}; $out_packet_rate = $f{out_packets} - $last_out_packets{$if}; $in_byte_rate = $f{in_bytes} - $last_in_bytes{$if}; $out_byte_rate = $f{out_bytes} - $last_out_bytes{$if}; # sanity check for ($in_packet_rate, $out_packet_rate, $in_byte_rate, $out_byte_rate) { $_ = 0 if $_ < 0; } } my $traffic = $in_byte_rate + $out_byte_rate; my $traffic_bar_len = 15; my $traffic_bar = ""; if ($traffic > 0) { my $portion = log($traffic)/log(10); $traffic_bar = "#" x (int($portion + 0.5)); } # for each rate, either blank it out (if it's zero) or format it nicely foreach ($in_packet_rate, $out_packet_rate, $in_byte_rate, $out_byte_rate) { if ($_ == 0) { $_ = ''; } else { my ($amount, $prefix) = si_prefix($_); $_ = sprintf "%+.0f%s/s", $amount, $prefix; } } # make the name look nice my $if_name = exists $interface_name{$if} ? "($interface_name{$if})" : ''; my $if_string = $if_name . " " x (15-(length($if_name) + length($if))) . $if; # format the two lines of information, one each for incoming and # outgoing data my $if_in_line = sprintf("%15s in: %19s %8s %15s %8s %5d", $if_string, space($f{in_bytes}), $in_byte_rate, space($f{in_packets}), $in_packet_rate,, $f{in_errs}+$f{in_drop}); my $if_out_line = sprintf("%-15s out: %19s %8s %15s %8s %5d", $traffic_bar, space($f{out_bytes}), $out_byte_rate, space($f{out_packets}), $out_packet_rate,, ($f{out_errs}+$f{out_drop}+ $f{out_collisions}+$f{out_carrier})); # display the lines put_text $stats_win, 0, $cur_line++, $if_in_line; put_text $stats_win, 0, $cur_line++, $if_out_line; # update the historical data $last_in_packets{$if} = $f{in_packets}; $last_out_packets{$if} = $f{out_packets}; $last_in_bytes{$if} = $f{in_bytes}; $last_out_bytes{$if} = $f{out_bytes}; } } # ====================================================================== # signal handlers $SIG{TERM} = $SIG{INT} = sub { $prog_state = STATE_QUIT; }; $SIG{WINCH} = sub { $prog_state = STATE_NEEDS_RESIZE; }; # ====================================================================== # handle a resize my $num_resizes = 0; sub do_resize { unless ($num_resizes == 0) { $stats_win->delwin; $log_win->delwin; $time_win->delwin; endwin; } my @resize_out = qx"$resize_executable"; foreach (@resize_out) { next unless /^(\w+)=(.*);$/; my ($key, $rest) = ($1, $2); $ENV{$key} = $rest; if ($key eq 'COLUMNS') { $scr_width = $rest; } elsif ($key eq 'LINES') { $scr_height = $rest; } } $prog_state = STATE_RUNNING; unless ($num_resizes == 0) { refresh; } # make the curses windows... my $stats_height = 12; $stats_win = new Curses $stats_height, $scr_width, 0, 0 or return; $stats_win->clear; $stats_win->leaveok; reset_draw_up_since(); # and a window to scroll the log "tail -f" in. my $log_height = $scr_height-($stats_height+1); $log_win = new Curses $log_height, $scr_width, $stats_height, 0 or return; $log_win->clear; $log_win->leaveok; $log_win->idlok; $log_win->scrollok(1); $log_win->setscrreg(1, $log_height-1); reset_draw_log(); # borrowing an idea from the tummy logwatcher, we put a copy of the # current time (in the bletcherous syslog time format) at the bottom # of the screen, lined up with the "tail -f"-like log file output. $time_win = new Curses 1, $scr_width, $scr_height-1, 0 or return; $time_win->clear; $time_win->leaveok; $num_resizes++; return 1; } # ====================================================================== # main loop while ($prog_state != STATE_QUIT) { if ($prog_state == STATE_NEEDS_RESIZE) { # take the result from "do_resize", and cycle through again. this # is in case the resize biffs, in which case we want to punt. $prog_state = do_resize() ? STATE_RUNNING : STATE_QUIT; next; } draw_hostname; draw_logname; while ($prog_state == STATE_RUNNING) { draw_cur_time; draw_uptime; draw_interfaces; draw_log; foreach ($stats_win, $log_win, $time_win) { $_->refresh; } sleep 1; } } endwin; exit 0;