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..04991fc30 --- /dev/null +++ b/AMW_angular/io/src/app/resources/resource-edit/tag-current-state/tag-current-state.component.html @@ -0,0 +1,52 @@ + + + + + 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..aa5bb8ea3 --- /dev/null +++ b/AMW_angular/io/src/app/resources/resource-edit/tag-current-state/tag-current-state.component.ts @@ -0,0 +1,94 @@ +import { Component, inject, input, signal, TemplateRef, ViewChild } from '@angular/core'; +import { NgbModal, NgbDatepickerModule, NgbDateStruct } 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, NgbDatepickerModule], + 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(this.dateToNgbDate(new Date())); + isCreatingTag = signal(false); + + openTagDialog() { + const now = new Date(); + this.tagLabel.set(''); + this.tagDate.set(this.dateToNgbDate(now)); + const modalRef = this.modalService.open(this.tagModal); + modalRef.result.then( + () => { + this.createTag(); + }, + () => { + this.tagLabel.set(''); + this.tagDate.set(this.dateToNgbDate(new Date())); + }, + ); + } + + createTag() { + const label = this.tagLabel(); + const ngbDate = this.tagDate(); + + if (!label || label.trim() === '') { + this.toastService.error('Tag label must not be empty.'); + return; + } + + if (!ngbDate) { + this.toastService.error('Tag date must not be empty.'); + return; + } + + const date = this.ngbDateToDate(ngbDate); + + 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); + const now = new Date(); + this.tagLabel.set(''); + this.tagDate.set(this.dateToNgbDate(now)); + }, + 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); + }, + }); + } + + private dateToNgbDate(date: Date): NgbDateStruct { + return { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate(), + }; + } + + private ngbDateToDate(ngbDate: NgbDateStruct): Date { + return new Date(ngbDate.year, ngbDate.month - 1, ngbDate.day, 12, 0, 0); + } +} 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..d0d269d88 --- /dev/null +++ b/AMW_angular/io/src/app/resources/services/resource-tags.service.ts @@ -0,0 +1,32 @@ +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; +} + +@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)); + } +} 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..241c3d878 --- /dev/null +++ b/AMW_rest/src/main/java/ch/mobi/itc/mobiliar/rest/resources/ResourceTagsRest.java @@ -0,0 +1,131 @@ +/* + * 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-2026 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(); + } + } + +}