Compare commits

...

10 commits

Author SHA1 Message Date
b2e218c616 Add busy spinner.
Some checks failed
Java CI with Gradle / build (push) Has been cancelled
2024-06-09 22:40:03 +02:00
4d5b6a58b7 Fix VM selection. 2024-06-09 22:32:33 +02:00
1dc3c1cc89 Add new permission. 2024-06-09 15:22:46 +02:00
074f0cf7da Fix some minor codacy complaints. 2024-06-09 13:25:10 +02:00
6a3f6c5e3e Add reset support.
Some checks failed
Java CI with Gradle / build (push) Has been cancelled
2024-06-09 12:48:20 +02:00
0a1f89a270 Fix avoiding unnecessary password changes. 2024-06-06 15:58:22 +02:00
92c9c6df9c Implement reset. 2024-06-06 15:45:02 +02:00
f20e61d57c Default to generating secrets.
Some checks failed
Java CI with Gradle / build (push) Has been cancelled
2024-06-06 12:36:29 +02:00
b0b6d6723e Fix typo. 2024-06-06 12:03:00 +02:00
d6a0cc6220 Improve state feedback.
Some checks failed
Java CI with Gradle / build (push) Has been cancelled
2024-06-05 21:10:05 +02:00
33 changed files with 438 additions and 89 deletions

View file

@ -10,4 +10,3 @@ based VMs in Kubernetes pods.
See the [project's home page](https://mnlipp.github.io/VM-Operator/) See the [project's home page](https://mnlipp.github.io/VM-Operator/)
for details. for details.

View file

@ -1012,7 +1012,12 @@ spec:
type: array type: array
items: items:
type: string type: string
enum: ["start", "stop", "accessConsole", "*"] enum:
- start
- stop
- reset
- accessConsole
- "*"
default: [] default: []
vm: vm:
type: object type: object

View file

@ -41,7 +41,8 @@ public class VmDefinitionModel extends K8sDynamicModel {
* Permissions for accessing and manipulating the VM. * Permissions for accessing and manipulating the VM.
*/ */
public enum Permission { public enum Permission {
START("start"), STOP("stop"), ACCESS_CONSOLE("accessConsole"); START("start"), STOP("stop"), RESET("reset"),
ACCESS_CONSOLE("accessConsole");
@SuppressWarnings("PMD.UseConcurrentHashMap") @SuppressWarnings("PMD.UseConcurrentHashMap")
private static Map<String, Permission> reprs = new HashMap<>(); private static Map<String, Permission> reprs = new HashMap<>();

View file

@ -0,0 +1,48 @@
/*
* VM-Operator
* Copyright (C) 2024 Michael N. Lipp
*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.jdrupes.vmoperator.manager.events;
import org.jgrapes.core.Event;
/**
* Triggers a reset of the VM.
*/
@SuppressWarnings("PMD.DataClass")
public class ResetVm extends Event<String> {
private final String vmName;
/**
* Instantiates a new event.
*
* @param vmName the vm name
*/
public ResetVm(String vmName) {
this.vmName = vmName;
}
/**
* Gets the vm name.
*
* @return the vm name
*/
public String vmName() {
return vmName;
}
}

View file

@ -18,10 +18,10 @@ dependencies {
implementation 'org.jgrapes:org.jgrapes.http:[3.1.0,4)' implementation 'org.jgrapes:org.jgrapes.http:[3.1.0,4)'
implementation 'org.jgrapes:org.jgrapes.util:[1.34.0,2)' implementation 'org.jgrapes:org.jgrapes.util:[1.34.0,2)'
implementation 'org.jgrapes:org.jgrapes.webconsole.base:[1.5.0,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.base:[1.7.0,2)'
implementation 'org.jgrapes:org.jgrapes.webconsole.vuejs:[1.5.0,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.vuejs:[1.5.0,2)'
implementation 'org.jgrapes:org.jgrapes.webconsole.rbac:[1.3.0,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.rbac:[1.3.0,2)'
implementation 'org.jgrapes:org.jgrapes.webconlet.oidclogin:[1.3.0,2)' implementation 'org.jgrapes:org.jgrapes.webconlet.oidclogin:[1.4.0,2)'
implementation 'org.jgrapes:org.jgrapes.webconlet.markdowndisplay:[1.2.0,2)' implementation 'org.jgrapes:org.jgrapes.webconlet.markdowndisplay:[1.2.0,2)'
runtimeOnly 'org.jgrapes:org.jgrapes.webconlet.sysinfo:[1.4.0,2)' runtimeOnly 'org.jgrapes:org.jgrapes.webconlet.sysinfo:[1.4.0,2)'

View file

@ -48,6 +48,12 @@ data:
# Whether a shutdown initiated by the guest stops the pod deployment # Whether a shutdown initiated by the guest stops the pod deployment
guestShutdownStops: ${ cr.spec.guestShutdownStops!false?c } guestShutdownStops: ${ cr.spec.guestShutdownStops!false?c }
# When incremented, the VM is reset. The value has no default value,
# i.e. if you start the VM without a value for this property, and
# decide to trigger a reset later, you have to first set the value
# and then inrement it.
resetCounter: ${ cr.resetCount }
# Forward the cloud-init data if provided # Forward the cloud-init data if provided
<#if cr.spec.cloudInit??> <#if cr.spec.cloudInit??>
cloudInit: cloudInit:

View file

@ -36,7 +36,6 @@ import org.jdrupes.vmoperator.common.K8s;
import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME;
import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME;
import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor; import org.yaml.snakeyaml.constructor.SafeConstructor;
@ -62,7 +61,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
/** /**
* Reconcile. * Reconcile.
* *
* @param event the event
* @param model the model * @param model the model
* @param channel the channel * @param channel the channel
* @return the dynamic kubernetes object * @return the dynamic kubernetes object
@ -70,8 +68,8 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
* @throws TemplateException the template exception * @throws TemplateException the template exception
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
public DynamicKubernetesObject reconcile(VmDefChanged event, public DynamicKubernetesObject reconcile(Map<String, Object> model,
Map<String, Object> model, VmChannel channel) VmChannel channel)
throws IOException, TemplateException, ApiException { throws IOException, TemplateException, ApiException {
// Get API // Get API
DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1", DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1",

View file

@ -181,13 +181,12 @@ public class Controller extends Component {
@Handler @Handler
public void onModifyVm(ModifyVm event, VmChannel channel) public void onModifyVm(ModifyVm event, VmChannel channel)
throws ApiException, IOException { throws ApiException, IOException {
patchVmSpec(channel.client(), event.name(), event.path(), patchVmDef(channel.client(), event.name(), "spec/vm/" + event.path(),
event.value()); event.value());
} }
private void patchVmSpec(K8sClient client, String name, String path, private void patchVmDef(K8sClient client, String name, String path,
Object value) Object value) throws ApiException, IOException {
throws ApiException, IOException {
var vmStub = K8sDynamicStub.get(client, var vmStub = K8sDynamicStub.get(client,
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace, new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace,
name); name);
@ -197,7 +196,7 @@ public class Controller extends Component {
? "\"" + value + "\"" ? "\"" + value + "\""
: value.toString(); : value.toString();
var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH,
new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/" new V1Patch("[{\"op\": \"replace\", \"path\": \"/"
+ path + "\", \"value\": " + valueAsText + "}]"), + path + "\", \"value\": " + valueAsText + "}]"),
client.defaultPatchOptions()); client.defaultPatchOptions());
if (!res.isPresent()) { if (!res.isPresent()) {

View file

@ -64,7 +64,7 @@ import org.jose4j.base64url.Base64;
var display = GsonPtr.to(event.vmDefinition().data()).to("spec", "vm", var display = GsonPtr.to(event.vmDefinition().data()).to("spec", "vm",
"display"); "display");
if (!display.get(JsonPrimitive.class, "spice", "generateSecret") if (!display.get(JsonPrimitive.class, "spice", "generateSecret")
.map(JsonPrimitive::getAsBoolean).orElse(false)) { .map(JsonPrimitive::getAsBoolean).orElse(true)) {
return; return;
} }

View file

@ -51,6 +51,7 @@ import org.jdrupes.vmoperator.common.K8sDynamicModel;
import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.K8sObserver;
import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.K8sV1SecretStub;
import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
import org.jdrupes.vmoperator.manager.events.ResetVm;
import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; import org.jdrupes.vmoperator.util.ExtendedObjectWrapper;
@ -209,13 +210,35 @@ public class Reconciler extends Component {
// Reconcile, use "augmented" vm definition for model // Reconcile, use "augmented" vm definition for model
Map<String, Object> model Map<String, Object> model
= prepareModel(channel.client(), patchCr(event.vmDefinition())); = prepareModel(channel.client(), patchCr(event.vmDefinition()));
var configMap = cmReconciler.reconcile(event, model, channel); var configMap = cmReconciler.reconcile(model, channel);
model.put("cm", configMap.getRaw()); model.put("cm", configMap.getRaw());
dsReconciler.reconcile(event, model, channel); dsReconciler.reconcile(event, model, channel);
stsReconciler.reconcile(event, model, channel); stsReconciler.reconcile(event, model, channel);
lbReconciler.reconcile(event, model, channel); lbReconciler.reconcile(event, model, channel);
} }
/**
* Reset the VM by incrementing the reset count and doing a
* partial reconcile (configmap only).
*
* @param event the event
* @param channel the channel
* @throws IOException
* @throws ApiException
* @throws TemplateException
*/
@Handler
public void onResetVm(ResetVm event, VmChannel channel)
throws ApiException, IOException, TemplateException {
var defRoot
= GsonPtr.to(channel.vmDefinition().data()).get(JsonObject.class);
defRoot.addProperty("resetCount",
defRoot.get("resetCount").getAsLong() + 1);
Map<String, Object> model
= prepareModel(channel.client(), patchCr(channel.vmDefinition()));
cmReconciler.reconcile(model, channel);
}
private DynamicKubernetesObject patchCr(K8sDynamicModel vmDef) { private DynamicKubernetesObject patchCr(K8sDynamicModel vmDef) {
var json = vmDef.data().deepCopy(); var json = vmDef.data().deepCopy();
// Adjust cdromImage path // Adjust cdromImage path

View file

@ -25,13 +25,13 @@ import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.util.Watch; import io.kubernetes.client.util.Watch;
import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.ListOptions;
import java.io.IOException; import java.io.IOException;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8s;
import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.K8sDynamicModel;
import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.K8sDynamicStub;
import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; import org.jdrupes.vmoperator.common.K8sObserver.ResponseType;
import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub; import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub;
@ -121,7 +121,7 @@ public class VmMonitor extends
} }
if (vmDef.data() != null) { if (vmDef.data() != null) {
// New data, augment and save // New data, augment and save
addDynamicData(channel.client(), vmDef); addDynamicData(channel.client(), vmDef, channel.vmDefinition());
channel.setVmDefinition(vmDef); channel.setVmDefinition(vmDef);
} else { } else {
// Reuse cached // Reuse cached
@ -151,8 +151,16 @@ public class VmMonitor extends
} }
} }
private void addDynamicData(K8sClient client, K8sDynamicModel vmState) { private void addDynamicData(K8sClient client, VmDefinitionModel vmState,
VmDefinitionModel prevState) {
var rootNode = GsonPtr.to(vmState.data()).get(JsonObject.class); var rootNode = GsonPtr.to(vmState.data()).get(JsonObject.class);
// Maintain (or initialize) the resetCount
rootNode.addProperty("resetCount", Optional.ofNullable(prevState)
.map(ps -> GsonPtr.to(ps.data()))
.flatMap(d -> d.getAsLong("resetCount")).orElse(0L));
// Add defaults in case the VM is not running
rootNode.addProperty("nodeName", ""); rootNode.addProperty("nodeName", "");
rootNode.addProperty("nodeAddress", ""); rootNode.addProperty("nodeAddress", "");

View file

@ -46,6 +46,12 @@
# "guestShutdownStops": # "guestShutdownStops":
# false # false
# When incremented, the VM is reset. The value has no default value,
# i.e. if you start the VM without a value for this property, and
# decide to trigger a reset later, you have to first set the value
# and then inrement it.
# "resetCounter": 1
# Define the VM (required) # Define the VM (required)
"vm": "vm":
# The VM's name (required) # The VM's name (required)

View file

@ -82,6 +82,9 @@ public class Configuration implements Dto {
/** If guest shutdown changes CRD .vm.state to "Stopped". */ /** If guest shutdown changes CRD .vm.state to "Stopped". */
public boolean guestShutdownStops; public boolean guestShutdownStops;
/** Increments of the reset counter trigger a reset of the VM. */
public Integer resetCounter;
/** The vm. */ /** The vm. */
@SuppressWarnings("PMD.ShortVariable") @SuppressWarnings("PMD.ShortVariable")
public Vm vm; public Vm vm;

View file

@ -116,8 +116,9 @@ public class DisplayController extends Component {
} }
if (Objects.equals(this.currentPassword, password)) { if (Objects.equals(this.currentPassword, password)) {
return false; return true;
} }
this.currentPassword = password;
logger.fine(() -> "Updating display password"); logger.fine(() -> "Updating display password");
fire(new MonitorCommand(new QmpSetDisplayPassword(protocol, password))); fire(new MonitorCommand(new QmpSetDisplayPassword(protocol, password)));
return true; return true;

View file

@ -55,6 +55,7 @@ import org.apache.commons.cli.Option;
import org.apache.commons.cli.Options; import org.apache.commons.cli.Options;
import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset;
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
import org.jdrupes.vmoperator.runner.qemu.events.Exit; import org.jdrupes.vmoperator.runner.qemu.events.Exit;
import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand;
@ -215,6 +216,7 @@ public class Runner extends Component {
private CommandDefinition cloudInitImgDefinition; private CommandDefinition cloudInitImgDefinition;
private CommandDefinition qemuDefinition; private CommandDefinition qemuDefinition;
private final QemuMonitor qemuMonitor; private final QemuMonitor qemuMonitor;
private Integer resetCounter;
private State state = State.INITIALIZING; private State state = State.INITIALIZING;
/** Preparatory actions for QEMU start */ /** Preparatory actions for QEMU start */
@ -615,7 +617,7 @@ public class Runner extends Component {
* @param event the event * @param event the event
*/ */
@Handler(priority = -1000) @Handler(priority = -1000)
public void onConfigureQemu(ConfigureQemu event) { public void onConfigureQemuFinal(ConfigureQemu event) {
if (state == State.STARTING) { if (state == State.STARTING) {
fire(new MonitorCommand(new QmpCont())); fire(new MonitorCommand(new QmpCont()));
state = State.RUNNING; state = State.RUNNING;
@ -624,6 +626,23 @@ public class Runner extends Component {
} }
} }
/**
* On configure qemu.
*
* @param event the event
*/
@Handler
public void onConfigureQemu(ConfigureQemu event) {
if (state == State.RUNNING) {
if (resetCounter != null
&& event.configuration().resetCounter != null
&& event.configuration().resetCounter > resetCounter) {
fire(new MonitorCommand(new QmpReset()));
}
resetCounter = event.configuration().resetCounter;
}
}
/** /**
* On process exited. * On process exited.
* *

View file

@ -0,0 +1,43 @@
/*
* VM-Operator
* Copyright (C) 2023 Michael N. Lipp
*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.jdrupes.vmoperator.runner.qemu.commands;
import com.fasterxml.jackson.databind.JsonNode;
/**
* A {@link QmpCommand} that send a system_reset to the VM.
*/
public class QmpReset extends QmpCommand {
@SuppressWarnings({ "PMD.FieldNamingConventions",
"PMD.VariableNamingConventions" })
private static final JsonNode jsonTemplate
= parseJson("{ \"execute\": \"system_reset\" }");
@Override
public JsonNode toJson() {
return jsonTemplate.deepCopy();
}
@Override
public String toString() {
return "QmpReset()";
}
}

View file

@ -265,6 +265,18 @@ public class GsonPtr {
return set(selector, new JsonPrimitive(value)); return set(selector, new JsonPrimitive(value));
} }
/**
* Short for `set(selector, new JsonPrimitive(value))`.
*
* @param selector the selector
* @param value the value
* @return the gson ptr
* @see #set(Object, JsonElement)
*/
public GsonPtr set(Object selector, Long value) {
return set(selector, new JsonPrimitive(value));
}
/** /**
* Short for `set(selector, new JsonPrimitive(value))`. * Short for `set(selector, new JsonPrimitive(value))`.
* *

View file

@ -52,12 +52,14 @@
v-html="controller.breakBeforeDots(entry[key])"></span> v-html="controller.breakBeforeDots(entry[key])"></span>
</td> </td>
<td class="jdrupes-vmoperator-vmconlet-view-action-list"> <td class="jdrupes-vmoperator-vmconlet-view-action-list">
<span role="button" v-if="entry.spec.vm.state != 'Running'" <span role="button"
v-if="entry.spec.vm.state != 'Running' && !entry['running']"
tabindex="0" class="fa fa-play" :title="localize('Start VM')" tabindex="0" class="fa fa-play" :title="localize('Start VM')"
v-on:click="vmAction(entry.name, 'start')"></span> v-on:click="vmAction(entry.name, 'start')"></span>
<span role="button" v-else class="fa fa-play" <span role="button" v-else class="fa fa-play"
aria-disabled="true" :title="localize('Start VM')"></span> aria-disabled="true" :title="localize('Start VM')"></span>
<span role="button" v-if="entry.spec.vm.state != 'Stopped'" <span role="button"
v-if="entry.spec.vm.state != 'Stopped' && entry['running']"
tabindex="0" class="fa fa-stop" :title="localize('Stop VM')" tabindex="0" class="fa fa-stop" :title="localize('Stop VM')"
v-on:click="vmAction(entry.name, 'stop')"></span> v-on:click="vmAction(entry.name, 'stop')"></span>
<span role="button" v-else class="fa fa-stop" <span role="button" v-else class="fa fa-stop"

View file

@ -5,7 +5,7 @@ plugins {
dependencies { dependencies {
implementation project(':org.jdrupes.vmoperator.manager.events') implementation project(':org.jdrupes.vmoperator.manager.events')
implementation 'org.jgrapes:org.jgrapes.webconsole.base:[1.3.0,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.base:[1.7.0,2)'
implementation 'org.jgrapes:org.jgrapes.webconsole.provider.vue:[1,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.vue:[1,2)'
implementation 'org.jgrapes:org.jgrapes.webconsole.provider.jgwcvuecomponents:[1.2,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.jgwcvuecomponents:[1.2,2)'
implementation 'org.jgrapes:org.jgrapes.webconsole.provider.chartjs:[1.2,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.chartjs:[1.2,2)'

View file

@ -0,0 +1,13 @@
<div
class="jdrupes-vmoperator-vmviewer jdrupes-vmoperator-vmviewer-confirm-reset">
<p>${_("confirmResetMsg")}</p>
<p>
<span role="button" tabindex="0" class="svg-icon"
onclick="orgJDrupesVmOperatorVmViewer.confirmReset('${conletType}', '${conletId}')">
<svg viewBox="0 0 1541.33 1535.5083">
<path d="m 0,127.9968 v 448 c 0,35 29,64 64,64 h 448 c 35,0 64,-29 64,-64 0,-17 -6.92831,-33.07213 -19,-45 C 264.23058,241.7154 337.19508,314.89599 109,82.996795 c -11.999999,-12 -28,-19 -45,-19 -35,0 -64,29 -64,64.000005 z" />
<path d="m 772.97656,1535.5046 c 117.57061,0.3623 236.06134,-26.2848 345.77544,-81.4687 292.5708,-147.1572 459.8088,-465.37411 415.5214,-790.12504 C 1489.9861,339.15993 1243.597,77.463924 922.29883,14.342498 601.00067,-48.778928 274.05699,100.37563 110.62891,384.39133 c -34.855139,60.57216 -14.006492,137.9313 46.5664,172.78516 60.57172,34.85381 137.92941,14.00532 172.78321,-46.56641 109.97944,-191.12927 327.69604,-290.34657 543.53515,-247.94336 215.83913,42.40321 380.18953,216.77543 410.00973,435.44141 29.8203,218.66598 -81.8657,430.94957 -278.4863,529.84567 -196.6206,98.8962 -432.84043,61.8202 -589.90233,-92.6777 -24.91016,-24.5038 -85.48587,-83.3326 -119.02246,-52.9832 -24.01114,21.7292 -35.41741,29.5454 -59.9209,54.4559 -24.50381,24.9102 -35.33636,36.9034 -57.54543,60.4713 -38.1335,40.4667 34.10761,93.9685 59.01808,118.472 145.96311,143.5803 339.36149,219.2087 535.3125,219.8125 z"/>
</svg>
</span>
</p>
</div>

View file

@ -1,4 +1,5 @@
<div title="${_("conletName")}" class="jdrupes-vmoperator-vmviewer-edit" <div title="${_("conletName")}"
class="jdrupes-vmoperator-vmviewer jdrupes-vmoperator-vmviewer-edit"
data-jgwc-on-load="orgJDrupesVmOperatorVmViewer.initEdit" data-jgwc-on-load="orgJDrupesVmOperatorVmViewer.initEdit"
data-jgwc-on-action="orgJDrupesVmOperatorVmViewer.applyEdit" data-jgwc-on-action="orgJDrupesVmOperatorVmViewer.applyEdit"
data-jgwc-on-unload="JGConsole.jgwc.unmountVueApps"> data-jgwc-on-unload="JGConsole.jgwc.unmountVueApps">

View file

@ -1,4 +1,5 @@
<div class="jdrupes-vmoperator-vmviewer jdrupes-vmoperator-vmviewer-preview" <div
class="jdrupes-vmoperator-vmviewer jdrupes-vmoperator-vmviewer-preview"
data-conlet-grid-rows="2" data-conlet-grid-columns="2" data-conlet-grid-rows="2" data-conlet-grid-columns="2"
data-jgwc-on-load="orgJDrupesVmOperatorVmViewer.initPreview" data-jgwc-on-load="orgJDrupesVmOperatorVmViewer.initPreview"
data-jgwc-on-unload="JGConsole.jgwc.unmountVueApps" data-jgwc-on-unload="JGConsole.jgwc.unmountVueApps"

View file

@ -1,3 +1,7 @@
conletName = VM Console conletName = VM Console
okayLabel = Apply and Close okayLabel = Apply and Close
confirmResetTitle = Confirm reset
confirmResetMsg = Resetting the VM may cause loss of data. \
Please confirm to continue.

View file

@ -5,4 +5,9 @@ Select\ VM = VM ausw
Start\ VM = VM starten Start\ VM = VM starten
Stop\ VM = VM anhalten Stop\ VM = VM anhalten
Reset\ VM = VM zurücksetzen
Open\ console = Konsole anzeigen Open\ console = Konsole anzeigen
confirmResetTitle = Zurücksetzen bestätigen
confirmResetMsg = Zurücksetzen der VM kann zu Datenverlust führen. \
Bitte bestätigen um fortzufahren.

View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="1541.33"
height="1535.5083"
version="1.1"
id="svg1"
sodipodi:docname="reset-icon2.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.34987054"
inkscape:cx="704.54631"
inkscape:cy="711.69181"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
d="m 0,127.9968 v 448 c 0,35 29,64 64,64 h 448 c 35,0 64,-29 64,-64 0,-17 -6.92831,-33.07213 -19,-45 C 264.23058,241.7154 337.19508,314.89599 109,82.996795 c -11.999999,-12 -28,-19 -45,-19 -35,0 -64,29 -64,64.000005 z"
id="path1"
sodipodi:nodetypes="sssssscss" />
<path
style="color:#000000;fill:#000000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:0;-inkscape-stroke:none;paint-order:fill markers stroke"
d="m 772.97656,1535.5046 c 117.57061,0.3623 236.06134,-26.2848 345.77544,-81.4687 292.5708,-147.1572 459.8088,-465.37411 415.5214,-790.12504 C 1489.9861,339.15993 1243.597,77.463924 922.29883,14.342498 601.00067,-48.778928 274.05699,100.37563 110.62891,384.39133 c -34.855139,60.57216 -14.006492,137.9313 46.5664,172.78516 60.57172,34.85381 137.92941,14.00532 172.78321,-46.56641 109.97944,-191.12927 327.69604,-290.34657 543.53515,-247.94336 215.83913,42.40321 380.18953,216.77543 410.00973,435.44141 29.8203,218.66598 -81.8657,430.94957 -278.4863,529.84567 -196.6206,98.8962 -432.84043,61.8202 -589.90233,-92.6777 -24.91016,-24.5038 -85.48587,-83.3326 -119.02246,-52.9832 -24.01114,21.7292 -35.41741,29.5454 -59.9209,54.4559 -24.50381,24.9102 -35.33636,36.9034 -57.54543,60.4713 -38.1335,40.4667 34.10761,93.9685 59.01808,118.472 145.96311,143.5803 339.36149,219.2087 535.3125,219.8125 z"
id="path2"
sodipodi:nodetypes="sssscccssscscscs" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -20,6 +20,7 @@ package org.jdrupes.vmoperator.vmviewer;
import com.fasterxml.jackson.annotation.JsonGetter; import com.fasterxml.jackson.annotation.JsonGetter;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
@ -54,6 +55,7 @@ import org.jdrupes.vmoperator.common.VmDefinitionModel.Permission;
import org.jdrupes.vmoperator.manager.events.ChannelCache; import org.jdrupes.vmoperator.manager.events.ChannelCache;
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.ModifyVm;
import org.jdrupes.vmoperator.manager.events.ResetVm;
import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jdrupes.vmoperator.util.GsonPtr; import org.jdrupes.vmoperator.util.GsonPtr;
@ -90,10 +92,10 @@ import org.jgrapes.webconsole.base.events.UpdateConletType;
import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
/** /**
* The Class VmConlet. * The Class VmViewer.
*/ */
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports", @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports",
"PMD.CouplingBetweenObjects", "PMD.GodClass" }) "PMD.CouplingBetweenObjects", "PMD.GodClass", "PMD.TooManyMethods" })
public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> { public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
private static final String VM_NAME_PROPERTY = "vmName"; private static final String VM_NAME_PROPERTY = "vmName";
@ -465,12 +467,19 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
@Override @Override
@SuppressWarnings({ "PMD.AvoidDecimalLiteralsInBigDecimalConstructor", @SuppressWarnings({ "PMD.AvoidDecimalLiteralsInBigDecimalConstructor",
"PMD.ConfusingArgumentToVarargsMethod" }) "PMD.ConfusingArgumentToVarargsMethod", "PMD.NcssCount",
"PMD.AvoidLiteralsInIfCondition" })
protected void doUpdateConletState(NotifyConletModel event, protected void doUpdateConletState(NotifyConletModel event,
ConsoleConnection channel, ViewerModel model) ConsoleConnection channel, ViewerModel model)
throws Exception { throws Exception {
event.stop(); event.stop();
var both = Optional.ofNullable(event.params().asString(0)) if ("selectedVm".equals(event.method())) {
selectVm(event, channel, model);
return;
}
// Handle command for selected VM
var both = Optional.ofNullable(model.vmName())
.flatMap(vm -> channelManager.both(vm)); .flatMap(vm -> channelManager.both(vm));
if (both.isEmpty()) { if (both.isEmpty()) {
return; return;
@ -479,14 +488,8 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
var vmDef = both.get().associated; var vmDef = both.get().associated;
var vmName = vmDef.metadata().getName(); var vmName = vmDef.metadata().getName();
var perms = permissions(vmDef, channel.session()); var perms = permissions(vmDef, channel.session());
var resourceBundle = resourceBundle(channel.locale());
switch (event.method()) { switch (event.method()) {
case "selectedVm":
model.setVmName(event.params().asString(0));
String jsonState = objectMapper.writeValueAsString(model);
channel.respond(new KeyValueStoreUpdate().update(storagePath(
channel.session(), model.getConletId()), jsonState));
updateConfig(channel, model);
break;
case "start": case "start":
if (perms.contains(Permission.START)) { if (perms.contains(Permission.START)) {
fire(new ModifyVm(vmName, "state", "Running", vmChannel)); fire(new ModifyVm(vmName, "state", "Running", vmChannel));
@ -497,6 +500,16 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); fire(new ModifyVm(vmName, "state", "Stopped", vmChannel));
} }
break; break;
case "reset":
if (perms.contains(Permission.RESET)) {
confirmReset(event, channel, model, resourceBundle);
}
break;
case "resetConfirmed":
if (perms.contains(Permission.RESET)) {
fire(new ResetVm(vmName), vmChannel);
}
break;
case "openConsole": case "openConsole":
if (perms.contains(Permission.ACCESS_CONSOLE)) { if (perms.contains(Permission.ACCESS_CONSOLE)) {
var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef), var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef),
@ -510,6 +523,15 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
} }
} }
private void selectVm(NotifyConletModel event, ConsoleConnection channel,
ViewerModel model) throws JsonProcessingException {
model.setVmName(event.params().asString(0));
String jsonState = objectMapper.writeValueAsString(model);
channel.respond(new KeyValueStoreUpdate().update(storagePath(
channel.session(), model.getConletId()), jsonState));
updateConfig(channel, model);
}
private void openConsole(String vmName, ConsoleConnection connection, private void openConsole(String vmName, ConsoleConnection connection,
ViewerModel model, String password) { ViewerModel model, String password) {
var vmDef = channelManager.associated(vmName).orElse(null); var vmDef = channelManager.associated(vmName).orElse(null);
@ -577,6 +599,20 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
.findFirst().or(() -> addrs.stream().findFirst()); .findFirst().or(() -> addrs.stream().findFirst());
} }
private void confirmReset(NotifyConletModel event,
ConsoleConnection channel, ViewerModel model,
ResourceBundle resourceBundle) throws TemplateNotFoundException,
MalformedTemplateNameException, ParseException, IOException {
Template tpl = freemarkerConfig()
.getTemplate("VmViewer-confirmReset.ftl.html");
channel.respond(new OpenModalDialog(type(), model.getConletId(),
processTemplate(event, tpl,
fmModel(event, channel, model.getConletId(), model)))
.addOption("cancelable", true).addOption("closeLabel", "")
.addOption("title",
resourceBundle.getString("confirmResetTitle")));
}
@Override @Override
protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
String conletId) throws Exception { String conletId) throws Exception {

View file

@ -31,8 +31,9 @@ declare global {
interface Window { interface Window {
orgJDrupesVmOperatorVmViewer: { orgJDrupesVmOperatorVmViewer: {
initPreview?: (previewDom: HTMLElement, isUpdate: boolean) => void, initPreview?: (previewDom: HTMLElement, isUpdate: boolean) => void,
initEdit?: (viewDom: HTMLElement, isUpdate: boolean) => void initEdit?: (viewDom: HTMLElement, isUpdate: boolean) => void,
applyEdit?: (viewDom: HTMLElement, apply: boolean) => void applyEdit?: (viewDom: HTMLElement, apply: boolean) => void,
confirmReset?: (conletType: string, conletId: string) => void
} }
} }
} }
@ -63,7 +64,16 @@ window.orgJDrupesVmOperatorVmViewer.initPreview = (previewDom: HTMLElement,
vmName: "", vmName: "",
vmDefinition: {} vmDefinition: {}
}); });
const vmDef = computed(() => previewApi.vmDefinition); const configured = computed(() => previewApi.vmDefinition.spec);
const startable = computed(() => previewApi.vmDefinition.spec &&
previewApi.vmDefinition.spec.vm.state !== 'Running'
&& !previewApi.vmDefinition.running);
const stoppable = computed(() => previewApi.vmDefinition.spec &&
previewApi.vmDefinition.spec.vm.state !== 'Stopped'
&& previewApi.vmDefinition.running);
const running = computed(() => previewApi.vmDefinition.running);
const permissions = computed(() => previewApi.vmDefinition.spec
? previewApi.vmDefinition.userPermissions : []);
watch(() => previewApi.vmName, (name: string) => { watch(() => previewApi.vmName, (name: string) => {
if (name !== "") { if (name !== "") {
@ -73,41 +83,51 @@ window.orgJDrupesVmOperatorVmViewer.initPreview = (previewDom: HTMLElement,
provideApi(previewDom, previewApi); provideApi(previewDom, previewApi);
const vmAction = (vmName: string, action: string) => { const vmAction = (action: string) => {
JGConsole.notifyConletModel(conletId, action, vmName); JGConsole.notifyConletModel(conletId, action);
}; };
return { localize, resourceBase, vmDef, vmAction }; return { localize, resourceBase, vmAction, configured,
startable, stoppable, running, permissions };
}, },
template: ` template: `
<table> <table>
<tbody> <tbody>
<tr> <tr>
<td><img role=button <td rowspan="2" style="position: relative"><span
:aria-disabled="!vmDef.running || !vmDef.userPermissions style="position: absolute;"
|| !vmDef.userPermissions.includes('accessConsole')" :class="{ busy: configured && !startable && !stoppable }"
v-on:click="vmAction(vmDef.name, 'openConsole')" ><img role=button :aria-disabled="!running
:src="resourceBase + (vmDef.running || !permissions.includes('accessConsole')"
v-on:click="vmAction('openConsole')"
:src="resourceBase + (running
? 'computer.svg' : 'computer-off.svg')" ? 'computer.svg' : 'computer-off.svg')"
:title="localize('Open console')"></td> :title="localize('Open console')"></span><span
<td v-if="vmDef.spec" style="visibility: hidden;"><img
class="jdrupes-vmoperator-vmviewer-preview-action-list"> :src="resourceBase + 'computer.svg'"></span></td>
<span role="button" v-if="vmDef.spec.vm.state != 'Running'" <td class="jdrupes-vmoperator-vmviewer-preview-action-list">
:aria-disabled="!vmDef.userPermissions.includes('start')" <span role="button"
:aria-disabled="!startable || !permissions.includes('start')"
tabindex="0" class="fa fa-play" :title="localize('Start VM')" tabindex="0" class="fa fa-play" :title="localize('Start VM')"
v-on:click="vmAction(vmDef.name, 'start')"></span> v-on:click="vmAction('start')"></span>
<span role="button" v-else class="fa fa-play" <span role="button"
aria-disabled="true" :title="localize('Start VM')"></span> :aria-disabled="!stoppable || !permissions.includes('stop')"
<span role="button" v-if="vmDef.spec.vm.state != 'Stopped'"
:aria-disabled="!vmDef.userPermissions.includes('stop')"
tabindex="0" class="fa fa-stop" :title="localize('Stop VM')" tabindex="0" class="fa fa-stop" :title="localize('Stop VM')"
v-on:click="vmAction(vmDef.name, 'stop')"></span> v-on:click="vmAction('stop')"></span>
<span role="button" v-else class="fa fa-stop" <span role="button"
aria-disabled="true" :title="localize('Stop VM')"></span> :aria-disabled="!running || !permissions.includes('reset')"
</td> tabindex="0" class="svg-icon" :title="localize('Reset VM')"
<td v-else> v-on:click="vmAction('reset')">
<svg viewBox="0 0 1541.33 1535.5083">
<path d="m 0,127.9968 v 448 c 0,35 29,64 64,64 h 448 c 35,0 64,-29 64,-64 0,-17 -6.92831,-33.07213 -19,-45 C 264.23058,241.7154 337.19508,314.89599 109,82.996795 c -11.999999,-12 -28,-19 -45,-19 -35,0 -64,29 -64,64.000005 z" />
<path d="m 772.97656,1535.5046 c 117.57061,0.3623 236.06134,-26.2848 345.77544,-81.4687 292.5708,-147.1572 459.8088,-465.37411 415.5214,-790.12504 C 1489.9861,339.15993 1243.597,77.463924 922.29883,14.342498 601.00067,-48.778928 274.05699,100.37563 110.62891,384.39133 c -34.855139,60.57216 -14.006492,137.9313 46.5664,172.78516 60.57172,34.85381 137.92941,14.00532 172.78321,-46.56641 109.97944,-191.12927 327.69604,-290.34657 543.53515,-247.94336 215.83913,42.40321 380.18953,216.77543 410.00973,435.44141 29.8203,218.66598 -81.8657,430.94957 -278.4863,529.84567 -196.6206,98.8962 -432.84043,61.8202 -589.90233,-92.6777 -24.91016,-24.5038 -85.48587,-83.3326 -119.02246,-52.9832 -24.01114,21.7292 -35.41741,29.5454 -59.9209,54.4559 -24.50381,24.9102 -35.33636,36.9034 -57.54543,60.4713 -38.1335,40.4667 34.10761,93.9685 59.01808,118.472 145.96311,143.5803 339.36149,219.2087 535.3125,219.8125 z"/>
</svg>
</span>
</td> </td>
</tr> </tr>
<tr>
<td></td>
</tr>
</tbody> </tbody>
</table>` </table>`
}); });
@ -209,3 +229,9 @@ window.orgJDrupesVmOperatorVmViewer.applyEdit =
const vmName = getApi<ref<string>>(dialogDom!)!.value; const vmName = getApi<ref<string>>(dialogDom!)!.value;
JGConsole.notifyConletModel(conletId, "selectedVm", vmName); JGConsole.notifyConletModel(conletId, "selectedVm", vmName);
} }
window.orgJDrupesVmOperatorVmViewer.confirmReset =
(conletType: string, conletId: string) => {
JGConsole.instance.closeModalDialog(conletType, conletId);
JGConsole.notifyConletModel(conletId, "resetConfirmed");
}

View file

@ -19,7 +19,24 @@
/* /*
* Conlet specific styles. * Conlet specific styles.
*/ */
.jdrupes-vmoperator-vmviewer-preview { .jdrupes-vmoperator-vmviewer {
span[role="button"].svg-icon {
display: inline-block;
line-height: 1;
/* Align with forkawesome */
font-size: 14px;
fill: var(--primary);
&[aria-disabled="true"], &[aria-disabled=""] {
fill: var(--disabled);
}
svg {
height: 2ex;
width: 1em;
}
}
[role=button] { [role=button] {
padding: 0.25rem; padding: 0.25rem;
@ -28,6 +45,9 @@
box-shadow: var(--darkening); box-shadow: var(--darkening);
} }
} }
}
.jdrupes-vmoperator-vmviewer.jdrupes-vmoperator-vmviewer-preview {
img { img {
height: 3em; height: 3em;
@ -37,14 +57,42 @@
opacity: 0.4; opacity: 0.4;
} }
} }
}
.jdrupes-vmoperator-vmviewer-preview-action-list { .jdrupes-vmoperator-vmviewer-preview-action-list {
white-space: nowrap; white-space: nowrap;
}
span.busy::before {
font: normal normal normal 14px/1 ForkAwesome;
font-size: 1.125em;
content: "\f1ce";
left: 1.45em;
top: 0.7em;
color: var(--info);
position: absolute;
animation: spin 2s linear infinite;
z-index: 100;
}
} }
.jdrupes-vmoperator-vmviewer-edit { .jdrupes-vmoperator-vmviewer.jdrupes-vmoperator-vmviewer-edit {
select { select {
width: 15em; width: 15em;
} }
} }
.jdrupes-vmoperator-vmviewer.jdrupes-vmoperator-vmviewer-confirm-reset {
p {
text-align: center;
}
span[role="button"].svg-icon {
fill: var(--danger);
svg {
width: 2.5em;
height: 2.5em;
}
}
}