#!/opt/vdops/bin/perl # This script takes mass-ping output (in CSV format) and produces a graphic # portraying hit and missed pings # V Who When What # --------------------------------------------------------------------------- # 1.1.0 skendric 2010-01-01 Implement sort # 1.0.5 skendric 2009-08-04 Add sort option # 1.0.4 skendric 2009-04-15 Refine use of Title and Details fields # 1.0.3 skendric 2009-03-22 Fix bug when handling multiple files # 1.0.2 skendric 2009-03-01 Calculate node_width # 1.0.1 skendric 2009-02-27 Support input directory # 1.0.0 skendric 2009-02-23 First Version # # Author: Stuart Kendrick, sbk {put at sign here} skendric {put dot here} com # # Source: http://www.skendric.com/nmgmt # # This software is available under the GNU GENERAL PUBLIC LICENSE, see # http://www.fsf.org/licenses/gpl.html # # # This script takes the following approach: # -Accepts a file name as input # # -Produces a PNG graphic portraying the missed/hit ping behavior # # # Requirements: # -PERL modules: the WI::Netops collection # # # Assumptions: # # # Tested on: # -perl-5.12.2 # # # Instructions: # -Customize the script for your site: find the 'user-configurable # variables' section and modify as appropriate # -Type "graph-mass-ping" to see the options # -Try it out # # # # Caveats: # # # Known Bugs: # # # To do: # - There must be a cleaner way to calculate canvas parameters and graphic # positioning: figure it out # # Begin script # Load modules use strict; use warnings; use feature 'say'; use feature 'switch'; use Carp qw(carp cluck croak confess); use Data::Dump::Streamer; use Data::Dumper; use English qw( -no_match_vars ); use Getopt::Std; use GD::Simple; use List::Util qw(max); use Net::hostent; use NetAddr::IP; use Net::DNS; use Net::IPAddress qw(validaddr); use Readonly; use Regexp::Common; use Spreadsheet::ParseExcel; # Declare global variables # Basics my $input_file; # Command-line provided location of input file my @data_files; # mass-ping data files extracted from $input_file my $mode; # Interactive or batch my %option; # Command-line arguments my $program_name; # Name of this program my $resolve_names; # Boolean telling us whether or not to resolve node # IP addresses to names my $sort_nodes; # Boolean telling us whether or not to sort nodes my $usage; # Usage message my $version; # Version of this program # Extracted from input file my $details; # Details string from beginning of data file my $error; # Error string from beginning of data file my $invocation; # String illustrating how mass-ping was run my %ping_data; # Hash of array refs, keyed by node name, listing # the hits or pings for that node, in time stamp # order my @timestamps; # List of time stamps extracted from data file my $title; # Title of graphic # Calculated my $announce_height; # Height of the announce line (this is just the # title line but printed smaller) my $details_height; # Height of the details line my $error_height; # Height of the error line my $invocation_height; # Height of the invocation line my $label_height; # Height of the label line my $node_count; # Number of nodes for which we have ping data my $node_count_height; # Height of the node count line my $node_width; # Width allocated for printing node name my $node_spacing; # Spacing between node lines my $stamp_count; # Number of timestamps for which we have ping data my $stamp_count_height; # Height of the stamp count line my $title_height; # Height of the title line # Calculated from operator-specified choices below my $bang_width; # Width of a '!' my $dot_width; # Width of a '.' my $height; # Height of canvas my $stamp_column_width; # Width of each column of data points my $width; # Width of canvas # Define global variables my $debug = 0; # 4 = Grody: print big var # 3 = Verbose: print mid var # 2 = Simple: print small var # 1 = Basic: subroutine trace # 0 = Disable debugging $program_name = 'graph-mass-ping'; $usage = 'Usage: graph-mass-ping -f {filename or directory name} [-d {debug level}] [-i {stamp interval}] [-n] [-t]'; $version = '1.1.0'; $OUTPUT_AUTOFLUSH = 1; $Getopt::Std::STANDARD_HELP_VERSION = 1; $resolve_names = 0; $sort_nodes = 0; # Define constants Readonly my $BANG => q{!}; Readonly my $COMMA => q{,}; Readonly my $EMPTY_STR => q{}; # Margins my $bottom_margin = 60; my $left_margin = 60; my $right_margin = 60; my $top_margin = 60; # Font choices my $header_font = 'Times'; # Font for header (Comment, Errors, Counts) my $label_font = 'Times'; # Font for labels my $node_font = 'Times'; # Font for node names my $stamp_font = 'Times'; # Font for time stamps and data elements: # bangs and dots my $title_font = 'Times:Bold'; # Font for title # Miscellaneous text my $bang_color = 'green'; # Color for ! my $dot_color = 'red'; # Color for . my $unk_color = 'orange'; # Color for unknown characters my $text_color = 'black'; # Color for text in title and labels my $x_label = 'Nodes'; # Label for the rows of nodes my $y_label = 'Time'; # Label for the columns of time stamps # Size my $header_font_size = 12; # Font size for the node and time labels my $label_font_size = 24; # Font size for the node and time labels my $node_font_size = 12; # Size to use for printing nodes my $stamp_font_size = 12; # Font size for time stamps and for # ping elements: bang and dot my $title_font_size = 48; # Font size for the title # Overall graph parameters my $stamp_interval = 30; # Interval, in seconds, at which we will # print a time stamp. One can produce # unreadable graphics by changing this # Grab arguments getopts('d:f:i:nt', \%option); # Set mode if ($option{r}) { $mode = 'report' } elsif (-t STDIN) { $mode = 'interactive' } else { $mode = 'batch' } ### Begin Main Program ############################################### { process_args(); # Process command line args build_the_graphic(); # Do the work } ##### End Main Program ############################################### ######################################################################## # Do the work: create the output PNG file # In 14 point Times, dots have a width of 6 and a height of 4, while # bangs have a width of 7 and a heigh of 15 ######################################################################## sub build_the_graphic { my $img; # GD object my $move_down; # Accumulates height as we move down the # canvas # Debug trace trace_location('begin') if $debug; # Walk through input files for my $data_file (@data_files) { my ($n, @timestamps); $n = 0; undef %ping_data; # Read data file, populating %ping_data say "Read $data_file..." if $mode eq 'interactive'; @timestamps = read_data($data_file); # Calculate canvas-wide parameters say 'Calculate canvas parameters...' if $mode eq 'interactive'; calculate_canvas_parameters(); # Notify operator say 'Build graphic...' if $mode eq 'interactive'; # Create a new image $img = GD::Simple->new($width, $height); # Print title $img->moveTo($width/5, $top_margin); $img->font($title_font); $img->fontsize($title_font_size); $img->string($title); # Set up header $img->font($header_font); $img->fontsize($header_font_size); # Print title again, but smaller $title_height = ($img->stringBounds($title))[1]; $move_down = $top_margin + $title_height * 3; $img->moveTo($left_margin, $move_down); $img->string("Title $title"); # Print invocation $move_down += $invocation_height * 1.25; $img->moveTo($left_margin, $move_down); $img->string($invocation); # Print details $move_down += $details_height * 1.25; $img->moveTo($left_margin, $move_down); $img->string($details); # Print error $move_down += $error_height * 1.25; $img->moveTo($left_margin, $move_down); $img->string($error); # Print node count $move_down += $node_count_height * 1.25; $img->moveTo($left_margin, $move_down); $img->string("Node count $node_count"); # Print time stamp count $move_down += $stamp_count_height * 1.25; $img->moveTo($left_margin, $move_down); $img->string("Time count $stamp_count"); # Print column heading $move_down += $label_height * 2; $img->moveTo($left_margin, $move_down); $img->fontsize($label_font_size); $img->string($x_label); $img->moveTo($left_margin + $width/4, $move_down); $img->string($y_label); # Set font characteristics suitable for time stamps $img->font($stamp_font); $img->fontsize($stamp_font_size); # Position y cursor $move_down += $label_height * 2; # Start at interval 0 my $i = 0; # Print time stamps STAMP: for (my $t = 0; $t < @timestamps; $t++) { my $timestamp; $timestamp = $timestamps[$t]; # Skip unless this is a time stamp we want to print next STAMP unless $t/$stamp_interval == int $t/$stamp_interval; # Print time stamp $img->moveTo($left_margin + $node_width + $i * $stamp_column_width, $move_down); $img->string($timestamp); # Increment interval counter $i++ } # Print some vertical blank space $move_down += $node_spacing; $img->moveTo(0, $move_down); $img->string($EMPTY_STR); # Print nodes and their ping data my %arg; given ($sort_nodes) { when (0) { for my $node (keys %ping_data) { %arg = (img => $img, move_down => $move_down, n => $n, node => $node); $img = print_node(\%arg); $n++; } } when (1) { for my $node (sort keys %ping_data) { %arg = (img => $img, move_down => $move_down, n => $n, node => $node); $img = print_node(\%arg); $n++; } } default { die "sort_nodes contains invalid value: $sort_nodes"; } } # Print the result (my $output_file = $data_file) =~ s/csv/png/; open my $output, '>', $output_file or die "Cannot open $output_file: $!"; print {$output} $img->png; close $output or warn "Cannot close $output_file: $!"; # Make things look pretty say "\n" if $mode eq 'interactive'; } # End 'Walk through input files' # Make things look pretty say "\n" if $mode eq 'interactive'; # Debug trace trace_location('end') if $debug; return 1; } ######################################################################## # Given the choices made in the 'Define global variables' section, # calculate remaining graphic-related parameters ######################################################################## sub calculate_canvas_parameters { my $img; # GD::Simple object my @node_widths; # List of the width of every node name, in pixels # Debug trace trace_location('begin') if $debug; # Create a GD object $img = GD::Simple->new(); # Assign ping element characteristics $img->font($stamp_font); $img->fontsize($stamp_font_size); # Calculate stamp_width for my $node (keys %ping_data) { $node .= 'mmm'; # Add three characters of space push @node_widths, $img->stringWidth($node); } $node_width = max @node_widths; # Calculate element widths $bang_width = $img->stringWidth('!'); $dot_width = $img->stringWidth('.'); $stamp_column_width = $bang_width * $stamp_interval; $node_spacing = ( (GD::Simple->fontMetrics($node_font, $node_font_size, 'hostname'))[2] ) * 1.50; # Calculate title height $title_height = (GD::Simple->fontMetrics($title_font, $title_font_size, $title))[2]; say " title_height = $title_height" if $debug > 2; # Calculate announce height $announce_height = (GD::Simple->fontMetrics($header_font, $header_font_size, $title))[2]; say " announce_height = $announce_height" if $debug > 2; # Calculate announce height $invocation_height = (GD::Simple->fontMetrics($header_font, $header_font_size, $invocation))[2]; say " invocation_height = $invocation_height" if $debug > 2; # Calculate comment height $details_height = (GD::Simple->fontMetrics($header_font, $header_font_size, $details))[2]; say " details_height = $details_height" if $debug > 2; # Calculate error height $error_height = (GD::Simple->fontMetrics($header_font, $header_font_size, $error))[2]; say " error_height = $error_height" if $debug > 2; # Calculate node_count height $node_count_height = (GD::Simple->fontMetrics($header_font, $header_font_size, $node_count))[2]; say " node_count_height = $node_count_height" if $debug > 2; # Calculate stamp_count height $stamp_count_height = (GD::Simple->fontMetrics($header_font, $header_font_size, $stamp_count))[2]; say " stamp_count_height = $stamp_count_height" if $debug > 2; # Calculate label height by finding the maximum of the $x_label height # and the $y_label height (there must be an easier way ...) { my ($x_height, $y_height); $x_height = (GD::Simple->fontMetrics($label_font, $label_font_size, $x_label))[2]; $y_height = (GD::Simple->fontMetrics($label_font, $label_font_size, $y_label))[2]; if ($x_height > $y_height) { $label_height = $x_height } else { $label_height = $y_height } say " label_height = $label_height" if $debug > 2; } # Calculate height and width of canvas $height = $top_margin + $title_height * 2 + $announce_height * 2 + $invocation_height * 2 + $details_height * 2 + $error_height * 2 + $node_count_height * 2 + $stamp_count_height * 2 + $label_height * 2 + $node_count * $node_spacing + $bottom_margin; $width = $left_margin + $node_width + ($stamp_count/$stamp_interval) * $stamp_column_width + $right_margin; # At 48 point, a typical title requires ~1800 pixels # Make sure that the width is big enough to display the title { my ($title_width, $fraction); $img->font($title_font); $img->fontsize($title_font_size); $title_width = $img->stringWidth($title); $fraction = $width / $title_width; if ($fraction < 1) { $title_font_size = int ($title_font_size * $fraction * .9); say "Shrinking title_font_size to $title_font_size" if $debug; } } # Debug info if ($debug) { say(''); say "Canvas Height = $height, Width = $width"; say "Element width bang_width = $bang_width, dot_width = $dot_width"; say "Numbers nodes = $node_count, time_stamps = $stamp_count"; say "Column width stamp_column_width = $stamp_column_width"; } # Debug trace trace_location('end') if $debug; return 1; } ####################################################################### # Given an IP address, return hostname ######################################################################## sub get_hostname { my $query; # Net::DNS search object my $res; # Net::DNS object my $hostname; # The answer my $addr = shift; # The IP address # Debug trace trace_location('begin') if $debug == 8; # Sanity check confess 'No parameters!' unless defined $addr; # Do the work $res = Net::DNS::Resolver->new(); $query = $res->search($addr); if ($query) { RR: for my $rr ($query->answer) { next RR unless $rr->type eq 'PTR'; $hostname = lc($rr->ptrdname); } } # Debug trace trace_location('end') if $debug == 8; return $hostname; } ####################################################################### # Given an IP address, return the nodename ######################################################################## sub get_nodename { my $addr = shift; # The IP address my $h; # Net::hostent object my $nodename; # The answer # Debug trace trace_location('begin') if $debug == 8; # Sanity check confess 'No parameters!' unless defined $addr; # Do the work $h = gethost($addr); if (defined $h) { $nodename = $h->name; ($nodename) = ($nodename =~ /(.*?)\./) if $nodename =~ /\./; } # Debug info if ($debug == 8) { if (defined $nodename) { say "$addr = $nodename"; } else { say "$addr nodename is undefined"; } } # Debug trace trace_location('end') if $debug == 8; return $nodename; } ######################################################################## # Given a node, its vertical offset downward, and a GD object, add the # node name plus its line of ping data to the GD object, and return the # GD object # %arg = ( # img => {GD object}, # move_down => {number}, # n => {integer}, # node => {string}, # ); ######################################################################## sub print_node { my $arg = shift; my $img; # GD object my $move_down; # Accumulates height as we move down the # canvas my $n; # Node number my $node; # Node name my @pings; my $width; # Debug trace trace_location('begin') if $debug; # Sanity check confess 'No parameter!' unless defined $arg; confess 'Parameter must be a hash ref' unless ref $arg eq 'HASH'; confess 'Must define img' unless defined $arg->{img}; confess 'Must define move_down' unless defined $arg->{move_down}; confess 'Must define n' unless defined $arg->{n}; confess 'Must define node' unless defined $arg->{node}; # Extract args $img = $arg->{img}; $move_down = $arg->{move_down}; $n = $arg->{n}; $node = $arg->{node}; # Check args confess 'img must be a GD::Simple object' unless ref $img eq 'GD::Simple'; confess 'move_down must be a number' unless $RE{num}{real}->matches($move_down); confess 'n must be an integer' unless $RE{num}{int}->matches($n); # Print this node name $img->moveTo($left_margin, $move_down + ($n + 1) * $node_spacing); $img->fgcolor($text_color); $img->fontsize($node_font_size); $img->string($node); # Extract ping data @pings = @{$ping_data{$node}}; # Set width $width = 0; # Print pings for (my $p = 0; $p < @pings; $p++) { my ($ping, $x, $y) ; # Extract local variables $ping = $pings[$p]; chomp $ping; # Figure out color given ($ping) { when ('!') { $img->fgcolor($bang_color) } when ('.') { $img->fgcolor($dot_color) } default { $img->fgcolor($unk_color) } } # Position pen $x = $left_margin + $node_width + $width; $y = $move_down + ($n + 1) * $node_spacing; $img->moveTo($x, $y); # Print string $img->string($ping); # Remember width $width = $width + $bang_width; } # Entertain operator print $BANG if $mode eq 'interactive'; # Debug trace trace_location('end') if $debug; return $img; } ######################################################################## # Process command-line arguments ######################################################################## sub process_args { # Debug trace trace_location('begin') if $debug; # Make things look pretty say('') if $mode eq 'interactive'; # Set debug level $debug = $option{d} if defined $option{d}; unless ($RE{num}{int}->matches($debug)) { say 'Option d must be an integer'; die "$usage\n"; } # Set stamp interval $stamp_interval = $option{i} if defined $option{i}; unless ($RE{num}{int}->matches($stamp_interval)) { say '-i {stamp interval} must be an integer'; die "$usage\n"; } # Set resolve_names flag $resolve_names = 1 if defined $option{n}; # Set sort flag $sort_nodes = 1 if defined $option{t}; # Capture input file or directory unless (defined $option{f}) { say 'Must specify -f {directory or file name}'; die "$usage\n"; } # Check input file unless (-r $option{f}) { say "$option{f} must be readable"; die "$usage\n"; } # Populate @data_files if (-f $option{f}) { push @data_files, $option{f}; } elsif (-d $option{f}) { my $dir = $option{f}; opendir (my $handle, $dir) or die "Cannot open $dir: $!"; FILE: while (defined (my $file = readdir($handle))) { next FILE if $file =~ /^\.\.?$/; next FILE if -d $file; next FILE unless "$dir/$file" =~ /\.csv$/; unless (-r "$dir/$file") { say "Cannot read $file, skipping"; next FILE; } push @data_files, "$dir/$file"; } } # Debug trace trace_location('end') if $debug; return 1; } ######################################################################## # Read the input file, populating the global %ping_data and returning # a list of time stamps ######################################################################## sub read_data { my $data_file = shift; my $i = 0; my @stamps; # Debug trace trace_location('begin') if $debug; # Sanity check confess 'Must feed me a file' unless defined $data_file; # Open the file open my $file, '<', $data_file or die "Cannot open $data_file: $!"; # Walk through the file, building data structures LINE: while (my $line = <$file>) { # Parse lines given ($i) { when (0) { # Grab title die "Cannot find title\n" unless $line =~ /^Mass-Ping/; $title = $line; chomp $title; } when (1) { # Grab invocation die "Cannot find invocation\n" unless $line =~ /^Invocation/; $invocation = $line; chomp $invocation; } when (2) { # Grab details die "Cannot find details\n" unless $line =~ /^Details/; $details = $line; chomp $details; } when (3) { # Grab errors die "Cannot find errors\n" unless $line =~ /^Errors/; $error = $line; chomp $error; } when (4) { # Grab timestamps die "Cannot find time stamps\n" unless $line =~ /^Timestamps/; @stamps = split $COMMA, $line; shift @stamps; } default { # Grab ping data my (@array, $node); @array = split $COMMA, $line; $node = shift @array; unless (defined $node and $node ne $EMPTY_STR) { say "\nSkipping line, malformed node"; next LINE; } $ping_data{$node} = \@array; } } # Increment line number $i++; } # Clean-up close $file or warn "Cannot close $data_file: $!"; # Calculate size of data set $node_count = scalar keys %ping_data; $stamp_count = scalar @stamps; # If asked, perform name resolution resolve_host_names() if $resolve_names; # Debug trace trace_location('end') if $debug; return @stamps; } ######################################################################## # Walk through %ping_data, resolving IP addresses to names where possible ######################################################################## sub resolve_host_names { # Debug trace trace_location('begin') if $debug; # Resolve nodenames where possible say "Resolving nodenames..." if $mode eq 'interactive'; NODE: for my $node (keys %ping_data) { my $name; my $data_ref = $ping_data{$node}; # Skip unless this node is an IP address next NODE unless validaddr($node); # Try DNS $name = lc get_hostname($node); # If that didn't work, try local name resolution if (not defined $name or $name eq $EMPTY_STR) { $name = lc get_nodename($node); } # If that didn't work, give up next NODE if (not defined $name or $name eq $EMPTY_STR); # Save the result delete $ping_data{$node}; $ping_data{$name} = $data_ref; # Entertain the operator print $BANG if $mode eq 'interactive'; } # Make things look pretty say "\n" if $mode eq 'interactive'; # Debug trace trace_location('end') if $debug; return 1; } ######################################################################## # Show the programmer where we are ######################################################################## sub trace_location { my $location = shift; my ($subroutine) = (caller (1))[3]; if ($location eq 'begin') { say "Entering $subroutine"; } elsif ($location eq 'end') { say "Leaving $subroutine"; } return 1; } ######################################################################## # Output help ######################################################################## sub HELP_MESSAGE { print <