blob: 1563ebfa978f2391d6d805941de65a21be70709b [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/alarm-off-icon-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 'elements-sk/toast-sk';
import '../incident-sk';
import '../bot-chooser-sk';
import '../email-chooser-sk';
import '../silence-sk';
import dialogPolyfill from 'dialog-polyfill';
import { diffDate } from 'common-sk/modules/human';
import { CheckOrRadio } from 'elements-sk/checkbox-sk/checkbox-sk';
import { HintableObject } from 'common-sk/modules/hintable';
import { $, $$ } from 'common-sk/modules/dom';
import { errorMessage } from 'elements-sk/errorMessage';
import { html, render, TemplateResult } from 'lit-html';
import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow';
import { stateReflector } from 'common-sk/modules/stateReflector';
import { SpinnerSk } from 'elements-sk/spinner-sk/spinner-sk';
import { Login } from '../../../infra-sk/modules/login';
import { BotChooserSk } from '../bot-chooser-sk/bot-chooser-sk';
import { EmailChooserSk } from '../email-chooser-sk/email-chooser-sk';
import '../../../infra-sk/modules/theme-chooser-sk';
import * as paramset from '../paramset';
import { displaySilence, expiresIn, getSilenceFullName } from '../am';
import {
Silence, Incident, StatsRequest, Stat, IncidentsResponse, ParamSet, Params, IncidentsInRangeRequest, AuditLog,
} from '../json';
// Legal states.
const START = 'start';
const INCIDENT = 'incident';
const EDIT_SILENCE = 'edit_silence';
const VIEW_STATS = 'view_stats';
const VIEW_AUDITLOG = 'view_auditlog';
const MAX_SILENCES_TO_DISPLAY_IN_TAB = 50;
const BOT_CENTRIC_PARAMS = ['alertname', 'bot'];
class State {
tab: number = 0; // The selected tab.
alert_id: string = ''; // The selected alert (if any).
}
// This response structure comes from chrome-ops-rotation-proxy.appspot.com.
// We do not have access to the structure to generate TS.
interface RotationResp {
emails: string[];
}
export class AlertManagerSk extends HTMLElement {
private incidents: Incident[] = []; // All active incidents.
private filterSilencesVal: string = '';
private filterAuditLogsVal: string = '';
private silences: Silence[] = []; // All active silences.
private stats: Stat[] = []; // Last requested stats.
private audit_logs: AuditLog[] = [];
private stats_range = '1w';
private incident_stats: Incident[] = []; // The incidents for a given stat.
private rhs_state = START; // One of START, INCIDENT, or EDIT_SILENCE.
private selected: Incident|Silence|null = null; // The selected incident, i.e. you clicked on the name.
private checked = new Set(); // Checked incidents, i.e. you clicked the checkbox.
private bots_to_incidents: Record<string, Incident[]> = {}; // Bot names to their incidents. Used in bot-centric view.
private isBotCentricView = false; // Determines if bot-centric view is displayed on incidents tab.
private current_silence: Silence|null = null; // A silence under construction.
// Params to ignore when constructing silences.
private ignored = ['__silence_state', 'description', 'id', 'swarming', 'assigned_to',
'kubernetes_pod_name', 'instance', 'pod_template_hash', 'abbr_owner_regex',
'controller_revision_hash'];
private shift_pressed_during_click = false; // If the shift key was held down during the mouse click.
private last_checked_incident: string|null = null; // Keeps track of the last checked incident. Used for multi-selecting incidents with shift.
private incidents_notified: Record<string, boolean> = {}; // Keeps track of all incidents that were notified via desktop notifications.
private incidentsToRecentlyExpired: Record<string, boolean> = {}; // Map of incident IDs to whether their silences recently expired.
private user = 'barney@example.org';
private infra_gardener = '';
// State is reflected to the URL via stateReflector.
private state: State = {
tab: 0,
alert_id: '',
};
private favicon: HTMLAnchorElement | null = null;
private spinner: SpinnerSk | null = null;
private emails: string[] = [];
private helpDialog: HTMLDialogElement | null = null;
constructor() {
super();
fetch('https://chrome-ops-rotation-proxy.appspot.com/current/grotation:skia-infra-gardener', { mode: 'cors' }).then(jsonOrThrow).then((json: RotationResp) => {
this.infra_gardener = json.emails[0];
this._render();
});
Login.then((loginstatus) => {
this.user = loginstatus.Email;
this._render();
});
}
private static template = (ele: AlertManagerSk) => html`
<header>
${ele.infraGardener()}
<theme-chooser-sk></theme-chooser-sk>
</header>
<dialog id=help>
<h2>Keyboard Controls</h2>
<table>
<tr><td class=mono>'ArrowDown'</td><td>Move down the list of incidents.</td></tr>
<tr><td class=mono>'ArrowUp'</td><td>Move up the list of incidents.</td></tr>
<tr><td class=mono>'Space'</td><td>Check the incident to create a silence or to assign.</td></tr>
<tr><td class=mono>'Shift+ArrowDown'</td><td>Check the below incident to create a silence (only if the current incident is checked).</td></tr>
<tr><td class=mono>'Shift+ArrowUp'</td><td>Check the above incident to create a silence (only if the current incident is checked).</td></tr>
<tr><td class=mono>'ArrowRight'</td><td>Move to the first textarea in RHS.</td></tr>
<tr><td class=mono>'a'</td><td>Assign selected alert(s).</td></tr>
<tr><td class=mono>'b'</td><td>Switches view from normal to bot-centric view.</td></tr>
<tr><td class=mono>'1'</td><td>Switches to the "Mine" tab.</td></tr>
<tr><td class=mono>'2'</td><td>Switches to the "Alerts" tab.</td></tr>
<tr><td class=mono>'?'</td><td>Show help.</td></tr>
<tr><td class=mono>'Esc'</td><td>Stop showing help.</td></tr>
</table>
<div class=footnote>
Note: Keyboard controls only work in the 'Mine' and 'Alerts' tabs. 'Bot-centric view' is also not supported.
</div>
</dialog>
<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>
<button>Audit</button>
</tabs-sk>
<tabs-panel-sk>
<section class=mine>
<span class=selection-buttons>
${ele.displayAssignMultiple()}
${ele.displayClearSelections()}
</span>
${ele.incidentList(ele.getMyIncidents(), false)}
</section>
<section class=incidents>
${ele.botCentricBtn()}
<span class=selection-buttons>
${ele.displayAssignMultiple()}
${ele.displayClearSelections()}
</span>
${ele.incidentList(ele.incidents, ele.isBotCentricView)}
</section>
<section class=silences>
<input class=silences-filter placeholder="Filter silences" .value="${ele.filterSilencesVal}" @input=${(e: Event) => ele.filterSilencesEvent(e)}></input>
<br/><br/>
${ele.silences.filter((silence: Silence) => getSilenceFullName(silence.param_set).includes(ele.filterSilencesVal)).slice(0, MAX_SILENCES_TO_DISPLAY_IN_TAB).map((i: Silence) => html`
<h2 class=${ele.classOfSilenceH2(i)} @click=${() => ele.silenceClick(i)}>
<span>
${displaySilence(i.param_set)}
</span>
<span>
<span title='Expires in'>${expiresIn(i.active, i.created, i.duration)}</span>
<comment-icon-sk title='This silence has notes.' class=${ele.hasNotes(i)}></comment-icon-sk>
<span title='The number of active alerts that match this silence.'>${ele.numMatchSilence(i)}</span>
</span>
</h2>`)}
</section>
<section class=stats>
${ele.statsList()}
</section>
<section class=auditlogs>
<input class=auditlogs-filter placeholder="Filter audit logs" .value="${ele.filterAuditLogsVal}" @input=${(e: Event) => ele.filterAuditLogsEvent(e)}></input>
</section>
</tabs-panel-sk>
</section>
<section class=edit>
${ele.rightHandSide()}
</section>
<footer>
<spinner-sk id=busy></spinner-sk>
<bot-chooser-sk id=bot-chooser></bot-chooser-sk>
<email-chooser-sk id=email-chooser></email-chooser-sk>
<error-toast-sk></error-toast-sk>
</footer>
`;
connectedCallback(): void {
this.requestDesktopNotificationPermission();
this.addEventListener('save-silence', (e) => this.saveSilence((e as CustomEvent).detail.silence));
this.addEventListener('archive-silence', (e) => this.archiveSilence((e as CustomEvent).detail.silence));
this.addEventListener('reactivate-silence', (e) => this.reactivateSilence((e as CustomEvent).detail.silence));
this.addEventListener('delete-silence', (e) => this.deleteSilence((e as CustomEvent).detail.silence));
this.addEventListener('add-silence-note', (e) => this.addSilenceNote(e as CustomEvent));
this.addEventListener('del-silence-note', (e) => this.delSilenceNote(e as CustomEvent));
this.addEventListener('add-silence-param', (e) => this.addSilenceParam((e as CustomEvent).detail.silence));
this.addEventListener('delete-silence-param', (e) => this.deleteSilenceParam((e as CustomEvent).detail.silence));
this.addEventListener('modify-silence-param', (e) => this.modifySilenceParam((e as CustomEvent).detail.silence));
this.addEventListener('add-note', (e) => this.addNote(e as CustomEvent));
this.addEventListener('del-note', (e) => this.delNote(e as CustomEvent));
this.addEventListener('take', (e) => this.take(e as CustomEvent));
this.addEventListener('bot-chooser', () => this.botChooser());
this.addEventListener('assign', (e) => this.assign(e as CustomEvent));
this.addEventListener('assign-to-owner', (e) => this.assignToOwner(e as CustomEvent));
// For keyboard navigation.
document.addEventListener('keydown', (e) => this.keyDown(e));
this.stateHasChanged = stateReflector(
/* getState */ () => (this.state as unknown) as HintableObject,
/* setState */ (newState) => {
this.state = (newState as unknown) as State;
// Set rhs_side to AUDIT_LOG if tab is on Audit.
if (this.state.tab === 4) {
this.getAuditLogs();
this.rhs_state = VIEW_AUDITLOG;
}
this._render();
},
);
this._render();
this.helpDialog = $$('#help', this);
dialogPolyfill.registerDialog(this.helpDialog!);
this.spinner = $$('#busy', this) as SpinnerSk;
this.favicon = $$('#favicon');
this.spinner.active = true;
this.poll(true);
}
private getMyIncidents(): Incident[] {
return this.incidents.filter((i: Incident) => i.active
&& i.params.__silence_state !== 'silenced'
&& (this.user === this.infra_gardener
|| i.params.assigned_to === this.user
|| (i.params.owner === this.user && !i.params.assigned_to)));
}
private keyDown(e: KeyboardEvent) {
// Ignore all tabs other than the mine and incident tabs.
if (this.state.tab !== 0 && this.state.tab !== 1) {
return;
}
// Too complicated to navigate through issues in bot centric view
// because the layout of incidents is completely different than
// the standard view.
if (this.isBotCentricView) {
return;
}
// Ignore IME composition events.
if (e.isComposing || e.keyCode === 229) {
return;
}
// Do not perform keyboard navigation if input/textarea/select are
// selected.
const focusedElem: Element | null = document.activeElement;
const ignoreKeyboardNavigationInTags = ['input', 'textarea', 'select'];
if (focusedElem && ignoreKeyboardNavigationInTags.includes(focusedElem.tagName.toLowerCase())) {
return;
}
switch (e.key) {
case '?':
(this.helpDialog! as any).showModal();
break;
case 'ArrowUp':
this.keyboardNavigateIncidents(e, true);
break;
case 'ArrowDown':
this.keyboardNavigateIncidents(e, false);
break;
case 'ArrowRight':
// Put focus on the 1st textarea on the page (if one exists).
if ($('textarea') && $('textarea').length > 0) {
($('textarea')[0] as HTMLElement).focus();
}
break;
case ' ':
if (this.selected) {
($$(`#${this.selected!.key}`)! as HTMLElement).click();
} else if (this.checked.size > 0) {
// No incident was selected. Clear all the checked incidents and
// start from the first incident.
this.checked.clear();
this.last_checked_incident = null;
if (this.incidents.length > 0) {
this.selected = this.incidents[0];
}
this._render();
}
e.preventDefault();
break;
case 'b':
this.flipBotCentricView();
break;
case 'a':
// If an alert is selected and nothing is checked then
// check the selected alert before assigning it.
if (this.selected && this.checked.size === 0) {
($$(`#${this.selected!.key}`)! as HTMLElement).click();
}
this.assignMultiple();
break;
case '1':
this.keyboardNavigateTabs(0);
break;
case '2':
this.keyboardNavigateTabs(1);
break;
default:
break;
}
}
private keyboardNavigateTabs(tabIndex: number) {
this.state.tab = tabIndex;
this.state.alert_id = '';
this.selected = null;
this.checked.clear();
this.rhs_state = START;
this.stateHasChanged();
this._render();
}
private keyboardNavigateIncidents(e: KeyboardEvent, reverseDirection: boolean) {
// If an alert is not selected or checked then the first incident in the
// incidents array can be starting point.
let foundStartingPoint = (this.selected === null) && (this.checked.size === 0);
// Figure out which incidents we are looking at.
let incidentsArray = this.incidents;
if (this.state.tab === 0) {
// Use "my" incidents if we are on the "Mine" tab.
incidentsArray = this.getMyIncidents();
}
if (reverseDirection && !foundStartingPoint) {
// We only want to reverse incidents if we have not yet
// found a starting point else we will start with the
// last alert.
incidentsArray = incidentsArray.reverse();
}
for (const incident of incidentsArray) {
if (foundStartingPoint) {
const k = incident.key;
// If shift key is pressed then check the incident if it is not already
// checked.
if (e.shiftKey && this.last_checked_incident) {
if (this.checked.has(k)) {
// This incident is already checked. Set it as the
// last_checked_incident so we can proceed from here the next time.
this.last_checked_incident = k;
} else {
// Check the incident.
($$(`#${k}`)! as HTMLElement).click();
}
} else {
($$(`#container-${k}`)! as HTMLElement).click();
}
break;
}
if (this.selected && this.selected.key === incident.key) {
// We found the selected incident, the next incident in the iteration
// is the one we need to process.
foundStartingPoint = true;
} else if (this.checked.size > 0 && this.last_checked_incident === incident.key) {
// We found the last checked incident, the next incident in the
// iteration is the one we need to process.
foundStartingPoint = true;
}
}
}
// Call this anytime something in private state is changed. Will be replaced
// with the real function once stateReflector has been setup.
// eslint-disable-next-line @typescript-eslint/no-empty-function
private stateHasChanged = () => {};
private classOfH2(incident: Incident): string {
const 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 (this.selected && this.selected.key === incident.key) {
ret.push('selected');
}
return ret.join(' ');
}
private classOfSilenceH2(silence: Silence): string {
const ret = [];
if (!silence.active) {
ret.push('inactive');
}
if (this.selected && this.selected.key === silence.key) {
ret.push('selected');
}
return ret.join(' ');
}
private editIncident(): TemplateResult {
if (this.selected) {
return html`<incident-sk .incident_silences=${this.silences} .incident_state=${this.selected}
></incident-sk>`;
}
return html``;
}
private editSilence(): TemplateResult {
return html`<silence-sk .silence_state=${this.current_silence} .silence_incidents=${this.incidents}
></silence-sk>`;
}
private viewStats(): TemplateResult[] {
return this.incident_stats.map((i, index) => html`<incident-sk .incident_state=${i} ?minimized params=${index === 0}></incident-sk>`);
}
private rightHandSide(): TemplateResult|TemplateResult[] {
switch (this.rhs_state) {
case START:
return [];
case INCIDENT:
return this.editIncident();
case EDIT_SILENCE:
return this.editSilence();
case VIEW_STATS:
return this.viewStats();
case VIEW_AUDITLOG:
return this.viewAuditLogsTable();
default:
return [];
}
}
private hasNotes(o: Incident| Silence): string {
return (o.notes && o.notes.length > 0) ? '' : 'invisible';
}
private hasRecentlyExpiredSilence(incident: Incident): string {
return (this.incidentsToRecentlyExpired[incident.id]) ? '' : 'invisible';
}
private displayIncident(incident: Incident): TemplateResult {
const ret = [incident.params.alertname];
const abbr = incident.params.abbr;
if (abbr) {
ret.push(` - ${abbr}`);
}
const fullIncident = ret.join(' ');
let displayIncident = fullIncident;
if (displayIncident.length > 33) {
displayIncident = `${displayIncident.slice(0, 30)}...`;
}
return html`<span title="${fullIncident}">${displayIncident}</span>`;
}
private infraGardener(): TemplateResult {
if (this.infra_gardener === this.user) {
return html`<notifications-icon-sk title='You are the Infra Gardener, awesome!'></notifications-icon-sk>`;
}
return html``;
}
private assignedTo(incident: Incident): TemplateResult {
if (incident.params.assigned_to === this.user) {
return html`<person-icon-sk title='This item is assigned to you.'></person-icon-sk>`;
} if (incident.params.assigned_to) {
return html`<span class='assigned-circle' title='This item is assigned to ${incident.params.assigned_to}.'>${incident.params.assigned_to[0].toUpperCase()}</span>`;
}
return html``;
}
private populateBotsToIncidents(incidents: Incident[]): void {
// Reset bots_to_incidents and populate it from scratch.
this.bots_to_incidents = {};
for (let i = 0; i < incidents.length; i++) {
const incident = incidents[i];
if (incident.params && incident.params.bot) {
// Only consider active bot incidents that are not assigned or silenced.
if (!incident.active || incident.params.__silence_state === 'silenced'
|| incident.params.assigned_to) {
continue;
}
const botName = incident.params.bot;
if (this.bots_to_incidents[botName]) {
this.bots_to_incidents[botName].push(incident);
} else {
this.bots_to_incidents[botName] = [incident];
}
}
}
}
private botCentricView(): TemplateResult[] {
this.populateBotsToIncidents(this.incidents);
const botsHTML: TemplateResult[] = [];
Object.keys(this.bots_to_incidents).forEach((botName) => {
botsHTML.push(html`
<h2 class="bot-centric">
<span class=noselect>
<checkbox-sk class=bot-alert-checkbox ?checked=${this.isBotChecked(this.bots_to_incidents[botName])} @change=${this.check_selected} @click=${this.clickHandler} id=${botName}></checkbox-sk>
<span class=bot-alert>
${botName}
<span class=bot-incident-list>
${this.incidentListForBot(this.bots_to_incidents[botName])}
</span>
</span>
</span>
</h2>
`);
});
return botsHTML;
}
// Checks to see if all the incidents for the bot are checked.
private isBotChecked(incidents: Incident[]): boolean {
for (let i = 0; i < incidents.length; i++) {
if (!this.checked.has(incidents[i].key)) {
return false;
}
}
return true;
}
private incidentListForBot(incidents: Incident[]): TemplateResult {
const incidentsHTML = incidents.map((i) => html`<li @click=${() => this.select(i)}>${i.params.alertname}</li>`);
return html`<ul class=bot-incident-elem>${incidentsHTML}</ul>`;
}
private incidentList(incidents: Incident[], isBotCentricView: boolean): TemplateResult[] {
if (isBotCentricView) {
return this.botCentricView();
}
return incidents.map((i) => html`
<h2 class=${this.classOfH2(i)} @click=${() => this.select(i)} id=container-${i.key}>
<span class=noselect>
<checkbox-sk ?checked=${this.checked.has(i.key)} @change=${this.check_selected} @click=${this.clickHandler} id=${i.key}></checkbox-sk>
${this.assignedTo(i)}
${this.displayIncident(i)}
</span>
<span>
<alarm-off-icon-sk title='This incident has a recently expired silence' class=${this.hasRecentlyExpiredSilence(i)}></alarm-off-icon-sk>
<comment-icon-sk title='This incident has notes.' class=${this.hasNotes(i)}></comment-icon-sk>
</span>
</h2>
`);
}
private statsList(): TemplateResult[] {
return this.stats.map((stat: Stat) => html`<h2 @click=${() => this.statsClick(stat.incident)}>${this.displayIncident(stat.incident)} <span>${stat.num}</span></h2>`);
}
private viewAuditLogsTable(): TemplateResult {
return html`
<table id=audit-logs-table>
${this.getAuditLogsRows()}
</table>
`;
}
private getAuditLogsRows(): TemplateResult[] {
const filtered_audit_logs = this.audit_logs.filter((a) => a.body.includes(this.filterAuditLogsVal));
return filtered_audit_logs.map((a) => html`
<tr>
<td>
${diffDate(a.timestamp * 1000)} ago
</td>
<td>
${a.action}
</td>
<td>
${a.user}
</td>
<td>
${a.body}
</td>
</tr>
`);
}
private numMatchSilence(s: Silence): number {
if (!this.incidents) {
return 0;
}
return this.incidents.filter(
(incident: Incident) => paramset.match(s.param_set, incident.params) && incident.active,
).length;
}
private displayClearSelections(): TemplateResult {
return html`<button class=selection ?disabled=${this.checked.size === 0} @click=${this.clearSelections}>Clear selections</button>`;
}
private filterSilencesEvent(e: Event): void {
this.filterSilencesVal = (e.target as HTMLInputElement).value;
this._render();
}
private filterAuditLogsEvent(e: Event): void {
this.filterAuditLogsVal = (e.target as HTMLInputElement).value;
this._render();
}
private clearSelections(): void {
this.checked = new Set();
this._render();
}
private displayAssignMultiple(): TemplateResult {
return html`<button class=selection ?disabled=${this.checked.size === 0} @click=${this.assignMultiple}>Assign ${this.checked.size} alerts</button>`;
}
private botCentricBtn(): TemplateResult {
let buttonText;
if (this.isBotCentricView) {
buttonText = 'Switch to Normal view';
} else {
buttonText = 'Switch to Bot-centric view';
}
return html`<button @click=${this.flipBotCentricView}>${buttonText}</button>`;
}
private findParent(ele: HTMLElement|null, tagName: string): HTMLElement|null {
while (ele && (ele.tagName !== tagName)) {
ele = ele.parentElement;
}
return ele;
}
private poll(stopSpinner: boolean): void {
const incidents = fetch('/_/incidents', {
credentials: 'include',
}).then(jsonOrThrow).then((json: IncidentsResponse) => {
this.incidents = json.incidents || [];
// If alert_id is specified and it is in supported rhs_states then display
// an incident.
if ((this.rhs_state === START || this.rhs_state === INCIDENT)
&& this.state.alert_id) {
for (let i = 0; i < this.incidents.length; i++) {
if (this.incidents[i].id === this.state.alert_id) {
this.select(this.incidents[i]);
break;
}
}
}
this.incidents = json.incidents || [];
this.incidentsToRecentlyExpired = json.ids_to_recently_expired_silences || {};
});
const silences = fetch('/_/silences', {
credentials: 'include',
}).then(jsonOrThrow).then((json: Silence[]) => {
this.silences = json;
});
const emails = fetch('/_/emails', {
credentials: 'include',
}).then(jsonOrThrow).then((json: string[]) => {
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.spinner!.active = false;
}
window.setTimeout(() => this.poll(false), 10000);
});
}
private tabSwitch(e: CustomEvent): void {
this.state.tab = e.detail.index;
// Unset alert_id when switching tabs.
this.state.alert_id = '';
this.stateHasChanged();
// Unset filters when switching tabs.
this.filterSilencesVal = '';
this.filterAuditLogsVal = '';
// If tab is stats then load stats.
if (e.detail.index === 3) {
this.getStats();
}
// If tab is silences then display empty silence to populate from scratch.
// This will go away if any existing silence is clicked on.
if (e.detail.index === 2) {
fetch('/_/new_silence', {
credentials: 'include',
}).then(jsonOrThrow).then((json: Silence) => {
this.selected = null;
this.current_silence = json;
this.rhs_state = EDIT_SILENCE;
this._render();
}).catch(errorMessage);
} else if (e.detail.index === 4) {
// If tab is audit logs then load them.
this.getAuditLogs();
this.rhs_state = VIEW_AUDITLOG;
this._render();
} else {
this.rhs_state = START;
this._render();
}
}
private clickHandler(e: KeyboardEvent): void {
this.shift_pressed_during_click = e.shiftKey;
e.stopPropagation();
}
private silenceClick(silence: Silence): void {
this.current_silence = JSON.parse(JSON.stringify(silence)) as Silence;
this.selected = silence;
this.rhs_state = EDIT_SILENCE;
this._render();
}
private statsClick(incident: Incident): void {
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.
private check_selected_impl(key: string, isChecked: boolean): void {
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);
}
});
}
if (this.isBotCentricView) {
this.make_bot_centric_param_set(this.current_silence!.param_set);
}
this.rhs_state = EDIT_SILENCE;
this._render();
}
// Goes through the paramset and leaves only silence keys that are useful
// in bot-centric view like 'alertname' and 'bot'.
private make_bot_centric_param_set(target_paramset: ParamSet): void {
Object.keys(target_paramset).forEach((key) => {
if (BOT_CENTRIC_PARAMS.indexOf(key) === -1) {
delete target_paramset[key];
}
});
}
private check_selected(e: Event): void {
const checkbox = this.findParent(e.target as HTMLElement, 'CHECKBOX-SK') as CheckOrRadio;
const incidents_to_check: string[] = [];
if (this.isBotCentricView && this.bots_to_incidents
&& this.bots_to_incidents[checkbox.id]) {
this.bots_to_incidents[checkbox.id].forEach((i) => {
incidents_to_check.push(i.key);
});
} else {
incidents_to_check.push(checkbox.id);
}
const checkSelectedImplFunc = () => {
incidents_to_check.forEach((id) => {
this.check_selected_impl(id, checkbox.checked);
});
};
if (!this.checked.size) {
// Request a new silence.
fetch('/_/new_silence', {
credentials: 'include',
}).then(jsonOrThrow).then((json) => {
this.selected = null;
this.current_silence = json;
checkSelectedImplFunc();
}).catch(errorMessage);
} else if (this.shift_pressed_during_click && this.last_checked_incident) {
let foundStart = false;
let foundEnd = false;
// Find all incidents included in the range during shift click.
const incidents_included_in_range: string[] = [];
// The incidents we go through for shift click selections will be
// different for bot-centric vs normal view.
const incidents = this.isBotCentricView
? ([] as Incident[]).concat(...Object.values(this.bots_to_incidents))
: this.incidents;
incidents.some((i) => {
if (i.key === this.last_checked_incident
|| incidents_to_check.includes(i.key)) {
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_included_in_range.push(i.key);
}
return foundEnd;
});
if (foundStart && foundEnd) {
incidents_included_in_range.forEach((key) => {
this.check_selected_impl(key, true);
});
} else {
// Could not find start and/or end incident. Only check the last
// clicked.
checkSelectedImplFunc();
}
} else {
checkSelectedImplFunc();
}
}
private select(incident: Incident): void {
this.state.alert_id = incident.id;
this.stateHasChanged();
this.rhs_state = INCIDENT;
this.checked = new Set();
this.selected = incident;
this.current_silence = null;
this._render();
}
private addNote(e: CustomEvent): void {
this.doImpl('/_/add_note', e.detail);
}
private delNote(e: CustomEvent): void {
this.doImpl('/_/del_note', e.detail);
}
private addSilenceParam(silence: Silence): void {
// Don't save silences that are just being created when you add a param.
if (!silence.key) {
this.current_silence = silence;
this._render();
return;
}
this.checked = new Set();
this.doImpl('/_/save_silence', silence, (json: Silence) => this.silenceAction(json, false));
}
private deleteSilenceParam(silence: Silence): void {
// 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: Silence) => this.silenceAction(json, false));
}
private modifySilenceParam(silence: Silence): void {
// Don't save silences that are just being created when you modify a param.
if (!silence.key) {
this.current_silence = silence;
this._render();
return;
}
this.checked = new Set();
this.doImpl('/_/save_silence', silence, (json: Silence) => this.silenceAction(json, false));
}
private saveSilence(silence: Silence): void {
this.checked = new Set();
this.doImpl('/_/save_silence', silence, (json: Silence) => this.silenceAction(json, true));
}
private archiveSilence(silence: Silence): void {
this.doImpl('/_/archive_silence', silence, (json: Silence) => this.silenceAction(json, true));
}
private reactivateSilence(silence: Silence): void {
this.doImpl('/_/reactivate_silence', silence, (json: Silence) => this.silenceAction(json, false));
}
private deleteSilence(silence: Silence): void {
this.doImpl('/_/del_silence', silence, (json: Silence) => {
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;
}
}
});
}
private addSilenceNote(e: CustomEvent): void {
this.doImpl('/_/add_silence_note', e.detail, (json: Silence) => this.silenceAction(json, false));
}
private delSilenceNote(e: CustomEvent): void {
this.doImpl('/_/del_silence_note', e.detail, (json: Silence) => this.silenceAction(json, false));
}
private botChooser(): void {
this.populateBotsToIncidents(this.incidents);
($$('#bot-chooser', this) as BotChooserSk).open(this.bots_to_incidents, this.current_silence!.param_set.bot!).then((bot) => {
if (!bot) {
return;
}
const bot_incidents = this.bots_to_incidents[bot];
bot_incidents.forEach((i) => {
const bot_centric_params: Params = {};
BOT_CENTRIC_PARAMS.forEach((p) => {
bot_centric_params[p] = i.params[p];
});
paramset.add(this.current_silence!.param_set, bot_centric_params, this.ignored);
});
this.modifySilenceParam(this.current_silence!);
});
}
private assign(e: CustomEvent): void {
const owner = this.selected && (this.selected as Incident).params.owner;
($$('#email-chooser', this) as EmailChooserSk).open(this.emails, owner!).then((email) => {
const detail = {
key: e.detail.key,
email: email,
};
this.doImpl('/_/assign', detail);
});
}
private flipBotCentricView(): void {
this.isBotCentricView = !this.isBotCentricView;
this._render();
}
private assignMultiple(): void {
// See if the selected incidents have a common owner.
let commonOwner = '';
for (let i = 0; i < this.incidents.length; i++) {
if (!this.checked.has(this.incidents[i].key)) {
// This incident has not been selected.
continue;
}
const incidentOwner = this.incidents[i].params.owner;
if (incidentOwner) {
if (commonOwner === '') {
commonOwner = incidentOwner;
} else if (commonOwner !== incidentOwner) {
// The incident owner is different than the common owner found so far.
// This means there is no common owner;
commonOwner = '';
break;
}
} else {
// This incident has no owner so there can be no common owner.
commonOwner = '';
break;
}
}
($$('#email-chooser', this) as EmailChooserSk).open(this.emails, commonOwner).then((email) => {
const detail = {
keys: Array.from(this.checked),
email: email,
};
this.doImpl('/_/assign_multiple', detail, (json) => {
this.incidents = json;
this.checked = new Set();
this._render();
});
});
}
private assignToOwner(e: CustomEvent): void {
const owner = this.selected && (this.selected as Incident).params.owner;
const detail = {
key: e.detail.key,
email: owner,
};
this.doImpl('/_/assign', detail);
}
private take(e: CustomEvent): void {
this.doImpl('/_/take', e.detail);
// Do not do desktop notification on takes, it is redundant.
this.incidents_notified[e.detail.key] = true;
}
private getStats(): void {
const detail: StatsRequest = {
range: this.stats_range,
};
this.doImpl('/_/stats', detail, (json: Stat[]) => this.statsAction(json));
}
private getAuditLogs(): void {
this.doImpl('/_/audit_logs', {}, (json: AuditLog[]) => this.auditLogsAction(json));
}
private incidentStats(): void {
const detail: IncidentsInRangeRequest = {
incident: this.selected as Incident,
range: this.stats_range,
};
this.doImpl('/_/incidents_in_range', detail, (json: Incident[]) => this.incidentStatsAction(json));
}
// Actions to take after updating incident stats.
private incidentStatsAction(json: Incident[]): void {
this.incident_stats = json;
}
// Actions to take after updating Stats.
private statsAction(json: Stat[]): void {
this.stats = json;
}
// Actions to take after updating Audit Logs.
private auditLogsAction(json: AuditLog[]): void {
this.audit_logs = json;
}
// Actions to take after updating an Incident.
private incidentAction(json: Incident): void {
const 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.
private silenceAction(json: Silence, clear: boolean): void {
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.
private doImpl(url: string, detail: any, action = (json: any) => this.incidentAction(json)): void {
this.spinner!.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.spinner!.active = false;
}).catch((msg) => {
this.spinner!.active = false;
msg.resp.text().then(errorMessage);
});
}
// Fix-up all the incidents and silences, including re-sorting them.
private rationalize(): void {
this.incidents.forEach((incident) => {
const silenced = this.silences.reduce((isSilenced, silence) => 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;
});
}
private needsTriaging(incident: Incident, isInfraGardener: boolean): boolean {
if (incident.active
&& (incident.params.__silence_state !== 'silenced')
&& (
(isInfraGardener && !incident.params.assigned_to)
|| (incident.params.assigned_to === this.user)
|| (incident.params.owner === this.user
&& !incident.params.assigned_to)
)
) {
return true;
}
return false;
}
private requestDesktopNotificationPermission(): void {
if (Notification && Notification.permission === 'default') {
Notification.requestPermission();
}
}
private sendDesktopNotification(unNotifiedIncidents: Incident[]): void {
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`;
}
const notification = new Notification('am.skia.org notification', {
icon: '/dist/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.
notification.onclick = () => {
window.parent.focus();
window.focus(); // Supports older browsers.
this.select(unNotifiedIncidents[0]); // Display the 1st incident.
notification.close();
};
setTimeout(notification.close.bind(notification), 10000);
}
private _render(): void {
this.rationalize();
render(AlertManagerSk.template(this), this, { eventContext: this });
// Update the icon.
const isInfraGardener = this.user === this.infra_gardener;
const numActive = this.incidents.reduce((n, incident) => n += this.needsTriaging(incident, isInfraGardener) ? 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, isInfraGardener));
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 = '/dist/icon-active.png';
} else {
this.favicon.href = '/dist/icon.png';
}
}
}
define('alert-manager-sk', AlertManagerSk);