Merge "Make new UI default."
diff --git a/src/dockerfiles/compose/docker-compose.yaml b/src/dockerfiles/compose/docker-compose.yaml
index 9330704..6851ee7 100644
--- a/src/dockerfiles/compose/docker-compose.yaml
+++ b/src/dockerfiles/compose/docker-compose.yaml
@@ -267,6 +267,7 @@
 
     environment:
       - GOOGLE_APPLICATION_CREDENTIALS=/home/moblab/.service_account.json
+      - WATCHTOWER_TAG=${WATCHTOWER}
   ##############################################################################
   remote_agent:
     image: gcr.io/chromeos-partner-moblab/moblab-remote-agent:${REMOTE_AGENT:-release}
diff --git a/src/moblab-rpcserver/moblab-rpcserver.py b/src/moblab-rpcserver/moblab-rpcserver.py
index 76300ec..1101542 100755
--- a/src/moblab-rpcserver/moblab-rpcserver.py
+++ b/src/moblab-rpcserver/moblab-rpcserver.py
@@ -73,8 +73,8 @@
         """Uploads image and feedback text to GCS.
 
         Args:
-          request: SendMoblabRequest object with a string description and a
-            Base64 serialized png image screenshot.
+          request: SendMoblabRequest object with a string email, string
+          description and a Base64 serialized png image screenshot.
           _context: grpc.server.Context
 
         Returns:
@@ -82,9 +82,14 @@
           uploaded feedback.
         """
         fc = feedback_connector.MoblabFeedbackConnector(self.moblab_bucket_name)
-        path, url = fc.upload_feedback(request.description, request.screenshot)
+        path, url = fc.upload_feedback(
+            request.contact_email,
+            request.description,
+            request.screenshot,
+        )
         response = moblabrpc_pb2.SendMoblabScreenshotResponse(
-            message='Feedback uploaded to bucket {} under {}. URL to screenshot: {}'.format(self.moblab_bucket_name, path, url)
+            message='Feedback uploaded to bucket {} under {}. URL to screenshot: {}'
+                .format(self.moblab_bucket_name, path, url)
         )
         return response
 
@@ -877,6 +882,37 @@
     def reboot_moblab(self, request, _context):
         host_connector.HostServicesConnector.reboot()
 
+    def get_is_update_available(self, request, _context):
+        """
+            Get whether an update is available for user to pull.
+        Args:
+            request: GetIsUpdateAvailableRequest object ( with no parameters )
+            _context: grpc.server.Context
+        Returns:
+            GetIsUpdateAvailableResponse ( with a boolean true iff an update is
+            available ).
+        """
+        is_update_available = self.service.get_is_update_available()
+        response = moblabrpc_pb2.GetIsUpdateAvailableResponse(
+            is_update_available=is_update_available
+        )
+        return response
+
+    def update_moblab(self, request, _context):
+        """
+            Set off an update of moblab.
+        Args:
+            request: UpdateMoblabRequest object ( with no parameters )
+            _context: grpc.server.Context
+        Returns:
+            UpdateMoblabResponse ( with a string result message ).
+        """
+        message = self.service.update_docker()
+        response = moblabrpc_pb2.UpdateMoblabResponse(
+            message=message
+        )
+        return response
+
 def setup_logging(level):
     """Enable the correct level of logging.
 
diff --git a/src/moblab-rpcserver/moblab_rpcservice.py b/src/moblab-rpcserver/moblab_rpcservice.py
index ad36a7b..3cc200d 100644
--- a/src/moblab-rpcserver/moblab_rpcservice.py
+++ b/src/moblab-rpcserver/moblab_rpcservice.py
@@ -3,7 +3,10 @@
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
+import docker
+import os
 import re
+import requests
 import socket
 
 from moblab_common import build_connector
@@ -36,6 +39,8 @@
 HARDWARE_AVL_BUG_ID = 'bug_id'
 HARDWARE_AVL_PART_NO = 'part_id'
 
+MOBLAB_DOCKER_REGISTRY = 'gcr.io/chromeos-partner-moblab/'
+
 class MoblabRpcError(Exception):
     pass
 
@@ -870,6 +875,57 @@
             'is_connected': False
         }
 
+    def get_is_update_available(self):
+        """
+            Check to see if there are any containers that have updated images.
+
+            Scan the running containers, if the watchtower program has pulled down
+            a newer image then the container to image reference tag is broken.
+
+            Detect these image tag link breaks and assume that means we have an update.
+            Looking through update tools and forums, it seems this is the way
+            all update tools work.
+
+        Returns:
+            True iff there is an update available.
+        """
+        client = docker.from_env(timeout=300)
+        for container in client.containers.list():
+            if len(container.image.tags) == 0:
+                return True
+        return False
+
+    def update_docker(self):
+        """
+            Sets off an update of all docker containers, using watchtower
+
+        Returns: String result message.
+        """
+        client = docker.from_env(timeout=600)
+
+        try:
+            container = client.containers.get('update')
+            raise MoblabRpcError("Already updating please wait.")
+        except docker.errors.NotFound:
+            pass
+
+        label = os.environ.get('WATCHTOWER_TAG', 'release')
+        container = client.containers.run(
+            MOBLAB_DOCKER_REGISTRY + 'watchtower:%s' % label,
+            volumes=['/var/run/docker.sock:/var/run/docker.sock'],
+            name='update',
+            detach=True,
+            command='--run-once'
+        )
+
+        try:
+            container.wait(timeout=600)
+        except requests.exceptions.ReadTimeout:
+            container.stop()
+        finally:
+            container.remove()
+        return 'Update finished.'
+
     def get_version_info(self):
         """
             Gets cloud version info of Moblab.
diff --git a/src/moblab-rpcserver/moblab_rpcservice_unittest.py b/src/moblab-rpcserver/moblab_rpcservice_unittest.py
index 8b42184..a7366db 100644
--- a/src/moblab-rpcserver/moblab_rpcservice_unittest.py
+++ b/src/moblab-rpcserver/moblab_rpcservice_unittest.py
@@ -9,9 +9,15 @@
 from unittest import mock
 from unittest.mock import MagicMock, PropertyMock
 
+class MockNotFoundError(Exception):
+  pass
 
+MOCKED_DOCKER = MagicMock()
+MOCKED_DOCKER.errors.NotFound = MockNotFoundError
+sys.modules['docker'] = MOCKED_DOCKER
 sys.modules['moblab_common'] = MagicMock()
-from moblab_rpcservice import MoblabService
+
+from moblab_rpcservice import MoblabService, MoblabRpcError
 
 
 class MoblabRPCServiceTest(unittest.TestCase):
@@ -42,5 +48,61 @@
     afe_set_configuration_info_mock.assert_called_once()
     assert isinstance(result_msg, str)
 
+  def _create_mock_container(self, tags=[]):
+    mock_container = MagicMock()
+    mock_container.image.__str__.return_value = 'gcr.io/chromeos-partner-moblab/mock-moblab-image'
+    mock_container.image.tags = tags
+    return mock_container
+
+  @mock.patch("moblab_rpcservice.docker.from_env")
+  def test_get_is_update_available(self, mock_docker_from_env):
+    mock_docker_client = MagicMock()
+    # A single tagless container should cause update to show as available.
+    mock_docker_client.containers.list.return_value = \
+      [self._create_mock_container(['tag']) for _ in range(3)] + \
+      [self._create_mock_container()]
+    mock_docker_from_env.return_value = mock_docker_client
+
+    is_update_available = self.service.get_is_update_available()
+    assert(is_update_available)
+
+  @mock.patch("moblab_rpcservice.docker.from_env")
+  def test_get_is_update_not_available(self, mock_docker_from_env):
+    mock_docker_client = MagicMock()
+    mock_docker_client.containers.list.return_value = \
+      [self._create_mock_container(['tag']) for _ in range(3)]
+    mock_docker_from_env.return_value = mock_docker_client
+
+    is_update_available = self.service.get_is_update_available()
+    assert(is_update_available == False)
+
+  @mock.patch("moblab_rpcservice.docker.from_env")
+  def test_update_docker(self, mock_docker_from_env):
+    mock_docker_client = MagicMock()
+    mock_container_run = MagicMock()
+    mock_docker_client.containers.get.side_effect = [MockNotFoundError]
+    mock_docker_client.containers.run = mock_container_run
+    mock_docker_from_env.return_value = mock_docker_client
+
+    result_msg = self.service.update_docker()
+
+    mock_container_run.assert_called_once_with(
+        'gcr.io/chromeos-partner-moblab/watchtower:release',
+        command='--run-once',
+        detach=True,
+        name='update',
+        volumes=['/var/run/docker.sock:/var/run/docker.sock']
+    )
+
+    assert(result_msg == 'Update finished.')
+
+  @mock.patch("moblab_rpcservice.docker.from_env")
+  def test_update_docker_already_running(self, mock_docker_from_env):
+    mock_docker_from_env.return_value = MagicMock()
+
+    with self.assertRaises(MoblabRpcError) as context:
+      result_msg = self.service.update_docker()
+
+
 if __name__ == "__main__":
   unittest.main()
diff --git a/src/moblab-rpcserver/moblabrpc.proto b/src/moblab-rpcserver/moblabrpc.proto
index 27d4844..b615ddb 100644
--- a/src/moblab-rpcserver/moblabrpc.proto
+++ b/src/moblab-rpcserver/moblabrpc.proto
@@ -40,6 +40,8 @@
   rpc repair_host(RepairHostRequest) returns (RepairHostResponse) {};
 
   rpc reboot_moblab(RebootMoblabRequest) returns (RebootMoblabResponse) {};
+  rpc get_is_update_available(GetIsUpdateAvailableRequest) returns (GetIsUpdateAvailableResponse) {};
+  rpc update_moblab(UpdateMoblabRequest) returns (UpdateMoblabResponse) {};
 
   rpc add_label_to_duts(AddLabelToDutsRequest) returns (AddLabelToDutsResponse) {};
   rpc remove_label_from_duts(RemoveLabelFromDutsRequest) returns (RemoveLabelFromDutsResponse) {};
@@ -60,8 +62,9 @@
 }
 
 
-// NEXT_TAG = 3
+// NEXT_TAG = 4
 message SendMoblabScreenshotRequest {
+  string contact_email = 3;
   // Text description of issue motivating screenshot submission.
   string description = 1;
   // Serialized Base64 png image.
@@ -627,3 +630,21 @@
 message RebootMoblabResponse {
   string error_message = 1;
 }
+
+// NEXT_TAG = 1;
+message GetIsUpdateAvailableRequest {
+}
+
+// NEXT_TAG = 2;
+message GetIsUpdateAvailableResponse {
+  bool is_update_available = 1;
+}
+
+// NEXT_TAG = 1;
+message UpdateMoblabRequest {
+}
+
+// NEXT_TAG = 2;
+message UpdateMoblabResponse {
+  string message = 1;
+}
diff --git a/src/moblab-ui/src/app/configuration/configuration.component.css b/src/moblab-ui/src/app/configuration/configuration.component.css
index d74f53d..cfa5737 100644
--- a/src/moblab-ui/src/app/configuration/configuration.component.css
+++ b/src/moblab-ui/src/app/configuration/configuration.component.css
@@ -71,10 +71,17 @@
 }
 
 .reboot-button-wrapper {
+  display: inline-flex;
   margin-left: 40px;
   margin-top: 10px;
 }
 
+.update-button-wrapper {
+  display: inline-flex;
+  margin-left: 15px;
+  margin-right: 15px;
+}
+
 #link-off-icon {
   padding-left: 10px;
   padding-bottom: 5px;
diff --git a/src/moblab-ui/src/app/configuration/configuration.component.html b/src/moblab-ui/src/app/configuration/configuration.component.html
index 16869a5..f788523 100644
--- a/src/moblab-ui/src/app/configuration/configuration.component.html
+++ b/src/moblab-ui/src/app/configuration/configuration.component.html
@@ -2,6 +2,11 @@
   <app-reboot-button></app-reboot-button>
 </div>
 
+
+<div class="update-button-wrapper">
+  <app-update-button></app-update-button>
+</div>
+
 <mat-card id="configuration-information-card">
   <div class="loading-overlay" *ngIf="configurationLoading">
     <div class="loading-spinner-wrapper">
diff --git a/src/moblab-ui/src/app/services/moblab-grpc.service.ts b/src/moblab-ui/src/app/services/moblab-grpc.service.ts
index f858915..fedd076 100644
--- a/src/moblab-ui/src/app/services/moblab-grpc.service.ts
+++ b/src/moblab-ui/src/app/services/moblab-grpc.service.ts
@@ -24,6 +24,8 @@
   GetDutDetailsResponse,
   GetDutTasksRequest,
   GetDutTasksResponse,
+  GetIsUpdateAvailableRequest,
+  GetIsUpdateAvailableResponse,
   GetJobDetailsRequest,
   GetJobDetailsResponse,
   GetJobIdsRequest,
@@ -77,7 +79,9 @@
   SetDutWifiInfoRequest,
   SetDutWifiInfoResponse,
   UpdateDutsFirmwareRequest,
-  UpdateDutsFirmwareResponse
+  UpdateDutsFirmwareResponse,
+  UpdateMoblabRequest,
+  UpdateMoblabResponse,
 } from "./moblabrpc_pb";
 
 @Injectable({providedIn: "root"})
@@ -108,10 +112,12 @@
   sendMoblabScreenshot(
     callback: (message: string) => void,
     error_callback: (message: string) => void,
+    contact_email,
     description,
     screenshot
   ) {
     const request = new SendMoblabScreenshotRequest();
+    request.setContactEmail(contact_email);
     request.setDescription(description);
     request.setScreenshot(screenshot.substring(screenshot.indexOf(',') + 1));
 
@@ -1094,4 +1100,52 @@
       }
     );
   }
+
+  get_is_update_available(
+    /**
+     * Get whether an update is available for the Moblab to pull.
+     *  @param callback: function invoked in success scenarios.
+     *  @param error_callback: function invoked in failure scenarios.
+     *  */
+    callback: (isUpdateAvailable: boolean) => void,
+    error_callback: (msg: string) => void
+  ) {
+    const call = this.moblabRpcService.get_is_update_available(
+      new GetIsUpdateAvailableRequest(),
+      {},
+      (err: grpcWeb.Error, response: GetIsUpdateAvailableResponse) => {
+        error_callback(err.message);
+      }
+    );
+
+    call.on('data', (response: GetIsUpdateAvailableResponse) => {
+      callback(
+        response.getIsUpdateAvailable(),
+      );
+    });
+  }
+
+  update_moblab(
+    callback: (message: string) => void,
+    error_callback: (msg: string) => void
+  ) {
+    /**
+     *  Invoke a moblab update.
+     *  @param callback: function invoked in success scenarios.
+     *  @param error_callback: function invoked in failure scenarios.
+     *  */
+    const call = this.moblabRpcService.update_moblab(
+      new UpdateMoblabRequest(),
+      {},
+      (err: grpcWeb.Error, response: UpdateMoblabResponse) => {
+        error_callback(err.message);
+      }
+    );
+
+    call.on('data', (response: UpdateMoblabResponse) => {
+      callback(
+        response.getMessage(),
+      );
+    });
+  }
 }
diff --git a/src/moblab-ui/src/app/third_party/feedback/entity/feedback.ts b/src/moblab-ui/src/app/third_party/feedback/entity/feedback.ts
index b298a16..fc0044c 100644
--- a/src/moblab-ui/src/app/third_party/feedback/entity/feedback.ts
+++ b/src/moblab-ui/src/app/third_party/feedback/entity/feedback.ts
@@ -1,4 +1,5 @@
 export class Feedback {
+  public contactEmail: string;
   public description: string;
   public screenshot: string;
 }
diff --git a/src/moblab-ui/src/app/third_party/feedback/feedback-dialog/feedback-dialog.component.css b/src/moblab-ui/src/app/third_party/feedback/feedback-dialog/feedback-dialog.component.css
index a877eb0..f985884 100644
--- a/src/moblab-ui/src/app/third_party/feedback/feedback-dialog/feedback-dialog.component.css
+++ b/src/moblab-ui/src/app/third_party/feedback/feedback-dialog/feedback-dialog.component.css
@@ -25,6 +25,20 @@
   margin: 0px;
 }
 
+.configuration-input {
+  margin-top: 10px;
+  margin-left: 10px;
+  min-width: 340px;
+}
+
+.contact-email-input {
+  display: -webkit-flex;
+  flex-grow: 1;
+  height: 30px;
+  width: 152px;
+  position: relative;
+}
+
 .dialog-content {
   display: -webkit-flex;
   flex-grow: 1;
diff --git a/src/moblab-ui/src/app/third_party/feedback/feedback-dialog/feedback-dialog.component.html b/src/moblab-ui/src/app/third_party/feedback/feedback-dialog/feedback-dialog.component.html
index d823a01..ab71380 100644
--- a/src/moblab-ui/src/app/third_party/feedback/feedback-dialog/feedback-dialog.component.html
+++ b/src/moblab-ui/src/app/third_party/feedback/feedback-dialog/feedback-dialog.component.html
@@ -4,6 +4,21 @@
       {{vars['title']}}
     </div>
   </div>
+  <div>
+    <form #formRef="ngForm">
+      <mat-form-field class="configuration-input" appearance="legacy">
+        <mat-label>Contact Email</mat-label>
+        <input id="dutWifiNameInput" matInput required
+               [(ngModel)]="feedback.contactEmail"
+               type="email"
+               name="name"
+               email>
+        <mat-error>
+          Please enter a valid email address
+        </mat-error>
+      </mat-form-field>
+    </form>
+  </div>
   <div class="dialog-content">
     <div class="description-tips">
       <div *ngIf="feedback.description==''">{{vars['placeholder']}}</div>
diff --git a/src/moblab-ui/src/app/third_party/feedback/feedback-dialog/feedback-dialog.component.ts b/src/moblab-ui/src/app/third_party/feedback/feedback-dialog/feedback-dialog.component.ts
index 9d7473c..2646bb6 100644
--- a/src/moblab-ui/src/app/third_party/feedback/feedback-dialog/feedback-dialog.component.ts
+++ b/src/moblab-ui/src/app/third_party/feedback/feedback-dialog/feedback-dialog.component.ts
@@ -2,7 +2,7 @@
 
 import {takeUntil, finalize, map, mergeMap, timeout, skipWhile, filter, scan, first} from 'rxjs/operators';
 import {Component, AfterViewInit, ViewChild, ElementRef, ChangeDetectorRef, HostListener, Renderer2} from '@angular/core';
-// import {MatDialogRef} from '@angular/material';
+import {FormControl} from '@angular/forms';
 import {MatDialogRef} from '@angular/material/dialog';
 
 import {Feedback} from '../entity/feedback';
@@ -21,6 +21,7 @@
   public showToolbar = false;
   public vars: object = {};
   public feedback = new Feedback();
+  public contactEmailForm = new FormControl();
   public includeScreenshot: boolean = true;
   public showSpinner = true;
   public screenshotEle: HTMLElement;
@@ -46,6 +47,7 @@
               private el: ElementRef) {
     this.feedback = new Feedback();
     this.feedback.description = '';
+    this.feedback.contactEmail = '';
     this.vars = this.feedbackService.initialVariables;
   }
 
diff --git a/src/moblab-ui/src/app/third_party/feedback/feedback.module.ts b/src/moblab-ui/src/app/third_party/feedback/feedback.module.ts
index 188838a..a1ceb35 100644
--- a/src/moblab-ui/src/app/third_party/feedback/feedback.module.ts
+++ b/src/moblab-ui/src/app/third_party/feedback/feedback.module.ts
@@ -1,12 +1,13 @@
 import {NgModule} from '@angular/core';
 import {CommonModule} from '@angular/common';
-import {FormsModule} from '@angular/forms';
+import {FormsModule, ReactiveFormsModule} from '@angular/forms';
 import {FeedbackDialogComponent} from './feedback-dialog/feedback-dialog.component';
 import {FeedbackToolbarComponent} from './feedback-toolbar/feedback-toolbar.component';
 import {FeedbackRectangleComponent} from './feedback-rectangle/feedback-rectangle.component';
 
 import {MatDialogModule} from '@angular/material/dialog';
 import {MatButtonModule} from '@angular/material/button';
+import {MatFormFieldModule} from '@angular/material/form-field';
 import {MatIconModule} from '@angular/material/icon';
 import {MatInputModule} from '@angular/material/input';
 import {MatTooltipModule} from '@angular/material/tooltip';
@@ -26,13 +27,15 @@
   imports: [
     MatDialogModule,
     MatButtonModule,
+    MatFormFieldModule,
     MatIconModule,
     MatInputModule,
     MatTooltipModule,
     CommonModule,
     FormsModule,
     MatCheckboxModule,
-    MatProgressSpinnerModule
+    MatProgressSpinnerModule,
+    ReactiveFormsModule
   ],
   exports: [
     FeedbackDirective
diff --git a/src/moblab-ui/src/app/widgets/confirmation-dialog/confirmation-dialog.component.spec.ts b/src/moblab-ui/src/app/widgets/confirmation-dialog/confirmation-dialog.component.spec.ts
new file mode 100644
index 0000000..54cf0d2
--- /dev/null
+++ b/src/moblab-ui/src/app/widgets/confirmation-dialog/confirmation-dialog.component.spec.ts
@@ -0,0 +1,72 @@
+import {async, ComponentFixture, TestBed} from '@angular/core/testing';
+
+import {MatButtonModule} from '@angular/material/button';
+import {
+  MatDialog,
+  MAT_DIALOG_DATA,
+  MatDialogModule,
+  MatDialogRef
+} from '@angular/material/dialog';
+import {MatSnackBarModule} from '@angular/material/snack-bar';
+
+import {ConfirmationDialog} from './confirmation-dialog.component';
+
+
+describe('RebootButtonComponent', () => {
+  const mockDialogRef = { afterClosed : () => {}, close: () => {} }
+  const dialogRefSpyObj = jasmine.createSpyObj(mockDialogRef);
+
+  let dialogComponent: ConfirmationDialog;
+  let dialogFixture: ComponentFixture<ConfirmationDialog>;
+
+  let dialogSpy: jasmine.Spy;
+  let emitSpy: jasmine.Spy;
+
+  beforeEach(async(() => {
+    TestBed
+      .configureTestingModule({
+        declarations: [
+          ConfirmationDialog,
+        ],
+        imports: [
+          MatButtonModule,
+          MatDialogModule,
+          MatSnackBarModule
+        ],
+        providers: [
+          { provide: MAT_DIALOG_DATA, useValue: {} },
+          { provide: MatDialogRef, useValue: mockDialogRef }
+        ],
+      })
+      .compileComponents();
+  }));
+
+  function dispatchButtonClick(fixture, buttonId: string) {
+    const button = fixture.debugElement.nativeElement.querySelector(
+      buttonId
+    );
+    button.dispatchEvent(new Event('click'));
+    fixture.detectChanges();
+  }
+
+  describe('dialog tests', function () {
+    beforeEach(() => {
+      dialogFixture = TestBed.createComponent(ConfirmationDialog);
+      dialogComponent = dialogFixture.componentInstance;
+      dialogFixture.detectChanges();
+
+      dialogSpy = spyOn(TestBed.get(MatDialog), 'open')
+        .and.returnValue(dialogRefSpyObj);
+      emitSpy = spyOn(dialogComponent.onOk, 'emit');
+    });
+
+    it('should compile', () => {
+      expect(dialogComponent).toBeTruthy();
+    });
+
+    it('should emit on ok press', () => {
+      dispatchButtonClick(dialogFixture,'#ok-button');
+      expect(emitSpy).toHaveBeenCalledTimes(1);
+    });
+  });
+});
diff --git a/src/moblab-ui/src/app/widgets/confirmation-dialog/confirmation-dialog.component.ts b/src/moblab-ui/src/app/widgets/confirmation-dialog/confirmation-dialog.component.ts
new file mode 100644
index 0000000..3749f16
--- /dev/null
+++ b/src/moblab-ui/src/app/widgets/confirmation-dialog/confirmation-dialog.component.ts
@@ -0,0 +1,24 @@
+import {Component, EventEmitter, Inject, Output} from '@angular/core';
+import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
+
+@Component({
+  selector: 'confirmation-dialog',
+  templateUrl: './confirmation-dialog.html',
+})
+export class ConfirmationDialog {
+  title = '';
+  message = '';
+
+  onOk = new EventEmitter();
+
+  constructor(
+    public dialogRef: MatDialogRef<ConfirmationDialog>,
+    @Inject(MAT_DIALOG_DATA) public data: any) {
+    this.message = data.message;
+    this.title = data.title;
+  }
+
+  onClick(): void {
+    this.onOk.emit();
+  }
+}
diff --git a/src/moblab-ui/src/app/widgets/reboot-button/confirmation_dialog.html b/src/moblab-ui/src/app/widgets/confirmation-dialog/confirmation-dialog.html
similarity index 89%
rename from src/moblab-ui/src/app/widgets/reboot-button/confirmation_dialog.html
rename to src/moblab-ui/src/app/widgets/confirmation-dialog/confirmation-dialog.html
index c7147aa..3940b89 100644
--- a/src/moblab-ui/src/app/widgets/reboot-button/confirmation_dialog.html
+++ b/src/moblab-ui/src/app/widgets/confirmation-dialog/confirmation-dialog.html
@@ -4,7 +4,7 @@
 </div>
 <div mat-dialog-actions>
   <button
-    id="reboot-ok-button"
+    id="ok-button"
     mat-button
     cdkFocusInitial
     [mat-dialog-close]
diff --git a/src/moblab-ui/src/app/widgets/feedback/feedback.component.ts b/src/moblab-ui/src/app/widgets/feedback/feedback.component.ts
index 54f4001..682d9ba 100644
--- a/src/moblab-ui/src/app/widgets/feedback/feedback.component.ts
+++ b/src/moblab-ui/src/app/widgets/feedback/feedback.component.ts
@@ -66,6 +66,7 @@
           message + DEFAULT_ERROR_SUFFIX,
           'Feedback Submission Failed');
       },
+      event.contactEmail,
       event.description,
       event.screenshot
     );
diff --git a/src/moblab-ui/src/app/widgets/reboot-button/reboot-button.component.spec.ts b/src/moblab-ui/src/app/widgets/reboot-button/reboot-button.component.spec.ts
index 9ecb583..4d05f30 100644
--- a/src/moblab-ui/src/app/widgets/reboot-button/reboot-button.component.spec.ts
+++ b/src/moblab-ui/src/app/widgets/reboot-button/reboot-button.component.spec.ts
@@ -9,17 +9,16 @@
 } from '@angular/material/dialog';
 import {MatSnackBarModule} from '@angular/material/snack-bar';
 
-import {ConfirmationDialog, RebootButtonComponent} from './reboot-button';
+import {ConfirmationDialog} from '../confirmation-dialog/confirmation-dialog.component';
+import {RebootButtonComponent} from './reboot-button.component';
 
 
 describe('RebootButtonComponent', () => {
-  const mock_dialog_ref = { afterClosed : () => {}, close: () => {} }
-  const dialogRefSpyObj = jasmine.createSpyObj(mock_dialog_ref);
+  const mockDialogRef = { afterClosed : () => {}, close: () => {} }
+  const dialogRefSpyObj = jasmine.createSpyObj(mockDialogRef);
 
   let buttonComponent: RebootButtonComponent;
-  let dialogComponent: ConfirmationDialog;
   let buttonFixture: ComponentFixture<RebootButtonComponent>;
-  let dialogFixture: ComponentFixture<ConfirmationDialog>;
 
   let dialogSpy: jasmine.Spy;
   let rebootMoblabSpy: jasmine.Spy;
@@ -38,7 +37,7 @@
         ],
         providers: [
           { provide: MAT_DIALOG_DATA, useValue: {} },
-          { provide: MatDialogRef, useValue: mock_dialog_ref }
+          { provide: MatDialogRef, useValue: mockDialogRef }
         ],
       })
       .compileComponents();
@@ -60,6 +59,11 @@
 
       dialogSpy = spyOn(TestBed.get(MatDialog), 'open')
         .and.returnValue(dialogRefSpyObj);
+      rebootMoblabSpy = spyOn<any>(
+        //ts-ignore
+        buttonComponent.moblabGrpcService,
+        'reboot_moblab'
+      );
     });
 
     it('should compile', () => {
@@ -70,29 +74,9 @@
       dispatchButtonClick(buttonFixture, '#reboot-button');
       expect(dialogSpy).toHaveBeenCalled();
     });
-  });
 
-  describe('dialog tests', function () {
-    beforeEach(() => {
-      dialogFixture = TestBed.createComponent(ConfirmationDialog);
-      dialogComponent = dialogFixture.componentInstance;
-      dialogFixture.detectChanges();
-
-      dialogSpy = spyOn(TestBed.get(MatDialog), 'open')
-        .and.returnValue(dialogRefSpyObj);
-      // @ts-ignore
-      rebootMoblabSpy = spyOn<any>(
-        dialogComponent.moblabGrpcService,
-        'reboot_moblab'
-      );
-    });
-
-    it('should compile', () => {
-      expect(dialogComponent).toBeTruthy();
-    });
-
-    it('should invoke reboot', () => {
-      dispatchButtonClick(dialogFixture,'#reboot-ok-button');
+    it('reboot command should call-through to moblabGRPC', () => {
+      buttonComponent.rebootMoblab();
       expect(rebootMoblabSpy).toHaveBeenCalled();
     });
   });
diff --git a/src/moblab-ui/src/app/widgets/reboot-button/reboot-button.ts b/src/moblab-ui/src/app/widgets/reboot-button/reboot-button.component.ts
similarity index 74%
rename from src/moblab-ui/src/app/widgets/reboot-button/reboot-button.ts
rename to src/moblab-ui/src/app/widgets/reboot-button/reboot-button.component.ts
index 290ac91..fdd2b08 100644
--- a/src/moblab-ui/src/app/widgets/reboot-button/reboot-button.ts
+++ b/src/moblab-ui/src/app/widgets/reboot-button/reboot-button.component.ts
@@ -3,35 +3,7 @@
 import {MatSnackBar} from '@angular/material/snack-bar';
 import {MAT_DIALOG_DATA, MatDialog, MatDialogRef} from '@angular/material/dialog';
 
-@Component({
-  selector: 'confirmation-dialog',
-  templateUrl: './confirmation_dialog.html',
-})
-export class ConfirmationDialog {
-  title = '';
-  message = '';
-
-  constructor(
-    public dialogRef: MatDialogRef<ConfirmationDialog>,
-    private snackBar: MatSnackBar,
-    private moblabGrpcService: MoblabGrpcService,
-    @Inject(MAT_DIALOG_DATA) public data: any) {
-    this.message = data.message;
-    this.title = data.title;
-  }
-  onClick(): void {
-    this.moblabGrpcService.reboot_moblab((error_message: string) => {
-      this.reportError(error_message);
-    });
-  }
-
-  reportError(dialogue) {
-    const snackBarRef = this.snackBar.open(dialogue, '', {
-      duration: 5000,
-      verticalPosition: 'top'
-    });
-  }
-}
+import {ConfirmationDialog} from '../confirmation-dialog/confirmation-dialog.component';
 
 @Component({
   selector: 'app-reboot-button',
@@ -40,6 +12,8 @@
 export class RebootButtonComponent{
   constructor(
     private dialog: MatDialog,
+    private snackBar: MatSnackBar,
+    private moblabGrpcService: MoblabGrpcService,
   ) {}
 
   rebootClick(): void {
@@ -50,5 +24,22 @@
         'title': 'Reboot'
       }
     });
+
+    const sub = dialogRef.componentInstance.onOk.subscribe(() => {
+      this.rebootMoblab();
+    });
+  }
+
+  rebootMoblab(): void {
+    this.moblabGrpcService.reboot_moblab((error_message: string) => {
+      this.reportError(error_message);
+    });
+  }
+
+  reportError(dialogue) {
+    const snackBarRef = this.snackBar.open(dialogue, '', {
+      duration: 5000,
+      verticalPosition: 'top'
+    });
   }
 }
diff --git a/src/moblab-ui/src/app/widgets/update-button/update-button.component.css b/src/moblab-ui/src/app/widgets/update-button/update-button.component.css
new file mode 100644
index 0000000..3883cb3
--- /dev/null
+++ b/src/moblab-ui/src/app/widgets/update-button/update-button.component.css
@@ -0,0 +1,10 @@
+.update-status-message {
+  display:inline-block;
+  font-family:'Open Sans', 'Helvetica Neue', sans-serif;
+  font-size:12px;
+  font-weight:400;
+}
+
+.update-button {
+  margin-right: 10px;
+}
diff --git a/src/moblab-ui/src/app/widgets/update-button/update-button.component.html b/src/moblab-ui/src/app/widgets/update-button/update-button.component.html
new file mode 100644
index 0000000..9f917dc
--- /dev/null
+++ b/src/moblab-ui/src/app/widgets/update-button/update-button.component.html
@@ -0,0 +1,8 @@
+<button *ngIf="!isUpdateAvailable" class="update-button" mat-raised-button color="basic" (click)="updateClick()">
+  Force Update
+</button>
+<button *ngIf="!getUpdateStatusFailed && isUpdateAvailable" class="update-button" mat-raised-button color="primary" (click)="updateClick()">
+  Update
+</button>
+
+<p  class="update-status-message"> {{getUpdateStatusMessage()}} </p>
diff --git a/src/moblab-ui/src/app/widgets/update-button/update-button.component.spec.ts b/src/moblab-ui/src/app/widgets/update-button/update-button.component.spec.ts
new file mode 100644
index 0000000..60db07b
--- /dev/null
+++ b/src/moblab-ui/src/app/widgets/update-button/update-button.component.spec.ts
@@ -0,0 +1,92 @@
+import {async, ComponentFixture, TestBed} from '@angular/core/testing';
+import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
+import {MatButtonModule} from '@angular/material/button';
+import {
+  MatDialog,
+  MAT_DIALOG_DATA,
+  MatDialogModule,
+  MatDialogRef
+} from '@angular/material/dialog';
+import {MatSnackBarModule} from '@angular/material/snack-bar';
+
+import {ConfirmationDialog} from '../confirmation-dialog/confirmation-dialog.component';
+import {UpdateButtonComponent} from './update-button.component';
+
+
+describe('UpdateButtonComponent', () => {
+  const mock_dialog_ref = { afterClosed : () => {}, close: () => {} }
+  const dialogRefSpyObj = jasmine.createSpyObj(mock_dialog_ref);
+
+  let buttonComponent: UpdateButtonComponent;
+  let buttonFixture: ComponentFixture<UpdateButtonComponent>;
+
+  let dialogSpy: jasmine.Spy;
+  let isUpdateAvailableSpy: jasmine.Spy;
+  let updateMoblabSpy: jasmine.Spy;
+
+  beforeEach(async(() => {
+    TestBed
+      .configureTestingModule({
+        declarations: [
+          ConfirmationDialog,
+          UpdateButtonComponent,
+        ],
+        imports: [
+          BrowserAnimationsModule,
+          MatButtonModule,
+          MatDialogModule,
+          MatSnackBarModule
+        ],
+        providers: [
+          { provide: MAT_DIALOG_DATA, useValue: {} },
+          { provide: MatDialogRef, useValue: mock_dialog_ref }
+        ],
+      })
+      .compileComponents();
+  }));
+
+  function dispatchButtonClick(fixture, buttonId: string) {
+    const button = fixture.debugElement.nativeElement.querySelector(
+      buttonId
+    );
+    button.dispatchEvent(new Event('click'));
+    fixture.detectChanges();
+  }
+
+  describe('button tests', function () {
+    beforeEach(() => {
+      buttonFixture = TestBed.createComponent(UpdateButtonComponent);
+      buttonComponent = buttonFixture.componentInstance;
+      buttonFixture.detectChanges();
+
+      dialogSpy = spyOn(TestBed.get(MatDialog), 'open')
+        .and.returnValue(dialogRefSpyObj);
+
+
+      isUpdateAvailableSpy = spyOn<any>(
+        //ts-ignore
+        buttonComponent.moblabGrpcService,
+        'get_is_update_available'
+      );
+      updateMoblabSpy = spyOn<any>(
+        //ts-ignore
+        buttonComponent.moblabGrpcService,
+        'update_moblab'
+      );
+    });
+
+    it('should compile', () => {
+      expect(buttonComponent).toBeTruthy();
+    });
+
+    it('update click should invoke dialog.', () => {
+      dispatchButtonClick(buttonFixture, '.update-button');
+      expect(dialogSpy).toHaveBeenCalled();
+    });
+
+    it('update command should call-through to moblabGRPC', () => {
+      buttonComponent.updateMoblab()
+      expect(updateMoblabSpy).toHaveBeenCalled();
+    });
+  });
+});
diff --git a/src/moblab-ui/src/app/widgets/update-button/update-button.component.ts b/src/moblab-ui/src/app/widgets/update-button/update-button.component.ts
new file mode 100644
index 0000000..97d0981
--- /dev/null
+++ b/src/moblab-ui/src/app/widgets/update-button/update-button.component.ts
@@ -0,0 +1,80 @@
+import {Component} from '@angular/core';
+import {MoblabGrpcService} from '../../services/moblab-grpc.service';
+import {MatSnackBar} from '@angular/material/snack-bar';
+import {MatDialog} from '@angular/material/dialog';
+import {OnInit} from '@angular/core';
+
+import {ConfirmationDialog} from '../confirmation-dialog/confirmation-dialog.component';
+
+
+@Component({
+  selector: 'app-update-button',
+  templateUrl: './update-button.component.html',
+  styleUrls: ['./update-button.component.css'],
+})
+export class UpdateButtonComponent implements OnInit {
+
+  isUpdateAvailable = false;
+  getUpdateStatusFailed = false;
+
+  constructor(
+    private moblabGrpcService: MoblabGrpcService,
+    private dialog: MatDialog,
+    private snackBar: MatSnackBar,
+  ) {}
+
+  ngOnInit(): void {
+    this.moblabGrpcService.get_is_update_available(
+      (is_update_available: boolean) => {
+        this.isUpdateAvailable = is_update_available;
+      },
+      (msg: string) => {
+        this.getUpdateStatusFailed = true;
+        this.reportError(msg);
+      }
+    )
+  }
+
+  getUpdateStatusMessage() {
+    if (this.getUpdateStatusFailed) {
+      return 'Failed to get update status.';
+    } else if (this.isUpdateAvailable) {
+      return 'An update is available!';
+    } else {
+      return 'No updates found.';
+    }
+  }
+
+  updateClick(): void {
+    const dialogRef = this.dialog.open(ConfirmationDialog, {
+      width: '500px',
+      data: {
+        'message': 'This software update will take 1-2 mins. Please ensure ' +
+          'no tests are running before the update. Would you like to proceed?',
+        'title': 'Update Confirmation'
+      }
+    });
+
+    const sub = dialogRef.componentInstance.onOk.subscribe(() => {
+      this.updateMoblab();
+    });
+  }
+
+  updateMoblab() {
+    this.moblabGrpcService.update_moblab(
+      (message: string) => {
+        this.reportError(message);
+      },
+      (error_message: string) => {
+        this.reportError(error_message);
+      }
+    );
+  }
+
+  reportError(dialogue) {
+    const snackBarRef = this.snackBar.open(dialogue, '', {
+      duration: 5000,
+      verticalPosition: 'top'
+    });
+  }
+}
diff --git a/src/moblab-ui/src/app/widgets/widgets.module.ts b/src/moblab-ui/src/app/widgets/widgets.module.ts
index da8da71..ca33c4f 100644
--- a/src/moblab-ui/src/app/widgets/widgets.module.ts
+++ b/src/moblab-ui/src/app/widgets/widgets.module.ts
@@ -1,7 +1,7 @@
 import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
 import {BrowserModule} from '@angular/platform-browser';
 import {CommonModule} from '@angular/common';
-import {ConfirmationDialog} from './reboot-button/reboot-button';
+import {ConfirmationDialog} from './confirmation-dialog/confirmation-dialog.component';
 import {FlexLayoutModule} from '@angular/flex-layout';
 import {FormsModule, ReactiveFormsModule} from '@angular/forms';
 import {MatButtonModule} from '@angular/material/button';
@@ -15,8 +15,9 @@
 import {NgModule} from '@angular/core';
 
 import {KeyValTableComponent} from './keyval-table/keyval-table.component'
-import {RebootButtonComponent} from './reboot-button/reboot-button';
+import {RebootButtonComponent} from './reboot-button/reboot-button.component';
 import {TableHeaderSelectorComponent} from './table-header-selector/table-header-selector.component';
+import {UpdateButtonComponent} from './update-button/update-button.component';
 
 import {FeedbackComponent, SubmissionResultDialog} from './feedback/feedback.component';
 import {FeedbackModule as ScreenshotFeedbackModule} from '../third_party/feedback/feedback.module';
@@ -29,13 +30,15 @@
     KeyValTableComponent,
     RebootButtonComponent,
     SubmissionResultDialog,
-    TableHeaderSelectorComponent
+    TableHeaderSelectorComponent,
+    UpdateButtonComponent,
   ],
   exports: [
     FeedbackComponent,
     KeyValTableComponent,
     RebootButtonComponent,
-    TableHeaderSelectorComponent
+    TableHeaderSelectorComponent,
+    UpdateButtonComponent,
   ],
   imports: [
     BrowserAnimationsModule,
diff --git a/src/moblab_common/feedback_connector.py b/src/moblab_common/feedback_connector.py
index 5781f52..0dc0bd5 100644
--- a/src/moblab_common/feedback_connector.py
+++ b/src/moblab_common/feedback_connector.py
@@ -4,11 +4,11 @@
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 import base64
-import datetime
-import urllib
+import urllib.parse
 
 from moblab_common import moblab_info
 
+from datetime import datetime
 # pylint: disable=no-name-in-module, import-error
 from google.cloud import storage
 
@@ -17,7 +17,7 @@
 class FeedbackPath():
   FEEDBACK = 'feedback'
   SCREENSHOT= 'screenshot'
-  GCS_URL = 'https://storage.cloud.google.com'
+  GCS_URL = 'https://pantheon.corp.google.com/storage/browser/_details'
 
 
 class MoblabFeedbackConnector(object):
@@ -30,10 +30,11 @@
     # TODO: Feedback will eventually be extended to include additional data beyond image and feedback text.
     raise NotImplementedError
 
-  def upload_feedback(self, feedback=None, screenshot=None, filenames=[]):
+  def upload_feedback(self, contact_email=None, feedback=None, screenshot=None, filenames=[]):
     """Uploads image and feedback text to GCS.
 
     Args:
+      contact_email: string email by which feedback submitter can be contacted.
       feedback: Text string of feedback.
       screenshot: Base 64 byte string representing a png screenshot image.
 
@@ -42,7 +43,7 @@
       URL is a direct link to the uploaded screenshot.
     """
     bucket = self.storage_client.bucket(self.moblab_bucket_name)
-    timestamp_str = str(datetime.datetime.utcnow()).split('.')[0]
+    timestamp_str = str(datetime.utcnow()).split('.')[0]
     feedback_dir_path = '/'.join([FeedbackPath.FEEDBACK, moblab_info.get_serial_number(), timestamp_str])
 
     if screenshot:
@@ -51,11 +52,17 @@
 
     if feedback:
       feedback_blob = storage.Blob(feedback_dir_path + '/' + FeedbackPath.FEEDBACK, bucket)
-      feedback_blob.upload_from_string(feedback)
+      feedback_blob.upload_from_string("Contact email: " + contact_email + "\n" + feedback)
 
     for filename in filenames:
       filename_blob = storage.Blob(feedback_dir_path + filename, bucket)
       filename_blob.upload_from_filename(filename)
 
-    url = FeedbackPath.GCS_URL + '/' + self.moblab_bucket_name + '/' + FeedbackPath.FEEDBACK + '/' + moblab_info.get_serial_number() + '/' + urllib.parse.quote(timestamp_str) + FeedbackPath.SCREENSHOT
+    url = FeedbackPath.GCS_URL + '/' +\
+          self.moblab_bucket_name + '/' +\
+          FeedbackPath.FEEDBACK + '%2F' +\
+          moblab_info.get_serial_number() + '%2F' +\
+          timestamp_str.replace(' ', '%20') + '%2F' +\
+          FeedbackPath.SCREENSHOT
+
     return feedback_dir_path, url
diff --git a/src/moblab_common/feedback_connector_unittest.py b/src/moblab_common/feedback_connector_unittest.py
new file mode 100644
index 0000000..452a204
--- /dev/null
+++ b/src/moblab_common/feedback_connector_unittest.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Unit tests for the MoblabService"""
+import sys
+import unittest
+
+from unittest import mock
+from unittest.mock import MagicMock, PropertyMock
+
+
+sys.modules['moblab_common'] = MagicMock()
+sys.modules['google.cloud'] = MagicMock()
+from feedback_connector import MoblabFeedbackConnector
+
+MOCK_BUCKET_NAME = 'moblab-mock-bucket'
+# not a real serial no
+MOCK_MOBLAB_SERIAL_NO = '4HBWUSIE15646458'
+
+
+class MoblabFeedbackConnectorTest(unittest.TestCase):
+  """Testing the MoblabService code."""
+
+  def setUp(self):
+    self.feedback_connector = MoblabFeedbackConnector(MOCK_BUCKET_NAME)
+
+  @mock.patch("feedback_connector.datetime")
+  @mock.patch("feedback_connector.moblab_info")
+  @mock.patch("feedback_connector.storage")
+  def test_upload_feedback(self, mock_storage, mock_moblab_info, mock_datetime):
+    mock_moblab_info.get_serial_number.return_value = MOCK_MOBLAB_SERIAL_NO
+    mock_datetime.utcnow.return_value = '2020-01-01 11:11:11'
+
+    screenshot_path, screenshot_url = self.feedback_connector.upload_feedback()
+    assert(screenshot_path == 'feedback/4HBWUSIE15646458/2020-01-01 11:11:11')
+    assert(screenshot_url ==
+           'https://pantheon.corp.google.com/storage/browser/_details/moblab-mock-bucket/' +
+           'feedback%2F4HBWUSIE15646458%2F2020-01-01%2011:11:11%2Fscreenshot')
+
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/third_party/autotest/tko/perf_upload/perf_dashboard_config.json b/third_party/autotest/tko/perf_upload/perf_dashboard_config.json
index 505bbc3..fc47b9a 100644
--- a/third_party/autotest/tko/perf_upload/perf_dashboard_config.json
+++ b/third_party/autotest/tko/perf_upload/perf_dashboard_config.json
@@ -1,9 +1,5 @@
 [
   {
-    "autotest_name": "audio_LoopbackLatency",
-    "master_name": "ChromeOS_Audio"
-  },
-  {
     "autotest_name": "audio_PlaybackPower",
     "master_name": "ChromeOS_Audio"
   },