blob: 5ff4d2ce31d099e9aa15b1015cfb2d7650317aff [file] [log] [blame]
/**
* @module incident-sk
* @description <h2><code>incident-sk</code></h2>
*
* <p>
* Displays a single Incident.
* </p>
*
* @attr minimized {boolean} If not set then the incident is displayed in expanded
* mode, otherwise it is displayed in compact mode.
*
* @attr params {boolean} If set then the incident params are displayed, only
* applicable if minimized is true.
*
* @evt add-note Sent when the user adds a note to an incident.
* The detail includes the text of the note and the key of the incident.
*
* <pre>
* detail {
* key: "12312123123",
* text: "blah blah blah",
* }
* </pre>
*
* @evt del-note Sent when the user deletes a note on an incident.
* The detail includes the index of the note and the key of the incident.
*
* <pre>
* detail {
* key: "12312123123",
* index: 0,
* }
* </pre>
*
* @evt take Sent when the user wants the incident assigned to themselves.
* The detail includes the key of the incident.
*
* <pre>
* detail {
* key: "12312123123",
* }
* </pre>
*
* @evt assign Sent when the user want to assign the incident to someone else.
* The detail includes the key of the incident.
*
* <pre>
* detail {
* key: "12312123123",
* }
* </pre>
*
*/
import { define } from 'elements-sk/define';
import 'elements-sk/icon/alarm-off-icon-sk';
import 'elements-sk/icon/delete-icon-sk';
import 'elements-sk/icon/thumbs-up-down-icon-sk';
import '../silence-sk';
import { $$ } from 'common-sk/modules/dom';
import { diffDate, strDuration } from 'common-sk/modules/human';
import { errorMessage } from 'elements-sk/errorMessage';
import { html, render, TemplateResult } from 'lit-html';
import { until } from 'lit-html/directives/until';
import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow';
import { abbr, linkify, displayNotes } from '../am';
import * as paramset from '../paramset';
import {
Silence, Incident, Params, RecentIncidentsResponse, Note,
} from '../json';
const MAX_MATCHING_SILENCES_TO_DISPLAY = 50;
class State {
key: string = '';
id: string = '';
params: Params = {};
start: number = 0;
last_seen: number = 0;
active: boolean = false;
notes: Note[] = [];
}
export class IncidentSk extends HTMLElement {
private silences: Silence[] = [];
private displaySilencesWithComments: boolean = false;
private flaky: boolean = false;
private recently_expired_silence: boolean = false;
private state: State = {
key: '',
id: '',
params: {},
start: 0,
last_seen: 0,
active: false,
notes: [],
};
private static template = (ele: IncidentSk) => html`
<h2 class=${ele.classOfH2()}>${ele.state.params.alertname} ${abbr(ele.state.params.abbr)} ${ele.displayRecentlyExpired(ele.recently_expired_silence)} ${ele.displayFlakiness(ele.flaky)}</h2>
<section class=detail>
${ele.actionButtons()}
<table class=timing>
<tr><th>Started</th><td title=${new Date(ele.state.start * 1000).toLocaleString()}>${diffDate(ele.state.start * 1000)}</td></tr>
${ele.lastSeen()}
${ele.duration()}
</table>
<table class=params>
${ele.table()}
</table>
${displayNotes(ele.state.notes, ele)}
<section class=addNote>
<textarea rows=2 cols=80></textarea>
<button @click=${ele.addNote}>Submit</button>
</section>
<section class=matchingSilences>
<span class=matchingSilencesHeaders>
<h3>Matching Silences</h3>
<checkbox-sk ?checked=${ele.displaySilencesWithComments} @click=${ele.toggleSilencesWithComments} label="Show only silences with comments">
</checkbox-sk>
</span>
${ele.matchingSilences()}
</section>
<section class=history>
<h3>History</h3>
${until(ele.history(), html`<div class=loading>Loading...</div>`)}
</section>
</section>
`;
public deleteNote(e: Event, index: number): void {
const detail = {
key: this.state.key,
index: index,
};
this.dispatchEvent(new CustomEvent('del-note', { detail: detail, bubbles: true }));
}
/** @prop incident_state An Incident. */
get incident_state(): State { return this.state; }
set incident_state(val: State) {
this.state = val;
this._render();
}
/** @prop incident_silences The list of active silences. */
get incident_silences(): Silence[] { return this.silences; }
set incident_silences(val: Silence[]) {
this._render();
this.silences = val;
}
/** @prop recently_expired_silence Whether silence recently expired. */
get incident_has_recently_expired_silence(): boolean { return this.recently_expired_silence; }
set incident_has_recently_expired_silence(val: boolean) {
// No need to render again if value is same as old value.
if (val !== this.recently_expired_silence) {
this.recently_expired_silence = val;
this._render();
}
}
/** @prop flaky Whether this incident has been flaky. */
get incident_flaky(): boolean { return this.flaky; }
set incident_flaky(val: boolean) {
// No need to render again if value is same as old value.
if (val !== this.flaky) {
this.flaky = val;
this._render();
}
}
private classOfH2(): string {
if (!this.state.active) {
return 'inactive';
}
if (this.state.params.assigned_to) {
return 'assigned';
}
return '';
}
private table(): TemplateResult[] {
const params = this.state.params;
const keys = Object.keys(params);
keys.sort();
return keys.filter((k) => !k.startsWith('__')).map((k) => html`<tr><th>${k}</th><td>${linkify(params[k])}</td></tr>`);
}
private actionButtons(): TemplateResult {
if (this.state.active) {
let assignToOwnerButton = html``;
if (this.state.params.owner) {
assignToOwnerButton = html`<button @click=${this.assignToOwner}>Assign to Owner</button>`;
}
return html`<section class=assign>
<button @click=${this.take}>Take</button>
${assignToOwnerButton}
<button @click=${this.assign}>Assign</button>
</section>`;
}
return html``;
}
private matchingSilences(): TemplateResult[] {
if (this.hasAttribute('minimized')) {
return [];
}
// Filter out silences whose paramsets do not match and
// which have no notes if displaySilencesWithComments is true.
const filteredSilences = this.silences.filter((silence: Silence) => paramset.match(silence.param_set, this.state.params)
&& !(this.displaySilencesWithComments && this.doesSilenceHaveNoNotes(silence)));
const ret = filteredSilences.slice(0, MAX_MATCHING_SILENCES_TO_DISPLAY).map((silence: Silence) => html`<silence-sk .silence_state=${silence} collapsable collapsed></silence-sk>`);
if (!ret.length) {
ret.push(html`<div class=nosilences>None</div>`);
}
return ret;
}
private doesSilenceHaveNoNotes(silence: Silence): boolean {
return !silence.notes || (silence.notes.length === 1 && silence.notes[0].text === '');
}
private lastSeen(): TemplateResult {
if (this.state.active) {
return html``;
}
return html`<tr><th>Last Seen</th><td title=${new Date(this.state.last_seen * 1000).toLocaleString()}>${diffDate(this.state.last_seen * 1000)}</td></tr>`;
}
private duration(): TemplateResult {
if (this.state.active) {
return html``;
}
return html`<tr><th>Duration</th><td>${strDuration(this.state.last_seen - this.state.start)}</td></tr>`;
}
private history(): Promise<any> {
if (this.hasAttribute('minimized') || this.state.id === '' || this.state.key === '') {
return Promise.resolve();
}
return fetch(`/_/recent_incidents?id=${this.state.id}&key=${this.state.key}`, {
headers: {
'content-type': 'application/json',
},
credentials: 'include',
method: 'GET',
}).then(jsonOrThrow).then((json: RecentIncidentsResponse) => {
const incidents = json.incidents || [];
this.incident_flaky = json.flaky;
this.incident_has_recently_expired_silence = json.recently_expired_silence;
return incidents.map((i: Incident) => html`<incident-sk .incident_state=${i} minimized></incident-sk>`);
}).catch(errorMessage);
}
private toggleSilencesWithComments(e: Event): void {
// This prevents a double event from happening.
e.preventDefault();
this.displaySilencesWithComments = !this.displaySilencesWithComments;
this._render();
}
private displayRecentlyExpired(recentlyExpiredSilence: boolean): TemplateResult {
if (recentlyExpiredSilence) {
return html`<alarm-off-icon-sk title='This alert has a recently expired silence'></alarm-off-icon-sk>`;
}
return html``;
}
private displayFlakiness(flaky: boolean): TemplateResult {
if (flaky) {
return html`<thumbs-up-down-icon-sk title='This alert is possibly flaky'></thumbs-up-down-icon-sk>`;
}
return html``;
}
private take(): void {
const detail = {
key: this.state.key,
};
this.dispatchEvent(new CustomEvent('take', { detail: detail, bubbles: true }));
}
private assignToOwner(): void {
const detail = {
key: this.state.key,
};
this.dispatchEvent(new CustomEvent('assign-to-owner', { detail: detail, bubbles: true }));
}
private assign(): void {
const detail = {
key: this.state.key,
};
this.dispatchEvent(new CustomEvent('assign', { detail: detail, bubbles: true }));
}
private addNote(): void {
const textarea = $$('textarea', this) as HTMLInputElement;
const detail = {
key: this.state.key,
text: textarea.value,
};
this.dispatchEvent(new CustomEvent('add-note', { detail: detail, bubbles: true }));
textarea.value = '';
}
private _render(): void {
if (!this.state) {
return;
}
render(IncidentSk.template(this), this, { eventContext: this });
}
}
define('incident-sk', IncidentSk);