[am] New 'Add bot' button

At the start of trooper shifts the trooper typically creates a silence for bots that require an office trip. Then subsequent bot alerts have to be manually added to the silence. This feature makes adding new bot alerts to an existing silence easy.

New 'Add bot' button next to the bot param for bot-centric silences: https://screenshot.googleplex.com/8rVumhC4csg9ucZ
Clicking on the button will display all bots with active alerts: https://screenshot.googleplex.com/3PYa6Wbu22XygNX
The list of active alerts is filtered against what is currently in the silence to avoid clicking the same bot twice.

When no active bot alerts are found it displays: https://screenshot.googleplex.com/9rm6p2xEXn8c45y

Caveat- Cannot select multiple bots in the select. Couldn't get this to quickly work. But hopefully not a big deal.

Bug: skia:10733
Change-Id: I496126d665fb91bd33c1a6e3c79938b863d9df56
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/322596
Commit-Queue: Ravi Mistry <rmistry@google.com>
Reviewed-by: Joe Gregorio <jcgregorio@google.com>
diff --git a/am/modules/alert-manager-sk/alert-manager-sk.js b/am/modules/alert-manager-sk/alert-manager-sk.js
index 83fe165..e7cec73 100644
--- a/am/modules/alert-manager-sk/alert-manager-sk.js
+++ b/am/modules/alert-manager-sk/alert-manager-sk.js
@@ -19,6 +19,7 @@
 import 'elements-sk/toast-sk';
 
 import '../incident-sk';
+import '../bot-chooser-sk';
 import '../email-chooser-sk';
 import '../silence-sk';
 
@@ -137,42 +138,45 @@
   return '';
 }
 
-function botCentricView(ele, incidents) {
-    // Reset bots_to_incidents and populate it from scratch.
-    ele._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(ele._bots_to_incidents[botName]) {
-          ele._bots_to_incidents[botName].push(incident);
-        } else {
-          ele._bots_to_incidents[botName] = [incident];
-        }
+function populateBotsToIncidents(ele, incidents) {
+  // Reset bots_to_incidents and populate it from scratch.
+  ele._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(ele._bots_to_incidents[botName]) {
+        ele._bots_to_incidents[botName].push(incident);
+      } else {
+        ele._bots_to_incidents[botName] = [incident];
       }
     }
+  }
+}
 
-    const botsHTML = [];
-    for (const botName in ele._bots_to_incidents) {
-      botsHTML.push(html`
-        <h2 class="bot-centric">
-          <span class=noselect>
-            <checkbox-sk class=bot-alert-checkbox ?checked=${isBotChecked(ele, ele._bots_to_incidents[botName])} @change=${ele._check_selected} @click=${ele._clickHandler} id=${botName}></checkbox-sk>
-            <span class=bot-alert>
-              ${botName}
-              <span class=bot-incident-list>
-                ${incidentListForBot(ele, ele._bots_to_incidents[botName])}
-              </span>
+function botCentricView(ele, incidents) {
+  populateBotsToIncidents(ele, incidents);
+  const botsHTML = [];
+  for (const botName in ele._bots_to_incidents) {
+    botsHTML.push(html`
+      <h2 class="bot-centric">
+        <span class=noselect>
+          <checkbox-sk class=bot-alert-checkbox ?checked=${isBotChecked(ele, ele._bots_to_incidents[botName])} @change=${ele._check_selected} @click=${ele._clickHandler} id=${botName}></checkbox-sk>
+          <span class=bot-alert>
+            ${botName}
+            <span class=bot-incident-list>
+              ${incidentListForBot(ele, ele._bots_to_incidents[botName])}
             </span>
           </span>
-        </h2>
-      `)
-    }
+        </span>
+      </h2>
+    `)
+  }
   return botsHTML;
 }
 
@@ -290,7 +294,8 @@
 </section>
 <footer>
   <spinner-sk id=busy></spinner-sk>
-  <email-chooser-sk id=chooser></email-chooser-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>
 `;
@@ -355,6 +360,7 @@
     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('bot-chooser', (e) => this._botChooser(e));
     this.addEventListener('assign', (e) => this._assign(e));
     this.addEventListener('assign-to-owner', (e) => this._assignToOwner(e));
 
@@ -660,9 +666,27 @@
     this._doImpl('/_/del_silence_note', e.detail, (json) => this._silenceAction(json, false));
   }
 
+  _botChooser(e) {
+    populateBotsToIncidents(this, this._incidents)
+    $$('#bot-chooser', this).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 = {}
+        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);
+    });
+  }
+
   _assign(e) {
     const owner = this._selected && this._selected.params.owner;
-    $$('#chooser', this).open(this._emails, owner).then((email) => {
+    $$('#email-chooser', this).open(this._emails, owner).then((email) => {
       const detail = {
         key: e.detail.key,
         email: email,
@@ -678,7 +702,7 @@
 
   _assignMultiple() {
     const owner = (this._selected && this._selected.params.owner) || '';
-    $$('#chooser', this).open(this._emails, owner).then((email) => {
+    $$('#email-chooser', this).open(this._emails, owner).then((email) => {
       const detail = {
         keys: Array.from(this._checked),
         email: email,
diff --git a/am/modules/bot-chooser-sk/bot-chooser-sk.js b/am/modules/bot-chooser-sk/bot-chooser-sk.js
new file mode 100644
index 0000000..8224a2d
--- /dev/null
+++ b/am/modules/bot-chooser-sk/bot-chooser-sk.js
@@ -0,0 +1,121 @@
+/**
+ * @module bot-chooser-sk
+ * @description <h2><code>bot-chooser-sk</code></h2>
+ *
+ * <p>
+ * This element pops up a dialog with OK and Cancel buttons. Its open method
+ * returns a Promise which will resolve when the user clicks OK after selecting
+ * a bot or reject when the user clicks Cancel.
+ * </p>
+ *
+ */
+import dialogPolyfill from 'dialog-polyfill';
+import { define } from 'elements-sk/define';
+import { html, render } from 'lit-html';
+import { $$ } from 'common-sk/modules/dom';
+
+import 'elements-sk/styles/buttons';
+import 'elements-sk/styles/select';
+
+function displayBotOptions(bots_to_incidents) {
+  const botsHTML = [];
+  for (const bot in bots_to_incidents) {
+    botsHTML.push(html`
+      <option value=${bot}>${bot} [${bots_to_incidents[bot].map((i) => i.params.alertname).join(',')}]</option>
+    `);
+  }
+  return botsHTML;
+}
+
+function displayDialogContents(ele) {
+  if (Object.keys(ele._bots_to_incidents).length === 0) {
+    return html`
+      <h2>No active bot alerts found</h2>
+      <br/>
+      <div class=buttons>
+        <button @click=${ele._dismiss}>OK</button>
+      </div>
+    `;
+  } else {
+    return html`
+      <h2>Bots with active alerts</h2>
+      <select size=10 @input=${ele._input}>
+        ${displayBotOptions(ele._bots_to_incidents)}
+      </select>
+      <div class=buttons>
+        <button @click=${ele._dismiss}>Cancel</button>
+        <button @click=${ele._confirm}>OK</button>
+      </div>
+    `
+  }
+}
+
+const template = (ele) => html`<dialog>${displayDialogContents(ele)}</dialog>`;
+
+define('bot-chooser-sk', class extends HTMLElement {
+  constructor() {
+    super();
+    this._resolve = null;
+    this._reject = null;
+    this._bots_to_incidents = [];
+    this._selected = '';
+  }
+
+  connectedCallback() {
+    this._render();
+    this._dialog = $$('dialog', this);
+    dialogPolyfill.registerDialog(this._dialog);
+  }
+
+  /**
+   * Display the dialog.
+   *
+   * @param bots_to_incidents {Object} Map of bots to their incidents.
+   * @param bots_to_ignore {Array} Which bots should be ignored.
+   * @returns {Promise} Returns a Promise that resolves on OK, and rejects on Cancel.
+   *
+   */
+  open(bots_to_incidents, bots_to_ignore) {
+    this._bots_to_incidents = {};
+    for (const bot in bots_to_incidents) {
+      if (bots_to_ignore.includes(bot)) {
+        continue;
+      }
+      this._bots_to_incidents[bot] = bots_to_incidents[bot];
+    }
+    this._render();
+    this._dialog.showModal();
+
+    const selectElem = $$('select', this);
+    if (selectElem) {
+      selectElem.focus();
+      if (selectElem.length > 0) {
+        // Select the first option.
+        this._selected = $$('select', this)[0].value;
+        $$('select', this).selectedIndex = 0;
+      }
+    }
+    return new Promise((resolve, reject) => {
+      this._resolve = resolve;
+      this._reject = reject;
+    });
+  }
+
+  _input(e) {
+    this._selected = e.srcElement.value;
+  }
+
+  _dismiss() {
+    this._dialog.close();
+    this._reject();
+  }
+
+  _confirm() {
+    this._dialog.close();
+    this._resolve(this._selected);
+  }
+
+  _render() {
+    render(template(this), this, { eventContext: this });
+  }
+});
diff --git a/am/modules/bot-chooser-sk/bot-chooser-sk.scss b/am/modules/bot-chooser-sk/bot-chooser-sk.scss
new file mode 100644
index 0000000..4d6591d
--- /dev/null
+++ b/am/modules/bot-chooser-sk/bot-chooser-sk.scss
@@ -0,0 +1,10 @@
+@import '~elements-sk/colors';
+
+bot-chooser-sk {
+  dialog-sk {
+    h2 {
+      color: var(--blue);
+      margin: 0;
+    }
+  }
+}
diff --git a/am/modules/bot-chooser-sk/index.js b/am/modules/bot-chooser-sk/index.js
new file mode 100644
index 0000000..fe06adb
--- /dev/null
+++ b/am/modules/bot-chooser-sk/index.js
@@ -0,0 +1,2 @@
+import './bot-chooser-sk';
+import './bot-chooser-sk.scss';
diff --git a/am/modules/email-chooser-sk/email-chooser-sk-demo.html b/am/modules/email-chooser-sk/email-chooser-sk-demo.html
index b89b82b..e71f8e4 100644
--- a/am/modules/email-chooser-sk/email-chooser-sk-demo.html
+++ b/am/modules/email-chooser-sk/email-chooser-sk-demo.html
@@ -7,6 +7,6 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
 </head>
 <body>
-<email-chooser-sk id=chooser></email-chooser-sk>
+<email-chooser-sk id=email-chooser></email-chooser-sk>
 </body>
 </html>
diff --git a/am/modules/email-chooser-sk/email-chooser-sk-demo.js b/am/modules/email-chooser-sk/email-chooser-sk-demo.js
index ab90773..685334d 100644
--- a/am/modules/email-chooser-sk/email-chooser-sk-demo.js
+++ b/am/modules/email-chooser-sk/email-chooser-sk-demo.js
@@ -1,7 +1,7 @@
 import './index';
 
 window.addEventListener('load', () => {
-  document.getElementById('chooser').open(
+  document.getElementById('email-chooser').open(
     ['alice@example.com', 'bob@example.com', 'claire@example.com'],
     'bob@example.com',
   );
diff --git a/am/modules/email-chooser-sk/email-chooser-sk.js b/am/modules/email-chooser-sk/email-chooser-sk.js
index b569725..cf50a68 100644
--- a/am/modules/email-chooser-sk/email-chooser-sk.js
+++ b/am/modules/email-chooser-sk/email-chooser-sk.js
@@ -48,7 +48,7 @@
 
   connectedCallback() {
     this._render();
-    this._dialog = $$('dialog');
+    this._dialog = $$('dialog', this);
     dialogPolyfill.registerDialog(this._dialog);
   }
 
diff --git a/am/modules/silence-sk/silence-sk.js b/am/modules/silence-sk/silence-sk.js
index 69a510e..627e5f6 100644
--- a/am/modules/silence-sk/silence-sk.js
+++ b/am/modules/silence-sk/silence-sk.js
@@ -100,9 +100,12 @@
 } from '../am';
 import * as paramset from '../paramset';
 
+const BOT_CENTRIC_PARAMS = ['alertname', 'bot'];
+
 function table(ele, o) {
   const keys = Object.keys(o);
   keys.sort();
+  const botCentricParams = JSON.stringify(keys) === JSON.stringify(BOT_CENTRIC_PARAMS);
   const rules = keys.filter((k) => !k.startsWith('__')).map((k) => html`
     <tr>
       <td>
@@ -111,6 +114,7 @@
       <th>${k}</th>
       <td>
         <input @change=${(e) => ele._modifyRule(e, k)} .value=${displayParamValue(o[k])}></input>
+        ${displayAddBots(botCentricParams, k, ele)}
       </td>
     </tr>`);
   rules.push(html`
@@ -129,6 +133,13 @@
   return rules;
 }
 
+function displayAddBots(botCentricParams, key, ele) {
+  if (botCentricParams && key === 'bot') {
+    return html `<button class="param-btns" @click=${() => ele._botsChooser()}>Add bot</button>`;
+  }
+  return '';
+}
+
 function displayParamValue(paramValue) {
   if (paramValue.length > 1) {
     return `${paramValue.join('|')}`
@@ -183,7 +194,7 @@
     </section>
     <table class=info>
       <tr><th>User:</th><td>${ele._state.user}</td></th>
-      <tr><th>Duration:</th><td><input class="duration" @change=${ele._durationChange} value=${ele._state.duration}></input><button class="till-next-shift" @click=${ele._tillNextShift}>Till next shift</button></td></th>
+      <tr><th>Duration:</th><td><input class="duration" @change=${ele._durationChange} value=${ele._state.duration}></input><button class="param-btns" @click=${ele._tillNextShift}>Till next shift</button></td></th>
       <tr><th>Created</th><td title=${new Date(ele._state.created * 1000).toLocaleString()}>${diffDate(ele._state.created * 1000)}</td></tr>
       <tr><th>Expires</th><td>${expiresIn(ele._state)}</td></tr>
     </table>
@@ -333,6 +344,10 @@
     valueInput.value = '';
   }
 
+  _botsChooser() {
+    this.dispatchEvent(new CustomEvent('bot-chooser', { detail: {}, bubbles: true }));
+  }
+
   _addNote() {
     const textarea = $$('textarea', this);
     const detail = {
diff --git a/am/modules/silence-sk/silence-sk.scss b/am/modules/silence-sk/silence-sk.scss
index 6e226de..ff9fd0a 100644
--- a/am/modules/silence-sk/silence-sk.scss
+++ b/am/modules/silence-sk/silence-sk.scss
@@ -56,7 +56,7 @@
     width: 3em;
   }
 
-  button.till-next-shift {
+  button.param-btns {
     font-size: x-small;
   }