diff --git a/README.md b/README.md index 0549898..ccd4aab 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,15 @@ Only changes are recorded. If a job template has Show Changes enabled and the mo The search box searches the change data itself, so you can ask which automated changes touched `/etc/sudoers`. +### Reports + +Both kinds of data can be saved as a report, which is shared with other users and mailed on a schedule. + +A facts report picks the facts to show as its columns and selects hosts by comparing their fact values. A changes +report lists the changes the way the Changes page does, narrowed by the same filters: host, job template, playbook, +role, module, job type, inventory, project and a search of the change data. It also takes a window in hours, so a +scheduled report covers the changes of the last night rather than everything ever recorded. + ## Authentication Ledger ignores data from servers it has not been told to trust, so a new server sends nothing useful until you approve it. diff --git a/files/web.conf b/files/web.conf index 74c2523..e51e321 100644 --- a/files/web.conf +++ b/files/web.conf @@ -63,6 +63,7 @@ server { rewrite ^/reports/delete/([0-9]+)?$ /reports.php?action=delete&report=$1 break; rewrite ^/reports/edit/([0-9]+)?$ /reports.php?action=edit&report=$1 break; rewrite ^/reports/new/?$ /reports.php?action=new break; + rewrite ^/reports/new/([a-z]+)/?$ /reports.php?action=new&type=$1 break; rewrite ^/reports/view/([0-9]+)?$ /reports.php?action=view&report=$1 break; rewrite ^/reports/perms/([0-9]+)/user/remove/([0-9]+)?$ /reports.php?action=removeuserperm&report=$1&user=$2 break; diff --git a/src/includes/classes/Report.php b/src/includes/classes/Report.php index 80aaa56..f01338d 100644 --- a/src/includes/classes/Report.php +++ b/src/includes/classes/Report.php @@ -10,6 +10,7 @@ class Report { var $sortc = 0; var $sortd = 'asc'; var $role = ''; + var $type = 'facts'; function __construct($id = 0) { if ($id) { @@ -44,6 +45,14 @@ function set_created($created) { $this->created = intval($created); } + function set_type($type) { + if ($type == 'changes') { + $this->type = 'changes'; + } else { + $this->type = 'facts'; + } + } + function set_columns($columns) { $this->columns = $columns; } @@ -75,6 +84,10 @@ function pop_class($u) { $this->sortc = $u['sortc']; $this->sortd = $u['sortd']; $this->role = (isset($u['role']) ? $u['role'] : 'view'); + $this->set_type(isset($u['type']) ? $u['type'] : 'facts'); + if ($this->type == 'changes') { + $this->columns = changes_report_columns(); + } } } @@ -101,6 +114,53 @@ function add_filter($fact, $compare, $value) { $this->save(); } + /* + Changes reports filter the changes table rather than the facts table, so their filters + are a field of the changes view and the value to match, in the same shape the changes + page builds from its own filter menus. One filter per field, as on that page. + */ + function add_changes_filter($field, $value) { + $fields = changes_report_filter_fields(); + + if (!isset($fields[$field])) { + return false; + } + + if (in_array($field, changes_report_numeric_filter_fields())) { + $value = intval($value); + if ($value < 1) { + return false; + } + } else { + $value = $this->clean_filter($value); + if ($value === '') { + return false; + } + } + + $filters = $this->strip_changes_filter($field); + $filters[] = array('field' => $field, 'value' => $value); + $this->filters = $filters; + $this->save(); + + return true; + } + + function remove_changes_filter($field) { + $this->filters = $this->strip_changes_filter($field); + $this->save(); + } + + function strip_changes_filter($field) { + $kept = array(); + foreach ($this->filters as $f) { + if (!isset($f['field']) || $f['field'] != $field) { + $kept[] = $f; + } + } + return $kept; + } + function remove_filter($i) { unset($this->filters[$i]); $this->save(); @@ -221,11 +281,11 @@ function delete() { function save() { if ($this->id) { - db_execute_prepare('UPDATE `reports` SET `owner` = ?, `name` = ?, `created` = ?, `filters` = ?, `columns` = ?, `sortc` = ?, `sortd` = ? WHERE `id` = ?', - array($this->owner, $this->name, $this->created, base64_encode(serialize($this->filters)), base64_encode(serialize($this->columns)), $this->sortc, $this->sortd, $this->id)); + db_execute_prepare('UPDATE `reports` SET `owner` = ?, `name` = ?, `created` = ?, `filters` = ?, `columns` = ?, `sortc` = ?, `sortd` = ?, `type` = ? WHERE `id` = ?', + array($this->owner, $this->name, $this->created, base64_encode(serialize($this->filters)), base64_encode(serialize($this->columns)), $this->sortc, $this->sortd, $this->type, $this->id)); } else { - $id = db_execute_prepare('INSERT INTO `reports` (`owner`, `name`, `created`, `filters`, `columns`, `sortc`, `sortd`) VALUES (?, ?, ?, ?, ?, ?, ?)', - array($this->owner, $this->name, $this->created, base64_encode(serialize($this->filters)),base64_encode(serialize($this->columns)), $this->sortc, $this->sortd)); + $id = db_execute_prepare('INSERT INTO `reports` (`owner`, `name`, `created`, `filters`, `columns`, `sortc`, `sortd`, `type`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + array($this->owner, $this->name, $this->created, base64_encode(serialize($this->filters)),base64_encode(serialize($this->columns)), $this->sortc, $this->sortd, $this->type)); $this->id = $id; db_execute_prepare('INSERT INTO `reports_perms` (`report`, `user`, `role`) VALUES (?, ?, ?)', array($this->id, $this->owner, 'owner')); diff --git a/src/includes/db_upgrade.php b/src/includes/db_upgrade.php index 29c7da9..19aea09 100644 --- a/src/includes/db_upgrade.php +++ b/src/includes/db_upgrade.php @@ -1,6 +1,6 @@ 'time', + 'Host' => 'hostname', + 'Job Template' => 'template', + 'Playbook' => 'playbook', + 'Role' => 'role', + 'Task' => 'task', + 'Module' => 'task_action', + ); +} + +/* + The fields a changes report can be filtered on. These are the filter menus of the changes + page, plus a time window, which is what makes a scheduled report useful: "the changes of the + last 24 hours" rather than everything ever recorded. +*/ +function changes_report_filter_fields() { + return array( + 'host' => 'Host', + 'template' => 'Job Template', + 'playbook' => 'Playbook', + 'role' => 'Role', + 'module' => 'Module', + 'type' => 'Job Type', + 'inventory' => 'Inventory', + 'project' => 'Project', + 'search' => 'Search', + 'hours' => 'Last Hours', + ); +} + +/* + The filter fields whose value is an id or a count rather than a name. +*/ +function changes_report_numeric_filter_fields() { + return array('host', 'template', 'inventory', 'project', 'hours'); +} + +/* + Turns the filters of a changes report into a WHERE clause and its parameters. The comparisons + are the ones the changes page makes, with the values bound rather than interpolated. +*/ +function build_changes_filter($filters) { + $where = array(); + $p = array(); + + foreach ($filters as $f) { + if (!isset($f['field']) || !isset($f['value'])) { + continue; + } + + switch ($f['field']) { + case 'host': + $where[] = '`changes`.`host` = ?'; + $p[] = intval($f['value']); + break; + case 'template': + $where[] = '`jobs`.`job_template_id` = ?'; + $p[] = intval($f['value']); + break; + case 'playbook': + $where[] = '`changes`.`playbook` = ?'; + $p[] = $f['value']; + break; + case 'role': + $where[] = '`changes`.`role` = ?'; + $p[] = $f['value']; + break; + case 'module': + $where[] = '`changes`.`task_action` = ?'; + $p[] = $f['value']; + break; + case 'type': + $where[] = '`jobs`.`job_type` = ?'; + $p[] = $f['value']; + break; + case 'inventory': + $where[] = '`jobs`.`inventory` LIKE ?'; + $p[] = '%-' . intval($f['value']); + break; + case 'project': + $where[] = '`jobs`.`project` LIKE ?'; + $p[] = '%-' . intval($f['value']); + break; + case 'search': + foreach (explode(' ', $f['value']) as $s) { + if ($s == '') { + continue; + } + $where[] = '(`changes`.`res` LIKE ? OR `changes`.`task_action` LIKE ? OR `changes`.`task` LIKE ? OR `changes`.`role` LIKE ? OR `changes`.`play` LIKE ?)'; + for ($i = 0; $i < 5; $i++) { + $p[] = '%' . $s . '%'; + } + } + break; + case 'hours': + $where[] = '`changes`.`time` >= ?'; + $p[] = time() - (intval($f['value']) * 3600); + break; + } + } + + if (empty($where)) { + return array('', $p); + } + + return array('WHERE ' . implode(' AND ', $where), $p); +} + +/* + Builds a changes report, returning the same shape as build_report: one row per change, each + holding the cell values of changes_report_columns() in order, so the report and email + templates render both kinds of report the same way. +*/ +function build_changes_report($id, $limit = 1000) { + $id = intval($id); + $report = new Report($id); + $data = array(); + + if (!$report->id || $report->type != 'changes') { + return $data; + } + + $w = build_changes_filter($report->filters); + + $sql = "SELECT `changes`.`id`, `changes`.`time`, `changes`.`playbook`, `changes`.`role`, `changes`.`task`, `changes`.`task_action`, + `hosts`.`hostname`, `jobs`.`name` as `template` + FROM `changes` + LEFT JOIN `jobs` ON `jobs`.`job` = `changes`.`job` + LEFT JOIN `hosts` ON `hosts`.`id` = `changes`.`host` + " . $w[0] . " + ORDER BY `changes`.`time` DESC + LIMIT " . intval($limit); + + $changes = db_fetch_assocs_prepare($sql, $w[1]); + + foreach ($changes as $c) { + $data[$c['id']] = array( + date('m/d/Y H:i', $c['time']), + $c['hostname'], + $c['template'], + $c['playbook'], + $c['role'], + $c['task'], + $c['task_action'], + ); + } + + return $data; +} + +/* + The values the filter menus of a changes report offer, built from the changes that were + actually recorded, the same way the changes page builds its own menus. +*/ +function changes_report_filter_options() { + $hosts = array(); + foreach (db_fetch_assocs('SELECT `id`, `hostname` FROM `hosts` ORDER BY `hostname`') as $h) { + $hosts[$h['id']] = $h['hostname']; + } + + $templates = array(); + foreach (db_fetch_assocs("SELECT DISTINCT `jobs`.`job_template_id`, `jobs`.`name` FROM `changes` LEFT JOIN `jobs` ON `jobs`.`job` = `changes`.`job` ORDER BY `jobs`.`name` ASC") as $t) { + if ($t['job_template_id']) { + $templates[$t['job_template_id']] = $t['name']; + } + } + + $inventories = array(); + foreach (db_fetch_assocs("SELECT DISTINCT `jobs`.`inventory` FROM `changes` LEFT JOIN `jobs` ON `jobs`.`job` = `changes`.`job` ORDER BY `jobs`.`inventory` ASC") as $i) { + $i = explode('-', $i['inventory']); + $id = array_pop($i); + if (intval($id)) { + $inventories[$id] = implode('-', $i); + } + } + + $projects = array(); + foreach (db_fetch_assocs("SELECT DISTINCT `jobs`.`project` FROM `changes` LEFT JOIN `jobs` ON `jobs`.`job` = `changes`.`job` ORDER BY `jobs`.`project` ASC") as $i) { + $i = explode('-', $i['project']); + $id = array_pop($i); + if (intval($id)) { + $projects[$id] = implode('-', $i); + } + } + + return array( + 'host' => $hosts, + 'template' => $templates, + 'playbook' => changes_report_option_list("SELECT DISTINCT `playbook` FROM `changes` ORDER BY `playbook` ASC", 'playbook'), + 'role' => changes_report_option_list("SELECT DISTINCT `role` FROM `changes` ORDER BY `role` ASC", 'role'), + 'module' => changes_report_option_list("SELECT DISTINCT `task_action` FROM `changes` ORDER BY `task_action` ASC", 'task_action'), + 'type' => array('run' => 'Run Mode', 'check' => 'Check Mode'), + 'inventory' => $inventories, + 'project' => $projects, + ); +} + +/* + Reads a column of distinct values into an option list where the value and the label are the + same, which is how the changes table stores playbooks, roles and modules. +*/ +function changes_report_option_list($sql, $column) { + $options = array(); + foreach (db_fetch_assocs($sql) as $row) { + if ($row[$column] != '') { + $options[$row[$column]] = $row[$column]; + } + } + return $options; +} diff --git a/src/reports.php b/src/reports.php index 06a9886..321d323 100644 --- a/src/reports.php +++ b/src/reports.php @@ -49,43 +49,55 @@ exit; case 'deletefilter': if ($report->owner == $account['id'] || $report->role == 'edit' || $account['super']) { - $filter = intval($_GET['filter']); - $report->remove_filter($filter); + if ($report->type == 'changes') { + if (isset($_GET['field'])) { + $report->remove_changes_filter($_GET['field']); + } + } else { + $filter = intval($_GET['filter']); + $report->remove_filter($filter); + } } Header("Location: /reports/edit/" . $report->id . "\n\n"); exit; case 'addfilter': if ($report->owner == $account['id'] || $report->role == 'edit' || $account['super']) { - $value = $_POST['value']; - $compare = $_POST['compare']; - $fact = $_POST['fact']; - $report->add_filter($fact, $compare, $value); + if ($report->type == 'changes') { + if (isset($_POST['field']) && isset($_POST['value'])) { + $report->add_changes_filter($_POST['field'], $_POST['value']); + } + } else { + $value = $_POST['value']; + $compare = $_POST['compare']; + $fact = $_POST['fact']; + $report->add_filter($fact, $compare, $value); + } } Header("Location: /reports/edit/" . $report->id . "\n\n"); exit; case 'moveup': - if ($report->owner == $account['id'] || $report->role == 'edit' || $account['super']) { + if (($report->owner == $account['id'] || $report->role == 'edit' || $account['super']) && $report->type != 'changes') { $fact = intval($_GET['fact']); $report->move_column_up($fact); } Header("Location: /reports/edit/" . $report->id . "\n\n"); exit; case 'movedown': - if ($report->owner == $account['id'] || $report->role == 'edit' || $account['super']) { + if (($report->owner == $account['id'] || $report->role == 'edit' || $account['super']) && $report->type != 'changes') { $fact = intval($_GET['fact']); $report->move_column_down($fact); } Header("Location: /reports/edit/" . $report->id . "\n\n"); exit; case 'deletefact': - if ($report->owner == $account['id'] || $report->role == 'edit' || $account['super']) { + if (($report->owner == $account['id'] || $report->role == 'edit' || $account['super']) && $report->type != 'changes') { $fact = intval($_GET['fact']); $report->remove_column($fact); } Header("Location: /reports/edit/" . $report->id . "\n\n"); exit; case 'addcolumn': - if ($report->owner == $account['id'] || $report->role == 'edit' || $account['super']) { + if (($report->owner == $account['id'] || $report->role == 'edit' || $account['super']) && $report->type != 'changes') { $display = $report->clean_column($_POST['display']); if ($display != '') { $facts = (isset($_POST['facts']) ? $_POST['facts'] : array()); @@ -172,11 +184,26 @@ } exit; case 'view': - $data = build_report ($report->id); + if ($report->type == 'changes') { + $data = build_changes_report($report->id); + } else { + $data = build_report($report->id); + } echo $twig->render('report.html', array_merge($twigarr, array('report' => $report, 'data' => $data, 'filters' => $report->filters, 'columns' => $report->columns, 'sortc' => $report->sortc, 'sortd' => $report->sortd))); exit; case 'edit': if ($report->owner == $account['id'] || $report->role == 'edit' || $account['super']) { + if ($report->type == 'changes') { + $current = array(); + foreach ($report->filters as $f) { + if (isset($f['field'])) { + $current[$f['field']] = $f['value']; + } + } + echo $twig->render('report_changes_edit.html', array_merge($twigarr, array('report' => $report, 'current' => $current, + 'columns' => $report->columns, 'fields' => changes_report_filter_fields(), 'options' => changes_report_filter_options()))); + exit; + } $allfacts = db_fetch_assocs('SELECT DISTINCT `fact` FROM facts'); $facts = array(); foreach ($allfacts as $f) { @@ -193,10 +220,17 @@ } else { switch ($_REQUEST['action']) { case 'new': + $type = (isset($_REQUEST['type']) ? $_REQUEST['type'] : 'facts'); $report = new Report(); $report->set_owner($account['id']); $report->set_created(time()); - $report->set_name('New Report - ' . $account['name']); + $report->set_type($type); + if ($report->type == 'changes') { + $report->set_name('New Changes Report - ' . $account['name']); + $report->set_columns(changes_report_columns()); + } else { + $report->set_name('New Report - ' . $account['name']); + } $report->save(); Header("Location: /reports/edit/" . $report->id . "\n\n"); exit; diff --git a/src/templates/parts/reports_table_body_tr.html b/src/templates/parts/reports_table_body_tr.html index 01c6297..487c41a 100644 --- a/src/templates/parts/reports_table_body_tr.html +++ b/src/templates/parts/reports_table_body_tr.html @@ -11,6 +11,7 @@ {% endif %}
A changes report lists the same fields as the Changes page, narrowed by the filters set here. + Set Last Hours to report on a window rather than on everything recorded, which is what a + scheduled report normally wants.
+ +