From 15b82fb1eb3bee657a39ad8ca92b9a84dd70a36b Mon Sep 17 00:00:00 2001 From: Ralf Ueberfuhr Date: Mon, 16 Sep 2024 11:55:03 +0200 Subject: [PATCH] Improve DevUI Configuration Editor. - allow editing of properties only for those from `application.properties` - improve UI with more awareness of the config source - add profile-awareness - improve code (simplification and code refactorings) - re-write algorithm to merge existing `application.properties` with single properties update - read `application.properties` the same way like Smallrye Config does (same behavior in case of non-UTF-8 `application.properties`) - add tests for RPC service #resolves 43229 --- .../menu/ConfigurationProcessor.java | 162 ++--- .../devmode/DevModeFailedStartHandler.java | 3 +- .../ConfigurationCompleteUpdatesRPCTest.java | 70 ++ ...igurationSinglePropertyUpdatesRPCTest.java | 143 +++++ .../io/quarkus/devui/ConfigurationTest.java | 40 -- .../conf/devui-configuration-test.properties | 6 + .../dev-ui/qwc/qwc-configuration-editor.js | 4 +- .../resources/dev-ui/qwc/qwc-configuration.js | 606 +++++++++++++----- .../config/ApplicationPropertiesService.java | 180 ++++++ .../runtime/config/ConfigDevUIRecorder.java | 9 + .../runtime/config/ConfigJsonRPCService.java | 112 +++- 11 files changed, 971 insertions(+), 364 deletions(-) create mode 100644 extensions/vertx-http/deployment/src/test/java/io/quarkus/devui/ConfigurationCompleteUpdatesRPCTest.java create mode 100644 extensions/vertx-http/deployment/src/test/java/io/quarkus/devui/ConfigurationSinglePropertyUpdatesRPCTest.java delete mode 100644 extensions/vertx-http/deployment/src/test/java/io/quarkus/devui/ConfigurationTest.java create mode 100644 extensions/vertx-http/deployment/src/test/resources/conf/devui-configuration-test.properties create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/devui/runtime/config/ApplicationPropertiesService.java diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/menu/ConfigurationProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/menu/ConfigurationProcessor.java index 6bd8f8d50701a..cf552b3e1690d 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/menu/ConfigurationProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/menu/ConfigurationProcessor.java @@ -1,10 +1,6 @@ package io.quarkus.devui.deployment.menu; -import java.io.BufferedWriter; import java.io.IOException; -import java.io.StringReader; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; @@ -29,8 +25,8 @@ import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem; import io.quarkus.deployment.builditem.DevServicesLauncherConfigResultBuildItem; import io.quarkus.dev.config.CurrentConfig; -import io.quarkus.dev.console.DevConsoleManager; import io.quarkus.devui.deployment.InternalPageBuildItem; +import io.quarkus.devui.runtime.config.ApplicationPropertiesService; import io.quarkus.devui.runtime.config.ConfigDescription; import io.quarkus.devui.runtime.config.ConfigDescriptionBean; import io.quarkus.devui.runtime.config.ConfigDevUIRecorder; @@ -42,6 +38,7 @@ /** * This creates Extensions Page */ +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") public class ConfigurationProcessor { @BuildStep(onlyIf = IsDevelopment.class) @@ -80,16 +77,19 @@ void registerConfigs(List configDescriptionBuildItem new ConfigDescription(item.getPropertyName(), formatJavadoc(cleanUpAsciiDocIfNecessary(item.getDocs())), item.getDefaultValue(), - isSetByDevServices(devServicesLauncherConfig, item.getPropertyName()), + devServicesLauncherConfig + .map(DevServicesLauncherConfigResultBuildItem::getConfig) + .map(config -> config.containsKey(item.getPropertyName())) + .orElse(false), item.getValueTypeName(), item.getAllowedValues(), item.getConfigPhase().name())); } Set devServicesConfig = new HashSet<>(); - if (devServicesLauncherConfig.isPresent()) { - devServicesConfig.addAll(devServicesLauncherConfig.get().getConfig().keySet()); - } + devServicesLauncherConfig.ifPresent( + devServicesLauncherConfigResultBuildItem -> devServicesConfig + .addAll(devServicesLauncherConfigResultBuildItem.getConfig().keySet())); recorder.registerConfigs(configDescriptions, devServicesConfig); } @@ -105,21 +105,27 @@ void registerJsonRpcService( BuildTimeActionBuildItem configActions = new BuildTimeActionBuildItem(NAMESPACE); - configActions.addAction("updateProperty", map -> { - Map values = Collections.singletonMap(map.get("name"), map.get("value")); - updateConfig(values); + configActions.addAction("updateProperty", payload -> { + final var updates = new Properties(); + updates.setProperty( + payload.get("name"), + Optional + .ofNullable(payload.get("value")) + .orElse("")); + try { + new ApplicationPropertiesService() + .mergeApplicationProperties(updates); + } catch (IOException e) { + return false; + } return true; }); - configActions.addAction("updateProperties", map -> { - String type = map.get("type"); - - if (type.equalsIgnoreCase("properties")) { - String content = map.get("content"); - - Properties p = new Properties(); - try (StringReader sr = new StringReader(content)) { - p.load(sr); // Validate - setConfig(content); + configActions.addAction("updatePropertiesAsString", payload -> { + if ("properties".equalsIgnoreCase(payload.get("type"))) { + final var content = payload.get("content"); + try { + new ApplicationPropertiesService() + .saveApplicationProperties(content); return true; } catch (IOException ex) { return false; @@ -135,19 +141,37 @@ void registerJsonRpcService( .scope(Singleton.class) .setRuntimeInit() .done()); + syntheticBeanProducer.produce( + SyntheticBeanBuildItem.configure(ApplicationPropertiesService.class).unremovable() + .supplier(recorder.applicationPropertiesService()) + .scope(Singleton.class) + .setRuntimeInit() + .done()); - CurrentConfig.EDITOR = ConfigurationProcessor::updateConfig; - shutdown.addCloseTask(new Runnable() { - @Override - public void run() { - CurrentConfig.EDITOR = null; - CurrentConfig.CURRENT = Collections.emptyList(); - } + ConfigurationProcessor.setDefaultConfigEditor(); + shutdown.addCloseTask(() -> { + CurrentConfig.EDITOR = null; + CurrentConfig.CURRENT = Collections.emptyList(); }, true); jsonRPCProvidersProducer.produce(new JsonRPCProvidersBuildItem(NAMESPACE, ConfigJsonRPCService.class)); } + public static void setDefaultConfigEditor() { + CurrentConfig.EDITOR = ConfigurationProcessor::mergeApplicationProperties; + } + + private static void mergeApplicationProperties(Map updatesMap) { + final var updates = new Properties(); + updates.putAll(updatesMap); + try { + new ApplicationPropertiesService() + .mergeApplicationProperties(updates); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + private static final Pattern codePattern = Pattern.compile("(\\{@code )([^}]+)(\\})"); private static final Pattern linkPattern = Pattern.compile("(\\{@link )([^}]+)(\\})"); @@ -178,85 +202,5 @@ private static String cleanUpAsciiDocIfNecessary(String docs) { .replace("\n", "
"); } - private static boolean isSetByDevServices(Optional devServicesLauncherConfig, - String propertyName) { - if (devServicesLauncherConfig.isPresent()) { - return devServicesLauncherConfig.get().getConfig().containsKey(propertyName); - } - return false; - } - - public static void updateConfig(Map values) { - if (values != null && !values.isEmpty()) { - try { - Path configPath = getConfigPath(); - List lines = Files.readAllLines(configPath); - for (Map.Entry entry : values.entrySet()) { - String name = entry.getKey(); - String value = entry.getValue(); - int nameLine = -1; - for (int i = 0, linesSize = lines.size(); i < linesSize; i++) { - String line = lines.get(i); - if (line.startsWith(name + "=")) { - nameLine = i; - break; - } - } - if (nameLine != -1) { - if (value.isEmpty()) { - lines.remove(nameLine); - } else { - lines.set(nameLine, name + "=" + value); - } - } else { - if (!value.isEmpty()) { - lines.add(name + "=" + value); - } - } - } - - try (BufferedWriter writer = Files.newBufferedWriter(configPath)) { - for (String i : lines) { - writer.write(i); - writer.newLine(); - } - } - } catch (Throwable t) { - throw new RuntimeException(t); - } - } - } - - private static void setConfig(String value) { - try { - Path configPath = getConfigPath(); - try (BufferedWriter writer = Files.newBufferedWriter(configPath)) { - if (value == null || value.isEmpty()) { - writer.newLine(); - } else { - writer.write(value); - } - } - } catch (Throwable t) { - throw new RuntimeException(t); - } - } - - private static Path getConfigPath() throws IOException { - List resourcesDir = DevConsoleManager.getHotReplacementContext().getResourcesDir(); - if (resourcesDir.isEmpty()) { - throw new IllegalStateException("Unable to manage configurations - no resource directory found"); - } - - // In the current project only - Path path = resourcesDir.get(0); - Path configPath = path.resolve("application.properties"); - if (!Files.exists(configPath)) { - Files.createDirectories(configPath.getParent()); - configPath = Files.createFile(path.resolve("application.properties")); - } - return configPath; - } - private static final String NAMESPACE = "devui-configuration"; } diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/DevModeFailedStartHandler.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/DevModeFailedStartHandler.java index 3e2d4b4c13b42..5c13ceb45f300 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/DevModeFailedStartHandler.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/DevModeFailedStartHandler.java @@ -1,12 +1,11 @@ package io.quarkus.vertx.http.deployment.devmode; -import io.quarkus.dev.config.CurrentConfig; import io.quarkus.dev.spi.DeploymentFailedStartHandler; import io.quarkus.devui.deployment.menu.ConfigurationProcessor; public class DevModeFailedStartHandler implements DeploymentFailedStartHandler { @Override public void handleFailedInitialStart() { - CurrentConfig.EDITOR = ConfigurationProcessor::updateConfig; + ConfigurationProcessor.setDefaultConfigEditor(); } } diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/devui/ConfigurationCompleteUpdatesRPCTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/devui/ConfigurationCompleteUpdatesRPCTest.java new file mode 100644 index 0000000000000..4441ae96bff40 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/devui/ConfigurationCompleteUpdatesRPCTest.java @@ -0,0 +1,70 @@ +package io.quarkus.devui; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.devui.tests.DevUIJsonRPCTest; +import io.quarkus.test.QuarkusDevModeTest; + +public class ConfigurationCompleteUpdatesRPCTest extends DevUIJsonRPCTest { + + @RegisterExtension + static final QuarkusDevModeTest config = new QuarkusDevModeTest() + .withEmptyApplication(); + + public ConfigurationCompleteUpdatesRPCTest() { + super("devui-configuration"); + } + + @Test + void testSavePropertiesAsString() throws Exception { + final String appProperties = """ + x = y + # test comment + a = b + """; + final var response = super.executeJsonRPCMethod( + "updatePropertiesAsString", + Map.of( + "type", "properties", + "content", appProperties)); + assertThat(response) + .isNotNull(); + assertThat(response.asBoolean()) + .isTrue(); + + final var result = super.executeJsonRPCMethod("getProjectPropertiesAsString"); + assertThat(result.get("error")) + .isNull(); + assertAll( + () -> assertThat(result.get("type").asText()) + .isEqualTo("properties"), + () -> assertThat(result.get("value").asText().trim()) + .isEqualTo(appProperties.trim())); + } + + @Test + void testSaveInvalidProperties() throws Exception { + final String appProperties = """ + x = y + # test comment + a = b + """; + final var response = super.executeJsonRPCMethod( + "updatePropertiesAsString", + Map.of( + "type", "json", + "content", appProperties)); + assertThat(response) + .isNotNull(); + assertThat(response.asBoolean()) + .isFalse(); + + } + +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/devui/ConfigurationSinglePropertyUpdatesRPCTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/devui/ConfigurationSinglePropertyUpdatesRPCTest.java new file mode 100644 index 0000000000000..c32e01902da25 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/devui/ConfigurationSinglePropertyUpdatesRPCTest.java @@ -0,0 +1,143 @@ +package io.quarkus.devui; + +import static java.util.stream.StreamSupport.stream; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +import java.util.List; +import java.util.Map; + +import org.assertj.core.api.AbstractListAssert; +import org.assertj.core.api.ObjectAssert; +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; + +import io.quarkus.devui.tests.DevUIJsonRPCTest; +import io.quarkus.test.QuarkusDevModeTest; + +public class ConfigurationSinglePropertyUpdatesRPCTest extends DevUIJsonRPCTest { + + @RegisterExtension + static final QuarkusDevModeTest config = new QuarkusDevModeTest() + .withApplicationRoot( + jar -> jar + .addAsResource( + "conf/devui-configuration-test.properties", + "application.properties")); + + public ConfigurationSinglePropertyUpdatesRPCTest() { + super("devui-configuration"); + } + + static AbstractListAssert, String, ObjectAssert> assertThatResponseDoesNotContainProperty( + JsonNode response, String name) { + assertThat(response) + .isNotNull() + .isInstanceOf(ArrayNode.class); + final var projectProperties = (ArrayNode) response; + return assertThat(stream(projectProperties.spliterator(), false)) + .isNotEmpty() + .extracting(node -> node.get("key").asText()) + .doesNotContain(name); + } + + AbstractListAssert, String, ObjectAssert> assertThatProjectDoesNotHaveProperty( + String name) throws Exception { + final var projectPropertiesResponse = super.executeJsonRPCMethod("getProjectProperties"); + return assertThatResponseDoesNotContainProperty( + projectPropertiesResponse, + name); + } + + static AbstractListAssert, Tuple, ObjectAssert> assertThatResponseContainsProperty( + JsonNode response, String name, String value) { + assertThat(response) + .isNotNull() + .isInstanceOf(ArrayNode.class); + final var projectProperties = (ArrayNode) response; + return assertThat(stream(projectProperties.spliterator(), false)) + .isNotEmpty() + .extracting(node -> node.get("key").asText(), node -> node.get("value").asText()) + .contains(tuple(name, value)); + } + + AbstractListAssert, Tuple, ObjectAssert> assertThatProjectHasProperty(String name, + String value) throws Exception { + final var projectPropertiesResponse = super.executeJsonRPCMethod("getProjectProperties"); + return assertThatResponseContainsProperty( + projectPropertiesResponse, + name, + value); + } + + void updateProperty(String name, String value) throws Exception { + final var response = super.executeJsonRPCMethod( + "updateProperty", + Map.of( + "name", name, + "value", value)); + assertThat(response) + .isNotNull(); + assertThat(response.asBoolean()) + .isTrue(); + } + + @Test + void testSingleConfigurationProperty() throws Exception { + assertThatProjectDoesNotHaveProperty("x.y"); + updateProperty( + "x.y", + "changedByTest"); + assertThatProjectHasProperty( + "x.y", + "changedByTest"); + } + + @Test + void testUpdateExistingProperty() throws Exception { + updateProperty( + "quarkus.application.name", + "changedByTest"); + assertThatProjectHasProperty( + "quarkus.application.name", + "changedByTest").doesNotHaveDuplicates(); + } + + @Test + void testUpdateNewProperty() throws Exception { + updateProperty( + "quarkus.application.name", + "changedByTest"); + assertThatProjectHasProperty( + "quarkus.application.name", + "changedByTest").doesNotHaveDuplicates(); + } + + @Test + void testUpdatePropertyWithSpaces() throws Exception { + updateProperty( + "my.property.with.spaces", + "changedByTest"); + assertThatProjectHasProperty( + "my.property.with.spaces", + "changedByTest").doesNotHaveDuplicates(); + } + + @Test + void testUpdatePropertyWithLineBreaks() throws Exception { + assertThatProjectHasProperty( + "my.property.with.linebreak", + "valuewithlinebreak"); + updateProperty( + "my.property.with.linebreak", + "changedByTest"); + assertThatProjectHasProperty( + "my.property.with.linebreak", + "changedByTest").doesNotHaveDuplicates(); + } + +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/devui/ConfigurationTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/devui/ConfigurationTest.java deleted file mode 100644 index ecb046f305306..0000000000000 --- a/extensions/vertx-http/deployment/src/test/java/io/quarkus/devui/ConfigurationTest.java +++ /dev/null @@ -1,40 +0,0 @@ -package io.quarkus.devui; - -import java.util.Map; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; - -import com.fasterxml.jackson.databind.JsonNode; - -import io.quarkus.devui.tests.DevUIJsonRPCTest; -import io.quarkus.test.QuarkusDevModeTest; - -public class ConfigurationTest extends DevUIJsonRPCTest { - - @RegisterExtension - static final QuarkusDevModeTest config = new QuarkusDevModeTest() - .withEmptyApplication(); - - public ConfigurationTest() { - super("devui-configuration"); - } - - @Test - public void testConfigurationUpdate() throws Exception { - - JsonNode updatePropertyResponse = super.executeJsonRPCMethod("updateProperty", - Map.of( - "name", "quarkus.application.name", - "value", "changedByTest")); - Assertions.assertNotNull(updatePropertyResponse); - Assertions.assertTrue(updatePropertyResponse.asBoolean()); - - // Get the properties to make sure it is changed - JsonNode allPropertiesResponse = super.executeJsonRPCMethod("getAllValues"); - Assertions.assertNotNull(allPropertiesResponse); - String applicationName = allPropertiesResponse.get("quarkus.application.name").asText(); - Assertions.assertEquals("changedByTest", applicationName); - } -} diff --git a/extensions/vertx-http/deployment/src/test/resources/conf/devui-configuration-test.properties b/extensions/vertx-http/deployment/src/test/resources/conf/devui-configuration-test.properties new file mode 100644 index 0000000000000..5ea90d0be62fc --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/resources/conf/devui-configuration-test.properties @@ -0,0 +1,6 @@ +quarkus.application.name=TestApp +%dev.quarkus.application.name=DevApp +my.property.with.spaces = value with spaces +my.property.with.linebreak = value\ + with\ + linebreak \ No newline at end of file diff --git a/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-configuration-editor.js b/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-configuration-editor.js index 91bf8c8de454c..1f5e01cf61e23 100644 --- a/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-configuration-editor.js +++ b/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-configuration-editor.js @@ -45,7 +45,7 @@ export class QwcConfigurationEditor extends observeState(LitElement) { connectedCallback() { super.connectedCallback(); - this.jsonRpc.getProjectProperties().then(e => { + this.jsonRpc.getProjectPropertiesAsString().then(e => { if(e.result.error){ this._error = e.result.error; }else{ @@ -106,7 +106,7 @@ export class QwcConfigurationEditor extends observeState(LitElement) { _save(){ this._inProgress = true; let newValue = this.shadowRoot.getElementById('code').getAttribute('value'); - this.jsonRpc.updateProperties({content: newValue, type: this._type}).then(jsonRpcResponse => { + this.jsonRpc.updatePropertiesAsString({content: newValue, type: this._type}).then(jsonRpcResponse => { this._inProgress = false; if(jsonRpcResponse.result === false){ notifier.showErrorMessage("Configuration failed to update. See log file for details"); diff --git a/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-configuration.js b/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-configuration.js index ee3729fd0beba..d9391e5e0af80 100644 --- a/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-configuration.js +++ b/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-configuration.js @@ -88,6 +88,7 @@ export class QwcConfiguration extends observeState(LitElement) { .config-source-dropdown { padding-left: 5px; width: 300px; + margin-right: 2px; } `; @@ -95,7 +96,6 @@ export class QwcConfiguration extends observeState(LitElement) { _filtered: {state: true, type: Array}, // Filter the visible configuration _visibleConfiguration: {state: true, type: Array}, // Either all or just user's configuration _allConfiguration: {state: true, type: Array}, - _values: {state: true}, _detailsOpenedItem: {state: true, type: Array}, _busy: {state: true}, _showOnlyConfigSource: {state: true}, @@ -111,6 +111,11 @@ export class QwcConfiguration extends observeState(LitElement) { this._showOnlyConfigSource = null; this._searchTerm = ''; + this._applicationProperties = { + name: 'application.properties', + display: 'application.properties', + overwritableInApplicationProperties: true + } } connectedCallback() { @@ -120,21 +125,63 @@ export class QwcConfiguration extends observeState(LitElement) { if(this._filteredValue){ this._filteredValue = this._filteredValue.replaceAll(",", " OR "); } - this.jsonRpc.getAllConfiguration().then(e => { - this._allConfiguration = e.result; - this._visibleConfiguration = e.result; - this._filtered = e.result; - - for (const configItem of this._allConfiguration) { - let configSourceName = this._getConfigSourceName(configItem.configValue); - if(configSourceName && !this._configSourceSet.has(configSourceName)){ - this._configSourceSet.set(configSourceName, this._createConfigSourceObject(configSourceName, configItem.configValue)); - } + this.jsonRpc + .getFullPropertyConfiguration() + .then(e => e.result) + .then( + result => result + .map(configItem => this._prepareConfiguration(configItem)) + .filter(configItem => !configItem.disabled) + ) + .then(result => { + this._allConfiguration = result; + this._visibleConfiguration = result; + this._filtered = result; + }); + } + + _prepareConfiguration(configItem) { + configItem.configDescription.configSourceObject = + this._findOrCreateConfigSourceObject(configItem.configDescription.configValue); + // if the configuration is explicitely set in application.properties, + // set it as source (no matter which config source was calculated by SmallRye Config) + if(configItem.sourceValue + && configItem.configDescription.configSourceObject !== this._applicationProperties) { + configItem.configDescription.configSourceObject = this._applicationProperties; + } + // properties beginning with % are profile-specific + const name = configItem.configDescription.name; + if(name.startsWith('%') && name.includes('.')) { + configItem.profile = name.substring(1, name.indexOf('.')); + configItem.configDescription.name = name.substring(configItem.profile.length+2); + configItem.disabled = true; + } + return configItem; + } + + _findOrCreateConfigSourceObject(configValue) { + let configSourceName = this._getConfigSourceName(configValue); + if(configSourceName){ + if(!this._configSourceSet.has(configSourceName)){ + const configSourceObject = this._createConfigSourceObject(configValue); + this._configSourceSet.set(configSourceName, configSourceObject); + return configSourceObject; + } else { + return this._configSourceSet.get(configSourceName); } - }); - this.jsonRpc.getAllValues().then(e => { - this._values = e.result; - }); + } + } + + get _document() { + return document + .querySelector('qwc-configuration') + .shadowRoot; + } + + _doBusy(promiseSupplier) { + this._busy = true; + promiseSupplier() + .finally(() => this._busy = null); } _getConfigSourceName(configValue){ @@ -144,21 +191,51 @@ export class QwcConfiguration extends observeState(LitElement) { return null; } - _createConfigSourceObject(configSourceName,configValue){ - - let displayName = configSourceName; - - if(configSourceName.startsWith("PropertiesConfigSource[source") - && configSourceName.endsWith("/application.properties]")){ - displayName = "My properties"; + _isApplicationProperties(configSource) { + const configSourceName = this._getConfigSourceName(configSource); + return configSourceName?.startsWith("PropertiesConfigSource[source") + && configSourceName?.endsWith("/application.properties]"); + } + + _createConfigSourceObject(configSource){ + + const name = this._getConfigSourceName(configSource); + const applicationProperties = this._isApplicationProperties(configSource); + + if(applicationProperties) { + this._applicationProperties.name = name; + this._applicationProperties.position = configSource.configSourcePosition; + this._applicationProperties.ordinal = configSource.configSourceOrdinal; + return this._applicationProperties; + } + + let displayName = name; + let overwritableInApplicationProperties = true; + switch (name) { + case 'SysPropConfigSource': + displayName = 'System Properties'; + overwritableInApplicationProperties = false; + break; + case 'EnvConfigSource': + displayName = 'Environment Variables'; + overwritableInApplicationProperties = false; + break; + case 'DefaultValuesConfigSource': + displayName = '(defaults)'; + break; } - - let configSourceObject = {name:configSourceName, display: displayName, position:configValue.configSourcePosition, ordinal:configValue.configSourceOrdinal}; - return configSourceObject; + + return { + name, + display: displayName, + overwritableInApplicationProperties, + position: configSource.configSourcePosition, + ordinal:configSource.configSourceOrdinal + }; } render() { - if (this._filtered && this._values) { + if (this._filtered) { return this._render(); } else if(!connectionState.current.isConnected){ return html`Waiting for backend connection...`; @@ -195,33 +272,33 @@ export class QwcConfiguration extends observeState(LitElement) { } this._filtered = this._visibleConfiguration.filter((prop) => { - return this._match(prop.name, this._searchTerm) || this._match(prop.description, this._searchTerm) + return this._match(prop.configDescription.name, this._searchTerm) || this._match(prop.configDescription.description, this._searchTerm) }); } _render() { return html`
+ + @value-changed="${this._filterTextChanged}"> ${this._filtered.length} - - -
${this._renderGrid()}
`; @@ -230,37 +307,42 @@ export class QwcConfiguration extends observeState(LitElement) { _toggleFilterByConfigSource(event){ if(event.target.value){ this._showOnlyConfigSource = event.target.value; - this._visibleConfiguration = this._allConfiguration.filter((prop) => { - return prop.configValue.sourceName && prop.configValue.sourceName === this._showOnlyConfigSource; - }); - }else{ + this._visibleConfiguration = this._allConfiguration + .filter(prop => prop.configDescription?.configSourceObject?.name === this._showOnlyConfigSource); + } else { this._showOnlyConfigSource = null; this._visibleConfiguration = this._allConfiguration; } return this._filterGrid(); } - _renderGrid(){ - if(this._busy){ - return html`${this._renderStyledGrid("disabledDatatable")}`; - }else{ - return html`${this._renderStyledGrid("datatable")}`; - } + get _gridComponent() { + return this._document.querySelector('#configuration-grid'); } - _renderStyledGrid(className){ - return html` + _renderGrid(){ + return html` + + + + - + ` + } else { + return html``; + } + } + + _configSourceRenderer(prop) { + if(prop.configDescription.configSourceObject) { + return html` + + ${prop.configDescription.configSourceObject.display} + + `; + } else { + return html``; } } _nameRenderer(prop) { let devservice = ""; let wildcard = ""; - if (prop.autoFromDevServices) { + let profile = ""; + if (prop.configDescription.autoFromDevServices) { devservice = html` - - + `; } - if (prop.wildcardEntry) { + if (prop.configDescription.wildcardEntry) { wildcard = html` - - + `; } + if (prop.profile) { + profile = html` + + ${prop.profile} + + `; + } + + let result = html` + ${profile}${prop.configDescription.name}${devservice}${wildcard}`; + if(prop.disabled) { + result = html` + ${result} + + `; + } + return result; + } + + _renderInputForBoolean(propertyContext) { return html` - ${prop.name}${devservice}${wildcard}`; + ${this._renderCalculatedValueWarning(propertyContext)} + -1}> + ${this._renderInputTooltip(propertyContext)} + + ${this._renderOverwriteButton(propertyContext)} + `; } - _valueRenderer(prop) { - let def = ''; - if (prop.defaultValue) { - def = "Default value: " + prop.defaultValue; - } else { - def = "No default value"; - } + _renderInputForEnum(propertyContext) { + return html` + + ${this._renderCalculatedValueWarning(propertyContext)} + ${this._renderInputTooltip(propertyContext)} + ${this._renderOverwriteButton(propertyContext)} + + `; + } + + _renderInputForInteger(propertyContext) { + return html` + + ${this._renderCalculatedValueWarning(propertyContext)} + ${this._renderInputTooltip(propertyContext)} + ${this._renderSaveButton(propertyContext)} + + `; + } + + _renderInputForDouble(propertyContext) { + return html` + + ${this._renderCalculatedValueWarning(propertyContext)} + ${this._renderInputTooltip(propertyContext)} + ${this._renderSaveButton(propertyContext)} + + `; + } + + _renderInputForText(propertyContext) { + return html` + + ${this._renderCalculatedValueWarning(propertyContext)} + ${this._renderInputTooltip(propertyContext)} + ${this._renderSaveButton(propertyContext)} + + `; + } - let actualValue = this._values[prop.name]; - if (!actualValue) { - actualValue = prop.defaultValue; + _renderInputTooltip(propertyContext, componentId) { + const defaultValueText = + propertyContext.property.configDescription.defaultValue + ? `Default value: ${propertyContext.property.configDescription.defaultValue}` + : 'No default value'; + let warningText = ''; + if(propertyContext.editable + && propertyContext.value !== propertyContext.property.sourceValue) { + warningText = `Raw value differs from calculated value: "${propertyContext.property.sourceValue}"! \n`; } + return html``; + } - if (prop.wildcardEntry) { - // TODO - } else if (prop.typeName === "java.lang.Boolean") { - let isChecked = (actualValue === 'true'); - return html` - - - `; - } else if (prop.typeName === "java.lang.Integer" || prop.typeName === "java.lang.Long") { - return html` - - - - `; - } else if (prop.typeName === "java.lang.Float" || prop.typeName === "java.lang.Double") { + _renderCalculatedValueWarning(propertyContext) { + if(propertyContext.editable + && propertyContext.value !== propertyContext.property.sourceValue) { return html` - - - - `; - } else if (prop.typeName === "java.lang.Enum" || prop.typeName === "java.util.logging.Level") { - let items = []; - let defaultValue = ''; - for (let idx in prop.allowedValues) { - if (prop.allowedValues[idx] === actualValue) { - defaultValue = prop.allowedValues[idx]; - } - items.push({ - 'label': prop.allowedValues[idx], - 'value': prop.allowedValues[idx], - }); - } - if (! defaultValue) { - defaultValue = prop.defaultValue; - } + + + `; + } + } + + _renderSaveButton(propertyContext) { + if(propertyContext.editable) { return html` - - - - + `; } else { + return this._renderOverwriteButton(propertyContext); + } + } + + _renderOverwriteButton(propertyContext) { + if(propertyContext.overwritable && this._applicationProperties) { return html` - - - - - + + `; + } else { + return html``; + } + } + + _isOverwritable(configDescription) { + return configDescription.configSourceObject !== this._applicationProperties + && !configDescription.name.includes('*') + && (configDescription.configSourceObject?.overwritableInApplicationProperties ?? true) + } + + _valueRenderer(prop) { + + const propertyContext = { + property: prop, + value: prop.configDescription.configValue?.value ?? prop.configDescription.defaultValue, + editable: prop.configDescription.configSourceObject === this._applicationProperties, + overwritable: this._isOverwritable(prop.configDescription), + } + + if (prop.configDescription.wildcardEntry) { + // TODO + } else { + switch (prop.configDescription.typeName) { + case 'java.lang.Boolean': + return this._renderInputForBoolean(propertyContext); + case 'java.lang.Enum': + case 'java.util.logging.Level': + return this._renderInputForEnum(propertyContext); + case 'java.lang.Byte': + case 'java.lang.Short': + case 'java.lang.Integer': + case 'java.lang.Long': + return this._renderInputForInteger(propertyContext); + case 'java.lang.Number': + case 'java.lang.Float': + case 'java.lang.Double': + return this._renderInputForDouble(propertyContext); + default: + return this._renderInputForText(propertyContext); + } } } _descriptionRenderer(prop) { - let val = prop.name; + let val = prop.configDescription.name; let res = ""; for (let i = 0; i < val.length; i++) { let c = val.charAt(i); @@ -419,16 +631,16 @@ export class QwcConfiguration extends observeState(LitElement) { res = res.toUpperCase(); let def = "Default value: None"; - if (prop.defaultValue) { - def = "Default value: " + prop.defaultValue; + if (prop.configDescription.defaultValue) { + def = "Default value: " + prop.configDescription.defaultValue; } let configSourceName = "Unknown"; - if(prop.configValue.sourceName){ - configSourceName = prop.configValue.sourceName; + if(prop.configDescription.configValue.sourceName){ + configSourceName = prop.configDescription.configValue.sourceName; } let src = "Config source: " + configSourceName; return html`
-

${unsafeHTML(prop.description)}

+

${unsafeHTML(prop.configDescription.description)}

Environment variable: ${res}
${unsafeHTML(def)}
@@ -439,39 +651,77 @@ export class QwcConfiguration extends observeState(LitElement) { _keydown(event){ if (event.key === 'Enter' || event.keyCode === 13) { - let name = event.target.parentElement.id.replace("input-", ""); - this._updateProperty(name, event.target.value); + let property = this._getPropertyOnInputField(event.target); + this._doBusy(() => this._updateProperty(property, event.target.value)); } } _selectChanged(event){ - let name = event.target.id.replace("select-", ""); - this._updateProperty(name, event.target.value); + let property = this._getPropertyOnInputField(event.target); + this._doBusy(() => this._updateProperty(property, event.target.value)); + } + + _getPropertyOnInputField(input) { + if(!input) { + throw "Input does not have a data attribute for the property"; + } + const result = input.dataset.propertyInput; + return result + ? this._allConfiguration.find(property => property.configDescription.name === result) + : this._getPropertyOnInputField(input.parentElement); + } + + _getInputFieldForProperty(property) { + return this._document + .querySelector(`[data-property-input='${property.configDescription.name}']`); } - _saveClicked(event){ + _saveClicked(event, property){ event.preventDefault(); - let parent = event.target.parentElement; - let name = parent.id.replace("input-", ""); - this._updateProperty(name, parent.value); + let newValue = this._getInputFieldForProperty(property).value; + this._doBusy( + () => this + ._updateProperty(property, newValue) + .then(() => this._gridComponent.requestContentUpdate()) + ); } - _checkedChanged(property, event, value) { + _overwriteClicked(event, property){ event.preventDefault(); - this._updateProperty(property.name, value.toString()); + const inputField = this._getInputFieldForProperty(property); + const newValue = inputField.dataset.propertyType === "boolean" + ? "" + inputField.checked + : inputField.value; + this._doBusy( + () =>this + ._updateProperty(property, newValue) + .then(property => { + property.configDescription.configSourceObject = this._applicationProperties; + return property; + }) + .then(() => this._gridComponent.requestContentUpdate()) + ); } - _updateProperty(name, value){ - this._busy = true; - this.jsonRpc.updateProperty({ - 'name': name, - 'value': value - }).then(e => { - this._values[name] = value; - fetch(devuiState.applicationInfo.contextRoot); - notifier.showInfoMessage("Property " + name + " updated"); - this._busy = null; - }); + _checkedChanged(event, property) { + event.preventDefault(); + const newValue = event.target.checked; + this._doBusy(() => this._updateProperty(property, newValue.toString())); + } + + _updateProperty(property, newValue){ + const propertyName = (property.profile ? `%${property.profile}.` : '' ) + property.configDescription.name; + return this.jsonRpc.updateProperty({ + 'name': propertyName, + 'value': newValue + }) + .then(() => { + property.configDescription.configValue.value = newValue; + property.sourceValue = newValue; + fetch(devuiState.applicationInfo.contextRoot); + notifier.showInfoMessage(`Property ${propertyName} updated`); + return property; + }); } } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/devui/runtime/config/ApplicationPropertiesService.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/devui/runtime/config/ApplicationPropertiesService.java new file mode 100644 index 0000000000000..b2296d363e985 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/devui/runtime/config/ApplicationPropertiesService.java @@ -0,0 +1,180 @@ +package io.quarkus.devui.runtime.config; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Properties; + +import io.quarkus.dev.console.DevConsoleManager; + +public class ApplicationPropertiesService { + + public static class ResourceDirectoryNotFoundException extends IOException { + + } + + public static class ApplicationPropertiesNotFoundException extends IOException { + + private final Path applicationPropertiesPath; + + public ApplicationPropertiesNotFoundException(Path applicationPropertiesPath) { + this.applicationPropertiesPath = applicationPropertiesPath; + } + + public Path getApplicationPropertiesPath() { + return applicationPropertiesPath; + } + } + + public Path findOrCreateApplicationProperties() throws IOException { + try { + return findApplicationProperties(); + } catch (ApplicationPropertiesNotFoundException e) { + Files.createDirectories(e.getApplicationPropertiesPath().getParent()); + Files.createFile(e.getApplicationPropertiesPath()); + return e.getApplicationPropertiesPath(); + } + } + + public Path findApplicationProperties() + throws ResourceDirectoryNotFoundException, ApplicationPropertiesNotFoundException { + final var resourceDirectories = DevConsoleManager + .getHotReplacementContext() + .getResourcesDir(); + if (resourceDirectories.isEmpty()) { + throw new ResourceDirectoryNotFoundException(); + } + // TODO: we only use the first directory + final var resourcesPath = resourceDirectories.get(0); + final var applicationPropertiesPath = resourcesPath + .resolve("application.properties"); + if (!Files.exists(applicationPropertiesPath)) { + throw new ApplicationPropertiesNotFoundException(applicationPropertiesPath); + } + return applicationPropertiesPath; + } + + public Reader createApplicationPropertiesReader() throws IOException { + final var applicationPropertiesPath = this.findApplicationProperties(); + // This is the same way as SmallRye Config loads the file! + // see io.smallrye.config.PropertiesConfigSource + return new InputStreamReader( + Files.newInputStream(applicationPropertiesPath), + StandardCharsets.UTF_8); + } + + public Properties readApplicationProperties() throws IOException { + try (final var reader = createApplicationPropertiesReader()) { + final var result = new Properties(); + result.load(reader); + return result; + } + } + + public BufferedWriter createApplicationPropertiesWriter() throws IOException { + final var applicationPropertiesPath = this.findOrCreateApplicationProperties(); + return Files.newBufferedWriter(applicationPropertiesPath); + } + + public void saveApplicationProperties(String content) throws IOException { + try (final var writer = createApplicationPropertiesWriter()) { + if (content == null || content.isEmpty()) { + writer.newLine(); + } else { + writer.write(content); + } + } + } + + /** + * Used to manage the application.properties line parsing state. + */ + private enum NextLineHandle { + /** + * Parse the line to find a property key. + * (default) + */ + PARSE, + /** + * Ignore the line, do not write out and do not parse. + * (used when the line before was replaced, but the next line is part of the replacement) + * (in case of line breaks in properties) + */ + IGNORE, + /** + * Just write the line without parsing. + * (used when the line before was NOT replaced, and the next line is continuing this line) + */ + WRITE; + } + + public void mergeApplicationProperties(Properties updates) throws IOException { + if (!updates.isEmpty()) { + final var handledKeys = new HashSet(); + final List lines = new ArrayList<>(); + // read line by line + try (final var reader = new BufferedReader( + Optional.ofNullable(createApplicationPropertiesReader()) + .orElseGet(() -> new StringReader("")))) { + String line; + while ((line = reader.readLine()) != null) { + lines.add(line); + } + } catch (ApplicationPropertiesNotFoundException e) { + // don't do anything + } + // go line by line, replace and write out + try (final var writer = createApplicationPropertiesWriter()) { + var firstLine = true; + var state = NextLineHandle.PARSE; + for (String line : lines) { + final var linebreak = line.endsWith("\\"); + switch (state) { + case IGNORE: + break; + case PARSE: + // parse property name from line (remove whitespaces) + state = NextLineHandle.WRITE; + final var assignmentIndex = line.indexOf('='); + if (assignmentIndex > 0) { + final String key = line.substring(0, assignmentIndex).trim(); + if (updates.containsKey(key) && handledKeys.add(key)) { + line = key + "=" + updates.getProperty(key); + state = NextLineHandle.IGNORE; + } + } + // fallthrough + case WRITE: + if (!firstLine) { + writer.newLine(); + } + writer.write(line); + } + if (!linebreak) { + state = NextLineHandle.PARSE; + } + firstLine = false; + } + // write new properties + final var updatesCopy = new Properties(); + updatesCopy.putAll(updates); + handledKeys.forEach(updatesCopy::remove); + if (!updatesCopy.isEmpty()) { + writer.newLine(); + updatesCopy.store(writer, "added by Dev UI"); + } + } + } + } + +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/devui/runtime/config/ConfigDevUIRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/devui/runtime/config/ConfigDevUIRecorder.java index c4ca99a1391e7..285e731760019 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/devui/runtime/config/ConfigDevUIRecorder.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/devui/runtime/config/ConfigDevUIRecorder.java @@ -39,6 +39,15 @@ public ConfigDescriptionBean get() { }; } + public Supplier applicationPropertiesService() { + return new Supplier<>() { + @Override + public ApplicationPropertiesService get() { + return new ApplicationPropertiesService(); + } + }; + } + private List calculate(List cd, Set devServicesProperties) { List configDescriptions = new ArrayList<>(cd); diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/devui/runtime/config/ConfigJsonRPCService.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/devui/runtime/config/ConfigJsonRPCService.java index 1b87e66558b94..5cd3af25b7249 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/devui/runtime/config/ConfigJsonRPCService.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/devui/runtime/config/ConfigJsonRPCService.java @@ -1,13 +1,17 @@ package io.quarkus.devui.runtime.config; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.Reader; +import java.util.Properties; +import java.util.stream.Collectors; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import io.quarkus.dev.console.DevConsoleManager; +import io.quarkus.devui.runtime.config.ApplicationPropertiesService.ApplicationPropertiesNotFoundException; +import io.quarkus.devui.runtime.config.ApplicationPropertiesService.ResourceDirectoryNotFoundException; +import io.quarkus.runtime.configuration.ConfigUtils; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; @@ -16,43 +20,85 @@ public class ConfigJsonRPCService { @Inject ConfigDescriptionBean configDescriptionBean; + @Inject + ApplicationPropertiesService applicationPropertiesService; + + public record ApplicationConfigDescriptionDto( + ConfigDescription configDescription, + String sourceValue, // value in application.properties, if available + String profile) { - public JsonArray getAllConfiguration() { - return new JsonArray(configDescriptionBean.getAllConfig()); } - public JsonObject getAllValues() { - JsonObject values = new JsonObject(); - for (ConfigDescription configDescription : configDescriptionBean.getAllConfig()) { - values.put(configDescription.getName(), configDescription.getConfigValue().getValue()); + private static ApplicationConfigDescriptionDto createApplicationConfigDescription( + Properties properties, + ConfigDescription config) { + // TODO only application.properties are read (no profile-specific properties) + // first profile wins + final var profiles = ConfigUtils.getProfiles(); + for (String profile : profiles) { + final var rawValue = properties.getProperty("%" + profile + "." + config.getName()); + if (null != rawValue) { + return new ApplicationConfigDescriptionDto( + config, + rawValue, + profile); + } } - return values; + return new ApplicationConfigDescriptionDto( + config, + properties.getProperty(config.getName()), + null); } - public JsonObject getProjectProperties() { - JsonObject response = new JsonObject(); + @SuppressWarnings("unused") // used in Configuration Form Editor + public JsonArray getFullPropertyConfiguration() throws IOException { + final Properties properties = new Properties(); try { - List resourcesDir = DevConsoleManager.getHotReplacementContext().getResourcesDir(); - if (resourcesDir.isEmpty()) { - response.put("error", "Unable to manage configurations - no resource directory found"); - } else { - - // In the current project only - Path path = resourcesDir.get(0); - Path configPropertiesPath = path.resolve("application.properties"); - if (Files.exists(configPropertiesPath)) { - // Properties file - response.put("type", "properties"); - String value = new String(Files.readAllBytes(configPropertiesPath)); - response.put("value", value); - } else { - response.put("type", "properties"); - response.put("value", ""); - } - } + properties.putAll(applicationPropertiesService.readApplicationProperties()); + } catch (ResourceDirectoryNotFoundException | ApplicationPropertiesNotFoundException ex) { + // don't do anything, properties will be just empty + } + return new JsonArray( + configDescriptionBean + .getAllConfig() + .stream() + .map(config -> createApplicationConfigDescription(properties, config)) + .toList()); + } + + record PropertyDto( + String key, + String value) { + } - } catch (Throwable t) { - throw new RuntimeException(t); + public JsonArray getProjectProperties() throws IOException { + return new JsonArray( + applicationPropertiesService + .readApplicationProperties() + .entrySet() + .stream() + .map(entry -> new PropertyDto( + String.valueOf(entry.getKey()), + String.valueOf(entry.getValue()))) + .toList()); + } + + @SuppressWarnings("unused") // used in Configuration Source Editor + public JsonObject getProjectPropertiesAsString() throws IOException { + JsonObject response = new JsonObject(); + try (Reader reader = applicationPropertiesService.createApplicationPropertiesReader(); + BufferedReader bufferedReader = new BufferedReader(reader)) { + final var content = bufferedReader + .lines() + .collect(Collectors.joining("\n")); + response.put("type", "properties"); + response.put("value", content); + } catch (ResourceDirectoryNotFoundException ex) { + response.put("error", "Unable to manage configurations - no resource directory found"); + } catch (ApplicationPropertiesNotFoundException ex) { + response.put("type", "properties"); + response.put("value", ""); } return response; }