diff --git a/manager/src/main/java/org/openremote/manager/asset/AssetResourceImpl.java b/manager/src/main/java/org/openremote/manager/asset/AssetResourceImpl.java index 316f9baf1b..f948083dcd 100644 --- a/manager/src/main/java/org/openremote/manager/asset/AssetResourceImpl.java +++ b/manager/src/main/java/org/openremote/manager/asset/AssetResourceImpl.java @@ -47,12 +47,17 @@ import org.openremote.model.asset.Asset; import org.openremote.model.asset.AssetResource; import org.openremote.model.asset.AssetTree; +import org.openremote.model.asset.ConfigurationTemplate; +import org.openremote.model.asset.ConfigurationTemplateImportFailure; +import org.openremote.model.asset.ConfigurationTemplateImportPreview; +import org.openremote.model.asset.ConfigurationTemplateImportRequest; import org.openremote.model.asset.UserAssetLink; import org.openremote.model.attribute.*; import org.openremote.model.http.RequestParams; import org.openremote.model.query.AssetQuery; import org.openremote.model.query.filter.RealmPredicate; import org.openremote.model.security.ClientRole; +import org.openremote.model.util.ConfigurationTemplateUtil; import org.openremote.model.util.TextUtil; import org.openremote.model.util.ValueUtil; @@ -466,6 +471,75 @@ public Asset update(RequestParams requestParams, String assetId, Asset ass } } + @Override + public ConfigurationTemplate exportConfigurationTemplate( + RequestParams requestParams, String assetId, List attributeNames) { + // Reuses the read access rules of #get, including the protected view for a restricted user + ConfigurationTemplate document = + ConfigurationTemplateUtil.export(get(requestParams, assetId), attributeNames); + + if (document.getAttributes() == null || document.getAttributes().isEmpty()) { + throw new WebApplicationException("No configuration items to export", BAD_REQUEST); + } + return document; + } + + @Override + public ConfigurationTemplateImportPreview previewConfigurationTemplateImport( + RequestParams requestParams, String assetId, ConfigurationTemplateImportRequest request) { + + Asset storageAsset = assetStorageService.find(assetId, true); + + if (storageAsset == null) { + LOG.fine("Asset not found: assetID=" + assetId); + throw new WebApplicationException(NOT_FOUND); + } + + throwIfNotRealmActiveAndAccessible(storageAsset.getRealm()); + + if (isRestrictedUser() && !assetStorageService.isUserAsset(getUserId(), assetId)) { + LOG.fine( + "Forbidden access for restricted user: username=" + + getUsername() + + ", assetID=" + + assetId); + throw new WebApplicationException(FORBIDDEN); + } + + if (request == null || request.getDraft() == null) { + throw new WebApplicationException("An asset draft is required", BAD_REQUEST); + } + + Asset draft = request.getDraft(); + + if (!Objects.equals(assetId, draft.getId()) + || !Objects.equals(storageAsset.getRealm(), draft.getRealm()) + || !Objects.equals(storageAsset.getType(), draft.getType())) { + throw importFailure( + ConfigurationTemplateImportPreview.failed( + ConfigurationTemplateImportFailure.DRAFT_MISMATCH)); + } + + ConfigurationTemplateImportPreview preview = + ConfigurationTemplateUtil.preview(draft, request.getDocument()); + + if (preview.getFailure() != null) { + throw importFailure(preview); + } + + return preview; + } + + protected WebApplicationException importFailure(ConfigurationTemplateImportPreview preview) { + Response.Status status = + preview.getFailure() == ConfigurationTemplateImportFailure.DRAFT_MISMATCH + ? CONFLICT + : BAD_REQUEST; + + return new WebApplicationException( + Response.status(status).entity(preview).type(MediaType.APPLICATION_JSON_TYPE).build()); + } + @Override public AttributeWriteResult writeAttributeValue( RequestParams requestParams, String assetId, String attributeName, Object value) { diff --git a/model/src/main/java/org/openremote/model/asset/AssetResource.java b/model/src/main/java/org/openremote/model/asset/AssetResource.java index db616548b8..1e727fed48 100644 --- a/model/src/main/java/org/openremote/model/asset/AssetResource.java +++ b/model/src/main/java/org/openremote/model/asset/AssetResource.java @@ -344,6 +344,87 @@ Asset update( value = ASSET_UPDATE))) Asset asset); + /** + * Retrieve a configuration template of the asset: a user editable document holding the type and + * metadata of every attribute that has metadata. Metadata that grants access or links attributes + * stays with the asset. Repeat the attributeName query parameter to export a subset of them. + * Access follows the same rules as {@link #get}. A 400 status is returned if nothing is left to + * export. + */ + @GET + @Path("{assetId}/configurationTemplate") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ASSETS_ROLE}) + @Operation( + operationId = "exportConfigurationTemplate", + summary = "Export a configuration template of an asset", + description = + "Returns a versioned document holding the type and metadata of each attribute that has metadata, limited to the requested attribute names when any are given. Metadata that grants access or links attributes stays with the asset, and attributes without other metadata are never included. Exporting nothing is rejected.") + @OpenApiResponses.Ok + @OpenApiResponses.Authenticated + @OpenApiResponses.BadRequest + @OpenApiResponses.NotFound + ConfigurationTemplate exportConfigurationTemplate( + @BeanParam RequestParams requestParams, + @Parameter(description = ASSET_ID, example = EXAMPLE_ASSET_ID) @PathParam("assetId") + String assetId, + @Parameter( + description = + "Attribute names to export. Repeat the query parameter for multiple attributes, omit it to export all attributes that have metadata.", + style = ParameterStyle.FORM, + explode = Explode.TRUE) + @QueryParam("attributeName") + List attributeNames); + + /** + * Validate a configuration template against an asset draft and report what importing it would do, + * without storing anything. The returned patched attributes are for the client to apply to the + * draft once the user confirms, so the same access as {@link #update} is required. A 400 status + * is returned if no draft is given or the template cannot be imported, and a 409 status if the + * draft no longer identifies the persisted asset. A rejected template is answered with the + * preview, whose failure names the reason. + */ + @POST + @Path("{assetId}/configurationTemplate/import") + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.WRITE_ASSETS_ROLE}) + @Operation( + operationId = "previewConfigurationTemplateImport", + summary = "Preview a configuration template import", + description = + "Validates the document, matches its attributes against the draft by name and type, and returns the compatibility report together with the draft attributes holding the imported metadata. Metadata that grants access or links attributes is ignored, and the draft keeps its own. Nothing is stored; the client applies the patch after the user confirms.") + @OpenApiResponses.Ok + @OpenApiResponses.Authenticated + @ApiResponse( + responseCode = "400", + description = + "The draft is missing, or the document cannot be imported; a rejected document is answered with the preview, whose failure names the reason", + content = { + @Content( + mediaType = APPLICATION_JSON, + schema = @Schema(implementation = ConfigurationTemplateImportPreview.class)), + @Content(mediaType = "text/plain", schema = @Schema(implementation = String.class)) + }) + @OpenApiResponses.NotFound + @ApiResponse( + responseCode = "409", + description = + "The draft no longer identifies the persisted asset; the preview failure is DRAFT_MISMATCH", + content = + @Content( + mediaType = APPLICATION_JSON, + schema = @Schema(implementation = ConfigurationTemplateImportPreview.class))) + ConfigurationTemplateImportPreview previewConfigurationTemplateImport( + @BeanParam RequestParams requestParams, + @Parameter(description = ASSET_ID, example = EXAMPLE_ASSET_ID) @PathParam("assetId") + String assetId, + @RequestBody( + required = true, + description = + "The asset draft being edited and the contents of the selected configuration file.") + @NotNull ConfigurationTemplateImportRequest request); + /** * Updates an attribute of an asset. Regular users can only update assets in their authenticated * realm, the superuser can update assets in other (all) realms. A 403 status is returned if a diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfiguration.java b/model/src/main/java/org/openremote/model/asset/AttributeConfiguration.java new file mode 100644 index 0000000000..d54f7681c7 --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfiguration.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.asset; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * The configuration of a single {@link org.openremote.model.attribute.Attribute} within an {@link + * ConfigurationTemplate}. The attribute name is the document map key, so it is not repeated here. + */ +@Schema( + description = + "Configuration of a single attribute; the attribute name is the key within the document.") +public class AttributeConfiguration { + + @Schema(description = "Value descriptor name of the attribute, e.g. number.") + protected String type; + + @Schema(description = "Metadata keyed by meta-item name, exactly as stored on the attribute.") + protected ObjectNode meta; + + @JsonCreator + public AttributeConfiguration( + @JsonProperty("type") String type, @JsonProperty("meta") ObjectNode meta) { + this.type = type; + this.meta = meta; + } + + public String getType() { + return type; + } + + public ObjectNode getMeta() { + return meta; + } +} diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationSummary.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationSummary.java new file mode 100644 index 0000000000..18c8f3879c --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationSummary.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.asset; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +/** An attribute of a {@link ConfigurationTemplate} as reported to the user. */ +@Schema(description = "Name and type of an attribute within an imported document.") +public class AttributeConfigurationSummary { + + @Schema(description = "Attribute name.") + protected String name; + + @Schema(description = "Value descriptor name of the attribute, e.g. number.") + protected String type; + + @JsonCreator + public AttributeConfigurationSummary( + @JsonProperty("name") String name, @JsonProperty("type") String type) { + this.name = name; + this.type = type; + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } +} diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationTypeMismatch.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationTypeMismatch.java new file mode 100644 index 0000000000..7271391d72 --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationTypeMismatch.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.asset; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * An attribute that cannot be imported because the target attribute holds a different value type. + */ +@Schema(description = "Attribute skipped on import because the target type differs.") +public class AttributeConfigurationTypeMismatch { + + @Schema(description = "Attribute name.") + protected String name; + + @Schema(description = "Value descriptor name within the imported document.") + protected String importedType; + + @Schema(description = "Value descriptor name of the target attribute.") + protected String targetType; + + @JsonCreator + public AttributeConfigurationTypeMismatch( + @JsonProperty("name") String name, + @JsonProperty("importedType") String importedType, + @JsonProperty("targetType") String targetType) { + this.name = name; + this.importedType = importedType; + this.targetType = targetType; + } + + public String getName() { + return name; + } + + public String getImportedType() { + return importedType; + } + + public String getTargetType() { + return targetType; + } +} diff --git a/model/src/main/java/org/openremote/model/asset/ConfigurationTemplate.java b/model/src/main/java/org/openremote/model/asset/ConfigurationTemplate.java new file mode 100644 index 0000000000..e46b77de55 --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/ConfigurationTemplate.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.asset; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Map; + +/** + * A configuration template: a user editable document holding the configuration of the {@link + * org.openremote.model.attribute.Attribute}s of an {@link Asset}. Attributes are keyed by attribute + * name. + */ +@Schema( + description = + "Versioned configuration template holding the attribute configuration of an asset, keyed by attribute name.") +public class ConfigurationTemplate { + + /** The only document version this release can read and write. */ + public static final int CURRENT_VERSION = 1; + + @Schema(description = "Document format version.", example = "1") + protected int version; + + @Schema(description = "Asset type the template was exported from.") + protected String assetType; + + @Schema(description = "Attribute configuration keyed by attribute name.") + protected Map attributes; + + @JsonCreator + public ConfigurationTemplate( + @JsonProperty("version") int version, + @JsonProperty("assetType") String assetType, + @JsonProperty("attributes") Map attributes) { + this.version = version; + this.assetType = assetType; + this.attributes = attributes; + } + + public int getVersion() { + return version; + } + + public String getAssetType() { + return assetType; + } + + public Map getAttributes() { + return attributes; + } +} diff --git a/model/src/main/java/org/openremote/model/asset/ConfigurationTemplateImportFailure.java b/model/src/main/java/org/openremote/model/asset/ConfigurationTemplateImportFailure.java new file mode 100644 index 0000000000..fdccd1d8c6 --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/ConfigurationTemplateImportFailure.java @@ -0,0 +1,44 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.asset; + +/** Reason why a {@link ConfigurationTemplate} cannot be imported. */ +public enum ConfigurationTemplateImportFailure { + + /** The document is not parsable JSON or is not a JSON object. */ + INVALID_JSON, + + /** The document version is not {@link ConfigurationTemplate#CURRENT_VERSION}. */ + UNSUPPORTED_VERSION, + + /** The version, asset type or attributes field is absent. */ + MISSING_REQUIRED_FIELD, + + /** The attributes field or one of its entries does not hold a type and a metadata object. */ + MALFORMED_ATTRIBUTE_ENTRY, + + /** A metadata item does not hold a value of its type, or the value breaks its constraints. */ + INVALID_META_ITEM, + + /** No attribute of the document exists on the target asset with the same type. */ + NO_IMPORTABLE_ATTRIBUTES, + + /** The submitted draft no longer identifies the persisted target asset. */ + DRAFT_MISMATCH +} diff --git a/model/src/main/java/org/openremote/model/asset/ConfigurationTemplateImportPreview.java b/model/src/main/java/org/openremote/model/asset/ConfigurationTemplateImportPreview.java new file mode 100644 index 0000000000..ef0b3d46eb --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/ConfigurationTemplateImportPreview.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.asset; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +/** + * What importing a {@link ConfigurationTemplate} into an asset draft would do, for the user to + * confirm or cancel. With a {@link #failure} nothing can be imported and there are no patched + * attributes, but the other fields still hold what could be worked out, such as the skipped + * attributes. + */ +@Schema( + description = + "Outcome of validating a configuration template against an asset draft, with the patched attributes to apply on confirmation.") +public class ConfigurationTemplateImportPreview { + + @Schema(description = "Why the document cannot be imported; absent when it can.") + protected ConfigurationTemplateImportFailure failure; + + @Schema(description = "Attributes whose metadata will be replaced.") + protected List importableAttributes; + + @Schema(description = "Attributes skipped because the target asset does not have them.") + protected List missingAttributes; + + @Schema(description = "Attributes skipped because the target attribute has a different type.") + protected List typeMismatches; + + @Schema( + description = + "The submitted draft attributes with the metadata of every importable attribute replaced, keyed by attribute name.") + protected ObjectNode patchedAttributes; + + @JsonCreator + public ConfigurationTemplateImportPreview( + @JsonProperty("failure") ConfigurationTemplateImportFailure failure, + @JsonProperty("importableAttributes") + List importableAttributes, + @JsonProperty("missingAttributes") List missingAttributes, + @JsonProperty("typeMismatches") List typeMismatches, + @JsonProperty("patchedAttributes") ObjectNode patchedAttributes) { + this.failure = failure; + this.importableAttributes = importableAttributes; + this.missingAttributes = missingAttributes; + this.typeMismatches = typeMismatches; + this.patchedAttributes = patchedAttributes; + } + + public static ConfigurationTemplateImportPreview failed( + ConfigurationTemplateImportFailure failure) { + return new ConfigurationTemplateImportPreview(failure, List.of(), List.of(), List.of(), null); + } + + public ConfigurationTemplateImportFailure getFailure() { + return failure; + } + + public List getImportableAttributes() { + return importableAttributes; + } + + public List getMissingAttributes() { + return missingAttributes; + } + + public List getTypeMismatches() { + return typeMismatches; + } + + public ObjectNode getPatchedAttributes() { + return patchedAttributes; + } +} diff --git a/model/src/main/java/org/openremote/model/asset/ConfigurationTemplateImportRequest.java b/model/src/main/java/org/openremote/model/asset/ConfigurationTemplateImportRequest.java new file mode 100644 index 0000000000..6c7d9d3a09 --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/ConfigurationTemplateImportRequest.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.asset; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +/** + * A {@link ConfigurationTemplate} to import, together with the asset draft it is imported into. The + * document is submitted as text so that unparsable files are reported like any other invalid + * document. + */ +@Schema(description = "A configuration template to validate against an asset draft, as file text.") +public class ConfigurationTemplateImportRequest { + + @Schema(description = "The asset draft currently being edited, including unsaved changes.") + @NotNull protected Asset draft; + + @Schema(description = "Contents of the selected configuration template file.") + @NotNull protected String document; + + @JsonCreator + public ConfigurationTemplateImportRequest( + @JsonProperty("draft") Asset draft, @JsonProperty("document") String document) { + this.draft = draft; + this.document = document; + } + + public Asset getDraft() { + return draft; + } + + public String getDocument() { + return document; + } +} diff --git a/model/src/main/java/org/openremote/model/util/ConfigurationTemplateUtil.java b/model/src/main/java/org/openremote/model/util/ConfigurationTemplateUtil.java new file mode 100644 index 0000000000..be86ae0fad --- /dev/null +++ b/model/src/main/java/org/openremote/model/util/ConfigurationTemplateUtil.java @@ -0,0 +1,270 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.util; + +import static org.openremote.model.asset.ConfigurationTemplateImportFailure.*; +import static org.openremote.model.value.MetaItemType.*; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import org.openremote.model.asset.Asset; +import org.openremote.model.asset.AttributeConfiguration; +import org.openremote.model.asset.AttributeConfigurationSummary; +import org.openremote.model.asset.AttributeConfigurationTypeMismatch; +import org.openremote.model.asset.ConfigurationTemplate; +import org.openremote.model.asset.ConfigurationTemplateImportPreview; +import org.openremote.model.attribute.Attribute; +import org.openremote.model.attribute.AttributeMap; +import org.openremote.model.attribute.MetaMap; +import org.openremote.model.value.MetaItemDescriptor; + +/** + * Reads and writes {@link ConfigurationTemplate}s. Callers are responsible for enforcing realm + * authorization and for persisting, or discarding, the patched attributes. + */ +public final class ConfigurationTemplateUtil { + + /** + * Metadata that stays with an asset rather than travel in a document, as it grants access or + * links attributes by asset id. Export leaves it out and import keeps that of the target. + */ + public static final Set ASSET_BOUND_META_ITEMS = + Set.of( + ACCESS_PUBLIC_READ.getName(), + ACCESS_PUBLIC_WRITE.getName(), + ACCESS_RESTRICTED_READ.getName(), + ACCESS_RESTRICTED_WRITE.getName(), + ATTRIBUTE_LINKS.getName()); + + private ConfigurationTemplateUtil() {} + + /** + * Builds the export document of an asset, holding the type and metadata of every attribute that + * has metadata other than {@link #ASSET_BOUND_META_ITEMS}. A null or empty selection exports all + * of them. + */ + public static ConfigurationTemplate export(Asset asset, Collection attributeNames) { + boolean selectAll = attributeNames == null || attributeNames.isEmpty(); + Map attributes = new TreeMap<>(); + + if (asset.getAttributes() != null) { + asset.getAttributes().values().stream() + .filter(attribute -> selectAll || attributeNames.contains(attribute.getName())) + .forEach( + attribute -> { + ObjectNode meta = withoutAssetBoundMeta(attribute.getMeta()); + + if (!meta.isEmpty()) { + attributes.put( + attribute.getName(), new AttributeConfiguration(typeName(attribute), meta)); + } + }); + } + + return new ConfigurationTemplate( + ConfigurationTemplate.CURRENT_VERSION, asset.getType(), attributes); + } + + /** + * Validates a document against an asset draft and works out what importing it would do, without + * changing the draft. The returned {@link + * ConfigurationTemplateImportPreview#getPatchedAttributes()} holds the draft attributes with the + * metadata of every importable attribute replaced, except for {@link #ASSET_BOUND_META_ITEMS}, + * which keep the values of the draft. + */ + public static ConfigurationTemplateImportPreview preview(Asset draft, String document) { + JsonNode root; + + try { + root = ValueUtil.JSON.readTree(document); + } catch (JsonProcessingException | IllegalArgumentException e) { + return ConfigurationTemplateImportPreview.failed(INVALID_JSON); + } + + if (root == null || !root.isObject()) { + return ConfigurationTemplateImportPreview.failed(INVALID_JSON); + } + + JsonNode version = root.get("version"); + JsonNode assetType = root.get("assetType"); + JsonNode attributes = root.get("attributes"); + + if (isAbsent(version) || isAbsent(assetType) || isAbsent(attributes)) { + return ConfigurationTemplateImportPreview.failed(MISSING_REQUIRED_FIELD); + } + if (!version.isIntegralNumber() || version.asInt() != ConfigurationTemplate.CURRENT_VERSION) { + return ConfigurationTemplateImportPreview.failed(UNSUPPORTED_VERSION); + } + if (!assetType.isTextual() || assetType.asText().isBlank()) { + return ConfigurationTemplateImportPreview.failed(MISSING_REQUIRED_FIELD); + } + if (!attributes.isObject()) { + return ConfigurationTemplateImportPreview.failed(MALFORMED_ATTRIBUTE_ENTRY); + } + + // The map key is the attribute name, so an entry's own name field, if any, is ignored + Map imported = new LinkedHashMap<>(); + for (Map.Entry entry : attributes.properties()) { + JsonNode type = entry.getValue().isObject() ? entry.getValue().get("type") : null; + JsonNode meta = entry.getValue().isObject() ? entry.getValue().get("meta") : null; + + if (type == null || !type.isTextual() || type.asText().isBlank()) { + return ConfigurationTemplateImportPreview.failed(MALFORMED_ATTRIBUTE_ENTRY); + } + if (meta == null || !meta.isObject()) { + return ConfigurationTemplateImportPreview.failed(MALFORMED_ATTRIBUTE_ENTRY); + } + + ObjectNode importedMeta = ((ObjectNode) meta).deepCopy(); + importedMeta.remove(ASSET_BOUND_META_ITEMS); + + if (!isValidMeta(importedMeta)) { + return ConfigurationTemplateImportPreview.failed(INVALID_META_ITEM); + } + + imported.put(entry.getKey(), new AttributeConfiguration(type.asText(), importedMeta)); + } + + AttributeMap targetAttributes = + draft.getAttributes() != null ? draft.getAttributes() : new AttributeMap(); + List importable = new ArrayList<>(); + List missing = new ArrayList<>(); + List typeMismatches = new ArrayList<>(); + + imported.forEach( + (name, configuration) -> { + Optional> target = targetAttributes.get(name); + + if (target.isEmpty()) { + missing.add(new AttributeConfigurationSummary(name, configuration.getType())); + return; + } + + String targetType = typeName(target.get()); + + if (!Objects.equals(configuration.getType(), targetType)) { + typeMismatches.add( + new AttributeConfigurationTypeMismatch(name, configuration.getType(), targetType)); + return; + } + + importable.add(new AttributeConfigurationSummary(name, configuration.getType())); + }); + + // Report what was skipped even when that leaves nothing to import + if (importable.isEmpty()) { + return new ConfigurationTemplateImportPreview( + NO_IMPORTABLE_ATTRIBUTES, importable, missing, typeMismatches, null); + } + + ObjectNode patchedAttributes = toObjectNode(targetAttributes); + importable.forEach( + attribute -> { + ObjectNode patched = (ObjectNode) patchedAttributes.get(attribute.getName()); + ObjectNode meta = imported.get(attribute.getName()).getMeta(); + JsonNode targetMeta = patched.get("meta"); + + if (targetMeta != null && targetMeta.isObject()) { + ASSET_BOUND_META_ITEMS.stream() + .filter(targetMeta::has) + .forEach(name -> meta.set(name, targetMeta.get(name))); + } + patched.set("meta", meta); + }); + + return new ConfigurationTemplateImportPreview( + null, importable, missing, typeMismatches, patchedAttributes); + } + + protected static boolean isAbsent(JsonNode node) { + return node == null || node.isNull(); + } + + protected static ObjectNode withoutAssetBoundMeta(MetaMap meta) { + ObjectNode node = meta != null ? toObjectNode(meta) : ValueUtil.JSON.createObjectNode(); + node.remove(ASSET_BOUND_META_ITEMS); + return node; + } + + /** + * Whether every metadata item with a known descriptor holds a value of its type that meets the + * bean constraints of that type. Unknown metadata items are taken as they are. + */ + protected static boolean isValidMeta(ObjectNode meta) { + for (Map.Entry item : meta.properties()) { + Optional> descriptor = ValueUtil.getMetaItemDescriptor(item.getKey()); + + if (descriptor.isEmpty() || item.getValue().isNull()) { + continue; + } + try { + Object value = + ValueUtil.JSON.treeToValue(item.getValue(), descriptor.get().getType().getType()); + + if (value != null + && (!ValueUtil.validate(value).isEmpty() || changesType(item.getValue(), value))) { + return false; + } + } catch (JsonProcessingException | IllegalArgumentException e) { + return false; + } + } + return true; + } + + /** + * Whether a value that names its subtype was read as another one, as happens to an agent link of + * a type that is not installed, which falls back to the default agent link. + */ + protected static boolean changesType(JsonNode node, Object value) { + JsonNode type = node.get("type"); + + if (type == null || !type.isTextual()) { + return false; + } + JsonNode readType = ValueUtil.JSON.valueToTree(value).get("type"); + return readType != null && !type.asText().equals(readType.asText()); + } + + protected static String typeName(Attribute attribute) { + return attribute.getType() != null ? attribute.getType().getName() : null; + } + + /** + * Serializes through text rather than a token buffer, so that attribute values held as raw JSON + * are materialized into the returned tree. + */ + protected static ObjectNode toObjectNode(Object value) { + try { + return (ObjectNode) ValueUtil.JSON.readTree(ValueUtil.JSON.writeValueAsString(value)); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Cannot serialize attribute configuration", e); + } + } +} diff --git a/model/src/test/groovy/org/openremote/model/util/ConfigurationTemplateUtilTest.groovy b/model/src/test/groovy/org/openremote/model/util/ConfigurationTemplateUtilTest.groovy new file mode 100644 index 0000000000..d8ad5b5293 --- /dev/null +++ b/model/src/test/groovy/org/openremote/model/util/ConfigurationTemplateUtilTest.groovy @@ -0,0 +1,268 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.util + +import org.openremote.model.asset.ConfigurationTemplate +import org.openremote.model.asset.impl.RoomAsset +import org.openremote.model.asset.impl.ThingAsset +import org.openremote.model.attribute.Attribute +import org.openremote.model.attribute.MetaItem +import org.openremote.model.value.MetaItemType +import org.openremote.model.value.ValueDescriptor +import org.openremote.model.value.ValueType +import spock.lang.Specification + +import static org.openremote.model.asset.ConfigurationTemplateImportFailure.* + +class ConfigurationTemplateUtilTest extends Specification { + + def setupSpec() { + ValueUtil.initialise(null) + } + + static def thingAsset(Map> attributes = [:]) { + def asset = new ThingAsset("Test Thing") + attributes.each { name, attribute -> asset.getAttributes().put(name, attribute) } + return asset + } + + static def attribute(String name, ValueDescriptor type, Map meta = null) { + def attribute = new Attribute<>(name, type) + meta?.each { metaName, value -> attribute.addMeta(new MetaItem<>(metaName, null, value)) } + return attribute + } + + static String document(String assetType = ThingAsset.DESCRIPTOR.name, Map entries = [:], Integer version = 1) { + def attributes = entries.collect { name, entry -> "\"${name}\": ${entry}" }.join(",") + def fields = [] + if (version != null) { + fields.add("\"version\": ${version}") + } + if (assetType != null) { + fields.add("\"assetType\": \"${assetType}\"") + } + fields.add("\"attributes\": {${attributes}}") + return "{${fields.join(",")}}" + } + + def "export holds the type and metadata of attributes that have metadata"() { + given: "an asset with configured and unconfigured attributes" + def asset = thingAsset([ + temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature", unknownMetaItem: [nested: 1]]), + plain : attribute("plain", ValueType.TEXT) + ]) + + when: "the configuration is exported" + def exported = ConfigurationTemplateUtil.export(asset, null) + + then: "the document is of the supported version and holds the asset type" + exported.version == ConfigurationTemplate.CURRENT_VERSION + exported.assetType == ThingAsset.DESCRIPTOR.name + + and: "only the attribute with metadata is held, with its type and its metadata as stored" + exported.attributes.keySet() == ["temperature"] as Set + exported.attributes.temperature.type == ValueType.NUMBER.name + exported.attributes.temperature.meta.get(MetaItemType.LABEL.name).asText() == "Temperature" + exported.attributes.temperature.meta.get("unknownMetaItem").get("nested").asInt() == 1 + } + + def "export limited to a selection holds only the selected attributes"() { + given: "an asset with two configured attributes" + def asset = thingAsset([ + temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature"]), + humidity : attribute("humidity", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Humidity"]) + ]) + + expect: "the selection is exported" + ConfigurationTemplateUtil.export(asset, ["humidity"]).attributes.keySet() == ["humidity"] as Set + } + + def "a document that cannot be read is rejected with #failure"() { + given: "an asset with a configured attribute" + def asset = thingAsset([temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature"])]) + + expect: "the import is rejected" + ConfigurationTemplateUtil.preview(asset, documentText).failure == failure + + where: + failure | documentText + INVALID_JSON | "not json at all" + INVALID_JSON | "[]" + INVALID_JSON | null + MISSING_REQUIRED_FIELD | document(ThingAsset.DESCRIPTOR.name, [temperature: '{"type": "number", "meta": {}}'], null) + MISSING_REQUIRED_FIELD | document(null, [temperature: '{"type": "number", "meta": {}}']) + MISSING_REQUIRED_FIELD | '{"version": 1, "assetType": "ThingAsset"}' + UNSUPPORTED_VERSION | document(ThingAsset.DESCRIPTOR.name, [temperature: '{"type": "number", "meta": {}}'], 2) + UNSUPPORTED_VERSION | document(ThingAsset.DESCRIPTOR.name, [temperature: '{"type": "number", "meta": {}}'], 0) + MALFORMED_ATTRIBUTE_ENTRY | '{"version": 1, "assetType": "ThingAsset", "attributes": 5}' + MALFORMED_ATTRIBUTE_ENTRY | document(ThingAsset.DESCRIPTOR.name, [temperature: '5']) + MALFORMED_ATTRIBUTE_ENTRY | document(ThingAsset.DESCRIPTOR.name, [temperature: '{"meta": {}}']) + MALFORMED_ATTRIBUTE_ENTRY | document(ThingAsset.DESCRIPTOR.name, [temperature: '{"type": "number"}']) + MALFORMED_ATTRIBUTE_ENTRY | document(ThingAsset.DESCRIPTOR.name, [temperature: '{"type": "number", "meta": 5}']) + INVALID_META_ITEM | document(ThingAsset.DESCRIPTOR.name, [temperature: '{"type": "number", "meta": {"readOnly": {"not": "a boolean"}}}']) + INVALID_META_ITEM | document(ThingAsset.DESCRIPTOR.name, [temperature: '{"type": "number", "meta": {"constraints": [{"type": "noSuchConstraint"}]}}']) + INVALID_META_ITEM | document(ThingAsset.DESCRIPTOR.name, [temperature: '{"type": "number", "meta": {"agentLink": {"type": "NoSuchAgentLink", "id": "agentId"}}}']) + NO_IMPORTABLE_ATTRIBUTES | document(ThingAsset.DESCRIPTOR.name, [:]) + } + + def "export leaves out metadata that grants access or links attributes"() { + given: "an asset whose attributes hold access and attribute link metadata" + def asset = thingAsset([ + temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature", (MetaItemType.ACCESS_PUBLIC_WRITE.name): true]), + linked: attribute("linked", ValueType.NUMBER, [(MetaItemType.ACCESS_RESTRICTED_READ.name): true, (MetaItemType.ATTRIBUTE_LINKS.name): []]) + ]) + + when: "the configuration is exported" + def exported = ConfigurationTemplateUtil.export(asset, null) + + then: "that metadata is left out" + exported.attributes.temperature.meta.fieldNames().toList() == [MetaItemType.LABEL.name] + + and: "an attribute holding no other metadata is left out entirely" + exported.attributes.keySet() == ["temperature"] as Set + } + + def "import keeps the access and attribute link metadata of the asset and ignores that of the document"() { + given: "an asset whose attribute is publicly readable" + def asset = thingAsset([temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature", (MetaItemType.ACCESS_PUBLIC_READ.name): true])]) + + when: "a document granting public write access is previewed" + def preview = ConfigurationTemplateUtil.preview(asset, document(ThingAsset.DESCRIPTOR.name, [ + temperature: '{"type": "number", "meta": {"label": "Imported", "accessPublicWrite": true, "attributeLinks": []}}' + ])) + + then: "the other metadata is imported" + def meta = preview.patchedAttributes.get("temperature").get("meta") + meta.get(MetaItemType.LABEL.name).asText() == "Imported" + + and: "the access and attribute link metadata of the document is ignored and that of the asset kept" + !meta.has(MetaItemType.ACCESS_PUBLIC_WRITE.name) + !meta.has(MetaItemType.ATTRIBUTE_LINKS.name) + meta.get(MetaItemType.ACCESS_PUBLIC_READ.name).asBoolean() + } + + def "an attribute missing on the target asset is skipped and reported"() { + given: "an asset without the imported attribute" + def asset = thingAsset([temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature"])]) + + when: "a document holding a known and an unknown attribute is previewed" + def preview = ConfigurationTemplateUtil.preview(asset, document(ThingAsset.DESCRIPTOR.name, [ + temperature: '{"type": "number", "meta": {"label": "Imported"}}', + humidity : '{"type": "number", "meta": {"label": "Imported"}}' + ])) + + then: "the unknown attribute is reported as missing and is not patched" + preview.failure == null + preview.missingAttributes*.name == ["humidity"] + preview.importableAttributes*.name == ["temperature"] + !preview.patchedAttributes.has("humidity") + } + + def "an attribute whose type differs is skipped and reported"() { + given: "an asset holding the imported attribute as text" + def asset = thingAsset([temperature: attribute("temperature", ValueType.TEXT, [(MetaItemType.LABEL.name): "Temperature"])]) + + when: "a document holding the attribute as a number is previewed" + def preview = ConfigurationTemplateUtil.preview(asset, document(ThingAsset.DESCRIPTOR.name, [ + temperature: '{"type": "number", "meta": {"label": "Imported"}}' + ])) + + then: "nothing can be imported, with the mismatch reported" + preview.failure == NO_IMPORTABLE_ATTRIBUTES + preview.typeMismatches*.name == ["temperature"] + preview.typeMismatches*.importedType == [ValueType.NUMBER.name] + preview.typeMismatches*.targetType == [ValueType.TEXT.name] + } + + def "type comparison is case sensitive"() { + given: "an asset holding a number attribute" + def asset = thingAsset([temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature"])]) + + when: "a document spelling the same type differently is previewed" + def preview = ConfigurationTemplateUtil.preview(asset, document(ThingAsset.DESCRIPTOR.name, [ + temperature: '{"type": "Number", "meta": {"label": "Imported"}}', + humidity : '{"type": "number", "meta": {"label": "Imported"}}' + ])) + + then: "the differently spelled type is a mismatch rather than a match" + preview.typeMismatches*.name == ["temperature"] + preview.typeMismatches*.importedType == ["Number"] + preview.typeMismatches*.targetType == [ValueType.NUMBER.name] + } + + def "a document exported from another asset type can still be imported"() { + given: "a thing asset holding a configured attribute" + def asset = thingAsset([temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature"])]) + + when: "a document exported from a room asset is previewed" + def preview = ConfigurationTemplateUtil.preview(asset, document(RoomAsset.DESCRIPTOR.name, [ + temperature: '{"type": "number", "meta": {"label": "Imported"}}' + ])) + + then: "the attribute is importable" + preview.failure == null + preview.importableAttributes*.name == ["temperature"] + } + + def "the metadata of an importable attribute is replaced as a whole"() { + given: "an asset whose attribute holds metadata that the document does not" + def asset = thingAsset([ + temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature", (MetaItemType.READ_ONLY.name): true]), + plain : attribute("plain", ValueType.TEXT) + ]) + asset.getAttributes().get("temperature").get().setValue(21.5d) + + when: "a document holding other metadata for that attribute is previewed" + def preview = ConfigurationTemplateUtil.preview(asset, document(ThingAsset.DESCRIPTOR.name, [ + temperature: '{"type": "number", "meta": {"unitType": ["celsius"]}}', + plain : '{"type": "text", "meta": {"label": "Plain"}}' + ])) + + then: "the imported metadata replaces the existing metadata entirely" + def patchedTemperature = preview.patchedAttributes.get("temperature") + patchedTemperature.get("meta").get("unitType").get(0).asText() == "celsius" + !patchedTemperature.get("meta").has(MetaItemType.LABEL.name) + !patchedTemperature.get("meta").has(MetaItemType.READ_ONLY.name) + + and: "an attribute without metadata gets the imported metadata added" + preview.patchedAttributes.get("plain").get("meta").get("label").asText() == "Plain" + + and: "the type and the value of the attribute are untouched" + patchedTemperature.get("type").asText() == ValueType.NUMBER.name + patchedTemperature.get("value").asDouble() == 21.5d + + and: "the asset itself is not modified" + asset.getAttributes().get("temperature").get().getMeta().has(MetaItemType.LABEL.name) + !asset.getAttributes().get("plain").get().getMeta().has("label") + } + + def "an internal name field within an entry is ignored"() { + given: "an asset holding a configured attribute" + def asset = thingAsset([temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature"])]) + + when: "a document whose entry names another attribute is previewed" + def preview = ConfigurationTemplateUtil.preview(asset, document(ThingAsset.DESCRIPTOR.name, [ + temperature: '{"name": "somethingElse", "type": "number", "meta": {"label": "Imported"}}' + ])) + + then: "the map key decides which attribute is imported" + preview.importableAttributes*.name == ["temperature"] + preview.patchedAttributes.get("temperature").get("meta").get("label").asText() == "Imported" + } +} diff --git a/setup/build.gradle b/setup/build.gradle index 09c58f1dd0..d94440f904 100644 --- a/setup/build.gradle +++ b/setup/build.gradle @@ -10,6 +10,7 @@ base { sourceSets { clustering + create("config-template") datetime demo integration @@ -21,6 +22,7 @@ sourceSets { // dependencies declaration configurations { clusteringImplementation.extendsFrom(implementation) + configTemplateImplementation.extendsFrom(implementation) datetimeImplementation.extendsFrom(implementation) demoImplementation.extendsFrom(implementation) integrationImplementation.extendsFrom(implementation) @@ -46,6 +48,11 @@ tasks.register('clusteringJar', Jar) { from sourceSets.clustering.output } +tasks.register('config-templateJar', Jar) { + archiveBaseName.set("openremote-config-template-${project.name}") + from sourceSets.getByName("config-template").output +} + tasks.register('datetimeJar', Jar) { archiveBaseName.set("openremote-datetime-${project.name}") from sourceSets.datetime.output diff --git a/setup/src/config-template/java/org/openremote/setup/configtemplate/ManagerConfigTemplateSetup.java b/setup/src/config-template/java/org/openremote/setup/configtemplate/ManagerConfigTemplateSetup.java new file mode 100644 index 0000000000..f976cfd2a5 --- /dev/null +++ b/setup/src/config-template/java/org/openremote/setup/configtemplate/ManagerConfigTemplateSetup.java @@ -0,0 +1,243 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.setup.configtemplate; + +import static org.openremote.model.Constants.*; +import static org.openremote.model.value.MetaItemType.*; +import static org.openremote.model.value.ValueType.*; + +import org.openremote.agent.protocol.bluetooth.mesh.BluetoothMeshAgent; +import org.openremote.agent.protocol.bluetooth.mesh.BluetoothMeshAgentLink; +import org.openremote.agent.protocol.http.HTTPAgent; +import org.openremote.agent.protocol.http.HTTPAgentLink; +import org.openremote.agent.protocol.knx.KNXAgent; +import org.openremote.agent.protocol.knx.KNXAgentLink; +import org.openremote.agent.protocol.mail.MailAgent; +import org.openremote.agent.protocol.mail.MailAgentLink; +import org.openremote.agent.protocol.modbus.ModbusAgentLink; +import org.openremote.agent.protocol.modbus.ModbusAgentLink.ModbusDataType; +import org.openremote.agent.protocol.modbus.ModbusAgentLink.ReadMemoryArea; +import org.openremote.agent.protocol.modbus.ModbusTcpAgent; +import org.openremote.agent.protocol.mqtt.MQTTAgent; +import org.openremote.agent.protocol.mqtt.MQTTAgentLink; +import org.openremote.agent.protocol.openweathermap.OpenWeatherMapAgent; +import org.openremote.agent.protocol.openweathermap.OpenWeatherMapAgentLink; +import org.openremote.agent.protocol.openweathermap.OpenWeatherMapProperty; +import org.openremote.agent.protocol.simulator.SimulatorAgent; +import org.openremote.agent.protocol.simulator.SimulatorAgentLink; +import org.openremote.agent.protocol.snmp.SNMPAgent; +import org.openremote.agent.protocol.snmp.SNMPAgentLink; +import org.openremote.agent.protocol.tcp.TCPAgent; +import org.openremote.agent.protocol.velbus.VelbusAgentLink; +import org.openremote.agent.protocol.velbus.VelbusTCPAgent; +import org.openremote.agent.protocol.websocket.WebsocketAgent; +import org.openremote.agent.protocol.websocket.WebsocketAgentLink; +import org.openremote.agent.protocol.zwave.ZWaveAgent; +import org.openremote.agent.protocol.zwave.ZWaveAgentLink; +import org.openremote.manager.setup.ManagerSetup; +import org.openremote.model.Constants; +import org.openremote.model.Container; +import org.openremote.model.asset.agent.Agent; +import org.openremote.model.asset.agent.AgentLink; +import org.openremote.model.asset.agent.DefaultAgentLink; +import org.openremote.model.asset.impl.ThingAsset; +import org.openremote.model.attribute.Attribute; +import org.openremote.model.attribute.MetaItem; +import org.openremote.model.value.ValueConstraint; +import org.openremote.model.value.ValueDescriptor; +import org.openremote.setup.configtemplate.model.PumpAsset; + +/** + * Seeds one configured pump and three targets to copy its configuration onto, so exporting and + * importing a configuration template can be tried end to end. Every target shows a different + * outcome: a full import, an import that skips attributes, and an import from another asset type. + * The pump's agent links follow the template parameter concept: three Modbus links share an agent + * and two of them a unit id, and two simulator links use different agents. A separate asset holds + * one attribute per agent link type, and another holds every agent. + */ +public class ManagerConfigTemplateSetup extends ManagerSetup { + + protected ThingAsset agentsParent; + + public ManagerConfigTemplateSetup(Container container) { + super(container); + } + + @Override + public void onStart() throws Exception { + super.onStart(); + + // Not a group asset, as those only hold one asset type + agentsParent = new ThingAsset("Agents"); + agentsParent.setRealm(MASTER_REALM); + agentsParent = assetStorageService.merge(agentsParent); + + // Two simulator agents, so the import asks for the agent of each simulator link separately + String simulator1AgentId = store(new SimulatorAgent("Pump Agent 1 (Simulator)")); + String simulator2AgentId = store(new SimulatorAgent("Pump Agent 2 (Simulator)")); + + // An agent of every other agent link type, disabled so none of them connects + String bluetoothMeshAgentId = storeDisabled(new BluetoothMeshAgent("Bluetooth Mesh Agent")); + String httpAgentId = storeDisabled(new HTTPAgent("HTTP Agent")); + String knxAgentId = storeDisabled(new KNXAgent("KNX Agent")); + String mailAgentId = storeDisabled(new MailAgent("Mail Agent")); + String modbusAgentId = storeDisabled(new ModbusTcpAgent("Modbus TCP Agent")); + String mqttAgentId = storeDisabled(new MQTTAgent("MQTT Agent")); + String openWeatherMapAgentId = storeDisabled(new OpenWeatherMapAgent("OpenWeatherMap Agent")); + String snmpAgentId = storeDisabled(new SNMPAgent("SNMP Agent")); + String tcpAgentId = storeDisabled(new TCPAgent("TCP Agent")); + String velbusAgentId = storeDisabled(new VelbusTCPAgent("Velbus TCP Agent")); + String websocketAgentId = storeDisabled(new WebsocketAgent("Websocket Agent")); + String zWaveAgentId = storeDisabled(new ZWaveAgent("Z-Wave Agent")); + + // The pump to export from; every attribute holds configuration items worth copying + PumpAsset configured = new PumpAsset("Pump 1 (configured)"); + configured.setRealm(MASTER_REALM); + configured.addOrReplaceAttributes( + new Attribute<>(PumpAsset.FLOW_RATE) + .addOrReplaceMeta( + new MetaItem<>(LABEL, "Flow rate"), + new MetaItem<>( + UNITS, Constants.units(UNITS_METRE, UNITS_CUBED, UNITS_PER, UNITS_HOUR)), + new MetaItem<>(READ_ONLY, true), + new MetaItem<>(STORE_DATA_POINTS, true), + new MetaItem<>(RULE_STATE, true), + new MetaItem<>( + AGENT_LINK, + modbusLink(modbusAgentId, 1, ReadMemoryArea.HOLDING, ModbusDataType.REAL, 1))), + new Attribute<>(PumpAsset.PRESSURE) + .addOrReplaceMeta( + new MetaItem<>(LABEL, "Pressure"), + new MetaItem<>(UNITS, Constants.units(UNITS_BAR)), + new MetaItem<>( + CONSTRAINTS, + ValueConstraint.constraints( + new ValueConstraint.Min(0), new ValueConstraint.Max(16))), + new MetaItem<>(STORE_DATA_POINTS, true), + new MetaItem<>( + AGENT_LINK, + modbusLink(modbusAgentId, 1, ReadMemoryArea.HOLDING, ModbusDataType.REAL, 3))), + new Attribute<>(PumpAsset.RUNNING) + .addOrReplaceMeta( + new MetaItem<>(LABEL, "Running"), + new MetaItem<>(ACCESS_PUBLIC_READ, true), + new MetaItem<>( + AGENT_LINK, + modbusLink(modbusAgentId, 2, ReadMemoryArea.COIL, ModbusDataType.BOOL, 1))), + // Not a descriptor attribute, so a target asset can be missing it + new Attribute<>("errorCode", TEXT) + .addOrReplaceMeta( + new MetaItem<>(LABEL, "Error code"), + new MetaItem<>(RULE_STATE, true), + new MetaItem<>(AGENT_LINK, new SimulatorAgentLink(simulator1AgentId))), + // Not a descriptor attribute, so a target asset can hold it with another value type + new Attribute<>("serialNumber", TEXT) + .addOrReplaceMeta( + new MetaItem<>(LABEL, "Serial number"), + new MetaItem<>(READ_ONLY, true), + new MetaItem<>(AGENT_LINK, new SimulatorAgentLink(simulator2AgentId)))); + assetStorageService.merge(configured); + + // Holds every attribute of the configured pump, so the whole configuration can be imported + PumpAsset bare = new PumpAsset("Pump 2 (import target)"); + bare.setRealm(MASTER_REALM); + bare.addOrReplaceAttributes( + new Attribute<>("errorCode", TEXT), new Attribute<>("serialNumber", TEXT)); + assetStorageService.merge(bare); + + // Misses errorCode and holds serialNumber as a number, so the import reports both as skipped + PumpAsset partial = new PumpAsset("Pump 3 (partially compatible)"); + partial.setRealm(MASTER_REALM); + partial.addOrReplaceAttributes(new Attribute<>("serialNumber", NUMBER)); + assetStorageService.merge(partial); + + // Another asset type, sharing only some attributes with the pump + ThingAsset otherType = new ThingAsset("Mixer 1 (other asset type)"); + otherType.setRealm(MASTER_REALM); + otherType.addOrReplaceAttributes( + new Attribute<>("flowRate", NUMBER), new Attribute<>("pressure", NUMBER)); + assetStorageService.merge(otherType); + + // One attribute per agent link type; "(requirements)" marks links with fields that must be set + ThingAsset agentLinks = new ThingAsset("Agent links (all types)"); + agentLinks.setRealm(MASTER_REALM); + agentLinks.addOrReplaceAttributes( + linked( + "bluetoothMesh", + BOOLEAN, + "Bluetooth mesh (requirements)", + new BluetoothMeshAgentLink(bluetoothMeshAgentId, 0, "0002", "GenericOnOffServer")), + linked("default", TEXT, "Default", new DefaultAgentLink(tcpAgentId)), + linked("http", TEXT, "HTTP (requirements)", new HTTPAgentLink(httpAgentId)), + linked( + "knx", + BOOLEAN, + "KNX (requirements)", + new KNXAgentLink(knxAgentId, "1.001", "1/0/1", "1/0/2")), + linked("mail", TEXT, "Mail", new MailAgentLink(mailAgentId)), + linked("modbus", NUMBER, "Modbus", new ModbusAgentLink(modbusAgentId)), + linked("mqtt", TEXT, "MQTT", new MQTTAgentLink(mqttAgentId)), + linked( + "openWeatherMap", + NUMBER, + "OpenWeatherMap (requirements)", + new OpenWeatherMapAgentLink(openWeatherMapAgentId) + .setWeatherProperty(OpenWeatherMapProperty.TEMPERATURE)), + linked("simulator", NUMBER, "Simulator", new SimulatorAgentLink(simulator1AgentId)), + linked( + "snmp", + TEXT, + "SNMP (requirements)", + new SNMPAgentLink(snmpAgentId, "1.3.6.1.2.1.1.3.0")), + linked( + "velbus", + BOOLEAN, + "Velbus (requirements)", + new VelbusAgentLink(velbusAgentId, 48, "CH1")), + linked("websocket", TEXT, "Websocket", new WebsocketAgentLink(websocketAgentId)), + linked("zWave", BOOLEAN, "Z-Wave", new ZWaveAgentLink(zWaveAgentId, 2, 0, "Basic"))); + assetStorageService.merge(agentLinks); + } + + protected String store(Agent agent) { + agent.setParent(agentsParent); + return assetStorageService.merge(agent).getId(); + } + + protected String storeDisabled(Agent agent) { + agent.setDisabled(true); + return store(agent); + } + + protected static ModbusAgentLink modbusLink( + String agentId, int unitId, ReadMemoryArea area, ModbusDataType type, int address) { + ModbusAgentLink link = new ModbusAgentLink(agentId); + link.setUnitId(unitId); + link.setReadMemoryArea(area); + link.setReadValueType(type); + link.setReadAddress(address); + return link; + } + + protected static Attribute linked( + String name, ValueDescriptor type, String label, AgentLink agentLink) { + return new Attribute<>(name, type) + .addOrReplaceMeta(new MetaItem<>(LABEL, label), new MetaItem<>(AGENT_LINK, agentLink)); + } +} diff --git a/setup/src/config-template/java/org/openremote/setup/configtemplate/SetupTasks.java b/setup/src/config-template/java/org/openremote/setup/configtemplate/SetupTasks.java new file mode 100644 index 0000000000..2ca980ffba --- /dev/null +++ b/setup/src/config-template/java/org/openremote/setup/configtemplate/SetupTasks.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.setup.configtemplate; + +import java.util.List; +import org.openremote.model.Container; +import org.openremote.model.setup.Setup; + +/** Setup tasks of the config template demo. */ +public class SetupTasks implements org.openremote.model.setup.SetupTasks { + + @Override + public List createTasks(Container container, String setupType, boolean keycloakEnabled) { + return List.of(new ManagerConfigTemplateSetup(container)); + } +} diff --git a/ui/component/or-map/src/controls/styles.ts b/setup/src/config-template/java/org/openremote/setup/configtemplate/model/ConfigTemplateAssetModelProvider.java similarity index 69% rename from ui/component/or-map/src/controls/styles.ts rename to setup/src/config-template/java/org/openremote/setup/configtemplate/model/ConfigTemplateAssetModelProvider.java index 97067430b5..62c670e154 100644 --- a/ui/component/or-map/src/controls/styles.ts +++ b/setup/src/config-template/java/org/openremote/setup/configtemplate/model/ConfigTemplateAssetModelProvider.java @@ -16,14 +16,15 @@ * * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { css } from "lit"; +package org.openremote.setup.configtemplate.model; -export const countBadgeStyle = css` - or-vaadin-badge { - --vaadin-badge-background: var(--shades-contrast-10, #3a463a0d); - border-radius: calc(var(--lumo-border-radius-m) + 2px); - font-size: 12px; - color: var(--lumo-secondary-text-color); - padding: 0 calc(var(--lumo-space-m) - 4px); +import org.openremote.model.AssetModelProvider; + +/** Adds the asset types of this source set to the asset model. */ +public class ConfigTemplateAssetModelProvider implements AssetModelProvider { + + @Override + public boolean useAutoScan() { + return true; } -`; +} diff --git a/setup/src/config-template/java/org/openremote/setup/configtemplate/model/PumpAsset.java b/setup/src/config-template/java/org/openremote/setup/configtemplate/model/PumpAsset.java new file mode 100644 index 0000000000..3a021ebb30 --- /dev/null +++ b/setup/src/config-template/java/org/openremote/setup/configtemplate/model/PumpAsset.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.setup.configtemplate.model; + +import jakarta.persistence.Entity; +import org.openremote.model.asset.Asset; +import org.openremote.model.asset.AssetDescriptor; +import org.openremote.model.value.AttributeDescriptor; +import org.openremote.model.value.ValueType; + +/** + * Asset type of the config template demo. The descriptors deliberately carry no metadata, so a new + * pump starts without configuration items and is an empty target for an import. + */ +@Entity +public class PumpAsset extends Asset { + + public static final AttributeDescriptor FLOW_RATE = + new AttributeDescriptor<>("flowRate", ValueType.NUMBER); + public static final AttributeDescriptor PRESSURE = + new AttributeDescriptor<>("pressure", ValueType.NUMBER); + public static final AttributeDescriptor RUNNING = + new AttributeDescriptor<>("running", ValueType.BOOLEAN); + + public static final AssetDescriptor DESCRIPTOR = + new AssetDescriptor<>("water-pump", "1565c0", PumpAsset.class); + + /** For use by hydrators (i.e. JPA/Jackson) */ + protected PumpAsset() {} + + public PumpAsset(String name) { + super(name); + } +} diff --git a/setup/src/config-template/resources/META-INF/services/org.openremote.model.AssetModelProvider b/setup/src/config-template/resources/META-INF/services/org.openremote.model.AssetModelProvider new file mode 100644 index 0000000000..4110715f93 --- /dev/null +++ b/setup/src/config-template/resources/META-INF/services/org.openremote.model.AssetModelProvider @@ -0,0 +1 @@ +org.openremote.setup.configtemplate.model.ConfigTemplateAssetModelProvider diff --git a/setup/src/config-template/resources/META-INF/services/org.openremote.model.setup.SetupTasks b/setup/src/config-template/resources/META-INF/services/org.openremote.model.setup.SetupTasks new file mode 100644 index 0000000000..40fcf9fdd2 --- /dev/null +++ b/setup/src/config-template/resources/META-INF/services/org.openremote.model.setup.SetupTasks @@ -0,0 +1 @@ +org.openremote.setup.configtemplate.SetupTasks diff --git a/test/src/test/groovy/org/openremote/test/assets/ConfigurationTemplateTest.groovy b/test/src/test/groovy/org/openremote/test/assets/ConfigurationTemplateTest.groovy new file mode 100644 index 0000000000..f168dfaf17 --- /dev/null +++ b/test/src/test/groovy/org/openremote/test/assets/ConfigurationTemplateTest.groovy @@ -0,0 +1,302 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.test.assets + +import jakarta.ws.rs.ForbiddenException +import jakarta.ws.rs.WebApplicationException +import org.openremote.manager.setup.SetupService +import org.openremote.model.asset.AssetResource +import org.openremote.model.asset.ConfigurationTemplateImportPreview +import org.openremote.model.asset.ConfigurationTemplateImportRequest +import org.openremote.model.asset.impl.RoomAsset +import org.openremote.model.asset.impl.ThingAsset +import org.openremote.model.attribute.Attribute +import org.openremote.model.attribute.MetaItem +import org.openremote.model.util.ValueUtil +import org.openremote.model.value.MetaItemType +import org.openremote.model.value.ValueType +import org.openremote.setup.integration.KeycloakTestSetup +import org.openremote.setup.integration.ManagerTestSetup +import org.openremote.test.ManagerContainerTrait +import spock.lang.Specification + +import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST +import static jakarta.ws.rs.core.Response.Status.CONFLICT +import static jakarta.ws.rs.core.Response.Status.FORBIDDEN +import static jakarta.ws.rs.core.Response.Status.NOT_FOUND +import static org.openremote.manager.security.ManagerIdentityProvider.OR_ADMIN_PASSWORD +import static org.openremote.manager.security.ManagerIdentityProvider.OR_ADMIN_PASSWORD_DEFAULT +import static org.openremote.model.Constants.* +import static org.openremote.model.asset.ConfigurationTemplateImportFailure.* +import static org.openremote.model.util.MapAccess.getString + +class ConfigurationTemplateTest extends Specification implements ManagerContainerTrait { + + def "Export and import a configuration template as superuser"() { + given: "the server container is started" + def container = startContainer(defaultConfig(), defaultServices()) + def keycloakTestSetup = container.getService(SetupService.class).getTaskOfType(KeycloakTestSetup.class) + + and: "an authenticated admin user" + def accessToken = authenticate( + container, + MASTER_REALM, + KEYCLOAK_CLIENT_ID, + MASTER_REALM_ADMIN_USER, + getString(container.getConfig(), OR_ADMIN_PASSWORD, OR_ADMIN_PASSWORD_DEFAULT) + ) + + and: "the asset resource" + def assetResource = getClientApiTarget(serverUri(serverPort), MASTER_REALM, accessToken).proxy(AssetResource.class) + + and: "a configured source asset and a target asset of the same type" + def source = assetResource.create(null, new ThingAsset("Configuration source") + .setRealm(keycloakTestSetup.realmMaster.name) + .addOrReplaceAttributes( + new Attribute<>("temperature", ValueType.NUMBER) + .addMeta(new MetaItem<>(MetaItemType.LABEL, "Source temperature")) + .addMeta(new MetaItem<>(MetaItemType.READ_ONLY, true)), + new Attribute<>("customValue", ValueType.TEXT) + .addMeta(new MetaItem<>(MetaItemType.LABEL, "Source custom value")), + new Attribute<>("plain", ValueType.TEXT) + )) + def target = assetResource.create(null, new ThingAsset("Configuration target") + .setRealm(keycloakTestSetup.realmMaster.name) + .addOrReplaceAttributes( + new Attribute<>("temperature", ValueType.NUMBER, 21.5d) + .addMeta(new MetaItem<>(MetaItemType.LABEL, "Target temperature")), + new Attribute<>("customValue", ValueType.NUMBER), + new Attribute<>("plain", ValueType.TEXT) + )) + + when: "a configuration template of the source asset is exported" + def document = assetResource.exportConfigurationTemplate(null, source.id, []) + + then: "only the attributes holding metadata are exported, with their type and metadata" + document.version == 1 + document.assetType == ThingAsset.DESCRIPTOR.name + document.attributes.keySet() == ["temperature", "customValue"] as Set + document.attributes.temperature.type == ValueType.NUMBER.name + document.attributes.temperature.meta.get(MetaItemType.LABEL.name).asText() == "Source temperature" + document.attributes.temperature.meta.get(MetaItemType.READ_ONLY.name).asBoolean() + + when: "a subset of the attributes is exported" + def subset = assetResource.exportConfigurationTemplate(null, source.id, ["temperature"]) + + then: "only the requested attribute is exported" + subset.attributes.keySet() == ["temperature"] as Set + + when: "only an attribute without metadata is selected" + assetResource.exportConfigurationTemplate(null, source.id, ["plain"]) + + then: "the export is rejected as there is nothing to export" + WebApplicationException nothingToExport = thrown() + nothingToExport.response.withCloseable { r -> + assert r.status == BAD_REQUEST.statusCode + return true + } + + when: "the exported document is imported into the target asset" + def preview = assetResource.previewConfigurationTemplateImport(null, target.id, + new ConfigurationTemplateImportRequest(target, ValueUtil.asJSON(document).orElseThrow())) + + then: "the attribute with a matching type is importable and the one with another type is reported" + preview.failure == null + preview.importableAttributes*.name == ["temperature"] + preview.typeMismatches*.name == ["customValue"] + preview.typeMismatches*.importedType == [ValueType.TEXT.name] + preview.typeMismatches*.targetType == [ValueType.NUMBER.name] + + and: "the patched attributes hold the imported metadata, keeping the attribute value" + def patched = preview.patchedAttributes.get("temperature") + patched.get("meta").get(MetaItemType.LABEL.name).asText() == "Source temperature" + patched.get("meta").get(MetaItemType.READ_ONLY.name).asBoolean() + patched.get("value").asDouble() == 21.5d + + and: "the attributes that are not imported are returned untouched" + preview.patchedAttributes.get("customValue").get("type").asText() == ValueType.NUMBER.name + + and: "the target asset is not stored by the preview" + assetResource.get(null, target.id).getAttribute("temperature").get().getMeta() + .getValue(MetaItemType.LABEL.name, String.class).orElse(null) == "Target temperature" + + when: "a document of an unsupported version is imported" + assetResource.previewConfigurationTemplateImport(null, target.id, + new ConfigurationTemplateImportRequest(target, '{"version": 2, "assetType": "ThingAsset", "attributes": {}}')) + + then: "the import is rejected with the reason" + WebApplicationException ex = thrown() + ex.response.withCloseable { r -> + assert r.status == BAD_REQUEST.statusCode + assert r.readEntity(ConfigurationTemplateImportPreview.class).failure == UNSUPPORTED_VERSION + return true + } + + when: "a document holding no attribute of the target asset is imported" + assetResource.previewConfigurationTemplateImport(null, target.id, + new ConfigurationTemplateImportRequest(target, '{"version": 1, "assetType": "ThingAsset", "attributes": {"absent": {"type": "text", "meta": {"label": "Absent"}}}}')) + + then: "the import is rejected and the skipped attribute is reported" + WebApplicationException noneImportable = thrown() + noneImportable.response.withCloseable { r -> + assert r.status == BAD_REQUEST.statusCode + def rejected = r.readEntity(ConfigurationTemplateImportPreview.class) + assert rejected.failure == NO_IMPORTABLE_ATTRIBUTES + assert rejected.missingAttributes*.name == ["absent"] + return true + } + + when: "the draft of another asset is submitted" + assetResource.previewConfigurationTemplateImport(null, target.id, + new ConfigurationTemplateImportRequest(source, ValueUtil.asJSON(document).orElseThrow())) + + then: "the import conflicts with the target asset" + WebApplicationException draftMismatch = thrown() + draftMismatch.response.withCloseable { r -> + assert r.status == CONFLICT.statusCode + assert r.readEntity(ConfigurationTemplateImportPreview.class).failure == DRAFT_MISMATCH + return true + } + + when: "no draft is submitted" + assetResource.previewConfigurationTemplateImport(null, target.id, + new ConfigurationTemplateImportRequest(null, ValueUtil.asJSON(document).orElseThrow())) + + then: "the request is rejected as invalid rather than as a conflict" + WebApplicationException noDraft = thrown() + noDraft.response.withCloseable { r -> + assert r.status == BAD_REQUEST.statusCode + return true + } + } + + def "Configuration template import requires asset write access"() { + given: "the server container is started" + def container = startContainer(defaultConfig(), defaultServices()) + def managerTestSetup = container.getService(SetupService.class).getTaskOfType(ManagerTestSetup.class) + def keycloakTestSetup = container.getService(SetupService.class).getTaskOfType(KeycloakTestSetup.class) + + and: "an authenticated user that can read but not write assets" + def accessToken = authenticate( + container, + keycloakTestSetup.realmBuilding.name, + KEYCLOAK_CLIENT_ID, + "testuser2", + "testuser2" + ) + + and: "the asset resource" + def assetResource = getClientApiTarget(serverUri(serverPort), keycloakTestSetup.realmBuilding.name, accessToken) + .proxy(AssetResource.class) + + when: "a configuration template of an asset of the authenticated realm is exported" + def document = assetResource.exportConfigurationTemplate(null, managerTestSetup.apartment1LivingroomId, []) + + then: "the configuration is returned" + document.version == 1 + !document.attributes.isEmpty() + + when: "the same user previews an import" + assetResource.previewConfigurationTemplateImport(null, managerTestSetup.apartment1LivingroomId, + new ConfigurationTemplateImportRequest( + assetResource.get(null, managerTestSetup.apartment1LivingroomId), + ValueUtil.asJSON(document).orElseThrow())) + + then: "access is forbidden" + ForbiddenException forbidden = thrown() + forbidden.response.withCloseable { r -> + assert r.status == FORBIDDEN.statusCode + return true + } + } + + def "Configuration template import is rejected for assets the user cannot access"() { + given: "the server container is started" + def container = startContainer(defaultConfig(), defaultServices()) + def managerTestSetup = container.getService(SetupService.class).getTaskOfType(ManagerTestSetup.class) + def keycloakTestSetup = container.getService(SetupService.class).getTaskOfType(KeycloakTestSetup.class) + def buildingRealm = keycloakTestSetup.realmBuilding.name + + and: "a document, which is never read as access is checked first" + def document = '{"version": 1, "assetType": "RoomAsset", "attributes": {}}' + + and: "a master realm user that can write assets" + def masterUserToken = authenticate( + container, + MASTER_REALM, + KEYCLOAK_CLIENT_ID, + "testuser1", + "testuser1" + ) + def masterUserResource = getClientApiTarget(serverUri(serverPort), MASTER_REALM, masterUserToken) + .proxy(AssetResource.class) + + and: "a restricted building realm user that can write the assets linked to it" + def restrictedUserToken = authenticate( + container, + buildingRealm, + KEYCLOAK_CLIENT_ID, + "testuser3", + "testuser3" + ) + def restrictedUserResource = getClientApiTarget(serverUri(serverPort), buildingRealm, restrictedUserToken) + .proxy(AssetResource.class) + + when: "the master realm user previews an import into an asset of the building realm" + def otherRealmAssetId = managerTestSetup.apartment1LivingroomId + masterUserResource.previewConfigurationTemplateImport(null, otherRealmAssetId, + new ConfigurationTemplateImportRequest(draft(otherRealmAssetId, buildingRealm), document)) + + then: "access is forbidden" + WebApplicationException otherRealm = thrown() + otherRealm.response.withCloseable { r -> + assert r.status == FORBIDDEN.statusCode + return true + } + + when: "the restricted user previews an import into an asset that is not linked to it" + def unlinkedAssetId = managerTestSetup.apartment2LivingroomId + restrictedUserResource.previewConfigurationTemplateImport(null, unlinkedAssetId, + new ConfigurationTemplateImportRequest(draft(unlinkedAssetId, buildingRealm), document)) + + then: "access is forbidden" + WebApplicationException unlinked = thrown() + unlinked.response.withCloseable { r -> + assert r.status == FORBIDDEN.statusCode + return true + } + + when: "an import into an asset that does not exist is previewed" + def unknownAssetId = "unknownAssetId00000001" + masterUserResource.previewConfigurationTemplateImport(null, unknownAssetId, + new ConfigurationTemplateImportRequest(draft(unknownAssetId, MASTER_REALM), document)) + + then: "the asset is not found" + WebApplicationException unknown = thrown() + unknown.response.withCloseable { r -> + assert r.status == NOT_FOUND.statusCode + return true + } + } + + /** A draft that identifies the asset, so only access decides the outcome. */ + protected static RoomAsset draft(String id, String realm) { + return new RoomAsset("Living room").setId(id).setRealm(realm) + } +} diff --git a/ui/app/shared/locales/en/or.json b/ui/app/shared/locales/en/or.json index 8acec2bd37..a3ea6c7841 100644 --- a/ui/app/shared/locales/en/or.json +++ b/ui/app/shared/locales/en/or.json @@ -1336,5 +1336,35 @@ } } } + }, + "configurationTemplate": { + "import": "Import", + "export": "Export", + "exportHeading": "Export configuration template", + "exportDescription": "Save the configuration items as a template, so you can reproduce the configuration on another instance.", + "templateName": "Template name", + "selectAttributes": "Select what to include in the template", + "selectAll": "Select all", + "nothingToExport": "None of the attributes of this asset have configuration items to export", + "saveBeforeExport": "Save or revert your changes before exporting", + "importHeading": "Import configuration template", + "selectFileDescription": "Select a JSON file to import attribute configuration data", + "noFileSelected": "no file selected", + "overwriteWarning": "You are about to import the following attribute configuration data. This will overwrite existing metadata for matched attributes in your current draft.", + "matchedAttributes": "Matched attributes", + "skippedAttributes": "Attribute name or type not found (these will not be imported)", + "back": "Back", + "next": "Next", + "json": "JSON", + "forbidden": "You are not allowed to import a configuration template into this asset.", + "failure": { + "INVALID_JSON": "The selected file does not hold valid JSON.", + "UNSUPPORTED_VERSION": "The selected file holds an unsupported configuration template version.", + "MISSING_REQUIRED_FIELD": "The selected file is missing a required field.", + "MALFORMED_ATTRIBUTE_ENTRY": "The selected file holds a malformed attribute entry.", + "INVALID_META_ITEM": "The selected file holds a configuration item whose value does not fit its type.", + "NO_IMPORTABLE_ATTRIBUTES": "None of the attributes in the selected file can be imported into this asset.", + "DRAFT_MISMATCH": "This asset has changed since you opened it; refresh it and try again." + } } } diff --git a/ui/app/shared/locales/nl/or.json b/ui/app/shared/locales/nl/or.json index a9a6459ab8..82c32c92b4 100644 --- a/ui/app/shared/locales/nl/or.json +++ b/ui/app/shared/locales/nl/or.json @@ -1322,5 +1322,35 @@ } } } + }, + "configurationTemplate": { + "import": "Importeren", + "export": "Exporteren", + "exportHeading": "Configuratie template exporteren", + "exportDescription": "Sla de configuratie items op als template, zodat je de configuratie op een andere instantie kunt reproduceren.", + "templateName": "Template naam", + "selectAttributes": "Selecteer wat je in de template opneemt", + "selectAll": "Alles selecteren", + "nothingToExport": "Geen van de attributen van dit asset heeft configuratie items om te exporteren", + "saveBeforeExport": "Sla je wijzigingen op of maak ze ongedaan voordat je exporteert", + "importHeading": "Configuratie template importeren", + "selectFileDescription": "Selecteer een JSON-bestand om attribuutconfiguratie te importeren", + "noFileSelected": "geen bestand geselecteerd", + "overwriteWarning": "Je staat op het punt de volgende attribuutconfiguratie te importeren. Dit overschrijft de bestaande metadata van overeenkomende attributen in je huidige concept.", + "matchedAttributes": "Overeenkomende attributen", + "skippedAttributes": "Attribuutnaam of type niet gevonden (deze worden niet geïmporteerd)", + "back": "Terug", + "next": "Volgende", + "json": "JSON", + "forbidden": "Je hebt geen rechten om een configuratie template in dit asset te importeren.", + "failure": { + "INVALID_JSON": "Het geselecteerde bestand bevat geen geldige JSON.", + "UNSUPPORTED_VERSION": "Het geselecteerde bestand bevat een niet-ondersteunde versie van het configuratie template.", + "MISSING_REQUIRED_FIELD": "In het geselecteerde bestand ontbreekt een verplicht veld.", + "MALFORMED_ATTRIBUTE_ENTRY": "Het geselecteerde bestand bevat een ongeldige attribuutvermelding.", + "INVALID_META_ITEM": "Het geselecteerde bestand bevat een configuratie item waarvan de waarde niet bij het type past.", + "NO_IMPORTABLE_ATTRIBUTES": "Geen van de attributen in het geselecteerde bestand kan in dit asset worden geïmporteerd.", + "DRAFT_MISMATCH": "Dit asset is gewijzigd sinds je het opende; ververs het en probeer het opnieuw." + } } } diff --git a/ui/component/or-asset-viewer/src/configuration-template.ts b/ui/component/or-asset-viewer/src/configuration-template.ts new file mode 100644 index 0000000000..7fb3769920 --- /dev/null +++ b/ui/component/or-asset-viewer/src/configuration-template.ts @@ -0,0 +1,779 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { html, type TemplateResult } from "lit"; +import { createRef, ref, type Ref } from "lit/directives/ref.js"; +import { live } from "lit/directives/live.js"; +import manager, { Util } from "@openremote/core"; +import { + AssetModelUtil, + WellknownMetaItems, + type Asset, + type Attribute, + type ConfigurationTemplateImportPreview, + type ValueConstraint, + type ValueFormat, +} from "@openremote/model"; +import { i18next } from "@openremote/or-translate"; +import "@openremote/or-icon"; +import "@openremote/or-translate"; +import type { OrIcon } from "@openremote/or-icon"; +import "@openremote/or-vaadin-components/or-vaadin-badge"; +import "@openremote/or-vaadin-components/or-vaadin-button"; +import "@openremote/or-vaadin-components/or-vaadin-checkbox"; +import "@openremote/or-vaadin-components/or-vaadin-dialog"; +import "@openremote/or-vaadin-components/or-vaadin-text-field"; +import type { OrVaadinButton } from "@openremote/or-vaadin-components/or-vaadin-button"; +import type { OrVaadinCheckbox } from "@openremote/or-vaadin-components/or-vaadin-checkbox"; +import { + dialogFooterRenderer, + dialogHeaderRenderer, + dialogRenderer, + showDialog, +} from "@openremote/or-vaadin-components/or-vaadin-dialog"; +import { showErrorDialog } from "@openremote/or-vaadin-components/or-vaadin-confirm-dialog"; +import { showSnackbar } from "@openremote/or-mwc-components/or-mwc-snackbar"; + +export type AssetAttributes = { [index: string]: Attribute }; + +/** A configuration item of an attribute as the rows show it. */ +interface ConfigurationItem { + label: string; + /** The value in short, if it has one. */ + value?: string; + /** The value as formatted JSON, for a value that is an object. */ + json?: string; +} + +const DIALOG_WIDTH = "550px"; + +// language=CSS +const dialogStyle = html` + +`; + +/** Configuration items that stay with an asset rather than travel in a template, as they grant access or link attributes. */ +const ASSET_BOUND_META_ITEMS: string[] = [ + WellknownMetaItems.ACCESSPUBLICREAD, + WellknownMetaItems.ACCESSPUBLICWRITE, + WellknownMetaItems.ACCESSRESTRICTEDREAD, + WellknownMetaItems.ACCESSRESTRICTEDWRITE, + WellknownMetaItems.ATTRIBUTELINKS, +]; + +/** The configuration items of an attribute that a template holds. */ +function getTemplateMeta(attribute: Attribute | undefined): { [name: string]: unknown } { + return Object.fromEntries( + Object.entries(attribute?.meta || {}).filter(([name]) => !ASSET_BOUND_META_ITEMS.includes(name)) + ); +} + +/** The attributes holding configuration items for a template, which are the attributes that can be exported. */ +export function getConfigurableAttributes(asset: Asset): Attribute[] { + return Object.entries(asset.attributes || {}) + .filter(([, attribute]) => Object.keys(getTemplateMeta(attribute)).length > 0) + .map(([name, attribute]) => ({ ...attribute, name })) + .sort(Util.sortByString((attribute) => attribute.name!.toUpperCase())); +} + +/** Lets the user pick the attributes to export, then downloads the configuration template. */ +export function showExportConfigurationTemplateDialog(host: Node, asset: Asset) { + const attributes = getConfigurableAttributes(asset); + + if (attributes.length === 0) { + showSnackbar(undefined, "configurationTemplate.nothingToExport"); + return; + } + + const selected = new Set(attributes.map((attribute) => attribute.name!)); + let templateName = `${asset.name}-configuration-template.json`; + const exportBtn: Ref = createRef(); + + const updateExportBtn = () => { + if (exportBtn.value) { + exportBtn.value.disabled = selected.size === 0 || templateName.trim() === ""; + } + }; + + const onToggle = (names: string[], checked: boolean) => { + names.forEach((name) => (checked ? selected.add(name) : selected.delete(name))); + updateExportBtn(); + dialog?.requestContentUpdate(); + }; + + const onTemplateNameChange = (name: string) => { + templateName = name; + updateExportBtn(); + }; + + const dialog = showDialog( + host, + html` + getHeaderTemplate("configurationTemplate.exportHeading", () => dialog?.close()))} + ${dialogRenderer( + () => html` + ${dialogStyle} +
+ +
+ ) => onTemplateNameChange(ev.detail.value)} + > +
+
+ + 0 && selected.size < attributes.length)} + @change=${(ev: Event) => + onToggle( + attributes.map((attribute) => attribute.name!), + (ev.currentTarget as OrVaadinCheckbox).checked + )} + > +
+ ${attributes.map((attribute) => + getAttributeRowTemplate( + asset, + attribute, + html` + + onToggle([attribute.name!], (ev.currentTarget as OrVaadinCheckbox).checked)} + > + + + ` + ) + )} +
+
+
+ ` + )} + ${dialogFooterRenderer( + () => html` +
+ dialog?.close()}> + + + { + dialog?.close(); + downloadConfigurationTemplate(asset, Array.from(selected), templateName); + }} + > + + +
+ ` + )} + >
+ ` + ); +} + +/** + * Lets the user pick a configuration file, reports what importing it would do and, once confirmed, + * hands the patched attributes to the caller to apply to the asset draft. + */ +export function showImportConfigurationTemplateDialog( + host: Node, + asset: Asset, + onImported: (attributes: AssetAttributes) => void, + selectedFile?: File +) { + let file = selectedFile; + const fileName: Ref = createRef(); + const fileInput: Ref = createRef(); + const nextBtn: Ref = createRef(); + + const onFileSelected = () => { + file = fileInput.value?.files?.[0]; + if (fileName.value) { + fileName.value.textContent = file ? file.name : i18next.t("configurationTemplate.noFileSelected"); + fileName.value.classList.toggle("ac-placeholder", !file); + } + if (nextBtn.value) { + nextBtn.value.disabled = !file; + } + }; + + const onNext = async () => { + if (!file) { + return; + } + + const selected = file; + const configuration = await selected.text(); + dialog?.close(); + + previewImport(host, asset, selected.name, configuration, onImported, () => + showImportConfigurationTemplateDialog(host, asset, onImported, selected) + ); + }; + + const dialog = showDialog( + host, + html` + getHeaderTemplate("configurationTemplate.importHeading", () => dialog?.close()))} + ${dialogRenderer( + () => html` + ${dialogStyle} +
+ +
+
+ onFileSelected()} + /> + fileInput.value?.click()}> + + + + ${file ? file.name : i18next.t("configurationTemplate.noFileSelected")} + +
+
+
+ ` + )} + ${dialogFooterRenderer( + () => html` +
+ dialog?.close()}> + + + onNext()}> + + +
+ ` + )} + >
+ ` + ); +} + +/** Asks the server what importing the configuration would do, and reports it. */ +async function previewImport( + host: Node, + asset: Asset, + fileName: string, + configuration: string, + onImported: (attributes: AssetAttributes) => void, + onBack: () => void +) { + try { + const response = await manager.rest.api.AssetResource.previewConfigurationTemplateImport(asset.id!, { + draft: asset, + document: configuration, + }); + showImportPreviewDialog(host, asset, fileName, configuration, response.data, onImported, onBack); + } catch (error) { + showImportFailureDialog(host, asset, configuration, error); + } +} + +/** Reports what the import would change and warns that it overwrites the metadata of matched attributes. */ +function showImportPreviewDialog( + host: Node, + asset: Asset, + fileName: string, + configuration: string, + preview: ConfigurationTemplateImportPreview, + onImported: (attributes: AssetAttributes) => void, + onBack: () => void +) { + const patchedAttributes = (preview.patchedAttributes || {}) as AssetAttributes; + const importable = preview.importableAttributes || []; + + const dialog = showDialog( + host, + html` + getHeaderTemplate("configurationTemplate.importHeading", () => dialog?.close()))} + ${dialogRenderer( + () => html` + ${dialogStyle} +
+ ${fileName} +
+ + +
+
+ +
+ ${importable.map((summary) => { + const attribute = { ...patchedAttributes[summary.name!], name: summary.name }; + return getAttributeRowTemplate( + asset, + attribute, + html` + + ${getAttributeNameTemplate(asset, attribute)} + ` + ); + })} +
+
+ ${getSkippedTemplate(asset, preview, configuration)} +
+ ` + )} + ${dialogFooterRenderer( + () => html` +
+ { + dialog?.close(); + onBack(); + }} + > + + + { + dialog?.close(); + onImported(patchedAttributes); + }} + > + + +
+ ` + )} + >
+ ` + ); +} + +/** Reports a document that cannot be imported, with whatever the server could tell about it. */ +function showImportFailureDialog(host: Node, asset: Asset, configuration: string, error: unknown) { + const preview = getFailurePreview(error); + const forbidden = (error as { response?: { status?: number } })?.response?.status === 403; + const message = preview + ? i18next.t(`configurationTemplate.failure.${preview.failure}`) + : i18next.t(forbidden ? "configurationTemplate.forbidden" : "errorOccurred"); + + showErrorDialog( + host, + html`${dialogStyle} +
+ ${message} + ${preview ? getSkippedTemplate(asset, preview, configuration) : ``} +
`, + "configurationTemplate.importHeading" + ); +} + +function getHeaderTemplate(titleKey: string, onClose: () => void): TemplateResult { + return html` + ${dialogStyle} +
+

+ onClose()}> + + +
+ `; +} + +/** The attributes of the file that are not imported, as the file holds them. */ +function getSkippedTemplate( + asset: Asset, + preview: ConfigurationTemplateImportPreview, + configuration: string +): TemplateResult | string { + const skipped = [ + ...(preview.missingAttributes || []).map((attribute) => attribute.name!), + ...(preview.typeMismatches || []).map((mismatch) => mismatch.name!), + ]; + + if (skipped.length === 0) { + return ``; + } + + const fileAttributes = getFileAttributes(configuration); + + return html` +
+ +
+ ${skipped.map((name) => { + const attribute = { ...fileAttributes[name], name }; + return getAttributeRowTemplate( + asset, + attribute, + html` + + ${getAttributeNameTemplate(asset, attribute)} + ` + ); + })} +
+
+ `; +} + +/** An attribute with its configuration items, which the row expands to show. */ +function getAttributeRowTemplate(asset: Asset, attribute: Attribute, lead: TemplateResult): TemplateResult { + return html` +
+
+ ${lead} + ${Object.keys(getTemplateMeta(attribute)).length} + + + +
+
+ ${getConfigurationItems(asset, attribute).map( + (item) => html` +
+
+ ${item.label} + ${ + item.json + ? html` + + + + ` + : `` + } +
+ ${ + item.value !== undefined + ? html`${item.value}` + : `` + } + ${item.json ? html`
${item.json}
` : ``} +
+ ` + )} +
+
+ `; +} + +/** Expands or collapses the section of the clicked header. */ +function toggleExpanded(ev: Event) { + const header = ev.currentTarget as HTMLElement; + const section = header.parentElement; + const icon = header.querySelector(".ac-expander or-icon") as OrIcon | null; + + // A checkbox in the header only toggles itself + if (!section || (ev.target as Element).closest("or-vaadin-checkbox")) { + return; + } + + section.classList.toggle("ac-expanded"); + + if (icon) { + icon.icon = section.classList.contains("ac-expanded") ? "chevron-up" : "chevron-down"; + } +} + +async function downloadConfigurationTemplate(asset: Asset, attributeNames: string[], templateName: string) { + try { + const response = await manager.rest.api.AssetResource.exportConfigurationTemplate(asset.id!, { + attributeName: attributeNames, + }); + const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: "application/json" }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + const fileName = templateName.trim(); + link.setAttribute("download", fileName.toLowerCase().endsWith(".json") ? fileName : `${fileName}.json`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error("Failed to export the configuration template", error); + showSnackbar(undefined, "errorOccurred"); + } +} + +/** The report the server sends along with a rejected import, if this is one. */ +function getFailurePreview(error: unknown): ConfigurationTemplateImportPreview | undefined { + const data = (error as { response?: { data?: ConfigurationTemplateImportPreview } })?.response?.data; + return data?.failure ? data : undefined; +} + +/** The attributes of a configuration file keyed by name, or none if it cannot be read. */ +function getFileAttributes(configuration: string): AssetAttributes { + try { + const attributes = JSON.parse(configuration)?.attributes; + return attributes && typeof attributes === "object" ? (attributes as AssetAttributes) : {}; + } catch { + return {}; + } +} + +/** The attribute label, which truncates when it does not fit, followed by its value type, which stays visible. */ +function getAttributeNameTemplate(asset: Asset, attribute: Attribute): TemplateResult { + return html` + ${attributeName(asset, attribute)} + (${Util.getValueDescriptorLabel(attribute.type)}) + `; +} + +/** The attribute label with its value type, as the Figma dialogs show it, e.g. "Model (Text)". */ +function attributeLabel(asset: Asset, attribute: Attribute): string { + return `${attributeName(asset, attribute)} (${Util.getValueDescriptorLabel(attribute.type)})`; +} + +function attributeName(asset: Asset, attribute: Attribute): string { + const descriptor = AssetModelUtil.getAttributeDescriptor(attribute.name!, asset.type!); + return Util.getAttributeLabel(attribute, descriptor, asset.type, false); +} + +/** The configuration items of an attribute that a template holds, sorted by label. */ +function getConfigurationItems(asset: Asset, attribute: Attribute): ConfigurationItem[] { + return Object.entries(getTemplateMeta(attribute)) + .map(([name, value]) => getConfigurationItem(asset, name, value)) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +/** + * A configuration item with its value in short: a plain value as it is, labeled with its value type, an item that has a + * short form as that, and any other list of plain values comma separated. Any other value only shows as formatted JSON, + * through its JSON button, which a short form has as well. + */ +function getConfigurationItem(asset: Asset, name: string, value: unknown): ConfigurationItem { + const label = Util.getMetaLabel(undefined, name, asset.type, false); + + if (value === null || typeof value !== "object") { + const type = AssetModelUtil.getMetaItemDescriptor(name)?.type; + return { label: type ? `${label} (${Util.getValueDescriptorLabel(type)})` : label, value: String(value) }; + } + const json = JSON.stringify(value, null, 2); + const shortValue = SHORT_VALUES[name]?.(value); + + if (shortValue) { + return { label, value: shortValue, json }; + } + if (Array.isArray(value) && value.every((item) => item === null || typeof item !== "object")) { + return { label, value: value.join(", ") }; + } + return { label, json }; +} + +/** The short forms of configuration items, by item name. */ +const SHORT_VALUES: { [name: string]: (value: any) => string | undefined } = { + [WellknownMetaItems.AGENTLINK]: getAgentLinkShortValue, + [WellknownMetaItems.CONSTRAINTS]: (constraints: ValueConstraint[]) => getNames(constraints.map(({ type }) => type)), + [WellknownMetaItems.FORMAT]: (format: ValueFormat) => getNames(Object.keys(format)), + [WellknownMetaItems.UNITS]: (units: string[]) => Util.resolveUnits(units), +}; + +/** An agent link as its type and agent id, e.g. "SimulatorAgentLink (5Bg0X…)". */ +function getAgentLinkShortValue(link: { type?: unknown; id?: unknown }): string | undefined { + if (typeof link.type !== "string") { + return undefined; + } + return link.id ? `${link.type} (${String(link.id)})` : link.type; +} + +/** Names in sentence case, comma separated, e.g. "Not blank, Size". */ +function getNames(names: string[]): string { + return names.map((name) => Util.camelCaseToSentenceCase(name)).join(", "); +} + +/** Shows or hides the formatted JSON of a configuration item. */ +function toggleJson(ev: Event) { + (ev.currentTarget as HTMLElement).closest(".ac-item")?.classList.toggle("ac-json-shown"); +} diff --git a/ui/component/or-asset-viewer/src/index.ts b/ui/component/or-asset-viewer/src/index.ts index 0d17bbb35a..b6783b51a7 100644 --- a/ui/component/or-asset-viewer/src/index.ts +++ b/ui/component/or-asset-viewer/src/index.ts @@ -324,7 +324,12 @@ declare global { } } -export function getPanel(id: string, panelConfig: PanelConfig, content: TemplateResult | undefined) { +export function getPanel( + id: string, + panelConfig: PanelConfig, + content: TemplateResult | undefined, + actions?: TemplateResult +) { if (!content) { return; } @@ -338,6 +343,7 @@ export function getPanel(id: string, panelConfig: PanelConfig, content: Template
+ ${actions ? html`
${actions}
` : ``}
${content}
@@ -1481,7 +1487,7 @@ export class OrAssetViewer extends subscribe(manager)(translate(i18next)(OrEleme if (editMode) { content = html`
- +
`; diff --git a/ui/component/or-asset-viewer/src/or-edit-asset-panel.ts b/ui/component/or-asset-viewer/src/or-edit-asset-panel.ts index 2c74c8d5b2..fcee588e22 100644 --- a/ui/component/or-asset-viewer/src/or-edit-asset-panel.ts +++ b/ui/component/or-asset-viewer/src/or-edit-asset-panel.ts @@ -40,6 +40,11 @@ import { showDialog, type OrVaadinDialog } from "@openremote/or-vaadin-component import type { OrVaadinCheckboxGroup } from "@openremote/or-vaadin-components/or-vaadin-checkbox-group"; import type { OrVaadinButton } from "@openremote/or-vaadin-components/or-vaadin-button"; import "./or-add-attribute-panel"; +import { + type AssetAttributes, + showExportConfigurationTemplateDialog, + showImportConfigurationTemplateDialog, +} from "./configuration-template"; import { getField, getPanel, getPropertyTemplate } from "./index"; import type { OrAddAttributePanelAttributeChangedEvent } from "./or-add-attribute-panel"; import { panelStyles } from "./style"; @@ -211,6 +216,10 @@ export class OrEditAssetPanel extends OrElement { @property({ attribute: false }) protected asset!: Asset; + /** Whether the draft holds unsaved changes, which blocks exporting a configuration template. */ + @property({ type: Boolean }) + public modified = false; + protected attributeTemplatesAndValidators: TemplateAndValidator[] = []; protected changedAttributes: string[] = []; @@ -380,7 +389,14 @@ export class OrEditAssetPanel extends OrElement { return html`
${getPanel("0", { type: "info", title: "properties" }, html`${properties}`) || ``} - ${getPanel("1", { type: "info", title: "attribute_plural" }, html`${attributes}`) || ``} + ${ + getPanel( + "1", + { type: "info", title: "attribute_plural" }, + html`${attributes}`, + this._getConfigurationActionsTemplate() + ) || `` + }
`; } @@ -479,6 +495,43 @@ export class OrEditAssetPanel extends OrElement { }; } + /** Import and Export, left out on agents as those are configured through attribute values. */ + protected _getConfigurationActionsTemplate(): TemplateResult | undefined { + if (AssetModelUtil.getAssetDescriptor(this.asset.type)?.descriptorType === "agent") { + return undefined; + } + + return html` + this._importConfiguration()}> + + + + showExportConfigurationTemplateDialog(this.shadowRoot!, this.asset)} + > + + + + `; + } + + protected _importConfiguration() { + showImportConfigurationTemplateDialog(this.shadowRoot!, this.asset, (attributes) => + this._onConfigurationImported(attributes) + ); + } + + protected async _onConfigurationImported(attributes: AssetAttributes) { + this.asset.attributes = attributes; + + // Render the imported metadata before validating it + this.requestUpdate(); + await this.updateComplete; + this._onModified(); + } + protected _onModified() { this.dispatchEvent(new OrEditAssetModifiedEvent(this.validate())); } diff --git a/ui/component/or-asset-viewer/src/style.ts b/ui/component/or-asset-viewer/src/style.ts index 34207eed91..ab31723cd4 100644 --- a/ui/component/or-asset-viewer/src/style.ts +++ b/ui/component/or-asset-viewer/src/style.ts @@ -61,6 +61,8 @@ export const panelStyles = css` } .panel-title { + display: flex; + align-items: center; font-size: 18px; font-weight: bold; color: var(--internal-or-asset-viewer-title-text-color); @@ -68,6 +70,14 @@ export const panelStyles = css` flex: 0 0 auto; } + .panel-title-actions { + display: flex; + gap: 8px; + margin-left: auto; + font-size: initial; + font-weight: initial; + } + .field { margin: 10px 0; width: 100%; diff --git a/ui/component/or-map/src/controls/legend.ts b/ui/component/or-map/src/controls/legend.ts index 7eaf3be99f..e4658c9ddf 100644 --- a/ui/component/or-map/src/controls/legend.ts +++ b/ui/component/or-map/src/controls/legend.ts @@ -18,7 +18,6 @@ */ import { css, type CSSResultGroup, html, type PropertyValues, type TemplateResult } from "lit"; import { OrElement } from "@openremote/or-element"; -import { countBadgeStyle } from "./styles"; import { customElement, property, state } from "lit/decorators.js"; import { AssetModelUtil } from "@openremote/model"; import { formatCount, getMarkerIconAndColorFromAssetType } from "../util"; @@ -53,7 +52,6 @@ declare global { export class OrMapLegend extends OrElement { static get styles(): CSSResultGroup { return [ - countBadgeStyle, css` :host { display: block; @@ -201,7 +199,7 @@ export class OrMapLegend extends OrElement { ${this._assetTypesInfo[assetType].label} - ${formatCount(this.assetCounts[assetType] ?? 0)} { export class OrMapPresetFilter extends OrElement { static get styles() { return [ - countBadgeStyle, css` :host { display: block; @@ -173,26 +171,25 @@ export class OrMapPresetFilter extends OrElement { return html` ${options.map( @@ -203,7 +200,7 @@ export class OrMapPresetFilter extends OrElement { >${opt.warning ? "⚠ " : ""}${opt.label} ${opt.warning ? html`${opt.warning}` : nothing} - ${formatCount(opt.count)} + ${formatCount(opt.count)} ` @@ -221,7 +218,7 @@ export class OrMapPresetFilter extends OrElement { ${selectRenderer(this._renderFilterOptions, [this.filters, this.assets])} @change="${this._onChange}" > - ${formatCount(currentCount)} + ${formatCount(currentCount)} `; } diff --git a/ui/component/theme/src/components/or-vaadin-badge.css b/ui/component/theme/src/components/or-vaadin-badge.css index a1b0d30f86..2a68d7afe7 100644 --- a/ui/component/theme/src/components/or-vaadin-badge.css +++ b/ui/component/theme/src/components/or-vaadin-badge.css @@ -18,8 +18,19 @@ */ @import "@vaadin/vaadin-lumo-styles/components/badge.css"; +@media lumo_badge_counter { + /* A count next to a label */ + :host([theme~="counter"]) { + --vaadin-badge-background: var(--lumo-contrast-10pct); + --vaadin-badge-text-color: var(--lumo-secondary-text-color); + --vaadin-badge-font-size: var(--lumo-font-size-xxs); + --vaadin-badge-font-weight: 400; + --vaadin-badge-padding: 0 10px; + } +} + :root::before, :host::before { --_lumo-vaadin-badge-inject: 1; - --_lumo-vaadin-badge-inject-modules: lumo_components_badge; + --_lumo-vaadin-badge-inject-modules: lumo_components_badge, lumo_badge_counter; } diff --git a/ui/component/theme/src/default.css b/ui/component/theme/src/default.css index e16b4ced03..492efc4a0a 100644 --- a/ui/component/theme/src/default.css +++ b/ui/component/theme/src/default.css @@ -176,6 +176,8 @@ --lumo-font-family: "Inter", -apple-system, BlinkMacSystemFont, "Roboto", "Segoe UI", Helvetica, Arial, sans-serif; --lumo-border-radius-m: 0.5em; --lumo-border-radius-l: 0.75em; + /* Not --lumo-border-radius-m, which is em based and shrinks with the smaller text of badges */ + --vaadin-badge-border-radius: 0.5rem; --vaadin-field-default-width: 100%; --vaadin-button-margin: 0; --lumo-clickable-cursor: pointer;