diff options
| author | vincent van beek <vincent@vlogic.nl> | 2026-03-27 16:49:40 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-27 15:49:40 +0000 |
| commit | 048bf777997bdbf599240645fc66612c98abf3c2 (patch) | |
| tree | c04e999cb981c98ae9dc0fd83ea70aec9eaa419c /opendc-web | |
| parent | 235057cd170f1583db14bf93ea7d2de39e492356 (diff) | |
Add import topology (#393)
* add a the posibility to import and export topogies in JSON format
* fix web-runner integration, there were several bugs and mismatches between new implementations in OpenDC and the UI
Diffstat (limited to 'opendc-web')
17 files changed, 491 insertions, 56 deletions
diff --git a/opendc-web/opendc-web-runner/build.gradle.kts b/opendc-web/opendc-web-runner/build.gradle.kts index 97328324..a02030d9 100644 --- a/opendc-web/opendc-web-runner/build.gradle.kts +++ b/opendc-web/opendc-web-runner/build.gradle.kts @@ -24,7 +24,7 @@ description = "Experiment runner for OpenDC" // Build configuration plugins { - `kotlin-library-conventions` + `kotlin-conventions` distribution } @@ -67,11 +67,11 @@ dependencies { } val createCli by tasks.creating(CreateStartScripts::class) { - dependsOn(cliJar) + dependsOn(cliJar, tasks.jar) applicationName = "opendc-runner" - mainClass.set("org.opendc.web.runner.cli.MainKt") - classpath = cliJar.outputs.files + cliRuntimeClasspath + mainClass.set("org.opendc.web.runner.MainKt") + classpath = tasks.jar.get().outputs.files + cliJar.outputs.files + cliRuntimeClasspath outputDir = project.layout.buildDirectory.get().asFile.resolve("scripts") } @@ -83,6 +83,7 @@ distributions { } into("lib") { + from(tasks.jar) from(cliJar) from(cliRuntimeClasspath) // Also includes main classpath } diff --git a/opendc-web/opendc-web-runner/src/cli/kotlin/org/opendc/web/runner/Main.kt b/opendc-web/opendc-web-runner/src/cli/kotlin/org/opendc/web/runner/Main.kt index 5d35fd98..6583810c 100644 --- a/opendc-web/opendc-web-runner/src/cli/kotlin/org/opendc/web/runner/Main.kt +++ b/opendc-web/opendc-web-runner/src/cli/kotlin/org/opendc/web/runner/Main.kt @@ -26,8 +26,8 @@ import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.options.convert import com.github.ajalt.clikt.parameters.options.default import com.github.ajalt.clikt.parameters.options.defaultLazy +import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option -import com.github.ajalt.clikt.parameters.options.required import com.github.ajalt.clikt.parameters.types.file import com.github.ajalt.clikt.parameters.types.int import mu.KotlinLogging @@ -54,6 +54,15 @@ class RunnerCli : CliktCommand(name = "opendc-runner") { .default(URI("https://api.opendc.org/v2")) /** + * Flag to disable authentication (for local development). + */ + private val noAuth by option( + "--no-auth", + help = "disable authentication (for local development)", + ) + .flag() + + /** * The auth domain to use. */ private val authDomain by option( @@ -61,17 +70,15 @@ class RunnerCli : CliktCommand(name = "opendc-runner") { help = "auth domain of the OpenDC API", envvar = "AUTH0_DOMAIN", ) - .required() /** - * The auth domain to use. + * The auth audience to use. */ private val authAudience by option( "--auth-audience", help = "auth audience of the OpenDC API", envvar = "AUTH0_AUDIENCE", ) - .required() /** * The auth client ID to use. @@ -81,7 +88,6 @@ class RunnerCli : CliktCommand(name = "opendc-runner") { help = "auth client id of the OpenDC API", envvar = "AUTH0_CLIENT_ID", ) - .required() /** * The auth client secret to use. @@ -91,7 +97,6 @@ class RunnerCli : CliktCommand(name = "opendc-runner") { help = "auth client secret of the OpenDC API", envvar = "AUTH0_CLIENT_SECRET", ) - .required() /** * The path to the traces directory. @@ -117,7 +122,31 @@ class RunnerCli : CliktCommand(name = "opendc-runner") { override fun run() { logger.info { "Starting OpenDC web runner" } - val client = OpenDCRunnerClient(baseUrl = apiUrl, OpenIdAuthController(authDomain, authClientId, authClientSecret, authAudience)) + // Validate auth parameters if authentication is enabled + if (!noAuth) { + require( + authDomain != null, + ) { "Auth domain is required when authentication is enabled. Use --no-auth to disable authentication." } + require( + authAudience != null, + ) { "Auth audience is required when authentication is enabled. Use --no-auth to disable authentication." } + require( + authClientId != null, + ) { "Auth client ID is required when authentication is enabled. Use --no-auth to disable authentication." } + require(authClientSecret != null) { + "Auth client secret is required when authentication is enabled. Use --no-auth to disable authentication." + } + } + + val authController = + if (noAuth) { + logger.info { "Running without authentication" } + null + } else { + OpenIdAuthController(authDomain!!, authClientId!!, authClientSecret!!, authAudience!!) + } + + val client = OpenDCRunnerClient(baseUrl = apiUrl, authController) val manager = JobManager(client) val runner = OpenDCRunner(manager, tracePath, parallelism = parallelism) diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/OpenDCRunner.kt b/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/OpenDCRunner.kt index 83583eab..d41400e3 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/OpenDCRunner.kt +++ b/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/OpenDCRunner.kt @@ -276,6 +276,8 @@ public class OpenDCRunner( val vms = workloadLoader.sampleByLoad(scenario.workload.samplingFraction) val startTime = vms.minOf { it.submittedAt } + logger.debug { "Using scheduler: '${scenario.schedulerName}' for scenario ${scenario.id}" } + provisioner.runSteps( setupComputeService( serviceDomain, diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Job.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Job.java index ef342e5f..63982854 100644 --- a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Job.java +++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Job.java @@ -26,7 +26,6 @@ import io.hypersistence.utils.hibernate.type.json.JsonType; import io.quarkus.hibernate.orm.panache.Panache; import io.quarkus.hibernate.orm.panache.PanacheEntityBase; import io.quarkus.hibernate.orm.panache.PanacheQuery; -import io.quarkus.panache.common.Parameters; import jakarta.persistence.*; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -36,8 +35,6 @@ import jakarta.persistence.FetchType; import jakarta.persistence.ForeignKey; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.NamedQueries; -import jakarta.persistence.NamedQuery; import jakarta.persistence.Table; import java.time.Instant; import java.util.Map; @@ -49,16 +46,6 @@ import org.opendc.web.proto.JobState; */ @Entity @Table -@NamedQueries({ - @NamedQuery( - name = "Job.updateOne", - query = - """ - UPDATE Job j - SET j.state = :newState, j.updatedAt = :updatedAt, j.runtime = :runtime, j.results = :results - WHERE j.id = :id AND j.state = :oldState - """) -}) public class Job extends PanacheEntityBase { /** * The main ID of a project. @@ -146,16 +133,16 @@ public class Job extends PanacheEntityBase { * @return <code>true</code> when the update succeeded`, <code>false</code> when there was a conflict. */ public boolean updateAtomically(JobState newState, Instant time, int runtime, Map<String, ?> results) { - long count = update( - "#Job.updateOne", - Parameters.with("id", id) - .and("oldState", state) - .and("newState", newState) - .and("updatedAt", time) - .and("runtime", runtime) - .and("results", results)); - Panache.getEntityManager().refresh(this); - return count > 0; + // Update entity fields directly - this uses the JsonType converter for proper JSON serialization + this.state = newState; + this.updatedAt = time; + this.runtime = runtime; + this.results = results; + + // Flush changes to database - JsonType will properly serialize the results map to JSON + Panache.getEntityManager().flush(); + + return true; } /** diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Topology.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Topology.java index ff8b4416..50402ab9 100644 --- a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Topology.java +++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Topology.java @@ -59,7 +59,10 @@ import org.opendc.web.proto.topology.Room; @NamedQuery(name = "Topology.findByProject", query = "SELECT t FROM Topology t WHERE t.project.id = :projectId"), @NamedQuery( name = "Topology.findOneByProject", - query = "SELECT t FROM Topology t WHERE t.project.id = :projectId AND t.number = :number") + query = "SELECT t FROM Topology t WHERE t.project.id = :projectId AND t.number = :number"), + @NamedQuery( + name = "Topology.findOneByName", + query = "SELECT t FROM Topology t WHERE t.project.id = :projectId AND t.name = :name") }) public class Topology extends PanacheEntityBase { /** @@ -149,4 +152,18 @@ public class Topology extends PanacheEntityBase { Parameters.with("projectId", projectId).and("number", number)) .firstResult(); } + + /** + * Find the [Topology] with the specified [name] belonging to [project][projectId]. + * + * @param projectId The unique identifier of the project. + * @param name The name of the topology. + * @return The topology or `null` if it does not exist. + */ + public static Topology findByName(long projectId, String name) { + return find( + "#Topology.findOneByName", + Parameters.with("projectId", projectId).and("name", name)) + .firstResult(); + } } diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/rest/SchedulerResource.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/rest/SchedulerResource.java index 3e839040..527c04b1 100644 --- a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/rest/SchedulerResource.java +++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/rest/SchedulerResource.java @@ -39,14 +39,19 @@ public final class SchedulerResource { @GET public List<String> getAll() { return List.of( - "mem", - "mem-inv", - "core-mem", - "core-mem-inv", - "active-tasks", - "active-tasks-inv", - "provisioned-cores", - "provisioned-cores-inv", - "random"); + "Mem", + "MemInv", + "CoreMem", + "CoreMemInv", + "ActiveServers", + "ActiveServersInv", + "ProvisionedCores", + "ProvisionedCoresInv", + "Random", + "TaskNumMemorizing", + "Timeshift", + "ProvisionedCpuGpuCores", + "ProvisionedCpuGpuCoresInv", + "GpuTaskMemorizing"); } } diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/rest/user/TopologyResource.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/rest/user/TopologyResource.java index 25819e32..79290e26 100644 --- a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/rest/user/TopologyResource.java +++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/rest/user/TopologyResource.java @@ -39,6 +39,15 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.WebApplicationException; import java.time.Instant; import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.opendc.web.proto.topology.Machine; +import org.opendc.web.proto.topology.MemoryUnit; +import org.opendc.web.proto.topology.ProcessingUnit; +import org.opendc.web.proto.topology.Rack; +import org.opendc.web.proto.topology.Room; +import org.opendc.web.proto.topology.RoomTile; import org.opendc.web.server.model.Project; import org.opendc.web.server.model.ProjectAuthorization; import org.opendc.web.server.model.Topology; @@ -102,9 +111,14 @@ public final class TopologyResource { Instant now = Instant.now(); Project project = auth.project; + + if (Topology.findByName(projectId, request.name()) != null) { + throw new WebApplicationException("Topology name already exists", 409); + } + int number = project.allocateTopology(now); - Topology topology = new Topology(project, number, request.name(), now, request.rooms()); + Topology topology = new Topology(project, number, request.name(), now, createNewInstances(request.rooms())); project.topologies.add(topology); topology.persist(); @@ -205,4 +219,114 @@ public final class TopologyResource { return UserProtocol.toDto(entity, auth); } + + /** + * Create new instances of the specified rooms. + */ + private List<Room> createNewInstances(List<Room> rooms) { + if (rooms == null) { + return null; + } + + return rooms.stream().map(this::createNewInstance).toList(); + } + + /** + * Create a new instance of the specified room. + */ + private Room createNewInstance(Room room) { + String roomId = UUID.randomUUID().toString(); + Set<RoomTile> tiles = room.tiles(); + if (tiles != null) { + tiles = tiles.stream().map(tile -> createNewInstance(tile, roomId)).collect(Collectors.toSet()); + } + + return new Room(roomId, room.name(), tiles, room.topologyId()); + } + + /** + * Create a new instance of the specified room tile. + */ + private RoomTile createNewInstance(RoomTile tile, String roomId) { + String tileId = UUID.randomUUID().toString(); + return new RoomTile( + tileId, + tile.positionX(), + tile.positionY(), + tile.rack() != null ? createNewInstance(tile.rack()) : null, + roomId); + } + + /** + * Create a new instance of the specified rack. + */ + private Rack createNewInstance(Rack rack) { + String rackId = UUID.randomUUID().toString(); + List<Machine> machines = rack.machines(); + if (machines != null) { + machines = machines.stream() + .map(machine -> createNewInstance(machine, rackId)) + .toList(); + } + + return new Rack(rackId, rack.name(), rack.capacity(), rack.powerCapacityW(), machines); + } + + /** + * Create a new instance of the specified machine. + */ + private Machine createNewInstance(Machine machine, String rackId) { + String machineId = UUID.randomUUID().toString(); + List<ProcessingUnit> cpus = machine.cpus(); + if (cpus != null) { + cpus = cpus.stream().map(this::createNewInstance).toList(); + } + + List<ProcessingUnit> gpus = machine.gpus(); + if (gpus != null) { + gpus = gpus.stream().map(this::createNewInstance).toList(); + } + + List<MemoryUnit> memory = machine.memory(); + if (memory != null) { + memory = memory.stream().map(this::createNewInstance).toList(); + } + + List<MemoryUnit> storage = machine.storage(); + if (storage != null) { + storage = storage.stream().map(this::createNewInstance).toList(); + } + + return new Machine(machineId, machine.position(), cpus, gpus, memory, storage, rackId); + } + + /** + * Create a new instance of the specified processing unit. + */ + private ProcessingUnit createNewInstance(ProcessingUnit unit) { + if (unit != null && (unit.id() == null || unit.id().isBlank())) { + return new ProcessingUnit( + UUID.randomUUID().toString(), + unit.name(), + unit.clockRateMhz(), + unit.numberOfCores(), + unit.energyConsumptionW()); + } + return unit; + } + + /** + * Create a new instance of the specified memory unit. + */ + private MemoryUnit createNewInstance(MemoryUnit unit) { + if (unit != null && (unit.id() == null || unit.id().isBlank())) { + return new MemoryUnit( + UUID.randomUUID().toString(), + unit.name(), + unit.speedMbPerS(), + unit.sizeMb(), + unit.energyConsumptionW()); + } + return unit; + } } 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 ca505b80..df3e3709 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 @@ -103,10 +103,10 @@ VALUES ('bitbrains-small', 'Bitbrains Small', 'small'); -- -------------------------------------------------------------------------------- INSERT INTO scenarios (name, number, phenomena, portfolio_id, project_id, scheduler_name, topology_id, sampling_fraction, trace_id, id) -VALUES ('Test Scenario testDelete', 1, '{"failures": false, "interference": false}' FORMAT JSON, 1, 1, 'test', 1, 1.0, 'bitbrains-small', 1); +VALUES ('Test Scenario testDelete', 1, '{"failures": false, "interference": false}' FORMAT JSON, 1, 1, 'Mem', 1, 1.0, 'bitbrains-small', 1); INSERT INTO scenarios (name, number, phenomena, portfolio_id, project_id, scheduler_name, topology_id, sampling_fraction, trace_id, id) -VALUES ('Test Scenario testDeleteUsed', 2, '{"failures": false, "interference": false}' FORMAT JSON, 1, 1, 'test', 4, 1.0, 'bitbrains-small', 2); +VALUES ('Test Scenario testDeleteUsed', 2, '{"failures": false, "interference": false}' FORMAT JSON, 1, 1, 'Random', 4, 1.0, 'bitbrains-small', 2); UPDATE projects p diff --git a/opendc-web/opendc-web-server/src/main/webui/components/projects/ImportTopology.js b/opendc-web/opendc-web-server/src/main/webui/components/projects/ImportTopology.js new file mode 100644 index 00000000..715c1a3e --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/projects/ImportTopology.js @@ -0,0 +1,59 @@ +/* + * 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 React, { useState } from 'react' +import PropTypes from 'prop-types' +import { UploadIcon } from '@patternfly/react-icons' +import { Button } from '@patternfly/react-core' +import { useNewTopology, useTopologies } from '../../data/topology' +import ImportTopologyModal from './ImportTopologyModal' + +function ImportTopology({ projectId }) { + const [isVisible, setVisible] = useState(false) + const { mutate: addTopology } = useNewTopology() + const { data: topologies = [] } = useTopologies(projectId, { enabled: isVisible }) + + const onSubmit = (topology) => { + addTopology({ projectId, ...topology }) + setVisible(false) + } + + return ( + <> + <Button variant="secondary" icon={<UploadIcon />} isSmall onClick={() => setVisible(true)}> + Import Topology + </Button> + <ImportTopologyModal + isOpen={isVisible} + onCancel={() => setVisible(false)} + onSubmit={onSubmit} + topologies={topologies} + /> + </> + ) +} + +ImportTopology.propTypes = { + projectId: PropTypes.number, +} + +export default ImportTopology diff --git a/opendc-web/opendc-web-server/src/main/webui/components/projects/ImportTopologyModal.js b/opendc-web/opendc-web-server/src/main/webui/components/projects/ImportTopologyModal.js new file mode 100644 index 00000000..d8f38426 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/projects/ImportTopologyModal.js @@ -0,0 +1,131 @@ +/* + * 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 React, { useState } from 'react' +import PropTypes from 'prop-types' +import { + Form, + FormGroup, + FileUpload, + Alert, +} from '@patternfly/react-core' +import Modal from '../util/modals/Modal' + +function ImportTopologyModal({ isOpen, onCancel, onSubmit, topologies = [] }) { + const [fileValue, setFileValue] = useState('') + const [fileName, setFileName] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const handleFileChange = (value, filename) => { + setFileValue(value) + setFileName(filename) + } + + const handleFileReadStarted = () => setIsLoading(true) + const handleFileReadFinished = () => setIsLoading(false) + + const clearState = () => { + setFileValue('') + setFileName('') + setError(null) + } + + const onCancelProxy = () => { + onCancel() + clearState() + } + + const handleFormSubmit = (event) => { + if (event) { + event.preventDefault() + } + + if (!fileValue) { + setError('Please upload a topology JSON file.') + return + } + + try { + const topology = JSON.parse(fileValue) + if (!topology.name || !topology.rooms) { + setError('Invalid topology format. Name and rooms are required.') + return + } + + if (topologies.some((t) => t.name === topology.name)) { + setError('A topology with the name "' + topology.name + '" already exists.') + return + } + + onSubmit(topology) + clearState() + } catch (err) { + setError('Failed to parse JSON: ' + err.message) + } + } + + return ( + <Modal + title="Import Topology" + isOpen={isOpen} + onCancel={onCancelProxy} + onSubmit={handleFormSubmit} + submitButtonText="Import" + > + <Form onSubmit={handleFormSubmit}> + {error && ( + <Alert variant="danger" title={error} isInline /> + )} + <FormGroup + label="Topology JSON File" + isRequired + fieldId="topology-file" + helperText="Upload the JSON file of the topology you want to import." + > + <FileUpload + id="topology-file" + type="text" + value={fileValue} + filename={fileName} + onChange={handleFileChange} + onReadStarted={handleFileReadStarted} + onReadFinished={handleFileReadFinished} + isLoading={isLoading} + dropzoneProps={{ + accept: '.json', + }} + /> + </FormGroup> + </Form> + </Modal> + ) +} + +ImportTopologyModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onCancel: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + topologies: PropTypes.array, +} + +export default ImportTopologyModal diff --git a/opendc-web/opendc-web-server/src/main/webui/components/projects/NewTopologyModal.js b/opendc-web/opendc-web-server/src/main/webui/components/projects/NewTopologyModal.js index 780ec034..68b7f150 100644 --- a/opendc-web/opendc-web-server/src/main/webui/components/projects/NewTopologyModal.js +++ b/opendc-web/opendc-web-server/src/main/webui/components/projects/NewTopologyModal.js @@ -54,7 +54,10 @@ const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCan const name = nameInput.current.value if (!name) { - setErrors({ name: true }) + setErrors({ name: 'empty' }) + return false + } else if (topologies.some((topology) => topology.name === name)) { + setErrors({ name: 'duplicate' }) return false } else { const candidate = topologies.find((topology) => topology.id === originTopology) || { rooms: [] } @@ -83,7 +86,11 @@ const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCan fieldId="name" isRequired validated={isSubmitted && errors.name ? 'error' : 'default'} - helperTextInvalid="This field cannot be empty" + helperTextInvalid={ + errors.name === 'empty' + ? 'This field cannot be empty' + : 'This topology name already exists' + } > <TextInput id="name" name="name" type="text" isRequired ref={nameInput} /> </FormGroup> diff --git a/opendc-web/opendc-web-server/src/main/webui/components/projects/ProjectOverview.js b/opendc-web/opendc-web-server/src/main/webui/components/projects/ProjectOverview.js index 3e1656f6..6800f401 100644 --- a/opendc-web/opendc-web-server/src/main/webui/components/projects/ProjectOverview.js +++ b/opendc-web/opendc-web-server/src/main/webui/components/projects/ProjectOverview.js @@ -36,6 +36,7 @@ import { Skeleton, } from '@patternfly/react-core' import NewTopology from './NewTopology' +import ImportTopology from './ImportTopology' import TopologyTable from './TopologyTable' import NewPortfolio from './NewPortfolio' import PortfolioTable from './PortfolioTable' @@ -65,6 +66,7 @@ function ProjectOverview({ projectId }) { <Card> <CardHeader> <CardActions> + <ImportTopology projectId={projectId} /> <NewTopology projectId={projectId} /> </CardActions> <CardTitle>Topologies</CardTitle> diff --git a/opendc-web/opendc-web-server/src/main/webui/components/projects/TopologyTable.js b/opendc-web/opendc-web-server/src/main/webui/components/projects/TopologyTable.js index 1c2c4f04..89a28889 100644 --- a/opendc-web/opendc-web-server/src/main/webui/components/projects/TopologyTable.js +++ b/opendc-web/opendc-web-server/src/main/webui/components/projects/TopologyTable.js @@ -37,11 +37,37 @@ function TopologyTable({ projectId }) { onError: (error) => setError(error), }) - const actions = ({ number }) => [ + const downloadTopology = (topology) => { + const data = JSON.stringify( + { name: topology.name, rooms: topology.rooms }, + (key, value) => { + if (key === 'id' || key === 'topologyId' || key === 'roomId' || key === 'rackId' || key === 'roomid') { + return undefined + } + return value + }, + 4 + ) + const blob = new Blob([data], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `${topology.name}.json` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + + const actions = (topology) => [ + { + title: 'Download as JSON', + onClick: () => downloadTopology(topology), + }, { title: 'Delete Topology', - onClick: () => deleteTopology({ projectId, number }), - isDisabled: number === 0, + onClick: () => deleteTopology({ projectId, number: topology.number }), + isDisabled: topology.number === 0, }, ] 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 8520e794..61265b1e 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 @@ -1,5 +1,6 @@ import { combineReducers } from 'redux' import { GO_DOWN_ONE_INTERACTION_LEVEL } from '../actions/interaction-level' +import { OPEN_TOPOLOGY } from '../actions/topology' import { CANCEL_NEW_ROOM_CONSTRUCTION_SUCCEEDED, FINISH_NEW_ROOM_CONSTRUCTION, @@ -11,6 +12,8 @@ import { DELETE_ROOM, START_RACK_CONSTRUCTION, STOP_RACK_CONSTRUCTION } from '.. export function currentRoomInConstruction(state = '-1', action) { switch (action.type) { + case OPEN_TOPOLOGY: + return '-1' case START_NEW_ROOM_CONSTRUCTION_SUCCEEDED: return action.roomId case START_ROOM_EDIT: @@ -27,6 +30,8 @@ export function currentRoomInConstruction(state = '-1', action) { export function inRackConstructionMode(state = false, action) { switch (action.type) { + case OPEN_TOPOLOGY: + return false case START_RACK_CONSTRUCTION: return true case STOP_RACK_CONSTRUCTION: @@ -39,6 +44,8 @@ export function inRackConstructionMode(state = false, action) { export function currentRackPrefab(state = null, action) { switch (action.type) { + case OPEN_TOPOLOGY: + return null case START_RACK_CONSTRUCTION: return action.rackPrefab || null case STOP_RACK_CONSTRUCTION: diff --git a/opendc-web/opendc-web-server/src/main/webui/redux/reducers/interaction-level.js b/opendc-web/opendc-web-server/src/main/webui/redux/reducers/interaction-level.js index b30c68b9..d1342d3d 100644 --- a/opendc-web/opendc-web-server/src/main/webui/redux/reducers/interaction-level.js +++ b/opendc-web/opendc-web-server/src/main/webui/redux/reducers/interaction-level.js @@ -4,12 +4,15 @@ import { GO_FROM_RACK_TO_MACHINE, GO_FROM_ROOM_TO_RACK, } from '../actions/interaction-level' +import { OPEN_TOPOLOGY } from '../actions/topology' import { DELETE_MACHINE } from '../actions/topology/machine' import { DELETE_RACK } from '../actions/topology/rack' import { DELETE_ROOM } from '../actions/topology/room' export function interactionLevel(state = { mode: 'BUILDING' }, action) { switch (action.type) { + case OPEN_TOPOLOGY: + return { mode: 'BUILDING' } case GO_FROM_BUILDING_TO_ROOM: return { mode: 'ROOM', diff --git a/opendc-web/opendc-web-server/src/main/webui/redux/reducers/topology/index.js b/opendc-web/opendc-web-server/src/main/webui/redux/reducers/topology/index.js index 2c849387..18d6cde4 100644 --- a/opendc-web/opendc-web-server/src/main/webui/redux/reducers/topology/index.js +++ b/opendc-web/opendc-web-server/src/main/webui/redux/reducers/topology/index.js @@ -21,18 +21,36 @@ */ import { CPU_UNITS, GPU_UNITS, MEMORY_UNITS, STORAGE_UNITS } from '../../../util/unit-specifications' +import { STORE_TOPOLOGY } from '../../actions/topology' +import { ADD_RACK_TO_TILE } from '../../actions/topology/room' import machine from './machine' import rack from './rack' import room from './room' import tile from './tile' import topology from './topology' +function unitReducer(defaultUnits, entityType) { + return (state = defaultUnits, action) => { + if (action.type === STORE_TOPOLOGY) { + return { ...defaultUnits, ...((action.entities && action.entities[entityType]) || {}) } + } else if (action.type === ADD_RACK_TO_TILE) { + return { ...state, ...((action.entities && action.entities[entityType]) || {}) } + } + return state + } +} + +const cpus = unitReducer(CPU_UNITS, 'cpus') +const gpus = unitReducer(GPU_UNITS, 'gpus') +const memories = unitReducer(MEMORY_UNITS, 'memories') +const storages = unitReducer(STORAGE_UNITS, 'storages') + function objects(state = {}, action) { return { - cpus: CPU_UNITS, - gpus: GPU_UNITS, - memories: MEMORY_UNITS, - storages: STORAGE_UNITS, + cpus: cpus(state.cpus, action), + gpus: gpus(state.gpus, action), + memories: memories(state.memories, action), + storages: storages(state.storages, action), machines: machine(state.machines, action, state), racks: rack(state.racks, action, state), tiles: tile(state.tiles, action), diff --git a/opendc-web/opendc-web-server/src/test/java/org/opendc/web/server/rest/user/TopologyResourceTest.java b/opendc-web/opendc-web-server/src/test/java/org/opendc/web/server/rest/user/TopologyResourceTest.java index 277376e5..b3b668dd 100644 --- a/opendc-web/opendc-web-server/src/test/java/org/opendc/web/server/rest/user/TopologyResourceTest.java +++ b/opendc-web/opendc-web-server/src/test/java/org/opendc/web/server/rest/user/TopologyResourceTest.java @@ -157,6 +157,23 @@ public final class TopologyResourceTest { } /** + * Test to create a topology with a duplicate name. + */ + @Test + @TestSecurity( + user = "test_user_1", + roles = {"openid"}) + public void testCreateDuplicateName() { + given().pathParam("project", "1") + .body(new Topology.Create("Test Topology testUpdate", List.of())) + .contentType(ContentType.JSON) + .when() + .post() + .then() + .statusCode(409); + } + + /** * Test that tries to obtain a topology without token. */ @Test |
