FILTER = input -> {
+ if (input.getCategory() instanceof AppearanceCategory) {
+ // Special case because ConsoleUrlProviderGlobalConfiguration is (currently) the only type in core that uses
+ // AppearanceCategory, and it hides its configuration if there are no custom providers, so we want to
+ // hide the whole "Appearance" link in that case.
+ if (input instanceof ConsoleUrlProviderGlobalConfiguration) {
+ return ((ConsoleUrlProviderGlobalConfiguration) input).isEnabled();
+ }
+ return true;
+ }
+ return false;
+ };
@Override
public String getIconFileName() {
diff --git a/core/src/main/java/jenkins/console/ConsoleUrlProvider.java b/core/src/main/java/jenkins/console/ConsoleUrlProvider.java
new file mode 100644
index 000000000000..ba4e5a000cbc
--- /dev/null
+++ b/core/src/main/java/jenkins/console/ConsoleUrlProvider.java
@@ -0,0 +1,136 @@
+/*
+ * The MIT License
+ *
+ * Copyright (c) 2023, CloudBees, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package jenkins.console;
+
+import edu.umd.cs.findbugs.annotations.CheckForNull;
+import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.Functions;
+import hudson.model.Describable;
+import hudson.model.Descriptor;
+import hudson.model.Run;
+import hudson.model.User;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import jenkins.model.Jenkins;
+import org.kohsuke.accmod.Restricted;
+import org.kohsuke.accmod.restrictions.NoExternalUse;
+import org.kohsuke.stapler.Stapler;
+
+/**
+ * Extension point that allows implementations to redirect build console links to a specified URL.
+ * In order to produce links to console URLs in Jelly templates, use {@link Functions#getConsoleUrl}.
+ *
Note: If you implement this API, consider providing a link to the classic console from within your console
+ * visualization as a fallback, particularly if your visualization is not as general as the classic console, has
+ * limitations that might be relevant in some cases, or requires advanced data that may be not exist for
+ * failed or corrupted builds. For example, if you visualize Pipeline build logs using only {@code LogStorage.stepLog},
+ * there will be log lines that will never show up in your visualization, or if your visualization traverses the
+ * Pipeline flow graph, there may be various edge cases where your visualization does not work at all, but the classic
+ * console view is unaffected.
+ * @see Functions#getConsoleUrl
+ * @since TODO
+ */
+public interface ConsoleUrlProvider extends Describable {
+ @Restricted(NoExternalUse.class)
+ Logger LOGGER = Logger.getLogger(ConsoleUrlProvider.class.getName());
+
+ /**
+ * Get a URL relative to the context path of Jenkins which should be used to link to the console for the specified build.
+ * Should only be used in the context of serving an HTTP request.
+ * @param run the build
+ * @return the URL for the console for the specified build, relative to the context of Jenkins, or {@code null}
+ * if this implementation does not want to server a special console view for this build.
+ */
+ @CheckForNull String getConsoleUrl(Run, ?> run);
+
+ @Override
+ default Descriptor getDescriptor() {
+ return Jenkins.get().getDescriptorOrDie(getClass());
+ }
+
+ /**
+ * Get a URL relative to the web server root which should be used to link to the console for the specified build.
+ * Should only be used in the context of serving an HTTP request.
+ *
Use {@link Functions#getConsoleUrl} to obtain this link in a Jelly template.
+ * @param run the build
+ * @return the URL for the console for the specified build, relative to the web server root
+ */
+ static @NonNull String getRedirectUrl(Run, ?> run) {
+ final List providers = new ArrayList<>();
+ User currentUser = User.current();
+ if (currentUser != null) {
+ ConsoleUrlProviderUserProperty userProperty = currentUser.getProperty(ConsoleUrlProviderUserProperty.class);
+ if (userProperty != null) {
+ List userProviders = userProperty.getProviders();
+ if (userProviders != null) {
+ providers.addAll(userProviders);
+ }
+ }
+ }
+ // Global providers are always considered in case the user-configured providers are non-exhaustive.
+ ConsoleUrlProviderGlobalConfiguration globalConfig = ConsoleUrlProviderGlobalConfiguration.get();
+ List globalProviders = globalConfig.getProviders();
+ if (globalProviders != null) {
+ providers.addAll(globalProviders);
+ }
+ String url = null;
+ for (ConsoleUrlProvider provider : providers) {
+ try {
+ String tempUrl = provider.getConsoleUrl(run);
+ if (tempUrl != null) {
+ if (new URI(tempUrl).isAbsolute()) {
+ LOGGER.warning(() -> "Ignoring absolute console URL " + tempUrl + " for " + run + " from " + provider.getClass());
+ } else {
+ // Found a valid non-null URL.
+ url = tempUrl;
+ break;
+ }
+ }
+ } catch (Exception e) { // Intentionally broad catch clause to guard against broken implementations.
+ LOGGER.log(Level.WARNING, e, () -> "Error looking up console URL for " + run + " from " + provider.getClass());
+ }
+ }
+ if (url == null) {
+ // Reachable if DefaultConsoleUrlProvider is not one of the configured providers, including if no providers are configured at all.
+ url = run.getUrl() + "console";
+ }
+ if (url.startsWith("/")) {
+ return Stapler.getCurrentRequest().getContextPath() + url;
+ } else {
+ return Stapler.getCurrentRequest().getContextPath() + '/' + url;
+ }
+ }
+
+ /**
+ * Check whether there are at least two {@link ConsoleUrlProvider} implementations available.
+ * @return {@code true} if there are at least two {@link ConsoleUrlProvider} implementations available, {@code false} otherwise.
+ */
+ static boolean isEnabled() {
+ // No point showing related configuration pages if the only option is ConsoleUrlProvider.Default.
+ return Jenkins.get().getDescriptorList(ConsoleUrlProvider.class).size() > 1;
+ }
+}
diff --git a/core/src/main/java/jenkins/console/ConsoleUrlProviderGlobalConfiguration.java b/core/src/main/java/jenkins/console/ConsoleUrlProviderGlobalConfiguration.java
new file mode 100644
index 000000000000..260321b009eb
--- /dev/null
+++ b/core/src/main/java/jenkins/console/ConsoleUrlProviderGlobalConfiguration.java
@@ -0,0 +1,110 @@
+/*
+ * The MIT License
+ *
+ * Copyright 2023 CloudBees, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package jenkins.console;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.BulkChange;
+import hudson.Extension;
+import hudson.ExtensionList;
+import hudson.model.Descriptor;
+import java.io.IOException;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+import jenkins.appearance.AppearanceCategory;
+import jenkins.model.GlobalConfiguration;
+import jenkins.model.GlobalConfigurationCategory;
+import jenkins.model.Jenkins;
+import net.sf.json.JSONObject;
+import org.jenkinsci.Symbol;
+import org.kohsuke.accmod.Restricted;
+import org.kohsuke.accmod.restrictions.NoExternalUse;
+import org.kohsuke.stapler.DataBoundSetter;
+import org.kohsuke.stapler.StaplerRequest;
+
+/**
+ * Allows administrators to activate and sort {@link ConsoleUrlProvider} extensions to set defaults for all users.
+ * @see ConsoleUrlProviderUserProperty
+ * @since TODO
+ */
+@Extension
+@Symbol("consoleUrlProvider")
+@Restricted(NoExternalUse.class)
+public class ConsoleUrlProviderGlobalConfiguration extends GlobalConfiguration {
+ private static final Logger LOGGER = Logger.getLogger(ConsoleUrlProviderGlobalConfiguration.class.getName());
+
+ private List providers;
+
+ public ConsoleUrlProviderGlobalConfiguration() {
+ load();
+ }
+
+ @NonNull
+ @Override
+ public GlobalConfigurationCategory getCategory() {
+ return GlobalConfigurationCategory.get(AppearanceCategory.class);
+ }
+
+ public List getProviders() {
+ return providers;
+ }
+
+ @DataBoundSetter
+ public void setProviders(List providers) {
+ this.providers = providers;
+ save();
+ }
+
+ @Override
+ public boolean configure(StaplerRequest req, JSONObject json) throws FormException {
+ // We have to null out providers before data binding to allow all providers to be deleted in the config UI.
+ // We use a BulkChange to avoid double saves in other cases.
+ try (BulkChange bc = new BulkChange(this)) {
+ providers = null;
+ req.bindJSON(this, json);
+ bc.commit();
+ } catch (IOException e) {
+ LOGGER.log(Level.WARNING, "Failed to save " + getConfigFile(), e);
+ }
+ return true;
+ }
+
+ public boolean isEnabled() {
+ return ConsoleUrlProvider.isEnabled();
+ }
+
+ public static ConsoleUrlProviderGlobalConfiguration get() {
+ return ExtensionList.lookupSingleton(ConsoleUrlProviderGlobalConfiguration.class);
+ }
+
+ public List extends Descriptor> getProvidersDescriptors() {
+ // For the global configuration, the default provider will always be consulted as a last resort, and since it
+ // handles all builds, there is no reason to ever select it explicitly.
+ return Jenkins.get().getDescriptorList(ConsoleUrlProvider.class).stream()
+ .filter(d -> !(d instanceof DefaultConsoleUrlProvider.DescriptorImpl))
+ .collect(Collectors.toList());
+ }
+}
diff --git a/core/src/main/java/jenkins/console/ConsoleUrlProviderUserProperty.java b/core/src/main/java/jenkins/console/ConsoleUrlProviderUserProperty.java
new file mode 100644
index 000000000000..d741a0f16fd4
--- /dev/null
+++ b/core/src/main/java/jenkins/console/ConsoleUrlProviderUserProperty.java
@@ -0,0 +1,78 @@
+/*
+ * The MIT License
+ *
+ * Copyright 2023 CloudBees, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package jenkins.console;
+
+import edu.umd.cs.findbugs.annotations.CheckForNull;
+import hudson.Extension;
+import hudson.model.User;
+import hudson.model.UserProperty;
+import hudson.model.UserPropertyDescriptor;
+import java.util.List;
+import org.jenkinsci.Symbol;
+import org.kohsuke.accmod.Restricted;
+import org.kohsuke.accmod.restrictions.NoExternalUse;
+import org.kohsuke.stapler.DataBoundConstructor;
+import org.kohsuke.stapler.DataBoundSetter;
+
+/**
+ * Allows users to activate and sort {@link ConsoleUrlProvider} extensions based on their preferences.
+ * @see ConsoleUrlProviderGlobalConfiguration
+ * @since TODO
+ */
+@Restricted(NoExternalUse.class)
+public class ConsoleUrlProviderUserProperty extends UserProperty {
+ private List providers;
+
+ @DataBoundConstructor
+ public ConsoleUrlProviderUserProperty() { }
+
+ public @CheckForNull List getProviders() {
+ return providers;
+ }
+
+ @DataBoundSetter
+ public void setProviders(List providers) {
+ this.providers = providers;
+ }
+
+ @Extension
+ @Symbol("consoleUrlProvider")
+ public static class DescriptorImpl extends UserPropertyDescriptor {
+ @Override
+ public String getDisplayName() {
+ return Messages.consoleUrlProviderDisplayName();
+ }
+
+ @Override
+ public UserProperty newInstance(User user) {
+ return new ConsoleUrlProviderUserProperty();
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return ConsoleUrlProvider.isEnabled();
+ }
+ }
+}
diff --git a/core/src/main/java/jenkins/console/DefaultConsoleUrlProvider.java b/core/src/main/java/jenkins/console/DefaultConsoleUrlProvider.java
new file mode 100644
index 000000000000..34722301529d
--- /dev/null
+++ b/core/src/main/java/jenkins/console/DefaultConsoleUrlProvider.java
@@ -0,0 +1,62 @@
+/*
+ * The MIT License
+ *
+ * Copyright 2023 CloudBees, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package jenkins.console;
+
+import hudson.Extension;
+import hudson.model.Descriptor;
+import hudson.model.Run;
+import org.jenkinsci.Symbol;
+import org.kohsuke.accmod.Restricted;
+import org.kohsuke.accmod.restrictions.NoExternalUse;
+import org.kohsuke.stapler.DataBoundConstructor;
+
+/**
+ * Default implementation of {@link ConsoleUrlProvider} that uses the standard Jenkins console view.
+ * Exists so that users have a way to override {@link ConsoleUrlProviderGlobalConfiguration} and specify the default
+ * console view if desired via {@link ConsoleUrlProviderUserProperty}.
+ * @since TODO
+ */
+@Restricted(value = NoExternalUse.class)
+public class DefaultConsoleUrlProvider implements ConsoleUrlProvider {
+
+ @DataBoundConstructor
+ public DefaultConsoleUrlProvider() {
+ }
+
+ @Override
+ public String getConsoleUrl(Run, ?> run) {
+ return run.getUrl() + "console";
+ }
+
+ @Extension
+ @Symbol(value = "default")
+ public static class DescriptorImpl extends Descriptor {
+
+ @Override
+ public String getDisplayName() {
+ return Messages.defaultProviderDisplayName();
+ }
+ }
+}
diff --git a/core/src/main/java/jenkins/widgets/BuildListTable.java b/core/src/main/java/jenkins/widgets/BuildListTable.java
index 19abff8b1d1f..495d7e50be6d 100644
--- a/core/src/main/java/jenkins/widgets/BuildListTable.java
+++ b/core/src/main/java/jenkins/widgets/BuildListTable.java
@@ -29,6 +29,7 @@
import hudson.model.BallColor;
import hudson.model.Run;
import java.util.Date;
+import jenkins.console.ConsoleUrlProvider;
import net.sf.json.JSONObject;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.DoNotUse;
@@ -41,6 +42,7 @@ public class BuildListTable extends RunListProgressiveRendering {
element.put("iconColorOrdinal", iconColor.ordinal());
element.put("iconColorDescription", iconColor.getDescription());
element.put("url", build.getUrl());
+ element.put("consoleUrl", ConsoleUrlProvider.getRedirectUrl(build));
element.put("iconName", build.getIconColor().getIconName());
element.put("parentUrl", build.getParent().getUrl());
element.put("parentFullDisplayName", Functions.breakableString(Functions.escape(build.getParent().getFullDisplayName())));
diff --git a/core/src/main/java/jenkins/widgets/BuildTimeTrend.java b/core/src/main/java/jenkins/widgets/BuildTimeTrend.java
index 2ddc25607de5..59ab879f05da 100644
--- a/core/src/main/java/jenkins/widgets/BuildTimeTrend.java
+++ b/core/src/main/java/jenkins/widgets/BuildTimeTrend.java
@@ -28,6 +28,7 @@
import hudson.model.BallColor;
import hudson.model.Node;
import hudson.model.Run;
+import jenkins.console.ConsoleUrlProvider;
import jenkins.model.Jenkins;
import net.sf.json.JSONObject;
import org.kohsuke.accmod.Restricted;
@@ -45,6 +46,7 @@ public class BuildTimeTrend extends RunListProgressiveRendering {
element.put("displayName", build.getDisplayName());
element.put("duration", build.getDuration());
element.put("durationString", build.getDurationString());
+ element.put("consoleUrl", ConsoleUrlProvider.getRedirectUrl(build));
if (build instanceof AbstractBuild) {
AbstractBuild, ?> b = (AbstractBuild) build;
Node n = b.getBuiltOn();
diff --git a/core/src/main/resources/hudson/model/AbstractBuild/changes.jelly b/core/src/main/resources/hudson/model/AbstractBuild/changes.jelly
index 13d351794f7c..c19409e87dc0 100644
--- a/core/src/main/resources/hudson/model/AbstractBuild/changes.jelly
+++ b/core/src/main/resources/hudson/model/AbstractBuild/changes.jelly
@@ -40,7 +40,7 @@ THE SOFTWARE.
${%Not yet determined}
- ${%Failed to determine} (${%log})
+ ${%Failed to determine} (${%log})
diff --git a/core/src/main/resources/hudson/model/AbstractBuild/index.jelly b/core/src/main/resources/hudson/model/AbstractBuild/index.jelly
index 2d35a86710ed..8444984d49cd 100644
--- a/core/src/main/resources/hudson/model/AbstractBuild/index.jelly
+++ b/core/src/main/resources/hudson/model/AbstractBuild/index.jelly
@@ -98,7 +98,7 @@ THE SOFTWARE.
${%Not yet determined}
- ${%Failed to determine} (${%log})
+ ${%Failed to determine} (${%log})
diff --git a/core/src/main/resources/hudson/model/Job/buildTimeTrend_resources.js b/core/src/main/resources/hudson/model/Job/buildTimeTrend_resources.js
index 7fcfa705ff73..105cdbc06ed2 100644
--- a/core/src/main/resources/hudson/model/Job/buildTimeTrend_resources.js
+++ b/core/src/main/resources/hudson/model/Job/buildTimeTrend_resources.js
@@ -16,7 +16,7 @@ window.buildTimeTrend_displayBuilds = function (data) {
let link = document.createElement("a");
link.classList.add("build-status-link");
- link.href = e.number + "/console";
+ link.href = e.consoleUrl;
td.appendChild(link);
let svg = generateSVGIcon(e.iconName);
link.appendChild(svg);
@@ -209,7 +209,7 @@ window.displayBuilds = function (data) {
div2.classList.add("jenkins-table__cell__button-wrapper");
var a3 = document.createElement("a");
a3.classList.add("jenkins-table__button");
- a3.href = rootUrl + "/" + e.url + "console";
+ a3.href = e.consoleUrl;
a3.innerHTML = p.dataset.consoleOutputIcon;
div2.appendChild(a3);
td5.appendChild(div2);
diff --git a/core/src/main/resources/hudson/widgets/HistoryWidget/entry.jelly b/core/src/main/resources/hudson/widgets/HistoryWidget/entry.jelly
index 5d92cd6b4c93..366071ebf7fa 100644
--- a/core/src/main/resources/hudson/widgets/HistoryWidget/entry.jelly
+++ b/core/src/main/resources/hudson/widgets/HistoryWidget/entry.jelly
@@ -38,7 +38,7 @@ THE SOFTWARE.
diff --git a/core/src/main/resources/jenkins/console/ConsoleUrlProviderGlobalConfiguration/config.jelly b/core/src/main/resources/jenkins/console/ConsoleUrlProviderGlobalConfiguration/config.jelly
new file mode 100644
index 000000000000..59f563d988b2
--- /dev/null
+++ b/core/src/main/resources/jenkins/console/ConsoleUrlProviderGlobalConfiguration/config.jelly
@@ -0,0 +1,13 @@
+
+
+
+
+
+ ${%description}
+
+
+
+
+
+
+
diff --git a/core/src/main/resources/jenkins/console/ConsoleUrlProviderGlobalConfiguration/config.properties b/core/src/main/resources/jenkins/console/ConsoleUrlProviderGlobalConfiguration/config.properties
new file mode 100644
index 000000000000..b4c5a51ccf95
--- /dev/null
+++ b/core/src/main/resources/jenkins/console/ConsoleUrlProviderGlobalConfiguration/config.properties
@@ -0,0 +1 @@
+description=Controls what visualization tool handles build console links. Providers higher in the list take precedence over providers lower in the list. If no explicitly specified provider can render a console link, the default provider will be used. Individual users can override this configuration according to their preferences.
\ No newline at end of file
diff --git a/core/src/main/resources/jenkins/console/ConsoleUrlProviderUserProperty/config.jelly b/core/src/main/resources/jenkins/console/ConsoleUrlProviderUserProperty/config.jelly
new file mode 100644
index 000000000000..57f5af6d2097
--- /dev/null
+++ b/core/src/main/resources/jenkins/console/ConsoleUrlProviderUserProperty/config.jelly
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+ ${%description}
+
+
+
+
+
+
diff --git a/core/src/main/resources/jenkins/console/ConsoleUrlProviderUserProperty/config.properties b/core/src/main/resources/jenkins/console/ConsoleUrlProviderUserProperty/config.properties
new file mode 100644
index 000000000000..9ebb3cd824ca
--- /dev/null
+++ b/core/src/main/resources/jenkins/console/ConsoleUrlProviderUserProperty/config.properties
@@ -0,0 +1 @@
+description=Controls what visualization tool handles build console links. Providers higher in the list take precedence over providers lower in the list. All providers configured here take precedence over those configured globally by administrators.
\ No newline at end of file
diff --git a/core/src/main/resources/jenkins/console/Messages.properties b/core/src/main/resources/jenkins/console/Messages.properties
new file mode 100644
index 000000000000..20a460d49709
--- /dev/null
+++ b/core/src/main/resources/jenkins/console/Messages.properties
@@ -0,0 +1,2 @@
+consoleUrlProviderDisplayName=Console URL Provider
+defaultProviderDisplayName=Default
\ No newline at end of file
diff --git a/core/src/main/resources/lib/hudson/buildProgressBar.jelly b/core/src/main/resources/lib/hudson/buildProgressBar.jelly
index 18f7151c4488..278d6de71b7b 100644
--- a/core/src/main/resources/lib/hudson/buildProgressBar.jelly
+++ b/core/src/main/resources/lib/hudson/buildProgressBar.jelly
@@ -36,5 +36,5 @@ THE SOFTWARE.
+ pos="${executor.progress}" href="${h.getConsoleUrl(build) ?: (rootURL + '/' + build.url + 'console')}"/>
diff --git a/core/src/main/resources/lib/hudson/project/console-link.jelly b/core/src/main/resources/lib/hudson/project/console-link.jelly
index 97ddb9d9aaee..3a0f9cd5f727 100644
--- a/core/src/main/resources/lib/hudson/project/console-link.jelly
+++ b/core/src/main/resources/lib/hudson/project/console-link.jelly
@@ -28,11 +28,11 @@ THE SOFTWARE.
-
+
-
+
diff --git a/test/src/test/java/jenkins/console/ConsoleUrlProviderTest.java b/test/src/test/java/jenkins/console/ConsoleUrlProviderTest.java
new file mode 100644
index 000000000000..b9881fbeddac
--- /dev/null
+++ b/test/src/test/java/jenkins/console/ConsoleUrlProviderTest.java
@@ -0,0 +1,177 @@
+/*
+ * The MIT License
+ *
+ * Copyright 2023 CloudBees, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package jenkins.console;
+
+import static org.junit.Assert.assertEquals;
+
+import hudson.model.Descriptor;
+import hudson.model.FreeStyleBuild;
+import hudson.model.FreeStyleProject;
+import hudson.model.Run;
+import hudson.model.User;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.jvnet.hudson.test.BuildWatcher;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.TestExtension;
+
+public class ConsoleUrlProviderTest {
+ @ClassRule
+ public static BuildWatcher watcher = new BuildWatcher();
+
+ @Rule
+ public JenkinsRule r = new JenkinsRule();
+
+ @Test
+ public void getRedirectUrl() throws Exception {
+ ConsoleUrlProviderGlobalConfiguration.get().setProviders(list(new CustomConsoleUrlProvider()));
+ FreeStyleProject p = r.createProject(FreeStyleProject.class);
+ // Default URL
+ FreeStyleBuild b = r.buildAndAssertSuccess(p);
+ assertCustomConsoleUrl(r.contextPath + '/' + b.getUrl() + "console", b);
+ // Custom URL without leading slash
+ b.setDescription("custom my/build/console");
+ assertCustomConsoleUrl(r.contextPath + "/my/build/console", b);
+ // Custom URL with leading slash
+ b.setDescription("custom /my/build/console");
+ assertCustomConsoleUrl(r.contextPath + "/my/build/console", b);
+ // Default URL is used when extensions throw exceptions.
+ b.setDescription("NullPointerException");
+ assertCustomConsoleUrl(r.contextPath + '/' + b.getUrl() + "console", b);
+ // Check precedence and fallthrough behavior with ConsoleUrlProviderGlobalConfiguration.providers.
+ ConsoleUrlProviderGlobalConfiguration.get().setProviders(
+ list(new IgnoreAllRunsConsoleUrlProvider(), new CustomConsoleUrlProvider("-a"), new CustomConsoleUrlProvider("-b")));
+ b.setDescription("custom my/build/console");
+ assertCustomConsoleUrl(r.contextPath + "/my/build/console-a", b);
+ }
+
+ @Test
+ public void getUserSpecificRedirectUrl() throws Exception {
+ r.jenkins.setSecurityRealm(r.createDummySecurityRealm());
+ User admin = User.getById("admin", true);
+ // Admin choses custom, user overrides to default
+ ConsoleUrlProviderGlobalConfiguration.get().setProviders(list(new CustomConsoleUrlProvider()));
+ admin.getProperty(ConsoleUrlProviderUserProperty.class).setProviders(list(new DefaultConsoleUrlProvider()));
+ FreeStyleProject p = r.createProject(FreeStyleProject.class);
+ FreeStyleBuild b = r.buildAndAssertSuccess(p);
+ b.setDescription("custom my/build/console");
+ assertCustomConsoleUrl(r.contextPath + "/my/build/console", b);
+ assertCustomConsoleUrl(r.contextPath + "/" + b.getUrl() + "console", admin, b);
+ // Admin does not configure anything, user chooses custom
+ ConsoleUrlProviderGlobalConfiguration.get().setProviders(null);
+ admin.getProperty(ConsoleUrlProviderUserProperty.class).setProviders(list(new CustomConsoleUrlProvider()));
+ assertCustomConsoleUrl(r.contextPath + "/" + b.getUrl() + "console", b);
+ assertCustomConsoleUrl(r.contextPath + "/my/build/console", admin, b);
+ // Check precedence and fallthrough behavior with ConsoleUrlProviderUserProperty.providers.
+ admin.getProperty(ConsoleUrlProviderUserProperty.class).setProviders(
+ list(new IgnoreAllRunsConsoleUrlProvider(), new CustomConsoleUrlProvider("-a"), new CustomConsoleUrlProvider("-b")));
+ assertCustomConsoleUrl(r.contextPath + "/my/build/console-a", admin, b);
+ }
+
+ @Test
+ public void useGlobalProvidersIfUserProvidersDontReturnValidUrl() throws Exception {
+ r.jenkins.setSecurityRealm(r.createDummySecurityRealm());
+ User admin = User.getById("admin", true);
+ // Admin choses custom, user chooses a provider that ignores everything, so global choice still gets used.
+ ConsoleUrlProviderGlobalConfiguration.get().setProviders(list(new CustomConsoleUrlProvider()));
+ admin.getProperty(ConsoleUrlProviderUserProperty.class).setProviders(list(new IgnoreAllRunsConsoleUrlProvider()));
+ FreeStyleProject p = r.createProject(FreeStyleProject.class);
+ FreeStyleBuild b = r.buildAndAssertSuccess(p);
+ b.setDescription("custom my/build/console");
+ assertCustomConsoleUrl(r.contextPath + "/my/build/console", admin, b);
+ }
+
+ @Test
+ public void invalidRedirectUrls() throws Exception {
+ ConsoleUrlProviderGlobalConfiguration.get().setProviders(list(new CustomConsoleUrlProvider()));
+ FreeStyleProject p = r.createProject(FreeStyleProject.class);
+ FreeStyleBuild b = r.buildAndAssertSuccess(p);
+ b.setDescription("custom https://example.com");
+ assertCustomConsoleUrl(r.contextPath + "/" + b.getUrl() + "console", b);
+ b.setDescription("custom invalid url");
+ assertCustomConsoleUrl(r.contextPath + "/" + b.getUrl() + "console", b);
+ }
+
+ public void assertCustomConsoleUrl(String expectedUrl, Run, ?> run) throws Exception {
+ assertCustomConsoleUrl(expectedUrl, null, run);
+ }
+
+ public void assertCustomConsoleUrl(String expectedUrl, User user, Run, ?> run) throws Exception {
+ JenkinsRule.WebClient wc = r.createWebClient();
+ if (user != null) {
+ wc.login(user.getId(), user.getId());
+ }
+ String actualUrl = wc.executeOnServer(() -> ConsoleUrlProvider.getRedirectUrl(run));
+ assertEquals(expectedUrl, actualUrl);
+ }
+
+ // Like List.of, but avoids JEP-200 class filter warnings.
+ private static List list(T... items) {
+ return new ArrayList<>(Arrays.asList(items));
+ }
+
+ public static class CustomConsoleUrlProvider implements ConsoleUrlProvider {
+ private final String suffix;
+
+ public CustomConsoleUrlProvider() {
+ this.suffix = "";
+ }
+
+ public CustomConsoleUrlProvider(String suffix) {
+ this.suffix = suffix;
+ }
+
+ @Override
+ public String getConsoleUrl(Run, ?> run) {
+ String description = run.getDescription();
+ if (description == null) {
+ return null;
+ } else if (description.startsWith("custom ")) {
+ return description.substring("custom ".length()) + suffix;
+ } else {
+ throw new NullPointerException("getConsoleUrl should be robust against runtime errors");
+ }
+ }
+
+ @TestExtension
+ public static class DescriptorImpl extends Descriptor { }
+ }
+
+ public static class IgnoreAllRunsConsoleUrlProvider implements ConsoleUrlProvider {
+
+ @Override
+ public String getConsoleUrl(Run, ?> run) {
+ return null;
+ }
+
+ @TestExtension
+ public static class DescriptorImpl extends Descriptor { }
+ }
+
+}
|