Add console access to VM management.

This commit is contained in:
Michael Lipp 2025-01-29 17:33:16 +01:00
parent 5cd4edcec1
commit af41c78c07
14 changed files with 561 additions and 124 deletions

View file

@ -1,6 +1,7 @@
<div class="jdrupes-vmoperator-vmmgmt jdrupes-vmoperator-vmmgmt-view"
data-jgwc-on-load="orgJDrupesVmOperatorVmMgmt.initView"
data-jgwc-on-unload="JGConsole.jgwc.unmountVueApps">
data-jgwc-on-unload="JGConsole.jgwc.unmountVueApps"
data-conlet-resource-base="${conletResource('')}">
<div class="jdrupes-vmoperator-vmmgmt-view-search">
<form>
<label class="form__label--horizontal">
@ -58,17 +59,26 @@
</td>
<td class="jdrupes-vmoperator-vmmgmt-view-action-list">
<span role="button"
v-if="entry.spec.vm.state != 'Running' && !entry['running']"
v-if="entry.spec.vm.state != 'Running' && !entry['running']
&& entry.permissions.includes('start')"
tabindex="0" class="fa fa-play" :title="localize('Start VM')"
v-on:click="vmAction(entry.name, 'start')"></span>
<span role="button" v-else class="fa fa-play"
aria-disabled="true" :title="localize('Start VM')"></span>
<span role="button"
v-if="entry.spec.vm.state != 'Stopped' && entry['running']"
v-if="entry.spec.vm.state != 'Stopped' && entry['running']
&& entry.permissions.includes('stop')"
tabindex="0" class="fa fa-stop" :title="localize('Stop VM')"
v-on:click="vmAction(entry.name, 'stop')"></span>
<span role="button" v-else class="fa fa-stop"
aria-disabled="true" :title="localize('Stop VM')"></span>
<img role="button" :src="resourceBase + (!entry['running']
? 'computer-off.svg' : (entry.usedFrom
? 'computer-in-use.svg' : 'computer.svg'))"
:title="localize('Open console')"
:aria-disabled="!entry['running']
|| !(entry.permissions.includes('accessConsole'))"
v-on:click="vmAction(entry.name, 'openConsole')">
</td>
</tr>
<tr :id="scopedId(rowIndex)" v-if="$aash.isDisclosed(scopedId(rowIndex))"

View file

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
fill="#000000"
width="800"
height="533.33331"
viewBox="0 0 24 15.999999"
version="1.1"
id="svg1"
sodipodi:docname="computer-in-use.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
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">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect4"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<linearGradient
id="swatch3"
inkscape:swatch="solid">
<stop
style="stop-color:#000000;stop-opacity:0;"
offset="0"
id="stop3" />
</linearGradient>
</defs>
<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.90509668"
inkscape:cx="345.81941"
inkscape:cy="376.2029"
inkscape:window-width="1920"
inkscape:window-height="1008"
inkscape:window-x="0"
inkscape:window-y="35"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
id="rect1"
style="fill-opacity:0;stroke:#000000;stroke-width:1.97262;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:0;paint-order:fill markers stroke"
d="m 4.7729709,13.006705 -1.7691517,0 V 0.98808897 H 20.99618 V 13.006705 l -1.639132,0"
sodipodi:nodetypes="cccccc" />
<path
id="rect2"
style="opacity:1;stroke-width:0.00145614;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:0;stroke-dasharray:none;paint-order:fill markers stroke"
d="m 0,13.998258 h 5.4336202 v 2.001741 H 0 Z"
sodipodi:nodetypes="ccccc" />
<path
id="rect3"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:1.05373;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
d="m 5.6839082,10.94394 c 0.2963594,-0.907428 2.9522319,-1.683971 2.767387,-1.6392261 0,0 1.5028596,1.6181771 3.6459428,1.6129171 2.018383,-0.005 3.362681,-1.6125503 3.362681,-1.6125503 -0.171441,-0.061235 2.778887,0.7741493 2.977303,1.6787203 0.393054,1.791919 0.25928,4.489072 0.25928,4.489072 l -13.3818748,0.001 c 0,0 -0.181061,-2.844856 0.369281,-4.529957 z"
sodipodi:nodetypes="sssssccs" />
<ellipse
style="fill:none;stroke:#000000;stroke-width:1.02152;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
id="path3"
cx="11.964992"
cy="6.3769712"
rx="3.2413731"
ry="3.225764" />
<path
id="rect2-2"
style="stroke-width:0.00145614;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:0;stroke-dasharray:none;paint-order:fill markers stroke"
d="M 18.56638,13.998258 H 24 v 2.001741 h -5.43362 z"
sodipodi:nodetypes="ccccc" />
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
fill="#000000"
width="800"
height="533.33331"
viewBox="0 0 24 15.999999"
version="1.1"
id="svg1"
sodipodi:docname="computer-off.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">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect4"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<linearGradient
id="swatch3"
inkscape:swatch="solid">
<stop
style="stop-color:#000000;stop-opacity:0;"
offset="0"
id="stop3" />
</linearGradient>
</defs>
<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="1.3435029"
inkscape:cx="377.74389"
inkscape:cy="227.01849"
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
id="rect1"
style="fill-opacity:1;stroke:#000000;stroke-width:1.97262;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:0;paint-order:fill markers stroke;fill:#545454"
d="M 3.0038192,0.98808897 H 20.99618 V 13.006705 H 3.0038192 Z" />
<rect
style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.00306926;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
id="rect2"
width="23.995173"
height="2.0017407"
x="0.0039473679"
y="13.998839" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
fill="#000000"
width="800"
height="533.33331"
viewBox="0 0 24 15.999999"
version="1.1"
id="svg1"
sodipodi:docname="computer.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">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect4"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<linearGradient
id="swatch3"
inkscape:swatch="solid">
<stop
style="stop-color:#000000;stop-opacity:0;"
offset="0"
id="stop3" />
</linearGradient>
</defs>
<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="1.3435029"
inkscape:cx="377.74389"
inkscape:cy="227.01849"
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
id="rect1"
style="fill-opacity:0;stroke:#000000;stroke-width:1.97262;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:0;paint-order:fill markers stroke"
d="M 3.0038192,0.98808897 H 20.99618 V 13.006705 H 3.0038192 Z" />
<rect
style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.00306926;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
id="rect2"
width="23.995173"
height="2.0017407"
x="0.0039473679"
y="13.998839" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -17,3 +17,8 @@ usedBy = Used by
usedFrom = Used from
vmActions = Actions
vmname = Name
confirmResetTitle = Confirm reset
confirmResetMsg = Resetting the VM may cause loss of data. \
Please confirm to continue.
consoleTakenNotification = Console access is locked by another user.

View file

@ -24,6 +24,12 @@ vmname = Name
Value\ is\ above\ maximum = Wert ist zu groß
Illegal\ format = Ungültiges Format
confirmResetTitle = Zurücksetzen bestätigen
confirmResetMsg = Zurücksetzen der VM kann zu Datenverlust führen. \
Bitte bestätigen um fortzufahren.
consoleTakenNotification = Die Konsole wird von einem anderen Benutzer verwendet.
Open\ console = Konsole anzeigen
Start\ VM = VM Starten
Stop\ VM = VM Anhalten

View file

@ -27,15 +27,22 @@ import io.kubernetes.client.custom.Quantity.Format;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.Set;
import org.jdrupes.vmoperator.common.K8sObserver;
import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinition.Permission;
import org.jdrupes.vmoperator.manager.events.ChannelTracker;
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
import org.jdrupes.vmoperator.manager.events.ModifyVm;
import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
@ -44,13 +51,17 @@ import org.jgrapes.core.Channel;
import org.jgrapes.core.Event;
import org.jgrapes.core.Manager;
import org.jgrapes.core.annotation.Handler;
import org.jgrapes.util.events.ConfigurationUpdate;
import org.jgrapes.webconsole.base.Conlet.RenderMode;
import org.jgrapes.webconsole.base.ConletBaseModel;
import org.jgrapes.webconsole.base.ConsoleConnection;
import org.jgrapes.webconsole.base.events.AddConletRequest;
import org.jgrapes.webconsole.base.ConsoleRole;
import org.jgrapes.webconsole.base.ConsoleUser;
import org.jgrapes.webconsole.base.WebConsoleUtils;
import org.jgrapes.webconsole.base.events.AddConletType;
import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource;
import org.jgrapes.webconsole.base.events.ConsoleReady;
import org.jgrapes.webconsole.base.events.DisplayNotification;
import org.jgrapes.webconsole.base.events.NotifyConletModel;
import org.jgrapes.webconsole.base.events.NotifyConletView;
import org.jgrapes.webconsole.base.events.RenderConlet;
@ -61,10 +72,12 @@ import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
/**
* The Class {@link VmMgmt}.
*/
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
"PMD.CouplingBetweenObjects" })
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.CouplingBetweenObjects",
"PMD.ExcessiveImports" })
public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
private Class<?> preferredIpVersion = Inet4Address.class;
private boolean deleteConnectionFile = true;
private static final Set<RenderMode> MODES = RenderMode.asSet(
RenderMode.Preview, RenderMode.View);
private final ChannelTracker<String, VmChannel,
@ -91,6 +104,44 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
setPeriodicRefresh(Duration.ofMinutes(1), () -> new Update());
}
/**
* Configure the component.
*
* @param event the event
*/
@SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" })
@Handler
public void onConfigurationUpdate(ConfigurationUpdate event) {
event.structured("/Manager/GuiHttpServer"
+ "/ConsoleWeblet/WebConsole/ComponentCollector/VmAccess")
.ifPresent(c -> {
try {
var dispRes = (Map<String, Object>) c
.getOrDefault("displayResource",
Collections.emptyMap());
switch ((String) dispRes.getOrDefault("preferredIpVersion",
"")) {
case "ipv6":
preferredIpVersion = Inet6Address.class;
break;
case "ipv4":
default:
preferredIpVersion = Inet4Address.class;
break;
}
// Delete connection file
deleteConnectionFile
= Optional.ofNullable(c.get("deleteConnectionFile"))
.filter(v -> v instanceof String)
.map(v -> (String) v)
.map(Boolean::parseBoolean).orElse(true);
} catch (ClassCastException e) {
logger.config("Malformed configuration: " + e.getMessage());
}
});
}
/**
* On {@link ConsoleReady}, fire the {@link AddConletType}.
*
@ -117,7 +168,7 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
}
@Override
protected Optional<VmsModel> createNewState(AddConletRequest event,
protected Optional<VmsModel> createStateRepresentation(Event<?> event,
ConsoleConnection connection, String conletId) throws Exception {
return Optional.of(new VmsModel(conletId));
}
@ -160,17 +211,25 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
}
if (sendVmInfos) {
for (var item : channelTracker.values()) {
channel.respond(new NotifyConletView(type(),
conletId, "updateVm",
simplifiedVmDefinition(item.associated())));
updateVm(channel, conletId, item.associated());
}
}
return renderedAs;
}
private void updateVm(ConsoleConnection channel, String conletId,
VmDefinition vmDef) {
var user = WebConsoleUtils.userFromSession(channel.session())
.map(ConsoleUser::getName).orElse(null);
var roles = WebConsoleUtils.rolesFromSession(channel.session())
.stream().map(ConsoleRole::getName).toList();
channel.respond(new NotifyConletView(type(), conletId, "updateVm",
simplifiedVmDefinition(vmDef, user, roles)));
}
@SuppressWarnings("PMD.AvoidDuplicateLiterals")
private Map<String, Object> simplifiedVmDefinition(VmDefinition vmDef) {
private Map<String, Object> simplifiedVmDefinition(VmDefinition vmDef,
String user, List<String> roles) {
// Convert RAM sizes to unitless numbers
var spec = DataPath.deepCopy(vmDef.spec());
var vmSpec = DataPath.<Map<String, Object>> get(spec, "vm").get();
@ -191,7 +250,9 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
"name", vmDef.name()),
"spec", spec,
"status", status,
"nodeName", vmDef.extra("nodeName"));
"nodeName", vmDef.extra("nodeName"),
"permissions", vmDef.permissionsFor(user, roles).stream()
.map(VmDefinition.Permission::toString).toList());
}
/**
@ -221,8 +282,7 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
channelTracker.put(vmName, channel, vmDef);
for (var entry : conletIdsByConsoleConnection().entrySet()) {
for (String conletId : entry.getValue()) {
entry.getKey().respond(new NotifyConletView(type(),
conletId, "updateVm", simplifiedVmDefinition(vmDef)));
updateVm(entry.getKey(), conletId, vmDef);
}
}
}
@ -337,22 +397,35 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
@Override
@SuppressWarnings("PMD.AvoidDecimalLiteralsInBigDecimalConstructor")
protected void doUpdateConletState(NotifyConletModel event,
ConsoleConnection channel, VmsModel conletState)
throws Exception {
ConsoleConnection channel, VmsModel model) throws Exception {
event.stop();
String vmName = event.param(0);
var vmChannel = channelTracker.channel(vmName).orElse(null);
if (vmChannel == null) {
var value = channelTracker.value(vmName);
var vmChannel = value.map(v -> v.channel()).orElse(null);
var vmDef = value.map(v -> v.associated()).orElse(null);
if (vmDef == null) {
return;
}
var user = WebConsoleUtils.userFromSession(channel.session())
.map(ConsoleUser::getName).orElse("");
var roles = WebConsoleUtils.rolesFromSession(channel.session())
.stream().map(ConsoleRole::getName).toList();
var perms = vmDef.permissionsFor(user, roles);
switch (event.method()) {
case "start":
fire(new ModifyVm(vmName, "state", "Running", vmChannel));
if (perms.contains(VmDefinition.Permission.START)) {
fire(new ModifyVm(vmName, "state", "Running", vmChannel));
}
break;
case "stop":
fire(new ModifyVm(vmName, "state", "Stopped", vmChannel));
if (perms.contains(VmDefinition.Permission.STOP)) {
fire(new ModifyVm(vmName, "state", "Stopped", vmChannel));
}
break;
case "openConsole":
if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)) {
openConsole(channel, model, vmChannel, vmDef, user, perms);
}
break;
case "cpus":
fire(new ModifyVm(vmName, "currentCpus",
@ -370,6 +443,29 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
}
}
private void openConsole(ConsoleConnection channel, VmsModel model,
VmChannel vmChannel, VmDefinition vmDef, String user,
Set<Permission> perms) {
ResourceBundle resourceBundle = resourceBundle(channel.locale());
if (!vmDef.consoleAccessible(user, perms)) {
channel.respond(new DisplayNotification(
resourceBundle.getString("consoleTakenNotification"),
Map.of("autoClose", 5_000, "type", "Warning")));
return;
}
var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user),
e -> {
var data = vmDef.connectionFile(e.password().orElse(null),
preferredIpVersion, deleteConnectionFile);
if (data == null) {
return;
}
channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", data));
});
fire(pwQuery, vmChannel);
}
@Override
protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
String conletId) throws Exception {

View file

@ -104,6 +104,7 @@ window.orgJDrupesVmOperatorVmMgmt.initView = (viewDom: HTMLElement,
setup(_props: object) {
const conletId: string
= (<HTMLElement>viewDom.parentNode!).dataset["conletId"]!;
const resourceBase = (<HTMLElement>viewDom).dataset.conletResourceBase;
const controller = reactive(new JGConsole.TableController([
["name", "vmname"],
@ -162,9 +163,9 @@ window.orgJDrupesVmOperatorVmMgmt.initView = (viewDom: HTMLElement,
}
return {
controller, vmInfos, filteredData, detailsByName, localize,
shortDateTime, formatMemory, vmAction, cic, parseMemory,
maximumCpus,
controller, vmInfos, filteredData, detailsByName,
resourceBase, localize, shortDateTime, formatMemory,
vmAction, cic, parseMemory, maximumCpus,
scopedId: (id: string) => { return idScope.scopedId(id); }
};
}
@ -219,3 +220,20 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmmgmt.VmMgmt",
Object.assign(vmSummary, summary);
});
JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmmgmt.VmMgmt",
"openConsole", function(_conletId: string, data: string) {
let target = document.getElementById(
"org.jdrupes.vmoperator.vmmgt.VmMgmt.target");
if (!target) {
target = document.createElement("iframe");
target.id = "org.jdrupes.vmoperator.vmmgt.VmMgmt.target";
target.setAttribute("name", target.id);
target.setAttribute("style", "display: none;");
document.querySelector("body")!.append(target);
}
const url = "data:application/x-virt-viewer;base64,"
+ window.btoa(data);
window.open(url, target.id);
});

View file

@ -103,6 +103,10 @@
.jdrupes-vmoperator-vmmgmt-view-action-list {
white-space: nowrap;
& > * + * {
margin-left: 0.5em;
}
[role=button] {
padding: 0.25rem;
@ -110,4 +114,14 @@
box-shadow: var(--darkening);
}
}
img {
display: inline;
height: 1.5em;
vertical-align: top;
&[aria-disabled=''], &[aria-disabled='true'] {
opacity: 0.4;
}
}
}