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 @@