[machine] Add button to clear the device dimensions.

Change-Id: Ie1839dc064e2c3eb57fe3bea1fa0de348076ba90
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/303001
Commit-Queue: Joe Gregorio <jcgregorio@google.com>
Reviewed-by: Kevin Lubick <kjlubick@google.com>
diff --git a/machine/go/machineserver/main.go b/machine/go/machineserver/main.go
index 8b2110f..8fb2f24 100644
--- a/machine/go/machineserver/main.go
+++ b/machine/go/machineserver/main.go
@@ -248,6 +248,42 @@
 	w.WriteHeader(http.StatusOK)
 }
 
+func (s *server) machineRemoveDeviceHandler(w http.ResponseWriter, r *http.Request) {
+	vars := mux.Vars(r)
+	id := strings.TrimSpace(vars["id"])
+	if id == "" {
+		httputils.ReportError(w, skerr.Fmt("ID must be supplied."), "ID must be supplied.", http.StatusInternalServerError)
+		return
+	}
+
+	var ret machine.Description
+	err := s.store.Update(r.Context(), id, func(in machine.Description) machine.Description {
+		ret = in.Copy()
+
+		newDescription := machine.NewDescription()
+		ret.Dimensions = newDescription.Dimensions
+
+		ret.Annotation = machine.Annotation{
+			User:      user(r),
+			Message:   fmt.Sprintf("Requested device removal"),
+			Timestamp: time.Now(),
+		}
+		return ret
+	})
+	auditlog.Log(r, "remove-device", struct {
+		MachineID string
+		PodName   string
+	}{
+		MachineID: id,
+		PodName:   ret.PodName,
+	})
+	if err != nil {
+		httputils.ReportError(w, err, "Failed to update machine.", http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
 // See baseapp.App.
 func (s *server) AddHandlers(r *mux.Router) {
 	r.HandleFunc("/", s.mainHandler).Methods("GET")
@@ -255,6 +291,7 @@
 	r.HandleFunc("/_/machine/toggle_mode/{id:.+}", s.machineToggleModeHandler).Methods("GET")
 	r.HandleFunc("/_/machine/toggle_update/{id:.+}", s.machineToggleUpdateHandler).Methods("GET")
 	r.HandleFunc("/_/machine/toggle_powercycle/{id:.+}", s.machineTogglePowerCycleHandler).Methods("GET")
+	r.HandleFunc("/_/machine/remove_device/{id:.+}", s.machineRemoveDeviceHandler).Methods("GET")
 	r.HandleFunc("/loginstatus/", login.StatusHandler).Methods("GET")
 }
 
diff --git a/machine/go/machineserver/main_test.go b/machine/go/machineserver/main_test.go
index 34f9fd0..4289bf2 100644
--- a/machine/go/machineserver/main_test.go
+++ b/machine/go/machineserver/main_test.go
@@ -239,3 +239,70 @@
 
 	assert.Equal(t, 404, w.Code)
 }
+
+func TestMachineRemoveDeviceHandler_Success(t *testing.T) {
+	unittest.LargeTest(t)
+	*baseapp.Local = true
+
+	ctx, cfg := setupForTest(t)
+	store, err := store.New(ctx, true, cfg)
+	require.NoError(t, err)
+
+	const podName = "rpi-swarming-123456"
+	err = store.Update(ctx, "someid", func(in machine.Description) machine.Description {
+		ret := in.Copy()
+		ret.PodName = podName
+		ret.Dimensions = machine.SwarmingDimensions{
+			"android_devices":  {"1"},
+			"device_os":        {"Q", " QP1A.190711.020", "QP1A.190711.020_G980FXXU1ATB3"},
+			"device_os_flavor": {"samsung"},
+		}
+		return ret
+	})
+	require.NoError(t, err)
+
+	// Create our server.
+	s := &server{
+		store: store,
+	}
+
+	// Put a mux.Router in place so the request path gets parsed.
+	router := mux.NewRouter()
+	s.AddHandlers(router)
+
+	r := httptest.NewRequest("GET", "/_/machine/remove_device/someid", nil)
+	w := httptest.NewRecorder()
+
+	// Make the request.
+	router.ServeHTTP(w, r)
+
+	// Confirm the request was successful.
+	require.Equal(t, 200, w.Code)
+	machines, err := store.List(ctx)
+	require.NoError(t, err)
+	require.Len(t, machines, 1)
+	assert.Equal(t, podName, machines[0].PodName)
+	// Confirm the dimensions were cleared.
+	assert.Empty(t, machines[0].Dimensions)
+	assert.Contains(t, machines[0].Annotation.Message, "Requested device removal")
+	assert.Equal(t, machines[0].Annotation.User, "barney@example.org")
+}
+
+func TestMachineRemoveDeviceHandler_FailOnMissingID(t *testing.T) {
+	unittest.LargeTest(t)
+
+	// Create our server.
+	s := &server{}
+
+	// Put a mux.Router in place so the request path gets parsed.
+	router := mux.NewRouter()
+	s.AddHandlers(router)
+
+	r := httptest.NewRequest("GET", "/_/machine/remove_device/", nil)
+	w := httptest.NewRecorder()
+
+	// Make the request.
+	router.ServeHTTP(w, r)
+
+	assert.Equal(t, 404, w.Code)
+}
diff --git a/machine/modules/machine-server-sk/machine-server-sk.ts b/machine/modules/machine-server-sk/machine-server-sk.ts
index e55c64c..09d81a7 100644
--- a/machine/modules/machine-server-sk/machine-server-sk.ts
+++ b/machine/modules/machine-server-sk/machine-server-sk.ts
@@ -18,6 +18,7 @@
 import '../../../infra-sk/modules/theme-chooser-sk';
 import 'elements-sk/error-toast-sk';
 import 'elements-sk/icon/cached-icon-sk';
+import 'elements-sk/icon/clear-icon-sk';
 import 'elements-sk/icon/pause-icon-sk';
 import 'elements-sk/icon/play-arrow-icon-sk';
 import 'elements-sk/icon/power-settings-new-icon-sk';
@@ -44,10 +45,12 @@
       <table>
         ${Object.entries(temperatures).map(
           (pair) =>
-            html`<tr>
-              <td>${pair[0]}</td>
-              <td>${pair[1]}</td>
-            </tr>`
+            html`
+              <tr>
+                <td>${pair[0]}</td>
+                <td>${pair[1]}</td>
+              </tr>
+            `
         )}
       </table>
     </details>
@@ -56,7 +59,9 @@
 
 const isRunning = (machine: Description) =>
   machine.RunningSwarmingTask
-    ? html`<cached-icon-sk title="Running"></cached-icon-sk>`
+    ? html`
+        <cached-icon-sk title="Running"></cached-icon-sk>
+      `
     : '';
 
 const asList = (arr: string[]) => arr.join(' | ');
@@ -66,15 +71,17 @@
     return '';
   }
   return html`
-    <details>
+    <details class="dimensions">
       <summary>Dimensions</summary>
       <table>
         ${Object.entries(machine.Dimensions).map(
           (pair) =>
-            html`<tr>
-              <td>${pair[0]}</td>
-              <td>${asList(pair[1])}</td>
-            </tr>`
+            html`
+              <tr>
+                <td>${pair[0]}</td>
+                <td>${asList(pair[1])}</td>
+              </tr>
+            `
         )}
       </table>
     </details>
@@ -91,11 +98,17 @@
   `;
 };
 
-const update = (machine: Description) => {
-  if (machine.ScheduledForDeletion) {
-    return 'Waiting for update.';
-  }
-  return 'Update';
+const update = (ele: MachineServerSk, machine: Description) => {
+  const msg = machine.ScheduledForDeletion ? 'Waiting for update.' : 'Update';
+  return html`
+    <button
+      title="Force the pod to be killed and re-created"
+      class="update"
+      @click=${() => ele._toggleUpdate(machine.Dimensions.id)}
+    >
+      ${msg}
+    </button>
+  `;
 };
 
 const imageName = (machine: Description) => {
@@ -112,51 +125,62 @@
   return parts[1];
 };
 
-const powerCycle = (machine: Description) => {
+const powerCycle = (ele: MachineServerSk, machine: Description) => {
   if (machine.PowerCycle) {
     return 'Waiting for Power Cycle';
   }
-  return html`<power-settings-new-icon-sk></power-settings-new-icon-sk>`;
+  return html`
+    <power-settings-new-icon-sk
+      title="Powercycle the host"
+      @click=${() => ele._togglePowerCycle(machine.Dimensions.id)}
+    ></power-settings-new-icon-sk>
+  `;
+};
+
+const clearDevice = (ele: MachineServerSk, machine: Description) => {
+  return machine.RunningSwarmingTask
+    ? ''
+    : html`
+        <clear-icon-sk
+          title="Clear the dimensions for the bot"
+          @click=${() => ele._clearDevice(machine.Dimensions.id)}
+        ></clear-icon-sk>
+      `;
+};
+
+const toggleMode = (ele: MachineServerSk, machine: Description) => {
+  return html`
+    <button class="mode" @click=${() => ele._toggleMode(machine.Dimensions.id)}>
+      ${machine.Mode}
+    </button>
+  `;
+};
+
+const machineLink = (machine: Description) => {
+  return html`
+    <a
+      href="https://chromium-swarm.appspot.com/bot?id=${machine.Dimensions.id}"
+    >
+      ${machine.Dimensions.id}
+    </a>
+  `;
 };
 
 const rows = (ele: MachineServerSk) =>
   ele._machines.map(
     (machine) => html`
       <tr id=${machine.Dimensions.id}>
-        <td>
-          <a
-            href="https://chromium-swarm.appspot.com/bot?id=${machine.Dimensions
-              .id}"
-            >${machine.Dimensions.id}</a
-          >
-        </td>
+        <td>${machineLink(machine)}</td>
         <td>${machine.PodName}</td>
         <td>${machine.Dimensions.device_type}</td>
-        <td>
-          <button
-            class="mode"
-            @click=${() => ele._toggleMode(machine.Dimensions.id)}
-            >${machine.Mode}</button
-          >
-        </td>
-        <td>
-          <button
-            class="update"
-            @click=${() => ele._toggleUpdate(machine.Dimensions.id)}
-            >${update(machine)}</button
-          >
-        </td>
-        <td
-          class="powercycle"
-          @click=${() => ele._togglePowerCycle(machine.Dimensions.id)}
-          >${powerCycle(machine)}</td
-        >
+        <td>${toggleMode(ele, machine)}</td>
+        <td>${update(ele, machine)}</td>
+        <td class="powercycle">${powerCycle(ele, machine)}</td>
+        <td>${clearDevice(ele, machine)}</td>
         <td>${machine.Dimensions.quarantined}</td>
         <td>${isRunning(machine)}</td>
         <td>${machine.Battery}</td>
-        <td>
-          ${temps(machine.Temperature)}
-        </td>
+        <td>${temps(machine.Temperature)}</td>
         <td>${diffDate(machine.LastUpdated)}</td>
         <td>${dimensions(machine)}</td>
         <td>${annotation(machine)}</td>
@@ -167,9 +191,13 @@
 
 const refreshButtonDisplayValue = (ele: MachineServerSk) => {
   if (ele.refreshing) {
-    return html`<pause-icon-sk></pause-icon-sk>`;
+    return html`
+      <pause-icon-sk></pause-icon-sk>
+    `;
   }
-  return html`<play-arrow-icon-sk></play-arrow-icon-sk>`;
+  return html`
+    <play-arrow-icon-sk></play-arrow-icon-sk>
+  `;
 };
 
 const template = (ele: MachineServerSk) => html`
@@ -178,8 +206,9 @@
       id="refresh"
       @click=${() => ele._toggleRefresh()}
       title="Start/Stop the automatic refreshing of data on the page."
-      >${refreshButtonDisplayValue(ele)}</span
     >
+      ${refreshButtonDisplayValue(ele)}
+    </span>
     <theme-chooser-sk
       title="Toggle between light and dark mode."
     ></theme-chooser-sk>
@@ -193,6 +222,7 @@
         <th>Mode</th>
         <th>Update</th>
         <th>Host</th>
+        <th>Device</th>
         <th>Quarantined</th>
         <th>Task</th>
         <th>Battery</th>
@@ -292,6 +322,17 @@
     }
   }
 
+  async _clearDevice(id: string[]) {
+    try {
+      this.setAttribute('waiting', '');
+      await fetch(`/_/machine/remove_device/${id}`);
+      this.removeAttribute('waiting');
+      this._update(true);
+    } catch (error) {
+      this._onError(error);
+    }
+  }
+
   async _refreshStep() {
     // Wait for _update to finish so we don't pile up requests if server latency
     // rises.
diff --git a/machine/modules/machine-server-sk/machine-server-sk_test.ts b/machine/modules/machine-server-sk/machine-server-sk_test.ts
index 6885906..d7c0253 100644
--- a/machine/modules/machine-server-sk/machine-server-sk_test.ts
+++ b/machine/modules/machine-server-sk/machine-server-sk_test.ts
@@ -104,7 +104,7 @@
         await fetchMock.flush(true);
 
         // Confirm the button text has been updated.
-        assert.equal('maintenance', button.textContent);
+        assert.equal('maintenance', button.textContent?.trim());
       }));
   });
 
@@ -173,7 +173,7 @@
         await fetchMock.flush(true);
 
         // Confirm the button text has been updated.
-        assert.equal('Waiting for update.', button.textContent);
+        assert.equal('Waiting for update.', button.textContent?.trim());
       }));
   });
 
@@ -304,14 +304,89 @@
         ]);
 
         // Click the button.
-        const button = s!.querySelector<HTMLButtonElement>('.powercycle')!;
+        const button = s!.querySelector<HTMLElement>(
+          'power-settings-new-icon-sk'
+        )!;
         button.click();
 
         // Wait for all requests to finish.
         await fetchMock.flush(true);
 
         // Confirm the button text has been updated.
-        assert.equal('Waiting for Power Cycle', button.textContent);
+        assert.equal(
+          'Waiting for Power Cycle',
+          s!.querySelector('.powercycle')?.textContent?.trim()
+        );
+      }));
+  });
+
+  describe('clears Dimensions on click', () => {
+    fetchMock.get('/_/machines', [
+      {
+        Mode: 'available',
+        Battery: 100,
+        PodName: 'rpi-swarming-123456-987',
+        ScheduledForDeletion: '',
+        Dimensions: {
+          id: ['skia-rpi2-rack4-shelf1-002'],
+          android_devices: ['1'],
+          device_os: ['H', 'HUAWEIELE-L29'],
+        },
+        Annotation: {
+          User: '',
+          Message: '',
+          LastUpdated: '2020-04-21T17:33:09.638275Z',
+        },
+        PowerCycle: false,
+        LastUpdated: '2020-04-21T17:33:09.638275Z',
+        Temperature: { dumpsys_battery: 26 },
+      },
+    ]);
+
+    it('updates PowerCycle when you click on the button', () =>
+      window.customElements.whenDefined('machine-server-sk').then(async () => {
+        container.innerHTML = '<machine-server-sk></machine-server-sk>';
+        const s = container.firstElementChild;
+
+        // Wait for the initial fetch to finish.
+        await fetchMock.flush(true);
+
+        // Confirm there are row in the dimensions.
+        assert.isNotNull(s!.querySelector('details.dimensions table tr'));
+
+        // Now set up fetchMock for the requests that happen when the button is clicked.
+        fetchMock.reset();
+        fetchMock.get(
+          '/_/machine/remove_device/skia-rpi2-rack4-shelf1-002',
+          200
+        );
+        fetchMock.get('/_/machines', [
+          {
+            Mode: 'maintenance',
+            Battery: 100,
+            PodName: 'rpi-swarming-123456-987',
+            ScheduledForDeletion: 'rpi-swarming-123456-987',
+            Dimensions: {},
+            Annotation: {
+              User: '',
+              Message: '',
+              LastUpdated: '2020-04-21T17:33:09.638275Z',
+            },
+            PowerCycle: true,
+            LastUpdated: '2020-04-21T17:33:09.638275Z',
+            Temperature: { dumpsys_battery: 26 },
+          },
+        ]);
+
+        // Click the button.
+        const button = s!.querySelector<HTMLElement>('clear-icon-sk')!;
+        button.click();
+
+        // Wait for all requests to finish.
+        await fetchMock.flush(true);
+
+        // Confirm the dimensions are now empty.
+        assert.isNull(s!.querySelector('details.dimensions table tr'));
       }));
   });
 });