diff --git a/README.md b/README.md index 07cdd4e..87b3f42 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,10 @@ -# Overview - -![](https://pixlcore.com/software/performa/screenshots/light-dark.png) - -**Performa** is a multi-server monitoring system with a web based front-end UI. It can monitor CPU, memory, disk, network, and of course your own custom metrics. Alerts can be configured to trigger on any expression, and send e-mails or fire web hooks. Timeline data can be stored on local disk or in Amazon S3. - -## Features at a Glance - -- Easy to install, configure and run -- Monitor any number of servers -- New servers are added to the system automatically -- Assign servers to groups manually or automatically -- Supports ephemeral servers (serverless, autoscale, etc.) -- Metrics are collected every minute -- Multiple graph scales: hourly, daily, monthly, yearly -- Real-time views with auto-refreshing graphs -- View graphs for individual servers or entire groups -- Add custom commands for gathering your own metrics -- Alerts with custom trigger expressions -- Alert e-mails and web hooks for notification -- Alert snooze feature to silence notifications -- One click snapshot-to-URL-to-clipboard for graphs -- Graph data can be kept indefinitely or auto-expired -- Light and dark themes for the UI - -## Screenshots - -
See Screenshots - -![](https://pixlcore.com/software/performa/screenshots/overview.png) - -![](https://pixlcore.com/software/performa/screenshots/group-detail.png) - -![](https://pixlcore.com/software/performa/screenshots/group-detail-2.png) - -![](https://pixlcore.com/software/performa/screenshots/server-detail-light.png) - -![](https://pixlcore.com/software/performa/screenshots/monitor-list.png) - -![](https://pixlcore.com/software/performa/screenshots/activity-log.png) - -![](https://pixlcore.com/software/performa/screenshots/edit-command.png) - -
- -## Table of Contents +
Table of Contents -* [Glossary](#glossary) +- [Overview](#overview) + * [Features at a Glance](#features-at-a-glance) + * [Screenshots](#screenshots) + * [Glossary](#glossary) - [Installation](#installation) - [Setup](#setup) - [Configuration](#configuration) @@ -86,6 +44,7 @@ + [Built-in Alerts](#built-in-alerts) + [Alert Web Hooks](#alert-web-hooks) * [Commands](#commands) + * [Snapshots](#snapshots) - [Command Line](#command-line) * [Starting and Stopping](#starting-and-stopping) * [Storage Maintenance](#storage-maintenance) @@ -99,6 +58,56 @@ * [Starting in Debug Mode](#starting-in-debug-mode) - [License](#license) +
+ +# Overview + +![](https://pixlcore.com/software/performa/screenshots/light-dark.png) + +**Performa** is a multi-server monitoring system with a web based front-end UI. It can monitor CPU, memory, disk, network, and of course your own custom metrics. Alerts can be configured to trigger on any expression, and send e-mails or fire web hooks. Timeline data can be stored on local disk or in Amazon S3. + +## Features at a Glance + +- Easy to install, configure and run +- Monitor any number of servers +- New servers are added to the system automatically +- Assign servers to groups manually or automatically +- Supports ephemeral servers (serverless, autoscale, etc.) +- Metrics are collected every minute +- Multiple graph scales: hourly, daily, monthly, yearly +- Real-time views with auto-refreshing graphs +- View graphs for individual servers or entire groups +- Add custom commands for graphing your own metrics +- Alerts with custom trigger expressions +- Alert e-mails and web hooks for notification +- Alert snooze feature to silence notifications +- Snapshot feature provides extra server details +- One click snapshot-to-URL-to-clipboard for graphs +- Graph data can be kept indefinitely or auto-expired +- Light and dark themes for the UI + +## Screenshots + +
See Screenshots + +![](https://pixlcore.com/software/performa/screenshots/overview.png) + +![](https://pixlcore.com/software/performa/screenshots/group-detail.png) + +![](https://pixlcore.com/software/performa/screenshots/group-detail-2.png) + +![](https://pixlcore.com/software/performa/screenshots/server-detail-light.png) + +![](https://pixlcore.com/software/performa/screenshots/snapshot-view.png) + +![](https://pixlcore.com/software/performa/screenshots/monitor-list.png) + +![](https://pixlcore.com/software/performa/screenshots/activity-log.png) + +![](https://pixlcore.com/software/performa/screenshots/edit-command.png) + +
+ ## Glossary A quick introduction to some common terms used in Performa: @@ -112,6 +121,7 @@ A quick introduction to some common terms used in Performa: | **API Key** | A special key that can be used by external apps to send API requests into Performa. | | **User** | A human user account, which has a username and a password. Passwords are salted and hashed with [bcrypt](https://en.wikipedia.org/wiki/Bcrypt). | | **Satellite** | Our headless companion product, which silently collects metrics on your servers and sends them to the master server. See [Performa Satellite](#performa-satellite) below. | +| **Snapshot** | A snapshot is a detailed record of everything happening on a server, including all processes and network sockets. Snapshots are taken when alerts trigger, and when being watched. See [Snapshots](#snapshots) below. | # Installation @@ -477,7 +487,7 @@ You can include any property from the main `conf/config.json` file by using the # Performa Satellite -Performa Satellite is our headless companion product, which silently collects metrics on your servers and sends them to the Performa master server. It has no dependencies and ships as a precompiled binary (for Linux and macOS), so it will be compatible with a wide range of systems. It does not run as a daemon, but instead launches via [cron](https://en.wikipedia.org/wiki/Cron) every minute, then exits. It uses about 25 MB of RAM while it is active. +Performa Satellite is our headless companion product, which silently collects metrics on your servers and sends them to the Performa master server. It has no dependencies and ships as a precompiled binary (for Linux and macOS), so it will be compatible with a wide range of systems. It does not run as a daemon, but instead launches via [cron](https://en.wikipedia.org/wiki/Cron) every minute, then exits. It uses about 25 MB of RAM while it is active (usually only a few seconds per minute). For more information about Performa Satellite, including installation and configuration instructions, please see the [Performa Satellite Github Repo](https://github.com/jhuckaby/performa-satellite). @@ -714,14 +724,27 @@ Each command has the following properties: | **Title** | A display title (label) for the alert, shown in the UI. | | **Enabled** | A checkbox denoting whether the command is enabled (will be executed) or disabled (skipped). | | **Groups** | A list of which server groups the command should be executed on (or you can select "all" groups). | -| **Executable** | The executable command to run (e.g. `/bin/sh` or `/usr/bin/python`). | +| **Executable** | The executable command to run (e.g. `/bin/sh`, `/usr/bin/python` or other). | | **Script** | The script source code to pipe to the command (i.e. shell commands or other). | | **Format** | If your command outputs JSON or XML, you can have this parsed for easier integration with monitors / alerts. | -| **User ID** | Optionally run your command as a different user (i.e. for security). | +| **User ID** | Optionally run your command as a different user on the server (i.e. for security purposes). | | **Notes** | A notes text field is provided for your own internal use. | If your command outputs raw text, you can use a regular expression to match the specific metric value (this is configured per each monitor that refers to the command). Or, if your command happens to output JSON or XML, then Performa can parse it, and provide more structured access in the [Data Source](#data-sources) system. +## Snapshots + +A snapshot is a detailed report of everything happening on a server, including all the information we collect every minute (i.e. CPU, memory stats), but also: + +- A detailed list of all processes running on the server, including PIDs, commands, CPU and memory usage. +- A detailed list of all network connections, their source and destination IP addresses and ports, and each connection state. +- A list of all socket listeners, including which protocol, interface and port number. +- A list of all filesystem mounts and their usage and free space. + +Performa automatically takes a snapshot of a server whenever any alert is triggered. This allows you to come back later to see exactly what was happening at the time of the alert (i.e. which processes and connections were open). You can access snapshots from the alert e-mail notification, as well as the "Snapshots" tab in the UI. + +In addition to automatic snapshots, you can "watch" any server or group for any amount of time. Setting a "watch" on a server or group means that it will generate a snapshot every minute for the duration of the watch timer. You can set watches by clicking the "Watch Server..." or "Watch Group..." buttons in the UI. + # Command Line Here are all the Performa services available to you on the command line. Most of these are accessed via the following shell script: diff --git a/htdocs/css/style.css b/htdocs/css/style.css index 4db9684..0eb7455 100644 --- a/htdocs/css/style.css +++ b/htdocs/css/style.css @@ -19,6 +19,10 @@ div.container { /* color: var(--header-text-color); */ } +#d_footer { + margin: 12px 0px 17px 0px; +} + /* Menus */ .tab_widget { @@ -244,6 +248,10 @@ fieldset.dt_fs > legend { /* Table Pagination Spacing Fix */ +fieldset div.pagination { + margin-top: 0; +} + div.pagination > table tr td { white-space: nowrap; } @@ -348,6 +356,10 @@ fieldset.inline_error { font-size: 12px; } +#fe_ctrl_server, #fe_ctrl_group { + max-width: 200px; +} + .chart_size_icon { font-size: 18px; cursor: pointer; @@ -558,6 +570,7 @@ body.dark .apexcharts-xaxistooltip { top: 0; left: 0; z-index: 2; + cursor: default; } .server_pie_container, .server_pie_graph, .server_pie_overlay { @@ -591,6 +604,17 @@ body.dark .apexcharts-xaxistooltip { text-overflow: ellipsis; } +.server_pie_widget { + position: absolute; + top: 0; + left: 0; + z-index: 2; + width: 180px; + height: 20px; + line-height: 20px; + text-align: right; +} + /* Group Tab */ div.priv_group_admin, div.priv_group_other { @@ -624,3 +648,37 @@ fieldset.overview_group > legend { font-size: 12px; color: var(--header-text-color); } + +/* Snapshot Tab */ + +.inline_table_scrollarea { + position: relative; + max-height: 75vh; + overflow-x: hidden; + overflow-y: scroll; +} + +table.fieldset_table tr th.st_col_header { + white-space: nowrap; + font-weight: bold; + cursor: pointer; +} +table.fieldset_table tr th.st_col_header:hover { + color: var(--highlight-color); + text-decoration: underline; +} +table.fieldset_table tr th.st_col_header.active { + color: var(--highlight-color); +} + +.percent_bar_container { + position: relative; + background-color: var(--border-color); + height: 15px; +} + +.percent_bar_inner { + box-sizing: border-box; + border-right: 1px solid var(--header-text-color); + height: 15px; +} diff --git a/htdocs/index-dev.html b/htdocs/index-dev.html index b7fe4c7..2525b22 100755 --- a/htdocs/index-dev.html +++ b/htdocs/index-dev.html @@ -32,14 +32,15 @@
- +
'; + return html; + }, + + getCPUTableHTML: function(cpus) { + // render HTML for CPU detail table + var self = this; + var html = ''; + html += 'CPU Details'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + var cpu_list = []; + for (var idx = 0, len = num_keys(cpus); idx < len; idx++) { + var key = 'cpu' + idx; + if (cpus[key]) cpu_list.push( cpus[key] ); + } + + cpu_list.forEach( function(cpu, idx) { + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + var total = 100 - (cpu.idle || 0); + html += ''; + html += ''; + }); + + html += '
CPU #System %User %Nice %I/O Wait %Hard IRQ %Soft IRQ %Total %
#' + Math.floor( idx + 1 ) + '' + pct( cpu.system || 0, 100 ) + '' + pct( cpu.user || 0, 100 ) + '' + pct( cpu.nice || 0, 100 ) + '' + pct( cpu.iowait || 0, 100 ) + '' + pct( cpu.irq || 0, 100 ) + '' + pct( cpu.softirq || 0, 100 ) + '' + self.getPercentBarHTML( total / 100, 200 ) + '
'; + return html; } } ); diff --git a/htdocs/js/pages/Group.class.js b/htdocs/js/pages/Group.class.js index c4c5f07..86a7ff8 100644 --- a/htdocs/js/pages/Group.class.js +++ b/htdocs/js/pages/Group.class.js @@ -115,6 +115,12 @@ Class.subclass( Page.Base, "Page.Group", { var html = ''; // html += '

' + this.group.title + '

'; + html += '
'; + html += ' ' + this.group.title + ""; + html += '
 Watch Group...
'; + html += '
 Take Snapshot
'; + html += '
'; + html += '
'; // insert alerts and server list table here // (will be populated later) @@ -676,6 +682,7 @@ Class.subclass( Page.Base, "Page.Group", { html += 'Detail'; html += 'Trigger'; html += 'Date/Time'; + html += 'Actions'; html += ''; all_alerts.forEach( function(alert) { @@ -691,6 +698,10 @@ Class.subclass( Page.Base, "Page.Group", { html += '' + alert.message + ''; html += '' + alert_def.expression + ''; html += '' + get_nice_date_time( alert.date ) + ''; + + var snap_id = alert.hostname + '/' + Math.floor( alert.date / 60 ); + html += 'View Snapshot'; + html += ''; }); @@ -711,7 +722,8 @@ Class.subclass( Page.Base, "Page.Group", { // group info table: fs_group_info var extra_server_info = config.extra_server_info; var html = ''; - html += '' + this.group.title +''; + // html += '' + this.group.title +''; + html += 'Group Members'; html += ''; html += ''; html += ''; @@ -882,6 +894,93 @@ Class.subclass( Page.Base, "Page.Group", { } }, + editGroupWatch: function() { + // open group watch dialog + var self = this; + var args = this.args; + var html = ''; + var watch_sel = 0; + var state = config.state; + var hostnames = this.hosts.map( function(host) { return host.hostname; } ); + + var watch_items = [ + [0, "(Disable Watch)"], + app.getTimeMenuItem( 60 ), + app.getTimeMenuItem( 60 * 5 ), + app.getTimeMenuItem( 60 * 10 ), + app.getTimeMenuItem( 60 * 15 ), + app.getTimeMenuItem( 60 * 30 ), + app.getTimeMenuItem( 60 * 45 ), + app.getTimeMenuItem( 3600 ), + app.getTimeMenuItem( 3600 * 2 ), + app.getTimeMenuItem( 3600 * 3 ), + app.getTimeMenuItem( 3600 * 6 ), + app.getTimeMenuItem( 3600 * 12 ), + app.getTimeMenuItem( 86400 ), + app.getTimeMenuItem( 86400 * 2 ), + app.getTimeMenuItem( 86400 * 3 ), + app.getTimeMenuItem( 86400 * 7 ), + app.getTimeMenuItem( 86400 * 15 ), + app.getTimeMenuItem( 86400 * 30 ) + ]; + + html += '
Use the menu below to optionally set watch timers on all current servers in the group. This will generate snapshots every minute until the timer expires.
'; + watch_sel = 3600; + + html += '
Hostname
' + + // get_form_table_spacer() + + get_form_table_row('Watch For:', '') + + get_form_table_caption("Select the duration for the group watch.") + + '
'; + + app.confirm( ' Watch Group', html, "Set Watch", function(result) { + app.clearError(); + + if (result) { + var watch_time = parseInt( $('#fe_watch_time').val() ); + var watch_date = time_now() + watch_time; + Dialog.hide(); + + app.api.post( 'app/watch', { hostnames: hostnames, date: watch_date }, function(resp) { + // update local state and show message + if (!state.watches) state.watches = {}; + + if (watch_time) { + app.showMessage('success', "Group will be watched for " + get_text_from_seconds(watch_time, false, true) + "."); + hostnames.forEach( function(hostname) { + state.watches[ hostname ] = watch_date; + }); + } + else { + app.showMessage('success', "Group watch has been disabled."); + hostnames.forEach( function(hostname) { + delete state.watches[ hostname ]; + }); + } + + } ); // api.post + } // user clicked set + } ); // app.confirm + }, + + takeSnapshot: function() { + // take a snapshot (i.e. 1 minute watch) + var args = this.args; + var state = config.state; + var watch_time = 60; + var watch_date = time_now() + watch_time; + var hostnames = this.hosts.map( function(host) { return host.hostname; } ); + + app.api.post( 'app/watch', { hostnames: hostnames, date: watch_date }, function(resp) { + // update local state and show message + if (!state.watches) state.watches = {}; + app.showMessage('success', 'Your snapshot(s) will be taken within a minute, and appear on the Snapshots tab.'); + hostnames.forEach( function(hostname) { + state.watches[ hostname ] = watch_date; + }); + } ); // api.post + }, + onThemeChange: function(theme) { // user has changed theme, update graphs if (this.graphs) { diff --git a/htdocs/js/pages/Home.class.js b/htdocs/js/pages/Home.class.js index dbe818e..2261da6 100644 --- a/htdocs/js/pages/Home.class.js +++ b/htdocs/js/pages/Home.class.js @@ -413,6 +413,7 @@ Class.subclass( Page.Base, "Page.Home", { html += 'Detail'; html += 'Trigger'; html += 'Date/Time'; + html += 'Actions'; html += ''; all_alerts.forEach( function(alert) { @@ -430,6 +431,10 @@ Class.subclass( Page.Base, "Page.Home", { html += '' + alert.message + ''; html += '' + alert_def.expression + ''; html += '' + get_nice_date_time( alert.date ) + ''; + + var snap_id = alert.hostname + '/' + Math.floor( alert.date / 60 ); + html += 'View Snapshot'; + html += ''; }); diff --git a/htdocs/js/pages/Server.class.js b/htdocs/js/pages/Server.class.js index 1314dd1..3326359 100644 --- a/htdocs/js/pages/Server.class.js +++ b/htdocs/js/pages/Server.class.js @@ -107,12 +107,19 @@ Class.subclass( Page.Base, "Page.Server", { }); var html = ''; - // html += '

' + this.args.hostname + '

'; + // html += '

' + app.formatHostname(args.hostname) + '

'; + html += '
'; + html += ' ' + app.formatHostname(args.hostname) + ""; + html += '
 Watch Server...
'; + html += '
 Take Snapshot
'; + html += '
'; + html += '
'; // insert alerts and server info here // (will be populated later) html += ''; html += ''; + html += ''; html += '
'; @@ -397,6 +404,7 @@ Class.subclass( Page.Base, "Page.Server", { '
' + pie.title + '
' + '
' + pie.subtitle + '
' ); + $overlay.attr('title', pie.tooltip || ''); if (pie.value > pie.max) pie.value = pie.max; else if (pie.value < 0) pie.value = 0; @@ -459,6 +467,7 @@ Class.subclass( Page.Base, "Page.Server", { var $overlay = $cont.find('div.server_pie_overlay'); $overlay.find('.pie_overlay_subtitle').html( pie.subtitle ); + $overlay.attr('title', pie.tooltip || ''); if (pie.value > pie.max) pie.value = pie.max; else if (pie.value < 0) pie.value = 0; @@ -510,6 +519,7 @@ Class.subclass( Page.Base, "Page.Server", { html += 'Detail'; html += 'Trigger'; html += 'Date/Time'; + html += 'Actions'; html += ''; all_alerts.forEach( function(alert) { @@ -524,6 +534,9 @@ Class.subclass( Page.Base, "Page.Server", { html += '' + alert.message + ''; html += '' + alert_def.expression + ''; html += '' + get_nice_date_time( alert.date ) + ''; + + var snap_id = alert.hostname + '/' + Math.floor( alert.date / 60 ); + html += 'View Snapshot'; html += ''; }); @@ -550,31 +563,53 @@ Class.subclass( Page.Base, "Page.Server", { delete this.disk_graph; } this.div.find('#fs_server_info').empty().hide(); + this.div.find('#fs_server_cpus').empty().hide(); return; } + var cpu_tooltip = ''; + var mem_tooltip = ''; + var disk_tooltip = ''; + + if (metadata.data.load) { + var nice_load = metadata.data.load.map( function(num) { return short_float_str(num); } ).join(', '); + cpu_tooltip = "Load Averages: " + nice_load; + } + if (metadata.data.memory) { + var mem = metadata.data.memory; + mem_tooltip = get_text_from_bytes(mem.used) + " of " + get_text_from_bytes(mem.total) + " in use, " + get_text_from_bytes(mem.available) + " available (" + get_text_from_bytes(mem.free) + " free)"; + } + if (metadata.data.mounts && metadata.data.mounts.root) { + var root_mount = metadata.data.mounts.root; + var avail_bytes = Math.max(0, root_mount.size - root_mount.used); + disk_tooltip = get_text_from_bytes(root_mount.used) + " of " + get_text_from_bytes(root_mount.size) + " in use, " + get_text_from_bytes(avail_bytes) + " available"; + } + // server info table: fs_server_info if (this.cpu_graph) { // update existing graphs, do not redraw this.updatePie( this.cpu_graph, { id: 'd_server_pie_cpu', - subtitle: short_float(metadata.data.load ? metadata.data.load[0] : 0), + subtitle: short_float_str(metadata.data.load ? metadata.data.load[0] : 0), value: metadata.data.load ? metadata.data.load[0] : 0, - max: metadata.data.cpu ? metadata.data.cpu.cores : 0 + max: metadata.data.cpu ? metadata.data.cpu.cores : 0, + tooltip: cpu_tooltip }); this.updatePie( this.mem_graph, { id: 'd_server_pie_mem', subtitle: get_text_from_bytes(metadata.data.memory.used || 0), value: metadata.data.memory.used || 0, - max: metadata.data.memory.total || 0 + max: metadata.data.memory.total || 0, + tooltip: mem_tooltip }); this.updatePie( this.disk_graph, { id: 'd_server_pie_disk', subtitle: pct( metadata.data.mounts.root.use, 100, false ), value: metadata.data.mounts.root.use || 0, - max: 100 + max: 100, + tooltip: disk_tooltip }); // uptime may change @@ -586,12 +621,12 @@ Class.subclass( Page.Base, "Page.Server", { html += 'Current Server Info'; // flex (god help me) - html += '
'; + html += '
'; // column 1 (info) html += '
'; html += '
Hostname
'; - html += '
' + app.formatHostname(args.hostname) + '
'; + html += '
' + args.hostname + '
'; html += '
IP Address
'; html += '
' + (metadata.ip || 'n/a') + '
'; @@ -611,7 +646,8 @@ Class.subclass( Page.Base, "Page.Server", { // metadata.ip html += '
Group Membership
'; - html += '
' + this.getNiceGroup(group_def, '#Group' + compose_query_string(query)) + '
'; + // html += '
' + this.getNiceGroup(group_def, '#Group' + compose_query_string(query)) + '
'; + html += '
' + this.getNiceGroup(group_def, false) + '
'; var nice_cores = 'n/a'; if (metadata.data.cpu && metadata.data.cpu.cores) { @@ -690,9 +726,10 @@ Class.subclass( Page.Base, "Page.Server", { this.cpu_graph = this.createPie({ id: 'd_server_pie_cpu', title: 'Load', - subtitle: short_float(metadata.data.load ? metadata.data.load[0] : 0), + subtitle: short_float_str(metadata.data.load ? metadata.data.load[0] : 0), value: metadata.data.load ? metadata.data.load[0] : 0, - max: metadata.data.cpu ? metadata.data.cpu.cores : 0 + max: metadata.data.cpu ? metadata.data.cpu.cores : 0, + tooltip: cpu_tooltip }); this.mem_graph = this.createPie({ @@ -700,7 +737,8 @@ Class.subclass( Page.Base, "Page.Server", { title: 'Mem', subtitle: get_text_from_bytes(metadata.data.memory.used || 0), value: metadata.data.memory.used || 0, - max: metadata.data.memory.total || 0 + max: metadata.data.memory.total || 0, + tooltip: mem_tooltip }); this.disk_graph = this.createPie({ @@ -708,13 +746,25 @@ Class.subclass( Page.Base, "Page.Server", { title: 'Disk', subtitle: pct( metadata.data.mounts.root.use, 100, false ), value: metadata.data.mounts.root.use || 0, - max: 100 + max: 100, + tooltip: disk_tooltip }); } else { // not real-time, hide entire fieldset this.div.find('#fs_server_info').empty().hide(); } + + // cpu details + if (this.isRealTime() && metadata.data.cpu.cpus && num_keys(metadata.data.cpu.cpus)) { + this.div.find('#fs_server_cpus').html( + this.getCPUTableHTML( metadata.data.cpu.cpus ) + ).show(); + } + else { + // not real-time or no cpu details, hide entire fieldset + this.div.find('#fs_server_cpus').empty().hide(); + } }, applyMonitorFilter: function(initial) { @@ -754,6 +804,93 @@ Class.subclass( Page.Base, "Page.Server", { } }, + editServerWatch: function() { + // open server watch dialog + var self = this; + var args = this.args; + var html = ''; + var watch_sel = 0; + var state = config.state; + + var watch_items = [ + [0, "(Disable Watch)"], + app.getTimeMenuItem( 60 ), + app.getTimeMenuItem( 60 * 5 ), + app.getTimeMenuItem( 60 * 10 ), + app.getTimeMenuItem( 60 * 15 ), + app.getTimeMenuItem( 60 * 30 ), + app.getTimeMenuItem( 60 * 45 ), + app.getTimeMenuItem( 3600 ), + app.getTimeMenuItem( 3600 * 2 ), + app.getTimeMenuItem( 3600 * 3 ), + app.getTimeMenuItem( 3600 * 6 ), + app.getTimeMenuItem( 3600 * 12 ), + app.getTimeMenuItem( 86400 ), + app.getTimeMenuItem( 86400 * 2 ), + app.getTimeMenuItem( 86400 * 3 ), + app.getTimeMenuItem( 86400 * 7 ), + app.getTimeMenuItem( 86400 * 15 ), + app.getTimeMenuItem( 86400 * 30 ) + ]; + + if (state.watches && state.watches[args.hostname] && (state.watches[args.hostname] > time_now())) { + // watch is currently enabled + html += '
A watch is currently enabled on this server, and will be until ' + get_nice_date_time(state.watches[args.hostname], false, false) + ' (approximately ' + get_text_from_seconds(state.watches[args.hostname] - time_now(), false, true) + ' from now). Use the menu below to reset the watch, or disable it entirely.
'; + watch_sel = 0; + } + else { + // watch is disabled + html += '
This server is not currently being watched. Use the menu below to optionally set a watch timer, which will generate snapshots every minute until the timer expires.
'; + watch_sel = 3600; + } + + html += '
' + + // get_form_table_spacer() + + get_form_table_row('Watch For:', '') + + get_form_table_caption("Select the duration for the server watch.") + + '
'; + + app.confirm( ' Watch Server', html, "Set Watch", function(result) { + app.clearError(); + + if (result) { + var watch_time = parseInt( $('#fe_watch_time').val() ); + var watch_date = time_now() + watch_time; + Dialog.hide(); + + app.api.post( 'app/watch', { hostnames: [args.hostname], date: watch_date }, function(resp) { + // update local state and show message + if (!state.watches) state.watches = {}; + + if (watch_time) { + app.showMessage('success', "Server will be watched for " + get_text_from_seconds(watch_time, false, true) + "."); + state.watches[ args.hostname ] = watch_date; + } + else { + app.showMessage('success', "Server watch has been disabled."); + delete state.watches[ args.hostname ]; + } + + } ); // api.post + } // user clicked set + } ); // app.confirm + }, + + takeSnapshot: function() { + // take a snapshot (i.e. 1 minute watch) + var args = this.args; + var state = config.state; + var watch_time = 60; + var watch_date = time_now() + watch_time; + + app.api.post( 'app/watch', { hostnames: [args.hostname], date: watch_date }, function(resp) { + // update local state and show message + if (!state.watches) state.watches = {}; + app.showMessage('success', 'Your snapshot will be taken within a minute, and appear on the Snapshots tab.'); + state.watches[ args.hostname ] = watch_date; + } ); // api.post + }, + onThemeChange: function(theme) { // user has changed theme, update graphs if (this.graphs) { diff --git a/htdocs/js/pages/Snapshot.class.js b/htdocs/js/pages/Snapshot.class.js new file mode 100644 index 0000000..ba906b6 --- /dev/null +++ b/htdocs/js/pages/Snapshot.class.js @@ -0,0 +1,657 @@ +Class.subclass( Page.Base, "Page.Snapshot", { + + default_sub: 'list', + + onInit: function() { + // called once at page load + var html = ''; + this.div.html( html ); + }, + + onActivate: function(args) { + // page activation + if (!this.requireLogin(args)) return true; + + if (!args) args = {}; + if (args.id) args.sub = 'snapshot'; + if (!args.sub) args.sub = this.default_sub; + this.args = args; + + app.showTabBar(true); + this.showControls(false); + + this.div.addClass('loading'); + this['gosub_'+args.sub](args); + + return true; + }, + + gosub_list: function(args) { + // show snapshot list + app.setWindowTitle( "Snapshot List" ); + + if (!args.offset) args.offset = 0; + if (!args.limit) args.limit = 25; + app.api.post( 'app/get_snapshots', copy_object(args), this.receive_snapshots.bind(this) ); + }, + + receive_snapshots: function(resp) { + // receive page of snapshots from server, render it + var self = this; + var html = ''; + this.div.removeClass('loading'); + + this.snapshots = []; + if (resp.rows) this.snapshots = resp.rows; + + var cols = ['Hostname', 'Date/Time', 'Source', 'Alerts', 'Actions']; + + html += '
'; + + html += '
'; + html += 'Server Snapshot List'; + // html += '
'; + html += '
'; + + if (resp.rows && resp.rows.length) { + html += this.getPaginatedTable( resp, cols, 'snapshot', function(item, idx) { + // { date, hostname, source, alerts, time_code } + var color = ''; + var snap_id = item.hostname + '/' + item.time_code; + var snap_url = '#Snapshot?id=' + snap_id; + var actions = [ 'View Snapshot' ]; + var nice_source = ''; + var nice_alerts = '(None)'; + + switch (item.source) { + case 'alert': nice_source = ' Alert System'; break; + case 'watch': nice_source = ' Server Watch'; break; + } + + if (item.alerts && num_keys(item.alerts)) { + nice_alerts = hash_keys_to_array(item.alerts).sort().map( function(alert_id) { + var alert_def = find_object( config.alerts, { id: alert_id } ) || { + id: alert.id, + title: '(' + alert.id + ')', + expression: 'n/a' + }; + return ' ' + alert_def.title; + }).join(', '); + } + + var tds = [ + '' + self.getNiceHostname( item.hostname, snap_url ) + '', + '
' + get_nice_date_time( item.date || 0, false, false ) + '
', + '
' + nice_source + '
', + // nice_source, + nice_alerts, + '
' + actions.join(' | ') + '
' + ]; + if (color) tds.className = color; + + return tds; + } ); + } + else { + html += '
'; + html += '
No Snapshots Found
'; + html += '
Snapshots are automatically created when an alert is triggered.
You can also request snapshots on any server by starting a  Watch.
'; + html += '
'; + } + + html += '
'; // padding + + this.div.html( html ); + }, + + getNiceHostname: function(hostname, link, width) { + // get formatted hostname with icon, plus custom link + if (!width) width = 500; + if (!hostname) return '(None)'; + + var html = '
'; + var icon = ' '; + if (link) { + html += ''; + html += icon + '' + this.formatHostname(hostname) + ''; + } + else { + html += icon + this.formatHostname(hostname); + } + html += '
'; + + return html; + }, + + gosub_snapshot: function(args) { + // show specific snapshot + var self = this; + var args = this.args; + + app.setWindowTitle( "View Snapshot" ); + + app.api.get( 'app/get_snapshot', args, this.receiveSnapshot.bind(this), function(err) { + self.doInlineError( "Server Error", err.description ); + } ); + }, + + jumpToHistorical: function() { + // jump to historical view for snapshot date and hostname + var hostname = this.metadata.hostname; + var date = this.metadata.date; + var dargs = get_date_args( date ); + Nav.go( '#Server?hostname=' + hostname + '&date=' + dargs.yyyy_mm_dd + '/' + dargs.hh ); + }, + + receiveSnapshot: function(resp) { + // render snapshot data + var self = this; + var args = this.args; + this.div.removeClass('loading'); + this.metadata = resp.metadata; + var metadata = resp.metadata; + var snapshot = metadata.snapshot; + var html = ''; + + this.group = app.findGroupFromHostData( metadata ); + if (!this.group) { + this.group = { id: "(unknown)", title: "(Unknown)" }; + // return this.doInlineError("No matching group found for server: " + this.args.hostname); + } + + html += '
'; + html += ' Server Snapshot: ' + app.formatHostname(metadata.hostname) + " — " + get_nice_date_time( metadata.date ); + html += '
 View Graphs...
'; + html += '
'; + html += '
'; + + // gather alerts from snapshot + var all_alerts = []; + if (metadata.alerts) { + for (var alert_id in metadata.alerts) { + all_alerts.push( + merge_objects( metadata.alerts[alert_id], { + id: alert_id, + hostname: metadata.hostname + } ) + ); + } // foreach alert + } // has alerts + + if (all_alerts.length) { + // build alert table + html += '
'; + html += 'Alerts'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + all_alerts.forEach( function(alert) { + var alert_def = find_object( config.alerts, { id: alert.id } ) || { + id: alert.id, + title: '(' + alert.id + ')', + expression: 'n/a' + }; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + + html += '
AlertHostnameDetailTriggerDate/Time
' + self.getNiceAlert(alert_def, true) + '' + self.getNiceHostname(alert.hostname, false) + '' + alert.message + '' + alert_def.expression + '' + get_nice_date_time( alert.date ) + '
'; + html += '
'; + } + + html += '
'; + html += 'Server Info'; + + // flex (god help me) + html += '
'; + + // column 1 + html += '
'; + html += '
Snapshot Date/Time
'; + html += '
' + get_nice_date_time( metadata.date ) + '
'; + + html += '
Hostname
'; + html += '
' + metadata.hostname + '
'; + + html += '
IP Address
'; + html += '
' + (metadata.ip || 'n/a') + '
'; + + html += '
Group Membership
'; + html += '
' + this.getNiceGroup(this.group, false) + '
'; + html += '
'; + + // column 2 + html += '
'; + var nice_cores = 'n/a'; + if (metadata.data.cpu && metadata.data.cpu.cores) { + if (metadata.data.cpu.physicalCores && (metadata.data.cpu.physicalCores != metadata.data.cpu.cores)) { + nice_cores = metadata.data.cpu.physicalCores + " physical, " + + metadata.data.cpu.cores + " virtual"; + } + else { + nice_cores = metadata.data.cpu.cores; + } + } + html += '
CPU Cores
'; + html += '
' + nice_cores + '
'; + + var nice_cpu_model = 'n/a'; + if (metadata.data.cpu && metadata.data.cpu.manufacturer) { + nice_cpu_model = metadata.data.cpu.manufacturer; + if (metadata.data.cpu.brand) nice_cpu_model += ' ' + metadata.data.cpu.brand; + } + html += '
CPU Type
'; + html += '
' + nice_cpu_model + '
'; + + var clock_ghz = metadata.data.cpu ? metadata.data.cpu.speed : 0; + var nice_clock_speed = '' + clock_ghz + ' GHz'; + if (clock_ghz < 1.0) { + nice_clock_speed = Math.floor(clock_ghz * 1000) + ' MHz'; + } + html += '
CPU Clock
'; + html += '
' + nice_clock_speed + '
'; + + var nice_load = metadata.data.load.map( function(num) { return short_float_str(num); } ).join(', '); + html += '
CPU Load Averages
'; + html += '
' + nice_load + '
'; + html += '
'; + + // column 3 + html += '
'; + html += '
Total RAM
'; + html += '
' + get_text_from_bytes(metadata.data.memory.total || 0) + '
'; + + html += '
Memory in Use
'; + html += '
' + get_text_from_bytes(metadata.data.memory.used || 0) + '
'; + + html += '
Memory Available
'; + html += '
' + get_text_from_bytes(metadata.data.memory.available || 0) + '
'; + + html += '
Memory Free
'; + html += '
' + get_text_from_bytes(metadata.data.memory.free || 0) + '
'; + html += '
'; + + // column 4 + html += '
'; + var socket_states = metadata.data.stats.network.states || {}; + html += '
Socket Listeners
'; + html += '
' + commify( socket_states.listen || 0 ) + '
'; + + html += '
Open Connections
'; + html += '
' + commify( socket_states.established || 0 ) + '
'; + + var num_closed = 0; + if (socket_states.close_wait) num_closed += socket_states.close_wait; + if (socket_states.closed) num_closed += socket_states.closed; + html += '
Closed Connections
'; + html += '
' + commify( num_closed ) + '
'; + + html += '
Total Processes
'; + html += '
' + commify( metadata.data.processes.all || 0 ) + '
'; + html += '
'; + + // column 5 + html += '
'; + var nice_disk = 'n/a'; + var root_mount = metadata.data.mounts.root; + if (root_mount) { + nice_disk = get_text_from_bytes(root_mount.used) + " of " + get_text_from_bytes(root_mount.size) + " (" + root_mount.use + "%)"; + } + html += '
Disk Usage (Root)
'; + html += '
' + nice_disk + '
'; + + var nice_os = 'n/a'; + if (metadata.data.os.distro) { + nice_os = metadata.data.os.distro + ' ' + metadata.data.os.release; // + ' (' + metadata.data.os.arch + ')'; + } + html += '
Operating System
'; + html += '
' + nice_os + '
'; + + var nice_kernel = 'n/a'; + var extra_server_info = config.extra_server_info; + if (extra_server_info.source) { + nice_kernel = substitute(extra_server_info.source, metadata.data, false); + } + html += '
' + extra_server_info.title + '
'; + html += '
' + nice_kernel + '
'; + + html += '
Server Uptime
'; + html += '
' + get_text_from_seconds(metadata.data.uptime_sec || 0, false, true) + '
'; + html += '
'; + + html += '
'; // flex + html += '
'; + + // CPU Details + if (metadata.data.cpu && metadata.data.cpu.cpus) { + html += '
'; + html += this.getCPUTableHTML( metadata.data.cpu.cpus ); + html += '
'; + } + + // Processes + snapshot.processes.list.forEach( function(item) { + var epoch = ((new Date( item.started.replace(/\-/g, '/') )).getTime() || 0) / 1000; + item.age = epoch ? Math.max(0, metadata.date - epoch) : 0; + }); + + var proc_opts = { + id: 't_snap_procs', + item_name: 'process', + sort_by: 'pcpu', + sort_dir: -1, + filter: '', + column_ids: ['pid', 'parentPid', 'user', 'pcpu', 'mem_rss', 'age', 'command'], + column_labels: ["PID", "Parent", "User", "CPU", "Memory", "Age", "Command"] + }; + html += '
'; + html += 'All Processes'; + html += '
'; + html += this.getSortableTable( snapshot.processes.list, proc_opts, function(item) { + return [ + item.pid, + item.parentPid, + item.user, + short_float(item.pcpu) + '%', + get_text_from_bytes( (item.mem_rss || 0) * 1024 ), + get_text_from_seconds( item.age || 0, false, true ), + '' + item.command + '' + ]; + }); + html += '
'; + html += '
'; + + // Connections + snapshot.network.connections.forEach( function(item) { + item.localport = parseInt( item.localport ) || 0; + item.peerport = parseInt( item.peerport ) || 0; + }); + var conn_opts = { + id: 't_snap_conns', + item_name: 'connection', + sort_by: 'peeraddress', + sort_dir: 1, + filter: 'established', + column_ids: ['protocol', 'localaddress', 'localport', 'peeraddress', 'peerport', 'state'], + column_labels: ["Protocol", "Local Address", "Local Port", "Peer Address", "Peer Port", "State"] + }; + html += '
'; + html += 'Network Connections'; + html += '
'; + html += this.getSortableTable( snapshot.network.connections, conn_opts, function(item) { + return [ + item.protocol.toUpperCase(), + item.localaddress, + item.localport, + item.peeraddress, + item.peerport, + item.state + ]; + }); + html += '
'; + html += '
'; + + // Filesystems + var mounts = []; + for (var key in metadata.data.mounts) { + var mount = metadata.data.mounts[key]; + mount.avail = Math.max(0, mount.size - mount.used); + mounts.push( mount ); + } + var fs_opts = { + id: 't_snap_fs', + item_name: 'mount', + sort_by: 'mount', + sort_dir: 1, + filter: '', + column_ids: ['mount', 'type', 'fs', 'size', 'used', 'avail', 'use'], + column_labels: ["Mount Point", "Type", "Device", "Total Size", "Used", "Available", "Use %"] + }; + html += '
'; + html += 'Filesystems'; + html += '
'; + html += this.getSortableTable( mounts, fs_opts, function(item) { + return [ + '' + item.mount + '', + item.type, + item.fs, + get_text_from_bytes( item.size ), + get_text_from_bytes( item.used ), + get_text_from_bytes( item.avail ), + self.getPercentBarHTML( item.use / 100, 200 ) + ]; + }); + html += '
'; + html += '
'; + + this.div.html( html ); + }, + + getSortedTableRows: function(id) { + // get sorted (and filtered!) table rows + var opts = this.tables[id]; + var filter_re = new RegExp( escape_regexp(opts.filter) || '.*', 'i' ); + var sort_by = opts.sort_by; + var sort_dir = opts.sort_dir; + var sort_type = 'number'; + if (opts.rows.length && (typeof(opts.rows[0][sort_by]) == 'string')) sort_type = 'string'; + + // apply filter + var rows = opts.rows.filter( function(row) { + var blob = hash_values_to_array(row).join(' '); + return !!blob.match( filter_re ); + } ); + + // apply custom sort + rows.sort( function(a, b) { + if (sort_type == 'number') { + return( (a[sort_by] - b[sort_by]) * sort_dir ); + } + else { + return( a[sort_by].toString().localeCompare(b[sort_by]) * sort_dir ); + } + }); + + return rows; + }, + + applyTableFilter: function(elem) { + // key typed in table filter box, redraw + var id = $(elem).data('id'); + var opts = this.tables[id]; + opts.filter = $(elem).val(); + + var disp_rows = this.getSortedTableRows( opts.id ); + + // redraw pagination thing + this.div.find('#st_hinfo_' + opts.id).html( + this.getTableHeaderInfo(id, disp_rows) + ); + + // redraw rows + this.div.find('#st_' + opts.id + ' > tbody').html( + this.getTableContentHTML( opts.id, disp_rows ) + ); + }, + + getTableHeaderInfo: function(id, disp_rows) { + // construct HTML for sortable table header info widget + var opts = this.tables[id]; + var rows = opts.rows; + var html = ''; + + if (disp_rows.length < rows.length) { + html += commify(disp_rows.length) + ' of ' + commify(rows.length) + ' ' + pluralize(opts.item_name, rows.length) + ''; + } + else { + html += commify(rows.length) + ' ' + pluralize(opts.item_name, rows.length) + ''; + } + + var bold_idx = opts.column_ids.indexOf( opts.sort_by ); + html += ', sorted by ' + opts.column_labels[bold_idx] + ''; + html += ' '; + // html += ((opts.sort_dir == 1) ? ' ascending' : ' descending'); + + return html; + }, + + getTableColumnHTML: function(id) { + // construct HTML for sortable table column headers (THs) + var opts = this.tables[id]; + var html = ''; + html += ''; + + opts.column_ids.forEach( function(col_id, idx) { + var col_label = opts.column_labels[idx]; + var classes = ['st_col_header']; + var icon = ''; + if (col_id == opts.sort_by) { + classes.push('active'); + icon = ' '; + } + html += '' + col_label + icon + ''; + }); + + html += ''; + return html; + }, + + getTableContentHTML: function(id, disp_rows) { + // construct HTML for sortable table content (rows) + var opts = this.tables[id]; + var html = ''; + var bold_idx = opts.column_ids.indexOf( opts.sort_by ); + + for (var idx = 0, len = disp_rows.length; idx < len; idx++) { + var row = disp_rows[idx]; + var tds = opts.callback(row, idx); + html += ''; + for (var idy = 0, ley = tds.length; idy < ley; idy++) { + html += '' + tds[idy] + ''; + } + // html += '' + tds.join('') + ''; + html += ''; + } // foreach row + + if (!disp_rows.length) { + html += ''; + html += 'No ' + pluralize(opts.item_name) + ' found.'; + html += ''; + } + + return html; + }, + + toggleTableSort: function(elem) { + var id = $(elem).data('id'); + var col_id = $(elem).data('col'); + var opts = this.tables[id]; + + // swap sort dir or change sort column + if (col_id == opts.sort_by) { + // swap dir + opts.sort_dir *= -1; + } + else { + // same sort dir but change column + opts.sort_by = col_id; + } + + var disp_rows = this.getSortedTableRows( opts.id ); + + // redraw pagination thing + this.div.find('#st_hinfo_' + opts.id).html( + this.getTableHeaderInfo(id, disp_rows) + ); + + // redraw columns + this.div.find('#st_' + opts.id + ' > thead').html( + this.getTableColumnHTML(id) + ); + + // redraw rows + this.div.find('#st_' + opts.id + ' > tbody').html( + this.getTableContentHTML( opts.id, disp_rows ) + ); + }, + + getSortableTable: function(rows, opts, callback) { + // get HTML for sortable and filterable table + var self = this; + var html = ''; + + // save in page for resort / filtering + if (!this.tables) this.tables = {}; + opts.rows = rows; + opts.callback = callback; + this.tables[ opts.id ] = opts; + + var disp_rows = this.getSortedTableRows( opts.id ); + + // pagination + html += ''; + + html += '
'; + html += ''; + + html += ''; + html += this.getTableColumnHTML( opts.id ); + html += ''; + + html += ''; + html += this.getTableContentHTML( opts.id, disp_rows ); + html += ''; + + html += '
'; + html += '
'; + + return html; + }, + + onSecond30: function(dargs) { + // update graphs on the :30s, but only in realtime view + var args = this.args; + + if (this.args.sub == 'list') { + // refresh snapshot list every minute + this.gosub_list(args); + } + }, + + onDeactivate: function() { + // called when page is deactivated + // this.div.html( '' ); + return true; + } + +} ); diff --git a/htdocs/js/pages/admin/Activity.js b/htdocs/js/pages/admin/Activity.js index 873702d..e038b9c 100644 --- a/htdocs/js/pages/admin/Activity.js +++ b/htdocs/js/pages/admin/Activity.js @@ -12,6 +12,7 @@ Class.add( Page.Admin, { '^user': ' User', '^server': ' Server', '^state': ' State', // mdi-lg + '^watch': ' Watch', // mdi-lg '^error': ' Error', '^warning': ' Warning' }, @@ -93,6 +94,8 @@ Class.add( Page.Admin, { case 'alert_new': desc = 'Alert Triggered: ' + item.def.title + ' for server ' + self.formatHostname(item.hostname) + ': ' + item.alert.message; color = 'red'; + actions.push( 'View Snapshot' ); + break; case 'alert_cleared': @@ -179,6 +182,18 @@ Class.add( Page.Admin, { else desc = "State data was updated."; break; + // watch + case 'watch_set': + if (item.hostname) item.hostnames = [item.hostname]; + var nice_host = app.formatHostname(item.hostnames[0]); + if (item.hostnames.length > 1) { + var remain = item.hostnames.length - 1; + nice_host += " and " + remain + " " + pluralize("other", remain); + } + if (item.date) desc = "Server watch set on " + nice_host + " until: " + get_nice_date_time(item.date, false, false) + ""; + else desc = "Server watch canceled for: " + nice_host; + break; + // errors case 'error': desc = encode_entities( item.description ); diff --git a/htdocs/js/pages/admin/Groups.js b/htdocs/js/pages/admin/Groups.js index 58109d8..5ab7ac3 100644 --- a/htdocs/js/pages/admin/Groups.js +++ b/htdocs/js/pages/admin/Groups.js @@ -372,7 +372,7 @@ Class.add( Page.Admin, { // hostname_match html += get_form_table_row( 'Hostname Match', '' ); - html += get_form_table_caption( "Optionally enter a regular expression match to auto-include hostnames in the group."); + html += get_form_table_caption( "Optionally enter a regular expression match to auto-include hostnames in the group.
To match all servers, set this to .+"); html += get_form_table_spacer(); // alert notifications enabled diff --git a/lib/api/admin.js b/lib/api/admin.js index f948242..8b77d02 100644 --- a/lib/api/admin.js +++ b/lib/api/admin.js @@ -52,6 +52,53 @@ module.exports = Class.create({ callback({ code: 0 }); + // write state async + self.storage.put( 'global/state', self.state, function(err) { + if (err) self.logError('state', "Failed to write state data: " + err); + }); + } ); + }, + + api_watch: function(args, callback) { + // set or unset a watch on one or more servers + var self = this; + var params = args.params; + + if (!this.requireParams(params, { + // hostname: /^\S+$/, + date: /^\d+$/ + }, callback)) return; + + if (!params.hostnames || !params.hostnames.length || (typeof(params.hostnames) != 'object')) { + return this.doError('watch', "Missing or malformed 'hostnames' parameter.", callback); + } + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + // set or unset watch + if (!self.state.watches) self.state.watches = {}; + + params.hostnames.forEach( function(hostname) { + if (params.date) { + self.state.watches[ hostname ] = params.date; + } + else { + // remove watch + delete self.state.watches[ hostname ]; + } + }); // forEach + + // import params into state + self.logDebug(4, "Updating state:", self.state); + self.logTransaction('watch_set', 'Server watch has been set', self.getClientInfo(args, params)); + + callback({ code: 0 }); + // write state async self.storage.put( 'global/state', self.state, function(err) { if (err) self.logError('state', "Failed to write state data: " + err); diff --git a/lib/api/submit.js b/lib/api/submit.js index ceb9169..f2dbff4 100644 --- a/lib/api/submit.js +++ b/lib/api/submit.js @@ -28,6 +28,7 @@ module.exports = Class.create({ this.logDebug(9, "Received hello from server", params); if (!this.requireParams(params, { + version: /^1\./, hostname: /^\S+$/, nonce: /^\S+$/ }, callback)) return; @@ -97,6 +98,7 @@ module.exports = Class.create({ if (!this.requireParams(params, { // date: /^\d+(\.\d+)?$/, + version: /^1\./, hostname: /^\S+$/ }, callback)) return; @@ -151,11 +153,10 @@ module.exports = Class.create({ action: 'custom', label: 'Performa Data Submission', handler: this.processDataSubmission.bind(this), - params: params + params: params, + args: args, + callback: callback }); - - // send immediate response to client - callback({ code: 0 }); }, processDataSubmission: function(task, callback) { @@ -167,6 +168,7 @@ module.exports = Class.create({ var alert_defs = this.alerts; var group_defs = this.groups; var group_def = Tools.findObject( group_defs, { id: params.group }); + var time_code = Math.floor( params.date / 60 ); // resolve monitor data values var data = {}; @@ -418,12 +420,6 @@ module.exports = Class.create({ } // alert cleared } // foreach alert - // accumulate group data (will be flushed on the minute) - self.updateGroupData( group_def.id, data ); - - // accumulate alert data (will be flushed on the minute) - self.alertCache[params.hostname] = params.alerts; - process.nextTick(callback); }, function(callback) { @@ -440,6 +436,12 @@ module.exports = Class.create({ ); }, function(callback) { + // accumulate group data (will be flushed on the minute) + self.updateGroupData( group_def.id, data ); + + // accumulate alert data (will be flushed on the minute) + self.alertCache[params.hostname] = params.alerts; + // write host data back to storage host_data = params; self.storage.put( host_key, host_data, callback ); @@ -454,11 +456,99 @@ module.exports = Class.create({ else { self.logDebug(6, "Data submission complete for: " + params.hostname); } + + // see if we need a snapshot from the server + var take_snap = false; + var snap_source = ''; + var state = self.state; + + if (params.new_alerts) { + take_snap = true; + snap_source = 'alert'; + } + if (state.watches && state.watches[params.hostname] && (state.watches[params.hostname] >= params.date)) { + take_snap = true; + snap_source = 'watch'; + } + + // API callback + task.callback({ + code: 0, + take_snapshot: take_snap, + snapshot_source: snap_source, + time_code: time_code + }); + + // queue callback callback(); } ); // async.series }, + api_snapshot: function(args, callback) { + // receive a snapshot from a server + // { version, hostname, time_code, ... } + var self = this; + var params = args.params; + + if (!this.server.started) { + return callback( + "503 Service Unavailable", + { 'Content-Type': "text/html" }, + "503 Service Unavailable: Server has not completed startup yet.\n" + ); + } + + if (!this.requireParams(params, { + // date: /^\d+(\.\d+)?$/, + version: /^1\./, + hostname: /^\S+$/, + time_code: /^\d+$/, + source: /^\S+$/ + }, callback)) return; + + this.logDebug(9, "Received snapshot submission from: " + params.hostname, + this.debugLevel(10) ? params : null + ); + + // normalize hostname for storage (and sanity) + params.hostname = this.storage.normalizeKey( params.hostname ).replace(/\//g, ''); + + var host_key = 'hosts/' + params.hostname + '/data'; + var snap_key = 'snapshots/' + params.hostname + '/' + params.time_code; + + // load host data to merge in with snapshot + this.storage.get( host_key, function(err, host_data) { + if (!host_data) return self.doError('snapshot', "Failed to load host data: " + host_key + ": " + err, callback); + + // merge n' save + host_data.snapshot = params; + + self.storage.put( snap_key, host_data, function(err) { + if (err) return self.doError('snapshot', "Failed to save snap data: " + snap_key + ": " + err, callback); + + if (self.server.config.get('expiration')) { + // set its expiration date + var exp_date = Tools.timeNow() + Tools.getSecondsFromText( self.server.config.get('expiration') ); + self.storage.expire( snap_key, exp_date ); + } + + self.storage.enqueue( function(task, callback) { + var stub = { + date: host_data.date, + hostname: params.hostname, + time_code: params.time_code, + source: params.source, + alerts: host_data.new_alerts || {} + }; + self.storage.listUnshift( 'logs/snapshots', stub, callback ); + }); + + callback({ code: 0 }); + }); // storage.put + }); // storage.get + }, + updateGroupData: function(group_id, data) { // merge in data values at the group level with totals // (this is flushed to disk every minute) @@ -581,6 +671,7 @@ module.exports = Class.create({ // send email and/or web hooks for alert (new or clear) // args: { template, def, params, alert } var self = this; + var time_code = Math.floor( args.params.date / 60 ); // add config to args args.config = this.server.config.get(); @@ -614,8 +705,9 @@ module.exports = Class.create({ args.nice_hostname = args.nice_hostname.replace( args.config.hostname_display_strip, '' ); } - // construct URL to realtime view of server + // construct URLs to views of server args.self_url = this.server.config.get('base_app_url') + '/#Server?hostname=' + args.params.hostname; + args.snapshot_url = this.server.config.get('base_app_url') + '/#Snapshot?id=' + args.params.hostname + '/' + time_code; // alert or group may override e-mail address args.email_to = args.def.email || group_def.alert_email || this.server.config.get('email_to'); diff --git a/lib/api/view.js b/lib/api/view.js index 12a14a1..4737ecc 100644 --- a/lib/api/view.js +++ b/lib/api/view.js @@ -70,47 +70,52 @@ module.exports = Class.create({ }); }; // finish - // branch for historical or real-time mode - if (query.date) { - // exact date, easy mode - var contrib_key = 'contrib/' + sys.id + '/' + query.date; - this.storage.get( contrib_key, function(err, data) { - var items = (data && data.hostnames) ? [data] : []; - finish( items ); - }); // storage.get - } - else if (query.length && (query.sys == 'hourly')) { - // real-time query, not so easy - query.length = parseInt( query.length ); - var len = Math.floor( query.length / 60 ) + 1; - var now = Tools.timeNow(); - var keys = []; - var items = []; - - for (var idx = 0; idx < len; idx++) { - var dargs = Tools.getDateArgs( now - (idx * 3600) ); - keys.push( 'contrib/' + sys.id + '/' + Tools.sub(sys.date_format, dargs) ); - } + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; - // load multiple records as quickly as possible using concurrency - async.eachLimit( keys, this.storage.concurrency, - function(key, callback) { - self.storage.get( key, function(err, data) { - // ignore errors here - if (data && data.hostnames) items.push( data ); - callback(); - }); // storage.listGet - }, - function() { - // finish up + // branch for historical or real-time mode + if (query.date) { + // exact date, easy mode + var contrib_key = 'contrib/' + sys.id + '/' + query.date; + self.storage.get( contrib_key, function(err, data) { + var items = (data && data.hostnames) ? [data] : []; finish( items ); + }); // storage.get + } + else if (query.length && (query.sys == 'hourly')) { + // real-time query, not so easy + query.length = parseInt( query.length ); + var len = Math.floor( query.length / 60 ) + 1; + var now = Tools.timeNow(); + var keys = []; + var items = []; + + for (var idx = 0; idx < len; idx++) { + var dargs = Tools.getDateArgs( now - (idx * 3600) ); + keys.push( 'contrib/' + sys.id + '/' + Tools.sub(sys.date_format, dargs) ); } - ); // async.eachLimit - } - else { - // malformed request - return this.doError('contrib', "Missing both date and length properties", callback); - } + + // load multiple records as quickly as possible using concurrency + async.eachLimit( keys, self.storage.concurrency, + function(key, callback) { + self.storage.get( key, function(err, data) { + // ignore errors here + if (data && data.hostnames) items.push( data ); + callback(); + }); // storage.listGet + }, + function() { + // finish up + finish( items ); + } + ); // async.eachLimit + } + else { + // malformed request + return self.doError('contrib', "Missing both date and length properties", callback); + } + }); // loadSession }, api_view: function(args, callback) { @@ -151,62 +156,67 @@ module.exports = Class.create({ }); // storage.get }; // finish - // branch for historical or real-time view - if (query.date) { - // exact date, easy mode - var timeline_key = 'timeline/' + sys.id + '/' + query.hostname + '/' + query.date; + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; - this.storage.listGet( timeline_key, 0, 0, function(err, items) { - if (err || !items || !items.length) return self.doError('no_data', "No data found", callback); - finish( items ); - }); // storage.listGet - } - else if (query.length && (query.sys == 'hourly')) { - // real-time view (will involve multiple lists) - query.length = parseInt( query.length ); - var len = Math.floor( query.length / 60 ) + 1; - var now = Tools.timeNow(); - var keys = []; - var values = {}; - - for (var idx = 0; idx < len; idx++) { - var dargs = Tools.getDateArgs( now - (idx * 3600) ); - keys.push( 'timeline/' + sys.id + '/' + query.hostname + '/' + Tools.sub(sys.date_format, dargs) ); - } - - // load multiple lists as quickly as possible using concurrency - async.eachLimit( keys, this.storage.concurrency, - function(key, callback) { - self.storage.listGet( key, 0, 0, function(err, items) { - // ignore errors here - if (items && items.length) values[key] = items; - callback(); - }); // storage.listGet - }, - function() { - // arrange all rows by date ascending - var items = []; - keys.reverse().forEach( function(key) { - if (values[key]) items = items.concat( values[key] ); - }); - - if (!items.length) { - return self.doError('no_data', "No data found", callback); - } - - // splice off extra from left (oldest) side, if applicable - if (items.length > query.length) { - items.splice( 0, items.length - query.length ); - } - + // branch for historical or real-time view + if (query.date) { + // exact date, easy mode + var timeline_key = 'timeline/' + sys.id + '/' + query.hostname + '/' + query.date; + + self.storage.listGet( timeline_key, 0, 0, function(err, items) { + if (err || !items || !items.length) return self.doError('no_data', "No data found", callback); finish( items ); + }); // storage.listGet + } + else if (query.length && (query.sys == 'hourly')) { + // real-time view (will involve multiple lists) + query.length = parseInt( query.length ); + var len = Math.floor( query.length / 60 ) + 1; + var now = Tools.timeNow(); + var keys = []; + var values = {}; + + for (var idx = 0; idx < len; idx++) { + var dargs = Tools.getDateArgs( now - (idx * 3600) ); + keys.push( 'timeline/' + sys.id + '/' + query.hostname + '/' + Tools.sub(sys.date_format, dargs) ); } - ); // async.eachLimit - } - else { - // no date or length, just return host data - finish([]); - } + + // load multiple lists as quickly as possible using concurrency + async.eachLimit( keys, self.storage.concurrency, + function(key, callback) { + self.storage.listGet( key, 0, 0, function(err, items) { + // ignore errors here + if (items && items.length) values[key] = items; + callback(); + }); // storage.listGet + }, + function() { + // arrange all rows by date ascending + var items = []; + keys.reverse().forEach( function(key) { + if (values[key]) items = items.concat( values[key] ); + }); + + if (!items.length) { + return self.doError('no_data', "No data found", callback); + } + + // splice off extra from left (oldest) side, if applicable + if (items.length > query.length) { + items.splice( 0, items.length - query.length ); + } + + finish( items ); + } + ); // async.eachLimit + } + else { + // no date or length, just return host data + finish([]); + } + }); // loadSession }, api_overview: function(args, callback) { @@ -224,16 +234,67 @@ module.exports = Class.create({ var offset = parseInt( query.offset ); var length = parseInt( query.length ); - this.storage.listGet( timeline_key, offset, length, function(err, items) { - if (err || !items || !items.length) return self.doError('no_data', "No data found", callback); + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + + self.storage.listGet( timeline_key, offset, length, function(err, items) { + if (err || !items || !items.length) return self.doError('no_data', "No data found", callback); + + // also load current alerts + self.storage.get( 'current/alerts', function(err, alert_data) { + if (err || !alert_data) alert_data = {}; + + callback({ code: 0, rows: items, alerts: alert_data }); + }); // storage.get + }); // storage.listGet + }); // loadSession + }, + + api_get_snapshots: function(args, callback) { + // get rows from snapshots log (with pagination) + var self = this; + var params = args.params; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + + self.storage.listGet( 'logs/snapshots', parseInt(params.offset || 0), parseInt(params.limit || 50), function(err, items, list) { + if (err) { + // no rows found, not an error for this API + return callback({ code: 0, rows: [], list: { length: 0 } }); + } + + // success, return rows and list header + callback({ code: 0, rows: items, list: list }); + } ); // got data + } ); // loaded session + }, + + api_get_snapshot: function(args, callback) { + // get single snapshot given id + var self = this; + var query = args.query; + + if (!this.requireParams(query, { + id: /^\S+$/ + }, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; - // also load current alerts - self.storage.get( 'current/alerts', function(err, alert_data) { - if (err || !alert_data) alert_data = {}; + var snap_key = 'snapshots/' + query.id; + self.storage.get( snap_key, function(err, data) { + if (err || !data) return self.doError('no_data', "No data found", callback); - callback({ code: 0, rows: items, alerts: alert_data }); + callback({ + code: 0, + metadata: data + }); }); // storage.get - }); // storage.listGet + }); // loadSession } }); // class diff --git a/lib/engine.js b/lib/engine.js index b63b15e..b0e0be1 100644 --- a/lib/engine.js +++ b/lib/engine.js @@ -373,7 +373,7 @@ module.exports = Class.create({ // don't run this if shutting down if (this.server.shut) return; - // delete old timeline data (for overview page) + // delete old timeline data (for overview timeline) var max_len = Math.floor( Tools.getSecondsFromText( self.server.config.get('expiration') ) / 60 ); this.storage.listGetInfo( timeline_key, function(err, list) { @@ -388,12 +388,54 @@ module.exports = Class.create({ if (err) { return self.logError('maint', "Failed to splice list: " + timeline_key + ": " + err); } - self.logDebug(4, "Maintenance complete"); + self.chopLists(); }); // listSplice } // need chop }); // listGetInfo }, + chopLists: function() { + // chop long lists (part of daily maint) + var self = this; + var max_rows = this.server.config.get('list_row_max') || 0; + if (!max_rows) { + self.logDebug(4, "Maintenance complete"); + return; + } + + var list_paths = ['logs/activity', 'logs/snapshots']; + + async.eachSeries( list_paths, + function(list_path, callback) { + // iterator function, work on single list + self.storage.listGetInfo( list_path, function(err, info) { + // list may not exist, skip if so + if (err) return callback(); + + // check list length + if (info.length > max_rows) { + // list has grown too long, needs a trim + self.logDebug(3, "List " + list_path + " has grown too long, trimming to max: " + max_rows, info); + self.storage.listSplice( list_path, max_rows, info.length - max_rows, null, callback ); + } + else { + // no trim needed, proceed to next list + callback(); + } + } ); // get list info + }, // iterator + function(err) { + if (err) { + self.logError('maint', "Failed to trim lists: " + err); + } + + // done with maint + self.logDebug(4, "Maintenance complete"); + + } // complete + ); // eachSeries + }, + archiveLogs: function() { // archive all logs (called once daily at midnight) // log_archive_storage: { enabled, key_template, expiration } diff --git a/package.json b/package.json index 1a42e19..409b20f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "performa", - "version": "1.0.1", + "version": "1.0.2", "description": "A multi-server monitoring system with a web based UI.", "author": "Joseph Huckaby ", "homepage": "https://github.com/jhuckaby/performa", @@ -27,7 +27,6 @@ ], "dependencies": { "async": "2.6.0", - "ws": "4.1.0", "mime": "2.3.1", "mkdirp": "0.5.1", "glob": "5.0.15", @@ -42,7 +41,7 @@ "pixl-args": "^1.0.3", "pixl-cli": "^1.0.8", "pixl-config": "^1.0.4", - "pixl-webapp": "^2.0.0", + "pixl-webapp": "^2.0.1", "pixl-class": "^1.0.2", "pixl-tools": "^1.0.22", "pixl-logger": "^1.0.13", @@ -50,13 +49,13 @@ "pixl-request": "^1.0.20", "pixl-mail": "^1.0.9", "pixl-perf": "^1.0.5", - "pixl-server": "^1.0.13", - "pixl-server-storage": "^2.0.14", - "pixl-server-web": "^1.1.5", + "pixl-server": "^1.0.21", + "pixl-server-storage": "^2.0.16", + "pixl-server-web": "^1.1.18", "pixl-server-api": "^1.0.1", - "pixl-server-user": "^1.0.8", - "pixl-boot": "^1.0.0", - "performa-satellite": "^1.0.0" + "pixl-server-user": "^1.0.9", + "pixl-boot": "^2.0.0", + "performa-satellite": "^1.0.4" }, "devDependencies": { "pixl-unit": "^1.0.9" diff --git a/sample_conf/config.json b/sample_conf/config.json index 44f1cf0..1fe29c5 100644 --- a/sample_conf/config.json +++ b/sample_conf/config.json @@ -15,6 +15,7 @@ "debug_level": 5, "maintenance": "04:00", "expiration": "10 years", + "list_row_max": 10000, "monitor_self": true, "hostname_display_strip": "\\.[\\w\\-]+\\.\\w+$", @@ -45,8 +46,7 @@ "Filesystem": { "base_dir": "data", - "key_namespaces": 1, - "raw_file_paths": 1 + "key_namespaces": 1 } }, diff --git a/sample_conf/emails/alert_cleared.txt b/sample_conf/emails/alert_cleared.txt index 885d052..d32cb35 100644 --- a/sample_conf/emails/alert_cleared.txt +++ b/sample_conf/emails/alert_cleared.txt @@ -11,6 +11,9 @@ Date/Time: [/date_time] Expression: [/def/expression] Elapsed: [/elapsed_nice] +Snapshot Detail View: +[/snapshot_url] + Live Server View: [/self_url] diff --git a/sample_conf/emails/alert_new.txt b/sample_conf/emails/alert_new.txt index 5a96b86..8492be3 100644 --- a/sample_conf/emails/alert_new.txt +++ b/sample_conf/emails/alert_new.txt @@ -12,6 +12,9 @@ Date/Time: [/date_time] Expression: [/def/expression] Evaluation: [/alert/exp] +Snapshot Detail View: +[/snapshot_url] + Live Server View: [/self_url] diff --git a/sample_conf/setup.json b/sample_conf/setup.json index 702305b..4389b84 100644 --- a/sample_conf/setup.json +++ b/sample_conf/setup.json @@ -75,7 +75,7 @@ [ "listPush", "global/monitors", { "id": "tcp_conns", "title": "Open TCP Connections", - "source": "[stats/network/conns]", + "source": "[stats/network/states/established]", "data_type": "integer", "suffix": "", "merge_type": "avg", @@ -151,7 +151,7 @@ [ "listPush", "global/monitors", { "id": "io_wait", "title": "CPU I/O Wait %", - "source": "[cpu/percentages/iowait]", + "source": "[cpu/totals/iowait]", "data_type": "float", "suffix": "%", "merge_type": "",