From 69fa2017cc6030740b45d6174ebfc04d894595a1 Mon Sep 17 00:00:00 2001
From: Leigh B Stoller <stoller@flux.utah.edu>
Date: Wed, 6 May 2020 11:21:27 -0600
Subject: [PATCH] Lots of work on rfmonitor stuff:

* Save data for 24 hours in the rfmonitor directory. The files are now
  gzipped, we use pako JS library to gunzip them at the client browser.

* Save data that corresponds to violations, in the archive directory for
  7 days.

* Change the violation email to reference the frequency graph for the
  current data set.

* Lots of changes to the frequency graph code to handle specific log
  files (by unix timestamp) and build a menu of all of the available
  data files so the use can flip through the data sets.
---
 GNUmakefile.in                          |   4 +-
 apt/GNUmakefile.in                      |   4 +-
 apt/rfmonitor_daemon.in                 |  96 +++++--
 configure                               |   3 +
 configure.ac                            |   3 +
 defs-default                            |   1 +
 powder/GNUmakefile.in                   |  96 +++++++
 powder/bspowerconfig.in                 | 142 ++++++++++
 powder/htaccess                         |   2 +
 powder/listing.php.in                   |  64 +++++
 powder/powder_deadman.in                | 360 ++++++++++++++++++++++++
 powder/powder_keepalive.in              | 180 ++++++++++++
 {apt => powder}/powder_shutdown.in      |   4 +-
 {apt => powder}/powderstats.in          |   0
 www/aptui/frequency-graph.ajax          |  53 +++-
 www/aptui/frequency-graph.php           |  18 ++
 www/aptui/js/freqgraphs.js              | 176 ++++++++++--
 www/aptui/js/frequency-graph.js         |   3 +
 www/aptui/js/status.js                  |   1 +
 www/aptui/server-ajax.php               |   2 +
 www/aptui/status.php                    |   1 +
 www/aptui/template/frequency-graph.html |  27 +-
 22 files changed, 1191 insertions(+), 49 deletions(-)
 create mode 100644 powder/GNUmakefile.in
 create mode 100644 powder/bspowerconfig.in
 create mode 100644 powder/htaccess
 create mode 100644 powder/listing.php.in
 create mode 100644 powder/powder_deadman.in
 create mode 100644 powder/powder_keepalive.in
 rename {apt => powder}/powder_shutdown.in (98%)
 rename {apt => powder}/powderstats.in (100%)

diff --git a/GNUmakefile.in b/GNUmakefile.in
index 32e7960c63..e6b540448e 100644
--- a/GNUmakefile.in
+++ b/GNUmakefile.in
@@ -1,5 +1,5 @@
 #
-# Copyright (c) 2000-2017 University of Utah and the Flux Group.
+# Copyright (c) 2000-2017, 2020 University of Utah and the Flux Group.
 # 
 # {{{EMULAB-LICENSE
 # 
@@ -59,7 +59,7 @@ ifeq ($(ISMAINSITE),1)
 SUBDIRS += tools/rmanage tools/whol
 endif
 ifeq ($(PGENISUPPORT),1)
-SUBDIRS += protogeni apt
+SUBDIRS += protogeni apt powder
 endif
 else
 SUBDIRS = db tbsetup account protogeni
diff --git a/apt/GNUmakefile.in b/apt/GNUmakefile.in
index 5329e3bf00..4a730ffa55 100644
--- a/apt/GNUmakefile.in
+++ b/apt/GNUmakefile.in
@@ -37,10 +37,10 @@ BIN_SCRIPTS	= manage_profile manage_instance manage_dataset \
 		  create_slivers searchip start-experiment manage_resgroup
 SBIN_SCRIPTS	= apt_daemon aptevent_daemon portal_xmlrpc apt_checkup \
 		  portal_monitor apt_scheduler portal_resources \
-		  manage_licenses manage_aggregate powder_shutdown \
+		  manage_licenses manage_aggregate \
 		  rfmonitor_daemon aptimage_daemon aptexpire_daemon \
 		  recalcmaxext aptresgroup_daemon aptbus_monitor \
-		  aptroute_monitor manage_rfranges powderstats
+		  aptroute_monitor manage_rfranges 
 LIB_SCRIPTS     = APT_Profile.pm APT_Instance.pm APT_Dataset.pm APT_Geni.pm \
 		  APT_Aggregate.pm APT_Utility.pm APT_Rspec.pm \
 		  APT_Reservation.pm APT_RFRange.pm
diff --git a/apt/rfmonitor_daemon.in b/apt/rfmonitor_daemon.in
index 9d2b9c8484..0f767e9f2f 100644
--- a/apt/rfmonitor_daemon.in
+++ b/apt/rfmonitor_daemon.in
@@ -68,10 +68,13 @@ my $OURDOMAIN        = "@OURDOMAIN@";
 my $TBBASE           = "@TBBASE@";
 my $MAINSITE         = @TBMAINSITE@;
 my $POWDER_RFMONITOR = @POWDER_RFMONITOR@;
+my $POWDER_NICKNAME  = "@POWDER_NICKNAME@";
 my $PGENISUPPORT     = @PROTOGENI_SUPPORT@;
 my $LOGFILE          = "$TB/log/rfmonitor_daemon.log";
 my $RAWDATADIR       = "$TB/www/rfmonitor";
 my $POWER            = "$TB/bin/power";
+my $GZIP             = "/usr/bin/gzip";
+my $FIND             = "/usr/bin/find";
 my $FORMAT           = "portid,timestamp,frequency,power";
 my $MAILDELAY        = 3600;
 
@@ -87,8 +90,8 @@ sub ShouldNotify($$);
 sub NodeNotified($$$);
 sub HandleChild($);
 sub LoadNodeData($);
-sub HandleViolations($$);
-sub WriteRawData($);
+sub HandleViolations($$$);
+sub WriteRawData($$$);
 
 #
 # Explanatory text.
@@ -295,6 +298,7 @@ sub HandleChild($)
     my $noisefloor = $NOISEFLOOR;
     my @dbinserts  = ();
     my %csvdata    = ();
+    my $filestamp  = time();
 
     # Add a DB insert to the list of violations to store in the DB.
     my $addInsert = sub {
@@ -556,8 +560,16 @@ sub HandleChild($)
 	    $nextdata->{$key} = $measurement;		    
 	}
     }
+    #
+    # Write the raw data file(s)
+    #
+    WriteRawData(\%csvdata, $filestamp, scalar(keys(%violaters)));
+
+    #
+    # We send links to the data and the graph page in the email.
+    #
     if (keys(%violaters)) {
-	HandleViolations(\%violaters, \%rflimits);
+	HandleViolations(\%violaters, \%rflimits, $filestamp);
     }
     if (@dbinserts) {
 	my $query = "insert into node_rf_violations ".
@@ -587,11 +599,6 @@ sub HandleChild($)
     else {
 	print STDERR "Could not open $datafile for writing: $!\n";
     }
-    #
-    # Write the raw data file(s)
-    #
-    WriteRawData(\%csvdata);
-
     print "Finished with $address:$port at " .
 	POSIX::strftime("%m/%d %H:%M:%S", localtime()) . "\n";
     exit(0);
@@ -632,15 +639,15 @@ sub LoadNodeData($)
 # List of nodes, and interfaces that are in violation. We want to generate
 # an informative email message and turn off the nodes.
 #
-sub HandleViolations($$)
+sub HandleViolations($$$)
 {
-    my ($violations, $rflimits) = @_;
+    my ($violations, $rflimits, $filestamp) = @_;
 
     #
     # Simple, send a message per node.
     #
     foreach my $nodeid (keys(%{$violations})) {
-	my ($TO, $subject, $body, $portalurl, $rfurl);
+	my ($TO, $subject, $body, $portalurl, $rfurl, $graphurl);
 	my $forcemail = 0;
 	my $headers;
 
@@ -725,6 +732,13 @@ sub HandleViolations($$)
 		    if ($measurement->{'repeatcount'} == 1);
 	    }
 	    $body .= "\n";
+	    #
+	    # This will need to change when there is more then one TX
+	    # iface per node.
+	    #
+	    $graphurl = "https://www.powderwireless.net" .
+		"/frequency-graph.php?node_id=$nodeid&iface=$iface" .
+		"&cluster=${POWDER_NICKNAME}&logid=$filestamp&archived=1";
 	}
 	if (!$mailonly) {
 	    $body .= "\n" . "This node will be immediately powered off!\n";
@@ -738,6 +752,7 @@ sub HandleViolations($$)
 	else {
 	    $rfurl = "$TBBASE/portal/show-rfviolations.php?node_id=$nodeid";
 	}
+	$body .= "\n" . "Monitor Graph:\n" . $graphurl . "\n";
 	$body .= "\n" . "Violation History:\n" . $rfurl . "\n";
 	
 	if ($portalurl) {
@@ -827,16 +842,38 @@ sub NodeNotify($$$)
 #
 # Write RAW data to CSV files in /usr/testbed/www
 #
-sub WriteRawData($)
+sub WriteRawData($$$)
 {
-    my ($data) = @_;
+    my ($data, $filestamp, $violations) = @_;
 
     foreach my $portid (keys(%{$data})) {
-	my @samples  = @{$data->{$portid}};
-	my $newname  = "$RAWDATADIR/${portid}.new";
-	my $csvname  = "$RAWDATADIR/${portid}.csv";
+	my @samples   = @{$data->{$portid}};
+	my $filename  = "${portid}-${filestamp}.csv";	
+	my $tmpname   = "$RAWDATADIR/${portid}.tmp";
+	my $csvname   = "$RAWDATADIR/${portid}.csv";
+	my $gzsymlink = "$RAWDATADIR/${portid}.csv.gz";
+	my ($gzipname);
+	#
+	# When there are violations, we archive the raw data for longer
+	# in the archive directory, and the symlink points there instead.
+	#
+	if ($violations) {
+	    $gzipname  = "$RAWDATADIR/archive/${filename}.gz";
+	}
+	else {
+	    $gzipname  = "$RAWDATADIR/${filename}.gz";
+	}
 
-	if (open(CSV, "> $newname")) {
+	#
+	# Prune historical data older then 24 hours.
+	# Prune archived data older then 1 week.
+	#
+	system("$FIND -E $RAWDATADIR -regex '^.*\-[0-9]+\.csv\.gz\$' ".
+	       "-mtime +24h -depth 1 -print " . ($impotent ? "" : "-delete"));
+	system("$FIND $RAWDATADIR/archive -mtime +7 -print ".
+	       ($impotent ? "" : "-delete"));
+
+	if (open(CSV, "> $tmpname")) {
 	    print CSV "frequency,power\n";
 	    
 	    foreach my $ref (@samples) {
@@ -847,16 +884,33 @@ sub WriteRawData($)
 		print CSV "$freq,$power\n";
 	    }
 	    if (close(CSV)) {
-		if (!rename($newname, $csvname)) {
-		    print STDERR "Could not rename new CSV file $newname: $!\n";
+		system("$GZIP -c $tmpname > $gzipname");
+		if ($?) {
+		    print STDERR "Could not gzip $tmpname\n";
+		    return -1;
+		}
+		if (!rename($tmpname, $csvname)) {
+		    print STDERR "Could not rename new CSV file: $!\n";
+		    return -1;
+		}
+		# A violation, symlink into the
+		if ($violations &&
+		    system("/bin/ln -sf archive/${filename}.gz ".
+			   "                  $RAWDATADIR/${filename}.gz")) {
+		    print STDERR "Could not create archive symlink\n";
+		    return -1;
+		}
+		if (system("/bin/ln -sf ${filename}.gz $gzsymlink")) {
+		    print STDERR "Could not update $gzsymlink\n";
+		    return -1;
 		}
 	    }
 	    else {
-		print STDERR "Could not close new CSV file $newname: $!\n";
+		print STDERR "Could not close new CSV file $tmpname: $!\n";
 	    }
 	}
 	else {
-	    print STDERR "Could not open new CSV file $newname: $!\n";
+	    print STDERR "Could not open new CSV file $tmpname: $!\n";
 	}
     }
 }
diff --git a/configure b/configure
index 8334fe9173..9f651c4ec5 100755
--- a/configure
+++ b/configure
@@ -678,6 +678,7 @@ TBOPSEMAIL_NOSLASH
 TBOPSEMAIL
 POWDER_RFMONITOR_HOST
 POWDER_RFMONITOR
+POWDER_NICKNAME
 UI_EXTERNAL_ACCOUNTS
 UI_DISABLE_RESERVATIONS
 UI_DISABLE_DATASETS
@@ -5367,6 +5368,7 @@ UI_DISABLE_RESERVATIONS=0
 UI_EXTERNAL_ACCOUNTS=0
 POWDER_RFMONITOR=0
 POWDER_RFMONITOR_HOST="0.0.0.0"
+POWDER_NICKNAME=""
 DISABLE_RESERVATION_EMAIL=0
 MAILERNODE="ops"
 
@@ -7299,6 +7301,7 @@ outfiles="$outfiles Makeconf GNUmakefile \
 	protogeni/rspec-emulab/0.2/GNUmakefile \
 	protogeni/rspec-emulab/2/GNUmakefile \
         apt/GNUmakefile \
+        powder/GNUmakefile \
         collab/GNUmakefile \
 	collab/exp-vis/GNUmakefile collab/exp-vis/fetch-vis \
 	node_usage/GNUmakefile node_usage/mk-plots \
diff --git a/configure.ac b/configure.ac
index e23b1d3c9a..58484280f5 100644
--- a/configure.ac
+++ b/configure.ac
@@ -375,6 +375,7 @@ AC_SUBST(UI_DISABLE_RESERVATIONS)
 AC_SUBST(UI_EXTERNAL_ACCOUNTS)
 AC_SUBST(POWDER_RFMONITOR)
 AC_SUBST(POWDER_RFMONITOR_HOST)
+AC_SUBST(POWDER_NICKNAME)
 AC_SUBST(DISABLE_RESERVATION_EMAIL)
 AC_SUBST(MAILERNODE)
 
@@ -571,6 +572,7 @@ UI_DISABLE_RESERVATIONS=0
 UI_EXTERNAL_ACCOUNTS=0
 POWDER_RFMONITOR=0
 POWDER_RFMONITOR_HOST="0.0.0.0"
+POWDER_NICKNAME=""
 DISABLE_RESERVATION_EMAIL=0
 MAILERNODE="ops"
 
@@ -1534,6 +1536,7 @@ outfiles="$outfiles Makeconf GNUmakefile \
 	protogeni/rspec-emulab/0.2/GNUmakefile \
 	protogeni/rspec-emulab/2/GNUmakefile \
         apt/GNUmakefile \
+        powder/GNUmakefile \
         collab/GNUmakefile \
 	collab/exp-vis/GNUmakefile collab/exp-vis/fetch-vis \
 	node_usage/GNUmakefile node_usage/mk-plots \
diff --git a/defs-default b/defs-default
index deb5cd2577..ca61130df3 100644
--- a/defs-default
+++ b/defs-default
@@ -225,6 +225,7 @@ BOOTINFO_EVENTS=0
 # Powder RF monitor.
 POWDER_RFMONITOR=1
 POWDER_RFMONITOR_HOST="$BOSSNODE_IP"
+POWDER_NICKNAME="Emulab"
 
 # No longer allow geni users direct access. Must go through the Portal
 # or the local web interface. 
diff --git a/powder/GNUmakefile.in b/powder/GNUmakefile.in
new file mode 100644
index 0000000000..eda120cc2d
--- /dev/null
+++ b/powder/GNUmakefile.in
@@ -0,0 +1,96 @@
+#
+# Copyright (c) 2000-2020 University of Utah and the Flux Group.
+# 
+# {{{EMULAB-LICENSE
+# 
+# This file is part of the Emulab network testbed software.
+# 
+# This file is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or (at
+# your option) any later version.
+# 
+# This file is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public
+# License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with this file.  If not, see <http://www.gnu.org/licenses/>.
+# 
+# }}}
+#
+
+SRCDIR		= @srcdir@
+TESTBED_SRCDIR	= @top_srcdir@
+OBJDIR		= ..
+SUBDIR		= powder
+
+include $(OBJDIR)/Makeconf
+
+SUBDIRS		= 
+
+SBIN_SCRIPTS	= powder_shutdown powderstats \
+		  powder_keepalive powder_deadman bspowerconfig
+WEB_SBIN_SCRIPTS= webpowder_shutdown
+LIBEXEC_SCRIPTS	= $(WEB_SBIN_SCRIPTS)
+
+#
+# Force dependencies on the scripts so that they will be rerun through
+# configure if the .in file is changed.
+# 
+all:	$(SBIN_SCRIPTS) $(LIBEXEC_SCRIPTS) listing.php
+
+subboss: 
+
+include $(TESTBED_SRCDIR)/GNUmakerules
+
+install: rfmonitor-install \
+	$(addprefix $(INSTALL_SBINDIR)/, $(SBIN_SCRIPTS)) \
+	$(addprefix $(INSTALL_LIBEXECDIR)/, $(LIBEXEC_SCRIPTS))
+
+boss-install: install install-subdirs
+
+rfmonitor-install: rfmonitor-subdir \
+	$(INSTALL_WWWDIR)/rfmonitor/.htaccess \
+	$(INSTALL_WWWDIR)/rfmonitor/listing.php
+
+$(INSTALL_WWWDIR)/rfmonitor/.htaccess: htaccess
+	$(INSTALL) -m 644 $< $@
+
+$(INSTALL_WWWDIR)/rfmonitor/listing.php: listing.php
+	$(INSTALL) -m 644 $< $@
+
+rfmonitor-subdir:
+	-mkdir -p $(INSTALL_WWWDIR)/rfmonitor
+	-mkdir -p $(INSTALL_WWWDIR)/rfmonitor/archive
+
+subboss-install: 
+
+post-install: 
+
+#
+# Control node installation (aka, ops)
+#
+control-install:
+
+# This rule says what web* script depends on which installed binary directory.
+$(WEB_SBIN_SCRIPTS): $(INSTALL_SBINDIR)
+
+# Just in case the dirs are not yet created,
+$(INSTALL_SBINDIR) $(INSTALL_BINDIR):
+
+# And then how to turn the template into the actual script. 
+$(WEB_SBIN_SCRIPTS): $(TESTBED_SRCDIR)/WEBtemplate.in
+	@echo "Generating $@"
+	cat $< | sed -e 's,@PROGTOINVOKE@,$(word 2,$^)/$(subst web,,$@),' > $@
+
+clean:	clean-subdirs
+
+# How to recursively descend into subdirectories to make general
+# targets such as `all'.
+%.MAKE:
+	@$(MAKE) -C $(dir $@) $(basename $(notdir $@))
+%-subdirs: $(addsuffix /%.MAKE,$(SUBDIRS)) ;
+
+.PHONY:	$(SUBDIRS) install
diff --git a/powder/bspowerconfig.in b/powder/bspowerconfig.in
new file mode 100644
index 0000000000..dca45dc938
--- /dev/null
+++ b/powder/bspowerconfig.in
@@ -0,0 +1,142 @@
+#!/usr/bin/perl -w
+#
+# Copyright (c) 2008-2020 University of Utah and the Flux Group.
+# 
+# {{{GENIPUBLIC-LICENSE
+# 
+# GENI Public License
+# 
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and/or hardware specification (the "Work") to
+# deal in the Work without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense,
+# and/or sell copies of the Work, and to permit persons to whom the Work
+# is furnished to do so, subject to the following conditions:
+# 
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Work.
+# 
+# THE WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE WORK OR THE USE OR OTHER DEALINGS
+# IN THE WORK.
+# 
+# }}}
+#
+use strict;
+use English;
+use Getopt::Std;
+use Data::Dumper;
+use Date::Parse;
+
+#
+# Generate config files for the local power control setup on the
+# base station cnucs.
+#
+sub usage()
+{
+    print "Usage: bspowerconfig bsname\n";
+    exit(1);
+}
+my $optlist   = "d";
+my $debug     = 0;
+my %pduinfo   = ();
+
+#
+# Configure variables
+#
+my $TB		     = "@prefix@";
+my $TBOPS            = "@TBOPSEMAIL@";
+my $MAINSITE         = @TBMAINSITE@;
+
+# un-taint path
+$ENV{'PATH'} = '/bin:/usr/bin:/usr/local/bin:/usr/site/bin';
+delete @ENV{'IFS', 'CDPATH', 'ENV', 'BASH_ENV'};
+
+# Protos
+sub fatal($);
+	  
+#
+# Turn off line buffering on output
+#
+$| = 1; 
+
+# Load the Testbed support stuff.
+use lib "@prefix@/lib";
+use emdb;
+use libtestbed;
+use emutil;
+use libEmulab;
+use Node;
+
+#
+# Load the radio info, which for now tells the node IDs of the radios.
+# From there we can find the pdu info in the outlets table.
+#
+my $query_result =
+    DBQueryFatal("select node_id,location from apt_aggregate_radioinfo ".
+		 "where installation_type='BS'");
+
+while (my ($node_id,$location) = $query_result->fetchrow_array()) {
+    my $node = Node->Lookup($node_id);
+    if (!defined($node)) {
+	fatal("No such node $node_id in the DB");
+    }
+    my $outlet_result =
+	DBQueryFatal("select power_id,outlet from outlets ".
+		     "where node_id='$node_id'");
+    if (!$outlet_result->numrows) {
+	print "No outlet info for $node_id, skipping\n";
+	next;
+    }
+    my ($power_id,$outlet) = $outlet_result->fetchrow_array();
+    my $powernode = Node->Lookup($power_id);
+    if (!defined($powernode)) {
+	fatal("No such node $power_id in the DB");
+    }
+    my $iface = $powernode
+}
+
+#
+# Hmm, we do not have anything in the DB that maps a base station
+# to its PDU name.
+#
+my %pdus = (
+    "powder-pdu-smt"
+    "powder-pdu-fm"
+    "powder-pdu-meb"
+    "powder-pdu-dentistry"
+    "powder-pdu-ustar"
+    "powder-pdu-honors"
+| powder-pdu-browning  |
+| powder-pdu-bes       |
+| powder-pdu-hospital    
+);
+
+#
+# 
+#
+my %options = ();
+if (! getopts($optlist, \%options)) {
+    usage();
+}
+if (defined($options{"d"})) {
+    $debug = 1;
+}
+usage()
+    if (!@ARGV);
+
+exit(0);
+
+sub fatal($)
+{
+    my ($msg) = @_;
+
+    die("*** $0:\n".
+	"    $msg\n");
+}
+
diff --git a/powder/htaccess b/powder/htaccess
new file mode 100644
index 0000000000..739e9e3214
--- /dev/null
+++ b/powder/htaccess
@@ -0,0 +1,2 @@
+Options Indexes FollowSymLinks
+Header Set Access-Control-Allow-Origin "*"
diff --git a/powder/listing.php.in b/powder/listing.php.in
new file mode 100644
index 0000000000..53df482e0d
--- /dev/null
+++ b/powder/listing.php.in
@@ -0,0 +1,64 @@
+<?php
+#
+# Copyright (c) 2000-2020 University of Utah and the Flux Group.
+# 
+# {{{EMULAB-LICENSE
+# 
+# This file is part of the Emulab network testbed software.
+# 
+# This file is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or (at
+# your option) any later version.
+# 
+# This file is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public
+# License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with this file.  If not, see <http://www.gnu.org/licenses/>.
+# 
+# }}}
+#
+$TBDIR          = "@prefix@/";
+$RFDIR          = "$TBDIR/www/rfmonitor/";
+
+$listing = array();
+
+function getFileList($dir, $archive)
+{
+    global $listing;
+
+    // open pointer to directory and read list of files
+    $d = dir($dir);
+    if (!$d) {
+        exit("Failed to open $dir for reading");
+    }
+    while (($entry = $d->read()) !== FALSE) {
+        #
+        # Only the ,gz files
+        #
+        if (!preg_match("/\.gz$/", $entry)) {
+            continue;
+        }
+        $listing[] = [
+            'name'     => $entry,
+            'size'     => filesize("{$dir}{$entry}"),
+            'lastmod'  => filemtime("{$dir}{$entry}"),
+            'archived' => $archive,
+        ];
+    }        
+    $d->close();
+}
+getFileList($RFDIR, 0);
+getFileList("$RFDIR/archive/", 1);
+
+header("Content-Type: text/plain");
+header("Expires: Mon, 26 Jul 1997 05:00:00 GMT");
+header("Cache-Control: no-cache, must-revalidate");
+header("Pragma: no-cache");
+
+echo json_encode($listing);
+
+?>
diff --git a/powder/powder_deadman.in b/powder/powder_deadman.in
new file mode 100644
index 0000000000..103f35f052
--- /dev/null
+++ b/powder/powder_deadman.in
@@ -0,0 +1,360 @@
+#!/usr/bin/perl -w
+#
+# Copyright (c) 2008-2020 University of Utah and the Flux Group.
+# 
+# {{{GENIPUBLIC-LICENSE
+# 
+# GENI Public License
+# 
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and/or hardware specification (the "Work") to
+# deal in the Work without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense,
+# and/or sell copies of the Work, and to permit persons to whom the Work
+# is furnished to do so, subject to the following conditions:
+# 
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Work.
+# 
+# THE WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE WORK OR THE USE OR OTHER DEALINGS
+# IN THE WORK.
+# 
+# }}}
+#
+use strict;
+use English;
+use Getopt::Std;
+use Data::Dumper;
+use Date::Parse;
+use POSIX qw(strftime ceil);
+
+#
+# Powder deadman switch; if do not hear from the Mothership within
+# <insert number> minutes, we power throw all local experiment into
+# panic (with power off) mode.
+#
+sub usage()
+{
+    print "Usage: powder_deadman [-d] [-s] [-n]\n";
+    exit(1);
+}
+my $optlist   = "dns";
+my $debug     = 0;
+my $impotent  = 0;
+my $oneshot   = 0;
+my $counter   = 0;
+my $lastping;
+
+#
+# Configure variables
+#
+my $TB		     = "@prefix@";
+my $TBOPS            = "@TBOPSEMAIL@";
+my $MAINSITE         = @TBMAINSITE@;
+my $POWDER_DEADMAN   = @POWDER_DEADMAN@;
+my $LOGFILE          = "$TB/log/powder_deadman.log";
+my $WAP              = "$TB/sbin/wap";
+my $SUDO	     = "/usr/local/bin/sudo";
+my $POWER            = "$TB/bin/power";
+my $SLEEP_INTERVAL   = 10;
+my $NOALIVE_THRESHOLD= 30;
+my $ISALIVE_THRESHOLD= 60;
+
+# un-taint path
+$ENV{'PATH'} = '/bin:/usr/bin:/usr/local/bin:/usr/site/bin';
+delete @ENV{'IFS', 'CDPATH', 'ENV', 'BASH_ENV'};
+
+# Protos
+sub fatal($);
+sub PowerAll($);
+sub PowerControl($@);
+sub NotifyTBOPS($$);
+	  
+#
+# Turn off line buffering on output
+#
+$| = 1; 
+
+if ($UID != 0) {
+    fatal("Must be root to run this script\n");
+}
+# Silently exit if not enabled, specific to powder aggregates.
+if (!$POWDER_DEADMAN) {
+    exit(0);
+}
+
+#
+# 
+#
+my %options = ();
+if (! getopts($optlist, \%options)) {
+    usage();
+}
+if (defined($options{"d"})) {
+    $debug = 1;
+}
+if (defined($options{"s"})) {
+    $oneshot = 1;
+}
+if (defined($options{"n"})) {
+    $impotent = 1;
+}
+
+# Load the Testbed support stuff.
+use lib "@prefix@/lib";
+use emdb;
+use EmulabConstants;
+use libtestbed;
+use emutil;
+use libEmulab;
+use Node;
+
+# In EmulabConstants
+my $PROTOUSER = PROTOUSER();
+
+if (! ($oneshot || $impotent)) {
+    if (CheckDaemonRunning("powder_deadman")) {
+	fatal("Not starting another powder_deadman daemon!");
+    }
+    # Go to ground.
+    if (! $debug) {
+	if (TBBackGround($LOGFILE)) {
+	    exit(0);
+	}
+    }
+    if (MarkDaemonRunning("powder_deadman")) {
+	fatal("Could not mark daemon as running!");
+    }
+}
+
+#
+# When starting up, set this so know we are getting fresh keepalives
+# from the Mothership.
+#
+emutil::UpdateVersionInfo('powder_isalive', time());
+emutil::UpdateVersionInfo('powder_deadman', undef);
+
+#
+# Local enable. We just want to print something to the log if disabled.
+#
+my $enable;
+if (GetSiteVar("powder/deadman_enable", \$enable) && $enable == 0) {
+    print "Currently disabled via sitevar powder/deadman_enable\n";
+}
+
+while (1) {
+    #
+    # We are not updated while NoLogins() is true.
+    #
+    if (NoLogins()) {
+	goto again;
+    }
+    #
+    # Local enable. 
+    #
+    my $enable;
+    if (GetSiteVar("powder/deadman_enable", \$enable) && $enable == 0) {
+	goto again;
+    }
+    print "Running at ".
+	POSIX::strftime("20%y-%m-%d %H:%M:%S", localtime()) . "\n";
+
+    my $thisping = emutil::VersionInfo('powder_isalive');
+    goto again
+	if (!defined($thisping));
+
+    my $deadman = emutil::VersionInfo('powder_deadman');
+
+    if ($debug) {
+	print "keepalive: $thisping\n";
+	if ($deadman) {
+	    print "deadman: $deadman, counter: $counter, lastping:" .
+		($lastping ? $lastping : 0) . "\n";
+	}
+    }
+
+    #
+    # If we are already in a deadman state, we are waiting on getting
+    # keepalives from the MotherShip. We want to see isalive change a
+    # a few times in the last while before we power things on.
+    #
+    if ($deadman) {
+	if ($lastping == $thisping) {
+	    # Nothing changing. 
+	}
+	elsif (time() - $lastping > $ISALIVE_THRESHOLD) {
+	    # Nothing for a while, lets reset the counter, we want to
+	    # get three good keepalives within a smallish window.
+	    $lastping = $thisping;
+	    $counter  = 0;
+	}
+	elsif ($counter < 3) {
+	    $counter++;
+	    $lastping = $thisping;
+	}
+	else {
+	    #
+	    # Mother is alive. 
+	    #
+	    print "Mothership is alive at ".
+		POSIX::strftime("20%y-%m-%d %H:%M:%S",
+				localtime($lastping)) . "\n";
+	    PowerAll("on");
+	    emutil::UpdateVersionInfo('powder_deadman', undef);
+	    $lastping = undef;
+	    $counter  = 0;
+	}
+    }
+    elsif (time() - $thisping > $NOALIVE_THRESHOLD) {
+	print "No contact from the Mothership for $NOALIVE_THRESHOLD seconds\n";
+	emutil::UpdateVersionInfo('powder_deadman', time());
+	$lastping = $thisping;
+	PowerAll("off");
+    }
+    exit(0)
+	if ($oneshot);
+
+    emutil::FlushCaches();
+  again:
+    sleep($SLEEP_INTERVAL);
+}
+exit(0);
+
+#
+# Put all active experiments into panic (with power off) mode.
+#
+sub PowerAll($)
+{
+    my ($onoff) = @_;
+    my @nodes   = ();
+
+    my $query_result =
+	DBQueryFatal("select node_id from nodes where role='testnode'");
+
+    while (my ($node_id) = $query_result->fetchrow_array()) {
+	my $node = Node->Lookup($node_id);
+	if (!defined($node)) {
+	    print STDERR "Could not lookup node $node_id\n";
+	    next;
+	}
+	if (!$node->HasOutlet()) {
+	    print STDERR "$node_id does not have an outlet, skipping.\n";
+	    next;
+	}
+	# When powering on, only reserved nodes/radios
+	next 
+	    if ($onoff eq "on" && 
+		(!$node->IsReserved() ||
+		 ($node->pid() eq NODEDEAD_PID() &&
+		  $node->eid() eq NODEDEAD_EID())));
+
+	push(@nodes, $node);
+    }
+    return
+	if (!@nodes);
+    
+    PowerControl($onoff, @nodes);
+}
+
+sub PowerControl($@)
+{
+    my ($onoff, @nodes) = @_;
+
+    foreach my $node (@nodes) {
+	my $node_id = $node->node_id();
+
+	$node->Refresh();
+    
+	if ($onoff eq "on") {
+	    if ($node->eventstate() eq TBDB_NODESTATE_POWEROFF()) {
+		if ($impotent) {
+		    print "Would power on $node_id\n";
+		}
+		else {
+		    print "Powering on $node_id\n";
+		    system("$SUDO -u $PROTOUSER $WAP $POWER on $node_id");
+		    #
+		    # We want to notify if this fails ...
+		    #
+		}
+	    }
+	}
+	else {
+	    if ($node->eventstate() ne TBDB_NODESTATE_POWEROFF()) {
+		if ($impotent) {
+		    print "Would power off $node_id\n";
+		}
+		else {
+		    print "Powering off $node_id\n";
+		    system("$SUDO -u $PROTOUSER $WAP $POWER off $node_id");
+		    #
+		    # We want to notify if this fails.
+		    #
+		}
+	    }
+	}
+    }
+    return 0;
+}
+
+sub fatal($)
+{
+    my ($msg) = @_;
+
+    if (! ($oneshot || $debug || $impotent)) {
+	#
+	# Send a message to the testbed list. 
+	#
+	SENDMAIL($TBOPS,
+		 "powder_deadman died",
+		 $msg,
+		 $TBOPS);
+    }
+    MarkDaemonStopped("powder_deadman")
+	if (! ($oneshot || $impotent));
+
+    die("*** $0:\n".
+	"    $msg\n");
+}
+
+#
+# Notify TBOPS
+#
+sub NotifyTBOPS($$)
+{
+    my ($subject, $message) = @_;
+
+    if ($impotent) {
+	print "$subject\n";
+	print "$message\n";
+	return;
+    }
+    SENDMAIL($TBOPS, $subject, $message, $TBOPS);
+}
+
+#
+# We notify a user if the experimental node is reserved. 
+#
+sub NotifyUser($$$)
+{
+    my ($node, $subject, $message) = @_;
+    my $experiment = $node->Reservation();
+    my $node_id    = $node->node_id();
+    my $creator    = $experiment->GetCreator();
+    my $user_email = $creator->email();
+    my $user_name  = $creator->name();
+
+    if ($impotent) {
+	print "$subject\n";
+	print "$message\n";
+	return;
+    }
+    SENDMAIL("$user_name <$user_email>", $subject, $message, $TBOPS);
+}
+
diff --git a/powder/powder_keepalive.in b/powder/powder_keepalive.in
new file mode 100644
index 0000000000..86dcd4b6fe
--- /dev/null
+++ b/powder/powder_keepalive.in
@@ -0,0 +1,180 @@
+#!/usr/bin/perl -w
+#
+# Copyright (c) 2008-2020 University of Utah and the Flux Group.
+# 
+# {{{GENIPUBLIC-LICENSE
+# 
+# GENI Public License
+# 
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and/or hardware specification (the "Work") to
+# deal in the Work without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense,
+# and/or sell copies of the Work, and to permit persons to whom the Work
+# is furnished to do so, subject to the following conditions:
+# 
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Work.
+# 
+# THE WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE WORK OR THE USE OR OTHER DEALINGS
+# IN THE WORK.
+# 
+# }}}
+#
+use strict;
+use English;
+use Getopt::Std;
+use Data::Dumper;
+use Date::Parse;
+use POSIX qw(strftime ceil);
+
+#
+# Ping each of the remote endpoints to let them know boss is still
+# here and operational. powder_deadman at the endpoints is watching
+# for the keep alive signal, and will shutdown the local radios if
+# it does not hear from the Mothership for some length of time, to
+# be determined.
+#
+sub usage()
+{
+    print "Usage: powder_keepalive [-d] [-s] [-n]\n";
+    exit(1);
+}
+my $optlist   = "dns";
+my $debug     = 0;
+my $impotent  = 0;
+my $oneshot   = 0;
+my $deadman   = 0;
+
+#
+# Configure variables
+#
+my $TB		     = "@prefix@";
+my $TBOPS            = "@TBOPSEMAIL@";
+my $MAINSITE         = @TBMAINSITE@;
+my $LOGFILE          = "$TB/log/powder_keepalive.log";
+my $SLEEP_INTERVAL   = 300;
+
+# un-taint path
+$ENV{'PATH'} = '/bin:/usr/bin:/usr/local/bin:/usr/site/bin';
+delete @ENV{'IFS', 'CDPATH', 'ENV', 'BASH_ENV'};
+
+# Protos
+sub fatal($);
+	  
+#
+# Turn off line buffering on output
+#
+$| = 1; 
+
+if ($UID != 0) {
+    fatal("Must be root to run this script\n");
+}
+if (!$MAINSITE) {
+    exit(0);
+}
+
+#
+# 
+#
+my %options = ();
+if (! getopts($optlist, \%options)) {
+    usage();
+}
+if (defined($options{"d"})) {
+    $debug = 1;
+}
+if (defined($options{"s"})) {
+    $oneshot = 1;
+}
+if (defined($options{"n"})) {
+    $impotent = 1;
+}
+
+# Load the Testbed support stuff.
+use lib "@prefix@/lib";
+use emdb;
+use libtestbed;
+use emutil;
+use libEmulab;
+use APT_Aggregate;
+
+if (! ($oneshot || $impotent)) {
+    if (CheckDaemonRunning("powder_keepalive")) {
+	fatal("Not starting another powder_keepalive daemon!");
+    }
+    # Go to ground.
+    if (! $debug) {
+	if (TBBackGround($LOGFILE)) {
+	    exit(0);
+	}
+    }
+    if (MarkDaemonRunning("powder_keepalive")) {
+	fatal("Could not mark daemon as running!");
+    }
+}
+
+while (1) {
+    #
+    # We always run, we do not look at NoLogins().
+    #
+    print "Running at ".
+	POSIX::strftime("20%y-%m-%d %H:%M:%S", localtime()) . "\n";
+
+    my @aggregates = APT_Aggregate->LookupAll();
+    if (!@aggregates) {
+	print "No Aggregates!\n";
+	goto again;
+    }
+    foreach my $aggregate (@aggregates) {
+	next
+	    if (!($aggregate->isFE() || $aggregate->ismobile()));
+	
+	# Skip if the monitor marked it as down.
+	next
+	    if (!$aggregate->IsUp());
+	
+	my $nickname = $aggregate->nickname();
+	if ($debug) {
+	    print "Pinging $nickname\n";
+	}
+	my $error;
+	# Use the fastpath RPC
+	if ($aggregate->CheckStatus(\$error, 1)) {
+	    print STDERR "$nickname: $error\n";
+	}
+    }
+    exit(0)
+	if ($oneshot);
+
+    emutil::FlushCaches();
+  again:
+    sleep($SLEEP_INTERVAL);
+}
+exit(0);
+
+sub fatal($)
+{
+    my ($msg) = @_;
+
+    if (! ($oneshot || $debug || $impotent)) {
+	#
+	# Send a message to the testbed list. 
+	#
+	SENDMAIL($TBOPS,
+		 "powder_keepalive died",
+		 $msg,
+		 $TBOPS);
+    }
+    MarkDaemonStopped("powder_keepalive")
+	if (! ($oneshot || $impotent));
+
+    die("*** $0:\n".
+	"    $msg\n");
+}
diff --git a/apt/powder_shutdown.in b/powder/powder_shutdown.in
similarity index 98%
rename from apt/powder_shutdown.in
rename to powder/powder_shutdown.in
index 120e0eba3e..0b6f4f91b8 100644
--- a/apt/powder_shutdown.in
+++ b/powder/powder_shutdown.in
@@ -1,6 +1,6 @@
 #!/usr/bin/perl -w
 #
-# Copyright (c) 2000-2019 University of Utah and the Flux Group.
+# Copyright (c) 2000-2020 University of Utah and the Flux Group.
 # 
 # {{{EMULAB-LICENSE
 # 
@@ -159,7 +159,7 @@ while (my ($uuid) = $query_result->fetchrow_array()) {
 	my $aptagg = $agg->GetAptAggregate();
     
 	next
-	    if ($agg->status() eq "deferred");
+	    if ($agg->deferred());
 
 	#
 	# Skip anything that is not ready. Need to handle imaging.
diff --git a/apt/powderstats.in b/powder/powderstats.in
similarity index 100%
rename from apt/powderstats.in
rename to powder/powderstats.in
diff --git a/www/aptui/frequency-graph.ajax b/www/aptui/frequency-graph.ajax
index 3e9cbc15d0..3844797833 100644
--- a/www/aptui/frequency-graph.ajax
+++ b/www/aptui/frequency-graph.ajax
@@ -70,7 +70,57 @@ function Do_GetFrequencyData()
         SPITAJAX_ERROR(-1, "Illegal interface: $iface");
         return 1;
     }
-    $url = $aggregate->weburl() . "/rfmonitor/${node_id}:${iface}.csv";
+    $logid = "";
+    if (isset($ajax_args["logid"]) && $ajax_args["logid"] != "") {
+        if (!TBvalid_userdata($ajax_args["logid"])) {
+            SPITAJAX_ERROR(-1, "Illegal logid");
+            return 1;
+        }
+        $logid = "-" . $ajax_args["logid"];
+    }
+    $archived = 0;
+    if (isset($ajax_args["archived"]) && $ajax_args["archived"] == "1") {
+        $archived = 1;
+    }
+    $url = $aggregate->weburl() . "/rfmonitor/" .
+        ($archived ? "/archive/" : "") .
+        "${node_id}:${iface}${logid}.csv.gz";
+    $url = preg_replace("/^https:/i","http:", $url);
+
+    $socket = fopen($url, "r");
+    if (!$socket) {
+        SPITAJAX_ERROR(-1, "Could not open URL");
+        return;
+    }
+    fpassthru($socket);
+    fclose($socket);
+    return;
+}
+
+#
+# Get directory We have both a CORS and unknown CA problem.
+#
+function Do_GetListing()
+{
+    global $instance, $creator, $this_user;
+    global $ajax_args;
+    $opt = "";
+
+    if (!isset($ajax_args["cluster"])) {
+	SPITAJAX_ERROR(1, "Missing urn argument");
+	return 1;
+    }
+    $cluster = $ajax_args["cluster"];
+    if (!TBvalid_node_id($cluster)) {
+        SPITAJAX_ERROR(-1, "Illegal characters in cluster");
+        return 1;
+    }
+    $aggregate = Aggregate::LookupByNickname($cluster);
+    if (!$aggregate) {
+        SPITAJAX_ERROR(-1, "No such cluster: $cluster");
+        return 1;
+    }
+    $url = $aggregate->weburl() . "/rfmonitor/listing.php";
     $url = preg_replace("/^https:/i","http:", $url);
 
     $socket = fopen($url, "r");
@@ -83,6 +133,7 @@ function Do_GetFrequencyData()
     return;
 }
 
+
 # Local Variables:
 # mode:php
 # End:
diff --git a/www/aptui/frequency-graph.php b/www/aptui/frequency-graph.php
index 573d208c8d..56ff9a7b08 100644
--- a/www/aptui/frequency-graph.php
+++ b/www/aptui/frequency-graph.php
@@ -41,6 +41,8 @@ $isadmin   = (ISADMIN() ? 1 : 0);
 $reqargs = RequiredPageArguments("cluster",   PAGEARG_STRING,
                                  "node_id",   PAGEARG_STRING,
                                  "iface",     PAGEARG_STRING);
+$optargs = OptionalPageArguments("logid",     PAGEARG_STRING,
+                                 "archived",  PAGEARG_BOOLEAN);
 
 #
 # The monitor looks at only one iface, rf0. That may change later.
@@ -67,6 +69,16 @@ if ($iface != "rf0") {
     SPITUSERERROR("Illegal interface: $iface");
     exit();
 }
+if (isset($logid)) {
+    if (!TBvalid_userdata($logid)) {
+        SPITUSERERROR("Illegal logid: $logid");
+        exit();
+    }
+}
+if (!isset($archived)) {
+    $archived = 0;
+}
+$url = $aggregate->weburl() . "/rfmonitor";
 SPITHEADER(1);
 
 echo "<link rel='stylesheet'
@@ -84,6 +96,11 @@ echo "<script type='text/javascript'>\n";
 echo "    window.CLUSTER     = '$cluster';\n";
 echo "    window.NODEID      = '$node_id';\n";
 echo "    window.IFACE       = '$iface';\n";
+echo "    window.URL         = '$url';\n";
+echo "    window.ARCHIVED    = $archived;\n";
+if (isset($logid)) {
+    echo "    window.LOGID       = '$logid';\n";
+}
 echo "</script>\n";
 
 REQUIRE_UNDERSCORE();
@@ -93,6 +110,7 @@ REQUIRE_APTFORMS();
 AddLibrary("js/freqgraphs.js");
 AddTemplateList(array("frequency-graph"));
 SPITREQUIRE("js/frequency-graph.js",
+            "<script src='js/lib/pako/pako.min.js'></script>\n".
             "<script src='js/lib/d3.v5.js'></script>\n");
 SPITFOOTER();
 ?>
diff --git a/www/aptui/js/freqgraphs.js b/www/aptui/js/freqgraphs.js
index d4c1b3d989..f7b61d9beb 100644
--- a/www/aptui/js/freqgraphs.js
+++ b/www/aptui/js/freqgraphs.js
@@ -11,7 +11,7 @@ window.ShowFrequencyGraph = (function ()
     var d3 = d3v5;
 
     function CreateGraph(args, data) {
-	console.log(data);
+	//console.log(data);
 	
 	var selector     = args.selector + " .frequency-graph-subgraph";
 	var parentWidth  = $(selector).width();
@@ -282,7 +282,7 @@ window.ShowFrequencyGraph = (function ()
 	    });
 	    bin.avg = sum / _.size(bin.samples);
 	});
-	console.info("bins", result);
+	//console.info("bins", result);
 	return result;
     }
 
@@ -317,6 +317,11 @@ window.ShowFrequencyGraph = (function ()
 	var ParentTop    = $(selector).position().top;
 	var ParentLeft   = $(selector).position().left;
 
+	// Clear old graph
+	$(selector).html("");
+	// And the sub graph.
+	$(args.selector + " .frequency-graph-subgraph").html("");
+	
 	var margin  = {top: 20, right: 20, bottom: 130, left: 55};
 	var width   = parentWidth - margin.left - margin.right;
 	var height  = parentHeight - margin.top - margin.bottom;
@@ -591,9 +596,12 @@ window.ShowFrequencyGraph = (function ()
 	return d;
     }
 
-    function GetFrequencyData(url, route, method, args, callback)
+    function GetFrequencyData(datatype, route, method, args, callback)
     {
 	var url = 'server-ajax.php';
+	if (!datatype) {
+	    datatype = "text";
+	}
 
 	var networkError = {
 	    "code"  : -1,
@@ -626,7 +634,7 @@ window.ShowFrequencyGraph = (function ()
             type: "GET",
  
             // the type of data we expect back
-            dataType : "text",
+            dataType : datatype,
 	});
 	var defer = $.Deferred();
     
@@ -640,24 +648,152 @@ window.ShowFrequencyGraph = (function ()
 	return defer;
     }
 
-    return function(args) {
+    // Easier to get a binary (gzip) file this way, since jquery does
+    // not directly support doing this. 
+    function GetBlob(url, success, failure) {
+	var oReq = new XMLHttpRequest();
+	oReq.open("GET", url, true);
+	oReq.responseType = "arraybuffer";
+
+	oReq.onload = function(oEvent) {
+	    success(oReq.response)
+	};
+	oReq.onerror = function(oEvent) {
+	    failure();
+	};
+	oReq.send();
+    }
+
+    /*
+     * Saving this. It is faster to go directly to the aggregate, but
+     * they all have to have valid certificates. Note that we cannot load
+     * it via http from inside an https page, the browser will block it.
+     */
+    function SaveMe(args) {
 	console.info("ShowFrequencyGraph", args);
+	GetBlob(window.URL + ".gz",
+		function (arrayBuffer) {
+		    console.info("gz version");
+		    var output = pako.inflate(arrayBuffer, { 'to': 'string' });
+		    
+		    var data = d3.csvParse(output, type);
+		    CreateBinGraph(args, data);
+		},
+		function () {
+		    $.get(window.URL)
+			.done(function (data) {
+			    console.info("text version");
+			    data = d3.csvParse(data, type);
+			    CreateBinGraph(args, data);
+			})
+			.fail(function() {
+			    alert("Could not get data file: " + window.URL);
+			});
+		});
+    }
 
-	GetFrequencyData(null, "frequency-graph", "GetFrequencyData",
-			 {"cluster"    : args.cluster,
-			  "node_id"    : args.node_id,
-			  "iface"      : args.iface},
-			 function (value) {
-			     // XXX This will always be a string. Need to
-			     // figure out how to deal with errors.
-			     if (typeof(value) == "object") {
-				 console.info("Could not get CVS data" +
-					      "data: " + value.value);
-				 return;
-			     }
-			     var data = d3.csvParse(value, type);
-			     CreateBinGraph(args, data);
-			 });
+    function BuildMenu(args)
+    {
+	var callback = function (value) {
+	    // XXX This will always be a string. Need to
+	    // figure out how to deal with errors.
+	    if (typeof(value) == "object") {
+		console.info("Could not get listing data: " + value.value);
+		return;
+	    }
+	    var listing = JSON.parse(_.unescape(value));
+	    console.info(listing);
+	    // nuc2:rf0-1588699912.csv.gz
+	    var re = /([^:]+):([^\-]+)\-(\d+)\.csv\.gz/;
+	    _.each(listing, function(info, index) {
+		var name  = info.name;
+		var match = name.match(re);
+		//console.info(name, match);
+		if (!match) {
+		    return;
+		}
+		info["node_id"]  = match[1];
+		info["iface"]    = match[2];
+		info["logid"]    = match[3];
+		info["cluster"]  = args.cluster;
+		info["selector"] = args.selector;
+
+		var url = "frequency-graph.php" +
+		    "?cluster="  + args.cluster +
+		    "&node_id="  + info.node_id +
+		    "&iface="    + info.iface +
+		    "&logid="    + info.logid;
+		if (info.archived) {
+		    url + "&archived=" + info.archived;
+		}
+		var html =
+		    "<li>" +
+		    " <a href='" + url + "' index='<%- index %>'>" +
+		    match[1] + ":" + match[2] + " - " +
+		    moment(match[3], "X").format("L LT") + "</a></li>";
+		var item = $(html);
+		// If the incoming args match this listing, start it active.
+		if (args.logid &&
+		    info.node_id == args.node_id &&
+		    info.iface   == args.iface &&
+		    info.logid   == args.logid) {
+		    $(item).addClass("active");
+		}
+		$(item).find("a").click(function (event) {
+		    event.preventDefault();
+		    $('#moregraphs-dropdown').find("li").removeClass("active");
+		    $(item).addClass("active");
+		    $(".frequency-graph-date")
+			.html(moment(match[3], "X").format("L LT"))
+			.removeClass("hidden");
+		    UpdateGraph(info);
+		});
+		$('#moregraphs-dropdown').append(item);
+	    });
+	};
+	GetFrequencyData("html", "frequency-graph", "GetListing",
+			 {"cluster"    : args.cluster}, callback);
+    }
+
+    function UpdateGraph(args)
+    {
+	/*
+	 * Gack, we cannot get binary data with the jquery ajax call.
+	 * Well there is lots of noise from google about how to mess
+	 * with it, but instead I am just going to create a GET url
+	 * that talks ajax server routine.
+	 */
+	var url = "server-ajax.php" +
+	    "?ajax_route=frequency-graph" +
+	    "&ajax_method=GetFrequencyData" +
+	    "&ajax_args[cluster]=" + args.cluster +
+	    "&ajax_args[node_id]=" + args.node_id +
+	    "&ajax_args[iface]="   + args.iface;
+	// Optional specific log.
+	if (args.logid) {
+	    url = url + "&ajax_args[logid]=" + args.logid;
+	}
+	if (args.archived) {
+	    url = url + "&ajax_args[archived]=1";
+	}
+	console.info(url);
+
+	GetBlob(url,
+		function (arrayBuffer) {
+		    console.info("gz version");
+		    var output = pako.inflate(arrayBuffer, { 'to': 'string' });
+		    
+		    var data = d3.csvParse(output, type);
+		    CreateBinGraph(args, data);
+		},
+		function () {
+		    alert("Could not get data file: " + url);
+		});
+    }
+
+    return function(args) {
+	BuildMenu(args);
+	UpdateGraph(args);
     };
 }
 )();
diff --git a/www/aptui/js/frequency-graph.js b/www/aptui/js/frequency-graph.js
index 45e17fdc0a..37a3349bd3 100644
--- a/www/aptui/js/frequency-graph.js
+++ b/www/aptui/js/frequency-graph.js
@@ -14,6 +14,9 @@ $(function ()
 	    "cluster"   : window.CLUSTER,
 	    "node_id"   : window.NODEID,
 	    "iface"     : window.IFACE,
+	    "url"       : window.URL,
+	    "logid"     : window.LOGID,
+	    "archived"  : window.ARCHIVED,
 	};
 	$('#main-body').html(mainTemplate(options));
 	// Its a little too big by itself
diff --git a/www/aptui/js/status.js b/www/aptui/js/status.js
index 30f5c7a648..e8a0a12441 100644
--- a/www/aptui/js/status.js
+++ b/www/aptui/js/status.js
@@ -4235,6 +4235,7 @@ $(function ()
 		"cluster"  : amlist[info.aggregate_urn].nickname,
 		"node_id"  : info.node_id,
 		"iface"    : "rf0",
+		"logid"    : null,
 	    }
 	    var html = monitorTemplate(options);
 
diff --git a/www/aptui/server-ajax.php b/www/aptui/server-ajax.php
index 2b87a1d009..d6b3ef43bd 100644
--- a/www/aptui/server-ajax.php
+++ b/www/aptui/server-ajax.php
@@ -641,6 +641,8 @@ $routing = array("geni-login" =>
 			      "guest"   => false,
 			      "methods" => array("GetFrequencyData" =>
 						     "Do_GetFrequencyData",
+                                                 "GetListing" =>
+						     "Do_GetListing",
                               )
                         ),
 		 "rfrange" =>
diff --git a/www/aptui/status.php b/www/aptui/status.php
index 7429b91633..d6ad3355ed 100644
--- a/www/aptui/status.php
+++ b/www/aptui/status.php
@@ -307,6 +307,7 @@ AddLibrary("js/bindings.js");
 AddLibrary("js/paramsets.js");
 if ($ISPOWDER) {
     AddLibrary("js/freqgraphs.js");
+    AddLibrary("js/lib/pako/pako.min.js");
 }
 SPITREQUIRE("js/status.js");
 
diff --git a/www/aptui/template/frequency-graph.html b/www/aptui/template/frequency-graph.html
index 9c08e742e8..d4d121e4dd 100644
--- a/www/aptui/template/frequency-graph.html
+++ b/www/aptui/template/frequency-graph.html
@@ -1,8 +1,33 @@
+<style>
+  #moregraphs-dropdown {
+      max-height: 400px;
+      overflow-y: scroll;
+  }
+</style>
 <div class="frequency-graph-div">
+  <span class='dropdown pull-right'>
+    <button id='action-menu-button' type='button'
+	    class='btn btn-default btn-xs dropdown-toggle'
+	    data-toggle='dropdown'>
+      More Graphs <span class="caret"></span>
+    </button>
+    <ul class='dropdown-menu text-left' id="moregraphs-dropdown" role='menu'>
+    </ul>
+  </span>
   <center>
     <h4>
       Radio Monitoring Graph for
-      <%- cluster %> <%- node_id %>:<%- iface %>
+      <%- cluster %> 
+      <span class="frequency-graph-nodeid">
+	<%- node_id %></span>:<span class="frequency-graph-iface"><%- iface %>
+	</span>
+	<% if (logid) { %>
+	  <span class="frequency-graph-date" style="margin-left: 15px;">
+	    <%- moment(logid, "X").format("L LT") %></span>
+	<% } else { %>
+	  <span class="frequency-graph-date hidden"
+		style="margin-left: 15px;"></span>
+	<% } %>
     </h4>
   </center>
   <div class="panel panel-default" style="margin-bottom: 0px;">
-- 
GitLab