From d11ec0987b6881d448d707c8ee166e6291973710 Mon Sep 17 00:00:00 2001 From: Max Burri Date: Fri, 27 Feb 2026 15:35:48 +0100 Subject: [PATCH 1/4] feat(amw_angular, amw_rest): add more -> tag resource --- .../io/src/app/resources/models/resource.ts | 1 + .../resource-edit.component.html | 17 ++ .../resource-edit/resource-edit.component.ts | 23 ++- .../tag-current-state.component.html | 40 +++++ .../tag-current-state.component.ts | 95 +++++++++++ .../services/resource-tags.service.ts | 44 +++++ .../itc/mobiliar/rest/RESTApplication.java | 1 + .../rest/resources/ResourceTagsRest.java | 151 ++++++++++++++++++ 8 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 AMW_angular/io/src/app/resources/resource-edit/tag-current-state/tag-current-state.component.html create mode 100644 AMW_angular/io/src/app/resources/resource-edit/tag-current-state/tag-current-state.component.ts create mode 100644 AMW_angular/io/src/app/resources/services/resource-tags.service.ts create mode 100644 AMW_rest/src/main/java/ch/mobi/itc/mobiliar/rest/resources/ResourceTagsRest.java diff --git a/AMW_angular/io/src/app/resources/models/resource.ts b/AMW_angular/io/src/app/resources/models/resource.ts index 05178a622..acf6666e7 100644 --- a/AMW_angular/io/src/app/resources/models/resource.ts +++ b/AMW_angular/io/src/app/resources/models/resource.ts @@ -5,6 +5,7 @@ export interface Resource { name: string; type: string; version: string; + release?: string; defaultRelease: Release; releases: Release[]; defaultResourceId?: number; diff --git a/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.html b/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.html index 171eb9788..70cbf76fa 100644 --- a/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.html +++ b/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.html @@ -41,6 +41,22 @@ } + @if (showMoreMenu()) { +
+ + More + + +
+ }
@@ -69,4 +85,5 @@
} + diff --git a/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.ts b/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.ts index ecf79deff..524a85534 100644 --- a/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.ts +++ b/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject, signal, Signal } from '@angular/core'; +import { Component, computed, inject, signal, Signal, ViewChild } from '@angular/core'; import { LoadingIndicatorComponent } from '../../shared/elements/loading-indicator.component'; import { PageComponent } from '../../layout/page/page.component'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; @@ -15,6 +15,7 @@ import { NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle } from import { ContextsListComponent } from '../contexts-list/contexts-list.component'; import { ResourcePropertiesComponent } from './resource-properties/resource-properties.component'; import { ResourceReleasesComponent } from './resource-releases/resource-releases.component'; +import { TagCurrentStateComponent } from './tag-current-state/tag-current-state.component'; @Component({ selector: 'app-resource-edit', @@ -33,6 +34,7 @@ import { ResourceReleasesComponent } from './resource-releases/resource-releases ResourcePropertiesComponent, ResourceReleasesComponent, RouterLink, + TagCurrentStateComponent, ], templateUrl: './resource-edit.component.html', styleUrl: './resource-edit.component.scss', @@ -43,6 +45,8 @@ export class ResourceEditComponent { private route = inject(ActivatedRoute); private router = inject(Router); + @ViewChild(TagCurrentStateComponent) tagCurrentStateComponent!: TagCurrentStateComponent; + id = toSignal(this.route.queryParamMap.pipe(map((params) => Number(params.get('id')))), { initialValue: 0 }); contextId = toSignal(this.route.queryParamMap.pipe(map((params) => Number(params.get('ctx')))), { initialValue: 1 }); resource: Signal = this.resourceService.resource; @@ -61,12 +65,15 @@ export class ResourceEditComponent { permissions = computed(() => { if (this.authService.restrictions().length > 0) { + const resourceTypeName = this.resource()?.type ?? null; + const resourceGroupId = this.resource()?.resourceGroupId ?? null; return { canEditResource: this.authService.hasPermission('RESOURCE', 'READ'), canTestGenerate: this.authService.hasPermission('RESOURCE_TEST_GENERATION', 'READ'), + canTagCurrentState: this.authService.hasPermission('RESOURCE', 'UPDATE', resourceTypeName, resourceGroupId), }; } else { - return { canEditResource: false, canTestGenerate: false }; + return { canEditResource: false, canTestGenerate: false, canTagCurrentState: false }; } }); @@ -82,6 +89,14 @@ export class ResourceEditComponent { () => this.testGenerationAvailable() && this.permissions().canTestGenerate, ); + protected readonly isApplicationServer = computed( + () => this.resource()?.type === 'APPLICATIONSERVER', + ); + + protected readonly showMoreMenu = computed( + () => this.permissions().canTagCurrentState && this.isApplicationServer(), + ); + loadResourceFromRelease(releaseId: number) { void this.router.navigate([], { relativeTo: this.route, @@ -89,4 +104,8 @@ export class ResourceEditComponent { queryParamsHandling: 'merge', }); } + + openTagDialog() { + this.tagCurrentStateComponent?.openTagDialog(); + } } diff --git a/AMW_angular/io/src/app/resources/resource-edit/tag-current-state/tag-current-state.component.html b/AMW_angular/io/src/app/resources/resource-edit/tag-current-state/tag-current-state.component.html new file mode 100644 index 000000000..486d8799d --- /dev/null +++ b/AMW_angular/io/src/app/resources/resource-edit/tag-current-state/tag-current-state.component.html @@ -0,0 +1,40 @@ + + + + + diff --git a/AMW_angular/io/src/app/resources/resource-edit/tag-current-state/tag-current-state.component.ts b/AMW_angular/io/src/app/resources/resource-edit/tag-current-state/tag-current-state.component.ts new file mode 100644 index 000000000..55a49a252 --- /dev/null +++ b/AMW_angular/io/src/app/resources/resource-edit/tag-current-state/tag-current-state.component.ts @@ -0,0 +1,95 @@ +import { Component, inject, input, signal, TemplateRef, ViewChild } from '@angular/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { FormsModule } from '@angular/forms'; +import { ModalHeaderComponent } from '../../../shared/modal-header/modal-header.component'; +import { ButtonComponent } from '../../../shared/button/button.component'; +import { ResourceTagsService } from '../../services/resource-tags.service'; +import { ToastService } from '../../../shared/elements/toast/toast.service'; +import { Resource } from '../../models/resource'; + +@Component({ + selector: 'app-tag-current-state', + standalone: true, + imports: [FormsModule, ModalHeaderComponent, ButtonComponent], + templateUrl: './tag-current-state.component.html', +}) +export class TagCurrentStateComponent { + private modalService = inject(NgbModal); + private resourceTagsService = inject(ResourceTagsService); + private toastService = inject(ToastService); + + @ViewChild('tagModal') tagModal!: TemplateRef; + + resource = input.required(); + + tagLabel = signal(''); + tagDate = signal(new Date()); + isCreatingTag = signal(false); + + openTagDialog() { + this.tagLabel.set(''); + this.tagDate.set(new Date()); + const modalRef = this.modalService.open(this.tagModal); + modalRef.result.then( + () => { + this.createTag(); + }, + () => { + this.tagLabel.set(''); + this.tagDate.set(new Date()); + }, + ); + } + + createTag() { + const label = this.tagLabel(); + const date = this.tagDate(); + + if (!label || label.trim() === '') { + this.toastService.error('Tag label must not be empty.'); + return; + } + + if (!date) { + this.toastService.error('Tag date must not be empty.'); + return; + } + + this.isCreatingTag.set(true); + this.resourceTagsService + .createTag(this.resource().id, { + label: label.trim(), + tagDate: date, + }) + .subscribe({ + next: () => { + this.toastService.success(`New tag '${label}' created.`); + this.isCreatingTag.set(false); + this.tagLabel.set(''); + this.tagDate.set(new Date()); + }, + error: (error) => { + console.error('Failed to create tag:', error); + const errorMessage = error?.error?.message || 'Failed to create tag.'; + this.toastService.error(errorMessage); + this.isCreatingTag.set(false); + }, + }); + } + + formatDateForInput(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; + } + + onDateChange(event: Event) { + const input = event.target as HTMLInputElement; + if (input.value) { + this.tagDate.set(new Date(input.value)); + } + } +} diff --git a/AMW_angular/io/src/app/resources/services/resource-tags.service.ts b/AMW_angular/io/src/app/resources/services/resource-tags.service.ts new file mode 100644 index 000000000..843097fae --- /dev/null +++ b/AMW_angular/io/src/app/resources/services/resource-tags.service.ts @@ -0,0 +1,44 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { BaseService } from '../../base/base.service'; +import { ResourceTag } from '../models/resource-tag'; + +export interface TagRequest { + label: string; + tagDate: Date; +} + +export interface CanTagResponse { + canTag: boolean; +} + +@Injectable({ providedIn: 'root' }) +export class ResourceTagsService extends BaseService { + private http = inject(HttpClient); + + getTagsForResource(resourceId: number): Observable { + return this.http + .get(`${this.getBaseUrl()}/resources/${resourceId}/tags`, { + headers: this.getHeaders(), + }) + .pipe(catchError(this.handleError)); + } + + createTag(resourceId: number, tagRequest: TagRequest): Observable { + return this.http + .post(`${this.getBaseUrl()}/resources/${resourceId}/tags`, tagRequest, { + headers: this.getHeaders(), + }) + .pipe(catchError(this.handleError)); + } + + canTagResource(resourceId: number): Observable { + return this.http + .get(`${this.getBaseUrl()}/resources/${resourceId}/canTag`, { + headers: this.getHeaders(), + }) + .pipe(catchError(this.handleError)); + } +} diff --git a/AMW_rest/src/main/java/ch/mobi/itc/mobiliar/rest/RESTApplication.java b/AMW_rest/src/main/java/ch/mobi/itc/mobiliar/rest/RESTApplication.java index 912758ec3..f6f9552e3 100644 --- a/AMW_rest/src/main/java/ch/mobi/itc/mobiliar/rest/RESTApplication.java +++ b/AMW_rest/src/main/java/ch/mobi/itc/mobiliar/rest/RESTApplication.java @@ -81,6 +81,7 @@ private void addRestResourceClasses(Set> resources) { resources.add(FunctionsRest.class); resources.add(ServersRest.class); resources.add(ResourceFunctionsRest.class); + resources.add(ResourceTagsRest.class); // writers resources.add(DeploymentDtoCsvBodyWriter.class); diff --git a/AMW_rest/src/main/java/ch/mobi/itc/mobiliar/rest/resources/ResourceTagsRest.java b/AMW_rest/src/main/java/ch/mobi/itc/mobiliar/rest/resources/ResourceTagsRest.java new file mode 100644 index 000000000..71066fb47 --- /dev/null +++ b/AMW_rest/src/main/java/ch/mobi/itc/mobiliar/rest/resources/ResourceTagsRest.java @@ -0,0 +1,151 @@ +/* + * AMW - Automated Middleware allows you to manage the configurations of + * your Java EE applications on an unlimited number of different environments + * with various versions, including the automated deployment of those apps. + * Copyright (C) 2013-2016 by Puzzle ITC + * + * 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 . + */ + +package ch.mobi.itc.mobiliar.rest.resources; + +import ch.mobi.itc.mobiliar.rest.dtos.ResourceTagDTO; +import ch.mobi.itc.mobiliar.rest.exceptions.ExceptionDto; +import ch.puzzle.itc.mobiliar.business.configurationtag.control.TagConfigurationService; +import ch.puzzle.itc.mobiliar.business.configurationtag.entity.ResourceTagEntity; +import ch.puzzle.itc.mobiliar.business.resourcegroup.boundary.ResourceLocator; +import ch.puzzle.itc.mobiliar.business.resourcegroup.entity.ResourceEntity; +import ch.puzzle.itc.mobiliar.business.security.control.PermissionService; +import ch.puzzle.itc.mobiliar.business.security.entity.Action; +import ch.puzzle.itc.mobiliar.business.security.entity.Permission; +import ch.puzzle.itc.mobiliar.common.exception.NotAuthorizedException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import javax.enterprise.context.RequestScoped; +import javax.inject.Inject; +import javax.ws.rs.*; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.util.List; +import java.util.stream.Collectors; + +import static javax.ws.rs.core.Response.Status.BAD_REQUEST; +import static javax.ws.rs.core.Response.Status.NOT_FOUND; + +@RequestScoped +@Path("/resources") +@Tag(name = "/resources", description = "Resource Tags") +public class ResourceTagsRest { + + @Inject + private TagConfigurationService tagConfigurationService; + + @Inject + private ResourceLocator resourceLocator; + + @Inject + private PermissionService permissionService; + + @GET + @Path("/{resourceId}/tags") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get all tags for a resource") + public Response getTagsForResource(@Parameter(description = "Resource ID") @PathParam("resourceId") Integer resourceId) { + ResourceEntity resource = resourceLocator.getResourceById(resourceId); + if (resource == null) { + return Response.status(NOT_FOUND).entity(new ExceptionDto("Resource not found")).build(); + } + + List tags = tagConfigurationService.loadTagLabelsForResource(resource); + List tagDTOs = tags.stream() + .map(ResourceTagDTO::new) + .collect(Collectors.toList()); + + return Response.ok(tagDTOs).build(); + } + + @POST + @Path("/{resourceId}/tags") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Create a new tag for a resource") + public Response createTag( + @Parameter(description = "Resource ID") @PathParam("resourceId") Integer resourceId, + @Parameter(description = "Tag data") ResourceTagDTO tagDTO) { + + if (tagDTO.getLabel() == null || tagDTO.getLabel().trim().isEmpty()) { + return Response.status(BAD_REQUEST).entity(new ExceptionDto("Tag label must not be empty")).build(); + } + + if (tagDTO.getTagDate() == null) { + return Response.status(BAD_REQUEST).entity(new ExceptionDto("Tag date must not be empty")).build(); + } + + ResourceEntity resource = resourceLocator.getResourceById(resourceId); + if (resource == null) { + return Response.status(NOT_FOUND).entity(new ExceptionDto("Resource not found")).build(); + } + + // Check if tag label already exists for this resource + List existingTags = tagConfigurationService.loadTagLabelsForResource(resource); + boolean labelExists = existingTags.stream() + .anyMatch(tag -> tag.getLabel().trim().equals(tagDTO.getLabel().trim())); + + if (labelExists) { + return Response.status(BAD_REQUEST) + .entity(new ExceptionDto("A label with the value '" + tagDTO.getLabel() + "' already exists for this resource.")) + .build(); + } + + try { + ResourceTagEntity createdTag = tagConfigurationService.tagConfiguration( + resourceId, + tagDTO.getLabel(), + tagDTO.getTagDate() + ); + return Response.ok(new ResourceTagDTO(createdTag)).build(); + } catch (NotAuthorizedException e) { + return Response.status(Response.Status.FORBIDDEN) + .entity(new ExceptionDto("Not authorized to create tags for this resource")) + .build(); + } catch (Exception e) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ExceptionDto("Failed to create tag: " + e.getMessage())) + .build(); + } + } + + @GET + @Path("/{resourceId}/canTag") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Check if user can tag the current state of a resource") + public Response canTagResource(@Parameter(description = "Resource ID") @PathParam("resourceId") Integer resourceId) { + ResourceEntity resource = resourceLocator.getResourceById(resourceId); + if (resource == null) { + return Response.status(NOT_FOUND).entity(new ExceptionDto("Resource not found")).build(); + } + + boolean canTag = permissionService.hasPermission( + Permission.RESOURCE, + null, + Action.UPDATE, + resource.getResourceGroup(), + null + ); + + return Response.ok().entity("{\"canTag\": " + canTag + "}").build(); + } +} From dbdcd564875e114ed24f9d638240980a98504494 Mon Sep 17 00:00:00 2001 From: Max Burri Date: Fri, 27 Feb 2026 15:56:34 +0100 Subject: [PATCH 2/4] feat(amw_angular, amw_rest): check permission using the authservice (not with a dedicated endpoint) --- .../services/resource-tags.service.ts | 12 ----------- .../rest/resources/ResourceTagsRest.java | 20 ------------------- 2 files changed, 32 deletions(-) diff --git a/AMW_angular/io/src/app/resources/services/resource-tags.service.ts b/AMW_angular/io/src/app/resources/services/resource-tags.service.ts index 843097fae..d0d269d88 100644 --- a/AMW_angular/io/src/app/resources/services/resource-tags.service.ts +++ b/AMW_angular/io/src/app/resources/services/resource-tags.service.ts @@ -10,10 +10,6 @@ export interface TagRequest { tagDate: Date; } -export interface CanTagResponse { - canTag: boolean; -} - @Injectable({ providedIn: 'root' }) export class ResourceTagsService extends BaseService { private http = inject(HttpClient); @@ -33,12 +29,4 @@ export class ResourceTagsService extends BaseService { }) .pipe(catchError(this.handleError)); } - - canTagResource(resourceId: number): Observable { - return this.http - .get(`${this.getBaseUrl()}/resources/${resourceId}/canTag`, { - headers: this.getHeaders(), - }) - .pipe(catchError(this.handleError)); - } } diff --git a/AMW_rest/src/main/java/ch/mobi/itc/mobiliar/rest/resources/ResourceTagsRest.java b/AMW_rest/src/main/java/ch/mobi/itc/mobiliar/rest/resources/ResourceTagsRest.java index 71066fb47..5bea3fb9d 100644 --- a/AMW_rest/src/main/java/ch/mobi/itc/mobiliar/rest/resources/ResourceTagsRest.java +++ b/AMW_rest/src/main/java/ch/mobi/itc/mobiliar/rest/resources/ResourceTagsRest.java @@ -128,24 +128,4 @@ public Response createTag( } } - @GET - @Path("/{resourceId}/canTag") - @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "Check if user can tag the current state of a resource") - public Response canTagResource(@Parameter(description = "Resource ID") @PathParam("resourceId") Integer resourceId) { - ResourceEntity resource = resourceLocator.getResourceById(resourceId); - if (resource == null) { - return Response.status(NOT_FOUND).entity(new ExceptionDto("Resource not found")).build(); - } - - boolean canTag = permissionService.hasPermission( - Permission.RESOURCE, - null, - Action.UPDATE, - resource.getResourceGroup(), - null - ); - - return Response.ok().entity("{\"canTag\": " + canTag + "}").build(); - } } From 9a9914da7f55c6dcbd400e76c063e628c0145e48 Mon Sep 17 00:00:00 2001 From: Max Burri Date: Fri, 6 Mar 2026 11:44:15 +0100 Subject: [PATCH 3/4] feat(amw_angular): use ngb-datepicker to control date format --- .../tag-current-state.component.html | 30 ++++++++++---- .../tag-current-state.component.ts | 41 +++++++++---------- 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/AMW_angular/io/src/app/resources/resource-edit/tag-current-state/tag-current-state.component.html b/AMW_angular/io/src/app/resources/resource-edit/tag-current-state/tag-current-state.component.html index 486d8799d..04991fc30 100644 --- a/AMW_angular/io/src/app/resources/resource-edit/tag-current-state/tag-current-state.component.html +++ b/AMW_angular/io/src/app/resources/resource-edit/tag-current-state/tag-current-state.component.html @@ -20,15 +20,27 @@
- - + +
+ + +