1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 11:46:49 +00:00

feat(instances): a VM's stats in one grid, its logs a switch away

The Stats tab stacked one large card per chart, so a VM with a few
devices made a long page, and its "Real Time" and "Logs" sub-tabs were
shown to every user although only a superuser may read the logs. The
owner chose the layout from rendered options: a state line, CPU and
memory side by side, one chart per NIC and disk below, each with its
latest reading next to its title, and a small Graphs/Logs switch for
superusers.

Codex reviewed the tab; this is fixed:

- Memory is shown in MiB (it was labelled MB), and its axis grows to
  the RSS when the RSS passes the balloon size instead of clipping it.
- The CPU reading says it is of the host's CPUs.
- A NIC is titled by its MAC, not an invented "eth0", and its readings
  are matched to its chart by the MAC: they were matched by position, so
  removing a NIC while the page was open moved the next one's readings
  into its chart.
- A VM that is not running says nothing is measured.
- The logs load whenever the Logs view is shown, by keyboard too; an
  empty answer says "None" and a failed one says so, instead of an
  empty table. The tab shows the latest 50 rows, with a link to all of
  the VM's rows on the Logs page.
- The VM's header shows memory in MiB; it said MB.
This commit is contained in:
catborise 2026-10-30 23:24:58 +03:00
parent b72e9b1442
commit 5e93920d78
9 changed files with 193 additions and 110 deletions

View file

@ -48,7 +48,7 @@
</div>
<div class="col-6 col-lg-2">
<dt class="fw-normal text-body-secondary">{% trans "Memory" %}</dt>
<dd class="mb-0">{{ instance.cur_memory }} {% trans "MB" %}</dd>
<dd class="mb-0">{{ instance.cur_memory }} MiB</dd>
</div>
<div class="col-6 col-lg-5">
<dt class="fw-normal text-body-secondary">{% trans "Disks" %}</dt>
@ -263,6 +263,7 @@
<script src="{% static 'js/poll.js' %}"></script>
<script>
{% trans "average over" as average_over %}
{% trans "In" as rx_label %}{% trans "Out" as tx_label %}{% trans "Read" as rd_label %}{% trans "Write" as wr_label %}
var stats_tab = document.querySelector('#graphics-tab');
var stats_poll = null;
// the seconds each CPU point averages
@ -270,6 +271,10 @@
// the usage sample of the previous answer: the next one averages since then
var statsSample = null;
// The charts are polled while the Stats tab is shown; they are made once.
// a reading for the line above a chart: one decimal, a dash where it is missing
function reading(value) {
return value == null ? "–" : String(Math.round(value * 10) / 10);
}
stats_tab.addEventListener('hidden.bs.tab', function (event) {
stats_poll.stop();
});
@ -285,14 +290,15 @@
return span ? text + ' ({{ average_over|escapejs }} ' + span + ' s)' : text;
},
});
var memChart = lineChart(document.getElementById("memChart"), "MB", ["Usage (RSS)"]);
var memChart = lineChart(document.getElementById("memChart"), "MiB", ["Usage (RSS)"]);
var diskChart = {};
{% for disk in instance.disks %}
diskChart['{{ disk.dev|escapejs }}'] = lineChart(document.getElementById("blk{{ disk.dev|escapejs }}Chart"), "MB/s", ["Read", "Write"]);
{% endfor %}
// two NICs with one MAC (a misconfiguration) share the first one's chart
var netChart = {};
{% for net in instance.networks %}
netChart[{{ forloop.counter0 }}] = lineChart(document.getElementById("netEth{{ forloop.counter0 }}Chart"), "Mbit/s", ["Inbound", "Outbound"]);
netChart['{{ net.mac|escapejs }}'] = netChart['{{ net.mac|escapejs }}'] || lineChart(document.getElementById("net{{ net.mac|escapejs }}Chart"), "Mbit/s", ["Inbound", "Outbound"]);
{% endfor %}
// the first point at once, then every 5 s like the host graph
@ -308,21 +314,33 @@
cpuWindows.shift();
}
addPoint(cpuChart, data.timeline, [data.cpudata]);
// a VM whose memory could not be read gives total 0: a gap, same axis
document.getElementById("cpuNow").textContent = reading(data.cpudata) + " %";
// a VM whose memory could not be read gives total 0: a gap, same axis;
// the RSS may pass the balloon size: then the axis grows to it
if (data.memdata.total) {
memChart.options.scales.y.max = Math.round(data.memdata.total / 1024);
addPoint(memChart, data.timeline, [Math.round(data.memdata.used / 1024)]);
var used = Math.round(data.memdata.used / 1024), total = Math.round(data.memdata.total / 1024);
memChart.options.scales.y.max = Math.max(total, used);
addPoint(memChart, data.timeline, [used]);
document.getElementById("memNow").textContent = used + " / " + total + " MiB";
} else {
addPoint(memChart, data.timeline, [null]);
document.getElementById("memNow").textContent = "";
}
// a disk or NIC added after the page loaded has no chart until a reload
function push(chart, values) {
// a disk or NIC added after the page loaded has no chart until a reload,
// and one removed keeps its last points
function push(chart, id, labels, unit, values) {
if (chart) {
addPoint(chart, data.timeline, values);
document.getElementById(id).textContent =
labels[0] + " " + reading(values[0]) + " · " + labels[1] + " " + reading(values[1]) + " " + unit;
}
}
data.blkdata.forEach(function (disk) { push(diskChart[disk.dev], disk.data); });
data.netdata.forEach(function (nic) { push(netChart[nic.dev], nic.data); });
data.blkdata.forEach(function (disk) {
push(diskChart[disk.dev], "blk" + disk.dev + "Now", ["{{ rd_label|escapejs }}", "{{ wr_label|escapejs }}"], "MB/s", disk.data);
});
data.netdata.forEach(function (nic) {
push(netChart[nic.mac], "net" + nic.mac + "Now", ["{{ rx_label|escapejs }}", "{{ tx_label|escapejs }}"], "Mbit/s", nic.data);
});
}, 0);
});
</script>

View file

@ -1,108 +1,113 @@
{% load i18n %}
{% load bootstrap_icons %}
<div class="tab-pane" id="graphics" role="tabpanel" >
<!-- Nav tabs -->
<div class="tab-pane" id="graphics" role="tabpanel">
<div class="card">
<div class="card-header bg-transparent pb-0">
<ul class="nav nav-underline small" role="tablist" aria-label="Instance graphs and logs menu">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#graphs" type="button" role="tab" aria-controls="graphs" aria-selected="true">
{% trans "Real Time" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#logs" type="button" role="tab" aria-controls="logs" aria-selected="false" onclick="update_logs_table();">
{% trans "Logs" %}
</button>
</li>
</ul>
</div>
<!-- Tab panes -->
<div class="tab-content card-body">
<div role="tabpanel" class="tab-pane active" id="graphs">
<div class="mb-3 card">
<div class="card-header">
<h5 class="card-title">{% bs_icon 'arrow-right' %}
{% trans "CPU Usage" %}
</h5>
</div>
<div class="card-body">
<div class="chart position-relative"><canvas id="cpuChart" role="img" aria-label="{% trans "CPU Usage" %}"></canvas></div>
</div>
<div class="card-body">
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
<p class="small text-body-secondary mb-0 me-auto">
{% if instance.status == 1 %}
{% trans "Updates every 5 seconds." %}
{% else %}
{% trans "The VM is not running: nothing is measured." %}
{% endif %}
</p>
{% if request.user.is_superuser %}
<div class="btn-group btn-group-sm" role="tablist" aria-label="{% trans "Stats view" %}">
<button class="btn btn-outline-secondary active" type="button" data-bs-toggle="tab" data-bs-target="#graphs" role="tab" aria-controls="graphs" aria-selected="true">{% trans "Graphs" %}</button>
<button class="btn btn-outline-secondary" id="logs-tab" type="button" data-bs-toggle="tab" data-bs-target="#logs" role="tab" aria-controls="logs" aria-selected="false">{% trans "Logs" %}</button>
</div>
<div class="mb-3 card">
<div class="card-header">
<h5 class="card-title">{% bs_icon 'arrow-right' %}
{% trans "Memory Usage" %}
</h5>
</div>
<div class="card-body">
<div class="chart position-relative"><canvas id="memChart" role="img" aria-label="{% trans "Memory Usage" %}"></canvas></div>
</div>
</div>
{% for net in instance.networks %}
<div class="mb-3 card">
<div class="card-header">
<h5 class="card-title">{% bs_icon 'arrow-right' %}
{% trans "Bandwidth Device" %}: eth{{ forloop.counter0 }}
</h5>
</div>
<div class="card-body">
<div class="chart position-relative"><canvas id="netEth{{ forloop.counter0 }}Chart" role="img" aria-label="{% trans "Bandwidth Device" %}: eth{{ forloop.counter0 }}"></canvas></div>
</div>
</div>
{% endfor %}
{% for disk in instance.disks %}
<div class="mb-3 card">
<div class="card-header">
<h5 class="card-title">{% bs_icon 'arrow-right' %}
{% trans "Disk I/O device" %}: {{ disk.dev }}
</h5>
</div>
<div class="card-body">
<div class="chart position-relative"><canvas id="blk{{ disk.dev }}Chart" role="img" aria-label="{% trans "Disk I/O device" %}: {{ disk.dev }}"></canvas></div>
</div>
</div>
{% endfor %}
<div class="clearfix"></div>
{% endif %}
</div>
<div role="tabpanel" class="tab-pane" id="logs">
<div class="table-responsive">
<table class="table table-striped align-middle" id="logs_table">
<thead>
<tr>
<th scope="col">{% trans "Date" %}</th>
<th scope="col">{% trans "User" %}</th>
<th scope="col">{% trans "Message" %}</th>
<th scope="col">{% trans "Result" %}</th>
</tr>
</thead>
<tbody class="searchable">
<tr>
<td colspan="4"><i>{% trans 'None' %}...</i></td>
</tr>
</tbody>
</table>
<div class="tab-content">
<div class="tab-pane active" id="graphs" role="tabpanel">
<div class="row g-3">
<section class="col-12 col-md-6">
<div class="d-flex flex-wrap align-items-baseline gap-2 mb-2">
<h2 class="h6 mb-0">{% trans "CPU" %}</h2>
<span><span id="cpuNow"></span> <span class="small text-body-secondary">{% trans "of the host" %}</span></span>
</div>
<div class="chart position-relative"><canvas id="cpuChart" role="img" aria-label="{% trans "CPU use, recent readings" %}"></canvas></div>
</section>
<section class="col-12 col-md-6">
<div class="d-flex flex-wrap align-items-baseline gap-2 mb-2">
<h2 class="h6 mb-0">{% trans "Memory" %}</h2>
<span id="memNow"></span>
</div>
<div class="chart position-relative"><canvas id="memChart" role="img" aria-label="{% trans "Memory use, recent readings" %}"></canvas></div>
</section>
</div>
<div class="border-top mt-3 pt-3">
<div class="row g-3">
{% for net in instance.networks %}
<section class="col-12 col-md-6 col-lg-4">
<h2 class="h6 mb-1">{% trans "Network" %} · {{ net.mac }}</h2>
<p class="small text-body-secondary mb-2" id="net{{ net.mac }}Now">&nbsp;</p>
<div class="chart position-relative"><canvas id="net{{ net.mac }}Chart" role="img" aria-label="{% blocktrans with mac=net.mac %}Network {{ mac }}, recent readings{% endblocktrans %}"></canvas></div>
</section>
{% endfor %}
{% for disk in instance.disks %}
<section class="col-12 col-md-6 col-lg-4">
<h2 class="h6 mb-1">{% trans "Disk" %} · {{ disk.dev }}</h2>
<p class="small text-body-secondary mb-2" id="blk{{ disk.dev }}Now">&nbsp;</p>
<div class="chart position-relative"><canvas id="blk{{ disk.dev }}Chart" role="img" aria-label="{% blocktrans with dev=disk.dev %}Disk {{ dev }}, recent readings{% endblocktrans %}"></canvas></div>
</section>
{% endfor %}
</div>
</div>
</div>
<div class="clearfix"></div>
{% if request.user.is_superuser %}
<div class="tab-pane" id="logs" role="tabpanel">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0" id="logs_table">
<thead>
<tr>
<th scope="col">{% trans "Date" %}</th>
<th scope="col">{% trans "User" %}</th>
<th scope="col">{% trans "Message" %}</th>
<th scope="col">{% trans "Result" %}</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<p class="small text-body-secondary mt-2 mb-0">
{% trans "The latest rows, newest first." %}
<a href="{% url 'admin:logs' %}?q={{ instance.uuid|urlencode }}">{% trans "All of this VM's logs" %}</a>
</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% if request.user.is_superuser %}
<script>
function update_logs_table() {
{% trans "None" as no_rows %}
{% trans "The logs could not be loaded." as logs_failed %}
// a click or the arrow keys: the rows of the moment each time Logs is shown
document.getElementById("logs-tab").addEventListener("shown.bs.tab", function () {
var tbody = $("#logs_table > tbody");
function note(text) {
tbody.empty().append($("<tr>").append($("<td colspan='4' class='text-body-secondary'>").text(text)));
}
$.getJSON("{% url 'vm_logs' instance.id %}", function(data) {
if (!data.length) {
note("{{ no_rows|escapejs }}");
return;
}
// Log messages contain user-entered names (snapshots, disks, clones);
// insert them as text, never as HTML.
var tbody = $("#logs_table > tbody").empty();
tbody.empty();
$.each(data, function(id, row) {
$("<tr>")
.append($("<td>").css("width", "150px").text(row['date']))
.append($("<td>").addClass("text-nowrap").text(row['date']))
.append($("<td>").text(row['user']))
.append($("<td>").text(row['message']))
.append($("<td>").text(row['result']))
.appendTo(tbody);
});
}).fail(function () {
note("{{ logs_failed|escapejs }}");
});
}
});
</script>
{% endif %}

View file

@ -40,7 +40,7 @@ class StatsViewTestCase(TestCase):
self.proxy.usage.return_value = {
"cpu": 12.345,
"disks": [{"dev": "vda", "rd": 2_000_000, "wr": None}],
"nics": [{"dev": 0, "rx": 8_000_000, "tx": 0}],
"nics": [{"mac": "52:54:00:00:00:01", "rx": 8_000_000, "tx": 0}],
"window": 9.96,
"sample": SAMPLE,
}
@ -58,7 +58,7 @@ class StatsViewTestCase(TestCase):
self.assertIsNone(self.previous())
self.assertEqual((data["cpudata"], data["window"]), (12.3, 10.0))
self.assertEqual(data["blkdata"], [{"dev": "vda", "data": [2.0, None]}])
self.assertEqual(data["netdata"], [{"dev": 0, "data": [8.0, 0.0]}])
self.assertEqual(data["netdata"], [{"mac": "52:54:00:00:00:01", "data": [8.0, 0.0]}])
self.stats(sample=data["sample"])
self.assertEqual(self.previous(), SAMPLE)
@ -178,7 +178,9 @@ class StatsScriptTestCase(TestDriverVM, TestCase):
memdata: {used: 1024, total: 1024},
// vdb was attached after the page loaded
blkdata: [{dev: "vdb", data: [9, 9]}, {dev: "vda", data: [1.5, null]}],
netdata: [{dev: 0, data: [2, 3]}]});
// a NIC is known by its MAC: one added later has no chart
netdata: [{mac: "52:54:00:00:00:99", data: [7, 7]},
{mac: "52:54:00:00:00:31", data: [2, 3]}]});
poll.request();
poll.onData(answer(3, "token-1"));
poll.request();

View file

@ -0,0 +1,47 @@
"""The Stats tab: a chart for the CPU, the memory and each NIC and disk, and
the VM's log rows for superusers only, whose log view is the only one."""
from types import SimpleNamespace
from django.template.loader import render_to_string
from django.test import RequestFactory, SimpleTestCase
from lxml import html
UUID = "abcdabcd-0000-0000-0000-000000000000"
def tab(status=1, superuser=True):
request = RequestFactory().get("/")
request.user = SimpleNamespace(is_superuser=superuser)
instance = SimpleNamespace(
id=1, uuid=UUID, status=status,
networks=[{"mac": "52:54:00:00:00:01"}], disks=[{"dev": "vda"}, {"dev": "vdb"}],
)
return html.fromstring(render_to_string("instances/stats_tab.html", {"instance": instance}, request))
class StatsTabTests(SimpleTestCase):
def test_every_device_has_a_chart_and_a_reading(self):
doc = tab()
for name in ("cpu", "mem", "net52:54:00:00:00:01", "blkvda", "blkvdb"):
with self.subTest(name=name):
self.assertTrue(doc.xpath(f"//canvas[@id='{name}Chart']"))
self.assertTrue(doc.xpath(f"//*[@id='{name}Now']"))
self.assertIn("52:54:00:00:00:01", doc.text_content())
def test_only_a_superuser_gets_the_logs(self):
for superuser in (True, False):
with self.subTest(superuser=superuser):
doc = tab(superuser=superuser)
self.assertEqual(bool(doc.xpath("//button[@data-bs-target='#logs']")), superuser)
self.assertEqual(bool(doc.xpath("//table[@id='logs_table']")), superuser)
def test_the_logs_link_to_all_of_the_vm_s_rows(self):
self.assertEqual(tab().xpath("//a[text()=\"All of this VM's logs\"]/@href"), [f"/admin/logs/?q={UUID}"])
def test_a_vm_that_is_not_running_says_nothing_is_measured(self):
for status, measured in ((1, True), (3, False), (5, False)):
with self.subTest(status=status):
text = tab(status=status).text_content()
self.assertEqual("Updates every 5 seconds." in text, measured)
self.assertEqual("nothing is measured" in text, not measured)

View file

@ -431,7 +431,7 @@ def stats(request, pk):
"memdata": mem_usage,
# MB/s and Mbit/s
"blkdata": [{"dev": disk["dev"], "data": [mega(disk["rd"]), mega(disk["wr"])]} for disk in usage["disks"]],
"netdata": [{"dev": nic["dev"], "data": [mega(nic["rx"]), mega(nic["tx"])]} for nic in usage["nics"]],
"netdata": [{"mac": nic["mac"], "data": [mega(nic["rx"]), mega(nic["tx"])]} for nic in usage["nics"]],
"timeline": time.strftime("%H:%M:%S"),
}
if "sample" in usage:

View file

@ -5,6 +5,7 @@ from django.urls import reverse
from computes.models import Compute
from instances.models import Instance
from logs.audit import write
from logs.views import VM_LOG_ROWS
UUID = "12345678-1234-1234-1234-123456789012"
@ -25,6 +26,13 @@ class VMLogsTestCase(TestCase):
self.assertEqual(response.status_code, 200)
return [(row["message"], row["result"]) for row in response.json()]
def test_only_the_latest_rows(self):
for n in range(VM_LOG_ROWS + 1):
write("admin", "c1", "test-vm", f"VM test-vm: row {n}", True, uuid=UUID)
shown = self.shown()
self.assertEqual(len(shown), VM_LOG_ROWS)
self.assertEqual(shown[0][0], f"VM test-vm: row {VM_LOG_ROWS}")
def test_rows_of_the_vm_with_their_result(self):
write("admin", "c1", "test-vm", "VM test-vm: power on", True, uuid=UUID)
write("admin", "c1", "test-vm", "VM test-vm: power off: not running", False, uuid=UUID)

View file

@ -6,6 +6,9 @@ from admin.decorators import superuser_only
from instances.models import Instance
from logs.models import Logs
# the rows of a VM on its Stats tab; the Logs page has them all
VM_LOG_ROWS = 50
@superuser_only
def vm_logs(request, pk):
@ -13,7 +16,7 @@ def vm_logs(request, pk):
vm = get_object_or_404(Instance, pk=pk)
results = {None: _("Unknown"), True: _("Done"), False: _("Failed")}
logs = []
for log in Logs.objects.filter(instance_uuid=vm.uuid).order_by("-date", "-id"):
for log in Logs.objects.filter(instance_uuid=vm.uuid).order_by("-date", "-id")[:VM_LOG_ROWS]:
logs.append({
"user": log.user,
"host": log.host,

View file

@ -1378,7 +1378,7 @@ class wvmInstance(wvmConnect):
and target.get("dev")
):
disks.append(target.get("dev")) # blockStats takes the target name
# all NICs, in the order of the page's charts; one without a target is not measured
# all NICs, known by their MAC; one without a target is not measured
nics = []
for nic in tree.findall("devices/interface"):
mac, target = nic.find("mac"), nic.find("target")
@ -1391,7 +1391,7 @@ class wvmInstance(wvmConnect):
return {
"cpu": 0,
"disks": [{"dev": dev, "rd": 0, "wr": 0} for dev in disks],
"nics": [{"dev": i, "rx": 0, "tx": 0} for i in range(len(nics))],
"nics": [{"mac": mac, "rx": 0, "tx": 0} for mac, target in nics],
}
key = [self.instance.ID(), self.host_info[2], disks, nics]
@ -1418,8 +1418,8 @@ class wvmInstance(wvmConnect):
for dev, old, new in zip(disks, before[1], after[1])
],
"nics": [
{"dev": i, "rx": _rate(old[0], new[0], seconds, 8), "tx": _rate(old[1], new[1], seconds, 8)}
for i, (old, new) in enumerate(zip(before[2], after[2]))
{"mac": mac, "rx": _rate(old[0], new[0], seconds, 8), "tx": _rate(old[1], new[1], seconds, 8)}
for (mac, target), old, new in zip(nics, before[2], after[2])
],
"window": seconds,
}
@ -1436,7 +1436,7 @@ class wvmInstance(wvmConnect):
usage = rates(before, counters) or {
"cpu": None,
"disks": [{"dev": dev, "rd": None, "wr": None} for dev in disks],
"nics": [{"dev": i, "rx": None, "tx": None} for i in range(len(nics))],
"nics": [{"mac": mac, "rx": None, "tx": None} for mac, target in nics],
"window": None,
}
usage["sample"] = {"key": key, "counters": counters}

View file

@ -86,8 +86,8 @@ class UsageTestCase(unittest.TestCase):
self.assertEqual(
result["disks"], [{"dev": "vda", "rd": 1000, "wr": 3000}, {"dev": "vdb", "rd": 2000, "wr": 6000}]
)
# bits; the NIC without a target is not measured and keeps its place
self.assertEqual(result["nics"], [{"dev": 0, "rx": None, "tx": None}, {"dev": 1, "rx": 800, "tx": 400}])
# bits; the NIC without a target is not measured
self.assertEqual(result["nics"], [{"mac": "52:54:00:00:00:01", "rx": None, "tx": None}, {"mac": "52:54:00:00:00:02", "rx": 800, "tx": 400}])
self.assertEqual(result["sample"]["counters"][3], 101.0)
def test_since_the_previous_sample_without_waiting(self):
@ -98,7 +98,7 @@ class UsageTestCase(unittest.TestCase):
self.time.sleep.assert_not_called()
self.assertEqual((result["cpu"], result["window"]), (12.5, 9.5))
self.assertEqual(result["disks"][1], {"dev": "vdb", "rd": 2000, "wr": 6000}) # per second
self.assertEqual(result["nics"][1], {"dev": 1, "rx": 800, "tx": 400})
self.assertEqual(result["nics"][1], {"mac": "52:54:00:00:00:02", "rx": 800, "tx": 400})
self.assertEqual(result["sample"]["counters"][3], 110.5)
def test_a_sample_of_another_run_host_or_devices_is_not_used(self):
@ -169,7 +169,7 @@ class UsageTestCase(unittest.TestCase):
{
"cpu": 0,
"disks": [{"dev": "vda", "rd": 0, "wr": 0}, {"dev": "vdb", "rd": 0, "wr": 0}],
"nics": [{"dev": 0, "rx": 0, "tx": 0}, {"dev": 1, "rx": 0, "tx": 0}],
"nics": [{"mac": "52:54:00:00:00:01", "rx": 0, "tx": 0}, {"mac": "52:54:00:00:00:02", "rx": 0, "tx": 0}],
},
)