diff --git a/README.md b/README.md
index e95a4656..3c55774b 100644
--- a/README.md
+++ b/README.md
@@ -534,6 +534,7 @@ A rollback leaves the clone on a detached HEAD at the restored commit; the updat
- A VM that vanishes from its compute keeps its owners for `INSTANCE_OWNERSHIP_RETENTION_DAYS` (30) in case the same UUID comes back; superusers list and remove these records on the "Removed VMs" admin page.
- The log rotates (10 MB × 5) in `data/webvirtcloud.log`.
- A disk can be grown while its VM runs or is paused, not only when it is shut off.
+- A host's CPU graph shows the average since its previous point (normally 5 s) instead of a 1 s sample taken while a worker waits.
- A running VM without a guest agent shows the IPv4 address the host learned by ARP (the lookup always failed before).
- After a live migration the VM's NVRAM file is deleted on the source, when it is in libvirt's `/var/lib/libvirt/qemu/nvram` and the destination does not share that directory (a VM created there later with the same name would start with those UEFI variables).
- gstfsd finds the guest's operating system the way libguestfs inspects it, so a root on LVM works, and it always answers (an error when it finds no or several operating systems). Update `/usr/local/bin/gstfsd` on every compute (see [Adding a Compute Node](#adding-a-compute-node), step 3).
diff --git a/computes/templates/overview.html b/computes/templates/overview.html
index ecadbce4..c7deca72 100644
--- a/computes/templates/overview.html
+++ b/computes/templates/overview.html
@@ -120,6 +120,9 @@
", page, re.S) if "pollWhileVisible(" in s)
+ js = """
+ const vm = require("vm");
+ const charts = [], requests = [];
+ let poll;
+ const page = {
+ document: {getElementById: () => ({getContext: () => ({})})},
+ // Chart.js adds the lists a config leaves out
+ Chart: function (ctx, config) {
+ this.data = Object.assign({labels: []}, config.data);
+ this.data.datasets.forEach((set) => { set.data = set.data || []; });
+ this.options = config.options;
+ this.update = () => {};
+ charts.push(this);
+ },
+ $: {getJSON: (url, params) => { requests.push(params); return {}; }},
+ pollWhileVisible: (interval, request, onData) => { poll = {request, onData}; },
+ };
+ vm.runInNewContext(SCRIPT, vm.createContext(page));
+ const answer = (cpu, sample) => ({cpudata: cpu, window: 5.1, cpu_sample: sample, timeline: "t",
+ memdata: {total: 1048576, usage: 0}});
+ poll.request();
+ poll.onData(answer(3, "token-1"));
+ poll.request();
+ poll.onData({cpudata: null, memdata: {total: null, usage: null}, timeline: "t"});
+ poll.request();
+ const label = charts[0].options.tooltips.callbacks.label({datasetIndex: 0, index: 0, yLabel: 3}, charts[0].data);
+ console.log(JSON.stringify({requests, label, cpu: charts[0].data.datasets[0].data,
+ mem: charts[1].data.datasets[0].data, memMax: charts[1].options.scales.yAxes[0].ticks.max}));
+ """.replace("SCRIPT", json.dumps(script))
+ out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30)
+ self.assertEqual(out.returncode, 0, out.stderr)
+ result = json.loads(out.stdout)
+ self.assertEqual(result["requests"], [{}, {"cpu_sample": "token-1"}, {}])
+ self.assertEqual(result["cpu"], [3, None])
+ # a host that could not be read leaves a gap and keeps the memory axis
+ self.assertEqual((result["mem"], result["memMax"]), ([0, None], 1))
+ self.assertTrue(result["label"].endswith(": 3 % (average over 5.1 s)"), result["label"])
diff --git a/computes/views.py b/computes/views.py
index 4bd6801f..00dade42 100644
--- a/computes/views.py
+++ b/computes/views.py
@@ -1,6 +1,7 @@
import json
from admin.decorators import superuser_only
+from django.core import signing
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -20,6 +21,11 @@ from computes.models import Compute
from . import utils
+# The host CPU graph sends back the CPU sample of its previous answer; usage
+# is taken since then when it is this recent, otherwise over one second.
+CPU_SAMPLE_MAX_AGE = 15
+CPU_SAMPLE_SIGNER = signing.TimestampSigner(salt="computes.cpu-sample")
+
@superuser_only
def computes(request):
@@ -130,7 +136,7 @@ def compute_graph(request, compute_id):
:return:
"""
comp_mgr = ComputeManager(compute_id)
- data = comp_mgr.compute_graph()
+ data = comp_mgr.compute_graph(request.GET.get("cpu_sample"))
return HttpResponse(data, content_type="application/json")
@@ -219,7 +225,21 @@ class ComputeManager:
return json.dumps(data)
- def compute_graph(self):
+ def _cpu_sample(self, token):
+ """The (idle, total, time) sample in a token of this compute's graph,
+ or None when there is none, it is altered or too old."""
+ if not token:
+ return None
+ try:
+ data = CPU_SAMPLE_SIGNER.unsign_object(token, max_age=CPU_SAMPLE_MAX_AGE)
+ except signing.BadSignature:
+ return None
+ if data.get("compute") != self.compute.id:
+ return None
+ return data["idle"], data["total"], data["time"]
+
+ def compute_graph(self, cpu_sample=None):
+ result = {"cpudata": None, "memdata": {"total": None, "usage": None}, "timeline": timezone.now().strftime("%H:%M:%S")}
try:
conn = wvmHostDetails(
self.compute.hostname,
@@ -227,19 +247,19 @@ class ComputeManager:
self.compute.password,
self.compute.type,
)
- current_time = timezone.now().strftime("%H:%M:%S")
- cpu_usage = conn.get_cpu_usage()
- mem_usage = conn.get_memory_usage()
+ cpu = conn.get_cpu_usage(previous=self._cpu_sample(cpu_sample))
+ idle, total, taken = cpu["sample"]
+ result = {
+ "cpudata": cpu["usage"],
+ "memdata": conn.get_memory_usage(),
+ "timeline": timezone.now().strftime("%H:%M:%S"),
+ "window": round(cpu["window"], 1),
+ "cpu_sample": CPU_SAMPLE_SIGNER.sign_object(
+ {"compute": self.compute.id, "idle": idle, "total": total, "time": taken}
+ ),
+ }
conn.close()
except libvirtError:
- cpu_usage = {"usage": 0}
- mem_usage = {"usage": 0}
- current_time = 0
+ pass
- return json.dumps(
- {
- "cpudata": cpu_usage["usage"],
- "memdata": mem_usage,
- "timeline": current_time,
- }
- )
+ return json.dumps(result)
diff --git a/vrtManager/hostdetails.py b/vrtManager/hostdetails.py
index 31aca86d..cc155364 100644
--- a/vrtManager/hostdetails.py
+++ b/vrtManager/hostdetails.py
@@ -1,9 +1,26 @@
import time
+from libvirt import VIR_NODE_CPU_STATS_ALL_CPUS
+
from vrtManager.connection import wvmConnect
from vrtManager.util import get_xml_path
+# the times of virNodeGetCPUStats that add up to the elapsed CPU time; guest is
+# already part of user and utilization is a percentage
+CPU_TIMES = ("kernel", "user", "idle", "iowait")
+
+
+def cpu_percent(before, after):
+ """Busy share in percent between two (idle, total) samples, or None when
+ the second does not follow the first."""
+ idle = after[0] - before[0]
+ total = after[1] - before[1]
+ if total <= 0 or not 0 <= idle <= total:
+ return None
+ return round(100 * (total - idle) / total, 1)
+
+
def cpu_version(doc):
for info in doc.xpath("/sysinfo/processor/entry"):
elem = info.xpath("@name")[0]
@@ -27,32 +44,34 @@ class wvmHostDetails(wvmConnect):
else:
return {"total": None, "usage": None, "percent": None}
- def get_cpu_usage(self, diff=True):
- """
- Function return cpu usage on node.
- """
- prev_idle = 0
- prev_total = 0
- cpu = self.wvm.getCPUStats(-1, 0)
- if not isinstance(cpu, dict):
- return {"usage": None}
+ def _cpu_times(self):
+ """(idle, total) nanoseconds of all host CPUs, from one reading."""
+ stats = self.wvm.getCPUStats(VIR_NODE_CPU_STATS_ALL_CPUS, 0)
+ return stats["idle"], sum(stats.get(name, 0) for name in CPU_TIMES)
- for num in range(2):
- idle = self.wvm.getCPUStats(-1, 0)["idle"]
- total = sum(self.wvm.getCPUStats(-1, 0).values())
- diff_idle = idle - prev_idle
- diff_total = total - prev_total
- diff_usage = (1000 * (diff_total - diff_idle) / diff_total + 5) / 10
- if not diff:
- return {"usage": diff_usage}
- prev_total = total
- prev_idle = idle
- if num == 0:
- time.sleep(1)
- else:
- diff_usage = max(diff_usage, 0)
-
- return {"usage": diff_usage}
+ def get_cpu_usage(self, diff=True, previous=None):
+ """
+ Busy share of all host CPUs in percent. diff=False: since the host
+ started. Otherwise since previous, an (idle, total, time) sample this
+ method returned, or over one second when there is none or it does not
+ lead to this one (the host restarted). Also returns the closing sample
+ and the seconds the usage covers.
+ """
+ times = self._cpu_times()
+ if not diff:
+ return {"usage": cpu_percent((0, 0), times)}
+ now = time.time()
+ usage = None
+ if previous is not None:
+ usage = cpu_percent(previous[:2], times)
+ start = previous[2]
+ if usage is None:
+ before, start = times, now
+ time.sleep(1)
+ times = self._cpu_times()
+ now = time.time()
+ usage = cpu_percent(before, times)
+ return {"usage": usage, "sample": (*times, now), "window": now - start}
def get_node_info(self):
"""