diff --git a/pd-notifier/background/pd-notifier.js b/pd-notifier/background/pd-notifier.js index 81bd481..50f66d7 100644 --- a/pd-notifier/background/pd-notifier.js +++ b/pd-notifier/background/pd-notifier.js @@ -14,12 +14,15 @@ function PagerDutyNotifier() self.openOnAck = false; // Whether to open the incident in a new tab when ack-ing. self.notifSound = false; // Whether to play a notification sound. self.requireInteraction = false; // Whether the notification will require user interaction to dismiss. - self.filterServices = null; // ServiceID's of services to only show alerts for. - self.filterUsers = null; // UserID's of users to only show alerts for. + self.basicFilters = {} + for (var filter in basicFilters) + self.basicFilters[filter] = null; + self.advancedFilter = false; // Compiled advanced filter. self.pdapi = null; // Helper for API calls. self.poller = null; // This points to the interval function so we can clear it if needed. self.showBadgeUpdates = false; // Whether we show updates on the toolbar badge. self.badgeLocation = null; // Which view should be linked to from the badge icon. + self.maxNotifAtOnce = 5; // Ctor self._construct = function _construct() @@ -46,20 +49,7 @@ function PagerDutyNotifier() // This loads any configuration we have stored with chrome.storage self.loadConfiguration = function loadConfiguration(callback) { - chrome.storage.sync.get( - { - pdAccountSubdomain: '', - pdAPIKey: null, - pdIncludeLowUrgency: false, - pdRemoveButtons: false, - pdOpenOnAck: false, - pdNotifSound: false, - pdRequireInteraction: false, - pdFilterServices: null, - pdFilterUsers: null, - pdShowBadgeUpdates: false, - pdBadgeLocation: 'triggered', - }, + chrome.storage.sync.get( storageDefaults, function(items) { self.account = items.pdAccountSubdomain; @@ -69,10 +59,12 @@ function PagerDutyNotifier() self.openOnAck = items.pdOpenOnAck; self.notifSound = items.pdNotifSound; self.requireInteraction = items.pdRequireInteraction; - self.filterServices = items.pdFilterServices; - self.filterUsers = items.pdFilterUsers; + for (var filter in basicFilters) { + self.basicFilters[filter] = items[basicFilters[filter]]; + } self.showBadgeUpdates = items.pdShowBadgeUpdates; self.badgeLocation = items.pdBadgeLocation; + self.advancedFilter = compileAdvancedFilter(items.pdAdvancedFilter); callback(true); }); } @@ -134,12 +126,16 @@ function PagerDutyNotifier() // Construct the URL var url = 'https://' + self.account + '.pagerduty.com/api/v1/incidents?' + 'statuses[]=triggered&' - + 'since=' + since.toISOString() + '&' - + 'limit=5&'; // More than this would be silly to show notifications for. + + 'since=' + since.toISOString() + '&'; url = self.includeFilters(url); - - // Make the request. - self.pdapi.GET(url, self.parseIncidents); + var counter = 0; + self.pdapi.Paginate(url, 'incidents', + function (incident) { + if (self.parseIncident(incident)) + counter += 1; + return counter < self.maxNotifAtOnce; + } + ) } // Adds filters to a URL we'll be using in a request @@ -148,31 +144,36 @@ function PagerDutyNotifier() // Limit to high urgency if that's all the user wants. if (!self.includeLowUgency) { url = url + 'urgencies[]=high&'; } - // Add a service filter if we have one. - if (self.filterServices && self.filterServices != null && self.filterServices != "") - { - self.filterServices.split(',').forEach(function(s) + // Limit to users/services/teams + for (var filter in basicFilters) { + var value = self.basicFilters[filter] + if (value && value != null && value != "") { - url = url + 'service_ids[]=' + s + '&'; - }); - } - - // Add a user filter if we have one. - if (self.filterUsers && self.filterUsers != null && self.filterUsers != "") - { - self.filterUsers.split(',').forEach(function(s) - { - url = url + 'user_ids[]=' + s + '&'; - }); + value.split(',').forEach(function(s) + { + url = url + filter + '_ids[]=' + s + '&'; + }); + } } return url; } - // This will parse the AJAX response and trigger notifications for each incident. - self.parseIncidents = function parseIncidents(data) + // This checks if incident should be displayed as notification. + // Returns true if notification was triggered. + self.parseIncident = function parseIncidents(incident) { - for (var i in data.incidents) { self.triggerNotification(data.incidents[i]); } + if (self.advancedFilter) { + try { + if (! self.advancedFilter(incident) ) + return false; + } catch (e) { + console.log("Advanced filter exception:", e) + // Filter failed, assume that incident should be displayed + } + } + self.triggerNotification(incident); + return true; } // This will update the icon badge in the toolbar. diff --git a/pd-notifier/lib/advanced-filter.js b/pd-notifier/lib/advanced-filter.js new file mode 100644 index 0000000..8d80c43 --- /dev/null +++ b/pd-notifier/lib/advanced-filter.js @@ -0,0 +1,230 @@ +"use strict"; +function AdvancedFilterCompiler() +{ + var self = this; + this.debug = 0; + this.compilers = { + bool_op: function compile_bool_op(kwdef, stack) { + if (stack.nextUnexpected('bool_op')) return false; + stack.push({ + next: { property: true, paren_left: true, bool_un: true }, + bool_op: true, + code: '(' + stack.pop().code + ') ' + kwdef.code + ' ', + }); + return true; + }, + bool_un: function compile_bool_un(kwdef, stack) { + if (stack.top() && stack.nextUnexpected('paren_left')) return false; + stack.push({ + next: { property: true, paren_left: true, bool_un: true }, + bool_op: true, + code: kwdef.code + ' ', + }); + return true; + }, + property: function compile_property(kwdef, stack) { + if (stack.top() && stack.nextUnexpected('property')) return false; + stack.push( { + next: { prop_op: true}, + code: kwdef.code + }); + return true; + }, + prop_op: function compile_prop_op(kwdef, stack) { + if (stack.nextUnexpected('prop_op')) return false; + stack.push({ + next: { string: true}, + lhs: stack.pop(), + code: kwdef.code, + add_rhs_and_push: function(rhs, stack) { + var op = this; + return stack.pushBool( + '( ((lhs,rhs) => lhs.reduce( (acc, item) => acc || (' + op.code + ')(item, rhs), false )) (' + op.lhs.code + ', ' + rhs + ') )' + ); + } + }); + return true; + }, + string: function compile_string(strdef, stack) { + if (stack.nextUnexpected('string')) return false; + return stack.pop().add_rhs_and_push(strdef.code, stack); + }, + paren_left: function compile_paren_left(_unused, stack) { + if (stack.top() && stack.nextUnexpected('paren_left')) return false; + stack.push({ + next:{ property: true, paren_left: true, bool_un: true }, + paren_left: true + }) + return true; + }, + paren_right: function compile_paren_right(_unused, stack) { + if (stack.top() && stack.nextUnexpected('paren_right')) return false; + var code = '(' + stack.pop().code + ')'; + if (! stack.safetop().paren_left) { + console.log("Expected left parenthesis on stack, got:", stack.top()); + return false; + } + stack.pop(); + return stack.pushBool(code); + }, + }; + this.tokens = { + whitespace:{ + rx: /^\s+/, + insert: (matchObject, stack) => true, + }, + keyword:{ + rx: /^([A-Za-z][_A-Za-z0-9]+|=)/, + insert: function insert_keyword(matchObject, stack) { + var kwdef = self.keywords[matchObject[0]]; + var parser = kwdef && self.compilers[kwdef.type]; + if (parser) + return parser(kwdef, stack); + if (self.debug) console.log("Unknown parser for:", matchObject); + return false; + }, + }, + string:{ + rx: /^('[^\\']*'|"[^\\"]*")/, //"', + insert: (matchObject, stack) => self.compilers.string({code: matchObject[0]}, stack), + }, + paren_left: { + rx: /^\(/, + insert: self.compilers.paren_left, + }, + paren_right: { + rx: /^\)/, + insert: self.compilers.paren_right, + }, + }; + this.keywords = { + 'AND': { type:'bool_op', code: '&&' }, + 'OR': { type:'bool_op', code: '||' }, + 'NOT': { type:'bool_un', code: '!' }, + + 'service_id': { type:'property', code: '[ incident.service.id ]'}, + 'team_id': { type:'property', code: 'incident.teams.map( x => x && x.id )'}, + 'user_id': { type:'property', + code: 'incident.assignments.map( x => x && x.assignee && x.assignee.id )'}, + + '=': { type:'prop_op', code: '( (prop, value) => (prop == value) )' }, + }; + + this.compileAdvancedFilter = function compileAdvancedFilter(filter) { + var stack = []; + stack.top = function () { return this[this.length - 1 ]; }; + stack.safetop = function () { if (this.length == 0) return {}; else return this[this.length - 1]; }; + stack.next = function () { var n = stack.safetop().next; if (n) return n; else return {}; }; + stack.nextUnexpected = function (name) { + if (stack.next()[name]) return false; + if (self.debug) console.log("[" + name + "] object on stack does not want me!", stack.top()); + return true; + }; + stack.pushBool = function (code) { + if (self.debug > 3) console.log("[Pushing bool]", code); + if (this.safetop().bool_op) { + var op = this.pop(); + return this.pushBool( op.code + '(' + code + ')' ); + } else { + this.push({ + next: { bool_op: true, paren_right: true }, + code: code + }); + if (self.debug > 3) console.log("Stack:", this); + return true; + } + }; + var pos = 0; + + while (filter.slice(pos).length > 0) { + var match = false; + for (var token in self.tokens) { + var match = self.tokens[token].rx.exec(filter.slice(pos)); + if (match) { + if (self.debug > 3) + console.log("\nToken:", token, " match:", match); + if (!self.tokens[token].insert(match, stack)) + // expression error + return null; + if (self.debug > 3) + console.log("Stack:", stack); + pos += match[0].length; + break; + } + } + if (! match) { + if (self.debug) console.log("Invalid token: '" + filter.slice(pos) + "'"); + return null; + } + } + if (self.debug > 3) console.log("Input end, stack:", stack); + if (stack.length == 1 && stack.next().paren_right) { //Expression may be enclosed in parentheses + if (self.debug > 1) + return stack[0].code; + try { + return Function('incident', 'return (' + stack[0].code + ');'); + } catch (err) { + // Internal error + console.log(err); + return null; + } + } else if (stack.length == 0) { + // empty expression + return false; + } else { + // invalid expression + return null; + } + } +}; + + +var compileAdvancedFilter = (function () { + var compiler = new AdvancedFilterCompiler(); + return x => compiler.compileAdvancedFilter(x); +})(); + +// SELF TEST in Node.js +if ((typeof process === 'object') && (typeof process.release === 'object') && process.release.name === 'node') { + var compiler = new AdvancedFilterCompiler(); + var incident = { + assignments:[ { assignee:{ id:"PUSER1" } }, { assignee:{ id:"PUSER2" } } ], + teams:[ { id:"PTEAM1" }, { id:"PTEAM2" } ], + service: { id: "PSERVICE1" } + }; + var tests = { + '':"empty", + ' user_id = "P123456" ':false, + ' user_id = "PUSER1" ':true, + ' team_id = "P123456" ':false, + ' team_id = "PTEAM1" ':true, + ' service_id = "P123456" ':false, + ' service_id = "PSERVICE1" ':true, + ' NOT service_id = "PSERVICE1" ':false, + ' NOT service_id = "P123456" ':true, + ' user_id = "PUSER1" AND service_id = "POFF" OR team_id = "PTEAM1" OR user_id = "POOH" ':true, + ' (user_id = "PUSER1") AND service_id = "POFF" OR team_id = "PTEAM1" OR user_id = "POOH" ':true, + ' (user_id = "PUSER1" AND service_id = "POFF") OR ( ( team_id = "PTEAM1" OR user_id = "POOH") ) ':true, + ' (user_id = "PUSER1" AND service_id = "POFF") OR (team_id = "PTEAM1" AND user_id = "POOH") ': false, + }; + + var logResult = function(test, value) { + if (tests[test] == value) + console.log(" [ok] , Result:", value); + else { + console.log("!FAIL!, Result:", value); + compiler.debug = 5; + console.log(compiler.compileAdvancedFilter(filter)); + } + }; + + for (var filter in tests) { + console.log("\nCompiling:", filter); + compiler.debug = 1; + var f = compiler.compileAdvancedFilter(filter); + if (!f) { + if (f == false) logResult(filter, "empty") + else logResult(filter, "invalid") + } else logResult(filter, f(incident)); + }; +} diff --git a/pd-notifier/lib/pd-api.js b/pd-notifier/lib/pd-api.js index ceaf284..f801375 100644 --- a/pd-notifier/lib/pd-api.js +++ b/pd-notifier/lib/pd-api.js @@ -52,4 +52,35 @@ function PDAPI(apiKey, version = 2) req.setRequestHeader("Content-Type", "application/json"); req.send(data); } + + // Perform a paginated GET request. + this.Paginate = function Paginate(url, list_name, item_callback, error_callback = null) + { + const items_limit = 50; + var pages_limit = 20; // 20*50 = 1000 should be enough. Do not overload servers with big data. + var offset = 0; + var parseResponse = function(data) { + if (data.offset != offset) { + error_callback(-999, "Invalid offset in response") + return null; + } + for (var i in data[list_name]) { + try { + if (item_callback(data[list_name][i]) === false) { + error_callback(-998, "Item callback returned false"); + return null; + } + } catch (e) { + error_callback(-997, "Item callback raised an error"); + return null; + } + } + if (data.more && data[list_name].length > 0 && pages_limit > 0) { + pages_limit -= 1; + offset += data[list_name].length; + self.GET(url + 'offset=' + offset + '&limit=' + items_limit, parseResponse, error_callback); + } + } + self.GET(url + 'offset=' + offset + '&limit=' + items_limit, parseResponse, error_callback); + } } diff --git a/pd-notifier/lib/storage.js b/pd-notifier/lib/storage.js new file mode 100644 index 0000000..11b86d0 --- /dev/null +++ b/pd-notifier/lib/storage.js @@ -0,0 +1,21 @@ +var basicFilters = { + service: "pdFilterServices", + user: "pdFilterUsers", + team: "pdFilterTeams", +}; + +var storageDefaults = { + pdAccountSubdomain: '', + pdAPIKey: '', + pdIncludeLowUrgency: false, + pdRemoveButtons: false, + pdOpenOnAck: false, + pdNotifSound: false, + pdRequireInteraction: false, + pdShowBadgeUpdates: false, + pdBadgeLocation: 'triggered', + pdAdvancedFilter: '', +}; + +for (var filter in basicFilters) + storageDefaults[basicFilters[filter]] =''; diff --git a/pd-notifier/manifest.json b/pd-notifier/manifest.json index 5aa6a89..8a1bd02 100644 --- a/pd-notifier/manifest.json +++ b/pd-notifier/manifest.json @@ -20,6 +20,7 @@ "https://*.pagerduty.com/api/v1/*", "contextMenus" ], + "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", "options_ui": { "open_in_tab": true, "page": "options/options.html" @@ -27,6 +28,8 @@ "background": { "scripts": [ "lib/pd-api.js", + "lib/storage.js", + "lib/advanced-filter.js", "background/pd-notifier.js" ], "persistent": true diff --git a/pd-notifier/options/options.css b/pd-notifier/options/options.css index d6efb1e..f470b13 100644 --- a/pd-notifier/options/options.css +++ b/pd-notifier/options/options.css @@ -42,7 +42,8 @@ h2 { /* Inputs ***************************************************************************************************/ input[type=text], -input[type=number] { +input[type=number], +textarea { margin-bottom: 3px; border: 1px solid #d9d9d9; border-top: 1px solid #c0c0c0; @@ -62,11 +63,11 @@ input#api-key { margin-right: .5em; } -input.bad { +input.bad, textarea.bad { border: 1px solid #f00; } -input[type=text] { +input[type=text], textarea { font-family: monospace; font-size: 1.3em; } @@ -76,20 +77,22 @@ input[type=number] { } input[type=text]:hover, -input[type=number]:hover { +input[type=number]:hover, +textarea:hover { border: 1px solid #b9b9b9; border-top: 1px solid #a0a0a0; } input[type=text]:focus, -input[type=number]:focus { +input[type=number]:focus, +textarea:focus { outline: none; border: 1px solid #888; box-shadow: 0px 0px 1px 1px rgba(50, 50, 50, 0.4); border-radius: 2px; } -input::placeholder { +input::placeholder, textarea::placeholder { color: #bbb; } diff --git a/pd-notifier/options/options.html b/pd-notifier/options/options.html index 3311c7f..3518aa4 100644 --- a/pd-notifier/options/options.html +++ b/pd-notifier/options/options.html @@ -46,6 +46,22 @@

Notification Filters

Only show notifications for incidents from these services. Should be one or more service IDs (comma separated, no spaces). +
+ + + Only show notifications for incidents from services of these team(s). Should be one or more team IDs (comma separated, no spaces). Cannot be combined with service filter. +
+ +
+ + + Only show notifications for incidents that passes advanced filter. This filter is evaluated client-side, filtering out incidents that pass above filters (those filters are server-side).
+ Keywords: OR, AND, user_id, team_id, service_id
+ Comparison operator: =
+ String literals: quoted in ' or ". No escaping (yet).
+ Parentheses: () +
+

Customizations

@@ -92,6 +108,8 @@

Customizations

+ + diff --git a/pd-notifier/options/options.js b/pd-notifier/options/options.js index da8d325..abfe60e 100644 --- a/pd-notifier/options/options.js +++ b/pd-notifier/options/options.js @@ -14,21 +14,7 @@ function isAPIKeyObfuscated(key) // Add an event listener to restore previously save configuration. document.addEventListener('DOMContentLoaded', function () { - chrome.storage.sync.get( - { - // Defaults - pdAccountSubdomain: '', - pdAPIKey: '', - pdIncludeLowUrgency: false, - pdRemoveButtons: false, - pdOpenOnAck: false, - pdNotifSound: false, - pdRequireInteraction: false, - pdFilterServices: '', - pdFilterUsers: '', - pdShowBadgeUpdates: false, - pdBadgeLocation: '' - }, + chrome.storage.sync.get(storageDefaults, function(items) { // Update the page elements appropriately. @@ -39,8 +25,9 @@ document.addEventListener('DOMContentLoaded', function () getElement('open-on-ack').checked = items.pdOpenOnAck; getElement('notif-sound').checked = items.pdNotifSound; getElement('require-interaction').checked = items.pdRequireInteraction; - getElement('filter-services').value = items.pdFilterServices; - getElement('filter-users').value = items.pdFilterUsers; + for (var filter in basicFilters) + getFilterElement(filter).value = items[basicFilters[filter]]; + getElement('advanced-filter').value = items.pdAdvancedFilter; getElement('show-badge').checked = items.pdShowBadgeUpdates; // Default to "Triggered" for badgeLocation. @@ -70,19 +57,21 @@ document.getElementById('save').addEventListener('click', function () badgeLocation = getElement('badge-location'); badgeLocation = badgeLocation.options[badgeLocation.selectedIndex].value; - chrome.storage.sync.set( - { + var save_object = { pdAccountSubdomain: getElement('account-subdomain').value, pdIncludeLowUrgency: getElement('low-urgency').checked, pdRemoveButtons: getElement('remove-buttons').checked, pdOpenOnAck: getElement('open-on-ack').checked, pdNotifSound: getElement('notif-sound').checked, pdRequireInteraction: getElement('require-interaction').checked, - pdFilterServices: getElement('filter-services').value, - pdFilterUsers: getElement('filter-users').value, pdShowBadgeUpdates: getElement('show-badge').checked, + pdAdvancedFilter: getElement('advanced-filter').value, pdBadgeLocation: badgeLocation - }, + } + for (var filter in basicFilters) + save_object[basicFilters[filter]] = getFilterElement(filter).value; + + chrome.storage.sync.set( save_object, function() { // Tell the notifier to reload itself with the latest configuration. @@ -106,6 +95,11 @@ function getElement(elementId) return document.getElementById(elementId); } +function getFilterElement(filter) +{ + return getElement("filter-" + filter + "s") +} + // Helper to determine if value is an integer. function isInteger(value) { @@ -125,6 +119,8 @@ function validateConfiguration() { e.className = "bad"; isValid = false; + } else { + e.className = "good"; } // API Key should be exactly 20 chars long. @@ -134,25 +130,43 @@ function validateConfiguration() { e.className = "bad"; isValid = false; + } else { + e.className = "good"; } - // Filter services shouldn't have any spaces - e = getElement('filter-services'); - e.value = e.value.replace(/\s+/g, ''); - if (e.value !== "" - && e.value.indexOf(" ") > -1) - { - e.className = "bad"; + // Basic filters shouldn't have any spaces + var filterUsed = {} + for (var filter in basicFilters) { + e = getFilterElement(filter); + e.value = e.value.replace(/\s+/g, ''); + var filterValid = true; + filterUsed[filter] = false; + if (e.value != "") e.value.split(',').forEach(function(item){ + filterUsed[filter] = true; + if (!item.match( /^P[A-Z0-9]*$/ )) + filterValid = false; + }); + if (!filterValid) { + e.className = "bad"; + isValid = false; + } else { + e.className = "good"; + } + } + // Pagerduty ingores team filter if service filter is used. + // Forbid usage of both filters, so users won't be confused. + if (filterUsed['service'] && filterUsed['team']) { isValid = false; + getFilterElement('service').className = 'bad'; + getFilterElement('team').className = 'bad'; } - - // Filter users shouldn't have any spaces. - e = getElement('filter-users'); - e.value = e.value.replace(/\s+/g, ''); - if (e.value !== "" - && e.value.indexOf(" ") > -1) - { + // Compile advanced filter + e = getElement('advanced-filter'); + var f = compileAdvancedFilter(e.value); + if (f || f === false) { + e.className = "good"; + } else { e.className = "bad"; isValid = false; }