#!/usr/local/bin/perl -w

# dialup-digest
#   Digests the dialup log file
#
#   Input: a file's path
#   Output: digested report
#
#   06/28/2001 Version 1.0
#   Use and distribute this script as per the Artistic License
#   Copyright (C) 2001 Igor S. Livshits <mailto:igorl@ayradyss.org>


# Define some constants
#
$true= (1==1);
$false= !$true;
$unexpected= "unexpected";	# key for unexpected log lines
$noARAPUser= ";";		# denotes a failed ARAP attempt
$programYear= 99;		# does not much matter since it is bogus
@monthsToDays=			# lookup table for days elapsed
  (0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334);
%monthsToNumeric=		# lookup table for months as ordered
  ("Jan" => "01",
   "Feb" => "02",
   "Mar" => "03",
   "Apr" => "04",
   "May" => "05",
   "Jun" => "06",
   "Jul" => "07",
   "Aug" => "08",
   "Sep" => "09",
   "Oct" => "10",
   "Nov" => "11",
   "Dec" => "12" );
%daysEachMonth=			# lookup table for days within each month
  ("01" => "31",
   "02" => "28",
   "03" => "31",
   "04" => "30",
   "05" => "31",
   "06" => "30",
   "07" => "31",
   "08" => "31",
   "09" => "30",
   "10" => "31",
   "11" => "30",
   "12" => "31" );
%secondsToMonths= 		# lookup table for months elapsed
  (          0 => "01",
       2678400 => "02",
       5097600 => "03",
       7776000 => "04",
      10368000 => "05",
      13046400 => "06",
      15638400 => "07",
      18316800 => "08",
      20995200 => "09",
      23587200 => "10",
      26265600 => "11",
      28857600 => "12" );

# Define storage for reduced data
#
%ports= ();			# tracks disconnecting ports
@isdnInterfaces= ();		# a history of ISDN channel activity
@modemInterfaces= ();		# a history of modem activity
%isdnChannels= ();		# tracks active ISDN channels
%userDurations= ();		# tracks total user connect times
%arapDurations= ();		# tracks ARAP connect times per modem
%arapUsers= ();			# tracks ARAP users per modem
%durations= ();			# tracks all connect times
%connections= ();		# tracks user connect counts
%userReceived= ();		# tracks user data transfers
%userTransmitted= ();		# tracks user data transfers
%received= ();			# total data received per modem
%transmitted= ();		# total data transmitted per modem
@configured= ();		# all configuration changes
%modemProblems= ();		# problem counts for each modem
%callerProblems= ();		# problem counts for each calling number
%unmatched= ();			# unmatched log records
%nameless= ();			# nameless modem call records
%namelessDurations= ();		# total connect times for each nameless origin
%abnormalTermUsers= ();		# all abnormal disconnects per user and type
%abnormalTermTypes= ();		# abnormal disconnect type counts by type


# Process command line options
#
use Getopt::Long;
GetOptions("d|debug" => \$debug,
	   "r|reverse" => \$reverse,
	   "p|primer=s" => \$primerLog,
	   "t|top" => \$top,
	   );

$top= 10 unless $top;		# report top ten by default

# Remaining arguments must be files
if ($reverse)
{				# sort files in reverse order
  @files= reverse sort @ARGV;	# to accommodate the likes of logrotate
}
else
{
  @files= sort @ARGV;
}


# Process the logs and report results
#
PrimeModemCounts($primerLog) if $primerLog;
foreach $file (@files)
{				# process each log file
  ProcessLog($file);
}
Report(GetDates($files[0], $files[$#files]));


# Clean exit
#
exit;


#
# Subroutines
#


# PrimeModemCounts
#  Find latest modem counts from a previous log file
#
sub PrimeModemCounts
{
  my($file)= shift;

  print "Priming initial values from $file...\n\n" if $debug;

  open(Input, $file) or die "Cannot read from $file: $!";
  while (<Input>)
  {				# prime initial values from a previous log
    if (/\%CALLRECORD-3-MCOM_TERSE_CALL_REC/)
    {				# track modem transfers
      my($dso, $port, $callID, $userID, $ip, $calling, $called,
	 $standard, $protocol, $compression, $initRate, $finalRate,
	 $snr, $transferred)= split(", ");

      $port= 1 + ($1-1)*24 + $2 # translate into a modem (TTY) number
	if $port=~ /slot\/port=(\d+)\/(\d+)/;

      if ($transferred=~ /(\d+)\/(\d+)/)
      {
	$received{$port}= $1;	# last chars received count
	$transmitted{$port}= $2; # last chars transmitted count
      }
      
      $arapUsers{$port}= $false # clear defunct ARAP statistics
	if exists($arapUsers{$port});
      $arapDurations{$port}= $false
	if exists($arapDurations{$port});

      next;			# grab the next line; we're done with this one
    }

    if (/\%ISDN-6-CONNECT: Interface Serial(\d+):(\d+)/)
    {				# track ISDN user connections
      my($interface)= $1 * 24 + $2; # serialize
      my($to)= /to (.+)/;
      
      if ($to)
      {
	my($calling, $userID)= split(" ", $to);
	if ($calling and $userID)
	{			# looks like a valid ISDN call
	  $calling= "21726" . $calling
	    if $calling < 99999; # fully qualify the phone number
	  $isdnChannels{$calling}= $userID; # track each user by channel
	  print "New call from $calling by $userID\n" if $debug;
	}
      }
      next;			# grab the next line; we're done with this one
    }

    if (/\%ARAP-6-ADDRFREE: TTY (\d+).+user ([^\s]+).+; (\d+) second/)
    {				# process an end of an ARAP call
      $arapUsers{$1}= $2;	# remember values for the subsequent
      $arapDurations{$1}= $3;	# modem record without a user name
      $arapUsers{$1}=~		# designate as an ARAP user
	s/;$/ (a)/ if $2 ne $noARAPUser;

      next; 			# grab the next line; we're done with this one
    }
  }
  close(Input);
}


# ProcessLog
#  Iterate through records and reduce them
#
sub ProcessLog
{
  my($file)= shift;
  my(%isdnDurations)= ();	# tracks active ISDN durations

  print "Processing $file...\n\n" if $debug;

  open(Input, $file) or die "Cannot read from $file: $!";
  while (<Input>)
  {				# accumulate records into lists
    next			# LINK-3-*, LINK-5-* and
      if /\%LINEPROTO-5-UPDOWN/ # LINEPROTO-5-* records are not
	or /\%LINK-5-CHANGED/	# properly paired
	  or /\%LINK-3-UPDOWN/;	# so ignore them

    if (/\%CALLRECORD-3-MCOM_TERSE_CALL_REC/)
    {				# process a call record entry (end of call)
      my($dso, $port, $callID, $userID, $ip, $calling, $called, # 
	 $standard, $protocol, $compression, $initRate, $finalRate,
	 $snr, $transferred, $ec, $time, $finalState,
	 $discRadius, $discLocal, $discRemote)= split(", ");
      my($disconnectTime)= /^(.{15})/;
      my($month, $day)= $disconnectTime=~ /^(.{3})\s+(\d+)/;
      $month= $monthsToNumeric{$month};
      $day= "0$day" if $day < 10;
      $disconnectTime=~ s/^.{6}/$programYear$month$day/;

      $userID=~ s/userid=//;	# drop tags and process fields
      if (length($userID) > 16)
      {				# curtail the name to fit the column
	$userID=~ s/^(.{13}).*$/$1\.\.\./;
      }

      $time=~ s/time=//;
      $discRadius=~ s/disc\(radius\)=//;
      
      $port= 1 + ($1-1)*24 + $2 # translate into a modem (TTY) number
	if $port=~ /slot\/port=(\d+)\/(\d+)/;
      push(@modemInterfaces,	# create a time history of modem use
	   ConnectTime($disconnectTime, $time) . "; +$port");
      push(@modemInterfaces, "$disconnectTime; -$port");
      
      $dso= $1 * 10 + $2 * 24 + $3 # serialize
	if $dso=~ /=(\d+)\/(\d+)\/(\d+)/;
      $ports{$dso}--;		# level 5 disconnect, negate by matching 3
      
      my($myReceived)= 0;
      my($myTransmitted)= 0;
      if ($transferred=~ /(\d+)\/(\d+)/)
      {
	if (exists($received{$port}))
	{
	  $myReceived= $1 - $received{$port};
	}
	else
	{
	  print "Initial received count for modem $port\n" if $debug;
	}
	$received{$port}= $1;	# last chars received count
	if (exists($transmitted{$port}))
	{
	  $myTransmitted= $2 - $transmitted{$port};
	}
	else
	{
	  print "Initial transmitted count for modem $port\n" if $debug;
	}
	$transmitted{$port}= $2; # last chars transmitted count
      }
      else
      {				# malformed transferred token?
	print "Malformed transmission counts <$transferred>\n" if $debug;
      }
      
      if ($userID eq "(n/a)")
      {				# no user name
	if($arapUsers{$port})
	{			# a possible ARAP record
	  if ($arapUsers{$port} eq $noARAPUser)
	  {			# an ARAP connect attempt failure
	    $calling=		# just keep the number
	      s/^calling=//;
	    $calling= "unknown" unless $calling;
	    $calling= "ARAP $calling";
	    $nameless{$calling}++; # track the number of such
	    $namelessDurations{$calling}+= $time;
	  }
	  else
	  {			# an ARAP session
	    $userID= $arapUsers{$port}; # grab remembered values
	    if (length($userID) > 16)
	    {			# curtail the name to fit the column
	      $userID=~ s/^(.{13}).*$/$1\.\.\./;
	    }
	    
	    $time= $arapDurations{$port};
	    
	    $connections{$userID}++;
	    $durations{$time}=	# remember the user for each duration
	      $userID;
	    $userDurations{$userID}+= # track each user's total connect time
	      $time;
	    $userReceived{$userID}+= # track each user's total bytes received
	      $myReceived if $myReceived > 0;
	    $userTransmitted{$userID}+= # track each user's total bytes sent
	      $myTransmitted if $myTransmitted > 0;
	  }
	}
	else
	{			# non-ARAP record with a missing user name
	  $calling= s/^calling=//; # just keep the number
	  $calling= "unknown" unless $calling;
	  $calling= "PPP $calling";
	  $nameless{$calling}++; # track the number of such
	  $namelessDurations{$calling}+= $time;
	}
      }
      else
      {				# track all identified disconnects
	$userID.= " [p]";	# designate as PPP users
	$connections{$userID}++;
	$durations{$time}=	# remember the user for each duration
	  $userID;
	$userDurations{$userID}+= # track each user's total connect time
	  $time;
	$userReceived{$userID}+= # track each user's total bytes received
	  $myReceived if $myReceived > 0;
	$userTransmitted{$userID}+= # track each user's total bytes transmitted
	  $myTransmitted if $myTransmitted > 0;
	if ($discRadius ne "User Request/Received Terminate")
	{			# abnormal disconnect
	  $abnormalTermUsers{"$userID\t$discRadius"}++;
	  $abnormalTermTypes{$discRadius}++;
	}
	print "Stranded ARAP record on port $port!"
	  if $arapUsers{$port} and $debug;
      }

      $arapUsers{$port}= $false; # clear remembered values
      $arapDurations{$port}= $false;
      next;			# grab the next line; we're done with this one
    }
      
    if (/\%CALLRECORD-3-MCOM_TERSE_CALL_FAILED_REC/)
    {				# process a failed call record
      my($dso, $port, $callID, $calling, $called,
	 $time, $finalState, $discLocal, $discRemote)= split(", ");
      my($disconnectTime)= /^(.{15})/;
      my($month, $day)= $disconnectTime=~ /^(.{3})\s+(\d+)/;
      $month= $monthsToNumeric{$month};
      $day= "0".$day if $day < 10;
      $disconnectTime=~ s/^.{6}/$programYear$month$day/;
      $time=~ s/time=//;

      $calling=~ s/calling=//;	# drop tags and process fields
      $port= 1 + ($1-1)*24 + $2 # translate into a modem (TTY) number
	if $port=~ /slot\/port=(\d+)\/(\d+)/;
      $time= 10 unless $time;	# allot at least 10 seconds to failed calls
      push(@modemInterfaces,	# create a time history of modem use
	     ConnectTime($disconnectTime, $time) . "; +$port");
      push(@modemInterfaces, "$disconnectTime; -$port");

      $dso= $1 * 10 + $2 * 24 + $3 # serialize
	if $dso=~ /=(\d+)\/(\d+)\/(\d+)/;
      $ports{$dso}--;		# level 5 disconnect, negate by matching 3
      
      $modemProblems{$port}++;	# remember the culprit modem
      $callerProblems{$calling}++; # remember the affected caller
      next; 			# grab the next line; we're done with this one
    }
      
    if (/\%ARAP-6-ADDRFREE: TTY (\d+).+user ([^\s]+).+; (\d+) second/)
    {				# process an end of an ARAP call
      $arapUsers{$1}= $2;	# remember values for the subsequent
      $arapDurations{$1}= $3;	# modem record without a user name
      $arapUsers{$1}=~		# designate as an ARAP user
	s/;$/ (a)/ if $2 ne $noARAPUser;

      next; 			# grab the next line; we're done with this one
    }
      
    next			# nothing useful within start of ARAP records
      if /\%ARAP-6-ADDRUSED/;
      
    if (/\%ISDN-6-DISCONNECT: Interface Serial(\d+):(\d+)/)
    {				# process an end of an ISDN call
      my($interface)= $1 * 24 + $2; # serialize
      my($time)= /call lasted (\d+) second/;
      my($disconnectTime)= /^(.{15})/;
      my($month, $day)= $disconnectTime=~ /^(.{3})\s+(\d+)/;
      $month= $monthsToNumeric{$month};
      $day= "0".$day if $day < 10;
      $disconnectTime=~ s/^.{6}/$programYear$month$day/;
      
      push(@isdnInterfaces,	# create a time history of interface use
	   ConnectTime($disconnectTime, $time) . "; +$interface");
      push(@isdnInterfaces, "$disconnectTime; -$interface");
      
      my($from)= /from ([^,]+)/;
      if ($from)
      {
	my($calling, $userID)= split(" ", $from);

	if ($userID)
	{
	  if (length($userID) > 16)
	  {			# curtail the name to fit the column
	    $userID=~ s/^(.{13}).*$/$1\.\.\./;
	  }

	  if ((exists($isdnChannels{$calling+1})
	       and $isdnChannels{$calling+1} eq $userID)
	      or (exists($isdnChannels{$calling-1})
		  and $isdnChannels{$calling-1} eq $userID))
	  {			# first channel of a dual-channel call
	    print "First channel of a dual-channel call: ",
	    "$userID via $calling\n" if $debug;
	    $isdnDurations{$userID}= $time;
	    $isdnChannels{$calling}= $false;
	  }
	  else
	  {
	    if ($isdnDurations{$userID})
	    {			# second channel of a dual-channel call
	      $time= $isdnDurations{$userID}
	        if $time < $isdnDurations{$userID};
	      $isdnDurations{$userID}= $false;
	      print "Second channel disconnecting of a dual-channel call:\n",
	            "\t $userID via $calling lasting $time\n\n" if $debug;
	      $userID.= " {2}";	# caller's user ID tagged for a 2-ch ISDN call
	    }
	    else
	    {			# single channel call
	      print "Single channel disconnect from $calling by $userID ",
	            "lasting $time\n" if $debug;
	      $userID.= " {1}";	# caller's user ID tagged for an ISDN call
	    }
	    
	    $connections{$userID}++;
	    $durations{$time}=	# remember the user for each duration
	      $userID;
	    $userDurations{$userID}+= # track each user's total connect time
	      $time;
	  }
	}
	else
	{			# track unknown ISDN records separately
	  $ports{$interface}++;	# level 3 disconnect, negate by matching 5
	}
      }
      next; 			# grab the next line; we're done with this one
    }

    if (/\%ISDN-6-CONNECT: Interface Serial(\d+):(\d+)/)
    {				# track ISDN user connections
      my($interface)= $1 * 24 + $2; # serialize
      my($to)= /to (.+)/;

      if ($to)
      {
	my($calling, $userID)= split(" ", $to);
	if ($calling and $userID)
	{			# looks like a valid ISDN call
	  $calling= "21726" . $calling
	    if $calling < 99999; # fully qualify the phone number
	  $isdnChannels{$calling}= $userID; # track each user by channel
	  print "New call from $calling by $userID\n" if $debug;
	}
      }
      next;			# grab the next line; we're done with this one
    }
      
    if (/^(.{15,15}).+\%SYS-5-CONFIG_I:(.+)$/)
    {				# process a configuration change
      push(@configured, "$1$2\n");
      next; 			# grab the next line; we're done with this one
    }
      
    # Skip some uninteresting and rare records
    next if /-Traceback=/;
    next if /\%AT-6-NODEWRONG/;
    next if /\%ARAP-6-MNP4T401/;
    next if /\%ARAP-6-RESENDSLOW/;
    next if /\%ARAP-6-RCVGIANT/;
    next if /\%ARAP-6-MAXRESENDS/;
    next if /\%SYS-3-CPUHOG/;
    next if /\%AAAA-3-INVSTATE/;
    
    if (/%([^:]+):/)
    {				# track unmatched records
      $unmatched{$1}++;
    }
    else
    {				# also preserve irregular lines
      /^\s*(.*)\s*$/;		# chop white spaces at terminals
      $unmatched{$1}= 0;
      $unmatched{$unexpected}++;
    }
  }
  close(Input);
}


# Report
#   Generate our report
#
sub Report
{
  my($dateRange)= shift;	# passed log date bounds
  my($index);			# a loop counter
  my($value);			# a given report value
  my($name);			# a user's account or name
  my(@ranks);			# an array of ranks for a given category
  my($stranded)= "";		# a report of stranded ports

  # Generate the title
  print "\nDial-up report: $dateRange\n";
  print "=======================================================\n";

  # Compute starting date and time
  my($month, $day)= $dateRange=~ /^(.{3})\s+(\d+)/;
  $month= $monthsToNumeric{$month};
  $day= "0".$day if $day < 10;
  $dateRange=~ s/^.{6}/$programYear$month$day/;
  
  # Report tabulated summaries
  print "\n           Peak Active ISDN Channels Each Hour\n\n",
  Tabulate($dateRange, sort(@isdnInterfaces)),
  "\n\n\n              Peak Active Modems Each Hour\n\n",
  Tabulate($dateRange, sort(@modemInterfaces)), "\n\n";
  
  # Failed calls and other problems
  print "\nStranded ports\n--------------\n";
  foreach (sort { $a <=> $b; } (keys(%ports)))
  {
    next unless $ports{$_};	# skip balanced ports
    $stranded.= sprintf "      Port %2d, state %2d\n", $_, $ports{$_};
  }
  $stranded= "None!\n" unless $stranded;
  print $stranded;

  if (each(%modemProblems))
  {				# make sure we have modem problems
    print "\nFailed calls by modem\n---------------------\n";
    @ranks= 
      sort {$modemProblems{$a}<=>$modemProblems{$b};} (keys(%modemProblems));
    while ($value= pop(@ranks))
    {				# list modems by descending problem counts
      printf "     modem %2d: %3d failed call%s\n",  $value,
      $modemProblems{$value}, $modemProblems{$value} == 1 ? "" : "s";
    }
  }
  
  if (each(%callerProblems))
  {				# make sure we have caller problems
    print "\nFailed calls by caller\n----------------------\n";
    @ranks= sort {$callerProblems{$a}<=>$callerProblems{$b};}
                 (keys(%callerProblems));
    while ($value= pop(@ranks))
    {				# list callers by descending problem counts
      if ($value eq "(n/a)")
      {
	printf "      unknown: %3d failed call%s\n", $callerProblems{$value},
	$callerProblems{$value} == 1 ? "" : "s";
      }
      else
      {
	printf "%13s: %3d failed call%s\n",  $value, $callerProblems{$value},
	$callerProblems{$value} == 1 ? "" : "s";
      }
    }
  }

  if (each(%nameless))
  {				# make sure we have nameless calls
    print "\nNameless call records\n---------------------\n";
    foreach (sort {$nameless{$a}<=>$nameless{$b};} (keys(%nameless)))
    {				# report all nameless calls
      if ($namelessDurations{$_})
      {				# we know the duration
	printf "%20s: %3d call%s averaging %s (total: %6ld s)\n",
	$_, $nameless{$_}, $nameless{$_} == 1 ? " " : "s",
	VerboseTime($namelessDurations{$_} / $nameless{$_} + .49),
	$namelessDurations{$_};
      }
      else
      {				# unknown duration
	printf "%20s: %3d call%s of unknown duration\n",
	$_, $nameless{$_}, $nameless{$_} == 1 ? " " : "s";
      }
    }
  }

  if (each(%abnormalTermTypes))
  {				# make sure we have abnormal terminations
    print "\nAbnormal terminations by type\n-----------------------------\n";
    foreach (sort {$abnormalTermTypes{$b}<=>$abnormalTermTypes{$a};}
	     (keys(%abnormalTermTypes)))
    {				# report all abnormal terminations
      printf "%9d %s\n", $abnormalTermTypes{$_}, $_;
    }
  }
    
  if (each(%abnormalTermUsers))
  {				# make sure we know abnormal termination users
    print "\nMost abnormal terminations by a user",
    "\n------------------------------------\n";
    @ranks= sort {$abnormalTermUsers{$a}<=>$abnormalTermUsers{$b};}
    (keys(%abnormalTermUsers));
    for ($index= 0; $index < $top; $index++)
    {				# report all abnormally terminated calls
      $value= pop(@ranks);
      last unless $value;	# make sure we did not run out of data
      my($userID, $type)= split("\t", $value);
      printf "%20s: %3d %s\n", $userID, $abnormalTermUsers{$value}, $type;
    }
  }

  # Top usage digests
  if (each(%userTransmitted))
  {				# make sure we know some user statistics
    print "\nMost data received by a user\n----------------------------\n";
    @ranks= sort {$userTransmitted{$a}<=>$userTransmitted{$b};}
                 (keys(%userTransmitted));
    for ($index= 0; $index < $top; $index++)
    {				# report top data transfers
      $value= pop(@ranks);
      last unless $value;	# make sure we did not run out of data
      printf "%20s: %s\n",  $value,
      DataSize($userTransmitted{$value}, $userDurations{$value});
    }
  }

  if (each(%userReceived))
  {				# make sure we know some user statistics
    print "\nMost data sent by a user\n------------------------\n";
    @ranks= sort {$userReceived{$a}<=>$userReceived{$b};}
                 (keys(%userReceived));
    for ($index= 0; $index < $top; $index++)
    {				# report top data transfers
      $value= pop(@ranks);
      last unless $value;	# make sure we did not run out of data
      printf "%20s: %s\n",  $value,
      DataSize($userReceived{$value}, $userDurations{$value});
    }
  }

  if (each(%durations))
  {				# make sure we know some user statistics
    print "\nLongest calls\n-------------\n";
    @ranks= sort { $a <=> $b; } (keys(%durations));
    for ($index= 0; $index < $top; $index++)
    {				# report top longest calls
      $value= pop(@ranks);
      last unless $value;	# make sure we did not run out of data
      printf "%20s: %s\n",  $durations{$value}, VerboseTime($value);
    }
  }
  
  # All usage digests
  if (each(%connections))
  {				# make sure we know some user statistics
    print "\nSuccessfull callers ranked by frequency",
    "\n---------------------------------------\n";
    foreach (sort {$connections{$b}<=>$connections{$a}} (keys(%connections)))
    {				# report callers ranked by frequency
      printf "%20s: %5d connection%s lasting %s (%9ld s)\n",
      $_, $connections{$_},
      $connections{$_} == 1 ? " " : "s", ApproximateTime($userDurations{$_}),
      $userDurations{$_};
    }
  }

  if (@configured)
  {				# make sure we have configuration entries
    print "\nConfiguration changes\n---------------------\n";
    foreach (@configured)
    {				# report all configuration events
      print;
    }
  }
  
  if (each(%unmatched))
  {				# make sure we know some user statistics
    print "\nUnexpected records and lines\n----------------------------\n";
    foreach (sort {$unmatched{$b}<=>$unmatched{$a}} (keys(%unmatched)))
    {				# report callers ranked by frequency
      if  ($unmatched{$_} == 0)
      {				# unexpected lines
	print "\n$_" if $debug;
      }
      else
      {				# unexpected records
	printf "%6d %s line%s\n", $unmatched{$_}, $_,
	$unmatched{$_}==1 ? "" : "s";
      }	
    }
  }
}


# GetDates
#  Learn first and last log dates
#
sub GetDates
{
  my($firstLog)= shift;
  my($lastLog)= shift;
  my($firstDate, $lastDate);

  open(Input, $firstLog) or die "Cannot read from $firstLog: $!";
  $firstDate= <Input>;		# learn first log's first line's date
  close(Input);

  open(Input, $lastLog) or die "Cannot read from $lastLog: $!";
  if (seek(Input, -1000, 2))
  {				# jump to near the end of the file
    while (<Input>)
    {				# run through the last few lines
      $lastDate= $_;		# and save the very last line
    }
  }
  else
  {
    $lastDate= "<unknown>";
  }
  close(Input);

  return (sprintf("%15.15s through %15.15s", $firstDate, $lastDate));
}

# DataSize
#  Return bytes converted to the best scale of bytes, KB, MB, or GB
#
sub DataSize
{
  my($size)= shift;		# data size
  my($time)= shift;		# time connected, optional
  
  return "inconceivable! ($size)" if $size < 1;
  return "nothing ($size)" if $size == 0;
  return "one byte ($size)" if $size == 1;

  my($rate);			# rate in kilobits per second
  if ($time)
  {				# calculate rate
    $rate= sprintf(", %5.2f Kbps)", ($size * 8) / ($time * 1024));
  }
  else
  {				# not enough info for rate calculations
    $rate= ")";
  }

  return "$size bytes ($size chars$rate"
    if $size < 1000;
  return sprintf("%6.2f kilobytes (%9ld chars$rate", $size / 1024, $size)
    if $size < 1000 * 1024; 
  return sprintf("%6.2f megabytes (%9ld chars$rate",
		 $size / (1024 * 1024), $size)
    if $size < 1000 * 1024 * 1024; 
  return sprintf("%6.2f gigabytes ($size chars$rate",
		 $size / (1024 * 1024 * 1024)); 
}


# VerboseTime
#   Returns seconds converted to a verbose time phrase
#
sub VerboseTime
{
  use integer;			# rely on integer arithmetic only
  my($time)= shift;		# get the total time in seconds
  my($phrase)= "";		# our time phrase
  my($span);			# a given time interval span
  
  return "inconceivable! ($time)" if $time < 0;
  return "no time" unless $time;

  if ($span= $time % 60)
  {				# insert second count
    $phrase= sprintf("%2d second%s", $span, $span>1 ? "s" : "");
  }
  return $phrase		# check if there is any time left
    unless $time/= 60;

  if ($span= $time % 60)
  {				# insert minute count
    $phrase= sprintf("%2d minute%s%s", $span,
		     $span>1 ? "s" : "",
		     $phrase ? ($span>1 ? ", " : ",  ") . $phrase : "");
  }
  return $phrase		# check if there is any time left
    unless $time/= 60;

  if ($span= $time % 24)
  {				# insert hour count
    $phrase= sprintf("%2d hour%s%s", $span,
		     $span>1 ? "s" : "",
		     $phrase ? ($span>1 ? ", " : ",  ") . $phrase : "");
  }
  return $phrase		# check if there is any time left
    unless $time/= 24;

  if ($span= $time % 7)
  {				# insert day count
    $phrase= sprintf("%2d day%s%s", $span,
		     $span>1 ? "s" : "",
		     $phrase ? ($span>1 ? ", " : ",  ") . $phrase : "");
  }
  return $phrase		# check if there is any time left
    unless $time/= 7;

  if ($span= $time % 52)
  {				# insert week count
    $phrase= sprintf("%2d week%s%s", $span,
		     $span>1 ? "s" : "",
		     $phrase ? ($span>1 ? ", " : ",  ") . $phrase : "");
  }
  return $phrase		# check if there is any time left
    unless $time/= 52;

				# insert year count
  $phrase= sprintf("%2d year%s%s", $span,
		   $span>1 ? "s" : "",
		   $phrase ? ($span>1 ? ", " : ",  ") . $phrase : "");

  return $phrase;
}


# ApproximateTime
#   Returns time described as an approximation to the largest unit
#
sub ApproximateTime
{
  my($time)= shift;		# get the total time in seconds

  return "inconceivable!   " if $time < 0;
  return "no time at all   " unless $time;
  
  return sprintf("      %2d second%s ", $time, $time>1 ? "s" : " ")
    if ($time < 45);
  
  return "about  1 minute  "	# less than a minute and a half
    if ($time < 91);
  
  return sprintf("about %2.0f minutes ", $time/60)
    if ($time < 3600);
  
  return "about an hour    "	# less than an hour and a half
    if ($time < 5401);
  
  return sprintf("about %2.0f hours   ", $time/3600)
    if ($time < 86400);
  
  return "about  1 day     "	# less than a day and a half
    if ($time < 129601);
  
  return sprintf("about %2.0f days    ", $time/86400)
    if ($time < 604800);
  
  return "about  1 week    "	# less than a week and a half
    if ($time < 907201);
  
  return sprintf("about %2.0f weeks   ", $time/604800)
    if ($time < 31449600);
  
  return "about  1 year    "	# less than a year and a half
    if ($time < 47174400);
  
  return sprintf("about %2.0f years   ", $time/31449600);
}


# ConnectTime
#   Compute a connection time stamp from a disconnect stamp and elapsed time
#     Ignore leap days -- not enough information to treat properly
#
sub ConnectTime
{
  use integer;
  my($disconnectTime)= shift;
  my($span)= shift;
  my($year, $month, $day, $hour, $minute, $second)=
    $disconnectTime=~ /^(\d\d)(\d\d)(\d\d)\s+(\d+):(\d+):(\d+)/;

  my($disconnectSeconds)= ($monthsToDays[$month-1]+$day-1) * 86400
    + $hour * 3600 + $minute * 60 + $second;

  my($connectSeconds)= $disconnectSeconds - $span;
  while ($connectSeconds < 0)
  {				# correct for year boundaries
    $connectSeconds+= 365 * 86400;
    $year--;
  }

  foreach (reverse sort { $a <=> $b; } keys(%secondsToMonths))
  {
    if ($connectSeconds >= $_)
    {				# matched a month
      print "$connectSeconds exceeds $_\n" if $debug;
      $month= $secondsToMonths{$_};
      $connectSeconds-= $_;
      last;
    }
  }

  $day= $connectSeconds / 86400 + 1;
  $day= "0" . $day if $day < 10;
  $connectSeconds%= 86400;
  $hour= $connectSeconds / 3600;
  $hour= "0" . $hour if $hour < 10;
  $connectSeconds%= 3600;
  $minute= $connectSeconds / 60;
  $minute= "0" . $minute if $minute < 10;
  $second= $connectSeconds % 60;
  $second= "0" . $second if $second < 10;
  
  print "$year$month$day $hour:$minute:$second to $disconnectTime ($span)\n"
    if $debug; 

  return "$year$month$day $hour:$minute:$second";
}


# Tabulate
#   Summarize a time history in a table
#
sub Tabulate
{
  my($firstDate)= shift;	# starting date and time
  my($firstHour);
  my($event);			# each event of the time history
  my($current)= 0;		# track activity
  my($max)= 0;
  my($min)= 99;
  my($date);
  my($lastDate);
  my($hour);
  my($lastHour);
  my(@maxima)= (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0);
  my(@minima)=
    (99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99);
  my(@means)= (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0); 
  my(@counts)= (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0); 
  my($table);			# formatted table and ingredients
  my($topRow)= 0;		# the top row in our table
  my($header)= "     < hr 0  1  2  3  4  5  6  7  8  9 10 11" .
               " 12 13 14 15 16 17 18 19 20 21 22 23";
  my($separator)= ":-------------------------------------" .
	          "------------------------------------";

  ($firstDate, $firstHour)= $firstDate=~ /^(\d{6}) (\d\d)/;
  $lastDate= $firstDate;
  $lastHour= $firstHour;
  $table= $header . "\n date " . $separator;
  $lastDate=~ /\d\d(\d\d)(\d\d)/;
  $table.= "\n$1\/$2 : ";	# start the first  row
  for ($hour= 0; $hour < $lastHour; $hour++)
  {				# advance to the first data cell
    $table.= "   ";
  }
  while($event= shift)
  {				# iterate through the history
    ($date, $hour)= $event=~ /^(\d{6}) (\d\d)/;
    if ($date < $firstDate)
    {				# pin to the start of the first log
      $date= $firstDate;
      $hour= $firstHour;
      $track= $false;
    }
    elsif ($date == $firstDate and $hour < $firstHour)
    {
      $hour= $firstHour;
      $track= $false;
    }
    else
    {				# only track statistics
      $track= $true;		# within the current period
    }
    while ($lastDate < $date)
    {				# make a new row
      while ($lastHour < 24)
      {				# first, fill the rest of the row
	$table.= $max ? sprintf("%3d", $max) : "   ";
	$minima[$lastHour]= $min if $track and ($min < $minima[$lastHour]);
	$maxima[$lastHour]= $max if $track and ($max > $maxima[$lastHour]);
	$means[$lastHour]+= $current if $track;
	$counts[$lastHour]++ if $track;
	$lastHour++;
	$max= $min= $current;	# reset extrema
      }
      my($year, $month, $day)= $lastDate=~ /^(\d\d)(\d\d)(\d\d)/;
      $lastHour= 0;		# reset hour to beginning of the next day
      $day++;			# advance to the next day
      if ($day > $daysEachMonth{$month})
      {				# wrap to the next month
	$date=~ /^\d\d(\d\d)(\d\d)/;
	unless ($month == 2 and $day == 29 and $1 == 2 and $2 == 29)
	{			# February is special due to a leap day
	  $month++;
	  if ($month < 10)
	  {			# pad single digit months
	    $month= "0$month" if $month < 10;
	  }
	  elsif ($month > 12)
	  {			# end of the year
	    $month= "01";
	    $year++;
	  }
	  $day= "01";
	}
      }
      $lastDate= "$year$month$day";
      $table.= "\n$month\/$day : "; # reset to start of next row
    }
    while ($lastHour < $hour)
    {				# catch up to the current hour
      $table.= $max ? sprintf("%3d", $max) : "   ";
      $minima[$lastHour]= $min if $track and ($min < $minima[$lastHour]);
      $maxima[$lastHour]= $max if $track and ($max > $maxima[$lastHour]);
      $means[$lastHour]+= $current if $track;
      $counts[$lastHour]++ if $track;
      $lastHour++;
      $max= $min= $current;	# reset extrema
    }
    
    # Track interface counts
    $event=~ /\+/ ? $current++ : $current--;
    if ($current < 0)
    {				# should NEVER happen
      print "Bogus interface count: $event ($track)\n";
      $current= 0;
    }
    $max= $current if $current > $max;
    $min= $current if $current < $min;
    print "Zero count: $event ($track)\n" if $debug and ($min == 0);
    $means[$hour]+= $current if $track;
    $counts[$hour]++ if $track;
    $minima[$hour]= $min if $track and ($min < $minima[$hour]);
    $maxima[$hour]= $max if $track and ($max > $maxima[$hour]);
  }
  $table.= sprintf("%3d", $max); # update for the last hour

  # Tabulate activity counts per hour for the period
  $table.= "\n      " . $separator . "\n" . $header . "\n \#    " . $separator;
  foreach (@maxima)
  {				# find the overall maximum
    $topRow= $_ if $_ > $topRow;
  }
  do				# construct our table
  {				# start a new row
    $table.= sprintf("\n   %2d : ", $topRow);
    for ($hour= 0; $hour < 24; $hour++)
    {				# construct a cell
      my($tick)= "   ";
      $tick= " - " if $minima[$hour] == $topRow;
      $tick= " + " if $maxima[$hour] == $topRow;
      $tick= " | " if $maxima[$hour] > $topRow and $topRow > $minima[$hour];
      $tick= " * " if ($counts[$hour])
	and (int($means[$hour]/$counts[$hour]+0.5) == $topRow);
      $table.= $tick;
    }
  }
  while ($topRow--);

  return $table . "\n      " . $separator;
}