diff options
Diffstat (limited to 'opendc-web')
17 files changed, 777 insertions, 43 deletions
diff --git a/opendc-web/opendc-web-proto/src/main/java/org/opendc/web/proto/user/RackPrefab.java b/opendc-web/opendc-web-proto/src/main/java/org/opendc/web/proto/user/RackPrefab.java new file mode 100644 index 00000000..142808b3 --- /dev/null +++ b/opendc-web/opendc-web-proto/src/main/java/org/opendc/web/proto/user/RackPrefab.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.opendc.web.proto.user; + +import jakarta.validation.constraints.NotBlank; +import java.time.Instant; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.opendc.web.proto.topology.Rack; + +/** + * Model for a rack prefab. + */ +public record RackPrefab( + long id, int number, Project project, String name, Rack rack, Instant createdAt, Instant updatedAt) { + /** + * Create a new rack prefab. + */ + @Schema(name = "RackPrefab.Create") + public record Create(@NotBlank(message = "Name must not be empty") String name, Rack rack) {} +} diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Project.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Project.java index ca032e21..a0c20443 100644 --- a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Project.java +++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Project.java @@ -77,6 +77,14 @@ import java.util.Set; UPDATE Project p SET p.scenariosCreated = :oldState + 1, p.updatedAt = :now WHERE p.id = :id AND p.scenariosCreated = :oldState + """), + @NamedQuery( + name = "Project.allocateRackPrefab", + query = + """ + UPDATE Project p + SET p.rackPrefabsCreated = :oldState + 1, p.updatedAt = :now + WHERE p.id = :id AND p.rackPrefabsCreated = :oldState """) }) public class Project extends PanacheEntityBase { @@ -153,6 +161,22 @@ public class Project extends PanacheEntityBase { public int scenariosCreated = 0; /** + * The rack prefabs belonging to this project. + */ + @OneToMany( + cascade = {CascadeType.ALL}, + mappedBy = "project", + orphanRemoval = true) + @OrderBy("id ASC") + public Set<RackPrefab> rackPrefabs = new HashSet<>(); + + /** + * The number of rack prefabs created for this project (including deleted rack prefabs). + */ + @Column(name = "rack_prefabs_created", nullable = false) + public int rackPrefabsCreated = 0; + + /** * The users authorized to access the project. */ @OneToMany( @@ -234,4 +258,26 @@ public class Project extends PanacheEntityBase { throw new IllegalStateException("Failed to allocate next scenario"); } + + /** + * Allocate the next rack prefab number for the specified [project]. + * + * @param time The time at which the new rack prefab is created. + */ + public int allocateRackPrefab(Instant time) { + for (int i = 0; i < 4; i++) { + long count = update( + "#Project.allocateRackPrefab", + Parameters.with("id", id) + .and("oldState", rackPrefabsCreated) + .and("now", time)); + if (count > 0) { + return rackPrefabsCreated + 1; + } else { + Panache.getEntityManager().refresh(this); + } + } + + throw new IllegalStateException("Failed to allocate next rack prefab"); + } } diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/RackPrefab.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/RackPrefab.java new file mode 100644 index 00000000..53b8466e --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/RackPrefab.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2024 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.opendc.web.server.model; + +import io.hypersistence.utils.hibernate.type.json.JsonType; +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import io.quarkus.panache.common.Parameters; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.NamedQueries; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import org.hibernate.annotations.Type; +import org.opendc.web.proto.topology.Rack; + +/** + * A rack prefab in OpenDC. + */ +@Entity +@Table( + name = "rack_prefabs", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_rack_prefabs_number", + columnNames = {"project_id", "number"}) + }, + indexes = {@Index(name = "ux_rack_prefabs_number", columnList = "project_id, number")}) +@NamedQueries({ + @NamedQuery( + name = "RackPrefab.findByProject", + query = "SELECT r FROM RackPrefab r WHERE r.project.id = :projectId"), + @NamedQuery( + name = "RackPrefab.findOneByProject", + query = "SELECT r FROM RackPrefab r WHERE r.project.id = :projectId AND r.number = :number") +}) +public class RackPrefab extends PanacheEntityBase { + /** + * The main ID of a rack prefab. + */ + @Id + @SequenceGenerator(name = "rackPrefabSeq", sequenceName = "rack_prefab_id_seq", allocationSize = 1) + @GeneratedValue(generator = "rackPrefabSeq") + public Long id; + + /** + * The {@link Project} to which the rack prefab belongs. + */ + @ManyToOne(optional = false) + @JoinColumn(name = "project_id", nullable = false) + public Project project; + + /** + * Unique number of the rack prefab for the project. + */ + @Column(nullable = false) + public int number; + + /** + * The name of the rack prefab. + */ + @Column(nullable = false) + public String name; + + /** + * The instant at which the rack prefab was created. + */ + @Column(name = "created_at", nullable = false, updatable = false) + public Instant createdAt; + + /** + * The instant at which the rack prefab was updated. + */ + @Column(name = "updated_at", nullable = false) + public Instant updatedAt; + + /** + * The rack design in JSON. + */ + @Column(columnDefinition = "jsonb", nullable = false) + @Type(JsonType.class) + public Rack rack; + + /** + * Construct a {@link RackPrefab} object. + */ + public RackPrefab(Project project, int number, String name, Instant createdAt, Rack rack) { + this.project = project; + this.number = number; + this.name = name; + this.createdAt = createdAt; + this.updatedAt = createdAt; + this.rack = rack; + } + + /** + * JPA constructor + */ + protected RackPrefab() {} + + /** + * Find all [RackPrefab]s that belong to [project][projectId]. + * + * @param projectId The unique identifier of the project. + * @return The query of rack prefabs that belong to the specified project. + */ + public static PanacheQuery<RackPrefab> findByProject(long projectId) { + return find("#RackPrefab.findByProject", Parameters.with("projectId", projectId)); + } + + /** + * Find the [RackPrefab] with the specified [number] belonging to [project][projectId]. + * + * @param projectId The unique identifier of the project. + * @param number The number of the rack prefab. + * @return The rack prefab or `null` if it does not exist. + */ + public static RackPrefab findByProject(long projectId, int number) { + return find( + "#RackPrefab.findOneByProject", + Parameters.with("projectId", projectId).and("number", number)) + .firstResult(); + } +} diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/rest/user/RackPrefabResource.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/rest/user/RackPrefabResource.java new file mode 100644 index 00000000..c0051b2b --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/rest/user/RackPrefabResource.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2024 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.opendc.web.server.rest.user; + +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.annotation.security.RolesAllowed; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; +import java.time.Instant; +import java.util.List; +import org.opendc.web.server.model.Project; +import org.opendc.web.server.model.ProjectAuthorization; +import org.opendc.web.server.model.RackPrefab; + +/** + * A resource representing rack prefabs. + */ +@Produces("application/json") +@Path("/projects/{project}/rack-prefabs") +@RolesAllowed("openid") +public final class RackPrefabResource { + /** + * The identity of the current user. + */ + private final SecurityIdentity identity; + + /** + * Construct a {@link RackPrefabResource}. + * + * @param identity The {@link SecurityIdentity} of the current user. + */ + public RackPrefabResource(SecurityIdentity identity) { + this.identity = identity; + } + + /** + * Get all rack prefabs that belong to the specified project. + */ + @GET + public List<org.opendc.web.proto.user.RackPrefab> getAll(@PathParam("project") long projectId) { + // User must have access to project + ProjectAuthorization auth = + ProjectAuthorization.findByUser(identity.getPrincipal().getName(), projectId); + + if (auth == null) { + return List.of(); + } + + return RackPrefab.findByProject(projectId).list().stream() + .map(p -> UserProtocol.toDto(p, auth)) + .toList(); + } + + /** + * Create a rack prefab for this project. + */ + @POST + @Consumes("application/json") + @Transactional + public org.opendc.web.proto.user.RackPrefab create( + @PathParam("project") long projectId, @Valid org.opendc.web.proto.user.RackPrefab.Create request) { + // User must have access to project + ProjectAuthorization auth = + ProjectAuthorization.findByUser(identity.getPrincipal().getName(), projectId); + + if (auth == null) { + throw new WebApplicationException("Project not found", 404); + } else if (!auth.canEdit()) { + throw new WebApplicationException("Not permitted to edit project", 403); + } + + Instant now = Instant.now(); + Project project = auth.project; + int number = project.allocateRackPrefab(now); + + RackPrefab prefab = new RackPrefab(project, number, request.name(), now, request.rack()); + + project.rackPrefabs.add(prefab); + prefab.persist(); + + return UserProtocol.toDto(prefab, auth); + } + + /** + * Delete the specified rack prefab. + */ + @Path("{number}") + @DELETE + @Transactional + public org.opendc.web.proto.user.RackPrefab delete( + @PathParam("project") long projectId, @PathParam("number") int number) { + // User must have access to project + ProjectAuthorization auth = + ProjectAuthorization.findByUser(identity.getPrincipal().getName(), projectId); + + if (auth == null) { + throw new WebApplicationException("Project not found", 404); + } else if (!auth.canEdit()) { + throw new WebApplicationException("Not permitted to edit project", 403); + } + + RackPrefab entity = RackPrefab.findByProject(projectId, number); + + if (entity == null) { + throw new WebApplicationException("Rack prefab not found", 404); + } + + entity.delete(); + + return UserProtocol.toDto(entity, auth); + } +} diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/rest/user/UserProtocol.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/rest/user/UserProtocol.java index 8196a9d6..3ee5d72e 100644 --- a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/rest/user/UserProtocol.java +++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/rest/user/UserProtocol.java @@ -26,6 +26,7 @@ import org.opendc.web.server.model.Job; import org.opendc.web.server.model.Portfolio; import org.opendc.web.server.model.Project; import org.opendc.web.server.model.ProjectAuthorization; +import org.opendc.web.server.model.RackPrefab; import org.opendc.web.server.model.Scenario; import org.opendc.web.server.model.Topology; import org.opendc.web.server.rest.BaseProtocol; @@ -92,6 +93,20 @@ public final class UserProtocol { } /** + * Convert a {@link RackPrefab} entity into a {@link org.opendc.web.proto.user.RackPrefab} DTO. + */ + public static org.opendc.web.proto.user.RackPrefab toDto(RackPrefab rackPrefab, ProjectAuthorization auth) { + return new org.opendc.web.proto.user.RackPrefab( + rackPrefab.id, + rackPrefab.number, + toDto(auth), + rackPrefab.name, + rackPrefab.rack, + rackPrefab.createdAt, + rackPrefab.updatedAt); + } + + /** * Convert a {@link Scenario} entity into a {@link org.opendc.web.proto.user.Scenario} DTO. */ public static org.opendc.web.proto.user.Scenario toDto(Scenario scenario, ProjectAuthorization auth) { diff --git a/opendc-web/opendc-web-server/src/main/resources/application-docker.properties b/opendc-web/opendc-web-server/src/main/resources/application-docker.properties index f0b3e7dc..3a9dce27 100644 --- a/opendc-web/opendc-web-server/src/main/resources/application-docker.properties +++ b/opendc-web/opendc-web-server/src/main/resources/application-docker.properties @@ -46,7 +46,7 @@ quarkus.smallrye-openapi.oidc-open-id-connect-url=https://${OPENDC_AUTH0_DOMAIN: quarkus.smallrye-openapi.servers=https://api.opendc.org # Enable the settings below if you want to test the docker-compose deployment locally -#quarkus.hibernate-orm.database.generation=drop-and-create -#quarkus.resteasy.path=/api -#quarkus.oidc.enabled=false -#opendc.security.enabled=false +quarkus.hibernate-orm.database.generation=drop-and-create +quarkus.resteasy.path=/api +quarkus.oidc.enabled=false +opendc.security.enabled=false diff --git a/opendc-web/opendc-web-server/src/main/resources/load_data.sql b/opendc-web/opendc-web-server/src/main/resources/load_data.sql index 39cb3a02..ca505b80 100644 --- a/opendc-web/opendc-web-server/src/main/resources/load_data.sql +++ b/opendc-web/opendc-web-server/src/main/resources/load_data.sql @@ -1,8 +1,8 @@ -- Insert data -INSERT INTO projects (created_at, name, portfolios_created, scenarios_created, topologies_created, updated_at, id) - VALUES ('2024-03-01T15:31:41.579969Z', 'Test Project 1', 0, 0, 0, '2024-03-01T15:31:41.579969Z', 1); +INSERT INTO projects (created_at, name, portfolios_created, scenarios_created, topologies_created, rack_prefabs_created, updated_at, id) + VALUES ('2024-03-01T15:31:41.579969Z', 'Test Project 1', 0, 0, 0, 0, '2024-03-01T15:31:41.579969Z', 1); INSERT INTO project_authorizations (role, project_id, user_id) VALUES ('OWNER', 1, 'test_user_1'); @@ -19,36 +19,36 @@ VALUES ('EDITOR', 1, 'test_user_3'); -- Create a project for test user 2 -INSERT INTO projects (created_at, name, portfolios_created, scenarios_created, topologies_created, updated_at, id) -VALUES ('2024-03-01T15:31:41.579969Z', 'Test Project 2', 0, 0, 0, '2024-03-01T15:31:41.579969Z', 2); +INSERT INTO projects (created_at, name, portfolios_created, scenarios_created, topologies_created, rack_prefabs_created, updated_at, id) +VALUES ('2024-03-01T15:31:41.579969Z', 'Test Project 2', 0, 0, 0, 0, '2024-03-01T15:31:41.579969Z', 2); INSERT INTO project_authorizations (role, project_id, user_id) VALUES ('OWNER', 2, 'test_user_2'); -- Create three projects for test user 3. User 3 has multiple projects to test getAll -INSERT INTO projects (created_at, name, portfolios_created, scenarios_created, topologies_created, updated_at, id) -VALUES ('2024-03-01T15:31:41.579969Z', 'Test Project 3', 0, 0, 0, '2024-03-01T15:31:41.579969Z', 3); +INSERT INTO projects (created_at, name, portfolios_created, scenarios_created, topologies_created, rack_prefabs_created, updated_at, id) +VALUES ('2024-03-01T15:31:41.579969Z', 'Test Project 3', 0, 0, 0, 0, '2024-03-01T15:31:41.579969Z', 3); INSERT INTO project_authorizations (role, project_id, user_id) VALUES ('OWNER', 3, 'test_user_3'); -INSERT INTO projects (created_at, name, portfolios_created, scenarios_created, topologies_created, updated_at, id) -VALUES ('2024-03-01T15:31:41.579969Z', 'Test Project 4', 0, 0, 0, '2024-03-01T15:31:41.579969Z', 4); +INSERT INTO projects (created_at, name, portfolios_created, scenarios_created, topologies_created, rack_prefabs_created, updated_at, id) +VALUES ('2024-03-01T15:31:41.579969Z', 'Test Project 4', 0, 0, 0, 0, '2024-03-01T15:31:41.579969Z', 4); INSERT INTO project_authorizations (role, project_id, user_id) VALUES ('OWNER', 4, 'test_user_3'); -INSERT INTO projects (created_at, name, portfolios_created, scenarios_created, topologies_created, updated_at, id) -VALUES ('2024-03-01T15:31:41.579969Z', 'Test Project 5', 0, 0, 0, '2024-03-01T15:31:41.579969Z', 5); +INSERT INTO projects (created_at, name, portfolios_created, scenarios_created, topologies_created, rack_prefabs_created, updated_at, id) +VALUES ('2024-03-01T15:31:41.579969Z', 'Test Project 5', 0, 0, 0, 0, '2024-03-01T15:31:41.579969Z', 5); INSERT INTO project_authorizations (role, project_id, user_id) VALUES ('OWNER', 5, 'test_user_3'); -- Project to delete -INSERT INTO projects (created_at, name, portfolios_created, scenarios_created, topologies_created, updated_at, id) -VALUES ('2024-03-01T15:31:41.579969Z', 'Test Project Delete', 0, 0, 0, '2024-03-01T15:31:41.579969Z', 6); +INSERT INTO projects (created_at, name, portfolios_created, scenarios_created, topologies_created, rack_prefabs_created, updated_at, id) +VALUES ('2024-03-01T15:31:41.579969Z', 'Test Project Delete', 0, 0, 0, 0, '2024-03-01T15:31:41.579969Z', 6); INSERT INTO project_authorizations (role, project_id, user_id) VALUES ('OWNER', 6, 'test_user_1'); diff --git a/opendc-web/opendc-web-server/src/main/webui/api/rack-prefabs.js b/opendc-web/opendc-web-server/src/main/webui/api/rack-prefabs.js new file mode 100644 index 00000000..1792704b --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/api/rack-prefabs.js @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2021 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { request } from './index' + +export function fetchRackPrefabs(auth, projectId) { + return request(auth, `projects/${projectId}/rack-prefabs`) +} + +export function addRackPrefab(auth, projectId, rackPrefab) { + return request(auth, `projects/${projectId}/rack-prefabs`, 'POST', rackPrefab) +} + +export function deleteRackPrefab(auth, projectId, rackPrefabNumber) { + return request(auth, `projects/${projectId}/rack-prefabs/${rackPrefabNumber}`, 'DELETE') +} diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/AddPrefab.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/AddPrefab.js index 6a0c3ff3..d3266537 100644 --- a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/AddPrefab.js +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/AddPrefab.js @@ -22,15 +22,81 @@ import PropTypes from 'prop-types' import React from 'react' -import { Button } from '@patternfly/react-core' +import { Button, AlertGroup, Alert, AlertVariant, AlertActionCloseButton } from '@patternfly/react-core' import { SaveIcon } from '@patternfly/react-icons' +import { useSelector } from 'react-redux' +import { useRouter } from 'next/router' +import { denormalize } from 'normalizr' +import { Rack } from '../../../../util/topology-schema' +import { useNewRackPrefab } from '../../../../data/rack-prefabs' + +function AddPrefab({ tileId }) { + const [alert, setAlert] = React.useState(null) + const router = useRouter() + const { project: projectId } = router.query + const { mutate: addRackPrefab } = useNewRackPrefab() + + const rackId = useSelector((state) => state.topology.tiles[tileId]?.rack) + const rack = useSelector((state) => { + const topologyState = state.topology + if (!rackId || !topologyState.racks[rackId]) { + return null + } + return denormalize(rackId, Rack, topologyState) + }) + + const onClick = () => { + if (rack && projectId) { + addRackPrefab( + { + projectId, + name: rack.name, + rack: { + ...rack, + id: 0, + machines: rack.machines.map((m) => ({ ...m, id: 0 })), + }, + }, + { + onSuccess: () => { + setAlert({ variant: AlertVariant.success, title: 'Rack saved as prefab' }) + }, + onError: (error) => { + setAlert({ variant: AlertVariant.danger, title: `Failed to save rack: ${error}` }) + }, + } + ) + } + } -function AddPrefab() { - const onClick = () => {} // TODO return ( - <Button variant="primary" icon={<SaveIcon />} isBlock onClick={onClick} className="pf-u-mb-sm"> - Save this rack to a prefab - </Button> + <> + <AlertGroup isToast> + {alert && ( + <Alert + isLiveRegion + variant={alert.variant} + title={alert.title} + actionClose={ + <AlertActionCloseButton + title={alert.title} + onClose={() => setAlert(null)} + /> + } + /> + )} + </AlertGroup> + <Button + variant="primary" + icon={<SaveIcon />} + isBlock + onClick={onClick} + className="pf-u-mb-sm" + isDisabled={!rack} + > + Save this rack to a prefab + </Button> + </> ) } diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RackConstructionComponent.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RackConstructionComponent.js index a384d5d5..f9eab381 100644 --- a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RackConstructionComponent.js +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RackConstructionComponent.js @@ -1,9 +1,11 @@ import PropTypes from 'prop-types' import React from 'react' -import { Button } from '@patternfly/react-core' +import { Button, FormSelect, FormSelectOption } from '@patternfly/react-core' import { PlusIcon, TimesIcon } from '@patternfly/react-icons' -const RackConstructionComponent = ({ onStart, onStop, inRackConstructionMode, isEditingRoom }) => { +const RackConstructionComponent = ({ onStart, onStop, inRackConstructionMode, isEditingRoom, prefabs = [] }) => { + const [selectedPrefabId, setSelectedPrefabId] = React.useState('') + if (inRackConstructionMode) { return ( <Button isBlock={true} icon={<TimesIcon />} onClick={onStop} className="pf-u-mb-sm"> @@ -12,16 +14,38 @@ const RackConstructionComponent = ({ onStart, onStop, inRackConstructionMode, is ) } + const onChangePrefab = (value) => { + setSelectedPrefabId(value) + } + return ( - <Button - icon={<PlusIcon />} - isBlock - isDisabled={isEditingRoom} - onClick={() => (isEditingRoom ? undefined : onStart())} - className="pf-u-mb-sm" - > - Start rack construction - </Button> + <> + <FormSelect + value={selectedPrefabId} + onChange={onChangePrefab} + aria-label="Select rack prefab" + className="pf-u-mb-sm" + > + <FormSelectOption key="" value="" label="Empty Rack" /> + {prefabs.map((prefab) => ( + <FormSelectOption key={prefab.id} value={prefab.id} label={prefab.name} /> + ))} + </FormSelect> + <Button + icon={<PlusIcon />} + isBlock + isDisabled={isEditingRoom} + onClick={() => { + if (!isEditingRoom) { + const prefab = prefabs.find((p) => p.id === parseInt(selectedPrefabId)) + onStart(prefab) + } + }} + className="pf-u-mb-sm" + > + Start rack construction + </Button> + </> ) } @@ -30,6 +54,7 @@ RackConstructionComponent.propTypes = { onStop: PropTypes.func, inRackConstructionMode: PropTypes.bool, isEditingRoom: PropTypes.bool, + prefabs: PropTypes.array, } export default RackConstructionComponent diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RackConstructionContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RackConstructionContainer.js index e04287a5..70f1b8e6 100644 --- a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RackConstructionContainer.js +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RackConstructionContainer.js @@ -22,21 +22,28 @@ import React from 'react' import { useDispatch, useSelector } from 'react-redux' +import { useRouter } from 'next/router' import { startRackConstruction, stopRackConstruction } from '../../../../redux/actions/topology/room' +import { useRackPrefabs } from '../../../../data/rack-prefabs' import RackConstructionComponent from './RackConstructionComponent' function RackConstructionContainer(props) { + const router = useRouter() + const { project: projectId } = router.query + const { data: prefabs = [] } = useRackPrefabs(projectId) + const isRackConstructionMode = useSelector((state) => state.construction.inRackConstructionMode) const isEditingRoom = useSelector((state) => state.construction.currentRoomInConstruction !== '-1') const dispatch = useDispatch() - const onStart = () => dispatch(startRackConstruction()) + const onStart = (rackPrefab) => dispatch(startRackConstruction(rackPrefab)) const onStop = () => dispatch(stopRackConstruction()) return ( <RackConstructionComponent {...props} inRackConstructionMode={isRackConstructionMode} isEditingRoom={isEditingRoom} + prefabs={prefabs} onStart={onStart} onStop={onStop} /> diff --git a/opendc-web/opendc-web-server/src/main/webui/data/query.js b/opendc-web/opendc-web-server/src/main/webui/data/query.js index 3e5423b9..109cd2e7 100644 --- a/opendc-web/opendc-web-server/src/main/webui/data/query.js +++ b/opendc-web/opendc-web-server/src/main/webui/data/query.js @@ -25,6 +25,7 @@ import { QueryClient } from 'react-query' import { useAuth } from '../auth' import { configureExperimentClient } from './experiments' import { configureProjectClient } from './project' +import { configureRackPrefabClient } from './rack-prefabs' import { configureTopologyClient } from './topology' import { configureUserClient } from './user' @@ -33,6 +34,7 @@ let queryClient function createQueryClient(auth) { const client = new QueryClient() configureProjectClient(client, auth) + configureRackPrefabClient(client, auth) configureExperimentClient(client, auth) configureTopologyClient(client, auth) configureUserClient(client, auth) diff --git a/opendc-web/opendc-web-server/src/main/webui/data/rack-prefabs.js b/opendc-web/opendc-web-server/src/main/webui/data/rack-prefabs.js new file mode 100644 index 00000000..1979fa24 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/data/rack-prefabs.js @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2021 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { useQuery, useMutation } from 'react-query' +import { addRackPrefab, deleteRackPrefab, fetchRackPrefabs } from '../api/rack-prefabs' + +/** + * Configure the query defaults for the rack prefab endpoints. + */ +export function configureRackPrefabClient(queryClient, auth) { + queryClient.setQueryDefaults('rack-prefabs', { + queryFn: ({ queryKey }) => fetchRackPrefabs(auth, queryKey[1]), + }) + + queryClient.setMutationDefaults('addRackPrefab', { + mutationFn: ({ projectId, ...data }) => addRackPrefab(auth, projectId, data), + onSuccess: (result) => { + queryClient.setQueryData(['rack-prefabs', result.project.id], (old = []) => [...old, result]) + }, + }) + queryClient.setMutationDefaults('deleteRackPrefab', { + mutationFn: ({ projectId, number }) => deleteRackPrefab(auth, projectId, number), + onSuccess: (result) => { + queryClient.setQueryData(['rack-prefabs', result.project.id], (old = []) => + old.filter((rackPrefab) => rackPrefab.id !== result.id) + ) + }, + }) +} + +/** + * Fetch all rack prefabs of the specified project. + */ +export function useRackPrefabs(projectId, options = {}) { + return useQuery(['rack-prefabs', projectId], { enabled: !!projectId, ...options }) +} + +/** + * Create a mutation for a new rack prefab. + */ +export function useNewRackPrefab() { + return useMutation('addRackPrefab') +} + +/** + * Create a mutation for deleting a rack prefab. + */ +export function useDeleteRackPrefab(options = {}) { + return useMutation('deleteRackPrefab', options) +} diff --git a/opendc-web/opendc-web-server/src/main/webui/redux/actions/topology/room.js b/opendc-web/opendc-web-server/src/main/webui/redux/actions/topology/room.js index 14cc126c..70c93d1f 100644 --- a/opendc-web/opendc-web-server/src/main/webui/redux/actions/topology/room.js +++ b/opendc-web/opendc-web-server/src/main/webui/redux/actions/topology/room.js @@ -1,4 +1,6 @@ import { v4 as uuid } from 'uuid' +import { normalize } from 'normalizr' +import { Rack as RackSchema } from '../../../util/topology-schema' import { DEFAULT_RACK_SLOT_CAPACITY, DEFAULT_RACK_POWER_CAPACITY, @@ -31,9 +33,10 @@ export function editRoomName(roomId, name) { } } -export function startRackConstruction() { +export function startRackConstruction(rackPrefab) { return { type: START_RACK_CONSTRUCTION, + rackPrefab, } } @@ -45,22 +48,34 @@ export function stopRackConstruction() { export function addRackToTile(positionX, positionY) { return (dispatch, getState) => { - const { topology, interactionLevel } = getState() + const { topology, interactionLevel, construction } = getState() const currentRoom = topology.rooms[interactionLevel.roomId] const tiles = currentRoom.tiles.map((tileId) => topology.tiles[tileId]) const tile = findTileWithPosition(tiles, positionX, positionY) if (tile !== null) { + const prefab = construction.currentRackPrefab + const rackId = uuid() + const rack = prefab + ? { + ...prefab.rack, + id: rackId, + machines: (prefab.rack.machines || []).map((m) => ({ ...m, id: uuid(), rackId })), + } + : { + id: rackId, + name: 'Rack', + capacity: DEFAULT_RACK_SLOT_CAPACITY, + powerCapacityW: DEFAULT_RACK_POWER_CAPACITY, + machines: [], + } + + const { entities, result: normalizedRackId } = normalize(rack, RackSchema) dispatch({ type: ADD_RACK_TO_TILE, tileId: tile.id, - rack: { - id: uuid(), - name: 'Rack', - capacity: DEFAULT_RACK_SLOT_CAPACITY, - powerCapacityW: DEFAULT_RACK_POWER_CAPACITY, - machines: [], - }, + rack: entities.racks[normalizedRackId], + entities, }) } } diff --git a/opendc-web/opendc-web-server/src/main/webui/redux/reducers/construction-mode.js b/opendc-web/opendc-web-server/src/main/webui/redux/reducers/construction-mode.js index d0aac5ae..8520e794 100644 --- a/opendc-web/opendc-web-server/src/main/webui/redux/reducers/construction-mode.js +++ b/opendc-web/opendc-web-server/src/main/webui/redux/reducers/construction-mode.js @@ -37,7 +37,20 @@ export function inRackConstructionMode(state = false, action) { } } +export function currentRackPrefab(state = null, action) { + switch (action.type) { + case START_RACK_CONSTRUCTION: + return action.rackPrefab || null + case STOP_RACK_CONSTRUCTION: + case GO_DOWN_ONE_INTERACTION_LEVEL: + return null + default: + return state + } +} + export const construction = combineReducers({ currentRoomInConstruction, inRackConstructionMode, + currentRackPrefab, }) diff --git a/opendc-web/opendc-web-server/src/main/webui/redux/reducers/topology/machine.js b/opendc-web/opendc-web-server/src/main/webui/redux/reducers/topology/machine.js index 1789257b..5cf38726 100644 --- a/opendc-web/opendc-web-server/src/main/webui/redux/reducers/topology/machine.js +++ b/opendc-web/opendc-web-server/src/main/webui/redux/reducers/topology/machine.js @@ -2,11 +2,19 @@ import produce from 'immer' import { STORE_TOPOLOGY } from '../../actions/topology' import { DELETE_MACHINE, ADD_UNIT, DELETE_UNIT } from '../../actions/topology/machine' import { ADD_MACHINE, DELETE_RACK } from '../../actions/topology/rack' +import { ADD_RACK_TO_TILE } from '../../actions/topology/room' function machine(state = {}, action, { racks }) { switch (action.type) { case STORE_TOPOLOGY: return action.entities.machines || {} + case ADD_RACK_TO_TILE: + return produce(state, (draft) => { + const { entities } = action + if (entities && entities.machines) { + Object.assign(draft, entities.machines) + } + }) case ADD_MACHINE: return produce(state, (draft) => { const { machine } = action diff --git a/opendc-web/opendc-web-server/src/test/java/org/opendc/web/server/rest/user/RackPrefabResourceTest.java b/opendc-web/opendc-web-server/src/test/java/org/opendc/web/server/rest/user/RackPrefabResourceTest.java new file mode 100644 index 00000000..82beb608 --- /dev/null +++ b/opendc-web/opendc-web-server/src/test/java/org/opendc/web/server/rest/user/RackPrefabResourceTest.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2024 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.opendc.web.server.rest.user; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; + +import io.quarkus.test.common.http.TestHTTPEndpoint; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import io.restassured.http.ContentType; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.opendc.web.proto.topology.Rack; +import org.opendc.web.proto.user.RackPrefab; + +/** + * Test suite for the {@link RackPrefabResource} endpoint. + */ +@QuarkusTest +@TestHTTPEndpoint(RackPrefabResource.class) +public class RackPrefabResourceTest { + /** + * Test that tries to create a rack prefab for a project. + */ + @Test + @TestSecurity( + user = "test_user_1", + roles = {"openid"}) + public void testCreate() { + Rack rack = new Rack("1", "Rack 1", 42, 1000.0, List.of()); + given().pathParam("project", "1") + .body(new RackPrefab.Create("Test Rack Prefab", rack)) + .contentType(ContentType.JSON) + .when() + .post() + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("name", equalTo("Test Rack Prefab")) + .body("project.id", equalTo(1)); + } + + /** + * Test that tries to get all rack prefabs for a project. + */ + @Test + @TestSecurity( + user = "test_user_1", + roles = {"openid"}) + public void testGetAll() { + given().pathParam("project", "1").when().get().then().statusCode(200).contentType(ContentType.JSON); + } + + /** + * Test that tries to delete a rack prefab. + */ + @Test + @TestSecurity( + user = "test_user_1", + roles = {"openid"}) + public void testDelete() { + Rack rack = new Rack("1", "Rack 1", 42, 1000.0, List.of()); + int number = given().pathParam("project", "1") + .body(new RackPrefab.Create("Test Rack Prefab to Delete", rack)) + .contentType(ContentType.JSON) + .when() + .post() + .then() + .statusCode(200) + .extract() + .path("number"); + + given().pathParam("project", "1") + .pathParam("number", number) + .when() + .delete("/{number}") + .then() + .statusCode(200); + } +} |
