summaryrefslogtreecommitdiffstats
path: root/controller-server
diff options
context:
space:
mode:
authorValerij Fredriksen <freva@users.noreply.github.com>2021-05-07 15:38:33 +0200
committerGitHub <noreply@github.com>2021-05-07 15:38:33 +0200
commitcae8dc88c08cd14f7fff6eb91ec44df2ec1285d9 (patch)
tree41c3033919daeaef3e9d272385ab1e9579527a4a /controller-server
parentad66501b29e1ea3edc515af871caa7c41297921a (diff)
parentca663df8b7af46fcd6b59631617e1f2fc725954c (diff)
Merge pull request #17775 from vespa-engine/jonmv/badges
Jonmv/badges
Diffstat (limited to 'controller-server')
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/Badges.java59
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/JobController.java26
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/BadgeApiHandler.java36
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/Badges.java293
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/BadgesTest.java61
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ZoneRegistryMock.java5
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/deployment/BadgeApiTest.java42
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/deployment/responses/history.svg176
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/deployment/responses/overview.svg125
9 files changed, 654 insertions, 169 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/Badges.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/Badges.java
deleted file mode 100644
index f5369406f97..00000000000
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/Badges.java
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright 2020 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.deployment;
-
-import com.yahoo.config.provision.ApplicationId;
-
-import java.net.URI;
-import java.util.List;
-import java.util.Optional;
-
-/**
- * URLs for deployment job badges using <a href="https://github.com/yahoo/badge-up">badge-up</a>.
- *
- * @author jonmv
- */
-class Badges {
-
- static final String dark = "555555",
- blue = "4477DD",
- red = "DD4444",
- purple = "AA11CC",
- yellow = "DDAA11",
- white = "FFFFFF";
-
- private final URI badgeApi;
-
- Badges(URI badgeApi) {
- this.badgeApi = badgeApi;
- }
-
- /** Returns a URI which gives a history badge for the given runs. */
- URI historic(ApplicationId id, Optional<Run> lastCompleted, List<Run> runs) {
- StringBuilder path = new StringBuilder(id + ";" + dark);
-
- lastCompleted.ifPresent(last -> path.append("/").append(last.id().type().jobName()).append(";").append(colorOf(last)));
- for (Run run : runs)
- path.append("/%20;").append(colorOf(run)).append(";s%7B").append(white).append("%7D");
-
- return badgeApi.resolve(path.toString());
- }
-
- /** Returns a URI which gives an overview badge for the given runs. */
- URI overview(ApplicationId id, List<Run> runs) {
- StringBuilder path = new StringBuilder(id + ";" + dark);
- for (Run run : runs)
- path.append("/").append(run.id().type().jobName()).append(";").append(colorOf(run));
-
- return badgeApi.resolve(path.toString());
- }
-
- private static String colorOf(Run run) {
- switch (run.status()) {
- case success: return blue;
- case running: return purple;
- case aborted: return yellow;
- default: return red;
- }
- }
-
-}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/JobController.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/JobController.java
index 3dc88d5d6d2..25bc21a0076 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/JobController.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/JobController.java
@@ -82,7 +82,6 @@ public class JobController {
private final CuratorDb curator;
private final BufferedLogStore logs;
private final TesterCloud cloud;
- private final Badges badges;
private final JobMetrics metric;
private final AtomicReference<Consumer<Run>> runner = new AtomicReference<>(__ -> { });
@@ -92,7 +91,6 @@ public class JobController {
this.curator = controller.curator();
this.logs = new BufferedLogStore(curator, controller.serviceRegistry().runDataStore());
this.cloud = controller.serviceRegistry().testerCloud();
- this.badges = new Badges(controller.zoneRegistry().badgeUrl());
this.metric = new JobMetrics(controller.metric(), controller::system);
}
@@ -539,30 +537,6 @@ public class JobController {
}
}
- /** Returns a URI which points at a badge showing historic status of given length for the given job type for the given application. */
- public URI historicBadge(ApplicationId id, JobType type, int historyLength) {
- List<Run> runs = new ArrayList<>(runs(id, type).values());
- Run lastCompleted = null;
- if (runs.size() > 0)
- lastCompleted = runs.get(runs.size() - 1);
- if (runs.size() > 1 && ! lastCompleted.hasEnded())
- lastCompleted = runs.get(runs.size() - 2);
-
- return badges.historic(id, Optional.ofNullable(lastCompleted), runs.subList(Math.max(0, runs.size() - historyLength), runs.size()));
- }
-
- /** Returns a URI which points at a badge showing current status for all jobs for the given application. */
- public URI overviewBadge(ApplicationId id) {
- DeploymentSteps steps = new DeploymentSteps(controller.applications().requireApplication(TenantAndApplicationId.from(id))
- .deploymentSpec().requireInstance(id.instance()),
- controller::system);
- return badges.overview(id,
- steps.jobs().stream()
- .map(type -> last(id, type))
- .flatMap(Optional::stream)
- .collect(toList()));
- }
-
private void prunePackages(TenantAndApplicationId id) {
controller.applications().lockApplicationIfPresent(id, application -> {
application.get().productionDeployments().values().stream()
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/BadgeApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/BadgeApiHandler.java
index befec42e84e..6fef422b811 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/BadgeApiHandler.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/BadgeApiHandler.java
@@ -5,19 +5,23 @@ import com.yahoo.config.provision.ApplicationId;
import com.yahoo.container.jdisc.HttpRequest;
import com.yahoo.container.jdisc.HttpResponse;
import com.yahoo.container.jdisc.LoggingRequestHandler;
-import com.yahoo.jdisc.Response;
import com.yahoo.jdisc.http.HttpRequest.Method;
import com.yahoo.restapi.Path;
import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobId;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType;
import com.yahoo.restapi.ErrorResponse;
+import com.yahoo.vespa.hosted.controller.application.TenantAndApplicationId;
+import com.yahoo.vespa.hosted.controller.deployment.DeploymentStatus;
import com.yahoo.yolean.Exceptions;
+import java.io.IOException;
import java.io.OutputStream;
-import java.net.URI;
import java.util.logging.Level;
import java.util.logging.Logger;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
/**
* This API serves redirects to a badge server.
*
@@ -62,24 +66,30 @@ public class BadgeApiHandler extends LoggingRequestHandler {
/** Returns a URI which points to an overview badge for the given application. */
private HttpResponse badge(String tenant, String application, String instance) {
- URI location = controller.jobController().overviewBadge(ApplicationId.from(tenant, application, instance));
- return redirect(location);
+ ApplicationId id = ApplicationId.from(tenant, application, instance);
+ DeploymentStatus status = controller.jobController().deploymentStatus(controller.applications().requireApplication(TenantAndApplicationId.from(id)));
+ return svgResponse(Badges.overviewBadge(id,
+ status.jobs().instance(id.instance()),
+ controller.system()));
}
/** Returns a URI which points to a history badge for the given application and job type. */
private HttpResponse badge(String tenant, String application, String instance, String jobName, String historyLength) {
- URI location = controller.jobController().historicBadge(ApplicationId.from(tenant, application, instance),
- JobType.fromJobName(jobName),
- historyLength == null ? 5 : Math.min(32, Math.max(0, Integer.parseInt(historyLength))));
- return redirect(location);
+ ApplicationId id = ApplicationId.from(tenant, application, instance);
+ return svgResponse(Badges.historyBadge(id,
+ controller.jobController().jobStatus(new JobId(id, JobType.fromJobName(jobName))),
+ historyLength == null ? 5 : Math.min(32, Math.max(0, Integer.parseInt(historyLength)))));
}
- private static HttpResponse redirect(URI location) {
- HttpResponse httpResponse = new HttpResponse(Response.Status.FOUND) {
- @Override public void render(OutputStream outputStream) { }
+ private static HttpResponse svgResponse(String svg) {
+ return new HttpResponse(200) {
+ @Override public void render(OutputStream outputStream) throws IOException {
+ outputStream.write(svg.getBytes(UTF_8));
+ }
+ @Override public String getContentType() {
+ return "image/svg+xml; charset=UTF-8";
+ }
};
- httpResponse.headers().add("Location", location.toString());
- return httpResponse;
}
}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/Badges.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/Badges.java
new file mode 100644
index 00000000000..006da9f4439
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/Badges.java
@@ -0,0 +1,293 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.deployment;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.SystemName;
+import com.yahoo.slime.ArrayTraverser;
+import com.yahoo.slime.SlimeUtils;
+import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType;
+import com.yahoo.vespa.hosted.controller.deployment.JobList;
+import com.yahoo.vespa.hosted.controller.deployment.JobStatus;
+import com.yahoo.vespa.hosted.controller.deployment.Run;
+import com.yahoo.vespa.hosted.controller.deployment.RunStatus;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static java.util.stream.Collectors.toList;
+
+public class Badges {
+
+ // https://chrishewett.com/blog/calculating-text-width-programmatically/ thank you!
+ private static final String characterWidths = "[[\" \",35.156],[\"!\",39.355],[\"\\\"\",45.898],[\"#\",81.836],[\"$\",63.574],[\"%\",107.617],[\"&\",72.656],[\"'\",26.855],[\"(\",45.41],[\")\",45.41],[\"*\",63.574],[\"+\",81.836],[\",\",36.377],[\"-\",45.41],[\".\",36.377],[\"/\",45.41],[\"0\",63.574],[\"1\",63.574],[\"2\",63.574],[\"3\",63.574],[\"4\",63.574],[\"5\",63.574],[\"6\",63.574],[\"7\",63.574],[\"8\",63.574],[\"9\",63.574],[\":\",45.41],[\";\",45.41],[\"<\",81.836],[\"=\",81.836],[\">\",81.836],[\"?\",54.541],[\"@\",100],[\"A\",68.359],[\"B\",68.555],[\"C\",69.824],[\"D\",77.051],[\"E\",63.232],[\"F\",57.471],[\"G\",77.539],[\"H\",75.146],[\"I\",42.09],[\"J\",45.459],[\"K\",69.287],[\"L\",55.664],[\"M\",84.277],[\"N\",74.805],[\"O\",78.711],[\"P\",60.303],[\"Q\",78.711],[\"R\",69.531],[\"S\",68.359],[\"T\",61.621],[\"U\",73.193],[\"V\",68.359],[\"W\",98.877],[\"X\",68.506],[\"Y\",61.523],[\"Z\",68.506],[\"[\",45.41],[\"\\\\\",45.41],[\"]\",45.41],[\"^\",81.836],[\"_\",63.574],[\"`\",63.574],[\"a\",60.059],[\"b\",62.305],[\"c\",52.1],[\"d\",62.305],[\"e\",59.57],[\"f\",35.156],[\"g\",62.305],[\"h\",63.281],[\"i\",27.441],[\"j\",34.424],[\"k\",59.18],[\"l\",27.441],[\"m\",97.266],[\"n\",63.281],[\"o\",60.693],[\"p\",62.305],[\"q\",62.305],[\"r\",42.676],[\"s\",52.1],[\"t\",39.404],[\"u\",63.281],[\"v\",59.18],[\"w\",81.836],[\"x\",59.18],[\"y\",59.18],[\"z\",52.539],[\"{\",63.477],[\"|\",45.41],[\"}\",63.477],[\"~\",81.836],[\"_median\",63.281]]";
+ private static final double[] widths = new double[128]; // 0-94 hold widths for corresponding chars (+32); 95 holds the fallback width.
+
+ static {
+ SlimeUtils.jsonToSlimeOrThrow(characterWidths).get()
+ .traverse((ArrayTraverser) (i, pair) -> {
+ if (i < 95)
+ assert Arrays.equals(new byte[]{(byte) (i + 32)}, pair.entry(0).asUtf8()) : i + ": " + pair.entry(0).asString();
+ else
+ assert "_median".equals(pair.entry(0).asString());
+
+ widths[i] = pair.entry(1).asDouble();
+ });
+ }
+
+ /** Character pixel width of a 100px size Verdana font rendering of the given code point, for code points in the range [32, 126]. */
+ public static double widthOf(int codePoint) {
+ return 32 <= codePoint && codePoint <= 126 ? widths[codePoint - 32] : widths[95];
+ }
+
+ /** Computes an approximate pixel width of a 10px size Verdana font rendering of the given string, ignoring kerning. */
+ public static double widthOf(String text) {
+ return text.codePoints().mapToDouble(Badges::widthOf).sum() / 10;
+ }
+
+ static String colorOf(Run run, Boolean wasOk) {
+ switch (run.status()) {
+ case running:
+ return wasOk ? "url(#run-on-success)" : "url(#run-on-failure)";
+ case success:
+ return success;
+ default:
+ return failure;
+ }
+ }
+
+ static String nameOf(JobType type) {
+ return type.isTest() ? type.isProduction() ? "test"
+ : type.jobName()
+ : type.jobName().replace("production-", "");
+ }
+
+ static final double xPad = 8;
+ static final double logoSize = 16;
+ static final String dark = "#5a5a5a";
+ static final String success = "#00ff48";
+ static final String running = "#ab83ff";
+ static final String failure = "#bf103c";
+
+ static void addText(List<String> texts, String text, double x, double width) {
+ texts.add(" <text x='" + (x + 0.5) + "' y='14' fill='#000' fill-opacity='.3' textLength='" + width + "'>" + text + "</text>\n");
+ texts.add(" <text x='" + x + "' y='13' fill='#fff' textLength='" + width + "'>" + text + "</text>\n");
+ }
+
+ static void addShade(List<String> sections, double x, double width) {
+ sections.add(" <rect x='" + (x - 6) + "' rx='3' width='" + (width + 6) + "' height='20' fill='url(#shade)'/>\n");
+ }
+
+ static void addShadow(List<String> sections, double x) {
+ sections.add(" <rect x='" + (x - 6) + "' rx='3' width='" + 9 + "' height='20' fill='url(#shadow)'/>\n");
+ }
+
+ static String historyBadge(ApplicationId id, JobStatus status, int length) {
+ List<String> sections = new ArrayList<>();
+ List<String> texts = new ArrayList<>();
+
+ double x = 0;
+ String text = id.toFullString();
+ double textWidth = widthOf(text);
+ double dx = xPad + logoSize + xPad + textWidth + xPad;
+
+ addShade(sections, x, dx);
+ sections.add(" <rect width='" + dx + "' height='20' fill='" + dark + "'/>\n");
+ addText(texts, text, x + (xPad + logoSize + dx) / 2, textWidth);
+ x += dx;
+
+ if (status.lastTriggered().isEmpty())
+ return badge(sections, texts, x);
+
+ Run lastTriggered = status.lastTriggered().get();
+ List<Run> runs = status.runs().descendingMap().values().stream()
+ .filter(Run::hasEnded)
+ .limit(length + (lastTriggered.hasEnded() ? 0 : 1))
+ .collect(toList());
+
+ boolean isOk = runs.isEmpty() || runs.get(0).status() == RunStatus.success;
+ if ( ! lastTriggered.hasEnded())
+ runs.remove(0);
+
+ text = lastTriggered.id().type().jobName();
+ textWidth = widthOf(text);
+ dx = xPad + textWidth + xPad;
+ addShade(sections, x, dx);
+ sections.add(" <rect x='" + (x - 6) + "' rx='3' width='" + (dx + 6) + "' height='20' fill='" + colorOf(lastTriggered, isOk) + "'/>\n");
+ addShadow(sections, x + dx);
+ addText(texts, text, x + dx / 2, textWidth);
+ x += dx;
+
+ dx = xPad * (128.0 / (32 + runs.size())); // Broader sections with shorter history.
+ for (Run run : runs) {
+ addShade(sections, x, dx);
+ sections.add(" <rect x='" + (x - 6) + "' rx='3' width='" + (dx + 6) + "' height='20' fill='" + colorOf(run, null) + "'/>\n");
+ addShadow(sections, x + dx);
+ dx *= Math.pow(0.3, 1.0 / (runs.size() + 8)); // Gradually narrowing sections with age.
+ x += dx;
+ }
+ Collections.reverse(sections);
+
+ return badge(sections, texts, x);
+ }
+
+ static String overviewBadge(ApplicationId id, JobList jobs, SystemName system) {
+ // Put production tests right after their deployments, for a more compact rendering.
+ List<Run> runs = new ArrayList<>(jobs.lastTriggered().asList());
+ for (int i = 0; i < runs.size(); i++) {
+ Run run = runs.get(i);
+ if (run.id().type().isProduction() && run.id().type().isTest()) {
+ int j = i;
+ while (!runs.get(j - 1).id().type().zone(system).equals(run.id().type().zone(system)))
+ runs.set(j, runs.get(--j));
+ runs.set(j, run);
+ }
+ }
+
+ List<String> sections = new ArrayList<>();
+ List<String> texts = new ArrayList<>();
+
+ double x = 0;
+ String text = id.toFullString();
+ double textWidth = widthOf(text);
+ double dx = xPad + logoSize + xPad + textWidth + xPad;
+ double tdx = xPad + widthOf("test");
+
+ addShade(sections, 0, dx);
+ sections.add(" <rect width='" + dx + "' height='20' fill='" + dark + "'/>\n");
+ addText(texts, text, x + (xPad + logoSize + dx) / 2, textWidth);
+ x += dx;
+
+ for (int i = 0; i < runs.size(); i++) {
+ Run run = runs.get(i);
+ Run test = i + 1 < runs.size() ? runs.get(i + 1) : null;
+ if (test == null || ! test.id().type().isTest() || ! test.id().type().isProduction())
+ test = null;
+
+ boolean isTest = run.id().type().isTest() && run.id().type().isProduction();
+ text = nameOf(run.id().type());
+ textWidth = widthOf(text);
+ dx = xPad + textWidth + (isTest ? 0 : xPad);
+ boolean wasOk = jobs.get(run.id().job()).flatMap(JobStatus::lastStatus).map(RunStatus.success::equals).orElse(true);
+
+ addText(texts, text, x + (dx - (isTest ? xPad : 0)) / 2, textWidth);
+
+ // Add "deploy" when appropriate
+ if ( ! run.id().type().isTest()) {
+ String deploy = "deploy";
+ textWidth = widthOf(deploy);
+ addText(texts, deploy, x + dx + textWidth / 2, textWidth);
+ dx += textWidth + xPad;
+ }
+
+ // Add shade across zone section.
+ if ( ! (isTest))
+ addShade(sections, x, dx + (test != null ? tdx : 0));
+
+ // Add colored section for job ...
+ if (test == null)
+ sections.add(" <rect x='" + (x - 16) + "' rx='3' width='" + (dx + 16) + "' height='20' fill='" + colorOf(run, wasOk) + "'/>\n");
+ // ... with a slant if a test is next.
+ else
+ sections.add(" <polygon points='" + (x - 6) + " 0 " + (x - 6) + " 20 " + (x + dx - 8.5) + " 20 " + (x + dx - 0.5) + " 0' fill='" + colorOf(run, wasOk) + "'/>\n");
+
+ // Cast a shadow onto the next zone ...
+ if (test == null)
+ addShadow(sections, x + dx);
+
+ x += dx;
+ }
+ Collections.reverse(sections);
+
+ return badge(sections, texts, x);
+ }
+
+ static String badge(List<String> sections, List<String> texts, double width) {
+ return "<svg xmlns='http://www.w3.org/2000/svg' width='" + width + "' height='20' role='img' aria-label='Deployment Status'>\n" +
+ " <title>Deployment Status</title>\n" +
+ // Lighting to give the badge a 3d look--dispersion at the top, shadow at the bottom.
+ " <linearGradient id='light' x2='0' y2='100%'>\n" +
+ " <stop offset='0' stop-color='#fff' stop-opacity='.5'/>\n" +
+ " <stop offset='.1' stop-color='#fff' stop-opacity='.15'/>\n" +
+ " <stop offset='.9' stop-color='#000' stop-opacity='.15'/>\n" +
+ " <stop offset='1' stop-color='#000' stop-opacity='.7'/>\n" +
+ " </linearGradient>\n" +
+ // Dispersed light at the left of the badge.
+ " <linearGradient id='left-light' x2='100%' y2='0'>\n" +
+ " <stop offset='0' stop-color='#fff' stop-opacity='.3'/>\n" +
+ " <stop offset='.5' stop-color='#fff' stop-opacity='.1'/>\n" +
+ " <stop offset='1' stop-color='#fff' stop-opacity='.0'/>\n" +
+ " </linearGradient>\n" +
+ // Shadow at the right of the badge.
+ " <linearGradient id='right-shadow' x2='100%' y2='0'>\n" +
+ " <stop offset='0' stop-color='#000' stop-opacity='.0'/>\n" +
+ " <stop offset='.5' stop-color='#000' stop-opacity='.1'/>\n" +
+ " <stop offset='1' stop-color='#000' stop-opacity='.3'/>\n" +
+ " </linearGradient>\n" +
+ // Shadow to highlight the border between sections, without using a heavy separator.
+ " <linearGradient id='shadow' x2='100%' y2='0'>\n" +
+ " <stop offset='0' stop-color='#222' stop-opacity='.4'/>\n" +
+ " <stop offset='.5' stop-color='#555' stop-opacity='.4'/>\n" +
+ " <stop offset='1' stop-color='#555' stop-opacity='.0'/>\n" +
+ " </linearGradient>\n" +
+ // Weak shade across each panel to highlight borders further.
+ " <linearGradient id='shade' x2='100%' y2='0'>\n" +
+ " <stop offset='0' stop-color='#000' stop-opacity='.20'/>\n" +
+ " <stop offset='0.05' stop-color='#000' stop-opacity='.10'/>\n" +
+ " <stop offset='1' stop-color='#000' stop-opacity='.0'/>\n" +
+ " </linearGradient>\n" +
+ // Running color sloshing back and forth on top of the failure color.
+ " <linearGradient id='run-on-failure' x1='40%' x2='80%' y2='0%'>\n" +
+ " <stop offset='0' stop-color='" + running + "' />\n" +
+ " <stop offset='1' stop-color='" + failure + "' />\n" +
+ " <animate attributeName='x1' values='-110%;150%;20%;-110%' dur='6s' repeatCount='indefinite' />\n" +
+ " <animate attributeName='x2' values='-10%;250%;120%;-10%' dur='6s' repeatCount='indefinite' />\n" +
+ " </linearGradient>\n" +
+ // Running color sloshing back and forth on top of the success color.
+ " <linearGradient id='run-on-success' x1='40%' x2='80%' y2='0%'>\n" +
+ " <stop offset='0' stop-color='" + running + "' />\n" +
+ " <stop offset='1' stop-color='" + success + "' />\n" +
+ " <animate attributeName='x1' values='-110%;150%;20%;-110%' dur='6s' repeatCount='indefinite' />\n" +
+ " <animate attributeName='x2' values='-10%;250%;120%;-10%' dur='6s' repeatCount='indefinite' />\n" +
+ " </linearGradient>\n" +
+ // Clipping to give the badge rounded corners.
+ " <clipPath id='rounded'>\n" +
+ " <rect width='" + width + "' height='20' rx='3' fill='#fff'/>\n" +
+ " </clipPath>\n" +
+ // Badge section backgrounds with status colors and shades for distinction.
+ " <g clip-path='url(#rounded)'>\n" +
+ String.join("", sections) +
+ " <rect width='" + 2 + "' height='20' fill='url(#left-light)'/>\n" +
+ " <rect x='" + (width - 2) + "' width='" + 2 + "' height='20' fill='url(#right-shadow)'/>\n" +
+ " <rect width='" + width + "' height='20' fill='url(#light)'/>\n" +
+ " </g>\n" +
+ " <g fill='#fff' text-anchor='middle' font-family='Verdana,Geneva,DejaVu Sans,sans-serif' text-rendering='geometricPrecision' font-size='10'>\n" +
+ // The vespa.ai logo (with a slightly coloured shadow)!
+ " <svg x='" + (xPad + 0.5) + "' y='" + ((20 - logoSize) / 2 + 1) + "' width='" + logoSize + "' height='" + logoSize + "' viewBox='0 0 150 150'>\n" +
+ " <polygon fill='#402a14' fill-opacity='0.5' points='84.84 10 34.1 44.46 34.1 103.78 84.84 68.02 135.57 103.78 135.57 44.46 84.84 10'/>\n" +
+ " <polygon fill='#402a14' fill-opacity='0.5' points='84.84 68.02 84.84 10 135.57 44.46 135.57 103.78 84.84 68.02'/>\n" +
+ " <polygon fill='#061a29' fill-opacity='0.5' points='65.07 81.99 14.34 46.22 14.34 105.54 65.07 140 115.81 105.54 115.81 46.22 65.07 81.99'/>\n" +
+ " <polygon fill='#061a29' fill-opacity='0.5' points='65.07 81.99 65.07 140 14.34 105.54 14.34 46.22 65.07 81.99'/>\n" +
+ " </svg>\n" +
+ " <svg x='" + xPad + "' y='" + ((20 - logoSize) / 2) + "' width='" + logoSize + "' height='" + logoSize + "' viewBox='0 0 150 150'>\n" +
+ " <linearGradient id='yellow-shaded' x1='91.17' y1='44.83' x2='136.24' y2='73.4' gradientUnits='userSpaceOnUse'>\n" +
+ " <stop offset='0.01' stop-color='#c6783e'/>\n" +
+ " <stop offset='0.54' stop-color='#ff9750'/>\n" +
+ " </linearGradient>\n" +
+ " <linearGradient id='blue-shaded' x1='60.71' y1='104.56' x2='-15.54' y2='63' gradientUnits='userSpaceOnUse'>\n" +
+ " <stop offset='0' stop-color='#005a8e'/>\n" +
+ " <stop offset='0.54' stop-color='#1a7db6'/>\n" +
+ " </linearGradient>\n" +
+ " <polygon fill='#ff9d4b' points='84.84 10 34.1 44.46 34.1 103.78 84.84 68.02 135.57 103.78 135.57 44.46 84.84 10'/>\n" +
+ " <polygon fill='url(#yellow-shaded)' points='84.84 68.02 84.84 10 135.57 44.46 135.57 103.78 84.84 68.02'/>\n" +
+ " <polygon fill='#1a7db6' points='65.07 81.99 14.34 46.22 14.34 105.54 65.07 140 115.81 105.54 115.81 46.22 65.07 81.99'/>\n" +
+ " <polygon fill='url(#blue-shaded)' points='65.07 81.99 65.07 140 14.34 105.54 14.34 46.22 65.07 81.99'/>\n" +
+ " </svg>\n" +
+ // Application ID and job names.
+ String.join("", texts) +
+ " </g>\n" +
+ "</svg>\n";
+ }
+
+} \ No newline at end of file
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/BadgesTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/BadgesTest.java
deleted file mode 100644
index 06d5a42f9c0..00000000000
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/BadgesTest.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright 2020 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.deployment;
-
-import com.google.common.collect.ImmutableMap;
-import com.yahoo.config.provision.ApplicationId;
-import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType;
-import com.yahoo.vespa.hosted.controller.api.integration.deployment.RunId;
-import org.junit.Test;
-
-import java.net.URI;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-
-import static com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType.stagingTest;
-import static com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType.systemTest;
-import static com.yahoo.vespa.hosted.controller.deployment.Step.report;
-import static java.time.Instant.EPOCH;
-import static java.time.Instant.now;
-import static org.junit.Assert.assertEquals;
-
-/**
- * @author jonmv
- */
-public class BadgesTest {
-
- private static final ApplicationId id = ApplicationId.from("tenant", "application", "default");
- private static final Run success = new Run(new RunId(id, systemTest, 3), ImmutableMap.of(report, new StepInfo(report, Step.Status.succeeded, Optional.empty())),
- null, null, Optional.of(now()), RunStatus.success, 0, EPOCH, Optional.empty(), Optional.empty(), Optional.empty());
-
- private static final Run running = new Run(new RunId(id, systemTest, 4), ImmutableMap.of(report, new StepInfo(report, Step.Status.succeeded, Optional.empty())),
- null, null, Optional.empty(), RunStatus.running, 0, EPOCH, Optional.empty(), Optional.empty(), Optional.empty());
-
- private static final Run failure = new Run(new RunId(id, JobType.stagingTest, 2), ImmutableMap.of(report, new StepInfo(report, Step.Status.succeeded, Optional.empty())),
- null, null, Optional.of(now()), RunStatus.testFailure, 0, EPOCH, Optional.empty(), Optional.empty(), Optional.empty());
-
- @Test
- public void test() {
- Badges badges = new Badges(URI.create("https://badges.tld/api/"));
-
- assertEquals(URI.create("https://badges.tld/api/tenant.application;" + Badges.dark),
- badges.historic(id, Optional.empty(), Collections.emptyList()));
-
- assertEquals(URI.create("https://badges.tld/api/tenant.application;" + Badges.dark +
- "/" + systemTest.jobName() + ";" + Badges.blue +
- "/%20;" + Badges.purple + ";s%7B" + Badges.white + "%7D"),
- badges.historic(id, Optional.of(success), Collections.singletonList(running)));
-
- assertEquals(URI.create("https://badges.tld/api/tenant.application;" + Badges.dark +
- "/" + systemTest.jobName() + ";" + Badges.blue +
- "/%20;" + Badges.blue + ";s%7B" + Badges.white + "%7D" +
- "/%20;" + Badges.purple + ";s%7B" + Badges.white + "%7D"),
- badges.historic(id, Optional.of(success), List.of(success, running)));
-
- assertEquals(URI.create("https://badges.tld/api/tenant.application;" + Badges.dark +
- "/" + systemTest.jobName() + ";" + Badges.purple +
- "/" + stagingTest.jobName() + ";" + Badges.red),
- badges.overview(id, List.of(running, failure)));
- }
-
-}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ZoneRegistryMock.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ZoneRegistryMock.java
index 1d2c743ffba..f1e0b8125e1 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ZoneRegistryMock.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ZoneRegistryMock.java
@@ -206,11 +206,6 @@ public class ZoneRegistryMock extends AbstractComponent implements ZoneRegistry
}
@Override
- public URI badgeUrl() {
- return URI.create("https://badges.tld");
- }
-
- @Override
public URI apiUrl() {
return URI.create("https://api.tld:4443/");
}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/deployment/BadgeApiTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/deployment/BadgeApiTest.java
index ff02a1e16be..21d8030dbee 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/deployment/BadgeApiTest.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/deployment/BadgeApiTest.java
@@ -2,11 +2,18 @@
package com.yahoo.vespa.hosted.controller.restapi.deployment;
import com.yahoo.vespa.hosted.controller.ControllerTester;
+import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType;
+import com.yahoo.vespa.hosted.controller.application.ApplicationPackage;
+import com.yahoo.vespa.hosted.controller.deployment.ApplicationPackageBuilder;
import com.yahoo.vespa.hosted.controller.deployment.DeploymentTester;
import com.yahoo.vespa.hosted.controller.restapi.ContainerTester;
import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerTest;
import org.junit.Test;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
/**
* @author jonmv
*/
@@ -15,15 +22,40 @@ public class BadgeApiTest extends ControllerContainerTest {
private static final String responseFiles = "src/test/java/com/yahoo/vespa/hosted/controller/restapi/deployment/responses/";
@Test
- public void testBadgeApi() {
+ public void testBadgeApi() throws IOException {
ContainerTester tester = new ContainerTester(container, responseFiles);
var application = new DeploymentTester(new ControllerTester(tester)).newDeploymentContext("tenant", "application", "default");
- application.submit();
+ ApplicationPackage applicationPackage = new ApplicationPackageBuilder().systemTest()
+ .parallel("us-west-1", "aws-us-east-1a")
+ .test("us-west-1")
+ .test("aws-us-east-1a")
+ .region("ap-southeast-1")
+ .test("ap-southeast-1")
+ .region("eu-west-1")
+ .test("eu-west-1")
+ .build();
+ application.submit(applicationPackage).deploy();
+ application.submit(applicationPackage)
+ .runJob(JobType.systemTest)
+ .runJob(JobType.stagingTest)
+ .runJob(JobType.productionUsWest1)
+ .runJob(JobType.productionAwsUsEast1a)
+ .runJob(JobType.testUsWest1)
+ .runJob(JobType.testAwsUsEast1a)
+ .runJob(JobType.productionApSoutheast1)
+ .failDeployment(JobType.testApSoutheast1);
+ application.submit(applicationPackage)
+ .runJob(JobType.systemTest)
+ .runJob(JobType.stagingTest);
+ for (int i = 0; i < 32; i++)
+ application.failDeployment(JobType.productionUsWest1);
+ application.triggerJobs();
+ tester.controller().applications().deploymentTrigger().reTrigger(application.instanceId(), JobType.testEuWest1);
tester.assertResponse(authenticatedRequest("http://localhost:8080/badge/v1/tenant/application/default"),
- "", 302);
- tester.assertResponse(authenticatedRequest("http://localhost:8080/badge/v1/tenant/application/default/system-test?historyLength=10"),
- "", 302);
+ Files.readString(Paths.get(responseFiles + "overview.svg")), 200);
+ tester.assertResponse(authenticatedRequest("http://localhost:8080/badge/v1/tenant/application/default/production-us-west-1?historyLength=32"),
+ Files.readString(Paths.get(responseFiles + "history.svg")), 200);
}
}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/deployment/responses/history.svg b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/deployment/responses/history.svg
new file mode 100644
index 00000000000..a081b21ec3f
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/deployment/responses/history.svg
@@ -0,0 +1,176 @@
+<svg xmlns='http://www.w3.org/2000/svg' width='616.7390658896196' height='20' role='img' aria-label='Deployment Status'>
+ <title>Deployment Status</title>
+ <linearGradient id='light' x2='0' y2='100%'>
+ <stop offset='0' stop-color='#fff' stop-opacity='.5'/>
+ <stop offset='.1' stop-color='#fff' stop-opacity='.15'/>
+ <stop offset='.9' stop-color='#000' stop-opacity='.15'/>
+ <stop offset='1' stop-color='#000' stop-opacity='.7'/>
+ </linearGradient>
+ <linearGradient id='left-light' x2='100%' y2='0'>
+ <stop offset='0' stop-color='#fff' stop-opacity='.3'/>
+ <stop offset='.5' stop-color='#fff' stop-opacity='.1'/>
+ <stop offset='1' stop-color='#fff' stop-opacity='.0'/>
+ </linearGradient>
+ <linearGradient id='right-shadow' x2='100%' y2='0'>
+ <stop offset='0' stop-color='#000' stop-opacity='.0'/>
+ <stop offset='.5' stop-color='#000' stop-opacity='.1'/>
+ <stop offset='1' stop-color='#000' stop-opacity='.3'/>
+ </linearGradient>
+ <linearGradient id='shadow' x2='100%' y2='0'>
+ <stop offset='0' stop-color='#222' stop-opacity='.4'/>
+ <stop offset='.5' stop-color='#555' stop-opacity='.4'/>
+ <stop offset='1' stop-color='#555' stop-opacity='.0'/>
+ </linearGradient>
+ <linearGradient id='shade' x2='100%' y2='0'>
+ <stop offset='0' stop-color='#000' stop-opacity='.20'/>
+ <stop offset='0.05' stop-color='#000' stop-opacity='.10'/>
+ <stop offset='1' stop-color='#000' stop-opacity='.0'/>
+ </linearGradient>
+ <linearGradient id='run-on-failure' x1='40%' x2='80%' y2='0%'>
+ <stop offset='0' stop-color='#ab83ff' />
+ <stop offset='1' stop-color='#bf103c' />
+ <animate attributeName='x1' values='-110%;150%;20%;-110%' dur='6s' repeatCount='indefinite' />
+ <animate attributeName='x2' values='-10%;250%;120%;-10%' dur='6s' repeatCount='indefinite' />
+ </linearGradient>
+ <linearGradient id='run-on-success' x1='40%' x2='80%' y2='0%'>
+ <stop offset='0' stop-color='#ab83ff' />
+ <stop offset='1' stop-color='#00ff48' />
+ <animate attributeName='x1' values='-110%;150%;20%;-110%' dur='6s' repeatCount='indefinite' />
+ <animate attributeName='x2' values='-10%;250%;120%;-10%' dur='6s' repeatCount='indefinite' />
+ </linearGradient>
+ <clipPath id='rounded'>
+ <rect width='616.7390658896196' height='20' rx='3' fill='#fff'/>
+ </clipPath>
+ <g clip-path='url(#rounded)'>
+ <rect x='610.9256720815912' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='604.6322196342305' rx='3' width='12.293452447360632' height='20' fill='#00ff48'/>
+ <rect x='604.6322196342305' rx='3' width='12.293452447360632' height='20' fill='url(#shade)'/>
+ <rect x='604.8245279299437' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='598.3387671868699' rx='3' width='12.485760743073765' height='20' fill='#bf103c'/>
+ <rect x='598.3387671868699' rx='3' width='12.485760743073765' height='20' fill='url(#shade)'/>
+ <rect x='598.5369518248465' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='591.8530064437961' rx='3' width='12.68394538105045' height='20' fill='#bf103c'/>
+ <rect x='591.8530064437961' rx='3' width='12.68394538105045' height='20' fill='url(#shade)'/>
+ <rect x='592.0572469867445' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='585.1690610627456' rx='3' width='12.888185923998947' height='20' fill='#bf103c'/>
+ <rect x='585.1690610627456' rx='3' width='12.888185923998947' height='20' fill='url(#shade)'/>
+ <rect x='585.3795425602511' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='578.2808751387468' rx='3' width='13.098667421504338' height='20' fill='#bf103c'/>
+ <rect x='578.2808751387468' rx='3' width='13.098667421504338' height='20' fill='url(#shade)'/>
+ <rect x='578.4977882949328' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='571.1822077172424' rx='3' width='13.31558057769039' height='20' fill='#bf103c'/>
+ <rect x='571.1822077172424' rx='3' width='13.31558057769039' height='20' fill='url(#shade)'/>
+ <rect x='571.4057490635566' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='563.866627139552' rx='3' width='13.539121924004645' height='20' fill='#bf103c'/>
+ <rect x='563.866627139552' rx='3' width='13.539121924004645' height='20' fill='url(#shade)'/>
+ <rect x='564.0969992128305' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='556.3275052155473' rx='3' width='13.769493997283261' height='20' fill='#bf103c'/>
+ <rect x='556.3275052155473' rx='3' width='13.769493997283261' height='20' fill='url(#shade)'/>
+ <rect x='556.564916741521' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='548.558011218264' rx='3' width='14.006905523256986' height='20' fill='#bf103c'/>
+ <rect x='548.558011218264' rx='3' width='14.006905523256986' height='20' fill='url(#shade)'/>
+ <rect x='548.8026773006716' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='540.5511056950071' rx='3' width='14.251571605664486' height='20' fill='#bf103c'/>
+ <rect x='540.5511056950071' rx='3' width='14.251571605664486' height='20' fill='url(#shade)'/>
+ <rect x='540.803248010487' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='532.2995340893426' rx='3' width='14.503713921144396' height='20' fill='#bf103c'/>
+ <rect x='532.2995340893426' rx='3' width='14.503713921144396' height='20' fill='url(#shade)'/>
+ <rect x='532.5593810882809' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='523.7958201681982' rx='3' width='14.763560920082657' height='20' fill='#bf103c'/>
+ <rect x='523.7958201681982' rx='3' width='14.763560920082657' height='20' fill='url(#shade)'/>
+ <rect x='524.0636072817127' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='515.0322592481156' rx='3' width='15.031348033597132' height='20' fill='#bf103c'/>
+ <rect x='515.0322592481156' rx='3' width='15.031348033597132' height='20' fill='url(#shade)'/>
+ <rect x='515.3082291013654' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='506.0009112145184' rx='3' width='15.307317886847013' height='20' fill='#bf103c'/>
+ <rect x='506.0009112145184' rx='3' width='15.307317886847013' height='20' fill='url(#shade)'/>
+ <rect x='506.2853138465317' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='496.6935933276714' rx='3' width='15.591720518860313' height='20' fill='#bf103c'/>
+ <rect x='496.6935933276714' rx='3' width='15.591720518860313' height='20' fill='url(#shade)'/>
+ <rect x='496.9866864178897' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='487.1018728088111' rx='3' width='15.884813609078591' height='20' fill='#bf103c'/>
+ <rect x='487.1018728088111' rx='3' width='15.884813609078591' height='20' fill='url(#shade)'/>
+ <rect x='487.4039219105567' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='477.2170591997325' rx='3' width='16.186862710824187' height='20' fill='#bf103c'/>
+ <rect x='477.2170591997325' rx='3' width='16.186862710824187' height='20' fill='url(#shade)'/>
+ <rect x='477.5283379808098' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='467.0301964889083' rx='3' width='16.498141491901468' height='20' fill='#bf103c'/>
+ <rect x='467.0301964889083' rx='3' width='16.498141491901468' height='20' fill='url(#shade)'/>
+ <rect x='467.3509869795569' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='456.5320549970068' rx='3' width='16.81893198255014' height='20' fill='#bf103c'/>
+ <rect x='456.5320549970068' rx='3' width='16.81893198255014' height='20' fill='url(#shade)'/>
+ <rect x='456.86264784543187' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='445.7131230144567' rx='3' width='17.14952483097518' height='20' fill='#bf103c'/>
+ <rect x='445.7131230144567' rx='3' width='17.14952483097518' height='20' fill='url(#shade)'/>
+ <rect x='446.05381775016656' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='434.56359818348153' rx='3' width='17.49021956668504' height='20' fill='#bf103c'/>
+ <rect x='434.56359818348153' rx='3' width='17.49021956668504' height='20' fill='url(#shade)'/>
+ <rect x='434.91470348867307' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='423.0733786167965' rx='3' width='17.841324871876566' height='20' fill='#bf103c'/>
+ <rect x='423.0733786167965' rx='3' width='17.841324871876566' height='20' fill='url(#shade)'/>
+ <rect x='423.43521260603256' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='411.23205374491994' rx='3' width='18.20315886111265' height='20' fill='#bf103c'/>
+ <rect x='411.23205374491994' rx='3' width='18.20315886111265' height='20' fill='url(#shade)'/>
+ <rect x='411.60494425335327' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='399.0288948838073' rx='3' width='18.576049369545963' height='20' fill='#bf103c'/>
+ <rect x='399.0288948838073' rx='3' width='18.576049369545963' height='20' fill='url(#shade)'/>
+ <rect x='399.41317976421124' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='386.45284551426136' rx='3' width='18.960334249949863' height='20' fill='#bf103c'/>
+ <rect x='386.45284551426136' rx='3' width='18.960334249949863' height='20' fill='url(#shade)'/>
+ <rect x='386.84887294313717' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='373.4925112643115' rx='3' width='19.35636167882567' height='20' fill='#bf103c'/>
+ <rect x='373.4925112643115' rx='3' width='19.35636167882567' height='20' fill='url(#shade)'/>
+ <rect x='373.90064005734945' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='360.1361495854858' rx='3' width='19.764490471863645' height='20' fill='#bf103c'/>
+ <rect x='360.1361495854858' rx='3' width='19.764490471863645' height='20' fill='url(#shade)'/>
+ <rect x='360.55674952266554' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='346.3716591136221' rx='3' width='20.18509040904341' height='20' fill='#bf103c'/>
+ <rect x='346.3716591136221' rx='3' width='20.18509040904341' height='20' fill='url(#shade)'/>
+ <rect x='346.8051112742472' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='332.1865687045787' rx='3' width='20.61854256966852' height='20' fill='#bf103c'/>
+ <rect x='332.1865687045787' rx='3' width='20.61854256966852' height='20' fill='url(#shade)'/>
+ <rect x='332.6332658125487' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='317.56802613491016' rx='3' width='21.06523967763854' height='20' fill='#bf103c'/>
+ <rect x='317.56802613491016' rx='3' width='21.06523967763854' height='20' fill='url(#shade)'/>
+ <rect x='318.02837291454324' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='302.5027864572716' rx='3' width='21.525586457271643' height='20' fill='#bf103c'/>
+ <rect x='302.5027864572716' rx='3' width='21.525586457271643' height='20' fill='url(#shade)'/>
+ <rect x='302.9772' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='286.9772' rx='3' width='22.0' height='20' fill='#bf103c'/>
+ <rect x='286.9772' rx='3' width='22.0' height='20' fill='url(#shade)'/>
+ <rect x='286.9772' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='162.7498' rx='3' width='130.2274' height='20' fill='url(#run-on-failure)'/>
+ <rect x='162.7498' rx='3' width='130.2274' height='20' fill='url(#shade)'/>
+ <rect width='168.7498' height='20' fill='#5a5a5a'/>
+ <rect x='-6.0' rx='3' width='174.7498' height='20' fill='url(#shade)'/>
+ <rect width='2' height='20' fill='url(#left-light)'/>
+ <rect x='614.7390658896196' width='2' height='20' fill='url(#right-shadow)'/>
+ <rect width='616.7390658896196' height='20' fill='url(#light)'/>
+ </g>
+ <g fill='#fff' text-anchor='middle' font-family='Verdana,Geneva,DejaVu Sans,sans-serif' text-rendering='geometricPrecision' font-size='10'>
+ <svg x='8.5' y='3.0' width='16.0' height='16.0' viewBox='0 0 150 150'>
+ <polygon fill='#402a14' fill-opacity='0.5' points='84.84 10 34.1 44.46 34.1 103.78 84.84 68.02 135.57 103.78 135.57 44.46 84.84 10'/>
+ <polygon fill='#402a14' fill-opacity='0.5' points='84.84 68.02 84.84 10 135.57 44.46 135.57 103.78 84.84 68.02'/>
+ <polygon fill='#061a29' fill-opacity='0.5' points='65.07 81.99 14.34 46.22 14.34 105.54 65.07 140 115.81 105.54 115.81 46.22 65.07 81.99'/>
+ <polygon fill='#061a29' fill-opacity='0.5' points='65.07 81.99 65.07 140 14.34 105.54 14.34 46.22 65.07 81.99'/>
+ </svg>
+ <svg x='8.0' y='2.0' width='16.0' height='16.0' viewBox='0 0 150 150'>
+ <linearGradient id='yellow-shaded' x1='91.17' y1='44.83' x2='136.24' y2='73.4' gradientUnits='userSpaceOnUse'>
+ <stop offset='0.01' stop-color='#c6783e'/>
+ <stop offset='0.54' stop-color='#ff9750'/>
+ </linearGradient>
+ <linearGradient id='blue-shaded' x1='60.71' y1='104.56' x2='-15.54' y2='63' gradientUnits='userSpaceOnUse'>
+ <stop offset='0' stop-color='#005a8e'/>
+ <stop offset='0.54' stop-color='#1a7db6'/>
+ </linearGradient>
+ <polygon fill='#ff9d4b' points='84.84 10 34.1 44.46 34.1 103.78 84.84 68.02 135.57 103.78 135.57 44.46 84.84 10'/>
+ <polygon fill='url(#yellow-shaded)' points='84.84 68.02 84.84 10 135.57 44.46 135.57 103.78 84.84 68.02'/>
+ <polygon fill='#1a7db6' points='65.07 81.99 14.34 46.22 14.34 105.54 65.07 140 115.81 105.54 115.81 46.22 65.07 81.99'/>
+ <polygon fill='url(#blue-shaded)' points='65.07 81.99 65.07 140 14.34 105.54 14.34 46.22 65.07 81.99'/>
+ </svg>
+ <text x='96.8749' y='14' fill='#000' fill-opacity='.3' textLength='128.7498'>tenant.application.default</text>
+ <text x='96.3749' y='13' fill='#fff' textLength='128.7498'>tenant.application.default</text>
+ <text x='231.3635' y='14' fill='#000' fill-opacity='.3' textLength='108.22739999999999'>production-us-west-1</text>
+ <text x='230.8635' y='13' fill='#fff' textLength='108.22739999999999'>production-us-west-1</text>
+ </g>
+</svg>
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/deployment/responses/overview.svg b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/deployment/responses/overview.svg
new file mode 100644
index 00000000000..0ddd91d4008
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/deployment/responses/overview.svg
@@ -0,0 +1,125 @@
+<svg xmlns='http://www.w3.org/2000/svg' width='913.3539000000001' height='20' role='img' aria-label='Deployment Status'>
+ <title>Deployment Status</title>
+ <linearGradient id='light' x2='0' y2='100%'>
+ <stop offset='0' stop-color='#fff' stop-opacity='.5'/>
+ <stop offset='.1' stop-color='#fff' stop-opacity='.15'/>
+ <stop offset='.9' stop-color='#000' stop-opacity='.15'/>
+ <stop offset='1' stop-color='#000' stop-opacity='.7'/>
+ </linearGradient>
+ <linearGradient id='left-light' x2='100%' y2='0'>
+ <stop offset='0' stop-color='#fff' stop-opacity='.3'/>
+ <stop offset='.5' stop-color='#fff' stop-opacity='.1'/>
+ <stop offset='1' stop-color='#fff' stop-opacity='.0'/>
+ </linearGradient>
+ <linearGradient id='right-shadow' x2='100%' y2='0'>
+ <stop offset='0' stop-color='#000' stop-opacity='.0'/>
+ <stop offset='.5' stop-color='#000' stop-opacity='.1'/>
+ <stop offset='1' stop-color='#000' stop-opacity='.3'/>
+ </linearGradient>
+ <linearGradient id='shadow' x2='100%' y2='0'>
+ <stop offset='0' stop-color='#222' stop-opacity='.4'/>
+ <stop offset='.5' stop-color='#555' stop-opacity='.4'/>
+ <stop offset='1' stop-color='#555' stop-opacity='.0'/>
+ </linearGradient>
+ <linearGradient id='shade' x2='100%' y2='0'>
+ <stop offset='0' stop-color='#000' stop-opacity='.20'/>
+ <stop offset='0.05' stop-color='#000' stop-opacity='.10'/>
+ <stop offset='1' stop-color='#000' stop-opacity='.0'/>
+ </linearGradient>
+ <linearGradient id='run-on-failure' x1='40%' x2='80%' y2='0%'>
+ <stop offset='0' stop-color='#ab83ff' />
+ <stop offset='1' stop-color='#bf103c' />
+ <animate attributeName='x1' values='-110%;150%;20%;-110%' dur='6s' repeatCount='indefinite' />
+ <animate attributeName='x2' values='-10%;250%;120%;-10%' dur='6s' repeatCount='indefinite' />
+ </linearGradient>
+ <linearGradient id='run-on-success' x1='40%' x2='80%' y2='0%'>
+ <stop offset='0' stop-color='#ab83ff' />
+ <stop offset='1' stop-color='#00ff48' />
+ <animate attributeName='x1' values='-110%;150%;20%;-110%' dur='6s' repeatCount='indefinite' />
+ <animate attributeName='x2' values='-10%;250%;120%;-10%' dur='6s' repeatCount='indefinite' />
+ </linearGradient>
+ <clipPath id='rounded'>
+ <rect width='913.3539000000001' height='20' rx='3' fill='#fff'/>
+ </clipPath>
+ <g clip-path='url(#rounded)'>
+ <rect x='907.3539000000001' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='870.3061' rx='3' width='43.0478' height='20' fill='url(#run-on-success)'/>
+ <polygon points='772.1412 0 772.1412 20 877.8061 20 885.8061 0' fill='#00ff48'/>
+ <rect x='772.1412' rx='3' width='141.2127' height='20' fill='url(#shade)'/>
+ <rect x='772.1412' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='735.0934' rx='3' width='43.0478' height='20' fill='#bf103c'/>
+ <polygon points='611.279 0 611.279 20 742.5934 20 750.5934 0' fill='#00ff48'/>
+ <rect x='611.279' rx='3' width='166.86219999999997' height='20' fill='url(#shade)'/>
+ <rect x='611.279' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='574.2312' rx='3' width='43.0478' height='20' fill='#00ff48'/>
+ <polygon points='449.0446 0 449.0446 20 581.7312 20 589.7312 0' fill='url(#run-on-success)'/>
+ <rect x='449.0446' rx='3' width='168.2344' height='20' fill='url(#shade)'/>
+ <rect x='449.0446' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='411.9968' rx='3' width='43.0478' height='20' fill='#00ff48'/>
+ <polygon points='314.5789 0 314.5789 20 419.4968 20 427.4968 0' fill='url(#run-on-failure)'/>
+ <rect x='314.5789' rx='3' width='140.4657' height='20' fill='url(#shade)'/>
+ <rect x='314.5789' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='228.3006' rx='3' width='92.2783' height='20' fill='url(#run-on-success)'/>
+ <rect x='238.3006' rx='3' width='82.2783' height='20' fill='url(#shade)'/>
+ <rect x='238.3006' rx='3' width='9' height='20' fill='url(#shadow)'/>
+ <rect x='152.7498' rx='3' width='91.55080000000001' height='20' fill='#00ff48'/>
+ <rect x='162.7498' rx='3' width='81.55080000000001' height='20' fill='url(#shade)'/>
+ <rect width='168.7498' height='20' fill='#5a5a5a'/>
+ <rect x='-6.0' rx='3' width='174.7498' height='20' fill='url(#shade)'/>
+ <rect width='2' height='20' fill='url(#left-light)'/>
+ <rect x='911.3539000000001' width='2' height='20' fill='url(#right-shadow)'/>
+ <rect width='913.3539000000001' height='20' fill='url(#light)'/>
+ </g>
+ <g fill='#fff' text-anchor='middle' font-family='Verdana,Geneva,DejaVu Sans,sans-serif' text-rendering='geometricPrecision' font-size='10'>
+ <svg x='8.5' y='3.0' width='16.0' height='16.0' viewBox='0 0 150 150'>
+ <polygon fill='#402a14' fill-opacity='0.5' points='84.84 10 34.1 44.46 34.1 103.78 84.84 68.02 135.57 103.78 135.57 44.46 84.84 10'/>
+ <polygon fill='#402a14' fill-opacity='0.5' points='84.84 68.02 84.84 10 135.57 44.46 135.57 103.78 84.84 68.02'/>
+ <polygon fill='#061a29' fill-opacity='0.5' points='65.07 81.99 14.34 46.22 14.34 105.54 65.07 140 115.81 105.54 115.81 46.22 65.07 81.99'/>
+ <polygon fill='#061a29' fill-opacity='0.5' points='65.07 81.99 65.07 140 14.34 105.54 14.34 46.22 65.07 81.99'/>
+ </svg>
+ <svg x='8.0' y='2.0' width='16.0' height='16.0' viewBox='0 0 150 150'>
+ <linearGradient id='yellow-shaded' x1='91.17' y1='44.83' x2='136.24' y2='73.4' gradientUnits='userSpaceOnUse'>
+ <stop offset='0.01' stop-color='#c6783e'/>
+ <stop offset='0.54' stop-color='#ff9750'/>
+ </linearGradient>
+ <linearGradient id='blue-shaded' x1='60.71' y1='104.56' x2='-15.54' y2='63' gradientUnits='userSpaceOnUse'>
+ <stop offset='0' stop-color='#005a8e'/>
+ <stop offset='0.54' stop-color='#1a7db6'/>
+ </linearGradient>
+ <polygon fill='#ff9d4b' points='84.84 10 34.1 44.46 34.1 103.78 84.84 68.02 135.57 103.78 135.57 44.46 84.84 10'/>
+ <polygon fill='url(#yellow-shaded)' points='84.84 68.02 84.84 10 135.57 44.46 135.57 103.78 84.84 68.02'/>
+ <polygon fill='#1a7db6' points='65.07 81.99 14.34 46.22 14.34 105.54 65.07 140 115.81 105.54 115.81 46.22 65.07 81.99'/>
+ <polygon fill='url(#blue-shaded)' points='65.07 81.99 65.07 140 14.34 105.54 14.34 46.22 65.07 81.99'/>
+ </svg>
+ <text x='96.8749' y='14' fill='#000' fill-opacity='.3' textLength='128.7498'>tenant.application.default</text>
+ <text x='96.3749' y='13' fill='#fff' textLength='128.7498'>tenant.application.default</text>
+ <text x='207.02519999999998' y='14' fill='#000' fill-opacity='.3' textLength='59.5508'>system-test</text>
+ <text x='206.52519999999998' y='13' fill='#fff' textLength='59.5508'>system-test</text>
+ <text x='282.93975' y='14' fill='#000' fill-opacity='.3' textLength='60.2783'>staging-test</text>
+ <text x='282.43975' y='13' fill='#fff' textLength='60.2783'>staging-test</text>
+ <text x='354.21315' y='14' fill='#000' fill-opacity='.3' textLength='50.2685'>us-west-1</text>
+ <text x='353.71315' y='13' fill='#fff' textLength='50.2685'>us-west-1</text>
+ <text x='403.9221' y='14' fill='#000' fill-opacity='.3' textLength='33.1494'>deploy</text>
+ <text x='403.4221' y='13' fill='#fff' textLength='33.1494'>deploy</text>
+ <text x='438.02070000000003' y='14' fill='#000' fill-opacity='.3' textLength='19.047800000000002'>test</text>
+ <text x='437.52070000000003' y='13' fill='#fff' textLength='19.047800000000002'>test</text>
+ <text x='502.5632' y='14' fill='#000' fill-opacity='.3' textLength='78.0372'>aws-us-east-1a</text>
+ <text x='502.0632' y='13' fill='#fff' textLength='78.0372'>aws-us-east-1a</text>
+ <text x='566.1565' y='14' fill='#000' fill-opacity='.3' textLength='33.1494'>deploy</text>
+ <text x='565.6565' y='13' fill='#fff' textLength='33.1494'>deploy</text>
+ <text x='600.2551' y='14' fill='#000' fill-opacity='.3' textLength='19.047800000000002'>test</text>
+ <text x='599.7551' y='13' fill='#fff' textLength='19.047800000000002'>test</text>
+ <text x='664.1115' y='14' fill='#000' fill-opacity='.3' textLength='76.66499999999999'>ap-southeast-1</text>
+ <text x='663.6115' y='13' fill='#fff' textLength='76.66499999999999'>ap-southeast-1</text>
+ <text x='727.0187' y='14' fill='#000' fill-opacity='.3' textLength='33.1494'>deploy</text>
+ <text x='726.5187' y='13' fill='#fff' textLength='33.1494'>deploy</text>
+ <text x='761.1173' y='14' fill='#000' fill-opacity='.3' textLength='19.047800000000002'>test</text>
+ <text x='760.6173' y='13' fill='#fff' textLength='19.047800000000002'>test</text>
+ <text x='812.14895' y='14' fill='#000' fill-opacity='.3' textLength='51.015499999999996'>eu-west-1</text>
+ <text x='811.64895' y='13' fill='#fff' textLength='51.015499999999996'>eu-west-1</text>
+ <text x='862.2314' y='14' fill='#000' fill-opacity='.3' textLength='33.1494'>deploy</text>
+ <text x='861.7314' y='13' fill='#fff' textLength='33.1494'>deploy</text>
+ <text x='896.33' y='14' fill='#000' fill-opacity='.3' textLength='19.047800000000002'>test</text>
+ <text x='895.83' y='13' fill='#fff' textLength='19.047800000000002'>test</text>
+ </g>
+</svg>