From 6034114c791d3899f021c96bcafe5ff8b64c48f1 Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Thu, 1 Oct 2026 15:29:22 +0200 Subject: [PATCH 1/4] Add asset attribute configuration import and export --- .../manager/asset/AssetResourceImpl.java | 74 +++ .../openremote/model/asset/AssetResource.java | 62 +++ .../model/asset/AttributeConfiguration.java | 56 ++ .../asset/AttributeConfigurationDocument.java | 69 +++ .../AttributeConfigurationImportFailure.java | 41 ++ .../AttributeConfigurationImportPreview.java | 121 +++++ .../AttributeConfigurationImportRequest.java | 56 ++ .../asset/AttributeConfigurationSummary.java | 49 ++ .../AttributeConfigurationTypeMismatch.java | 61 +++ .../util/AttributeConfigurationUtil.java | 206 ++++++++ .../AttributeConfigurationUtilTest.groovy | 232 +++++++++ .../assets/AttributeConfigurationTest.groovy | 182 +++++++ ui/app/shared/locales/en/or.json | 28 + .../src/attribute-configuration.ts | 480 ++++++++++++++++++ ui/component/or-asset-viewer/src/index.ts | 10 +- .../src/or-edit-asset-panel.ts | 50 +- ui/component/or-asset-viewer/src/style.ts | 10 + 17 files changed, 1784 insertions(+), 3 deletions(-) create mode 100644 model/src/main/java/org/openremote/model/asset/AttributeConfiguration.java create mode 100644 model/src/main/java/org/openremote/model/asset/AttributeConfigurationDocument.java create mode 100644 model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportFailure.java create mode 100644 model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportPreview.java create mode 100644 model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportRequest.java create mode 100644 model/src/main/java/org/openremote/model/asset/AttributeConfigurationSummary.java create mode 100644 model/src/main/java/org/openremote/model/asset/AttributeConfigurationTypeMismatch.java create mode 100644 model/src/main/java/org/openremote/model/util/AttributeConfigurationUtil.java create mode 100644 model/src/test/groovy/org/openremote/model/util/AttributeConfigurationUtilTest.groovy create mode 100644 test/src/test/groovy/org/openremote/test/assets/AttributeConfigurationTest.groovy create mode 100644 ui/component/or-asset-viewer/src/attribute-configuration.ts 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..a8b7f59779 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.AttributeConfigurationDocument; +import org.openremote.model.asset.AttributeConfigurationImportFailure; +import org.openremote.model.asset.AttributeConfigurationImportPreview; +import org.openremote.model.asset.AttributeConfigurationImportRequest; 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.AttributeConfigurationUtil; 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 AttributeConfigurationDocument exportAttributeConfiguration( + RequestParams requestParams, String assetId, List attributeNames) { + // Reuses the read access rules of #get, including the protected view for a restricted user + return AttributeConfigurationUtil.export(get(requestParams, assetId), attributeNames); + } + + @Override + public AttributeConfigurationImportPreview previewAttributeConfigurationImport( + RequestParams requestParams, String assetId, AttributeConfigurationImportRequest request) { + + Asset storageAsset = assetStorageService.find(assetId, true); + + if (storageAsset == null) { + LOG.fine("Asset not found: assetID=" + assetId); + throw new WebApplicationException(NOT_FOUND); + } + + if (!isRealmActiveAndAccessible(storageAsset.getRealm())) { + LOG.fine( + "Realm '" + + storageAsset.getRealm() + + "' is nonexistent, inactive or inaccessible: username=" + + getUsername() + + ", assetID=" + + assetId); + throw new WebApplicationException(FORBIDDEN); + } + + if (isRestrictedUser() && !assetStorageService.isUserAsset(getUserId(), assetId)) { + LOG.fine( + "Forbidden access for restricted user: username=" + + getUsername() + + ", assetID=" + + assetId); + throw new WebApplicationException(FORBIDDEN); + } + + Asset draft = request != null ? request.getDraft() : null; + + if (draft == null + || !Objects.equals(assetId, draft.getId()) + || !Objects.equals(storageAsset.getRealm(), draft.getRealm()) + || !Objects.equals(storageAsset.getType(), draft.getType())) { + throw importFailure( + AttributeConfigurationImportPreview.failed( + AttributeConfigurationImportFailure.DRAFT_MISMATCH)); + } + + AttributeConfigurationImportPreview preview = + AttributeConfigurationUtil.preview(draft, request.getDocument()); + + if (preview.getFailure() != null) { + throw importFailure(preview); + } + + return preview; + } + + protected WebApplicationException importFailure(AttributeConfigurationImportPreview preview) { + Response.Status status = + preview.getFailure() == AttributeConfigurationImportFailure.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..f569314548 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,68 @@ Asset update( value = ASSET_UPDATE))) Asset asset); + /** + * Retrieve the attribute configuration of the asset as a user editable document, holding the type + * and metadata of every attribute that has metadata. Repeat the attributeName query parameter to + * export a subset of them. Access follows the same rules as {@link #get}. + */ + @GET + @Path("{assetId}/attributeConfiguration") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ASSETS_ROLE}) + @Operation( + operationId = "exportAttributeConfiguration", + summary = "Export the attribute configuration 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. Attributes without metadata are never included.") + @OpenApiResponses.Ok + @OpenApiResponses.Authenticated + @OpenApiResponses.BadRequest + @OpenApiResponses.NotFound + AttributeConfigurationDocument exportAttributeConfiguration( + @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 an attribute configuration document 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 the document cannot be imported, and a 409 status if the draft no + * longer identifies the persisted asset. + */ + @POST + @Path("{assetId}/attributeConfiguration/import") + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.WRITE_ASSETS_ROLE}) + @Operation( + operationId = "previewAttributeConfigurationImport", + summary = "Preview an attribute configuration 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. Nothing is stored; the client applies the patch after the user confirms.") + @OpenApiResponses.Ok + @OpenApiResponses.Authenticated + @OpenApiResponses.BadRequest + @OpenApiResponses.NotFound + @OpenApiResponses.Conflict + AttributeConfigurationImportPreview previewAttributeConfigurationImport( + @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 AttributeConfigurationImportRequest 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..74ab14331e --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfiguration.java @@ -0,0 +1,56 @@ +/* + * 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 + * AttributeConfigurationDocument}. 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/AttributeConfigurationDocument.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationDocument.java new file mode 100644 index 0000000000..fd55f0518c --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationDocument.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 user editable document holding the configuration of the {@link + * org.openremote.model.attribute.Attribute}s of an {@link Asset}, exchanged as a file by the + * Manager UI. Attributes are keyed by attribute name. + */ +@Schema( + description = + "Versioned document holding attribute configuration of an asset, keyed by attribute name.") +public class AttributeConfigurationDocument { + + /** The only document version this release can read and write. */ + public static final int VERSION = 1; + + @Schema(description = "Document format version.", example = "1") + protected int version; + + @Schema(description = "Asset type the configuration was exported from.") + protected String assetType; + + @Schema(description = "Attribute configuration keyed by attribute name.") + protected Map attributes; + + @JsonCreator + public AttributeConfigurationDocument( + @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/AttributeConfigurationImportFailure.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportFailure.java new file mode 100644 index 0000000000..04d67e03f2 --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportFailure.java @@ -0,0 +1,41 @@ +/* + * 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 an {@link AttributeConfigurationDocument} cannot be imported. */ +public enum AttributeConfigurationImportFailure { + + /** The document is not parsable JSON or is not a JSON object. */ + INVALID_JSON, + + /** The document version is not {@link AttributeConfigurationDocument#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, + + /** 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/AttributeConfigurationImportPreview.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportPreview.java new file mode 100644 index 0000000000..e4b569c972 --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportPreview.java @@ -0,0 +1,121 @@ +/* + * 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 an {@link AttributeConfigurationDocument} into an asset draft would do, for the + * user to confirm or cancel. A {@link #failure} means nothing can be imported and the remaining + * fields are empty. + */ +@Schema( + description = + "Outcome of validating an attribute configuration document against an asset draft, with the patched attributes to apply on confirmation.") +public class AttributeConfigurationImportPreview { + + @Schema(description = "Why the document cannot be imported; absent when it can.") + protected AttributeConfigurationImportFailure failure; + + @Schema(description = "Asset type the document was exported from.") + protected String importedAssetType; + + @Schema(description = "Asset type of the target draft.") + protected String targetAssetType; + + @Schema(description = "Whether the document was exported from a different asset type.") + protected boolean assetTypeMismatch; + + @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 AttributeConfigurationImportPreview( + @JsonProperty("failure") AttributeConfigurationImportFailure failure, + @JsonProperty("importedAssetType") String importedAssetType, + @JsonProperty("targetAssetType") String targetAssetType, + @JsonProperty("assetTypeMismatch") boolean assetTypeMismatch, + @JsonProperty("importableAttributes") + List importableAttributes, + @JsonProperty("missingAttributes") List missingAttributes, + @JsonProperty("typeMismatches") List typeMismatches, + @JsonProperty("patchedAttributes") ObjectNode patchedAttributes) { + this.failure = failure; + this.importedAssetType = importedAssetType; + this.targetAssetType = targetAssetType; + this.assetTypeMismatch = assetTypeMismatch; + this.importableAttributes = importableAttributes; + this.missingAttributes = missingAttributes; + this.typeMismatches = typeMismatches; + this.patchedAttributes = patchedAttributes; + } + + public static AttributeConfigurationImportPreview failed( + AttributeConfigurationImportFailure failure) { + return new AttributeConfigurationImportPreview( + failure, null, null, false, List.of(), List.of(), List.of(), null); + } + + public AttributeConfigurationImportFailure getFailure() { + return failure; + } + + public String getImportedAssetType() { + return importedAssetType; + } + + public String getTargetAssetType() { + return targetAssetType; + } + + public boolean isAssetTypeMismatch() { + return assetTypeMismatch; + } + + 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/AttributeConfigurationImportRequest.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportRequest.java new file mode 100644 index 0000000000..101e7e3e41 --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportRequest.java @@ -0,0 +1,56 @@ +/* + * 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; + +/** + * An {@link AttributeConfigurationDocument} 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 = + "An attribute configuration document to validate against an asset draft, as file text.") +public class AttributeConfigurationImportRequest { + + @Schema(description = "The asset draft currently being edited, including unsaved changes.") + @NotNull protected Asset draft; + + @Schema(description = "Contents of the selected attribute configuration file.") + @NotNull protected String document; + + @JsonCreator + public AttributeConfigurationImportRequest( + @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/asset/AttributeConfigurationSummary.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationSummary.java new file mode 100644 index 0000000000..50ae0f0249 --- /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 an {@link AttributeConfigurationDocument} 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/util/AttributeConfigurationUtil.java b/model/src/main/java/org/openremote/model/util/AttributeConfigurationUtil.java new file mode 100644 index 0000000000..b6e4f32c92 --- /dev/null +++ b/model/src/main/java/org/openremote/model/util/AttributeConfigurationUtil.java @@ -0,0 +1,206 @@ +/* + * 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.AttributeConfigurationImportFailure.*; + +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.TreeMap; +import org.openremote.model.asset.Asset; +import org.openremote.model.asset.AttributeConfiguration; +import org.openremote.model.asset.AttributeConfigurationDocument; +import org.openremote.model.asset.AttributeConfigurationImportPreview; +import org.openremote.model.asset.AttributeConfigurationSummary; +import org.openremote.model.asset.AttributeConfigurationTypeMismatch; +import org.openremote.model.attribute.Attribute; +import org.openremote.model.attribute.AttributeMap; + +/** + * Reads and writes {@link AttributeConfigurationDocument}s. Callers are responsible for enforcing + * realm authorization and for persisting, or discarding, the patched attributes. + */ +public final class AttributeConfigurationUtil { + + private AttributeConfigurationUtil() {} + + /** + * Builds the export document of an asset, holding the type and metadata of every attribute that + * has metadata. A null or empty selection exports all of them. + */ + public static AttributeConfigurationDocument 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 -> attribute.getMeta() != null && !attribute.getMeta().isEmpty()) + .filter(attribute -> selectAll || attributeNames.contains(attribute.getName())) + .forEach( + attribute -> + attributes.put( + attribute.getName(), + new AttributeConfiguration( + typeName(attribute), toObjectNode(attribute.getMeta())))); + } + + return new AttributeConfigurationDocument( + AttributeConfigurationDocument.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 + * AttributeConfigurationImportPreview#getPatchedAttributes()} holds the draft attributes with the + * metadata of every importable attribute replaced. + */ + public static AttributeConfigurationImportPreview preview(Asset draft, String document) { + JsonNode root; + + try { + root = ValueUtil.JSON.readTree(document); + } catch (JsonProcessingException | IllegalArgumentException e) { + return AttributeConfigurationImportPreview.failed(INVALID_JSON); + } + + if (root == null || !root.isObject()) { + return AttributeConfigurationImportPreview.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 AttributeConfigurationImportPreview.failed(MISSING_REQUIRED_FIELD); + } + if (!version.isIntegralNumber() || version.asInt() != AttributeConfigurationDocument.VERSION) { + return AttributeConfigurationImportPreview.failed(UNSUPPORTED_VERSION); + } + if (!assetType.isTextual() || assetType.asText().isBlank()) { + return AttributeConfigurationImportPreview.failed(MISSING_REQUIRED_FIELD); + } + if (!attributes.isObject()) { + return AttributeConfigurationImportPreview.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 AttributeConfigurationImportPreview.failed(MALFORMED_ATTRIBUTE_ENTRY); + } + if (meta == null || !meta.isObject()) { + return AttributeConfigurationImportPreview.failed(MALFORMED_ATTRIBUTE_ENTRY); + } + + imported.put( + entry.getKey(), new AttributeConfiguration(type.asText(), (ObjectNode) meta.deepCopy())); + } + + 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())); + }); + + boolean assetTypeMismatch = !Objects.equals(assetType.asText(), draft.getType()); + + // Report what was skipped even when that leaves nothing to import + if (importable.isEmpty()) { + return new AttributeConfigurationImportPreview( + NO_IMPORTABLE_ATTRIBUTES, + assetType.asText(), + draft.getType(), + assetTypeMismatch, + importable, + missing, + typeMismatches, + null); + } + + ObjectNode patchedAttributes = toObjectNode(targetAttributes); + importable.forEach( + attribute -> + ((ObjectNode) patchedAttributes.get(attribute.getName())) + .set("meta", imported.get(attribute.getName()).getMeta())); + + return new AttributeConfigurationImportPreview( + null, + assetType.asText(), + draft.getType(), + assetTypeMismatch, + importable, + missing, + typeMismatches, + patchedAttributes); + } + + protected static boolean isAbsent(JsonNode node) { + return node == null || node.isNull(); + } + + protected static String typeName(Attribute attribute) { + return attribute.getType() != null ? attribute.getType().getName() : null; + } + + /** + * Serialises through text rather than a token buffer, so that attribute values held as raw JSON + * are materialised 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 serialise attribute configuration", e); + } + } +} diff --git a/model/src/test/groovy/org/openremote/model/util/AttributeConfigurationUtilTest.groovy b/model/src/test/groovy/org/openremote/model/util/AttributeConfigurationUtilTest.groovy new file mode 100644 index 0000000000..f9e22c7afb --- /dev/null +++ b/model/src/test/groovy/org/openremote/model/util/AttributeConfigurationUtilTest.groovy @@ -0,0 +1,232 @@ +/* + * 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.AttributeConfigurationDocument +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.AttributeConfigurationImportFailure.* + +class AttributeConfigurationUtilTest 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 = AttributeConfigurationUtil.export(asset, null) + + then: "the document is of the supported version and holds the asset type" + exported.version == AttributeConfigurationDocument.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" + AttributeConfigurationUtil.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" + AttributeConfigurationUtil.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}']) + NO_IMPORTABLE_ATTRIBUTES | document(ThingAsset.DESCRIPTOR.name, [:]) + } + + 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 = AttributeConfigurationUtil.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 = AttributeConfigurationUtil.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 = AttributeConfigurationUtil.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 is reported but 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 = AttributeConfigurationUtil.preview(asset, document(RoomAsset.DESCRIPTOR.name, [ + temperature: '{"type": "number", "meta": {"label": "Imported"}}' + ])) + + then: "the mismatch is reported without preventing the import" + preview.failure == null + preview.assetTypeMismatch + preview.importedAssetType == RoomAsset.DESCRIPTOR.name + preview.targetAssetType == ThingAsset.DESCRIPTOR.name + 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 = AttributeConfigurationUtil.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 = AttributeConfigurationUtil.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/test/src/test/groovy/org/openremote/test/assets/AttributeConfigurationTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AttributeConfigurationTest.groovy new file mode 100644 index 0000000000..c520e1cdc8 --- /dev/null +++ b/test/src/test/groovy/org/openremote/test/assets/AttributeConfigurationTest.groovy @@ -0,0 +1,182 @@ +/* + * 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.AttributeConfigurationImportPreview +import org.openremote.model.asset.AttributeConfigurationImportRequest +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 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.AttributeConfigurationImportFailure.* +import static org.openremote.model.util.MapAccess.getString + +class AttributeConfigurationTest extends Specification implements ManagerContainerTrait { + + def "Export and import attribute configuration 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: "the attribute configuration of the source asset is exported" + def document = assetResource.exportAttributeConfiguration(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.exportAttributeConfiguration(null, source.id, ["temperature"]) + + then: "only the requested attribute is exported" + subset.attributes.keySet() == ["temperature"] as Set + + when: "the exported document is imported into the target asset" + def preview = assetResource.previewAttributeConfigurationImport(null, target.id, + new AttributeConfigurationImportRequest(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.assetTypeMismatch + 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.previewAttributeConfigurationImport(null, target.id, + new AttributeConfigurationImportRequest(target, '{"version": 2, "assetType": "ThingAsset", "attributes": {}}')) + + then: "the import is rejected with the reason" + WebApplicationException ex = thrown() + ex.response.status == BAD_REQUEST.statusCode + ex.response.readEntity(AttributeConfigurationImportPreview.class).failure == UNSUPPORTED_VERSION + + when: "a document holding no attribute of the target asset is imported" + assetResource.previewAttributeConfigurationImport(null, target.id, + new AttributeConfigurationImportRequest(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.status == BAD_REQUEST.statusCode + def rejected = noneImportable.response.readEntity(AttributeConfigurationImportPreview.class) + rejected.failure == NO_IMPORTABLE_ATTRIBUTES + rejected.missingAttributes*.name == ["absent"] + } + + def "Attribute configuration 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: "the attribute configuration of an asset of the authenticated realm is exported" + def document = assetResource.exportAttributeConfiguration(null, managerTestSetup.apartment1LivingroomId, []) + + then: "the configuration is returned" + document.version == 1 + !document.attributes.isEmpty() + + when: "the same user previews an import" + assetResource.previewAttributeConfigurationImport(null, managerTestSetup.apartment1LivingroomId, + new AttributeConfigurationImportRequest( + assetResource.get(null, managerTestSetup.apartment1LivingroomId), + ValueUtil.asJSON(document).orElseThrow())) + + then: "access is forbidden" + thrown(ForbiddenException) + } +} diff --git a/ui/app/shared/locales/en/or.json b/ui/app/shared/locales/en/or.json index e87d0da42e..f4d5f8115e 100644 --- a/ui/app/shared/locales/en/or.json +++ b/ui/app/shared/locales/en/or.json @@ -1332,5 +1332,33 @@ } } } + }, + "attributeConfiguration": { + "import": "Import", + "export": "Export", + "exportHeading": "Export attribute configuration", + "exportDescription": "You can save the configuration items of these attributes as a file, to apply the same configuration to similar assets.", + "selectAttributes": "Export the configuration of these attributes", + "nothingToExport": "None of the attributes of this asset have configuration items to export", + "saveBeforeExport": "Save or revert your changes before exporting", + "importHeading": "Import attribute configuration", + "selectFile": "Select a JSON file to import attribute configuration data", + "chooseFile": "Choose file", + "noFileSelected": "No file selected", + "overwriteWarning": "You are about to import the following attribute configuration. This overwrites the existing configuration items of these attributes in your current draft.", + "assetTypeMismatch": "This configuration was exported from a {{imported}}, while this asset is a {{target}}.", + "willBeImported": "Will be imported", + "skippedMissing": "Skipped, not an attribute of this asset", + "skippedTypeMismatch": "Skipped, the attribute has a different type", + "imported": "The configuration of these attributes was imported: {{attributes}}", + "saveToPersist": "Save the asset to keep these changes, or discard them as usual.", + "failure": { + "INVALID_JSON": "The selected file does not hold valid JSON.", + "UNSUPPORTED_VERSION": "The selected file holds an unsupported attribute configuration version.", + "MISSING_REQUIRED_FIELD": "The selected file is missing a required field.", + "MALFORMED_ATTRIBUTE_ENTRY": "The selected file holds a malformed attribute entry.", + "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/component/or-asset-viewer/src/attribute-configuration.ts b/ui/component/or-asset-viewer/src/attribute-configuration.ts new file mode 100644 index 0000000000..9325e0ec80 --- /dev/null +++ b/ui/component/or-asset-viewer/src/attribute-configuration.ts @@ -0,0 +1,480 @@ +/* + * 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 { when } from "lit/directives/when.js"; +import manager, { Util } from "@openremote/core"; +import { + AssetModelUtil, + type Asset, + type Attribute, + type AttributeConfigurationImportPreview, +} from "@openremote/model"; +import { i18next } from "@openremote/or-translate"; +import "@openremote/or-icon"; +import "@openremote/or-translate"; +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 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 { + getConfirmDialogContent, + showConfirmDialog, + 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 }; + +// language=CSS +const dialogStyle = html` + +`; + +/** The attributes that hold metadata, which are the attributes that can be exported. */ +export function getConfigurableAttributes(asset: Asset): Attribute[] { + return Object.entries(asset.attributes || {}) + .filter(([, attribute]) => Object.keys(attribute.meta || {}).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 document. */ +export function showExportAttributeConfigurationDialog(host: Node, asset: Asset) { + const attributes = getConfigurableAttributes(asset); + + if (attributes.length === 0) { + showSnackbar(undefined, "attributeConfiguration.nothingToExport"); + return; + } + + const selected = new Set(attributes.map((attribute) => attribute.name!)); + const exportBtn: Ref = createRef(); + + const onToggle = (name: string, checked: boolean) => { + if (checked) { + selected.add(name); + } else { + selected.delete(name); + } + if (exportBtn.value) { + exportBtn.value.disabled = selected.size === 0; + } + }; + + const dialog = showDialog( + host, + html` + html`

` + )} + ${dialogRenderer( + () => html` + ${dialogStyle} +

+
+ +
+ ${attributes.map( + (attribute) => html` +
+
+ onToggle(attribute.name!, (ev.currentTarget as OrVaadinCheckbox).checked)} + > + ${Object.keys(attribute.meta || {}).length} +
+
${metaLabels(asset, attribute).join(", ")}
+
+ ` + )} + ` + )} + ${dialogFooterRenderer( + () => html` + dialog?.close()}> + + + { + dialog?.close(); + downloadAttributeConfiguration(asset, Array.from(selected)); + }} + > + + + + ` + )} + >
+ ` + ); +} + +/** + * 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 showImportAttributeConfigurationDialog( + host: Node, + asset: Asset, + onImported: (attributes: AssetAttributes) => void +) { + let file: File | undefined; + const fileName: Ref = createRef(); + const fileInput: Ref = createRef(); + const continueBtn: Ref = createRef(); + + const onFileSelected = () => { + file = fileInput.value?.files?.[0]; + if (fileName.value) { + fileName.value.textContent = file ? file.name : i18next.t("attributeConfiguration.noFileSelected"); + } + if (continueBtn.value) { + continueBtn.value.disabled = !file; + } + }; + + const onContinue = async () => { + if (!file) { + return; + } + + const configuration = await file.text(); + const selectedName = file.name; + dialog?.close(); + + try { + const response = await manager.rest.api.AssetResource.previewAttributeConfigurationImport(asset.id!, { + draft: asset, + document: configuration, + }); + showImportPreviewDialog(host, asset, selectedName, response.data, onImported); + } catch (error) { + showImportFailureDialog(host, error); + } + }; + + const dialog = showDialog( + host, + html` + html`

` + )} + ${dialogRenderer( + () => html` + ${dialogStyle} +

+
+ onFileSelected()} + /> + fileInput.value?.click()}> + + + + ${i18next.t("attributeConfiguration.noFileSelected")} +
+ ` + )} + ${dialogFooterRenderer( + () => html` + dialog?.close()}> + + + onContinue()}> + + + ` + )} + >
+ ` + ); +} + +/** Reports what the import would change and applies it once the user confirms. */ +function showImportPreviewDialog( + host: Node, + asset: Asset, + fileName: string, + preview: AttributeConfigurationImportPreview, + onImported: (attributes: AssetAttributes) => void +) { + const patchedAttributes = (preview.patchedAttributes || {}) as AssetAttributes; + const importable = preview.importableAttributes || []; + + const dialog = showDialog( + host, + html` + html`

` + )} + ${dialogRenderer( + () => html` + ${dialogStyle} +

${fileName}

+ ${getAssetTypeMismatchTemplate(preview)} +

+ +

+
+
+ +
+ ${importable.map( + (attribute) => html` +
+
+ ${attributeLabel(asset, patchedAttributes[attribute.name!], attribute.name)} + + ${Object.keys(patchedAttributes[attribute.name!]?.meta || {}).length} + +
+
${metaLabels(asset, patchedAttributes[attribute.name!]).join(", ")}
+
+ ` + )} +
+ ${getSkippedTemplate(preview)} + ` + )} + ${dialogFooterRenderer( + () => html` + dialog?.close()}> + + + { + dialog?.close(); + onImported(patchedAttributes); + showImportResultDialog(host, preview); + }} + > + + + + ` + )} + >
+ ` + ); +} + +/** Reports the applied import, which the user still has to save or discard. */ +function showImportResultDialog(host: Node, preview: AttributeConfigurationImportPreview) { + const names = (preview.importableAttributes || []).map((attribute) => attribute.name).join(", "); + + showConfirmDialog( + host, + html` + + ${getConfirmDialogContent( + "tertiary", + "attributeConfiguration.importHeading", + html` +
${i18next.t("attributeConfiguration.imported", { attributes: names })}
+
+ `, + "close" + )} +
+ ` + ); +} + +/** Reports a document that cannot be imported, with whatever the server could tell about it. */ +function showImportFailureDialog(host: Node, error: unknown) { + const preview = getFailurePreview(error); + const message = preview ? i18next.t(`attributeConfiguration.failure.${preview.failure}`) : i18next.t("errorOccurred"); + + showErrorDialog( + host, + html`${dialogStyle} +
${message}
+ ${preview ? getSkippedTemplate(preview) : ``}`, + "attributeConfiguration.importHeading" + ); +} + +function getAssetTypeMismatchTemplate(preview: AttributeConfigurationImportPreview): TemplateResult | string { + return when( + preview.assetTypeMismatch, + () => html` +
+ + + ${i18next.t("attributeConfiguration.assetTypeMismatch", { + imported: Util.getAssetTypeLabel(preview.importedAssetType), + target: Util.getAssetTypeLabel(preview.targetAssetType), + })} + +
+ `, + () => `` + ); +} + +function getSkippedTemplate(preview: AttributeConfigurationImportPreview): TemplateResult | string { + const missing = preview.missingAttributes || []; + const mismatches = preview.typeMismatches || []; + + if (missing.length === 0 && mismatches.length === 0) { + return ``; + } + + return html` + ${when( + missing.length > 0, + () => html` +
+
+ +
+
${missing.map((attribute) => `${attribute.name} (${attribute.type})`).join(", ")}
+
+ ` + )} + ${when( + mismatches.length > 0, + () => html` +
+
+ +
+
+ ${mismatches + .map((mismatch) => `${mismatch.name} (${mismatch.importedType} → ${mismatch.targetType})`) + .join(", ")} +
+
+ ` + )} + `; +} + +async function downloadAttributeConfiguration(asset: Asset, attributeNames: string[]) { + try { + const response = await manager.rest.api.AssetResource.exportAttributeConfiguration(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; + link.setAttribute("download", `${asset.name}-attribute-config.json`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error("Failed to export attribute configuration", error); + showSnackbar(undefined, "errorOccurred"); + } +} + +/** The report the server sends along with a rejected import, if this is one. */ +function getFailurePreview(error: unknown): AttributeConfigurationImportPreview | undefined { + const data = (error as { response?: { data?: AttributeConfigurationImportPreview } })?.response?.data; + return data?.failure ? data : undefined; +} + +/** The attribute label with its value type, as the Figma dialogs show it, e.g. "Model (Text)". */ +function attributeLabel(asset: Asset, attribute?: Attribute, fallback?: string): string { + if (!attribute) { + return fallback || ""; + } + const descriptor = AssetModelUtil.getAttributeDescriptor(attribute.name!, asset.type!); + const label = Util.getAttributeLabel(attribute, descriptor, asset.type, false, fallback); + return `${label} (${Util.getValueDescriptorLabel(attribute.type)})`; +} + +function metaLabels(asset: Asset, attribute?: Attribute): string[] { + return Object.keys(attribute?.meta || {}) + .map((name) => Util.getMetaLabel(undefined, name, asset.type, false)) + .sort(); +} diff --git a/ui/component/or-asset-viewer/src/index.ts b/ui/component/or-asset-viewer/src/index.ts index 6c73e026d8..d1ce5cf9e3 100644 --- a/ui/component/or-asset-viewer/src/index.ts +++ b/ui/component/or-asset-viewer/src/index.ts @@ -325,7 +325,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; } @@ -339,6 +344,7 @@ export function getPanel(id: string, panelConfig: PanelConfig, content: Template
+ ${actions ? html`
${actions}
` : ``}
${content}
@@ -1472,7 +1478,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 afddc0df50..f3f2cb3dc7 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 @@ -39,6 +39,11 @@ import type { OrIcon } from "@openremote/or-icon"; import { showDialog, OrMwcDialog, type DialogAction } from "@openremote/or-mwc-components/or-mwc-dialog"; import { type ListItem, ListType, type OrMwcList } from "@openremote/or-mwc-components/or-mwc-list"; import "./or-add-attribute-panel"; +import { + type AssetAttributes, + showExportAttributeConfigurationDialog, + showImportAttributeConfigurationDialog, +} from "./attribute-configuration"; import { getField, getPanel, getPropertyTemplate } from "./index"; import type { OrAddAttributePanelAttributeChangedEvent } from "./or-add-attribute-panel"; import { panelStyles } from "./style"; @@ -210,6 +215,10 @@ export class OrEditAssetPanel extends OrElement { @property({ attribute: false }) protected asset!: Asset; + /** Whether the draft holds unsaved changes, which blocks exporting the attribute configuration. */ + @property({ type: Boolean }) + public modified = false; + protected attributeTemplatesAndValidators: TemplateAndValidator[] = []; protected changedAttributes: string[] = []; @@ -379,7 +388,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() + ) || `` + }
`; } @@ -478,6 +494,38 @@ export class OrEditAssetPanel extends OrElement { }; } + protected _getConfigurationActionsTemplate(): TemplateResult { + return html` + this._importConfiguration()}> + + + + showExportAttributeConfigurationDialog(this.shadowRoot!, this.asset)} + > + + + + `; + } + + protected _importConfiguration() { + showImportAttributeConfigurationDialog(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%; From 3724471030e0a3dfd19a97649ecc1aab375e3134 Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Thu, 1 Oct 2026 21:51:23 +0200 Subject: [PATCH 2/4] Match the designs --- ui/app/shared/locales/en/or.json | 20 +- ui/app/shared/locales/nl/or.json | 30 ++ .../src/attribute-configuration.ts | 478 ++++++++++++------ 3 files changed, 357 insertions(+), 171 deletions(-) diff --git a/ui/app/shared/locales/en/or.json b/ui/app/shared/locales/en/or.json index f4d5f8115e..bbebdaee6a 100644 --- a/ui/app/shared/locales/en/or.json +++ b/ui/app/shared/locales/en/or.json @@ -1336,20 +1336,22 @@ "attributeConfiguration": { "import": "Import", "export": "Export", - "exportHeading": "Export attribute configuration", - "exportDescription": "You can save the configuration items of these attributes as a file, to apply the same configuration to similar assets.", - "selectAttributes": "Export the configuration of these attributes", + "exportHeading": "Export configuration template", + "exportDescription": "You can save all the configured attributes and their configuration items as a template, this allows you to reproduce this asset easily", + "selectAttributes": "Save these attributes:", "nothingToExport": "None of the attributes of this asset have configuration items to export", "saveBeforeExport": "Save or revert your changes before exporting", - "importHeading": "Import attribute configuration", - "selectFile": "Select a JSON file to import attribute configuration data", - "chooseFile": "Choose file", - "noFileSelected": "No file selected", - "overwriteWarning": "You are about to import the following attribute configuration. This overwrites the existing configuration items of these attributes in your current draft.", + "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 compatible attributes in your current draft.", "assetTypeMismatch": "This configuration was exported from a {{imported}}, while this asset is a {{target}}.", - "willBeImported": "Will be imported", "skippedMissing": "Skipped, not an attribute of this asset", "skippedTypeMismatch": "Skipped, the attribute has a different type", + "next": "Next", + "warningHeading": "Warning", + "overwriteConfirm": "You are about to overwrite existing attribute values, are you sure?", + "confirm": "Confirm", "imported": "The configuration of these attributes was imported: {{attributes}}", "saveToPersist": "Save the asset to keep these changes, or discard them as usual.", "failure": { diff --git a/ui/app/shared/locales/nl/or.json b/ui/app/shared/locales/nl/or.json index 01f9b9a7f9..22ed6c9784 100644 --- a/ui/app/shared/locales/nl/or.json +++ b/ui/app/shared/locales/nl/or.json @@ -1318,5 +1318,35 @@ } } } + }, + "attributeConfiguration": { + "import": "Importeren", + "export": "Exporteren", + "exportHeading": "Configuratiesjabloon exporteren", + "exportDescription": "Je kunt alle geconfigureerde attributen en hun configuratie-items als sjabloon opslaan, zodat je dit asset eenvoudig kunt reproduceren", + "selectAttributes": "Sla deze attributen op:", + "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": "Configuratiesjabloon 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 compatibele attributen in je huidige concept.", + "assetTypeMismatch": "Deze configuratie is geëxporteerd vanaf een {{imported}}, terwijl dit asset een {{target}} is.", + "skippedMissing": "Overgeslagen, geen attribuut van dit asset", + "skippedTypeMismatch": "Overgeslagen, het attribuut heeft een ander type", + "next": "Volgende", + "warningHeading": "Waarschuwing", + "overwriteConfirm": "Je staat op het punt bestaande attribuutwaarden te overschrijven, weet je het zeker?", + "confirm": "Bevestigen", + "imported": "De configuratie van deze attributen is geïmporteerd: {{attributes}}", + "saveToPersist": "Sla het asset op om deze wijzigingen te behouden, of maak ze zoals gebruikelijk ongedaan.", + "failure": { + "INVALID_JSON": "Het geselecteerde bestand bevat geen geldige JSON.", + "UNSUPPORTED_VERSION": "Het geselecteerde bestand bevat een niet-ondersteunde versie van de attribuutconfiguratie.", + "MISSING_REQUIRED_FIELD": "In het geselecteerde bestand ontbreekt een verplicht veld.", + "MALFORMED_ATTRIBUTE_ENTRY": "Het geselecteerde bestand bevat een ongeldige attribuutvermelding.", + "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/attribute-configuration.ts b/ui/component/or-asset-viewer/src/attribute-configuration.ts index 9325e0ec80..be13ebbb3c 100644 --- a/ui/component/or-asset-viewer/src/attribute-configuration.ts +++ b/ui/component/or-asset-viewer/src/attribute-configuration.ts @@ -29,6 +29,7 @@ import { 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-button"; import "@openremote/or-vaadin-components/or-vaadin-checkbox"; import "@openremote/or-vaadin-components/or-vaadin-dialog"; @@ -44,59 +45,124 @@ import { getConfirmDialogContent, showConfirmDialog, showErrorDialog, + type OrVaadinConfirmDialog, } 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 }; +const DIALOG_WIDTH = "550px"; + // language=CSS const dialogStyle = html` `; @@ -136,50 +202,59 @@ export function showExportAttributeConfigurationDialog(host: Node, asset: Asset) host, html` html`

` - )} + width=${DIALOG_WIDTH} + ${dialogHeaderRenderer(() => getHeaderTemplate("attributeConfiguration.exportHeading", () => dialog?.close()))} ${dialogRenderer( () => html` ${dialogStyle} -

-
- -
- ${attributes.map( - (attribute) => html` -
-
- onToggle(attribute.name!, (ev.currentTarget as OrVaadinCheckbox).checked)} - > - ${Object.keys(attribute.meta || {}).length} -
-
${metaLabels(asset, attribute).join(", ")}
+
+ +
+ +
+ ${attributes.map( + (attribute) => html` +
+
+ + onToggle(attribute.name!, (ev.currentTarget as OrVaadinCheckbox).checked)} + > + ${Object.keys(attribute.meta || {}).length} + + + +
+
+ ${metaLabels(asset, attribute).map((label) => html`${label}`)} +
+
+ ` + )}
- ` - )} +
+
` )} ${dialogFooterRenderer( () => html` - dialog?.close()}> - - - { - dialog?.close(); - downloadAttributeConfiguration(asset, Array.from(selected)); - }} - > - - - +
+ dialog?.close()}> + + + { + dialog?.close(); + downloadAttributeConfiguration(asset, Array.from(selected)); + }} + > + + +
` )} > @@ -199,19 +274,20 @@ export function showImportAttributeConfigurationDialog( let file: File | undefined; const fileName: Ref = createRef(); const fileInput: Ref = createRef(); - const continueBtn: Ref = createRef(); + const importBtn: Ref = createRef(); const onFileSelected = () => { file = fileInput.value?.files?.[0]; if (fileName.value) { fileName.value.textContent = file ? file.name : i18next.t("attributeConfiguration.noFileSelected"); + fileName.value.classList.toggle("ac-placeholder", !file); } - if (continueBtn.value) { - continueBtn.value.disabled = !file; + if (importBtn.value) { + importBtn.value.disabled = !file; } }; - const onContinue = async () => { + const onImport = async () => { if (!file) { return; } @@ -235,38 +311,43 @@ export function showImportAttributeConfigurationDialog( host, html` html`

` - )} + width=${DIALOG_WIDTH} + ${dialogHeaderRenderer(() => getHeaderTemplate("attributeConfiguration.importHeading", () => dialog?.close()))} ${dialogRenderer( () => html` ${dialogStyle} -

-
- onFileSelected()} - /> - fileInput.value?.click()}> - - - - ${i18next.t("attributeConfiguration.noFileSelected")} +
+
+ +
+ onFileSelected()} + /> + fileInput.value?.click()}> + + + + ${i18next.t("attributeConfiguration.noFileSelected")} + +
+
` )} ${dialogFooterRenderer( () => html` - dialog?.close()}> - - - onContinue()}> - - +
+ dialog?.close()}> + + + onImport()}> + + +
` )} > @@ -274,7 +355,7 @@ export function showImportAttributeConfigurationDialog( ); } -/** Reports what the import would change and applies it once the user confirms. */ +/** Reports what the import would change, before the final overwrite warning. */ function showImportPreviewDialog( host: Node, asset: Asset, @@ -289,55 +370,56 @@ function showImportPreviewDialog( host, html` html`

` - )} + width=${DIALOG_WIDTH} + ${dialogHeaderRenderer(() => getHeaderTemplate("attributeConfiguration.importHeading", () => dialog?.close()))} ${dialogRenderer( () => html` ${dialogStyle} -

${fileName}

- ${getAssetTypeMismatchTemplate(preview)} -

- -

-
-
- -
- ${importable.map( - (attribute) => html` -
-
- ${attributeLabel(asset, patchedAttributes[attribute.name!], attribute.name)} - - ${Object.keys(patchedAttributes[attribute.name!]?.meta || {}).length} +
+ ${fileName} + ${getAssetTypeMismatchTemplate(preview)} +
+ + ${importable.map( + (attribute) => html` +
+ + ${attributeLabel(asset, patchedAttributes[attribute.name!], attribute.name)} +
    + ${metaLabels(asset, patchedAttributes[attribute.name!]).map((label) => html`
  • ${label}
  • `)} +
-
${metaLabels(asset, patchedAttributes[attribute.name!]).join(", ")}
-
- ` - )} + ` + )} +
+ ${getSkippedTemplate(preview)}
- ${getSkippedTemplate(preview)} ` )} ${dialogFooterRenderer( () => html` - dialog?.close()}> - - - { - dialog?.close(); - onImported(patchedAttributes); - showImportResultDialog(host, preview); - }} - > - - - +
+ dialog?.close()}> + + + { + dialog?.close(); + showOverwriteWarningDialog( + host, + () => showImportPreviewDialog(host, asset, fileName, preview, onImported), + () => { + onImported(patchedAttributes); + showImportResultDialog(host, preview); + } + ); + }} + > + + +
` )} > @@ -345,6 +427,43 @@ function showImportPreviewDialog( ); } +/** The last step before the draft is changed, which the user can still step back out of. */ +function showOverwriteWarningDialog(host: Node, onBack: () => void, onConfirm: () => void) { + const dialog: Ref = createRef(); + + showConfirmDialog( + host, + html` + onConfirm()}> + ${getConfirmDialogContent( + "", + html` + ${dialogStyle} +
+ { + dialog.value?.close(); + onBack(); + }} + > + + +

+ dialog.value?.close()}> + + +
+ `, + "attributeConfiguration.overwriteConfirm", + "attributeConfiguration.confirm", + "cancel" + )} +
+ ` + ); +} + /** Reports the applied import, which the user still has to save or discard. */ function showImportResultDialog(host: Node, preview: AttributeConfigurationImportPreview) { const names = (preview.importableAttributes || []).map((attribute) => attribute.name).join(", "); @@ -354,7 +473,7 @@ function showImportResultDialog(host: Node, preview: AttributeConfigurationImpor html` ${getConfirmDialogContent( - "tertiary", + "", "attributeConfiguration.importHeading", html`
${i18next.t("attributeConfiguration.imported", { attributes: names })}
@@ -375,17 +494,31 @@ function showImportFailureDialog(host: Node, error: unknown) { showErrorDialog( host, html`${dialogStyle} -
${message}
- ${preview ? getSkippedTemplate(preview) : ``}`, +
+ ${message} + ${preview ? getSkippedTemplate(preview) : ``} +
`, "attributeConfiguration.importHeading" ); } +function getHeaderTemplate(titleKey: string, onClose: () => void): TemplateResult { + return html` + ${dialogStyle} +
+

+ onClose()}> + + +
+ `; +} + function getAssetTypeMismatchTemplate(preview: AttributeConfigurationImportPreview): TemplateResult | string { return when( preview.assetTypeMismatch, () => html` -
+
${i18next.t("attributeConfiguration.assetTypeMismatch", { @@ -408,35 +541,56 @@ function getSkippedTemplate(preview: AttributeConfigurationImportPreview): Templ } return html` - ${when( - missing.length > 0, - () => html` -
-
- -
-
${missing.map((attribute) => `${attribute.name} (${attribute.type})`).join(", ")}
-
- ` - )} - ${when( - mismatches.length > 0, - () => html` -
-
- +
+ ${when( + missing.length > 0, + () => html` +
+ + + +
    + ${missing.map((attribute) => html`
  • ${attribute.name} (${attribute.type})
  • `)} +
-
- ${mismatches - .map((mismatch) => `${mismatch.name} (${mismatch.importedType} → ${mismatch.targetType})`) - .join(", ")} + ` + )} + ${when( + mismatches.length > 0, + () => html` +
+ + + +
    + ${mismatches.map( + (mismatch) => html`
  • ${mismatch.name} (${mismatch.importedType} → ${mismatch.targetType})
  • ` + )} +
-
- ` - )} + ` + )} +
`; } +/** Expands or collapses the metadata of an attribute row. */ +function toggleRow(ev: Event) { + const button = ev.currentTarget as HTMLElement; + const row = button.closest(".ac-row"); + const icon = button.querySelector("or-icon") as OrIcon | null; + + if (!row) { + return; + } + + row.classList.toggle("ac-expanded"); + + if (icon) { + icon.icon = row.classList.contains("ac-expanded") ? "chevron-up" : "chevron-down"; + } +} + async function downloadAttributeConfiguration(asset: Asset, attributeNames: string[]) { try { const response = await manager.rest.api.AssetResource.exportAttributeConfiguration(asset.id!, { From 257fdcac17a94cdc46dad928cd44d71684806a96 Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Fri, 16 Oct 2026 09:14:48 +0200 Subject: [PATCH 3/4] Add generic parameters to attribute configuration import and export --- .../manager/asset/AssetResourceImpl.java | 16 +- .../openremote/model/asset/AssetResource.java | 26 +- .../asset/AttributeConfigurationDocument.java | 12 + ...ttributeConfigurationGenericParameter.java | 61 +++ .../AttributeConfigurationImportFailure.java | 15 + .../AttributeConfigurationImportPreview.java | 26 +- .../AttributeConfigurationImportRequest.java | 20 +- .../util/AttributeConfigurationUtil.java | 404 +++++++++++++++++- .../AttributeConfigurationUtilTest.groovy | 215 +++++++++- .../assets/AttributeConfigurationTest.groovy | 47 +- ui/app/shared/locales/en/or.json | 8 + ui/app/shared/locales/nl/or.json | 8 + .../src/attribute-configuration.ts | 383 ++++++++++++++++- ui/component/or-attribute-input/src/index.ts | 2 + .../src/renderers/agent-link.ts | 9 +- 15 files changed, 1222 insertions(+), 30 deletions(-) create mode 100644 model/src/main/java/org/openremote/model/asset/AttributeConfigurationGenericParameter.java 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 a8b7f59779..e69c7b21de 100644 --- a/manager/src/main/java/org/openremote/manager/asset/AssetResourceImpl.java +++ b/manager/src/main/java/org/openremote/manager/asset/AssetResourceImpl.java @@ -473,9 +473,18 @@ public Asset update(RequestParams requestParams, String assetId, Asset ass @Override public AttributeConfigurationDocument exportAttributeConfiguration( - RequestParams requestParams, String assetId, List attributeNames) { + RequestParams requestParams, + String assetId, + List attributeNames, + List genericPaths) { // Reuses the read access rules of #get, including the protected view for a restricted user - return AttributeConfigurationUtil.export(get(requestParams, assetId), attributeNames); + Asset asset = get(requestParams, assetId); + + try { + return AttributeConfigurationUtil.export(asset, attributeNames, genericPaths); + } catch (IllegalArgumentException e) { + throw new BadRequestException(e.getMessage()); + } } @Override @@ -521,7 +530,8 @@ public AttributeConfigurationImportPreview previewAttributeConfigurationImport( } AttributeConfigurationImportPreview preview = - AttributeConfigurationUtil.preview(draft, request.getDocument()); + AttributeConfigurationUtil.preview( + draft, request.getDocument(), request.getGenericParameterValues()); if (preview.getFailure() != null) { throw importFailure(preview); 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 f569314548..5ee58ed291 100644 --- a/model/src/main/java/org/openremote/model/asset/AssetResource.java +++ b/model/src/main/java/org/openremote/model/asset/AssetResource.java @@ -347,7 +347,10 @@ Asset update( /** * Retrieve the attribute configuration of the asset as a user editable document, holding the type * and metadata of every attribute that has metadata. Repeat the attributeName query parameter to - * export a subset of them. Access follows the same rules as {@link #get}. + * export a subset of them, and the genericPath query parameter to leave values out for the user + * to supply on import. A 400 status is returned if a generic path does not hold the same text, + * boolean or number in every exported attribute holding it. Access follows the same rules as + * {@link #get}. */ @GET @Path("{assetId}/attributeConfiguration") @@ -357,7 +360,7 @@ Asset update( operationId = "exportAttributeConfiguration", summary = "Export the attribute configuration 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. Attributes without metadata are never included.") + "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. Attributes without metadata are never included. Values at the requested generic paths are left out and recorded as generic parameters, named after their path, for the user to supply on import.") @OpenApiResponses.Ok @OpenApiResponses.Authenticated @OpenApiResponses.BadRequest @@ -372,14 +375,23 @@ AttributeConfigurationDocument exportAttributeConfiguration( style = ParameterStyle.FORM, explode = Explode.TRUE) @QueryParam("attributeName") - List attributeNames); + List attributeNames, + @Parameter( + description = + "Paths relative to an attribute, such as meta.agentLink.id, whose value becomes a generic parameter. Each must hold the same text, boolean or number in every exported attribute holding it. Repeat the query parameter for multiple paths.", + example = "meta.agentLink.id", + style = ParameterStyle.FORM, + explode = Explode.TRUE) + @QueryParam("genericPath") + List genericPaths); /** * Validate an attribute configuration document 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 the document cannot be imported, and a 409 status if the draft no - * longer identifies the persisted asset. + * 400 status is returned if the document cannot be imported, including while its generic + * parameters lack a valid value, and a 409 status if the draft no longer identifies the persisted + * asset. */ @POST @Path("{assetId}/attributeConfiguration/import") @@ -390,7 +402,7 @@ AttributeConfigurationDocument exportAttributeConfiguration( operationId = "previewAttributeConfigurationImport", summary = "Preview an attribute configuration 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. Nothing is stored; the client applies the patch after the user confirms.") + "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. When the document has generic parameters, the report lists those needing a value and is rejected until valid values are submitted. Nothing is stored; the client applies the patch after the user confirms.") @OpenApiResponses.Ok @OpenApiResponses.Authenticated @OpenApiResponses.BadRequest @@ -403,7 +415,7 @@ AttributeConfigurationImportPreview previewAttributeConfigurationImport( @RequestBody( required = true, description = - "The asset draft being edited and the contents of the selected configuration file.") + "The asset draft being edited, the contents of the selected configuration file and the values of its generic parameters.") @NotNull AttributeConfigurationImportRequest request); /** diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationDocument.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationDocument.java index fd55f0518c..19c8771529 100644 --- a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationDocument.java +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationDocument.java @@ -42,6 +42,11 @@ public class AttributeConfigurationDocument { @Schema(description = "Asset type the configuration was exported from.") protected String assetType; + @Schema( + description = + "Values left out of the attribute configuration, keyed by parameter name, for the user to supply on import.") + protected Map genericParameters; + @Schema(description = "Attribute configuration keyed by attribute name.") protected Map attributes; @@ -49,9 +54,12 @@ public class AttributeConfigurationDocument { public AttributeConfigurationDocument( @JsonProperty("version") int version, @JsonProperty("assetType") String assetType, + @JsonProperty("genericParameters") + Map genericParameters, @JsonProperty("attributes") Map attributes) { this.version = version; this.assetType = assetType; + this.genericParameters = genericParameters; this.attributes = attributes; } @@ -63,6 +71,10 @@ public String getAssetType() { return assetType; } + public Map getGenericParameters() { + return genericParameters; + } + public Map getAttributes() { return attributes; } diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationGenericParameter.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationGenericParameter.java new file mode 100644 index 0000000000..7d6c7a1ebd --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationGenericParameter.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; +import java.util.List; + +/** + * A value that an {@link AttributeConfigurationDocument} leaves out and asks for on import, applied + * to every path it lists. The parameter name is the document map key, so it is not repeated here. + */ +@Schema( + description = + "A value supplied on import and applied to every listed path; the parameter name is the key within the document.") +public class AttributeConfigurationGenericParameter { + + @Schema( + description = + "Value descriptor name of the value, one of text, boolean, integer, long or number.", + example = "text") + protected String type; + + @Schema( + description = + "Dot separated paths within the document that receive the value, each of the form attributes..meta.[....].", + example = "[\"attributes.error.meta.agentLink.id\"]") + protected List paths; + + @JsonCreator + public AttributeConfigurationGenericParameter( + @JsonProperty("type") String type, @JsonProperty("paths") List paths) { + this.type = type; + this.paths = paths; + } + + public String getType() { + return type; + } + + public List getPaths() { + return paths; + } +} diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportFailure.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportFailure.java index 04d67e03f2..43ada6a6b6 100644 --- a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportFailure.java +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportFailure.java @@ -33,9 +33,24 @@ public enum AttributeConfigurationImportFailure { /** The attributes field or one of its entries does not hold a type and a metadata object. */ MALFORMED_ATTRIBUTE_ENTRY, + /** + * The generic parameters field or one of its entries does not hold a supported type and paths to + * existing metadata of the document. + */ + MALFORMED_GENERIC_PARAMETER, + /** No attribute of the document exists on the target asset with the same type. */ NO_IMPORTABLE_ATTRIBUTES, + /** A generic parameter of an importable attribute has no value. */ + GENERIC_PARAMETER_VALUES_REQUIRED, + + /** + * A generic parameter value does not match its type or the schema of the metadata it is applied + * to. + */ + INVALID_GENERIC_PARAMETER_VALUE, + /** The submitted draft no longer identifies the persisted target asset. */ DRAFT_MISMATCH } diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportPreview.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportPreview.java index e4b569c972..87a146e922 100644 --- a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportPreview.java +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportPreview.java @@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; +import java.util.Map; /** * What importing an {@link AttributeConfigurationDocument} into an asset draft would do, for the @@ -55,6 +56,16 @@ public class AttributeConfigurationImportPreview { @Schema(description = "Attributes skipped because the target attribute has a different type.") protected List typeMismatches; + @Schema( + description = + "Generic parameters that need a value, keyed by parameter name, listing only the paths of importable attributes.") + protected Map genericParameters; + + @Schema( + description = + "Names of the generic parameters whose value does not match their type or the schema of the metadata they are applied to.") + protected List invalidGenericParameters; + @Schema( description = "The submitted draft attributes with the metadata of every importable attribute replaced, keyed by attribute name.") @@ -70,6 +81,9 @@ public AttributeConfigurationImportPreview( List importableAttributes, @JsonProperty("missingAttributes") List missingAttributes, @JsonProperty("typeMismatches") List typeMismatches, + @JsonProperty("genericParameters") + Map genericParameters, + @JsonProperty("invalidGenericParameters") List invalidGenericParameters, @JsonProperty("patchedAttributes") ObjectNode patchedAttributes) { this.failure = failure; this.importedAssetType = importedAssetType; @@ -78,13 +92,15 @@ public AttributeConfigurationImportPreview( this.importableAttributes = importableAttributes; this.missingAttributes = missingAttributes; this.typeMismatches = typeMismatches; + this.genericParameters = genericParameters; + this.invalidGenericParameters = invalidGenericParameters; this.patchedAttributes = patchedAttributes; } public static AttributeConfigurationImportPreview failed( AttributeConfigurationImportFailure failure) { return new AttributeConfigurationImportPreview( - failure, null, null, false, List.of(), List.of(), List.of(), null); + failure, null, null, false, List.of(), List.of(), List.of(), Map.of(), List.of(), null); } public AttributeConfigurationImportFailure getFailure() { @@ -115,6 +131,14 @@ public List getTypeMismatches() { return typeMismatches; } + public Map getGenericParameters() { + return genericParameters; + } + + public List getInvalidGenericParameters() { + return invalidGenericParameters; + } + public ObjectNode getPatchedAttributes() { return patchedAttributes; } diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportRequest.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportRequest.java index 101e7e3e41..28d3340278 100644 --- a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportRequest.java +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportRequest.java @@ -20,8 +20,10 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +import java.util.Map; /** * An {@link AttributeConfigurationDocument} to import, together with the asset draft it is imported @@ -39,11 +41,23 @@ public class AttributeConfigurationImportRequest { @Schema(description = "Contents of the selected attribute configuration file.") @NotNull protected String document; + @Schema( + description = + "Values of the generic parameters of the document, keyed by parameter name; absent until the user has supplied them.") + protected Map genericParameterValues; + + public AttributeConfigurationImportRequest(Asset draft, String document) { + this(draft, document, null); + } + @JsonCreator public AttributeConfigurationImportRequest( - @JsonProperty("draft") Asset draft, @JsonProperty("document") String document) { + @JsonProperty("draft") Asset draft, + @JsonProperty("document") String document, + @JsonProperty("genericParameterValues") Map genericParameterValues) { this.draft = draft; this.document = document; + this.genericParameterValues = genericParameterValues; } public Asset getDraft() { @@ -53,4 +67,8 @@ public Asset getDraft() { public String getDocument() { return document; } + + public Map getGenericParameterValues() { + return genericParameterValues; + } } diff --git a/model/src/main/java/org/openremote/model/util/AttributeConfigurationUtil.java b/model/src/main/java/org/openremote/model/util/AttributeConfigurationUtil.java index b6e4f32c92..0743ed8bca 100644 --- a/model/src/main/java/org/openremote/model/util/AttributeConfigurationUtil.java +++ b/model/src/main/java/org/openremote/model/util/AttributeConfigurationUtil.java @@ -24,21 +24,31 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; 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 java.util.TreeSet; +import java.util.function.BiConsumer; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.openremote.model.asset.Asset; import org.openremote.model.asset.AttributeConfiguration; import org.openremote.model.asset.AttributeConfigurationDocument; +import org.openremote.model.asset.AttributeConfigurationGenericParameter; import org.openremote.model.asset.AttributeConfigurationImportPreview; import org.openremote.model.asset.AttributeConfigurationSummary; import org.openremote.model.asset.AttributeConfigurationTypeMismatch; import org.openremote.model.attribute.Attribute; import org.openremote.model.attribute.AttributeMap; +import org.openremote.model.value.ValueType; /** * Reads and writes {@link AttributeConfigurationDocument}s. Callers are responsible for enforcing @@ -46,6 +56,21 @@ */ public final class AttributeConfigurationUtil { + /** Separates the segments of a generic parameter path. */ + public static final String PATH_SEPARATOR = "."; + + /** Value descriptor names a generic parameter can have, which are those of JSON scalars. */ + public static final Set GENERIC_PARAMETER_TYPES = + Set.of( + ValueType.TEXT.getName(), + ValueType.BOOLEAN.getName(), + ValueType.INTEGER.getName(), + ValueType.LONG.getName(), + ValueType.NUMBER.getName()); + + protected static final String ATTRIBUTES_SEGMENT = "attributes"; + protected static final String META_SEGMENT = "meta"; + private AttributeConfigurationUtil() {} /** @@ -54,6 +79,20 @@ private AttributeConfigurationUtil() {} */ public static AttributeConfigurationDocument export( Asset asset, Collection attributeNames) { + return export(asset, attributeNames, null); + } + + /** + * Builds the export document of an asset like {@link #export(Asset, Collection)}, leaving out the + * values at the given generic paths. Each generic path is relative to an attribute, e.g. {@code + * meta.agentLink.id}, and must be one of the {@link #findGenericPathCandidates} of the exported + * attributes. Its value is recorded as a generic parameter instead, named by {@link + * #deriveGenericParameterName}. + * + * @throws IllegalArgumentException if a generic path is not a candidate + */ + public static AttributeConfigurationDocument export( + Asset asset, Collection attributeNames, Collection genericPaths) { boolean selectAll = attributeNames == null || attributeNames.isEmpty(); Map attributes = new TreeMap<>(); @@ -69,8 +108,59 @@ public static AttributeConfigurationDocument export( typeName(attribute), toObjectNode(attribute.getMeta())))); } + Map genericParameters = + extractGenericParameters(attributes, genericPaths); + return new AttributeConfigurationDocument( - AttributeConfigurationDocument.VERSION, asset.getType(), attributes); + AttributeConfigurationDocument.VERSION, + asset.getType(), + genericParameters.isEmpty() ? null : genericParameters, + attributes); + } + + /** + * Finds the metadata values that can become a generic parameter: text, boolean and number values + * reached through object properties only, which are equal in every attribute holding them. + * Returns their paths relative to an attribute, e.g. {@code meta.agentLink.id}, mapped to the + * names of the attributes holding them. + */ + public static Map> findGenericPathCandidates( + Map attributes) { + Map> holders = new TreeMap<>(); + Map values = new HashMap<>(); + Set differing = new HashSet<>(); + + new TreeMap<>(attributes) + .forEach( + (name, configuration) -> + collectScalars( + configuration.getMeta(), + META_SEGMENT, + (path, value) -> { + holders.computeIfAbsent(path, key -> new ArrayList<>()).add(name); + JsonNode first = values.putIfAbsent(path, value); + if (first != null && !first.equals(value)) { + differing.add(path); + } + })); + + differing.forEach(holders::remove); + return holders; + } + + /** + * Derives a generic parameter name from a path relative to an attribute by joining the segments + * after {@code meta} in camel case, so {@code meta.agentLink.id} becomes {@code agentLinkId}. + */ + public static String deriveGenericParameterName(String path) { + String[] segments = splitPath(path); + StringBuilder name = new StringBuilder(segments[1]); + + for (int i = 2; i < segments.length; i++) { + name.append(Character.toUpperCase(segments[i].charAt(0))).append(segments[i].substring(1)); + } + + return name.toString(); } /** @@ -80,6 +170,17 @@ public static AttributeConfigurationDocument export( * metadata of every importable attribute replaced. */ public static AttributeConfigurationImportPreview preview(Asset draft, String document) { + return preview(draft, document, null); + } + + /** + * Validates a document against an asset draft like {@link #preview(Asset, String)}, applying the + * given generic parameter values to the imported metadata. Every generic parameter with a path in + * an importable attribute needs a value of its type, which must also read as the value type of + * the meta item it is applied to. + */ + public static AttributeConfigurationImportPreview preview( + Asset draft, String document, Map genericParameterValues) { JsonNode root; try { @@ -95,6 +196,7 @@ public static AttributeConfigurationImportPreview preview(Asset draft, String JsonNode version = root.get("version"); JsonNode assetType = root.get("assetType"); JsonNode attributes = root.get("attributes"); + JsonNode genericParameters = root.get("genericParameters"); if (isAbsent(version) || isAbsent(assetType) || isAbsent(attributes)) { return AttributeConfigurationImportPreview.failed(MISSING_REQUIRED_FIELD); @@ -126,6 +228,25 @@ public static AttributeConfigurationImportPreview preview(Asset draft, String entry.getKey(), new AttributeConfiguration(type.asText(), (ObjectNode) meta.deepCopy())); } + Map declared = new LinkedHashMap<>(); + if (!isAbsent(genericParameters)) { + if (!genericParameters.isObject()) { + return AttributeConfigurationImportPreview.failed(MALFORMED_GENERIC_PARAMETER); + } + + Set declaredPaths = new HashSet<>(); + for (Map.Entry entry : genericParameters.properties()) { + AttributeConfigurationGenericParameter parameter = + readGenericParameter(entry.getValue(), imported, declaredPaths); + + if (parameter == null) { + return AttributeConfigurationImportPreview.failed(MALFORMED_GENERIC_PARAMETER); + } + + declared.put(entry.getKey(), parameter); + } + } + AttributeMap targetAttributes = draft.getAttributes() != null ? draft.getAttributes() : new AttributeMap(); List importable = new ArrayList<>(); @@ -164,6 +285,73 @@ public static AttributeConfigurationImportPreview preview(Asset draft, String importable, missing, typeMismatches, + Map.of(), + List.of(), + null); + } + + // Skipped attributes are not patched, so only parameters of importable ones need a value + Set importableNames = + importable.stream().map(AttributeConfigurationSummary::getName).collect(Collectors.toSet()); + Map required = new TreeMap<>(); + declared.forEach( + (name, parameter) -> { + List paths = + parameter.getPaths().stream() + .filter(path -> importableNames.contains(splitPath(path)[1])) + .toList(); + if (!paths.isEmpty()) { + required.put( + name, new AttributeConfigurationGenericParameter(parameter.getType(), paths)); + } + }); + + Map values = + genericParameterValues != null ? genericParameterValues : Map.of(); + + if (required.keySet().stream().anyMatch(name -> isAbsent(values.get(name)))) { + return new AttributeConfigurationImportPreview( + GENERIC_PARAMETER_VALUES_REQUIRED, + assetType.asText(), + draft.getType(), + assetTypeMismatch, + importable, + missing, + typeMismatches, + required, + List.of(), + null); + } + + List invalid = + required.entrySet().stream() + .filter(entry -> !isOfType(values.get(entry.getKey()), entry.getValue().getType())) + .map(Map.Entry::getKey) + .toList(); + + if (invalid.isEmpty()) { + required.forEach( + (name, parameter) -> + parameter + .getPaths() + .forEach( + path -> + resolveParent(imported, path) + .set(leafName(path), values.get(name).deepCopy()))); + invalid = findSchemaViolations(imported, required); + } + + if (!invalid.isEmpty()) { + return new AttributeConfigurationImportPreview( + INVALID_GENERIC_PARAMETER_VALUE, + assetType.asText(), + draft.getType(), + assetTypeMismatch, + importable, + missing, + typeMismatches, + required, + invalid, null); } @@ -181,9 +369,223 @@ public static AttributeConfigurationImportPreview preview(Asset draft, String importable, missing, typeMismatches, + required, + List.of(), patchedAttributes); } + /** + * Removes the values at the generic paths from the attributes and records them as generic + * parameters, keyed by a name that is unique within the document. + */ + protected static Map extractGenericParameters( + Map attributes, Collection genericPaths) { + Map parameters = new TreeMap<>(); + + if (genericPaths == null || genericPaths.isEmpty()) { + return parameters; + } + + // Find every candidate before removing any value, as the removal changes what is held + Map> candidates = findGenericPathCandidates(attributes); + + for (String path : new TreeSet<>(genericPaths)) { + List holders = candidates.get(path); + + if (holders == null) { + throw new IllegalArgumentException( + "Not a value that is equal in every exported attribute holding it: " + path); + } + + String[] segments = splitPath(path); + int leaf = segments.length - 1; + String type = null; + List paths = new ArrayList<>(); + + for (String holder : holders) { + ObjectNode parent = + (ObjectNode) resolve(attributes.get(holder).getMeta(), segments, 1, leaf); + type = scalarType(parent.remove(segments[leaf])); + paths.add(String.join(PATH_SEPARATOR, ATTRIBUTES_SEGMENT, holder, path)); + } + + String name = deriveGenericParameterName(path); + String uniqueName = name; + for (int i = 2; parameters.containsKey(uniqueName); i++) { + uniqueName = name + i; + } + + parameters.put(uniqueName, new AttributeConfigurationGenericParameter(type, paths)); + } + + return parameters; + } + + /** + * Reads a generic parameter declaration, or returns null if it is malformed. Each path must lead + * to a value of the document that is absent or a scalar, and may only be declared once. + */ + protected static AttributeConfigurationGenericParameter readGenericParameter( + JsonNode node, Map attributes, Set declaredPaths) { + JsonNode type = node.isObject() ? node.get("type") : null; + JsonNode paths = node.isObject() ? node.get("paths") : null; + + if (type == null || !type.isTextual() || !GENERIC_PARAMETER_TYPES.contains(type.asText())) { + return null; + } + if (paths == null || !paths.isArray() || paths.isEmpty()) { + return null; + } + + List result = new ArrayList<>(); + for (JsonNode path : paths) { + if (!path.isTextual() || !declaredPaths.add(path.asText())) { + return null; + } + + ObjectNode parent = resolveParent(attributes, path.asText()); + JsonNode value = parent != null ? parent.get(leafName(path.asText())) : null; + + if (parent == null || (value != null && value.isContainerNode())) { + return null; + } + + result.add(path.asText()); + } + + return new AttributeConfigurationGenericParameter(type.asText(), result); + } + + /** + * Names of the parameters applied to metadata that does not read as the value type of its meta + * item descriptor. Metadata without a descriptor has no schema to check against. + */ + protected static List findSchemaViolations( + Map attributes, + Map parameters) { + Set invalid = new TreeSet<>(); + + parameters.forEach( + (name, parameter) -> + parameter + .getPaths() + .forEach( + path -> { + String[] segments = splitPath(path); + JsonNode metaValue = attributes.get(segments[1]).getMeta().get(segments[3]); + + ValueUtil.getMetaItemDescriptor(segments[3]) + .ifPresent( + descriptor -> { + try { + ValueUtil.JSON.treeToValue( + metaValue, descriptor.getType().getType()); + } catch (JsonProcessingException | IllegalArgumentException e) { + invalid.add(name); + } + }); + })); + + return new ArrayList<>(invalid); + } + + /** + * The object holding the value at a document path of the form {@code + * attributes..meta.[....]}, or null if the path has another form + * or does not lead through objects of the given attributes. + */ + protected static ObjectNode resolveParent( + Map attributes, String path) { + String[] segments = splitPath(path); + + if (segments.length < 4 + || !ATTRIBUTES_SEGMENT.equals(segments[0]) + || !META_SEGMENT.equals(segments[2]) + || Arrays.stream(segments).anyMatch(String::isEmpty) + || !attributes.containsKey(segments[1])) { + return null; + } + + JsonNode parent = + resolve(attributes.get(segments[1]).getMeta(), segments, 3, segments.length - 1); + return parent != null && parent.isObject() ? (ObjectNode) parent : null; + } + + /** + * The node reached through the object properties named by the segments from the first index up to + * the second, or null. + */ + protected static JsonNode resolve(JsonNode node, String[] segments, int from, int to) { + for (int i = from; i < to && node != null; i++) { + node = node.isObject() ? node.get(segments[i]) : null; + } + return node; + } + + /** + * Calls the consumer with the path and value of each scalar reached through object properties. + */ + protected static void collectScalars( + JsonNode node, String path, BiConsumer consumer) { + if (node != null && node.isObject()) { + node.properties().stream() + .filter(property -> !property.getKey().isEmpty()) + .filter(property -> !property.getKey().contains(PATH_SEPARATOR)) + .forEach( + property -> + collectScalars( + property.getValue(), path + PATH_SEPARATOR + property.getKey(), consumer)); + } else if (scalarType(node) != null) { + consumer.accept(path, node); + } + } + + /** The generic parameter type of a JSON scalar, or null if the node is not one. */ + protected static String scalarType(JsonNode node) { + if (node == null) { + return null; + } + if (node.isTextual()) { + return ValueType.TEXT.getName(); + } + if (node.isBoolean()) { + return ValueType.BOOLEAN.getName(); + } + if (node.isIntegralNumber() && node.canConvertToInt()) { + return ValueType.INTEGER.getName(); + } + if (node.isIntegralNumber() && node.canConvertToLong()) { + return ValueType.LONG.getName(); + } + if (node.isNumber()) { + return ValueType.NUMBER.getName(); + } + return null; + } + + /** Whether a value fits a generic parameter type, a wider number type taking narrower ones. */ + protected static boolean isOfType(JsonNode value, String type) { + if (value == null) { + return false; + } + if (ValueType.NUMBER.getName().equals(type)) { + return value.isNumber(); + } + if (ValueType.LONG.getName().equals(type)) { + return value.isIntegralNumber() && value.canConvertToLong(); + } + return Objects.equals(type, scalarType(value)); + } + + protected static String[] splitPath(String path) { + return path.split(Pattern.quote(PATH_SEPARATOR), -1); + } + + protected static String leafName(String path) { + String[] segments = splitPath(path); + return segments[segments.length - 1]; + } + protected static boolean isAbsent(JsonNode node) { return node == null || node.isNull(); } diff --git a/model/src/test/groovy/org/openremote/model/util/AttributeConfigurationUtilTest.groovy b/model/src/test/groovy/org/openremote/model/util/AttributeConfigurationUtilTest.groovy index f9e22c7afb..278233f672 100644 --- a/model/src/test/groovy/org/openremote/model/util/AttributeConfigurationUtilTest.groovy +++ b/model/src/test/groovy/org/openremote/model/util/AttributeConfigurationUtilTest.groovy @@ -18,6 +18,7 @@ */ package org.openremote.model.util +import com.fasterxml.jackson.databind.JsonNode import org.openremote.model.asset.AttributeConfigurationDocument import org.openremote.model.asset.impl.RoomAsset import org.openremote.model.asset.impl.ThingAsset @@ -48,7 +49,7 @@ class AttributeConfigurationUtilTest extends Specification { return attribute } - static String document(String assetType = ThingAsset.DESCRIPTOR.name, Map entries = [:], Integer version = 1) { + static String document(String assetType = ThingAsset.DESCRIPTOR.name, Map entries = [:], Integer version = 1, String genericParameters = null) { def attributes = entries.collect { name, entry -> "\"${name}\": ${entry}" }.join(",") def fields = [] if (version != null) { @@ -57,10 +58,36 @@ class AttributeConfigurationUtilTest extends Specification { if (assetType != null) { fields.add("\"assetType\": \"${assetType}\"") } + if (genericParameters != null) { + fields.add("\"genericParameters\": ${genericParameters}") + } fields.add("\"attributes\": {${attributes}}") return "{${fields.join(",")}}" } + static Map values(Map entries) { + return entries.collectEntries { name, value -> + [(name): ValueUtil.JSON.valueToTree(value)] + } as Map + } + + /** A pump whose attributes are linked to the same agent, each at another write value. */ + static def linkedPump() { + return thingAsset([ + pumpOn : attribute("pumpOn", ValueType.BOOLEAN, [ + (MetaItemType.LABEL.name) : "Pump on", + (MetaItemType.AGENT_LINK.name): [id: "agent1", type: "DefaultAgentLink", writeValue: "on", updateOnWrite: true] + ]), + error : attribute("error", ValueType.BOOLEAN, [ + (MetaItemType.LABEL.name) : "Error", + (MetaItemType.READ_ONLY.name) : true, + (MetaItemType.UNITS.name) : ["celsius"], + (MetaItemType.AGENT_LINK.name): [id: "agent1", type: "DefaultAgentLink", writeValue: "error", updateOnWrite: true] + ]), + plain : attribute("plain", ValueType.TEXT) + ]) + } + def "export holds the type and metadata of attributes that have metadata"() { given: "an asset with configured and unconfigured attributes" def asset = thingAsset([ @@ -229,4 +256,190 @@ class AttributeConfigurationUtilTest extends Specification { preview.importableAttributes*.name == ["temperature"] preview.patchedAttributes.get("temperature").get("meta").get("label").asText() == "Imported" } + + def "generic path candidates are the values held equally by every selected attribute holding them"() { + given: "a pump whose attributes share an agent but differ in label and write value" + def asset = linkedPump() + + when: "the candidates of all configured attributes are found" + def candidates = AttributeConfigurationUtil.findGenericPathCandidates(AttributeConfigurationUtil.export(asset, null).attributes) + + then: "shared values and values held by a single attribute are candidates, differing values and arrays are not" + candidates.keySet() as List == [ + "meta.agentLink.id", + "meta.agentLink.type", + "meta.agentLink.updateOnWrite", + "meta.readOnly" + ] + candidates["meta.agentLink.id"] == ["error", "pumpOn"] + candidates["meta.readOnly"] == ["error"] + + when: "the candidates of a single attribute are found" + def selected = AttributeConfigurationUtil.findGenericPathCandidates(AttributeConfigurationUtil.export(asset, ["pumpOn"]).attributes) + + then: "the value that differs from the unselected attribute is a candidate" + selected.containsKey("meta.agentLink.writeValue") + } + + def "a generic parameter name is derived from its path as #name"() { + expect: + AttributeConfigurationUtil.deriveGenericParameterName(path) == name + + where: + path | name + "meta.agentLink.id" | "agentLinkId" + "meta.agentLink.unitId" | "agentLinkUnitId" + "meta.label" | "label" + } + + def "export leaves a generic value out of every attribute holding it and records where it belongs"() { + given: "a pump whose attributes share an agent" + def asset = linkedPump() + + when: "the configuration is exported with the agent link id as a generic path" + def exported = AttributeConfigurationUtil.export(asset, null, ["meta.agentLink.id"]) + + then: "a single parameter named after the path records its type and both attribute paths" + exported.version == AttributeConfigurationDocument.VERSION + exported.genericParameters.keySet() == ["agentLinkId"] as Set + exported.genericParameters.agentLinkId.type == ValueType.TEXT.name + exported.genericParameters.agentLinkId.paths == ["attributes.error.meta.agentLink.id", "attributes.pumpOn.meta.agentLink.id"] + + and: "the value is removed from both attributes while the rest of their metadata is kept" + !exported.attributes.error.meta.get("agentLink").has("id") + !exported.attributes.pumpOn.meta.get("agentLink").has("id") + exported.attributes.error.meta.get("agentLink").get("writeValue").asText() == "error" + exported.attributes.pumpOn.meta.get(MetaItemType.LABEL.name).asText() == "Pump on" + + and: "the asset itself keeps the value, so exporting without generic paths still holds it" + def plain = AttributeConfigurationUtil.export(asset, null, []) + plain.genericParameters == null + plain.attributes.error.meta.get("agentLink").get("id").asText() == "agent1" + } + + def "export rejects the generic path #path"() { + given: "a pump whose attributes share an agent but differ in write value" + def asset = linkedPump() + + when: "the configuration is exported with a path that cannot be generic" + AttributeConfigurationUtil.export(asset, null, [path]) + + then: "the export is rejected" + thrown(IllegalArgumentException) + + where: + path << [ + "meta.agentLink.writeValue", + "meta.units", + "meta.agentLink", + "meta.absent", + "agentLink.id" + ] + } + + def "a generic parameter is asked for once and applied to every attribute holding it"() { + given: "a document exported with the agent link id of two attributes as a generic parameter" + def document = ValueUtil.asJSON(AttributeConfigurationUtil.export(linkedPump(), null, ["meta.agentLink.id"])).orElseThrow() + + and: "a pump that is not linked yet" + def target = thingAsset([ + pumpOn: attribute("pumpOn", ValueType.BOOLEAN), + error : attribute("error", ValueType.BOOLEAN) + ]) + + when: "the document is previewed without a value" + def preview = AttributeConfigurationUtil.preview(target, document) + + then: "the single parameter is reported with both attribute paths" + preview.failure == GENERIC_PARAMETER_VALUES_REQUIRED + preview.importableAttributes*.name == ["error", "pumpOn"] + preview.genericParameters.keySet() == ["agentLinkId"] as Set + preview.genericParameters.agentLinkId.type == ValueType.TEXT.name + preview.genericParameters.agentLinkId.paths == ["attributes.error.meta.agentLink.id", "attributes.pumpOn.meta.agentLink.id"] + preview.patchedAttributes == null + + when: "the document is previewed with a value for the parameter" + preview = AttributeConfigurationUtil.preview(target, document, values([agentLinkId: "agent2"])) + + then: "the value is applied to both attributes, next to the metadata that was exported" + preview.failure == null + preview.patchedAttributes.get("error").get("meta").get("agentLink").get("id").asText() == "agent2" + preview.patchedAttributes.get("pumpOn").get("meta").get("agentLink").get("id").asText() == "agent2" + preview.patchedAttributes.get("pumpOn").get("meta").get("agentLink").get("writeValue").asText() == "on" + } + + def "a generic parameter held only by skipped attributes needs no value"() { + given: "a document with a generic parameter held by the error attribute alone" + def document = ValueUtil.asJSON(AttributeConfigurationUtil.export(linkedPump(), null, ["meta.readOnly"])).orElseThrow() + + and: "a pump without that attribute" + def target = thingAsset([pumpOn: attribute("pumpOn", ValueType.BOOLEAN)]) + + when: "the document is previewed without a value" + def preview = AttributeConfigurationUtil.preview(target, document) + + then: "the remaining attribute is imported without asking for the parameter" + preview.failure == null + preview.missingAttributes*.name == ["error"] + preview.genericParameters.isEmpty() + preview.patchedAttributes.get("pumpOn").get("meta").get(MetaItemType.LABEL.name).asText() == "Pump on" + } + + def "a generic parameter value is checked against the type #type and the metadata schema"() { + given: "an asset holding a linked number attribute" + def asset = thingAsset([temperature: attribute("temperature", ValueType.NUMBER)]) + + and: "a document with a generic parameter for one of its metadata values" + def documentText = document(ThingAsset.DESCRIPTOR.name, [ + temperature: '{"type": "number", "meta": {"readOnly": true, "agentLink": {"id": "agent1", "type": "DefaultAgentLink"}}}' + ], 1, "{\"parameter\": {\"type\": \"${type}\", \"paths\": [\"attributes.temperature.meta.${path}\"]}}") + + when: "the document is previewed with the value" + def preview = AttributeConfigurationUtil.preview(asset, documentText, values([parameter: value])) + + then: "the value is accepted only when it fits" + preview.failure == (valid ? null : INVALID_GENERIC_PARAMETER_VALUE) + preview.invalidGenericParameters == (valid ? [] : ["parameter"]) + + where: + type | path | value | valid + "text" | "agentLink.id" | "agent2" | true + "text" | "agentLink.id" | 5 | false + "boolean" | "readOnly" | false | true + "boolean" | "readOnly" | "yes" | false + "integer" | "custom" | 5 | true + "integer" | "custom" | 1.5 | false + "long" | "custom" | 5 | true + "number" | "custom" | 5 | true + "text" | "readOnly" | "notABoolean" | false + "text" | "agentLink.updateOnWrite" | "sometimes" | false + } + + def "a malformed generic parameter declaration is rejected"() { + given: "an asset holding a number attribute" + def asset = thingAsset([temperature: attribute("temperature", ValueType.NUMBER)]) + + expect: "the import is rejected" + AttributeConfigurationUtil.preview(asset, document(ThingAsset.DESCRIPTOR.name, [ + temperature: '{"type": "number", "meta": {"label": "Temperature", "agentLink": {"id": "agent1", "type": "DefaultAgentLink"}}}' + ], 1, genericParameters), values([p: "value", q: "value"])).failure == MALFORMED_GENERIC_PARAMETER + + where: + genericParameters << [ + '5', + '{"p": 5}', + '{"p": {"paths": ["attributes.temperature.meta.label"]}}', + '{"p": {"type": "color", "paths": ["attributes.temperature.meta.label"]}}', + '{"p": {"type": "text"}}', + '{"p": {"type": "text", "paths": []}}', + '{"p": {"type": "text", "paths": [5]}}', + '{"p": {"type": "text", "paths": ["temperature.meta.label"]}}', + '{"p": {"type": "text", "paths": ["attributes.temperature.label"]}}', + '{"p": {"type": "text", "paths": ["attributes.temperature.meta.label."]}}', + '{"p": {"type": "text", "paths": ["attributes.absent.meta.label"]}}', + '{"p": {"type": "text", "paths": ["attributes.temperature.meta.missing.id"]}}', + '{"p": {"type": "text", "paths": ["attributes.temperature.meta.agentLink"]}}', + '{"p": {"type": "text", "paths": ["attributes.temperature.meta.label"]}, "q": {"type": "text", "paths": ["attributes.temperature.meta.label"]}}' + ] + } } diff --git a/test/src/test/groovy/org/openremote/test/assets/AttributeConfigurationTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AttributeConfigurationTest.groovy index c520e1cdc8..bb0056114f 100644 --- a/test/src/test/groovy/org/openremote/test/assets/AttributeConfigurationTest.groovy +++ b/test/src/test/groovy/org/openremote/test/assets/AttributeConfigurationTest.groovy @@ -18,6 +18,7 @@ */ package org.openremote.test.assets +import com.fasterxml.jackson.databind.node.BooleanNode import jakarta.ws.rs.ForbiddenException import jakarta.ws.rs.WebApplicationException import org.openremote.manager.setup.SetupService @@ -82,7 +83,7 @@ class AttributeConfigurationTest extends Specification implements ManagerContain )) when: "the attribute configuration of the source asset is exported" - def document = assetResource.exportAttributeConfiguration(null, source.id, []) + def document = assetResource.exportAttributeConfiguration(null, source.id, [], []) then: "only the attributes holding metadata are exported, with their type and metadata" document.version == 1 @@ -93,7 +94,7 @@ class AttributeConfigurationTest extends Specification implements ManagerContain document.attributes.temperature.meta.get(MetaItemType.READ_ONLY.name).asBoolean() when: "a subset of the attributes is exported" - def subset = assetResource.exportAttributeConfiguration(null, source.id, ["temperature"]) + def subset = assetResource.exportAttributeConfiguration(null, source.id, ["temperature"], []) then: "only the requested attribute is exported" subset.attributes.keySet() == ["temperature"] as Set @@ -142,6 +143,46 @@ class AttributeConfigurationTest extends Specification implements ManagerContain def rejected = noneImportable.response.readEntity(AttributeConfigurationImportPreview.class) rejected.failure == NO_IMPORTABLE_ATTRIBUTES rejected.missingAttributes*.name == ["absent"] + + when: "the read only flag of the source asset is exported as a generic parameter" + def generic = assetResource.exportAttributeConfiguration(null, source.id, [], ["meta.readOnly"]) + + then: "the flag is recorded as a parameter instead of a value" + generic.genericParameters.readOnly.type == ValueType.BOOLEAN.name + generic.genericParameters.readOnly.paths == ["attributes.temperature.meta.readOnly"] + !generic.attributes.temperature.meta.has(MetaItemType.READ_ONLY.name) + + when: "the document is imported without a value for the parameter" + assetResource.previewAttributeConfigurationImport(null, target.id, + new AttributeConfigurationImportRequest(target, ValueUtil.asJSON(generic).orElseThrow())) + + then: "the import asks for the parameter" + WebApplicationException valuesRequired = thrown() + valuesRequired.response.withCloseable { r -> + assert r.status == BAD_REQUEST.statusCode + def report = r.readEntity(AttributeConfigurationImportPreview.class) + assert report.failure == GENERIC_PARAMETER_VALUES_REQUIRED + assert report.genericParameters.keySet() == ["readOnly"] as Set + return true + } + + when: "the document is imported with a value for the parameter" + def withValue = assetResource.previewAttributeConfigurationImport(null, target.id, + new AttributeConfigurationImportRequest(target, ValueUtil.asJSON(generic).orElseThrow(), [readOnly: BooleanNode.FALSE])) + + then: "the value is applied to the imported metadata" + withValue.failure == null + !withValue.patchedAttributes.get("temperature").get("meta").get(MetaItemType.READ_ONLY.name).asBoolean() + + when: "a value that differs between the exported attributes is requested as a generic parameter" + assetResource.exportAttributeConfiguration(null, source.id, [], ["meta.label"]) + + then: "the export is rejected" + WebApplicationException notGeneric = thrown() + notGeneric.response.withCloseable { r -> + assert r.status == BAD_REQUEST.statusCode + return true + } } def "Attribute configuration import requires asset write access"() { @@ -164,7 +205,7 @@ class AttributeConfigurationTest extends Specification implements ManagerContain .proxy(AssetResource.class) when: "the attribute configuration of an asset of the authenticated realm is exported" - def document = assetResource.exportAttributeConfiguration(null, managerTestSetup.apartment1LivingroomId, []) + def document = assetResource.exportAttributeConfiguration(null, managerTestSetup.apartment1LivingroomId, [], []) then: "the configuration is returned" document.version == 1 diff --git a/ui/app/shared/locales/en/or.json b/ui/app/shared/locales/en/or.json index bbebdaee6a..e8e8063b0b 100644 --- a/ui/app/shared/locales/en/or.json +++ b/ui/app/shared/locales/en/or.json @@ -320,6 +320,7 @@ "linkedUsers": "Linked users", "setup": "Setup", "selectFile": "Select file", + "selectAgent": "Select an agent", "file": "File", "discoverAssets": "Discover assets", "discovering": "Discovering", @@ -1354,12 +1355,19 @@ "confirm": "Confirm", "imported": "The configuration of these attributes was imported: {{attributes}}", "saveToPersist": "Save the asset to keep these changes, or discard them as usual.", + "genericParameters": "Included generic parameters:", + "genericParametersDescription": "Selected values are left out of the file and asked for once when it is imported.", + "genericParameterValuesDescription": "This configuration needs a value for the following parameters. Each value is applied to every attribute listed with it.", + "invalidGenericParameterValue": "This value does not fit the configuration it is applied to.", "failure": { "INVALID_JSON": "The selected file does not hold valid JSON.", "UNSUPPORTED_VERSION": "The selected file holds an unsupported attribute configuration version.", "MISSING_REQUIRED_FIELD": "The selected file is missing a required field.", "MALFORMED_ATTRIBUTE_ENTRY": "The selected file holds a malformed attribute entry.", + "MALFORMED_GENERIC_PARAMETER": "The selected file holds a malformed generic parameter.", "NO_IMPORTABLE_ATTRIBUTES": "None of the attributes in the selected file can be imported into this asset.", + "GENERIC_PARAMETER_VALUES_REQUIRED": "The selected file needs a value for each of its generic parameters.", + "INVALID_GENERIC_PARAMETER_VALUE": "A generic parameter value does not fit the configuration it is applied to.", "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 22ed6c9784..0f231feb6b 100644 --- a/ui/app/shared/locales/nl/or.json +++ b/ui/app/shared/locales/nl/or.json @@ -315,6 +315,7 @@ "linkedUsers": "Gekoppelde gebruikers", "setup": "Instellingen", "selectFile": "Kies bestand", + "selectAgent": "Selecteer een agent", "file": "Bestand", "discoverAssets": "Haal assets op", "discovering": "Assets ophalen", @@ -1340,12 +1341,19 @@ "confirm": "Bevestigen", "imported": "De configuratie van deze attributen is geïmporteerd: {{attributes}}", "saveToPersist": "Sla het asset op om deze wijzigingen te behouden, of maak ze zoals gebruikelijk ongedaan.", + "genericParameters": "Opgenomen generieke parameters:", + "genericParametersDescription": "Geselecteerde waarden worden uit het bestand weggelaten en bij het importeren eenmalig gevraagd.", + "genericParameterValuesDescription": "Deze configuratie heeft een waarde nodig voor de volgende parameters. Elke waarde wordt toegepast op alle attributen die erbij vermeld staan.", + "invalidGenericParameterValue": "Deze waarde past niet bij de configuratie waarop ze wordt toegepast.", "failure": { "INVALID_JSON": "Het geselecteerde bestand bevat geen geldige JSON.", "UNSUPPORTED_VERSION": "Het geselecteerde bestand bevat een niet-ondersteunde versie van de attribuutconfiguratie.", "MISSING_REQUIRED_FIELD": "In het geselecteerde bestand ontbreekt een verplicht veld.", "MALFORMED_ATTRIBUTE_ENTRY": "Het geselecteerde bestand bevat een ongeldige attribuutvermelding.", + "MALFORMED_GENERIC_PARAMETER": "Het geselecteerde bestand bevat een ongeldige generieke parameter.", "NO_IMPORTABLE_ATTRIBUTES": "Geen van de attributen in het geselecteerde bestand kan in dit asset worden geïmporteerd.", + "GENERIC_PARAMETER_VALUES_REQUIRED": "Het geselecteerde bestand heeft een waarde nodig voor elk van zijn generieke parameters.", + "INVALID_GENERIC_PARAMETER_VALUE": "Een waarde van een generieke parameter past niet bij de configuratie waarop ze wordt toegepast.", "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/attribute-configuration.ts b/ui/component/or-asset-viewer/src/attribute-configuration.ts index be13ebbb3c..4bc1c6d153 100644 --- a/ui/component/or-asset-viewer/src/attribute-configuration.ts +++ b/ui/component/or-asset-viewer/src/attribute-configuration.ts @@ -18,14 +18,23 @@ */ import { html, type TemplateResult } from "lit"; import { createRef, ref, type Ref } from "lit/directives/ref.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { live } from "lit/directives/live.js"; +import { repeat } from "lit/directives/repeat.js"; +import { until } from "lit/directives/until.js"; import { when } from "lit/directives/when.js"; import manager, { Util } from "@openremote/core"; import { AssetModelUtil, + AttributeConfigurationImportFailure, + WellknownMetaItems, + WellknownValueTypes, type Asset, type Attribute, + type AttributeConfigurationGenericParameter, type AttributeConfigurationImportPreview, } from "@openremote/model"; +import { getAgentOptions, loadAgents } from "@openremote/or-attribute-input"; import { i18next } from "@openremote/or-translate"; import "@openremote/or-icon"; import "@openremote/or-translate"; @@ -33,6 +42,9 @@ import type { OrIcon } from "@openremote/or-icon"; 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-number-field"; +import "@openremote/or-vaadin-components/or-vaadin-select"; +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 { @@ -51,6 +63,16 @@ import { showSnackbar } from "@openremote/or-mwc-components/or-mwc-snackbar"; export type AssetAttributes = { [index: string]: Attribute }; +/** A metadata value that can be left out of an export, for the user to supply on import. */ +export interface GenericPathCandidate { + /** Path relative to an attribute, e.g. meta.agentLink.id. */ + path: string; + value: string | number | boolean; + attributes: Attribute[]; +} + +type GenericParameterValues = { [name: string]: unknown }; + const DIALOG_WIDTH = "550px"; // language=CSS @@ -90,6 +112,10 @@ const dialogStyle = html` .ac-row-header .ac-expander { margin-left: auto; } + .ac-row-header .ac-input { + flex: 1; + min-width: 0; + } .ac-row-details { display: none; flex-direction: column; @@ -133,6 +159,14 @@ const dialogStyle = html` .ac-placeholder { color: var(--lumo-disabled-text-color); } + .ac-hint { + color: var(--lumo-secondary-text-color); + font-size: var(--lumo-font-size-s); + } + .ac-error { + color: var(--lumo-error-text-color); + font-size: var(--lumo-font-size-s); + } .ac-file-row { display: flex; align-items: center; @@ -185,6 +219,8 @@ export function showExportAttributeConfigurationDialog(host: Node, asset: Asset) } const selected = new Set(attributes.map((attribute) => attribute.name!)); + const genericPaths = new Set(); + let candidates = getGenericPathCandidates(attributes); const exportBtn: Ref = createRef(); const onToggle = (name: string, checked: boolean) => { @@ -196,6 +232,23 @@ export function showExportAttributeConfigurationDialog(host: Node, asset: Asset) if (exportBtn.value) { exportBtn.value.disabled = selected.size === 0; } + + // A value only stays a candidate while the selected attributes holding it agree on it + candidates = getGenericPathCandidates(attributes.filter((attribute) => selected.has(attribute.name!))); + genericPaths.forEach((path) => { + if (!candidates.some((candidate) => candidate.path === path)) { + genericPaths.delete(path); + } + }); + dialog?.requestContentUpdate(); + }; + + const onToggleGeneric = (path: string, checked: boolean) => { + if (checked) { + genericPaths.add(path); + } else { + genericPaths.delete(path); + } }; const dialog = showDialog( @@ -235,6 +288,46 @@ export function showExportAttributeConfigurationDialog(host: Node, asset: Asset) )}
+ ${when( + candidates.length > 0, + () => html` +
+ + +
+ ${repeat( + candidates, + (candidate) => candidate.path, + (candidate) => html` +
+
+ + onToggleGeneric(candidate.path, (ev.currentTarget as OrVaadinCheckbox).checked)} + > + ${candidate.attributes.length} + + + +
+
+ ${candidate.path}: ${String(candidate.value)} + ${candidate.attributes.map( + (attribute) => html`${attributeLabel(asset, attribute)}` + )} +
+
+ ` + )} +
+
+ ` + )}
` )} @@ -249,7 +342,7 @@ export function showExportAttributeConfigurationDialog(host: Node, asset: Asset) ${ref(exportBtn)} @click=${() => { dialog?.close(); - downloadAttributeConfiguration(asset, Array.from(selected)); + downloadAttributeConfiguration(asset, Array.from(selected), Array.from(genericPaths)); }} > @@ -296,15 +389,7 @@ export function showImportAttributeConfigurationDialog( const selectedName = file.name; dialog?.close(); - try { - const response = await manager.rest.api.AssetResource.previewAttributeConfigurationImport(asset.id!, { - draft: asset, - document: configuration, - }); - showImportPreviewDialog(host, asset, selectedName, response.data, onImported); - } catch (error) { - showImportFailureDialog(host, error); - } + previewImport(host, asset, selectedName, configuration, undefined, onImported); }; const dialog = showDialog( @@ -355,6 +440,281 @@ export function showImportAttributeConfigurationDialog( ); } +/** + * Asks the server what importing the configuration would do, first asking the user for any generic + * parameter values it needs. + */ +async function previewImport( + host: Node, + asset: Asset, + fileName: string, + configuration: string, + values: GenericParameterValues | undefined, + onImported: (attributes: AssetAttributes) => void +) { + try { + const response = await manager.rest.api.AssetResource.previewAttributeConfigurationImport(asset.id!, { + draft: asset, + document: configuration, + genericParameterValues: values, + }); + showImportPreviewDialog(host, asset, fileName, response.data, onImported); + } catch (error) { + const preview = getFailurePreview(error); + + if ( + preview?.failure === AttributeConfigurationImportFailure.GENERIC_PARAMETER_VALUES_REQUIRED || + preview?.failure === AttributeConfigurationImportFailure.INVALID_GENERIC_PARAMETER_VALUE + ) { + showGenericParametersDialog(host, asset, fileName, preview, values || {}, (submitted) => + previewImport(host, asset, fileName, configuration, submitted, onImported) + ); + } else { + showImportFailureDialog(host, error); + } + } +} + +/** Asks once for the value of each generic parameter, which the import applies to all its paths. */ +function showGenericParametersDialog( + host: Node, + asset: Asset, + fileName: string, + preview: AttributeConfigurationImportPreview, + previousValues: GenericParameterValues, + onSubmit: (values: GenericParameterValues) => void +) { + const parameters = Object.entries(preview.genericParameters || {}); + const invalid = new Set(preview.invalidGenericParameters || []); + const values: GenericParameterValues = { ...previousValues }; + const nextBtn: Ref = createRef(); + + parameters + .filter(([name, parameter]) => parameter.type === WellknownValueTypes.BOOLEAN && values[name] === undefined) + .forEach(([name]) => (values[name] = false)); + + const isComplete = () => + parameters.every(([name, parameter]) => isGenericParameterValue(values[name], parameter.type)); + + const onChange = (name: string, value: unknown) => { + values[name] = value; + if (nextBtn.value) { + nextBtn.value.disabled = !isComplete(); + } + }; + + const dialog = showDialog( + host, + html` + getHeaderTemplate("attributeConfiguration.importHeading", () => dialog?.close()))} + ${dialogRenderer( + () => html` + ${dialogStyle} +
+ ${fileName} +
+ +
+ ${parameters.map( + ([name, parameter]) => html` +
+
+ ${getGenericParameterInputTemplate(name, parameter, values[name], (value) => + onChange(name, value) + )} + ${parameter.paths?.length} + + + +
+ ${when( + invalid.has(name), + () => html` + + ` + )} +
+ ${(parameter.paths || []) + .map((path) => path.split(".")[1]) + .map( + (attributeName) => + html`${attributeLabel(asset, asset.attributes?.[attributeName], attributeName)}` + )} +
+
+ ` + )} +
+
+
+ ` + )} + ${dialogFooterRenderer( + () => html` +
+ dialog?.close()}> + + + { + dialog?.close(); + onSubmit(values); + }} + > + + +
+ ` + )} + >
+ ` + ); +} + +/** The input for a generic parameter value, an agent picker when it is the id of agent links. */ +function getGenericParameterInputTemplate( + name: string, + parameter: AttributeConfigurationGenericParameter, + value: unknown, + onChange: (value: unknown) => void +): TemplateResult { + if (isAgentLinkIdParameter(parameter)) { + return html`${until( + loadAgents().then( + (agents) => html` + ({ value: id, label }))} + .value=${(value as string | undefined) ?? ""} + @value-changed=${(ev: CustomEvent<{ value: string }>) => onChange(ev.detail.value)} + > + ` + ), + html`` + )}`; + } + + switch (parameter.type) { + case WellknownValueTypes.BOOLEAN: + return html` + ) => onChange(ev.detail.value)} + > + `; + case WellknownValueTypes.INTEGER: + case WellknownValueTypes.LONG: + case WellknownValueTypes.NUMBER: + return html` + ) => + onChange(ev.detail.value === "" ? undefined : Number(ev.detail.value))} + > + `; + default: + return html` + ) => onChange(ev.detail.value)} + > + `; + } +} + +/** Whether every path of the parameter is the agent id of an agent link. */ +function isAgentLinkIdParameter(parameter: AttributeConfigurationGenericParameter): boolean { + const agentIdPath = `meta.${WellknownMetaItems.AGENTLINK}.id`; + return (parameter.paths || []).every((path) => path.split(".").slice(2).join(".") === agentIdPath); +} + +/** Whether a value can be submitted for a generic parameter of the given type. */ +function isGenericParameterValue(value: unknown, type?: string): boolean { + switch (type) { + case WellknownValueTypes.BOOLEAN: + return typeof value === "boolean"; + case WellknownValueTypes.INTEGER: + return Number.isInteger(value) && (value as number) >= -(2 ** 31) && (value as number) < 2 ** 31; + case WellknownValueTypes.LONG: + return Number.isSafeInteger(value); + case WellknownValueTypes.NUMBER: + return typeof value === "number" && Number.isFinite(value); + default: + return typeof value === "string" && value.trim() !== ""; + } +} + +/** + * The metadata values that can become a generic parameter: text, boolean and number values reached + * through object properties only, which are equal in every given attribute holding them. + */ +export function getGenericPathCandidates(attributes: Attribute[]): GenericPathCandidate[] { + const candidates = new Map(); + const differing = new Set(); + + attributes.forEach((attribute) => + collectScalars(attribute.meta, "meta", (path, value) => { + const candidate = candidates.get(path); + + if (!candidate) { + candidates.set(path, { path, value, attributes: [attribute] }); + return; + } + + candidate.attributes.push(attribute); + if (candidate.value !== value) { + differing.add(path); + } + }) + ); + + return [...candidates.values()] + .filter((candidate) => !differing.has(candidate.path)) + .sort(Util.sortByString((candidate) => candidate.path)); +} + +/** Names a generic parameter after its path, so meta.agentLink.id becomes agentLinkId. */ +function deriveGenericParameterName(path: string): string { + return path + .split(".") + .slice(1) + .map((segment, index) => (index === 0 ? segment : segment.charAt(0).toUpperCase() + segment.slice(1))) + .join(""); +} + +function collectScalars( + value: unknown, + path: string, + consumer: (path: string, value: string | number | boolean) => void +) { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + Object.entries(value) + .filter(([key]) => key !== "" && !key.includes(".")) + .forEach(([key, child]) => collectScalars(child, `${path}.${key}`, consumer)); + } else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + consumer(path, value); + } +} + /** Reports what the import would change, before the final overwrite warning. */ function showImportPreviewDialog( host: Node, @@ -591,10 +951,11 @@ function toggleRow(ev: Event) { } } -async function downloadAttributeConfiguration(asset: Asset, attributeNames: string[]) { +async function downloadAttributeConfiguration(asset: Asset, attributeNames: string[], genericPaths: string[]) { try { const response = await manager.rest.api.AssetResource.exportAttributeConfiguration(asset.id!, { attributeName: attributeNames, + genericPath: genericPaths, }); const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: "application/json" }); const url = window.URL.createObjectURL(blob); diff --git a/ui/component/or-attribute-input/src/index.ts b/ui/component/or-attribute-input/src/index.ts index e52012a060..de33765caa 100644 --- a/ui/component/or-attribute-input/src/index.ts +++ b/ui/component/or-attribute-input/src/index.ts @@ -66,6 +66,8 @@ import { agentIdRendererRegistryEntry } from "./renderers/agent-link"; import { schedulerRendererRegistryEntry } from "./renderers/scheduler"; import { replayDataRendererRegistryEntry } from "./renderers/replay-data"; +export { getAgentOptions, loadAgents } from "./renderers/agent-link"; + export class OrAttributeInputChangedEvent extends CustomEvent { public static readonly NAME = "or-attribute-input-changed"; diff --git a/ui/component/or-attribute-input/src/renderers/agent-link.ts b/ui/component/or-attribute-input/src/renderers/agent-link.ts index d89840f97e..17c0fbcfa3 100644 --- a/ui/component/or-attribute-input/src/renderers/agent-link.ts +++ b/ui/component/or-attribute-input/src/renderers/agent-link.ts @@ -46,7 +46,7 @@ let loadingPromise: Promise | undefined; let subscribed = false; const timeout = 2000; -function loadAgents(): PromiseLike { +export function loadAgents(): PromiseLike { if (agents) { return Promise.resolve(agents); } @@ -93,6 +93,11 @@ function loadAgents(): PromiseLike { return loadingPromise; } +/** The agents to pick from for an agent link, as value and label pairs. */ +export function getAgentOptions(agents: Agent[]): [string, string][] { + return agents.map((agent) => [agent.id!, agent.name + " (" + agent.id + ")"]); +} + const agentIdTester: RankedTester = rankWith(6, and(uiTypeIs("Control"), formatIs("or-agent-id"))); const agentIdRenderer = (state: JsonFormsStateContext, props: ControlProps) => { props = { @@ -118,7 +123,7 @@ const agentIdRenderer = (state: JsonFormsStateContext, props: ControlProps) => { }; const loadedTemplatePromise = loadAgents().then((agents) => { - const options: [string, string][] = agents.map((agent) => [agent.id!, agent.name + " (" + agent.id + ")"]); + const options = getAgentOptions(agents); return html` Date: Mon, 26 Oct 2026 13:38:03 +0100 Subject: [PATCH 4/4] Address review comments --- .../manager/asset/AssetResourceImpl.java | 20 +++----- .../openremote/model/asset/AssetResource.java | 27 ++++++++-- .../AttributeConfigurationImportPreview.java | 5 +- .../assets/AttributeConfigurationTest.groovy | 49 ++++++++++++++++--- ui/app/shared/locales/en/or.json | 3 +- ui/app/shared/locales/nl/or.json | 3 +- .../src/attribute-configuration.ts | 5 +- 7 files changed, 82 insertions(+), 30 deletions(-) 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 e69c7b21de..8a6b2c0971 100644 --- a/manager/src/main/java/org/openremote/manager/asset/AssetResourceImpl.java +++ b/manager/src/main/java/org/openremote/manager/asset/AssetResourceImpl.java @@ -498,16 +498,7 @@ public AttributeConfigurationImportPreview previewAttributeConfigurationImport( throw new WebApplicationException(NOT_FOUND); } - if (!isRealmActiveAndAccessible(storageAsset.getRealm())) { - LOG.fine( - "Realm '" - + storageAsset.getRealm() - + "' is nonexistent, inactive or inaccessible: username=" - + getUsername() - + ", assetID=" - + assetId); - throw new WebApplicationException(FORBIDDEN); - } + throwIfNotRealmActiveAndAccessible(storageAsset.getRealm()); if (isRestrictedUser() && !assetStorageService.isUserAsset(getUserId(), assetId)) { LOG.fine( @@ -518,10 +509,13 @@ public AttributeConfigurationImportPreview previewAttributeConfigurationImport( throw new WebApplicationException(FORBIDDEN); } - Asset draft = request != null ? request.getDraft() : null; + if (request == null || request.getDraft() == null) { + throw new WebApplicationException("An asset draft is required", BAD_REQUEST); + } + + Asset draft = request.getDraft(); - if (draft == null - || !Objects.equals(assetId, draft.getId()) + if (!Objects.equals(assetId, draft.getId()) || !Objects.equals(storageAsset.getRealm(), draft.getRealm()) || !Objects.equals(storageAsset.getType(), draft.getType())) { throw importFailure( 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 5ee58ed291..680d5aa241 100644 --- a/model/src/main/java/org/openremote/model/asset/AssetResource.java +++ b/model/src/main/java/org/openremote/model/asset/AssetResource.java @@ -389,9 +389,10 @@ AttributeConfigurationDocument exportAttributeConfiguration( * Validate an attribute configuration document 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 the document cannot be imported, including while its generic - * parameters lack a valid value, and a 409 status if the draft no longer identifies the persisted - * asset. + * 400 status is returned if no draft is given or the document cannot be imported, including while + * its generic parameters lack a valid value, and a 409 status if the draft no longer identifies + * the persisted asset. A rejected document is answered with the preview, whose failure names the + * reason. */ @POST @Path("{assetId}/attributeConfiguration/import") @@ -405,9 +406,25 @@ AttributeConfigurationDocument exportAttributeConfiguration( "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. When the document has generic parameters, the report lists those needing a value and is rejected until valid values are submitted. Nothing is stored; the client applies the patch after the user confirms.") @OpenApiResponses.Ok @OpenApiResponses.Authenticated - @OpenApiResponses.BadRequest + @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 = AttributeConfigurationImportPreview.class)), + @Content(mediaType = "text/plain", schema = @Schema(implementation = String.class)) + }) @OpenApiResponses.NotFound - @OpenApiResponses.Conflict + @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 = AttributeConfigurationImportPreview.class))) AttributeConfigurationImportPreview previewAttributeConfigurationImport( @BeanParam RequestParams requestParams, @Parameter(description = ASSET_ID, example = EXAMPLE_ASSET_ID) @PathParam("assetId") diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportPreview.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportPreview.java index 87a146e922..5559a30fe3 100644 --- a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportPreview.java +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportPreview.java @@ -27,8 +27,9 @@ /** * What importing an {@link AttributeConfigurationDocument} into an asset draft would do, for the - * user to confirm or cancel. A {@link #failure} means nothing can be imported and the remaining - * fields are empty. + * 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 or the generic parameters that need a value. */ @Schema( description = diff --git a/test/src/test/groovy/org/openremote/test/assets/AttributeConfigurationTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AttributeConfigurationTest.groovy index bb0056114f..e04457590d 100644 --- a/test/src/test/groovy/org/openremote/test/assets/AttributeConfigurationTest.groovy +++ b/test/src/test/groovy/org/openremote/test/assets/AttributeConfigurationTest.groovy @@ -37,6 +37,8 @@ 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 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.* @@ -130,8 +132,11 @@ class AttributeConfigurationTest extends Specification implements ManagerContain then: "the import is rejected with the reason" WebApplicationException ex = thrown() - ex.response.status == BAD_REQUEST.statusCode - ex.response.readEntity(AttributeConfigurationImportPreview.class).failure == UNSUPPORTED_VERSION + ex.response.withCloseable { r -> + assert r.status == BAD_REQUEST.statusCode + assert r.readEntity(AttributeConfigurationImportPreview.class).failure == UNSUPPORTED_VERSION + return true + } when: "a document holding no attribute of the target asset is imported" assetResource.previewAttributeConfigurationImport(null, target.id, @@ -139,10 +144,36 @@ class AttributeConfigurationTest extends Specification implements ManagerContain then: "the import is rejected and the skipped attribute is reported" WebApplicationException noneImportable = thrown() - noneImportable.response.status == BAD_REQUEST.statusCode - def rejected = noneImportable.response.readEntity(AttributeConfigurationImportPreview.class) - rejected.failure == NO_IMPORTABLE_ATTRIBUTES - rejected.missingAttributes*.name == ["absent"] + noneImportable.response.withCloseable { r -> + assert r.status == BAD_REQUEST.statusCode + def rejected = r.readEntity(AttributeConfigurationImportPreview.class) + assert rejected.failure == NO_IMPORTABLE_ATTRIBUTES + assert rejected.missingAttributes*.name == ["absent"] + return true + } + + when: "the draft of another asset is submitted" + assetResource.previewAttributeConfigurationImport(null, target.id, + new AttributeConfigurationImportRequest(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(AttributeConfigurationImportPreview.class).failure == DRAFT_MISMATCH + return true + } + + when: "no draft is submitted" + assetResource.previewAttributeConfigurationImport(null, target.id, + new AttributeConfigurationImportRequest(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 + } when: "the read only flag of the source asset is exported as a generic parameter" def generic = assetResource.exportAttributeConfiguration(null, source.id, [], ["meta.readOnly"]) @@ -218,6 +249,10 @@ class AttributeConfigurationTest extends Specification implements ManagerContain ValueUtil.asJSON(document).orElseThrow())) then: "access is forbidden" - thrown(ForbiddenException) + ForbiddenException forbidden = thrown() + forbidden.response.withCloseable { r -> + assert r.status == FORBIDDEN.statusCode + return true + } } } diff --git a/ui/app/shared/locales/en/or.json b/ui/app/shared/locales/en/or.json index e8e8063b0b..8f7a4959e5 100644 --- a/ui/app/shared/locales/en/or.json +++ b/ui/app/shared/locales/en/or.json @@ -1351,7 +1351,7 @@ "skippedTypeMismatch": "Skipped, the attribute has a different type", "next": "Next", "warningHeading": "Warning", - "overwriteConfirm": "You are about to overwrite existing attribute values, are you sure?", + "overwriteConfirm": "You are about to overwrite existing attribute configuration, are you sure?", "confirm": "Confirm", "imported": "The configuration of these attributes was imported: {{attributes}}", "saveToPersist": "Save the asset to keep these changes, or discard them as usual.", @@ -1359,6 +1359,7 @@ "genericParametersDescription": "Selected values are left out of the file and asked for once when it is imported.", "genericParameterValuesDescription": "This configuration needs a value for the following parameters. Each value is applied to every attribute listed with it.", "invalidGenericParameterValue": "This value does not fit the configuration it is applied to.", + "forbidden": "You are not allowed to import attribute configuration into this asset.", "failure": { "INVALID_JSON": "The selected file does not hold valid JSON.", "UNSUPPORTED_VERSION": "The selected file holds an unsupported attribute configuration version.", diff --git a/ui/app/shared/locales/nl/or.json b/ui/app/shared/locales/nl/or.json index 0f231feb6b..6fe846c3b4 100644 --- a/ui/app/shared/locales/nl/or.json +++ b/ui/app/shared/locales/nl/or.json @@ -1337,7 +1337,7 @@ "skippedTypeMismatch": "Overgeslagen, het attribuut heeft een ander type", "next": "Volgende", "warningHeading": "Waarschuwing", - "overwriteConfirm": "Je staat op het punt bestaande attribuutwaarden te overschrijven, weet je het zeker?", + "overwriteConfirm": "Je staat op het punt bestaande attribuutconfiguratie te overschrijven, weet je het zeker?", "confirm": "Bevestigen", "imported": "De configuratie van deze attributen is geïmporteerd: {{attributes}}", "saveToPersist": "Sla het asset op om deze wijzigingen te behouden, of maak ze zoals gebruikelijk ongedaan.", @@ -1345,6 +1345,7 @@ "genericParametersDescription": "Geselecteerde waarden worden uit het bestand weggelaten en bij het importeren eenmalig gevraagd.", "genericParameterValuesDescription": "Deze configuratie heeft een waarde nodig voor de volgende parameters. Elke waarde wordt toegepast op alle attributen die erbij vermeld staan.", "invalidGenericParameterValue": "Deze waarde past niet bij de configuratie waarop ze wordt toegepast.", + "forbidden": "Je hebt geen rechten om attribuutconfiguratie 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 de attribuutconfiguratie.", diff --git a/ui/component/or-asset-viewer/src/attribute-configuration.ts b/ui/component/or-asset-viewer/src/attribute-configuration.ts index 4bc1c6d153..ad47c2f345 100644 --- a/ui/component/or-asset-viewer/src/attribute-configuration.ts +++ b/ui/component/or-asset-viewer/src/attribute-configuration.ts @@ -849,7 +849,10 @@ function showImportResultDialog(host: Node, preview: AttributeConfigurationImpor /** Reports a document that cannot be imported, with whatever the server could tell about it. */ function showImportFailureDialog(host: Node, error: unknown) { const preview = getFailurePreview(error); - const message = preview ? i18next.t(`attributeConfiguration.failure.${preview.failure}`) : i18next.t("errorOccurred"); + const forbidden = (error as { response?: { status?: number } })?.response?.status === 403; + const message = preview + ? i18next.t(`attributeConfiguration.failure.${preview.failure}`) + : i18next.t(forbidden ? "attributeConfiguration.forbidden" : "errorOccurred"); showErrorDialog( host,