Revamp the UX, introducing the install button

Adds a entry Plugins>Manage to Gerrit Top-Menu if:
- remote plugin management is enabled
- current user has admin permissions

The list of available plugins includes both version + sha1
allowing to match them against the list of loaded plugins.
Plugins that are already installed and loaded are not shown anymore
in the list of the ones available.

Expose the plugin jar URL for installation and leverage that
information from the UX using the HTTP PUT on the Gerrit
/plugin/<name>.jar API

Change-Id: Ie4e6042c0b33bebbd14b515141776bf91e2c4c0b
diff --git a/src/main/java/com/googlesource/gerrit/plugins/manager/ListAvailablePlugins.java b/src/main/java/com/googlesource/gerrit/plugins/manager/ListAvailablePlugins.java
index d3d8316..9d2bb66 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/manager/ListAvailablePlugins.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/manager/ListAvailablePlugins.java
@@ -27,6 +27,7 @@
 
 import com.googlesource.gerrit.plugins.manager.repository.PluginInfo;
 
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
@@ -52,7 +53,7 @@
     Map<String, PluginInfo> output = Maps.newTreeMap();
     List<PluginInfo> plugins;
     try {
-      plugins = pluginsCache.availablePlugins();
+      plugins = new ArrayList<>(pluginsCache.availablePlugins());
     } catch (ExecutionException e) {
       throw new RestApiException(
           "Unable to load the list of available plugins", e);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/manager/Module.java b/src/main/java/com/googlesource/gerrit/plugins/manager/Module.java
index 1c44dcd..c29f2c9 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/manager/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/manager/Module.java
@@ -15,6 +15,8 @@
 package com.googlesource.gerrit.plugins.manager;
 
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.inject.AbstractModule;
 import com.google.inject.internal.UniqueAnnotations;
 
@@ -25,6 +27,8 @@
 
   @Override
   protected void configure() {
+    DynamicSet.bind(binder(), TopMenu.class).to(PluginManagerTopMenu.class);
+
     bind(PluginsRepository.class).to(JenkinsCiPluginsRepository.class);
 
     install(PluginsCentralCache.module());
diff --git a/src/main/java/com/googlesource/gerrit/plugins/manager/PluginManagerTopMenu.java b/src/main/java/com/googlesource/gerrit/plugins/manager/PluginManagerTopMenu.java
new file mode 100644
index 0000000..ec25d8f
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/manager/PluginManagerTopMenu.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.manager;
+
+import com.google.gerrit.extensions.annotations.PluginCanonicalWebUrl;
+import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.extensions.webui.TopMenu;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.plugins.PluginLoader;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+@Singleton
+public class PluginManagerTopMenu implements TopMenu {
+
+  private PluginLoader loader;
+  private List<MenuEntry> menuEntries;
+  private Provider<CurrentUser> userProvider;
+
+  @Inject
+  public PluginManagerTopMenu(@PluginCanonicalWebUrl String myUrl,
+      PluginLoader loader, Provider<CurrentUser> userProvider) {
+    this.loader = loader;
+    this.userProvider = userProvider;
+    this.menuEntries =
+        Arrays.asList(new MenuEntry("Plugins", Arrays.asList(new MenuItem(
+            "Manage", myUrl + "static/index.html", "_blank"))));
+  }
+
+  @Override
+  public List<MenuEntry> getEntries() {
+    if (loader.isRemoteAdminEnabled()
+        && userProvider.get().getCapabilities().canAdministrateServer()) {
+      return menuEntries;
+    }
+
+    return Collections.emptyList();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/manager/PluginsCentralCache.java b/src/main/java/com/googlesource/gerrit/plugins/manager/PluginsCentralCache.java
index 56cb82f..c4ffbab 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/manager/PluginsCentralCache.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/manager/PluginsCentralCache.java
@@ -23,22 +23,23 @@
 import com.googlesource.gerrit.plugins.manager.PluginsCentralLoader.ListKey;
 import com.googlesource.gerrit.plugins.manager.repository.PluginInfo;
 
-import java.util.List;
+import java.util.Collection;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 
 public class PluginsCentralCache {
 
-  private final LoadingCache<ListKey, List<PluginInfo>> pluginsCache;
+  private final LoadingCache<ListKey, Collection<PluginInfo>> pluginsCache;
 
   public static final String PLUGINS_LIST_CACHE_NAME = "plugins_list";
 
   @Inject
-  public PluginsCentralCache(@Named(PLUGINS_LIST_CACHE_NAME) LoadingCache<ListKey, List<PluginInfo>> pluginsCache) {
+  public PluginsCentralCache(
+      @Named(PLUGINS_LIST_CACHE_NAME) LoadingCache<ListKey, Collection<PluginInfo>> pluginsCache) {
     this.pluginsCache = pluginsCache;
   }
 
-  public List<PluginInfo> availablePlugins() throws ExecutionException {
+  public Collection<PluginInfo> availablePlugins() throws ExecutionException {
     return pluginsCache.get(ListKey.ALL);
   }
 
@@ -47,7 +48,7 @@
       @Override
       protected void configure() {
         cache(PluginsCentralCache.PLUGINS_LIST_CACHE_NAME, ListKey.class,
-            new TypeLiteral<List<PluginInfo>>() {}).expireAfterWrite(1,
+            new TypeLiteral<Collection<PluginInfo>>() {}).expireAfterWrite(1,
             TimeUnit.DAYS).loader(PluginsCentralLoader.class);
 
         bind(PluginsCentralCache.class);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/manager/PluginsCentralLoader.java b/src/main/java/com/googlesource/gerrit/plugins/manager/PluginsCentralLoader.java
index d0385c9..d7bf1b9 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/manager/PluginsCentralLoader.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/manager/PluginsCentralLoader.java
@@ -23,11 +23,11 @@
 import com.googlesource.gerrit.plugins.manager.repository.PluginInfo;
 import com.googlesource.gerrit.plugins.manager.repository.PluginsRepository;
 
-import java.util.List;
+import java.util.Collection;
 
 @Singleton
 public class PluginsCentralLoader extends
-    CacheLoader<ListKey, List<PluginInfo>> {
+    CacheLoader<ListKey, Collection<PluginInfo>> {
 
   public static class ListKey {
     static final ListKey ALL = new ListKey();
@@ -35,6 +35,8 @@
     private ListKey() {}
   }
 
+  private static final String GERRIT_VERSION = Version.getVersion();
+
   private final PluginsRepository repository;
 
   @Inject
@@ -43,7 +45,7 @@
   }
 
   @Override
-  public List<PluginInfo> load(ListKey all) throws Exception {
-    return repository.list(Version.getVersion());
+  public Collection<PluginInfo> load(ListKey all) throws Exception {
+    return repository.list(GERRIT_VERSION);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/manager/WebModule.java b/src/main/java/com/googlesource/gerrit/plugins/manager/WebModule.java
index a80ee9a..177372b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/manager/WebModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/manager/WebModule.java
@@ -23,6 +23,6 @@
 
     serve("/available*").with(PluginManagerRestApiServlet.class);
 
-    filterRegex(".*\\.js").through(XAuthFilter.class);
+    filterRegex(".*plugin-manager\\.js").through(XAuthFilter.class);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/manager/XAuthFilter.java b/src/main/java/com/googlesource/gerrit/plugins/manager/XAuthFilter.java
index 33f05b3..57fc2c9 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/manager/XAuthFilter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/manager/XAuthFilter.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.httpd.WebSession;
+import com.google.gerrit.server.AccessPath;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -57,8 +58,11 @@
     HttpServletRequest httpReq = (HttpServletRequest) req;
     HttpServletResponse httpResp = (HttpServletResponse) resp;
 
-    final String gerritAuth = webSession.get().getXGerritAuth();
+    WebSession session = webSession.get();
+    final String gerritAuth = session.getXGerritAuth();
     if (gerritAuth != null) {
+      session.setAccessPathOk(AccessPath.REST_API, true);
+
       log.debug("Injecting X-Gerrit-Auth for {}", httpReq.getRequestURI());
       httpResp = new HttpServletResponseWrapper(httpResp) {
 
@@ -86,9 +90,16 @@
               "@X-Gerrit-Auth".getBytes(), gerritAuth.getBytes());
         }
       };
-    }
 
-    chain.doFilter(req, httpResp);
+      httpResp.setHeader("Cache-Control", "private, no-cache, no-store, must-revalidate, max-age=0");
+      httpResp.setHeader("Pragma", "no-cache");
+      httpResp.setHeader("Expires", "0");
+
+      chain.doFilter(req, httpResp);
+    } else {
+      HttpServletResponse res = (HttpServletResponse) resp;
+      res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+    }
   }
 
   @Override
diff --git a/src/main/java/com/googlesource/gerrit/plugins/manager/repository/JenkinsCiPluginsRepository.java b/src/main/java/com/googlesource/gerrit/plugins/manager/repository/JenkinsCiPluginsRepository.java
index 6148407..36c7dd0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/manager/repository/JenkinsCiPluginsRepository.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/manager/repository/JenkinsCiPluginsRepository.java
@@ -30,8 +30,11 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.BufferedReader;
 import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URL;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -133,10 +136,34 @@
       return Optional.absent();
     }
 
-    SmartJson artifactJson = SmartJson.of(artifacts.get(0));
-    String pluginFileName = artifactJson.getString("fileName");
+    Optional<SmartJson> artifactJson = findArtifact(artifacts, ".jar");
+    if (!artifactJson.isPresent()) {
+      return Optional.absent();
+    }
+
+    String pluginPath = artifactJson.get().getString("relativePath");
+
+    String[] pluginPathParts = pluginPath.split("/");
+    String pluginName = pluginPathParts[pluginPathParts.length-2];
+    String pluginUrl =
+        String.format("%s/artifact/%s", buildExecution.getString("url"),
+            pluginPath);
 
     String pluginVersion = "";
+    Optional<SmartJson> verArtifactJson =
+        findArtifact(artifacts, ".jar-version");
+    if (verArtifactJson.isPresent()) {
+      String versionUrl =
+          String.format("%s/artifact/%s", buildExecution.getString("url"),
+              verArtifactJson.get().getString("relativePath"));
+      try (BufferedReader reader =
+          new BufferedReader(new InputStreamReader(
+              new URL(versionUrl).openStream()), 4096)) {
+        pluginVersion = reader.readLine();
+      }
+    }
+
+    String sha1 = "";
     for (JsonElement elem : buildExecution.get("actions").get()
         .getAsJsonArray()) {
       SmartJson elemJson = SmartJson.of(elem);
@@ -144,10 +171,22 @@
           elemJson.getOptional("lastBuiltRevision");
 
       if (lastBuildRevision.isPresent()) {
-        pluginVersion = lastBuildRevision.get().getString("SHA1");
+        sha1 = lastBuildRevision.get().getString("SHA1").substring(0, 8);
       }
     }
 
-    return Optional.of(new PluginInfo(pluginFileName, pluginVersion));
+    return Optional.of(new PluginInfo(pluginName, pluginVersion, sha1,
+        pluginUrl));
+  }
+
+  private Optional<SmartJson> findArtifact(JsonArray artifacts, String string) {
+    for (int i = 0; i < artifacts.size(); i++) {
+      SmartJson artifact = SmartJson.of(artifacts.get(i));
+      if (artifact.getString("relativePath").endsWith(string)) {
+        return Optional.of(artifact);
+      }
+    }
+
+    return Optional.absent();
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/manager/repository/PluginInfo.java b/src/main/java/com/googlesource/gerrit/plugins/manager/repository/PluginInfo.java
index 03e4d8a..32bb47f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/manager/repository/PluginInfo.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/manager/repository/PluginInfo.java
@@ -20,10 +20,14 @@
   public final String id;
   public final String name;
   public final String version;
+  public final String sha1;
+  public final String url;
 
-  PluginInfo(String name, String version) {
+  public PluginInfo(String name, String version, String sha1, String url) {
     this.id = Url.encode(name);
     this.name = name;
     this.version = version;
+    this.sha1 = sha1;
+    this.url = url;
   }
 }
\ No newline at end of file
diff --git a/src/main/java/com/googlesource/gerrit/plugins/manager/repository/PluginsRepository.java b/src/main/java/com/googlesource/gerrit/plugins/manager/repository/PluginsRepository.java
index 236fc28..f61e9af 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/manager/repository/PluginsRepository.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/manager/repository/PluginsRepository.java
@@ -15,9 +15,9 @@
 package com.googlesource.gerrit.plugins.manager.repository;
 
 import java.io.IOException;
-import java.util.List;
+import java.util.Collection;
 
 public interface PluginsRepository {
 
-  List<PluginInfo> list(String gerritVersion) throws IOException;
+  Collection<PluginInfo> list(String gerritVersion) throws IOException;
 }
diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css
new file mode 100644
index 0000000..ff0df76
--- /dev/null
+++ b/src/main/resources/static/css/style.css
@@ -0,0 +1,21 @@
+.inprogress {
+  background-image: url("../images/spinner-24px.gif");
+  background-repeat: no-repeat;
+  background-position-y: 7px;
+}
+
+.hidden {
+  display: none;
+}
+
+h5 {
+  margin: 0px;
+}
+
+td {
+  height: 40px;
+}
+
+.main {
+  margin-top: 51px;
+}
\ No newline at end of file
diff --git a/src/main/resources/static/images/spinner-24px.gif b/src/main/resources/static/images/spinner-24px.gif
new file mode 100644
index 0000000..a0ec671
--- /dev/null
+++ b/src/main/resources/static/images/spinner-24px.gif
Binary files differ
diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html
index 40d710d..3868747 100644
--- a/src/main/resources/static/index.html
+++ b/src/main/resources/static/index.html
@@ -5,21 +5,77 @@
 <title>Gerrit Plugin Manager</title>
 <script src= "https://ajax.googleapis.com/ajax/libs/angularjs/1.4.7/angular.min.js"></script>
 <script src="js/plugin-manager.js"></script>
+<script src="https://code.jquery.com/jquery-2.1.4.min.js"></script>
+<link
+  href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css"
+  rel="stylesheet"
+  integrity="sha256-MfvZlkHCEqatNoGiOXveE8FIwMzZg4W85qfrfIFBfYc= sha512-dTfge/zgoMYpP7QbHy4gWMEGsbsdZeCXz7irItjcC3sPUFtf0kuFbDz/ixG7ArTxmDjLXDmezHubeNikyKGVyQ=="
+  crossorigin="anonymous">
+<link href="css/style.css" rel="stylesheet">
 </head>
-<body>
-<div ng-controller="LoadInstalledPlugins as plugins">
-    <h1>Installed plugins</h1>
-    <ul>
-        <li ng-repeat="(key, prop) in plugins.list">
-            {{key}} - {{prop.version}}
-        </li>
-    </ul>
-    <h1>Available plugins</h1>
-    <ul>
-        <li ng-repeat="(key, prop) in plugins.available">
-            {{key}} - {{prop.version}}
-        </li>
-    </ul>
-</div>
+
+<body role="document">
+  <nav class="navbar navbar-inverse navbar-fixed-top">
+    <div class="container">
+      <div class="navbar-header">
+        <a class="navbar-brand" href="#">Gerrit Plugin Manager</a>
+      </div>
+    </div>
+  </nav>
+  <div class="container main" role="main"
+    ng-controller="LoadInstalledPlugins as plugins">
+    <div class="col-md-6">
+      <h2>Loaded</h2>
+      <table class="table table-striped">
+        <thead>
+          <tr>
+            <th>Plugin Name</th>
+            <th>Version</th>
+            <th>Actions</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr ng-repeat="(key, prop) in plugins.list">
+            <td>{{key}}</td>
+            <td>{{prop.version}}</td>
+            <td></td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+    <div class="col-md-6">
+      <h2>Available</h2>
+      <table class="table table-striped">
+        <thead>
+          <tr>
+            <th>Plugin Name</th>
+            <th>Version</th>
+            <th>SHA1</th>
+            <th>Install</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr id="{{key}}-{{prop.version}}"
+            ng-repeat="(key, prop) in plugins.available">
+            <td>{{key}}</td>
+            <td>{{prop.version}}</td>
+            <td>{{prop.sha1}}</td>
+            <td>
+              <h5>
+                <span id="installing-{{key}}"
+                  class="label label-default hidden">Installing</span> <span
+                  id="installed-{{key}}"
+                  class="label label-success hidden">Installed</span> <span
+                  id="failed-{{key}}" class="label label-warning hidden">Failed</span>
+                <button id="{{key}}" type="button"
+                  class="btn btn-xs btn-primary"
+                  ng-click="install(prop.id,prop.url)">Install</button>
+              </h5>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+  </div>
 </body>
 </html>
diff --git a/src/main/resources/static/js/plugin-manager.js b/src/main/resources/static/js/plugin-manager.js
index d3e455a..5b2a695 100644
--- a/src/main/resources/static/js/plugin-manager.js
+++ b/src/main/resources/static/js/plugin-manager.js
@@ -14,24 +14,46 @@
 
 var app = angular.module('PluginManager', []).controller(
     'LoadInstalledPlugins',
-    function($http) {
+    function($scope, $http) {
       var plugins = this;
 
       plugins.list = {};
 
       plugins.available = {};
 
-      $http.get('/plugins/?all', plugins.httpConfig).then(
-          function successCallback(response) {
-            plugins.list = response.data;
-          }, function errorCallback(response) {
-          });
-      
-      $http.get('/plugins/plugin-manager/available', plugins.httpConfig).then(
-          function successCallback(response) {
-            plugins.available = response.data;
-          }, function errorCallback(response) {
-          });
+      $scope.refreshInstalled = function() {
+        $http.get('/plugins/?all', plugins.httpConfig).then(
+            function successCallback(response) {
+              plugins.list = response.data;
+            }, function errorCallback(response) {
+            });
+      }
+
+      $scope.refreshAvailable = function() {
+        $http.get('/plugins/plugin-manager/available', plugins.httpConfig)
+            .then(function successCallback(response) {
+              plugins.available = response.data;
+            }, function errorCallback(response) {
+            });
+      }
+
+      $scope.install = function(id, url) {
+        var pluginInstallData = { "url" : url };
+        $("button#" + id).addClass("hidden");
+        $("span#installing-" + id).removeClass("hidden");
+        $http.put('/a/plugins/' + id + ".jar", pluginInstallData).then(
+            function successCallback(response) {
+              $("span#installing-" + id).addClass("hidden");
+              $("span#installed-" + id).removeClass("hidden");
+              $scope.refreshInstalled();
+            }, function errorCallback(response) {
+              $("span#installing-" + id).addClass("hidden");
+              $("span#failed-" + id).removeClass("hidden");
+            });
+      }
+
+      $scope.refreshInstalled();
+      $scope.refreshAvailable();
     });
 
 app.config(function($httpProvider) {