| /** |
| * @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 { html, render, TemplateResult } from 'lit-html'; |
| import { until } from 'lit-html/directives/until'; |
| import { define } from '../../../elements-sk/modules/define'; |
| import '../../../elements-sk/modules/icons/alarm-off-icon-sk'; |
| import '../../../elements-sk/modules/icons/delete-icon-sk'; |
| import '../../../elements-sk/modules/icons/thumbs-up-down-icon-sk'; |
| import '../../../infra-sk/modules/clipboard-sk'; |
| import '../silence-sk'; |
| |
| import { $$ } from '../../../infra-sk/modules/dom'; |
| import { diffDate, strDuration } from '../../../infra-sk/modules/human'; |
| import { errorMessage } from '../../../elements-sk/modules/errorMessage'; |
| import { jsonOrThrow } from '../../../infra-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; |
| |
| const PARAMS_TO_DISPLAY_COPY_ICON = ['abbr', 'alertname', 'app', 'bot']; |
| |
| 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.state.key, 'del-note')} |
| <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> |
| `; |
| |
| /** @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> |
| <span class="respect-newlines">${linkify(params[k])}</span> |
| ${this.maybeDisplayCopyIcon(k)} |
| </td> |
| </tr> |
| ` |
| ); |
| } |
| |
| private maybeDisplayCopyIcon(k: string): TemplateResult { |
| if (PARAMS_TO_DISPLAY_COPY_ICON.includes(k)) { |
| return html`<clipboard-sk value=${this.state.params[k]}></clipboard-sk>`; |
| } |
| return html``; |
| } |
| |
| 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); |