blob: 0ab4e26900c4fff887426b9a5f32b9f73a925be4 [file] [log] [blame]
/**
* @module alert-manager-sk
* @description <h2><code>alert-manager-sk</code></h2>
*
* The main application element for am.skia.org.
*
*/
import { define } from 'elements-sk/define'
import 'elements-sk/checkbox-sk'
import 'elements-sk/error-toast-sk'
import 'elements-sk/icon/comment-icon-sk'
import 'elements-sk/icon/notifications-icon-sk'
import 'elements-sk/icon/person-icon-sk'
import 'elements-sk/spinner-sk'
import 'elements-sk/styles/buttons'
import 'elements-sk/tabs-panel-sk'
import 'elements-sk/tabs-sk'
import '../../../infra-sk/modules/login-sk'
import '../incident-sk'
import '../email-chooser-sk'
import '../silence-sk'
import { $$ } from 'common-sk/modules/dom'
import { Login } from '../../../infra-sk/modules/login'
import { errorMessage } from 'elements-sk/errorMessage'
import { html, render } from 'lit-html'
import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow'
import { stateReflector} from 'common-sk/modules/stateReflector'
import * as paramset from '../paramset'
import { abbr, displaySilence, expiresIn } from '../am'
// Legal states.
const START = 'start';
const INCIDENT = 'incident';
const EDIT_SILENCE = 'edit_silence';
const VIEW_STATS = 'view_stats';
const MAX_SILENCES_TO_DISPLAY_IN_TAB = 50;
function classOfH2(ele, incident) {
let ret = [];
if (!incident.active) {
ret.push('inactive');
} else if (incident.params.__silence_state === 'silenced') {
ret.push('silenced');
} else if (incident.params.assigned_to) {
ret.push('assigned');
}
if (ele._selected && ele._selected.key === incident.key) {
ret.push('selected');
}
return ret.join(' ');
}
function classOfSilenceH2(ele, silence) {
var ret = [];
if (!silence.active) {
ret.push('inactive');
}
if (ele._selected && ele._selected.key === silence.key) {
ret.push('selected');
}
return ret.join(' ');
}
function editIncident(ele) {
if (ele._selected) {
return html`<incident-sk .silences=${ele._silences} .state=${ele._selected}
></incident-sk>`
} else {
return ``
}
}
function editSilence(ele) {
return html`<silence-sk .state=${ele._current_silence} .incidents=${ele._incidents}
></silence-sk>`;
}
function viewStats(ele) {
return ele._incident_stats.map((i, index) => html`<incident-sk .state=${i} ?minimized params=${index===0}></incident-sk>`)
}
function rightHandSide(ele) {
switch (ele._rhs_state) {
case START:
return ``
case INCIDENT:
return editIncident(ele)
case EDIT_SILENCE:
return editSilence(ele)
case VIEW_STATS:
return viewStats(ele)
default:
return ``
}
}
function hasNotes(o) {
return (o.notes && o.notes.length > 0) ? '' : 'invisible';
}
function displayIncident(incident) {
let ret = [incident.params.alertname];
let abbr = incident.params['abbr'];
if (abbr) {
ret.push(` - ${abbr}`);
}
let s = ret.join(' ');
if (s.length > 33) {
s = s.slice(0, 30) + '...';
}
return s;
}
function trooper(ele) {
if (ele._trooper === ele._user) {
return html`<notifications-icon-sk title='You are the trooper, awesome!'></notifications-icon-sk>`
}
return ``
}
function assignedTo(incident, ele) {
if (incident.params.assigned_to === ele._user) {
return html`<person-icon-sk title='This item is assigned to you.'></person-icon-sk>`
}
return ``
}
function incidentList(ele, incidents) {
return incidents.map(i => html`
<h2 class=${classOfH2(ele, i)} @click=${e => ele._select(i)}>
<span class=noselect>
<checkbox-sk ?checked=${ele._checked.has(i.key)} @change=${ele._check_selected} @click=${ele._clickHandler} id=${i.key}></checkbox-sk>
${assignedTo(i, ele)}
${displayIncident(i)}
</span>
<comment-icon-sk title='This incident has notes.' class=${hasNotes(i)}></comment-icon-sk>
</h2>
`);
}
function statsList(ele) {
return ele._stats.map(stat => html`<h2 @click=${e => ele._statsClick(stat.incident)}>${displayIncident(stat.incident)} <span>${stat.num}</span></h2>`);
}
function numMatchSilence(ele, s) {
if (!ele._incidents) {
return ``;
}
return ele._incidents.filter(
(incident) => paramset.match(s.param_set, incident.params) && incident.active
).length;
}
const template = (ele) => html`
<header>${trooper(ele)}<login-sk></login-sk></header>
<section class=nav>
<tabs-sk @tab-selected-sk=${ele._tabSwitch} selected=${ele._state.tab}>
<button>Mine</button>
<button>Alerts</button>
<button>Silences</button>
<button>Stats</button>
</tabs-sk>
<tabs-panel-sk>
<section class=mine>
${incidentList(ele, ele._incidents.filter(i => i.active && i.params.__silence_state !== 'silenced' && (ele._user === ele._trooper || (i.params.assigned_to === ele._user) || (i.params.owner === ele._user && !i.params.assigned_to))))}
</section>
<section class=incidents>
${incidentList(ele, ele._incidents)}
</section>
<section class=silences>
${ele._silences.slice(0, MAX_SILENCES_TO_DISPLAY_IN_TAB).map(i => html`
<h2 class=${classOfSilenceH2(ele, i)} @click=${e => ele._silenceClick(i)}>
<span>
${displaySilence(i)}
</span>
<span>
<span title='Expires in'>${expiresIn(i)}</span>
<comment-icon-sk title='This silence has notes.' class=${hasNotes(i)}></comment-icon-sk>
<span title='The number of active alerts that match this silence.'>${numMatchSilence(ele, i)}</span>
</span>
</h2>`)}
</section>
<section class=stats>
${statsList(ele)}
</section>
</tabs-panel-sk>
</section>
<section class=edit>
${rightHandSide(ele)}
</section>
<footer>
<spinner-sk id=busy></spinner-sk>
<email-chooser-sk id=chooser></email-chooser-sk>
<error-toast-sk></error-toast-sk>
<footer>
`;
function findParent(ele, tagName) {
while (ele && (ele.tagName !== tagName)) {
ele = ele.parentElement;
}
return ele;
}
define('alert-manager-sk', class extends HTMLElement {
constructor() {
super();
this._incidents = []; // All active incidents.
this._silences = []; // All active silences.
this._stats = []; // Last requested stats.
this._stats_range = '1w';
this._incident_stats = []; // The incidents for a given stat.
this._rhs_state = START; // One of START, INCIDENT, or EDIT_SILENCE.
this._selected = null; // The selected incident, i.e. you clicked on the name.
this._checked = new Set(); // Checked incidents, i.e. you clicked the checkbox.
this._current_silence = null; // A silence under construction.
this._ignored = [ '__silence_state', 'description', 'id', 'swarming', 'assigned_to']; // Params to ignore when constructing silences.
this._shift_pressed_during_click = false; // If the shift key was held down during the mouse click.
this._last_checked_incident = null; // Keeps track of the last checked incident. Used for multi-selecting incidents with shift.
this._incidents_notified = {}; // Keeps track of all incidents that were notified via desktop notifications.
this._user = 'barney@example.org';
this._trooper = '';
this._state = {
tab: 0, // The selected tab.
};
fetch('https://skia-tree-status.appspot.com/current-trooper?format=json', {mode: 'cors'}).then(jsonOrThrow).then(json => {
this._trooper = json.username;
this._render();
});
Login.then(loginstatus => {
this._user = loginstatus.Email;
this._render();
});
}
connectedCallback() {
this._requestDesktopNotificationPermission()
this.addEventListener('save-silence', e => this._saveSilence(e.detail.silence));
this.addEventListener('archive-silence', e => this._archiveSilence(e.detail.silence));
this.addEventListener('reactivate-silence', e => this._reactivateSilence(e.detail.silence));
this.addEventListener('delete-silence', e => this._deleteSilence(e.detail.silence));
this.addEventListener('add-silence-note', e => this._addSilenceNote(e));
this.addEventListener('del-silence-note', e => this._delSilenceNote(e));
this.addEventListener('delete-silence-param', e => this._deleteSilenceParam(e.detail.silence));
this.addEventListener('add-note', e => this._addNote(e));
this.addEventListener('del-note', e => this._delNote(e));
this.addEventListener('take', e => this._take(e));
this.addEventListener('assign', e => this._assign(e));
this.addEventListener('assign-to-owner', e => this._assignToOwner(e));
this._stateHasChanged = stateReflector(
() => this._state,
(state) => {
this._state = state;
this._render();
}
);
this._render();
this._busy = $$('#busy', this);
this._favicon = $$('#favicon');
this._busy.active = true;
this._poll(true);
}
_poll(stopSpinner) {
let incidents = fetch('/_/incidents', {
credentials: 'include',
}).then(jsonOrThrow).then((json) => {
this._incidents = json;
});
let silences = fetch('/_/silences', {
credentials: 'include',
}).then(jsonOrThrow).then((json) => {
this._silences = json;
});
let emails = fetch('/_/emails', {
credentials: 'include',
}).then(jsonOrThrow).then((json) => {
this._emails = json;
});
Promise.all([incidents, silences, emails]).then(() => { this._render() }).catch((msg) => {
if (msg.resp) {
msg.resp.text().then(errorMessage);
} else {
errorMessage(msg);
}
}).finally(() => {
if (stopSpinner) {
this._busy.active = false;
}
window.setTimeout(() => this._poll(), 10000)
});
}
_tabSwitch(e) {
this._state.tab = e.detail.index;
this._stateHasChanged();
// If tab is stats then load stats.
if (e.detail.index === 3) {
this._getStats();
}
this._rhs_state = START;
this._render();
}
_clickHandler(e) {
this._shift_pressed_during_click = e.shiftKey;
e.stopPropagation();
}
_silenceClick(silence) {
this._current_silence = JSON.parse(JSON.stringify(silence));
this._selected = silence;
this._rhs_state = EDIT_SILENCE;
this._render();
}
_statsClick(incident) {
this._selected = incident;
this._incidentStats();
this._rhs_state = VIEW_STATS;
}
// Update the paramset for a silence as Incidents are checked and unchecked.
// TODO(jcgregorio) Remove this once checkbox-sk is fixed.
_check_selected_impl(key, isChecked) {
if (isChecked) {
this._last_checked_incident = key
this._checked.add(key);
this._incidents.forEach(i => {
if (i.key == key) {
paramset.add(this._current_silence.param_set, i.params, this._ignored);
}
});
} else {
this._last_checked_incident = null;
this._checked.delete(key);
this._current_silence.param_set = {};
this._incidents.forEach(i => {
if (this._checked.has(i.key)) {
paramset.add(this._current_silence.param_set , i.params, this._ignored);
}
});
}
this._rhs_state = EDIT_SILENCE;
this._render();
}
_check_selected(e) {
let checkbox = findParent(e.target, 'CHECKBOX-SK');
if (!this._checked.size) {
// Request a new silence.
fetch('/_/new_silence', {
credentials: 'include',
}).then(jsonOrThrow).then((json) => {
this._selected = null;
this._current_silence = json;
this._check_selected_impl(checkbox.id, checkbox._input.checked);
}).catch(errorMessage);
} else {
if (this._shift_pressed_during_click && this._last_checked_incident) {
let foundStart = false;
let foundEnd = false;
let incidents_to_check = [];
this._incidents.some(i => {
if (i.key == this._last_checked_incident || i.key == checkbox.id) {
if (!foundStart) {
// This is the 1st time we have entered this block. This means we
// found the first incident.
foundStart = true;
} else {
// This is the 2nd time we have entered this block. This means we
// found the last incident.
foundEnd = true;
}
}
if (foundStart) {
incidents_to_check.push(i.key);
}
return foundEnd;
});
if (foundStart && foundEnd) {
incidents_to_check.forEach(key => {
this._check_selected_impl(key, true);
});
} else {
// Could not find start and/or end incident. Only check the last
// clicked.
this._check_selected_impl(checkbox.id, checkbox._input.checked);
}
} else {
this._check_selected_impl(checkbox.id, checkbox._input.checked);
}
}
}
_select(incident) {
this._rhs_state = INCIDENT;
this._checked = new Set();
this._selected = incident;
this._current_silence = null;
this._render();
}
_addNote(e) {
this._doImpl('/_/add_note', e.detail);
}
_delNote(e) {
this._doImpl('/_/del_note', e.detail);
}
_deleteSilenceParam(silence) {
// Don't save silences that are just being created when you delete a param.
if (!silence.key) {
this._current_silence = silence;
this._render();
return
}
this._checked = new Set();
this._doImpl('/_/save_silence', silence, json => this._silenceAction(json, false));
}
_saveSilence(silence) {
this._checked = new Set();
this._doImpl('/_/save_silence', silence, json => this._silenceAction(json, true));
}
_archiveSilence(silence) {
this._doImpl('/_/archive_silence', silence, json => this._silenceAction(json, true));
}
_reactivateSilence(silence) {
this._doImpl('/_/reactivate_silence', silence, json => this._silenceAction(json, false));
}
_deleteSilence(silence) {
this._doImpl('/_/del_silence', silence, json => {
for (let i = 0; i < this._silences.length; i++) {
if (this._silences[i].key === json.key) {
this._silences.splice(i, 1)
this._rhs_state = START;
break;
}
}
});
}
_addSilenceNote(e) {
this._doImpl('/_/add_silence_note', e.detail, json => this._silenceAction(json, false));
}
_delSilenceNote(e) {
this._doImpl('/_/del_silence_note', e.detail, json => this._silenceAction(json, false));
}
_assign(e) {
const owner = this._selected && this._selected.params.owner;
$$('#chooser', this).open(this._emails, owner).then(email => {
let detail = {
key: e.detail.key,
email: email,
}
this._doImpl('/_/assign', detail);
});
}
_assignToOwner(e) {
const owner = this._selected && this._selected.params.owner;
let detail = {
key: e.detail.key,
email: owner,
}
this._doImpl('/_/assign', detail);
}
_take(e) {
this._doImpl('/_/take', e.detail);
// Do not do desktop notification on takes, it is redundant.
this._incidents_notified[e.detail.key] = true;
}
_getStats() {
let detail = {
range: this._stats_range,
}
this._doImpl('/_/stats', detail, json => this._statsAction(json));
}
_incidentStats() {
let detail = {
incident: this._selected,
range: this._stats_range,
}
this._doImpl('/_/incidents_in_range', detail, json => this._incidentStatsAction(json));
}
// Actions to take after updating incident stats.
_incidentStatsAction(json) {
this._incident_stats = json;
}
// Actions to take after updating Stats.
_statsAction(json) {
this._stats = json;
}
// Actions to take after updating an Incident.
_incidentAction(json) {
let incidents = this._incidents;
for (let i = 0; i < incidents.length; i++) {
if (incidents[i].key === json.key) {
incidents[i] = json;
break;
}
}
this._selected = json;
}
// Actions to take after updating a Silence.
_silenceAction(json, clear) {
let found = false;
this._current_silence = json;
for (let i = 0; i < this._silences.length; i++) {
if (this._silences[i].key === json.key) {
this._silences[i] = json;
found = true;
break;
}
}
if (!found) {
this._silences.push(json);
}
if (clear) {
this._rhs_state = START;
}
}
// Common work done for all fetch requests.
_doImpl(url, detail, action=json => this._incidentAction(json)) {
this._busy.active = true;
fetch(url, {
body: JSON.stringify(detail),
headers: {
'content-type': 'application/json',
},
credentials: 'include',
method: 'POST',
}).then(jsonOrThrow).then(json => {
action(json)
this._render();
this._busy.active = false;
}).catch(msg => {
this._busy.active = false;
msg.resp.text().then(errorMessage);
});
}
// Fix-up all the incidents and silences, including re-sorting them.
_rationalize() {
this._incidents.forEach(incident => {
let silenced = this._silences.reduce((isSilenced, silence) => {
return isSilenced ||
(silence.active && paramset.match(silence.param_set, incident.params));
}, false);
incident.params.__silence_state = silenced ? 'silenced' : 'active';
});
// Sort the incidents, using the following 'sortby' list as tiebreakers.
const sortby = ['__silence_state', 'assigned_to', 'alertname', 'abbr', 'id'];
this._incidents.sort((a, b) => {
// Sort active before inactive.
if (a.active !== b.active) {
return a.active ? -1 : 1;
}
// Inactive incidents are then sorted by 'lastseen' timestamp.
if (!a.active) {
const delta = b.last_seen - a.last_seen;
if (delta) {
return delta;
}
}
for (let i = 0; i < sortby.length; i++) {
const key = sortby[i];
const left = a.params[key] || '';
const right = b.params[key] || '';
const cmp = left.localeCompare(right);
if (cmp) {
return cmp;
}
}
return 0;
});
this._silences.sort((a,b) => {
// Sort active before inactive.
if (a.active != b.active) {
return a.active ? -1 : 1;
}
return b.updated - a.updated;
});
}
_needsTriaging(incident, isTrooper) {
if (incident.active
&& (incident.params.__silence_state != 'silenced')
&& (
(isTrooper && !incident.params.assigned_to)
|| (incident.params.assigned_to == this._user)
|| (incident.params.owner == this._user
&& !incident.params.assigned_to)
)
) {
return true;
}
return false;
}
_requestDesktopNotificationPermission() {
if(Notification && Notification.permission === 'default') {
Notification.requestPermission(function (permission) {
if(!('permission' in Notification)) {
Notification.permission = permission;
}
});
}
}
_sendDesktopNotification(unNotifiedIncidents) {
if (unNotifiedIncidents.length == 0) {
// Do nothing.
return;
}
let text = '';
if (unNotifiedIncidents.length == 1) {
text = unNotifiedIncidents[0].params.alertname + "\n\n" + unNotifiedIncidents[0].params.description;
} else {
text = `There are ${unNotifiedIncidents.length} alerts assigned to you`;
}
let notification = new Notification('am.skia.org notification', {
icon: '/static/icon-active.png',
body: text,
// 'tag' handles multi-tab scenarios. When multiple tabs are open then
// only one notification is sent for the same alert.
tag: 'alertManagerNotification' + text,
});
// onclick move focus to the am.skia.org tab and close the notification.
var that = this;
notification.onclick = function() {
parent.focus();
window.focus(); // Supports older browsers.
that._select(unNotifiedIncidents[0]); // Display the 1st incident.
this.close();
};
setTimeout(notification.close.bind(notification), 10000);
}
_render() {
this._rationalize();
render(template(this), this, {eventContext: this});
// Update the icon.
let isTrooper = this._user === this._trooper;
let numActive = this._incidents.reduce((n, incident) => n += this._needsTriaging(incident, isTrooper) ? 1 : 0, 0);
// Show desktop notifications only if permission was granted and only if
// silences have been successfully fetched. If silences have not been
// fetched yet then we might end up notifying on silenced incidents.
if (Notification.permission === "granted" && this._silences.length != 0) {
const unNotifiedIncidents = this._incidents.filter(i =>
!this._incidents_notified[i.key] && this._needsTriaging(i, isTrooper));
this._sendDesktopNotification(unNotifiedIncidents);
unNotifiedIncidents.forEach(i => this._incidents_notified[i.key] = true);
}
document.title = `${numActive} - AlertManager`;
if (!this._favicon) {
return;
}
if (numActive > 0) {
this._favicon.href = '/static/icon-active.png';
} else {
this._favicon.href = '/static/icon.png';
}
}
});