Feature/web gui2 (#16)
Some checks failed
Java CI with Gradle / build (push) Has been cancelled

Add oveview and enhance.
This commit is contained in:
Michael N. Lipp 2023-10-30 23:10:26 +01:00 committed by GitHub
parent 8567a2f052
commit 6f45e7982a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1382 additions and 250 deletions

View file

@ -7,7 +7,9 @@ dependencies {
implementation 'org.jgrapes:org.jgrapes.webconsole.base:[1.2.0,2)'
implementation 'org.jgrapes:org.jgrapes.webconsole.provider.vue:[1,2)'
implementation 'org.jgrapes:org.jgrapes.webconsole.provider.jgwcvuecomponents:[1,2)'
implementation 'org.jgrapes:org.jgrapes.webconsole.provider.jgwcvuecomponents:[1.2,2)'
implementation 'org.jgrapes:org.jgrapes.webconsole.provider.chartjs:[1.2,2)'
}
apply plugin: 'com.github.node-gradle.node'
@ -22,6 +24,7 @@ task extractDependencies(type: Copy) {
|| it.name.contains('org.jgrapes.webconsole.base')
}
.collect{ zipTree (it) }
exclude '*.class'
into 'build/unpacked'
duplicatesStrategy 'include'
}

View file

@ -1,5 +1,46 @@
<div class="jdrupes-vmoperator-vmconlet jdrupes-vmoperator-vmconlet-preview"
data-conlet-grid-rows="5"
data-jgwc-on-load="orgJDrupesVmOperatorVmConlet.initPreview"
data-jgwc-on-unload="JGConsole.jgwc.unmountVueApps">
<div>Preview</div>
data-jgwc-on-unload="JGConsole.jgwc.unmountVueApps">
<form>
<fieldset>
<legend>{{ localize("Period") }}:</legend>
<ul>
<li>
<label>
<input type="radio" name="period" v-model="period" value="day">
<span>{{ localize("Last day") }}</span>
</label>
</li>
<li>
<label>
<input type="radio" name="period" v-model="period" value="hour">
<span>{{ localize("Last hour") }}</span>
</label>
</li>
</ul>
</fieldset>
</form>
<table>
<tbody>
<tr>
<td>{{ localize("VMsSummary") }}:</td>
<td>{{ vmSummary.runningVms }} / {{ vmSummary.totalVms }}</td>
</tr>
<tr>
<td>{{ localize("Used CPUs") }}:</td>
<td>{{ vmSummary.usedCpus }}</td>
</tr>
<tr>
<td>{{ localize("Used RAM") }}:</td>
<td>{{ formatMemory(Number(vmSummary.usedRam)) }}</td>
</tr>
</tbody>
</table>
<div class="vmsChart-wrapper">
<canvas class="vmsChart"></canvas>
</div>
</div>

View file

@ -44,8 +44,10 @@
class="fa fa-check" :title="localize('Yes')"></span>
<span v-else-if="key === 'running' && !entry[key]"
class="fa fa-close" :title="localize('No')"></span>
<span v-else-if="key === 'runningConditionSince'"
>{{ shortDateTime(entry[key].toString()) }}</span>
<span v-else-if="key === 'currentRam'"
v-html="formatMemory(entry[key])"></span>
>{{ formatMemory(entry[key]) }}</span>
<span v-else
v-html="controller.breakBeforeDots(entry[key])"></span>
</td>
@ -64,7 +66,7 @@
</tr>
<tr :id="scopedId(rowIndex)" v-if="$aash.isDisclosed(scopedId(rowIndex))"
:class="[(rowIndex % 2) ? 'odd' : 'even']">
<td colspan="4" class="details">
<td colspan="6" class="details">
<table class="table--basic table--basic--autoStriped">
<tr>
<td>{{ localize("maximumCpus") }}</td>
@ -72,15 +74,31 @@
</tr>
<tr>
<td>{{ localize("requestedCpus") }}</td>
<td>{{ entry.spec.vm.maximumCpus }}</td>
<td v-if="cic.key !== (entry['name'] + ':cpus')" tabindex="0"
v-on:focus="cic.startEdit(entry['name'] + ':cpus', entry.spec.vm.currentCpus)"
>{{ entry.spec.vm.currentCpus }}</td>
<td v-else><form action="javascript:void();"
><input :ref="(el) => { cic.input = el; }"
type="number" required :max="entry.spec.vm.maximumCpus"
v-on:focusout="cic.endEdit(cic.parseNumber)"
v-on:keydown.escape="cic.endEdit()"
><span>{{ cic.error }}</span></form></td>
</tr>
<tr>
<td>{{ localize("maximumRam") }}</td>
<td>{{ formatMemory(BigInt(entry.spec.vm.maximumRam)) }}</td>
<td>{{ formatMemory(Number(entry.spec.vm.maximumRam)) }}</td>
</tr>
<tr>
<td>{{ localize("requestedRam") }}</td>
<td>{{ formatMemory(BigInt(entry.spec.vm.maximumRam)) }}</td>
<td v-if="cic.key !== (entry['name'] + ':ram')" tabindex="0"
v-on:focus="cic.startEdit(entry['name'] + ':ram', formatMemory(entry.spec.vm.currentRam))"
>{{ formatMemory(entry.spec.vm.currentRam) }}</td>
<td v-else><form action="javascript:void(0);"
><input :ref="(el) => { cic.input = el; }"
type="text" required
v-on:focusout="cic.endEdit(parseMemory)"
v-on:keydown.escape="cic.endEdit()"
><span>{{ cic.error }}</span></form></td>
</tr>
</table>
</td>

View file

@ -1,5 +1,8 @@
conletName = VM Viewer
VMsSummary = VMs (running/total)
since = Since
currentCpus = Current CPUs
currentRam = Current RAM
maximumCpus = Maximum CPUs

View file

@ -1,6 +1,15 @@
conletName = VM Anzeige
VMsSummary = VMs (gestartet/gesamt)
Used\ CPUs = Verwendete CPUs
Used\ RAM = Verwendetes RAM
Period = Zeitraum
Last\ hour = Letzte Stunde
Last\ day = Letzter Tag
running = Gestartet
since = Seit
currentCpus = Aktuelle CPUs
currentRam = Akuelles RAM
maximumCpus = Maximale CPUs
@ -10,6 +19,8 @@ requestedCpus = Angeforderte CPUs
requestedRam = Angefordertes RAM
vmActions = Aktionen
vmname = Name
Value\ is\ above\ maximum = Wert ist zu groß
Illegal\ format = Ungültiges Format
Start\ VM = VM Starten
Stop\ VM = VM Anhalten

View file

@ -11,7 +11,8 @@ let pathsMap = {
"jgconsole": "../../console-base-resource/jgconsole.js",
"jgwc": "../../page-resource/jgwc-vue-components/jgwc-components.js",
"l10nBundles": "./" + baseName + "-l10nBundles.ftl.js",
"vue": "../../page-resource/vue/vue.esm-browser.js"
"vue": "../../page-resource/vue/vue.esm-browser.js",
"chartjs": "../../page-resource/chart.js/auto.js"
}
export default {

View file

@ -0,0 +1,144 @@
/*
* 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.vmconlet;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
/**
* The Class TimeSeries.
*/
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class TimeSeries {
private final List<Entry> data = new LinkedList<>();
private final Duration period;
/**
* Instantiates a new time series.
*
* @param series the number of series
*/
public TimeSeries(Duration period) {
this.period = period;
}
/**
* Adds data to the series.
*
* @param time the time
* @param numbers the numbers
* @return the time series
*/
@SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
public TimeSeries add(Instant time, Number... numbers) {
var newEntry = new Entry(time, numbers);
boolean adjust = false;
if (data.size() >= 2) {
var lastEntry = data.get(data.size() - 1);
var lastButOneEntry = data.get(data.size() - 2);
adjust = lastEntry.valuesEqual(lastButOneEntry)
&& lastEntry.valuesEqual(newEntry);
}
if (adjust) {
data.get(data.size() - 1).adjustTime(time);
} else {
data.add(new Entry(time, numbers));
}
// Purge
Instant limit = time.minus(period);
while (data.size() > 2
&& data.get(0).getTime().isBefore(limit)
&& data.get(1).getTime().isBefore(limit)) {
data.remove(0);
}
return this;
}
/**
* Returns the entries.
*
* @return the list
*/
public List<Entry> entries() {
return data;
}
/**
* The Class Entry.
*/
public static class Entry {
private Instant timestamp;
private final Number[] values;
/**
* Instantiates a new entry.
*
* @param time the time
* @param numbers the numbers
*/
@SuppressWarnings("PMD.ArrayIsStoredDirectly")
public Entry(Instant time, Number... numbers) {
timestamp = time;
values = numbers;
}
/**
* Changes the entry's time.
*
* @param time the time
*/
public void adjustTime(Instant time) {
timestamp = time;
}
/**
* Returns the entry's time.
*
* @return the instant
*/
public Instant getTime() {
return timestamp;
}
/**
* Returns the values.
*
* @return the number[]
*/
@SuppressWarnings("PMD.MethodReturnsInternalArray")
public Number[] getValues() {
return values;
}
/**
* Returns `true` if both entries have the same values.
*
* @param other the other
* @return true, if successful
*/
public boolean valuesEqual(Entry other) {
return Arrays.equals(values, other.values);
}
}
}

View file

@ -24,8 +24,13 @@ import freemarker.template.MalformedTemplateNameException;
import freemarker.template.Template;
import freemarker.template.TemplateNotFoundException;
import io.kubernetes.client.custom.Quantity;
import io.kubernetes.client.custom.Quantity.Format;
import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.time.Instant;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
@ -33,8 +38,7 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.jdrupes.json.JsonBeanDecoder;
import org.jdrupes.json.JsonDecodeException;
import org.jdrupes.vmoperator.manager.events.StartVm;
import org.jdrupes.vmoperator.manager.events.StopVm;
import org.jdrupes.vmoperator.manager.events.ModifyVm;
import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jdrupes.vmoperator.manager.events.VmDefChanged.Type;
@ -59,6 +63,7 @@ import org.jgrapes.webconsole.base.events.SetLocale;
import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
/**
* The Class VmConlet.
*/
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class VmConlet extends FreeMarkerConlet<VmConlet.VmsModel> {
@ -67,6 +72,14 @@ public class VmConlet extends FreeMarkerConlet<VmConlet.VmsModel> {
RenderMode.Preview, RenderMode.View);
private final Map<String, DynamicKubernetesObject> vmInfos
= new ConcurrentHashMap<>();
private final TimeSeries summarySeries = new TimeSeries(Duration.ofDays(1));
private Summary cachedSummary;
/**
* The periodically generated update event.
*/
public static class Update extends Event<Void> {
}
/**
* Creates a new component with its channel set to the given channel.
@ -77,6 +90,7 @@ public class VmConlet extends FreeMarkerConlet<VmConlet.VmsModel> {
*/
public VmConlet(Channel componentChannel) {
super(componentChannel);
setPeriodicRefresh(Duration.ofMinutes(1), () -> new Update());
}
/**
@ -116,7 +130,7 @@ public class VmConlet extends FreeMarkerConlet<VmConlet.VmsModel> {
ConsoleConnection channel, String conletId, VmsModel conletState)
throws Exception {
Set<RenderMode> renderedAs = new HashSet<>();
boolean sendData = false;
boolean sendVmInfos = false;
if (event.renderAs().contains(RenderMode.Preview)) {
Template tpl
= freemarkerConfig().getTemplate("VmConlet-preview.ftl.html");
@ -127,7 +141,12 @@ public class VmConlet extends FreeMarkerConlet<VmConlet.VmsModel> {
RenderMode.Preview.addModifiers(event.renderAs()))
.setSupportedModes(MODES));
renderedAs.add(RenderMode.View);
sendData = true;
channel.respond(new NotifyConletView(type(),
conletId, "summarySeries", summarySeries.entries()));
var summary = evaluateSummary(false);
channel.respond(new NotifyConletView(type(),
conletId, "updateSummary", summary));
sendVmInfos = true;
}
if (event.renderAs().contains(RenderMode.View)) {
Template tpl
@ -139,9 +158,9 @@ public class VmConlet extends FreeMarkerConlet<VmConlet.VmsModel> {
RenderMode.View.addModifiers(event.renderAs()))
.setSupportedModes(MODES));
renderedAs.add(RenderMode.View);
sendData = true;
sendVmInfos = true;
}
if (sendData) {
if (sendVmInfos) {
for (var vmInfo : vmInfos.values()) {
var def = JsonBeanDecoder.create(vmInfo.getRaw().toString())
.readObject();
@ -158,13 +177,14 @@ public class VmConlet extends FreeMarkerConlet<VmConlet.VmsModel> {
*
* @param event the event
* @param channel the channel
* @throws JsonDecodeException
* @throws JsonDecodeException the json decode exception
* @throws IOException
*/
@Handler(namedChannels = "manager")
@SuppressWarnings({ "PMD.ConfusingTernary",
"PMD.AvoidInstantiatingObjectsInLoops" })
@SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity",
"PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals" })
public void onVmDefChanged(VmDefChanged event, VmChannel channel)
throws JsonDecodeException {
throws JsonDecodeException, IOException {
if (event.type() == Type.DELETED) {
var vmName = event.vmDefinition().getMetadata().getName();
vmInfos.remove(vmName);
@ -175,25 +195,7 @@ public class VmConlet extends FreeMarkerConlet<VmConlet.VmsModel> {
}
}
} else {
var vmDef = new DynamicKubernetesObject(
event.vmDefinition().getRaw().deepCopy());
GsonPtr.to(vmDef.getRaw()).to("metadata").get(JsonObject.class)
.remove("managedFields");
var vmSpec = GsonPtr.to(vmDef.getRaw()).to("spec", "vm");
vmSpec.set("maximumRam", Quantity.fromString(
vmSpec.getAsString("maximumRam").orElse("0")).getNumber()
.toBigInteger().toString());
vmSpec.set("currentRam", Quantity.fromString(
vmSpec.getAsString("currentRam").orElse("0")).getNumber()
.toBigInteger().toString());
var status = GsonPtr.to(vmDef.getRaw()).to("status");
status.set("ram", Quantity.fromString(
status.getAsString("ram").orElse("0")).getNumber()
.toBigInteger().toString());
String vmName = event.vmDefinition().getMetadata().getName();
vmInfos.put(vmName, vmDef);
// Extract running
var vmDef = prepareForSending(event);
var def = JsonBeanDecoder.create(vmDef.getRaw().toString())
.readObject();
for (var entry : conletIdsByConsoleConnection().entrySet()) {
@ -203,20 +205,165 @@ public class VmConlet extends FreeMarkerConlet<VmConlet.VmsModel> {
}
}
}
var summary = evaluateSummary(true);
summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam);
for (var entry : conletIdsByConsoleConnection().entrySet()) {
for (String conletId : entry.getValue()) {
entry.getKey().respond(new NotifyConletView(type(),
conletId, "updateSummary", summary));
}
}
}
@SuppressWarnings("PMD.AvoidDuplicateLiterals")
private DynamicKubernetesObject prepareForSending(VmDefChanged event) {
// Clone and remove managed fields
var vmDef = new DynamicKubernetesObject(
event.vmDefinition().getRaw().deepCopy());
GsonPtr.to(vmDef.getRaw()).to("metadata").get(JsonObject.class)
.remove("managedFields");
// Convert RAM sizes to unitless numbers
var vmSpec = GsonPtr.to(vmDef.getRaw()).to("spec", "vm");
vmSpec.set("maximumRam", Quantity.fromString(
vmSpec.getAsString("maximumRam").orElse("0")).getNumber()
.toBigInteger());
vmSpec.set("currentRam", Quantity.fromString(
vmSpec.getAsString("currentRam").orElse("0")).getNumber()
.toBigInteger());
var status = GsonPtr.to(vmDef.getRaw()).to("status");
status.set("ram", Quantity.fromString(
status.getAsString("ram").orElse("0")).getNumber()
.toBigInteger());
String vmName = event.vmDefinition().getMetadata().getName();
vmInfos.put(vmName, vmDef);
return vmDef;
}
/**
* Handle the periodic update event by sending {@link NotifyConletView}
* events.
*
* @param event the event
* @param connection the console connection
*/
@Handler
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
public void onUpdate(Update event, ConsoleConnection connection) {
var summary = evaluateSummary(false);
summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam);
for (String conletId : conletIds(connection)) {
connection.respond(new NotifyConletView(type(),
conletId, "updateSummary", summary));
}
}
/**
* The Class Summary.
*/
@SuppressWarnings("PMD.DataClass")
public static class Summary {
/** The total vms. */
public int totalVms;
/** The running vms. */
public int runningVms;
/** The used cpus. */
public int usedCpus;
/** The used ram. */
public BigInteger usedRam = BigInteger.ZERO;
/**
* Gets the total vms.
*
* @return the totalVms
*/
public int getTotalVms() {
return totalVms;
}
/**
* Gets the running vms.
*
* @return the runningVms
*/
public int getRunningVms() {
return runningVms;
}
/**
* Gets the used cpus.
*
* @return the usedCpus
*/
public int getUsedCpus() {
return usedCpus;
}
/**
* Gets the used ram. Returned as String for Json rendering.
*
* @return the usedRam
*/
public String getUsedRam() {
return usedRam.toString();
}
}
@SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
private Summary evaluateSummary(boolean force) {
if (!force && cachedSummary != null) {
return cachedSummary;
}
Summary summary = new Summary();
for (var vmDef : vmInfos.values()) {
summary.totalVms += 1;
var status = GsonPtr.to(vmDef.getRaw()).to("status");
summary.usedCpus += status.getAsInt("cpus").orElse(0);
summary.usedRam = summary.usedRam.add(status.getAsString("ram")
.map(BigInteger::new).orElse(BigInteger.ZERO));
for (var c : status.getAsListOf(JsonObject.class, "conditions")) {
if ("Running".equals(GsonPtr.to(c).getAsString("type")
.orElse(null))
&& "True".equals(GsonPtr.to(c).getAsString("status")
.orElse(null))) {
summary.runningVms += 1;
}
}
}
cachedSummary = summary;
return summary;
}
@Override
@SuppressWarnings("PMD.AvoidDecimalLiteralsInBigDecimalConstructor")
protected void doUpdateConletState(NotifyConletModel event,
ConsoleConnection channel, VmsModel conletState)
throws Exception {
event.stop();
switch (event.method()) {
case "start":
fire(new StartVm(event.params().asString(0),
fire(new ModifyVm(event.params().asString(0), "state", "Running",
new NamedChannel("manager")));
break;
case "stop":
fire(new StopVm(event.params().asString(0),
fire(new ModifyVm(event.params().asString(0), "state", "Stopped",
new NamedChannel("manager")));
break;
case "cpus":
fire(new ModifyVm(event.params().asString(0), "currentCpus",
new BigDecimal(event.params().asDouble(1)).toBigInteger(),
new NamedChannel("manager")));
break;
case "ram":
fire(new ModifyVm(event.params().asString(0), "currentRam",
new Quantity(new BigDecimal(event.params().asDouble(1)),
Format.BINARY_SI).toSuffixedString(),
new NamedChannel("manager")));
break;
default:// ignore

View file

@ -0,0 +1,100 @@
/*
* 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/>.
*/
import { ref, Ref, nextTick } from "vue";
/**
* A controller for conditionally shown inputs. "Conditionally shown"
* means that the value is usually shown using some display element
* (e.g. `span`). Only when that elements gets the focus, it is replaced
* with an input element for editing the value.
*/
export default class ConditionlInputController {
private submitCallback: (selected: string, value: any) => string | null;
private readonly inputKey = ref("");
private startValue: any;
private inputElement: HTMLInputElement | null = null;
private errorMessage = ref("");
/**
* Creates a new controller.
*/
constructor(submitCallback: (selected: string, value: string) => string | null) {
// this.inputRef = inputRef;
this.submitCallback = submitCallback;
}
get key() {
return this.inputKey.value;
}
get error() {
return this.errorMessage.value;
}
set input(element: HTMLInputElement) {
this.inputElement = element;
}
startEdit (key: string, value: any) {
if (this.inputKey.value != "") {
return;
}
this.startValue = value;
this.errorMessage.value = "";
this.inputKey.value = key;
nextTick(() => {
this.inputElement!.value = value;
this.inputElement!.focus();
});
}
endEdit (converter?: (value: string) => any | null) : boolean {
if (typeof converter === 'undefined') {
this.inputKey.value = "";
return false;
}
let newValue = converter(this.inputElement!.value);
if (newValue === this.startValue) {
this.inputKey.value = "";
return false;
}
let submitResult = this.submitCallback (this.inputKey.value, newValue);
if (submitResult !== null) {
this.errorMessage.value = submitResult;
// Neither doing it directly nor doing it with nextTick works.
setTimeout(() => this.inputElement!.focus(), 10);
} else {
this.inputKey.value = "";
}
// In case it is called by form action
return false;
}
get parseNumber() {
return (value: string): number | null => {
if (value.match(/^\d+$/)) {
return Number(value);
}
return null;
}
}
}

View file

@ -0,0 +1,138 @@
/*
* 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/>.
*/
import { Chart } from "chartjs";
import TimeSeries from "./TimeSeries";
import { formatMemory } from "./MemorySize";
import JGConsole from "jgconsole";
import l10nBundles from "l10nBundles";
import { JGWC } from "jgwc";
export default class CpuRamChart extends Chart {
private period = 24 * 3600 * 1000;
constructor(canvas: HTMLCanvasElement, series: TimeSeries) {
super(canvas.getContext('2d')!, {
// The type of chart we want to create
type: 'line',
// The data for our datasets
data: {
labels: series.getTimes(),
datasets: [{
// See localize
data: series.getSeries(0),
yAxisID: 'cpus'
}, {
// See localize
data: series.getSeries(1),
yAxisID: 'ram'
}]
},
// Configuration options go here
options: {
animation: false,
maintainAspectRatio: false,
scales: {
x: {
type: 'time',
time: { minUnit: 'minute' },
adapters: {
date: {
// See localize
}
}
},
cpus: {
type: 'linear',
display: true,
position: 'left',
min: 0
},
ram: {
type: 'linear',
display: true,
position: 'right',
min: 0,
grid: { drawOnChartArea: false },
ticks: {
stepSize: 1024 * 1024 * 1024,
callback: function(value, _index, _values) {
return formatMemory(Math.round(Number(value)));
}
}
}
}
}
});
let css = getComputedStyle(canvas);
this.setPropValue("options.plugins.legend.labels.font.family", css.fontFamily);
this.setPropValue("options.plugins.legend.labels.color", css.color);
this.setPropValue("options.scales.x.ticks.font.family", css.fontFamily);
this.setPropValue("options.scales.x.ticks.color", css.color);
this.setPropValue("options.scales.cpus.ticks.font.family", css.fontFamily);
this.setPropValue("options.scales.cpus.ticks.color", css.color);
this.setPropValue("options.scales.ram.ticks.font.family", css.fontFamily);
this.setPropValue("options.scales.ram.ticks.color", css.color);
this.localizeChart();
}
setPeriod(period: number) {
this.period = period;
this.update();
}
setPropValue(path: string, value: any) {
let ptr: any = this;
let segs = path.split(".");
let lastSeg = segs.pop()!;
for (let seg of segs) {
let cur = ptr[seg];
if (!cur) {
ptr[seg] = {};
}
// ptr[seg] = ptr[seg] || {}
ptr = ptr[seg];
}
ptr[lastSeg] = value;
}
localizeChart() {
(<any>this.options.scales?.x).adapters.date.locale = JGWC.lang();
this.data.datasets[0].label
= JGConsole.localize(l10nBundles, JGWC.lang(), "Used CPUs")
this.data.datasets[1].label
= JGConsole.localize(l10nBundles, JGWC.lang(), "Used RAM")
this.update();
}
shift() {
this.setPropValue("options.scales.x.max", Date.now());
this.update();
}
update() {
this.setPropValue("options.scales.x.min", Date.now() - this.period);
super.update();
}
}

View file

@ -0,0 +1,65 @@
/*
* 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/>.
*/
let unitMap = new Map<string, number>();
let unitMappings = new Array<{ key: string; value: number }>();
let memorySize = /^(\d+(\.\d+)?)\s*(B|kB|MB|GB|TB|PB|EB|KiB|MiB|GiB|TiB|PiB|EiB)?$/;
// SI units and common abbreviations
let factor = 1;
unitMap.set("", factor);
let scale = 1000;
for (let unit of ["B", "kB", "MB", "GB", "TB", "PB", "EB"]) {
unitMap.set(unit, factor);
factor = factor * scale;
}
// Binary units
factor = 1024;
scale = 1024;
for (let unit of ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]) {
unitMap.set(unit, factor);
factor = factor * scale;
}
unitMap.forEach((value: number, key: string) => {
unitMappings.push({ key, value });
});
unitMappings.sort((a, b) => a.value < b.value ? 1 : a.value > b.value ? -1 : 0);
export function formatMemory(size: number): string {
for (let mapping of unitMappings) {
if (size >= mapping.value
&& (size % mapping.value) === 0) {
return (size / mapping.value + " " + mapping.key).trim();
}
}
return size.toString();
}
export function parseMemory(value: string): number | null {
let match = value.match(memorySize);
if (!match) {
return null;
}
let unit = 1;
if (match[3]) {
unit = unitMap.get(match[3])!;
}
return Number(match[1]) * unit;
}

View file

@ -0,0 +1,91 @@
/*
* 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/>.
*/
type OnChangeCallback = ((ts: TimeSeries) => void) | null;
export default class TimeSeries {
private timestamps: Date[] = [];
private series: number[][];
private period: number;
private onChange: OnChangeCallback;
constructor(nbOfSeries: number, period = 24 * 3600 * 1000,
onChange: OnChangeCallback = null) {
this.period = period;
this.onChange = onChange;
this.series = [];
while (this.series.length < nbOfSeries) {
this.series.push([]);
}
}
clear() {
this.timestamps.length = 0;
for (let values of this.series) {
values.length = 0;
}
if (this.onChange) {
this.onChange(this);
}
}
push(time: Date, ...values: number[]) {
let adjust = false;
if (this.timestamps.length >= 2) {
adjust = true;
for (let i = 0; i < values.length; i++) {
if (values[i] !== this.series[i][this.series[i].length - 1]
|| values[i] !== this.series[i][this.series[i].length - 2]) {
adjust = false;
break;
}
}
}
if (adjust) {
this.timestamps[this.timestamps.length - 1] = time;
} else {
this.timestamps.push(time);
for (let i = 0; i < values.length; i++) {
this.series[i].push(values[i]);
}
}
// Purge
let limit = time.getTime() - this.period;
while (this.timestamps.length > 2
&& this.timestamps[0].getTime() < limit
&& this.timestamps[1].getTime() < limit) {
this.timestamps.shift();
for (let values of this.series) {
values.shift();
}
}
if (this.onChange) {
this.onChange(this);
}
}
getTimes(): Date[] {
return this.timestamps;
}
getSeries(n: number): number[] {
return this.series[n];
}
}

View file

@ -16,51 +16,19 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { reactive, ref, createApp, computed, onMounted } from "vue";
import {
reactive, ref, Ref, createApp, computed, onMounted, watch, nextTick
} from "vue";
import JGConsole from "jgconsole";
import JgwcPlugin, { JGWC } from "jgwc";
import { provideApi, getApi } from "aash-plugin";
import l10nBundles from "l10nBundles";
import TimeSeries from "./TimeSeries";
import { formatMemory, parseMemory } from "./MemorySize";
import CpuRamChart from "./CpuRamChart";
import ConditionlInputController from "./ConditionalInputController";
import "./VmConlet-style.scss";
//
// Helpers
//
let unitMap = new Map<string, bigint>();
let unitMappings = new Array<{ key: string; value: bigint }>();
let memorySize = /^\\s*(\\d+(\\.\\d+)?)\\s*([A-Za-z]*)\\s*/;
// SI units and common abbreviations
let factor = BigInt("1");
unitMap.set("", factor);
let scale = BigInt("1000");
for (let unit of ["B", "kB", "MB", "GB", "TB", "PB", "EB"]) {
unitMap.set(unit, factor);
factor = factor * scale;
}
// Binary units
factor = BigInt("1024");
scale = BigInt("1024");
for (let unit of ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]) {
unitMap.set(unit, factor);
factor = factor * scale;
}
unitMap.forEach((value: bigint, key: string) => {
unitMappings.push({ key, value });
});
unitMappings.sort((a, b) => a.value < b.value ? 1 : a.value > b.value ? -1 : 0);
function formatMemory(size: bigint): string {
for (let mapping of unitMappings) {
if (size >= mapping.value
&& (size % mapping.value) === BigInt("0")) {
return (size / mapping.value + " " + mapping.key).trim();
}
}
return size.toString();
}
// For global access
declare global {
interface Window {
@ -71,14 +39,64 @@ declare global {
window.orgJDrupesVmOperatorVmConlet = {};
let vmInfos = reactive(new Map());
let vmSummary = reactive({
totalVms: 0,
runningVms: 0,
usedCpus: 0,
usedRam: ""
});
window.orgJDrupesVmOperatorVmConlet.initPreview
= (previewDom: HTMLElement, _isUpdate: boolean) => {
const app = createApp({});
app.use(JgwcPlugin, []);
app.config.globalProperties.window = window;
app.mount(previewDom);
};
const localize = (key: string) => {
return JGConsole.localize(
l10nBundles, JGWC.lang(), key);
};
const shortDateTime = (time: Date) => {
// https://stackoverflow.com/questions/63958875/why-do-i-get-rangeerror-date-value-is-not-finite-in-datetimeformat-format-w
return new Intl.DateTimeFormat(JGWC.lang(),
{ dateStyle: "short", timeStyle: "short" }).format(new Date(time));
};
// Cannot be reactive, leads to infinite recursion.
let chartData = new TimeSeries(2);
let chartDateUpdate = ref<Date>(null);
window.orgJDrupesVmOperatorVmConlet.initPreview = (previewDom: HTMLElement,
_isUpdate: boolean) => {
const app = createApp({
setup(_props: any) {
const conletId: string
= (<HTMLElement>previewDom.parentNode!).dataset["conletId"]!;
let chart: CpuRamChart | null = null;
onMounted(() => {
let canvas: HTMLCanvasElement
= previewDom.querySelector(":scope .vmsChart")!;
chart = new CpuRamChart(canvas, chartData);
})
watch(chartDateUpdate, (_) => {
chart?.update();
})
watch(JGWC.langRef(), (_) => {
chart?.localizeChart();
})
const period: Ref<string> = ref<string>("day");
watch(period, (_) => {
let hours = (period.value === "day") ? 24 : 1;
chart?.setPeriod(hours * 3600 * 1000);
});
return { localize, formatMemory, vmSummary, period };
}
});
app.use(JgwcPlugin, []);
app.config.globalProperties.window = window;
app.mount(previewDom);
};
window.orgJDrupesVmOperatorVmConlet.initView = (viewDom: HTMLElement,
_isUpdate: boolean) => {
@ -87,14 +105,10 @@ window.orgJDrupesVmOperatorVmConlet.initView = (viewDom: HTMLElement,
const conletId: string
= (<HTMLElement>viewDom.parentNode!).dataset["conletId"]!;
const localize = (key: string) => {
return JGConsole.localize(
l10nBundles, JGWC.lang() || "en", key);
};
const controller = reactive(new JGConsole.TableController([
["name", "vmname"],
["running", "running"],
["runningConditionSince", "since"],
["currentCpus", "currentCpus"],
["currentRam", "currentRam"],
["nodeName", "nodeName"]
@ -114,12 +128,30 @@ window.orgJDrupesVmOperatorVmConlet.initView = (viewDom: HTMLElement,
const idScope = JGWC.createIdScope();
const detailsByName = reactive(new Set());
const submitCallback = (selected: string, value: any) => {
if (value === null) {
return localize("Illegal format");
}
let vmName = selected.substring(0, selected.lastIndexOf(":"));
let property = selected.substring(selected.lastIndexOf(":") + 1);
var vmDef = vmInfos.get(vmName);
let maxValue = vmDef.spec.vm["maximum"
+ property.substring(0, 1).toUpperCase() + property.substring(1)];
if (value > maxValue) {
return localize("Value is above maximum");
}
JGConsole.notifyConletModel(conletId, property, vmName, value);
return null;
}
const cic = new ConditionlInputController(submitCallback);
return {
controller, vmInfos, filteredData, detailsByName,
localize, formatMemory, vmAction,
controller, vmInfos, filteredData, detailsByName, localize,
shortDateTime, formatMemory, vmAction, cic, parseMemory,
scopedId: (id: string) => { return idScope.scopedId(id); }
}
};
}
});
app.use(JgwcPlugin);
@ -132,14 +164,15 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmconlet.VmConlet",
// Add some short-cuts for table controller
vmDefinition.name = vmDefinition.metadata.name;
vmDefinition.currentCpus = vmDefinition.status.cpus;
vmDefinition.currentRam = BigInt(vmDefinition.status.ram);
vmDefinition.currentRam = Number(vmDefinition.status.ram);
for (let condition of vmDefinition.status.conditions) {
if (condition.type === "Running") {
vmDefinition.running = condition.status === "True";
vmDefinition.runningConditionSince
= new Date(condition.lastTransitionTime);
break;
}
}
vmInfos.set(vmDefinition.name, vmDefinition);
});
@ -147,3 +180,22 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmconlet.VmConlet",
"removeVm", function(_conletId: String, vmName: String) {
vmInfos.delete(vmName);
});
JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmconlet.VmConlet",
"summarySeries", function(_conletId: String, series: any[]) {
chartData.clear();
for (let entry of series) {
chartData.push(new Date(entry.time.epochSecond * 1000
+ entry.time.nano / 1000000),
entry.values[0], entry.values[1]);
}
chartDateUpdate.value = new Date();
});
JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmconlet.VmConlet",
"updateSummary", function(_conletId: String, summary: any) {
chartData.push(new Date(), summary.usedCpus, Number(summary.usedRam));
chartDateUpdate.value = new Date();
Object.assign(vmSummary, summary);
});

View file

@ -20,6 +20,23 @@
* Conlet specific styles.
*/
.jdrupes-vmoperator-vmconlet-preview {
form {
float: right;
padding: 0.15em 0.3em;
border: 1px solid var(--panel-border);
border-radius: var(--corner-radius);
}
table {
margin-bottom: 1em;
}
.vmsChart-wrapper {
height: 12em;
}
}
.jdrupes-vmoperator-vmconlet-view-search {
display: flex;
justify-content: flex-end
@ -30,7 +47,7 @@
}
.jdrupes-vmoperator-vmconlet-view-action-list {
white-space: nowrap;
white-space: nowrap;
}
.jdrupes-vmoperator-vmconlet-view-action-list [role=button]:not(:last-child) {
@ -39,28 +56,51 @@
.jdrupes-vmoperator-vmconlet-view td {
vertical-align: top;
&[tabindex] {
outline: 1px solid var(--primary);
cursor: text;
}
}
.jdrupes-vmoperator-vmconlet-view td:not([colspan]):first-child {
white-space: nowrap;
white-space: nowrap;
}
.jdrupes-vmoperator-vmconlet-view table td.details {
padding-left: 1em;
padding-left: 1em;
}
.jdrupes-vmoperator-vmconlet-view-table {
td.column-running {
text-align: center;
.jdrupes-vmoperator-vmconlet-view-table {
td.column-running {
text-align: center;
span {
&.fa-check {
color: var(--success);
}
span {
&.fa-check {
color: var(--success);
}
&.fa-close {
color: var(--danger);
}
}
&.fa-close {
color: var(--danger);
}
}
}
td.details {
table {
td:nth-child(2) {
min-width: 7em;
input {
max-width: 5em;
}
}
input~span {
margin-left: 0.5em;
color: var(--danger);
}
}
}
}

View file

@ -15,7 +15,8 @@
"jgconsole": ["./build/unpacked/org/jgrapes/webconsole/base/JGConsole"],
"jgwc": ["./build/unpacked/org/jgrapes/webconsole/provider/jgwcvuecomponents/jgwc-vue-components/jgwc-components"],
"l10nBundles": ["./src/org/jdrupes/vmoperator/vmconlet/browser/l10nBundles-stub"],
"vue": ["./build/unpacked/org/jgrapes/webconsole/provider/vue/vue/vue"]
"vue": ["./build/unpacked/org/jgrapes/webconsole/provider/vue/vue/vue"],
"chartjs": ["./build/unpacked/org/jgrapes/webconsole/provider/chartjs/chart.js/auto/auto"]
}
},
"include": ["src/**/*.ts"],