[freeside-commits] branch FREESIDE_3_BRANCH updated. 600e1ad4ae42a8800527660038b74eaedc13a958

Jonathan Prykop jonathan at 420.am
Mon Apr 13 17:20:37 PDT 2015


The branch, FREESIDE_3_BRANCH has been updated
       via  600e1ad4ae42a8800527660038b74eaedc13a958 (commit)
       via  03c9f5e33216c503fb52dc02826426b17f01f629 (commit)
       via  17a25e030a9ba4123eecb20413465aabe41d37cf (commit)
       via  af8b1f302097a43229b59cdc2ab9782fbff28dc9 (commit)
       via  ecf01f1adf08cfbbb489d18c39a9626079112526 (commit)
       via  e05e4f1f0d6a704db4d3c8b3d9a851216569580c (commit)
      from  f279524a0b3266ae4fb342ab8ee26d8918c86c75 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 600e1ad4ae42a8800527660038b74eaedc13a958
Author: Jonathan Prykop <jonathan at freeside.biz>
Date:   Mon Apr 13 19:18:41 2015 -0500

    RT#18834: Cacti integration [added thaw/decode]

diff --git a/FS/FS/part_export/cacti.pm b/FS/FS/part_export/cacti.pm
index abeb5e4..740be25 100644
--- a/FS/FS/part_export/cacti.pm
+++ b/FS/FS/part_export/cacti.pm
@@ -27,7 +27,9 @@ use FS::cacti_page;
 use File::Rsync;
 use File::Slurp qw( slurp );
 use File::stat;
-use MIME::Base64 qw( encode_base64 );
+use MIME::Base64 qw( decode_base64 encode_base64 );
+use Storable qw(thaw);
+
 
 use vars qw( %info );
 
@@ -275,7 +277,8 @@ and stores the generated pages in the database.
 =cut
 
 sub process_graphs {
-  my ($job,$param) = @_;
+  my $job = shift;
+  my $param = thaw(decode_base64(shift));
 
   $job->update_statustext(10);
   my $cachedir = $FS::UID::cache_dir . '/cacti-graphs/';

commit 03c9f5e33216c503fb52dc02826426b17f01f629
Author: Jonathan Prykop <jonathan at freeside.biz>
Date:   Mon Apr 13 17:59:12 2015 -0500

    RT#18834 Cacti integration [schema history fix and cleanup refactor]

diff --git a/FS/FS/Cron/cacti_cleanup.pm b/FS/FS/Cron/cacti_cleanup.pm
deleted file mode 100644
index f862627..0000000
--- a/FS/FS/Cron/cacti_cleanup.pm
+++ /dev/null
@@ -1,19 +0,0 @@
-package FS::Cron::cacti_cleanup;
-use base 'Exporter';
-use vars '@EXPORT_OK';
-
-use FS::Record qw( qsearch );
-use Data::Dumper;
-
- at EXPORT_OK = qw( cacti_cleanup );
-
-sub cacti_cleanup {
-  foreach my $export (qsearch({
-    'table' => 'part_export',
-    'hashref' => { 'exporttype' => 'cacti' }
-  })) {
-    $export->cleanup;
-  }
-}
-
-1;
diff --git a/FS/FS/Cron/cleanup.pm b/FS/FS/Cron/cleanup.pm
index 4c5cff2..6ec4013 100644
--- a/FS/FS/Cron/cleanup.pm
+++ b/FS/FS/Cron/cleanup.pm
@@ -2,8 +2,9 @@ package FS::Cron::cleanup;
 use base 'Exporter';
 use vars '@EXPORT_OK';
 use FS::queue;
+use FS::Record qw( qsearch );
 
- at EXPORT_OK = qw( cleanup );
+ at EXPORT_OK = qw( cleanup cleanup_before_backup );
 
 # start janitor jobs
 sub cleanup {
@@ -15,4 +16,19 @@ sub cleanup {
   $job->insert('_JOB');
 }
 
+sub cleanup_before_backup {
+  #remove outdated cacti_page entries
+  foreach my $export (qsearch({
+    'table' => 'part_export',
+    'hashref' => { 'exporttype' => 'cacti' }
+  })) {
+    $export->cleanup;
+  }
+  #remove cache files
+  my $deldir = "$FS::UID::cache_dir/cache.$FS::UID::datasrc/";
+  unlink <${deldir}.invoice*>;
+  unlink <${deldir}.letter*>;
+  unlink <${deldir}.CGItemp*>;
+}
+
 1;
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 2b025c7..794f566 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -197,7 +197,7 @@ sub dbdef_dist {
            && ! /^legacy_cust_history$/
            && ( ! /^queue(_arg|_depend|_stat)?$/ || ! $opt->{'queue-no_history'} )
            && ! $tables_hashref_torrus->{$_}
-           && ! /^cacti_graph$/
+           && ! /^cacti_page$/
          }
       $dbdef->tables
   ) {
diff --git a/FS/bin/freeside-daily b/FS/bin/freeside-daily
index a793813..1162e79 100755
--- a/FS/bin/freeside-daily
+++ b/FS/bin/freeside-daily
@@ -74,15 +74,9 @@ export_batch_submit(%opt);
 use FS::Cron::agent_email qw(agent_email);
 agent_email(%opt);
 
-#does nothing unless there are cacti imports
-#should run before backup, no need to backup cacti imports
-use FS::Cron::cacti_cleanup qw(cacti_cleanup);
-cacti_cleanup();
-
-my $deldir = "$FS::UID::cache_dir/cache.$FS::UID::datasrc/";
-unlink <${deldir}.invoice*>;
-unlink <${deldir}.letter*>;
-unlink <${deldir}.CGItemp*>;
+#clears out cacti imports & deletes select database cache files
+use FS::Cron::cleanup qw( cleanup cleanup_before_backup );
+cleanup_before_backup();
 
 #backup should be last
 #you can skip this just by not having the config
@@ -90,7 +84,6 @@ use FS::Cron::backup qw(backup);
 backup();
 
 #except we'd rather not start cleanup jobs until the backup is done
-use FS::Cron::cleanup qw(cleanup);
 cleanup();
 
 $log->info('finish');

commit 17a25e030a9ba4123eecb20413465aabe41d37cf
Author: Jonathan Prykop <jonathan at freeside.biz>
Date:   Wed Apr 8 22:16:12 2015 -0500

    RT#18834: Cacti integration [agent access]

diff --git a/httemplate/misc/cacti_graphs.html b/httemplate/misc/cacti_graphs.html
index 90a4350..9a90b89 100644
--- a/httemplate/misc/cacti_graphs.html
+++ b/httemplate/misc/cacti_graphs.html
@@ -43,6 +43,21 @@ die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('View customer services');
 
 my $svcnum    = $cgi->param('svcnum') or die 'Illegal svcnum';
+
+# false laziness with view/elements/svc_Common.html
+# only doing this to check agent access, don't actually use $svc_x
+my $svc_x = qsearchs({
+  'select'    => 'svc_broadband.*',
+  'table'     => 'svc_broadband',
+  'addl_from' => ' LEFT JOIN cust_svc  USING ( svcnum  ) '.
+                 ' LEFT JOIN cust_pkg  USING ( pkgnum  ) '.
+                 ' LEFT JOIN cust_main USING ( custnum ) ',
+  'hashref'   => { 'svcnum' => $svcnum },
+  'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql(
+                            'null_right' => 'View/link unlinked services'
+                          ),
+}) or die "Unknown svcnum $svcnum in svc_broadband table\n";
+
 my $load      = $cgi->param('load');
 my $graphnum  = $cgi->param('graphnum') || '';
 
diff --git a/httemplate/misc/process/cacti_graphs.cgi b/httemplate/misc/process/cacti_graphs.cgi
index f2baeb4..a4df722 100644
--- a/httemplate/misc/process/cacti_graphs.cgi
+++ b/httemplate/misc/process/cacti_graphs.cgi
@@ -4,6 +4,22 @@
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('View customer services');
 
+# false laziness with view/elements/svc_Common.html
+# only doing this to check agent access, don't actually use $svc_x
+my %param = $cgi->param('arg');
+my $svcnum = $param{'svcnum'};
+my $svc_x = qsearchs({
+  'select'    => 'svc_broadband.*',
+  'table'     => 'svc_broadband',
+  'addl_from' => ' LEFT JOIN cust_svc  USING ( svcnum  ) '.
+                 ' LEFT JOIN cust_pkg  USING ( pkgnum  ) '.
+                 ' LEFT JOIN cust_main USING ( custnum ) ',
+  'hashref'   => { 'svcnum' => $svcnum },
+  'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql(
+                            'null_right' => 'View/link unlinked services'
+                          ),
+}) or die "Unknown svcnum $svcnum in svc_broadband table\n";
+
 my $server = FS::UI::Web::JSRPC->new('FS::part_export::cacti::process_graphs', $cgi);
 </%init>
 

commit af8b1f302097a43229b59cdc2ab9782fbff28dc9
Author: Jonathan Prykop <jonathan at freeside.biz>
Date:   Mon Apr 6 22:01:05 2015 -0500

    RT#18834: Cacti integration [database storage]

diff --git a/FS/FS/Cron/cacti_cleanup.pm b/FS/FS/Cron/cacti_cleanup.pm
new file mode 100644
index 0000000..f862627
--- /dev/null
+++ b/FS/FS/Cron/cacti_cleanup.pm
@@ -0,0 +1,19 @@
+package FS::Cron::cacti_cleanup;
+use base 'Exporter';
+use vars '@EXPORT_OK';
+
+use FS::Record qw( qsearch );
+use Data::Dumper;
+
+ at EXPORT_OK = qw( cacti_cleanup );
+
+sub cacti_cleanup {
+  foreach my $export (qsearch({
+    'table' => 'part_export',
+    'hashref' => { 'exporttype' => 'cacti' }
+  })) {
+    $export->cleanup;
+  }
+}
+
+1;
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index cd74a38..2b025c7 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -197,6 +197,7 @@ sub dbdef_dist {
            && ! /^legacy_cust_history$/
            && ( ! /^queue(_arg|_depend|_stat)?$/ || ! $opt->{'queue-no_history'} )
            && ! $tables_hashref_torrus->{$_}
+           && ! /^cacti_graph$/
          }
       $dbdef->tables
   ) {
@@ -4807,9 +4808,29 @@ sub tables_hashref {
       'index'  => [ ],
     },
 
-
-
-
+    'cacti_page' => {
+      'columns' => [
+        'cacti_pagenum',  'serial',   '',     '', '', '',
+        'exportnum',      'int',      'NULL', '', '', '',
+        'svcnum',         'int',      'NULL', '', '', '', 
+        'graphnum',       'int',      'NULL', '', '', '', 
+        'imported',       @date_type,             '', '',
+        'content',        'text',     'NULL', '', '', '',
+      ],
+      'primary_key' => 'cacti_pagenum',
+      'unique'  => [ ],
+      'index'   => [ ['svcnum'], ['imported'] ],
+      'foreign_keys' => [
+                          { columns    => [ 'svcnum' ],
+                            table      => 'cust_svc',
+                            references => [ 'svcnum' ],
+                          },
+                          { columns    => [ 'exportnum' ],
+                            table      => 'part_export',
+                            references => [ 'exportnum' ],
+                          },
+                        ],
+    },
 
     # name type nullability length default local
 
diff --git a/FS/FS/cacti_page.pm b/FS/FS/cacti_page.pm
new file mode 100644
index 0000000..febcae4
--- /dev/null
+++ b/FS/FS/cacti_page.pm
@@ -0,0 +1,135 @@
+package FS::cacti_page;
+use base qw( FS::Record );
+
+use strict;
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::cacti_page - Object methods for cacti_page records
+
+=head1 SYNOPSIS
+
+  use FS::cacti_page;
+
+  $record = new FS::cacti_page \%hash;
+  $record = new FS::table_name {
+              'exportnum' => 3,           #part_export associated with this page
+              'svcnum'   => 123,          #svc_broadband associated with this page
+              'graphnum' => 45,           #blank for svcnum index
+              'imported' => 1428358699,   #date of import
+              'content'  => $htmlcontent, #html containing base64-encoded images
+  };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cacti_page object represents an html page for viewing cacti graphs.
+FS::cacti_page inherits from FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item cacti_pagenum - primary key
+
+=item exportnum - part_export exportnum for this page
+
+=item svcnum - svc_broadband svcnum for this page
+
+=item graphnum - cacti graphnum for this page (blank for overview page)
+
+=item imported - date this page was imported
+
+=item content - text/html content of page, should not include newlines
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new object.  To add the object to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'cacti_page'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+# the insert method can be inherited from FS::Record
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+# the delete method can be inherited from FS::Record
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+# the replace method can be inherited from FS::Record
+
+=item check
+
+Checks all fields to make sure this is a valid example.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+# the check method should currently be supplied - FS::Record contains some
+# data checking routines
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('cacti_pagenum', 'graphnum')
+    || $self->ut_foreign_key('exportnum','part_export','exportnum')
+    || $self->ut_foreign_key('svcnum','cust_svc','svcnum')
+    || $self->ut_number('imported')
+    || $self->ut_text('content')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+Will be described here once found.
+
+=head1 SEE ALSO
+
+L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/FS/part_export/cacti.pm b/FS/FS/part_export/cacti.pm
index 1f5f64c..abeb5e4 100644
--- a/FS/FS/part_export/cacti.pm
+++ b/FS/FS/part_export/cacti.pm
@@ -1,13 +1,31 @@
 package FS::part_export::cacti;
 
+=pod
+
+=head1 NAME
+
+FS::part_export::cacti
+
+=head1 SYNOPSIS
+
+Cacti integration for Freeside
+
+=head1 DESCRIPTION
+
+This module in particular handles FS::part_export object creation for Cacti integration;
+consult any existing L<FS::part_export> documentation for details on how that works.
+
+=cut
+
 use strict;
 
 use base qw( FS::part_export );
-use FS::Record qw( qsearchs );
+use FS::Record qw( qsearchs qsearch );
 use FS::UID qw( dbh );
+use FS::cacti_page;
 
 use File::Rsync;
-use File::Slurp qw( append_file slurp write_file );
+use File::Slurp qw( slurp );
 use File::stat;
 use MIME::Base64 qw( encode_base64 );
 
@@ -24,8 +42,8 @@ tie my %options, 'Tie::IxHash',
                            default => '' },
   'tree_id'           => { label   => 'Graph Tree ID (optional)',
                            default => '' },
-  'description'       => { label   => 'Description (can use $ip_addr and $description tokens)',
-                           default => 'Freeside $description $ip_addr' },
+  'description'       => { label   => 'Description (can use tokens $contact, $ip_addr and $description)',
+                           default => 'Freeside $contact $description $ip_addr' },
   'graphs_path'       => { label   => 'Graph Export Directory (user at host:/path/to/graphs/)',
                            default => '' },
   'import_freq'       => { label   => 'Minimum minutes between graph imports',
@@ -43,7 +61,7 @@ tie my %options, 'Tie::IxHash',
   'options'         => \%options,
   'notes'           => <<'END',
 Add service to cacti upon provisioning, for broadband services.<BR>
-See FS::part_export::cacti documentation for details.
+See <A HREF="http://www.freeside.biz/mediawiki/index.php/Freeside:4:Documentation:Cacti#Connecting_Cacti_To_Freeside">documentation</A> for details.
 END
 );
 
@@ -113,6 +131,7 @@ sub _insert_queue {
     'tree_id'     => $self->option('tree_id'),
     'description' => $self->option('description'),
 	'svc_desc'    => $svc_broadband->description,
+    'contact'     => $svc_broadband->cust_main->contact,
     'svcnum'      => $svc_broadband->svcnum,
   );
   return ($queue,$error);
@@ -143,12 +162,13 @@ sub ssh_insert {
   die "Non-numerical Host Template ID, check export configuration\n"
     unless $opt{'template_id'} =~ /^\d+$/;
   die "Non-numerical Graph Tree ID, check export configuration\n"
-    unless $opt{'tree_id'} =~ /^\d+$/;
+    unless $opt{'tree_id'} =~ /^\d*$/;
 
   # Add host to cacti
   my $desc = $opt{'description'};
   $desc =~ s/\$ip_addr/$opt{'hostname'}/g;
   $desc =~ s/\$description/$opt{'svc_desc'}/g;
+  $desc =~ s/\$contact/$opt{'contact'}/g;
   $desc =~ s/'/'\\''/g;
   my $cmd = $php
           . $opt{'script_path'} 
@@ -238,11 +258,24 @@ sub ssh_delete {
   return '';
 }
 
-# NOT A METHOD, run as an FS::queue job
-# copies graphs for a single service from Cacti export directory to FS cache
-# generates basic html pages for this service's graphs, and stores them in FS cache
+=head1 SUBROUTINES
+
+=over 4
+
+=item process_graphs JOB PARAM
+
+Intended to be run as an FS::queue job.
+
+Copies graphs for a single service from Cacti export directory to FS cache,
+generates basic html pages for this service with base64-encoded graphs embedded, 
+and stores the generated pages in the database.
+
+=back
+
+=cut
+
 sub process_graphs {
-  my ($job,$param) = @_; #
+  my ($job,$param) = @_;
 
   $job->update_statustext(10);
   my $cachedir = $FS::UID::cache_dir . '/cacti-graphs/';
@@ -259,23 +292,37 @@ sub process_graphs {
 
   $job->update_statustext(20);
 
-  # check for recent uploads, avoid doing this too often
-  my $svchtml = $cachedir.'svc_'.$svcnum.'.html';
-  if (-e $svchtml) {
-    open(my $fh, "<$svchtml");
-    my $firstline = <$fh>;
-    close($fh);
-    if ($firstline =~ /UPDATED (\d+)/) {
-      if ($1 > time - 60 * ($self->option('import_freq') || 5)) {
-        $job->update_statustext(100);
-        return '';
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  # check for existing pages
+  my $now = time;
+  my @oldpages = qsearch({
+    'table'    => 'cacti_page',
+    'hashref'  => { 'svcnum' => $svcnum, 'exportnum' => $self->exportnum },
+    'select'   => 'cacti_pagenum, exportnum, svcnum, graphnum, imported', #no need to load old content
+    'order_by' => 'ORDER BY graphnum',
+  });
+  if (@oldpages) {
+    #if pages are recent enough, do nothing and return
+    if ($oldpages[0]->imported > $self->exptime($now)) {
+      $job->update_statustext(100);
+      return '';
+    }
+    #delete old pages
+    foreach my $oldpage (@oldpages) {
+      my $error = $oldpage->delete;
+      if ($error) {
+        $dbh->rollback if $oldAutoCommit;
+        die $error;
       }
     }
   }
 
   $job->update_statustext(30);
 
-  # get list of graphs for this svc
+  # get list of graphs for this svc from cacti server
   my $cmd = $php
           . $self->option('script_path')
           . q(freeside_cacti.php --get-graphs --ip=')
@@ -290,7 +337,7 @@ sub process_graphs {
 
   $job->update_statustext(40);
 
-  # copy graphs to cache
+  # copy graphs from cacti server to cache
   # requires version 2.6.4 of rsync, released March 2005
   my $rsync = File::Rsync->new({
     'rsh'       => 'ssh',
@@ -311,12 +358,11 @@ sub process_graphs {
 
   $job->update_statustext(50);
 
-  # create html files in cache
-  my $now = time;
-  my $svchead = q(<!-- UPDATED ) . $now . qq( -->\n)
-              . '<H2 STYLE="margin-top: 0;">Service #' . $svcnum . '</H2>' . "\n"
-              . q(<P>Last updated ) . scalar(localtime($now)) . q(</P>) . "\n";
-  write_file($svchtml,$svchead);
+  # create html file contents
+  my $svchead = q(<!-- UPDATED ) . $now . qq( -->)
+              . '<H2 STYLE="margin-top: 0;">Service #' . $svcnum . '</H2>'
+              . q(<P>Last updated ) . scalar(localtime($now)) . q(</P>);
+  my $svchtml = $svchead;
   my $maxgraph = 1024 * 1024 * ($self->options('max_graph_size') || 5);
   my $nographs = 1;
   for (my $i = 0; $i <= $#graphs; $i++) {
@@ -328,31 +374,53 @@ sub process_graphs {
     ) {
       $nographs = 0;
       # add graph to main file
-      my $graphhead = q(<H3>) . $$graph[1] . q(</H3>) . "\n";
-      append_file( $svchtml, $graphhead,
-        anchor_tag( 
-          $svcnum, $$graph[0], img_tag($thumbfile)
-        )
-      );
+      my $graphhead = q(<H3>) . $$graph[1] . q(</H3>);
+      $svchtml .= $graphhead;
+      $svchtml .= anchor_tag( $svcnum, $$graph[0], img_tag($thumbfile) );
       # create graph details file
-      my $graphhtml = $cachedir . 'svc_' . $svcnum . '_graph_' . $$graph[0] . '.html';
-      write_file($graphhtml,$svchead,$graphhead);
+      my $graphhtml = $svchead . $graphhead;
       my $nodetail = 1;
       my $j = 1;
       while (-e (my $graphfile = $cachedir.'graphs/graph_'.$$graph[0].'_'.$j.'.png')) {
         if ( stat($graphfile)->size() < $maxgraph ) {
           $nodetail = 0;
-          append_file( $graphhtml, img_tag($graphfile) );
+          $graphhtml .= img_tag($graphfile);
         }
         $j++;
       }
-      append_file($graphhtml, '<P>No detail graphs to display for this graph</P>')
+      $graphhtml .= '<P>No detail graphs to display for this graph</P>'
         if $nodetail;
+      my $newobj = new FS::cacti_page {
+        'exportnum' => $self->exportnum,
+        'svcnum'    => $svcnum,
+        'graphnum'  => $$graph[0],
+        'imported'  => $now,
+        'content'   => $graphhtml,
+      };
+      $error = $newobj->insert;
+      if ($error) {
+        $dbh->rollback if $oldAutoCommit;
+        die $error;
+      }
     }
-    $job->update_statustext(50 + ($i / $#graphs) * 50);
+    $job->update_statustext(49 + int($i / $#graphs) * 50);
   }
-  append_file($svchtml,'<P>No graphs to display for this service</P>')
+  $svchtml .= '<P>No graphs to display for this service</P>'
     if $nographs;
+  my $newobj = new FS::cacti_page {
+    'exportnum' => $self->exportnum,
+    'svcnum'    => $svcnum,
+    'graphnum'  => '',
+    'imported'  => $now,
+    'content'   => $svchtml,
+  };
+  $error  = $newobj->insert;
+  if ($error) {
+    $dbh->rollback if $oldAutoCommit;
+    die $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   $job->update_statustext(100);
   return '';
@@ -361,8 +429,8 @@ sub process_graphs {
 sub img_tag {
   my $somefile = shift;
   return q(<IMG SRC="data:image/png;base64,)
-       . encode_base64(slurp($somefile,binmode=>':raw'))
-       . qq(" STYLE="margin-bottom: 1em;"><BR>\n);
+       . encode_base64(slurp($somefile,binmode=>':raw'),'')
+       . qq(" STYLE="margin-bottom: 1em;"><BR>);
 }
 
 sub anchor_tag {
@@ -389,82 +457,51 @@ sub ssh_cmd {
   return $output;
 }
 
-=pod
-
-=head1 NAME
-
-FS::part_export::cacti
-
-=head1 SYNOPSIS
-
-Cacti integration for Freeside
-
-=head1 DESCRIPTION
-
-This module in particular handles FS::part_export object creation for Cacti integration;
-consult any existing L<FS::part_export> documentation for details on how that works.
-What follows is more general instructions for connecting your Cacti installation
-to your Freeside installation.
-
-=head2 Connecting Cacti To Freeside
-
-Copy the freeside_cacti.php script from the bin directory of your Freeside
-installation to the cli directory of your Cacti installation.  Give this file 
-the same permissions as the other files in that directory, and create 
-(or choose an existing) user with sufficient permission to read these scripts.
-
-In the regular Cacti interface, create a Host Template to be used by 
-devices exported by Freeside, and note the template's id number.  Optionally,
-create a Graph Tree for these devices to be automatically added to, and note
-the tree's id number.  Configure a Graph Export (under Settings) and note 
-the Export Directory.
-
-In Freeside, go to Configuration->Services->Provisioning exports to
-add a new export.  From the Add Export page, select cacti for Export then enter...
-
-* the Hostname or IP address of your Cacti server
-
-* the User Name with permission to run scripts in the cli directory
-
-* the full Script Path to that directory (eg /usr/share/cacti/cli/)
+=head1 METHODS
 
-* the Host Template ID for adding new devices
+=over 4
 
-* the Graph Tree ID for adding new devices (optional)
+=item cleanup
 
-* the Description for new devices;  you can use the tokens
-  $ip_addr and $description to include the equivalent fields
-  from the broadband service definition
+Removes all expired graphs for this export from the database.
 
-* the Graph Export Directory, including connection information
-  if necessary (user at host:/path/to/graphs/)
+=cut
 
-* the minimum minutes between graph imports to Freeside (graphs will
-  otherwise be imported into Freeside as needed.)  This should be at least
-  as long as the minumum time between graph exports configured in Cacti.
-  Defaults to 5 if unspecified.
+sub cleanup {
+  my $self = shift;
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+  my $sth = $dbh->prepare('DELETE FROM cacti_page WHERE exportnum = ? and imported <= ?') 
+    or do {
+      $dbh->rollback if $oldAutoCommit;
+      return $dbh->errstr;
+    };
+  $sth->execute($self->exportnum,$self->exptime)
+    or do {
+      $dbh->rollback if $oldAutoCommit;
+      return $dbh->errstr;
+    };
+  $dbh->commit or return $dbh->errstr if $oldAutoCommit;
+  return '';
+}
 
-* the maximum size per graph, in MB;  individual graphs that exceed this size
-  will be quietly ignored by Freeside.  Defaults to 5 if unspecified.
+=item exptime [ TIME ]
 
-After adding the export, go to Configuration->Services->Service definitions.
-The export you just created will be available for selection when adding or
-editing broadband service definitions; check the box to activate it for 
-a given service.  Note that you should only have one cacti export per
-broadband service definition.
+Accepts optional current time, defaults to actual current time.
 
-When properly configured broadband services are provisioned, they will now
-be added to Cacti using the Host Template you specified.  If you also specified
-a Graph Tree, the created device will also be added to that.
+Returns timestamp for the oldest possible non-expired graph import,
+based on the import_freq option.
 
-Once added, a link to the graphs for this host will be available when viewing 
-the details of the provisioned service in Freeside.
+=cut
 
-Devices will be deleted from Cacti when the service is unprovisioned in Freeside, 
-and they will be deleted and re-added if the ip address changes.
+sub exptime {
+  my $self = shift;
+  my $now = shift || time;
+  return $now - 60 * ($self->option('import_freq') || 5);
+}
 
-Currently, graphs themselves must still be added in Cacti by hand or some
-other form of automation tailored to your specific graph inputs and data sources.
+=back
 
 =head1 AUTHOR
 
diff --git a/FS/bin/freeside-daily b/FS/bin/freeside-daily
index f04faea..a793813 100755
--- a/FS/bin/freeside-daily
+++ b/FS/bin/freeside-daily
@@ -74,6 +74,11 @@ export_batch_submit(%opt);
 use FS::Cron::agent_email qw(agent_email);
 agent_email(%opt);
 
+#does nothing unless there are cacti imports
+#should run before backup, no need to backup cacti imports
+use FS::Cron::cacti_cleanup qw(cacti_cleanup);
+cacti_cleanup();
+
 my $deldir = "$FS::UID::cache_dir/cache.$FS::UID::datasrc/";
 unlink <${deldir}.invoice*>;
 unlink <${deldir}.letter*>;
diff --git a/httemplate/misc/cacti_graphs.html b/httemplate/misc/cacti_graphs.html
index 9cc5e24..90a4350 100644
--- a/httemplate/misc/cacti_graphs.html
+++ b/httemplate/misc/cacti_graphs.html
@@ -21,33 +21,43 @@
 process();
 </SCRIPT>
 
-% } else {
-%   if ($error) {
+% } elsif ($error) {
 
-<P><% $error %></P>
+<P><% emt($error) %></P>
+<FORM NAME="CactiGraphForm" ID="CactiGraphForm" style="margin-top: 0">
+<INPUT TYPE="hidden" NAME="svcnum" VALUE="<% $svcnum %>">
+<INPUT TYPE="hidden" NAME="load" VALUE="1">
+<INPUT TYPE="submit" VALUE="Reload Graphs">
+</FORM>
 
-%   } else {
+% } else {
 
-<% slurp($htmlfile) %>
+<% $content %>
 
-%   }
 % }
 
 <%init>
 use File::Slurp qw( slurp );
 
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('View customer services');
+
 my $svcnum    = $cgi->param('svcnum') or die 'Illegal svcnum';
 my $load      = $cgi->param('load');
-my $graphnum  = $cgi->param('graphnum');
-
-my $htmlfile = $FS::UID::cache_dir 
-             . '/cacti-graphs/'
-             . 'svc_'
-             . $svcnum;
-$htmlfile .= '_graph_' . $graphnum
-  if $graphnum;
-$htmlfile .= '.html';
+my $graphnum  = $cgi->param('graphnum') || '';
+
+my ($content,$error);
+unless ($load) {
+  my $page = qsearchs({
+    'table'    => 'cacti_page',
+    'hashref'  => { 'svcnum' => $svcnum, 'graphnum' => $graphnum },
+  });
+  if ($page) {
+    $content = $page->content;
+  } else {
+    $error = 'No graphs found in import cache.  Click below to retry import.';
+  }
+}
 
-my $error = (-e $htmlfile) ? '' : 'File not found';
 </%init>
 
diff --git a/httemplate/misc/process/cacti_graphs.cgi b/httemplate/misc/process/cacti_graphs.cgi
index 160b1ad..f2baeb4 100644
--- a/httemplate/misc/process/cacti_graphs.cgi
+++ b/httemplate/misc/process/cacti_graphs.cgi
@@ -1,6 +1,9 @@
 <% $server->process %>
 
 <%init>
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('View customer services');
+
 my $server = FS::UI::Web::JSRPC->new('FS::part_export::cacti::process_graphs', $cgi);
 </%init>
 

commit ecf01f1adf08cfbbb489d18c39a9626079112526
Author: Jonathan Prykop <jonathan at freeside.biz>
Date:   Fri Mar 27 14:20:48 2015 -0500

    RT#18834: Cacti integration [real graph import]

diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 3c90892..cd74a38 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -3248,7 +3248,6 @@ sub tables_hashref {
         'suid',                    'int', 'NULL',        '', '', '',
         'shared_svcnum',           'int', 'NULL',        '', '', '',
         'serviceid',           'varchar', 'NULL',        64, '', '',#srvexport/reportfields
-        'cacti_leaf_id',           'int', 'NULL',        '', '', '',
       ],
       'primary_key' => 'svcnum',
       'unique'      => [ [ 'ip_addr' ], [ 'mac_addr' ] ],
diff --git a/FS/FS/part_export/cacti.pm b/FS/FS/part_export/cacti.pm
index 6877c8f..1f5f64c 100644
--- a/FS/FS/part_export/cacti.pm
+++ b/FS/FS/part_export/cacti.pm
@@ -1,10 +1,16 @@
 package FS::part_export::cacti;
 
 use strict;
+
 use base qw( FS::part_export );
 use FS::Record qw( qsearchs );
 use FS::UID qw( dbh );
 
+use File::Rsync;
+use File::Slurp qw( append_file slurp write_file );
+use File::stat;
+use MIME::Base64 qw( encode_base64 );
+
 use vars qw( %info );
 
 my $php = 'php -q ';
@@ -14,14 +20,18 @@ tie my %options, 'Tie::IxHash',
                            default => 'freeside' },
   'script_path'       => { label   => 'Script Path',
                            default => '/usr/share/cacti/cli/' },
-  'base_url'          => { label   => 'Base Cacti URL',
-                           default => '' },
   'template_id'       => { label   => 'Host Template ID',
                            default => '' },
-  'tree_id'           => { label   => 'Graph Tree ID',
+  'tree_id'           => { label   => 'Graph Tree ID (optional)',
                            default => '' },
   'description'       => { label   => 'Description (can use $ip_addr and $description tokens)',
                            default => 'Freeside $description $ip_addr' },
+  'graphs_path'       => { label   => 'Graph Export Directory (user at host:/path/to/graphs/)',
+                           default => '' },
+  'import_freq'       => { label   => 'Minimum minutes between graph imports',
+                           default => '5' },
+  'max_graph_size'    => { label   => 'Maximum size per graph (MB)',
+                           default => '5' },
 #  'delete_graphs'     => { label   => 'Delete associated graphs and data sources when unprovisioning', 
 #                           type    => 'checkbox',
 #                         },
@@ -155,27 +165,18 @@ sub ssh_insert {
   my $id = $1;
 
   # Add host to tree
-  $cmd = $php
-       . $opt{'script_path'}
-       . q(add_tree.php --type=node --node-type=host --tree-id=)
-       . $opt{'tree_id'}
-       . q( --host-id=)
-       . $id;
-  $response = ssh_cmd(%opt, 'command' => $cmd);
-  unless ( $response =~ /Added Node node-id: \((\d+)\)/ ) {
+  if ($opt{'tree_id'}) {
+    $cmd = $php
+         . $opt{'script_path'}
+         . q(add_tree.php --type=node --node-type=host --tree-id=)
+         . $opt{'tree_id'}
+         . q( --host-id=)
+         . $id;
+    $response = ssh_cmd(%opt, 'command' => $cmd);
+    unless ( $response =~ /Added Node node-id: \((\d+)\)/ ) {
       die "Error adding host to tree: $response";
+    }
   }
-  my $leaf_id = $1;
-
-  # Store id for generating graph urls
-  my $svc_broadband = qsearchs({
-    'table'   => 'svc_broadband',
-    'hashref' => { 'svcnum' => $opt{'svcnum'} },
-  });
-  die "Could not reload broadband service" unless $svc_broadband;
-  $svc_broadband->set('cacti_leaf_id',$leaf_id);
-  my $error = $svc_broadband->replace;
-  return $error if $error;
 
 #  # Get list of graph templates for new id
 #  $cmd = $php
@@ -237,6 +238,145 @@ sub ssh_delete {
   return '';
 }
 
+# NOT A METHOD, run as an FS::queue job
+# copies graphs for a single service from Cacti export directory to FS cache
+# generates basic html pages for this service's graphs, and stores them in FS cache
+sub process_graphs {
+  my ($job,$param) = @_; #
+
+  $job->update_statustext(10);
+  my $cachedir = $FS::UID::cache_dir . '/cacti-graphs/';
+
+  # load the service
+  my $svcnum = $param->{'svcnum'} || die "No svcnum specified";
+  my $svc = qsearchs({
+   'table'   => 'svc_broadband',
+   'hashref' => { 'svcnum' => $svcnum },
+  }) || die "Could not load svcnum $svcnum";
+
+  # load relevant FS::part_export::cacti object
+  my ($self) = $svc->cust_svc->part_svc->part_export('cacti');
+
+  $job->update_statustext(20);
+
+  # check for recent uploads, avoid doing this too often
+  my $svchtml = $cachedir.'svc_'.$svcnum.'.html';
+  if (-e $svchtml) {
+    open(my $fh, "<$svchtml");
+    my $firstline = <$fh>;
+    close($fh);
+    if ($firstline =~ /UPDATED (\d+)/) {
+      if ($1 > time - 60 * ($self->option('import_freq') || 5)) {
+        $job->update_statustext(100);
+        return '';
+      }
+    }
+  }
+
+  $job->update_statustext(30);
+
+  # get list of graphs for this svc
+  my $cmd = $php
+          . $self->option('script_path')
+          . q(freeside_cacti.php --get-graphs --ip=')
+          . $svc->ip_addr
+          . q(');
+  my @graphs = map { [ split(/\t/,$_) ] } 
+                 split(/\n/, ssh_cmd(
+                   'host'          => $self->machine,
+                   'user'          => $self->option('user'),
+                   'command'       => $cmd
+                 ));
+
+  $job->update_statustext(40);
+
+  # copy graphs to cache
+  # requires version 2.6.4 of rsync, released March 2005
+  my $rsync = File::Rsync->new({
+    'rsh'       => 'ssh',
+    'verbose'   => 1,
+    'recursive' => 1,
+    'source'    => $self->option('graphs_path'),
+    'dest'      => $cachedir,
+    'include'   => [
+      (map { q('**graph_).${$_}[0].q(*.png') } @graphs),
+      (map { q('**thumb_).${$_}[0].q(.png') } @graphs),
+      q('*/'),
+      q('- *'),
+    ],
+  });
+  #don't know why a regular $rsync->exec isn't doing includes right, but this does
+  my $error = system(join(' ',@{$rsync->getcmd()}));
+  die "rsync failed with exit status $error" if $error;
+
+  $job->update_statustext(50);
+
+  # create html files in cache
+  my $now = time;
+  my $svchead = q(<!-- UPDATED ) . $now . qq( -->\n)
+              . '<H2 STYLE="margin-top: 0;">Service #' . $svcnum . '</H2>' . "\n"
+              . q(<P>Last updated ) . scalar(localtime($now)) . q(</P>) . "\n";
+  write_file($svchtml,$svchead);
+  my $maxgraph = 1024 * 1024 * ($self->options('max_graph_size') || 5);
+  my $nographs = 1;
+  for (my $i = 0; $i <= $#graphs; $i++) {
+    my $graph = $graphs[$i];
+    my $thumbfile = $cachedir . 'graphs/thumb_' . $$graph[0] . '.png';
+    if (
+      (-e $thumbfile) && 
+      ( stat($thumbfile)->size() < $maxgraph )
+    ) {
+      $nographs = 0;
+      # add graph to main file
+      my $graphhead = q(<H3>) . $$graph[1] . q(</H3>) . "\n";
+      append_file( $svchtml, $graphhead,
+        anchor_tag( 
+          $svcnum, $$graph[0], img_tag($thumbfile)
+        )
+      );
+      # create graph details file
+      my $graphhtml = $cachedir . 'svc_' . $svcnum . '_graph_' . $$graph[0] . '.html';
+      write_file($graphhtml,$svchead,$graphhead);
+      my $nodetail = 1;
+      my $j = 1;
+      while (-e (my $graphfile = $cachedir.'graphs/graph_'.$$graph[0].'_'.$j.'.png')) {
+        if ( stat($graphfile)->size() < $maxgraph ) {
+          $nodetail = 0;
+          append_file( $graphhtml, img_tag($graphfile) );
+        }
+        $j++;
+      }
+      append_file($graphhtml, '<P>No detail graphs to display for this graph</P>')
+        if $nodetail;
+    }
+    $job->update_statustext(50 + ($i / $#graphs) * 50);
+  }
+  append_file($svchtml,'<P>No graphs to display for this service</P>')
+    if $nographs;
+
+  $job->update_statustext(100);
+  return '';
+}
+
+sub img_tag {
+  my $somefile = shift;
+  return q(<IMG SRC="data:image/png;base64,)
+       . encode_base64(slurp($somefile,binmode=>':raw'))
+       . qq(" STYLE="margin-bottom: 1em;"><BR>\n);
+}
+
+sub anchor_tag {
+  my ($svcnum, $graphnum, $contents) = @_;
+  return q(<A HREF="?svcnum=)
+       . $svcnum
+       . q(&graphnum=)
+       . $graphnum
+       . q(">)
+       . $contents
+       . q(</A>);
+}
+
+#this gets used by everything else
 #fake false laziness, other ssh_cmds handle error/output differently
 sub ssh_cmd {
   use Net::OpenSSH;
@@ -274,41 +414,56 @@ the same permissions as the other files in that directory, and create
 (or choose an existing) user with sufficient permission to read these scripts.
 
 In the regular Cacti interface, create a Host Template to be used by 
-devices exported by Freeside, and note the template's id number.
+devices exported by Freeside, and note the template's id number.  Optionally,
+create a Graph Tree for these devices to be automatically added to, and note
+the tree's id number.  Configure a Graph Export (under Settings) and note 
+the Export Directory.
 
 In Freeside, go to Configuration->Services->Provisioning exports to
 add a new export.  From the Add Export page, select cacti for Export then enter...
 
-* the User Name with permission to run scripts in the cli directory
+* the Hostname or IP address of your Cacti server
 
-* enter the full Script Path to that directory (eg /usr/share/cacti/cli/)
+* the User Name with permission to run scripts in the cli directory
 
-* enter the Base Cacti URL for your cacti server (eg https://example.com/cacti/)
+* the full Script Path to that directory (eg /usr/share/cacti/cli/)
 
 * the Host Template ID for adding new devices
 
-* the Graph Tree ID for adding new devices
+* the Graph Tree ID for adding new devices (optional)
 
 * the Description for new devices;  you can use the tokens
   $ip_addr and $description to include the equivalent fields
   from the broadband service definition
 
+* the Graph Export Directory, including connection information
+  if necessary (user at host:/path/to/graphs/)
+
+* the minimum minutes between graph imports to Freeside (graphs will
+  otherwise be imported into Freeside as needed.)  This should be at least
+  as long as the minumum time between graph exports configured in Cacti.
+  Defaults to 5 if unspecified.
+
+* the maximum size per graph, in MB;  individual graphs that exceed this size
+  will be quietly ignored by Freeside.  Defaults to 5 if unspecified.
+
 After adding the export, go to Configuration->Services->Service definitions.
 The export you just created will be available for selection when adding or
-editing broadband service definitions.
+editing broadband service definitions; check the box to activate it for 
+a given service.  Note that you should only have one cacti export per
+broadband service definition.
 
-When properly configured broadband services are provisioned, they should now
-be added to Cacti using the Host Template you specified, and the created device
-will also be added to the specified Graph Tree.
+When properly configured broadband services are provisioned, they will now
+be added to Cacti using the Host Template you specified.  If you also specified
+a Graph Tree, the created device will also be added to that.
 
 Once added, a link to the graphs for this host will be available when viewing 
-the details of the provisioned service in Freeside (you will need to authenticate 
-into Cacti to view them.)
+the details of the provisioned service in Freeside.
 
 Devices will be deleted from Cacti when the service is unprovisioned in Freeside, 
 and they will be deleted and re-added if the ip address changes.
 
-Currently, graphs themselves must still be added in cacti by hand or some
+Currently, graphs themselves must still be added in Cacti by hand or some
 other form of automation tailored to your specific graph inputs and data sources.
 
 =head1 AUTHOR
@@ -320,8 +475,8 @@ jonathan at freeside.biz
 
 Copyright 2015 Freeside Internet Services      
 
-This program is free software; you can redistribute it and/or           |
-modify it under the terms of the GNU General Public License             |
+This program is free software; you can redistribute it and/or 
+modify it under the terms of the GNU General Public License 
 as published by the Free Software Foundation.
 
 =cut
diff --git a/bin/freeside_cacti.php b/bin/freeside_cacti.php
index 22fb0f0..0a9ee9c 100755
--- a/bin/freeside_cacti.php
+++ b/bin/freeside_cacti.php
@@ -32,15 +32,15 @@ if (!isset($_SERVER["argv"][0]) || isset($_SERVER['REQUEST_METHOD'])  || isset($
 $no_http_headers = true;
 
 /* 
-Currently, only drop-device is actually being used by Freeside integration,
+Currently, only drop-device and get-graphs is actually being used by Freeside integration,
 but keeping commented out code for potential future development.
 */
 
 include(dirname(__FILE__)."/../site/include/global.php");
 include_once($config["base_path"]."/lib/api_device.php");
+include_once($config["base_path"]."/lib/api_automation_tools.php");
 
 /*
-include_once($config["base_path"]."/lib/api_automation_tools.php");
 include_once($config["base_path"]."/lib/api_data_source.php");
 include_once($config["base_path"]."/lib/api_graph.php");
 include_once($config["base_path"]."/lib/functions.php");
@@ -57,6 +57,9 @@ if (sizeof($parms)) {
 	foreach($parms as $parameter) {
 		@list($arg, $value) = @explode("=", $parameter);
 		switch ($arg) {
+        case "--get-graphs":
+			$action = 'get-graphs';
+            break;
         case "--drop-device":
 			$action = 'drop-device';
             break;
@@ -94,6 +97,9 @@ if (sizeof($parms)) {
 
 /* Now take an action */
 switch ($action) {
+case "get-graphs":
+	displayHostGraphs(host_id($ip),TRUE);
+	break;
 case "drop-device":
 	$host_id = host_id($ip);
 /*
diff --git a/httemplate/misc/cacti_graphs.html b/httemplate/misc/cacti_graphs.html
new file mode 100644
index 0000000..9cc5e24
--- /dev/null
+++ b/httemplate/misc/cacti_graphs.html
@@ -0,0 +1,53 @@
+<% include( '/elements/header.html', 'Cacti Graphs' ) %>
+
+% if ($load) {
+
+<FORM NAME="CactiGraphForm" ID="CactiGraphForm" style="margin-top: 0">
+<INPUT TYPE="hidden" NAME="svcnum" VALUE="<% $svcnum %>">
+</FORM>
+<% include( '/elements/progress-init.html',
+              'CactiGraphForm', 
+              [ 'svcnum' ],
+              $p.'misc/process/cacti_graphs.cgi',
+              { url => 'javascript:window.location.replace("'.popurl(2).'misc/cacti_graphs.html?svcnum='.$svcnum.'")' },
+) %>
+<!--
+  note we use window.location.replace for the callback url above
+  so that this page gets removed from browser history after processing
+  so that process() doesn't get triggered by the back button
+-->
+<P>Loading graphs, please wait...</P>
+<SCRIPT TYPE="text/javascript">
+process();
+</SCRIPT>
+
+% } else {
+%   if ($error) {
+
+<P><% $error %></P>
+
+%   } else {
+
+<% slurp($htmlfile) %>
+
+%   }
+% }
+
+<%init>
+use File::Slurp qw( slurp );
+
+my $svcnum    = $cgi->param('svcnum') or die 'Illegal svcnum';
+my $load      = $cgi->param('load');
+my $graphnum  = $cgi->param('graphnum');
+
+my $htmlfile = $FS::UID::cache_dir 
+             . '/cacti-graphs/'
+             . 'svc_'
+             . $svcnum;
+$htmlfile .= '_graph_' . $graphnum
+  if $graphnum;
+$htmlfile .= '.html';
+
+my $error = (-e $htmlfile) ? '' : 'File not found';
+</%init>
+
diff --git a/httemplate/misc/process/cacti_graphs.cgi b/httemplate/misc/process/cacti_graphs.cgi
new file mode 100644
index 0000000..160b1ad
--- /dev/null
+++ b/httemplate/misc/process/cacti_graphs.cgi
@@ -0,0 +1,6 @@
+<% $server->process %>
+
+<%init>
+my $server = FS::UI::Web::JSRPC->new('FS::part_export::cacti::process_graphs', $cgi);
+</%init>
+
diff --git a/httemplate/view/svc_broadband.cgi b/httemplate/view/svc_broadband.cgi
index 9fe10bd..4935a10 100644
--- a/httemplate/view/svc_broadband.cgi
+++ b/httemplate/view/svc_broadband.cgi
@@ -72,15 +72,11 @@ sub ip_addr {
   my $out = $ip_addr;
   $out .= ' (' . include('/elements/popup_link-ping.html', ip => $ip_addr) . ')'
     if $ip_addr;
-  if ($svc->cacti_leaf_id) {
-    # should only ever be one, but not sure if that is enforced
-    my ($cacti) = $svc->cust_svc->part_svc->part_export('cacti');
-    $out .= ' (<A HREF="' 
-         .  $cacti->option('base_url')
-         .  'graph_view.php?action=tree&tree_id='
-         .  $cacti->option('tree_id')
-         .  '&leaf_id='
-         .  $svc->cacti_leaf_id
+  if ($svc->cust_svc->part_svc->part_export('cacti')) {
+    $out .= ' (<A HREF="'
+         .  popurl(2)
+         .  'misc/cacti_graphs.html?load=1&svcnum=' 
+         .  $svc->svcnum
          .  '">cacti</A>)';
   }
   if ( my $addr_block = $svc->addr_block ) {

commit e05e4f1f0d6a704db4d3c8b3d9a851216569580c
Author: Jonathan Prykop <jonathan at freeside.biz>
Date:   Fri Mar 20 15:25:12 2015 -0500

    RT#18834 Cacti integration [phase one, simple but stable]

diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index cd74a38..3c90892 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -3248,6 +3248,7 @@ sub tables_hashref {
         'suid',                    'int', 'NULL',        '', '', '',
         'shared_svcnum',           'int', 'NULL',        '', '', '',
         'serviceid',           'varchar', 'NULL',        64, '', '',#srvexport/reportfields
+        'cacti_leaf_id',           'int', 'NULL',        '', '', '',
       ],
       'primary_key' => 'svcnum',
       'unique'      => [ [ 'ip_addr' ], [ 'mac_addr' ] ],
diff --git a/FS/FS/part_export/cacti.pm b/FS/FS/part_export/cacti.pm
new file mode 100644
index 0000000..6877c8f
--- /dev/null
+++ b/FS/FS/part_export/cacti.pm
@@ -0,0 +1,331 @@
+package FS::part_export::cacti;
+
+use strict;
+use base qw( FS::part_export );
+use FS::Record qw( qsearchs );
+use FS::UID qw( dbh );
+
+use vars qw( %info );
+
+my $php = 'php -q ';
+
+tie my %options, 'Tie::IxHash',
+  'user'              => { label   => 'User Name',
+                           default => 'freeside' },
+  'script_path'       => { label   => 'Script Path',
+                           default => '/usr/share/cacti/cli/' },
+  'base_url'          => { label   => 'Base Cacti URL',
+                           default => '' },
+  'template_id'       => { label   => 'Host Template ID',
+                           default => '' },
+  'tree_id'           => { label   => 'Graph Tree ID',
+                           default => '' },
+  'description'       => { label   => 'Description (can use $ip_addr and $description tokens)',
+                           default => 'Freeside $description $ip_addr' },
+#  'delete_graphs'     => { label   => 'Delete associated graphs and data sources when unprovisioning', 
+#                           type    => 'checkbox',
+#                         },
+;
+
+%info = (
+  'svc'             => 'svc_broadband',
+  'desc'            => 'Export service to cacti server, for svc_broadband services',
+  'options'         => \%options,
+  'notes'           => <<'END',
+Add service to cacti upon provisioning, for broadband services.<BR>
+See FS::part_export::cacti documentation for details.
+END
+);
+
+# standard hooks for provisioning/unprovisioning service
+
+sub _export_insert {
+  my ($self, $svc_broadband) = @_;
+  my ($q,$error) = _insert_queue($self, $svc_broadband);
+  return $error;
+}
+
+sub _export_delete {
+  my ($self, $svc_broadband) = @_;
+  my ($q,$error) = _delete_queue($self, $svc_broadband);
+  return $error;
+}
+
+sub _export_replace {
+  my($self, $new, $old) = @_;
+  return '' if $new->ip_addr eq $old->ip_addr; #important part didn't change
+  #delete old then insert new, with second job dependant on the first
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+  my ($dq, $iq, $error);
+  ($dq,$error) = _delete_queue($self,$old);
+  if ($error) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+  ($iq,$error) = _insert_queue($self,$new);
+  if ($error) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+  $error = $iq->depend_insert($dq->jobnum);
+  if ($error) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  return '';
+}
+
+sub _export_suspend {
+  return '';
+}
+
+sub _export_unsuspend {
+  return '';
+}
+
+# create queued jobs
+
+sub _insert_queue {
+  my ($self, $svc_broadband) = @_;
+  my $queue = new FS::queue {
+    'svcnum' => $svc_broadband->svcnum,
+    'job'    => "FS::part_export::cacti::ssh_insert",
+  };
+  my $error = $queue->insert(
+    'host'        => $self->machine,
+    'user'        => $self->option('user'),
+    'hostname'    => $svc_broadband->ip_addr,
+    'script_path' => $self->option('script_path'),
+    'template_id' => $self->option('template_id'),
+    'tree_id'     => $self->option('tree_id'),
+    'description' => $self->option('description'),
+	'svc_desc'    => $svc_broadband->description,
+    'svcnum'      => $svc_broadband->svcnum,
+  );
+  return ($queue,$error);
+}
+
+sub _delete_queue {
+  my ($self, $svc_broadband) = @_;
+  my $queue = new FS::queue {
+    'svcnum' => $svc_broadband->svcnum,
+    'job'    => "FS::part_export::cacti::ssh_delete",
+  };
+  my $error = $queue->insert(
+    'host'          => $self->machine,
+    'user'          => $self->option('user'),
+    'hostname'      => $svc_broadband->ip_addr,
+    'script_path'   => $self->option('script_path'),
+#    'delete_graphs' => $self->option('delete_graphs'),
+  );
+  return ($queue,$error);
+}
+
+# routines run by queued jobs
+
+sub ssh_insert {
+  my %opt = @_;
+
+  # Option validation
+  die "Non-numerical Host Template ID, check export configuration\n"
+    unless $opt{'template_id'} =~ /^\d+$/;
+  die "Non-numerical Graph Tree ID, check export configuration\n"
+    unless $opt{'tree_id'} =~ /^\d+$/;
+
+  # Add host to cacti
+  my $desc = $opt{'description'};
+  $desc =~ s/\$ip_addr/$opt{'hostname'}/g;
+  $desc =~ s/\$description/$opt{'svc_desc'}/g;
+  $desc =~ s/'/'\\''/g;
+  my $cmd = $php
+          . $opt{'script_path'} 
+          . q(add_device.php --description=')
+          . $desc
+          . q(' --ip=')
+          . $opt{'hostname'}
+          . q(' --template=)
+          . $opt{'template_id'};
+  my $response = ssh_cmd(%opt, 'command' => $cmd);
+  unless ( $response =~ /Success - new device-id: \((\d+)\)/ ) {
+    die "Error adding device: $response";
+  }
+  my $id = $1;
+
+  # Add host to tree
+  $cmd = $php
+       . $opt{'script_path'}
+       . q(add_tree.php --type=node --node-type=host --tree-id=)
+       . $opt{'tree_id'}
+       . q( --host-id=)
+       . $id;
+  $response = ssh_cmd(%opt, 'command' => $cmd);
+  unless ( $response =~ /Added Node node-id: \((\d+)\)/ ) {
+      die "Error adding host to tree: $response";
+  }
+  my $leaf_id = $1;
+
+  # Store id for generating graph urls
+  my $svc_broadband = qsearchs({
+    'table'   => 'svc_broadband',
+    'hashref' => { 'svcnum' => $opt{'svcnum'} },
+  });
+  die "Could not reload broadband service" unless $svc_broadband;
+  $svc_broadband->set('cacti_leaf_id',$leaf_id);
+  my $error = $svc_broadband->replace;
+  return $error if $error;
+
+#  # Get list of graph templates for new id
+#  $cmd = $php
+#       . $opt{'script_path'} 
+#       . q(freeside_cacti.php --get-graph-templates --host-template=)
+#       . $opt{'template_id'};
+#  my @gtids = split(/\n/,ssh_cmd(%opt, 'command' => $cmd));
+#  die "No graphs configured for host template"
+#    unless @gtids;
+#
+#  # Create graphs
+#  foreach my $gtid (@gtids) {
+#
+#    # sanity checks, should never happen
+#    next unless $gtid;
+#    die "Bad graph template: $gtid"
+#      unless $gtid =~ /^\d+$/;
+#
+#    # create the graph
+#    $cmd = $php
+#         . $opt{'script_path'}
+#         . q(add_graphs.php --graph-type=cg --graph-template-id=)
+#         . $gtid
+#         . q( --host-id=)
+#         . $id;
+#    $response = ssh_cmd(%opt, 'command' => $cmd);
+#    die "Error creating graph $gtid: $response"
+#      unless $response =~ /Graph Added - graph-id: \((\d+)\)/;
+#    my $gid = $1;
+#
+#    # add the graph to the tree
+#    $cmd = $php
+#         . $opt{'script_path'}
+#         . q(add_tree.php --type=node --node-type=graph --tree-id=)
+#         . $opt{'tree_id'}
+#         . q( --graph-id=)
+#         . $gid;
+#    $response = ssh_cmd(%opt, 'command' => $cmd);
+#    die "Error adding graph $gid to tree: $response"
+#      unless $response =~ /Added Node/;
+#
+#  } #foreach $gtid
+
+  return '';
+}
+
+sub ssh_delete {
+  my %opt = @_;
+  my $cmd = $php
+          . $opt{'script_path'} 
+          . q(freeside_cacti.php --drop-device --ip=')
+          . $opt{'hostname'}
+          . q(');
+#  $cmd .= q( --delete-graphs)
+#    if $opt{'delete_graphs'};
+  my $response = ssh_cmd(%opt, 'command' => $cmd);
+  die "Error removing from cacti: " . $response
+    if $response;
+  return '';
+}
+
+#fake false laziness, other ssh_cmds handle error/output differently
+sub ssh_cmd {
+  use Net::OpenSSH;
+  my $opt = { @_ };
+  my $ssh = Net::OpenSSH->new($opt->{'user'}.'@'.$opt->{'host'});
+  die "Couldn't establish SSH connection: ". $ssh->error if $ssh->error;
+  my ($output, $errput) = $ssh->capture2($opt->{'command'});
+  die "Error running SSH command: ". $ssh->error if $ssh->error;
+  die $errput if $errput;
+  return $output;
+}
+
+=pod
+
+=head1 NAME
+
+FS::part_export::cacti
+
+=head1 SYNOPSIS
+
+Cacti integration for Freeside
+
+=head1 DESCRIPTION
+
+This module in particular handles FS::part_export object creation for Cacti integration;
+consult any existing L<FS::part_export> documentation for details on how that works.
+What follows is more general instructions for connecting your Cacti installation
+to your Freeside installation.
+
+=head2 Connecting Cacti To Freeside
+
+Copy the freeside_cacti.php script from the bin directory of your Freeside
+installation to the cli directory of your Cacti installation.  Give this file 
+the same permissions as the other files in that directory, and create 
+(or choose an existing) user with sufficient permission to read these scripts.
+
+In the regular Cacti interface, create a Host Template to be used by 
+devices exported by Freeside, and note the template's id number.
+
+In Freeside, go to Configuration->Services->Provisioning exports to
+add a new export.  From the Add Export page, select cacti for Export then enter...
+
+* the User Name with permission to run scripts in the cli directory
+
+* enter the full Script Path to that directory (eg /usr/share/cacti/cli/)
+
+* enter the Base Cacti URL for your cacti server (eg https://example.com/cacti/)
+
+* the Host Template ID for adding new devices
+
+* the Graph Tree ID for adding new devices
+
+* the Description for new devices;  you can use the tokens
+  $ip_addr and $description to include the equivalent fields
+  from the broadband service definition
+
+After adding the export, go to Configuration->Services->Service definitions.
+The export you just created will be available for selection when adding or
+editing broadband service definitions.
+
+When properly configured broadband services are provisioned, they should now
+be added to Cacti using the Host Template you specified, and the created device
+will also be added to the specified Graph Tree.
+
+Once added, a link to the graphs for this host will be available when viewing 
+the details of the provisioned service in Freeside (you will need to authenticate 
+into Cacti to view them.)
+
+Devices will be deleted from Cacti when the service is unprovisioned in Freeside, 
+and they will be deleted and re-added if the ip address changes.
+
+Currently, graphs themselves must still be added in cacti by hand or some
+other form of automation tailored to your specific graph inputs and data sources.
+
+=head1 AUTHOR
+
+Jonathan Prykop 
+jonathan at freeside.biz
+
+=head1 LICENSE AND COPYRIGHT
+
+Copyright 2015 Freeside Internet Services      
+
+This program is free software; you can redistribute it and/or           |
+modify it under the terms of the GNU General Public License             |
+as published by the Free Software Foundation.
+
+=cut
+
+1;
+
+
diff --git a/bin/freeside_cacti.php b/bin/freeside_cacti.php
new file mode 100755
index 0000000..22fb0f0
--- /dev/null
+++ b/bin/freeside_cacti.php
@@ -0,0 +1,175 @@
+#!/usr/bin/php -q
+<?php
+/*
+ +-------------------------------------------------------------------------+
+ | Copyright (C) 2015 Freeside Internet Services                           |
+ |                                                                         |
+ | This program is free software; you can redistribute it and/or           |
+ | modify it under the terms of the GNU General Public License             |
+ | as published by the Free Software Foundation; either version 2          |
+ | of the License, or (at your option) any later version.                  |
+ |                                                                         |
+ | This program 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 General Public License for more details.                            |
+ +-------------------------------------------------------------------------+
+ | Copy this file to the cli directory of your Cacti installation, which   |
+ | should also contain an add_device.php script.  Give this file the same  |
+ | permissions as add_device.php, and configure your Freeside installation |
+ | with the location of that directory and the name of a user who has      |
+ | permission to read these files.  See the FS::part_export::cacti docs    |
+ | for more details.                                                       |
+ +-------------------------------------------------------------------------+
+*/
+
+/* do NOT run this script through a web browser */
+if (!isset($_SERVER["argv"][0]) || isset($_SERVER['REQUEST_METHOD'])  || isset($_SERVER['REMOTE_ADDR'])) {
+	die("<br><strong>This script is only meant to run at the command line.</strong>");
+}
+
+/* We are not talking to the browser */
+$no_http_headers = true;
+
+/* 
+Currently, only drop-device is actually being used by Freeside integration,
+but keeping commented out code for potential future development.
+*/
+
+include(dirname(__FILE__)."/../site/include/global.php");
+include_once($config["base_path"]."/lib/api_device.php");
+
+/*
+include_once($config["base_path"]."/lib/api_automation_tools.php");
+include_once($config["base_path"]."/lib/api_data_source.php");
+include_once($config["base_path"]."/lib/api_graph.php");
+include_once($config["base_path"]."/lib/functions.php");
+*/
+
+/* process calling arguments */
+$action = '';
+$ip = '';
+$host_template = '';
+// $delete_graphs = FALSE;
+$parms = $_SERVER["argv"];
+array_shift($parms);
+if (sizeof($parms)) {
+	foreach($parms as $parameter) {
+		@list($arg, $value) = @explode("=", $parameter);
+		switch ($arg) {
+        case "--drop-device":
+			$action = 'drop-device';
+            break;
+/*
+        case "--get-device":
+			$action = 'get-device';
+            break;
+        case "--get-graph-templates":
+			$action = 'get-graph-templates';
+            break;
+*/
+		case "--ip":
+			$ip = trim($value);
+			break;
+		case "--host-template":
+			$host_template = trim($value);
+			break;
+/*
+		case "--delete-graphs":
+			$delete_graphs = TRUE;
+			break;
+*/
+		case "--version":
+		case "-V":
+		case "-H":
+		case "--help":
+			die(default_die());
+		default:
+			die("ERROR: Invalid Argument: ($arg)");
+		}
+	}
+} else {
+  die(default_die());
+}
+
+/* Now take an action */
+switch ($action) {
+case "drop-device":
+	$host_id = host_id($ip);
+/*
+	if ($delete_graphs) {
+		// code copied & pasted from version 0.8.8a
+        // cacti/site/lib/host.php and cacti/site/graphs.php 
+		// unfortunately no api function for this yet
+		$graphs = db_fetch_assoc("select
+			graph_local.id as local_graph_id
+			from graph_local
+			where graph_local.host_id=" . $host_id);
+		if (sizeof($graphs) > 0) {
+			foreach ($graphs as $graph) {
+				$data_sources = array_rekey(db_fetch_assoc("SELECT data_template_data.local_data_id
+					FROM (data_template_rrd, data_template_data, graph_templates_item)
+					WHERE graph_templates_item.task_item_id=data_template_rrd.id
+					AND data_template_rrd.local_data_id=data_template_data.local_data_id
+					AND graph_templates_item.local_graph_id=" . $graph["local_graph_id"] . "
+					AND data_template_data.local_data_id > 0"), "local_data_id", "local_data_id");
+				if (sizeof($data_sources)) {
+					api_data_source_remove_multi($data_sources);
+				}
+				api_graph_remove($graph["local_graph_id"]);
+			}
+		}
+	}
+*/
+	api_device_remove($host_id);
+	if (host_id($ip,1)) {
+		die("Failed to remove hostname $ip");
+	}
+	exit(0);
+/*
+case "get-device":
+	echo host_id($ip);
+	exit(0);
+case "get-graph-templates":
+	if (!$host_template) {
+		die("No host template specified");
+	}
+	$graphs = getGraphTemplatesByHostTemplate($host_template);
+	if (sizeof($graphs)) {
+		foreach (array_keys($graphs) as $gtid) {
+			echo $gtid . "\n";
+		}
+		exit(0);
+	}
+	die("No graph templates associated with this host template");
+*/
+default:
+	die("Specified action not found, contact a developer");
+}
+
+function default_die() {
+  return "Cacti interface for freeside.  Do not use for anything else.";
+}
+
+function host_id($ip_address, $nodie=0) {
+	if (!$ip_address) {
+		die("No hostname specified");
+	}
+	$devices = array();
+	$query = "select id from host";
+	$query .= " where hostname='$ip_address'";
+	$devices = db_fetch_assoc($query);
+	if (sizeof($devices) > 1) {
+        // This should never happen, just being thorough
+		die("Multiple devices found for hostname $ip_address");
+	} else if (!sizeof($devices)) {
+		if ($nodie) {
+			return '';
+		} else {
+			die("Could not find hostname $ip_address");
+		}
+	}
+	return $devices[0]['id'];
+}
+
+?>
diff --git a/httemplate/view/svc_broadband.cgi b/httemplate/view/svc_broadband.cgi
index 70c0b53..9fe10bd 100644
--- a/httemplate/view/svc_broadband.cgi
+++ b/httemplate/view/svc_broadband.cgi
@@ -72,6 +72,17 @@ sub ip_addr {
   my $out = $ip_addr;
   $out .= ' (' . include('/elements/popup_link-ping.html', ip => $ip_addr) . ')'
     if $ip_addr;
+  if ($svc->cacti_leaf_id) {
+    # should only ever be one, but not sure if that is enforced
+    my ($cacti) = $svc->cust_svc->part_svc->part_export('cacti');
+    $out .= ' (<A HREF="' 
+         .  $cacti->option('base_url')
+         .  'graph_view.php?action=tree&tree_id='
+         .  $cacti->option('tree_id')
+         .  '&leaf_id='
+         .  $svc->cacti_leaf_id
+         .  '">cacti</A>)';
+  }
   if ( my $addr_block = $svc->addr_block ) {
     $out .= '<br>Netmask: ' . $addr_block->NetAddr->mask .
             '<br>Gateway: ' . $addr_block->ip_gateway;

-----------------------------------------------------------------------

Summary of changes:
 FS/FS/Cron/cleanup.pm                       |   18 +-
 FS/FS/Schema.pm                             |   27 +-
 eg/table_template.pm => FS/FS/cacti_page.pm |   49 ++-
 FS/FS/part_export/cacti.pm                  |  526 +++++++++++++++++++++++++++
 FS/bin/freeside-daily                       |    8 +-
 bin/freeside_cacti.php                      |  181 +++++++++
 httemplate/misc/cacti_graphs.html           |   78 ++++
 httemplate/misc/process/cacti_graphs.cgi    |   25 ++
 httemplate/view/svc_broadband.cgi           |    7 +
 9 files changed, 895 insertions(+), 24 deletions(-)
 copy eg/table_template.pm => FS/FS/cacti_page.pm (51%)
 create mode 100644 FS/FS/part_export/cacti.pm
 create mode 100755 bin/freeside_cacti.php
 create mode 100644 httemplate/misc/cacti_graphs.html
 create mode 100644 httemplate/misc/process/cacti_graphs.cgi




More information about the freeside-commits mailing list