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

build: Chart.js 4.5.1

This commit is contained in:
catborise 2026-10-17 12:54:51 +03:00
parent 53d900fd57
commit f5ec757d1a
9 changed files with 164 additions and 404 deletions

View file

@ -99,7 +99,7 @@
{% bs_icon 'arrow-right' %}
{% trans "CPU Utilization" %}
</h5>
<canvas id="cpuChart" width="735" height="160"></canvas>
<div class="chart"><canvas id="cpuChart" role="img" aria-label="{% trans "CPU Utilization" %}"></canvas></div>
</div>
</div>
@ -108,7 +108,7 @@
<h5 class="card-title ">
{% bs_icon 'arrow-right'%} {% trans "RAM Utilization" %}
</h5>
<canvas id="memChart" width="735" height="160"></canvas>
<div class="chart"><canvas id="memChart" role="img" aria-label="{% trans "RAM Utilization" %}"></canvas></div>
</div>
</div>
</div>
@ -116,114 +116,22 @@
</div>
{% endblock %}
{% block script %}
<script src="{% static "js/Chart.bundle.min.js" %}"></script>
<script src="{% static "js/chart.umd.min.js" %}"></script>
<script src="{% static "js/charts.js" %}"></script>
<script src="{% static "js/poll.js" %}"></script>
<script>
{% trans "average over" as average_over %}
// the seconds each CPU point averages
var cpuWindows = [];
var cpu_ctx = document.getElementById("cpuChart").getContext("2d");
var cpuChart = new Chart(cpu_ctx, {
type: 'line',
data: {
datasets : [{
label: 'Usage',
backgroundColor: "rgba(241,72,70,0.5)",
pointRadius: 2,
}]
var cpuChart = lineChart(document.getElementById("cpuChart"), "%", ["Usage"], {
max: 100,
tooltip: function (context, text) {
var span = cpuWindows[context.dataIndex];
return span ? text + ' ({{ average_over|escapejs }} ' + span + ' s)' : text;
},
options: {
responsive: true,
legend: {
display: false
},
scales: {
xAxes:[{
offset: false,
ticks: {
beginAtZero: false,
autoSkip: true,
maxTicksLimit: 10,
maxRotation: 0,
minRotation: 0,
stepSize: 10,
},
}],
yAxes: [{
ticks: {
max: 100,
min: 0,
stepSize: 20,
callback: function(value, index, values) {
return value + ' %';
}
},
}],
},
tooltips: {
callbacks: {
label: function (tooltipItem, chart) {
var label = chart.datasets[tooltipItem.datasetIndex].label || '';
if (label) {
label += ': ';
}
label += tooltipItem.yLabel + ' %';
var span = cpuWindows[tooltipItem.index];
return span ? label + ' ({{ average_over|escapejs }} ' + span + ' s)' : label;
}
}
}
}
});
var mem_ctx = document.getElementById("memChart").getContext("2d");
var memChart = new Chart(mem_ctx, {
type: 'line',
data: {
datasets: [{
pointRadius: 2,
}]
},
options: {
responsive: true,
legend: {
display: false
},
scales: {
xAxes:[{
offset: false,
ticks: {
beginAtZero: false,
autoSkip: true,
maxTicksLimit: 10,
maxRotation: 0,
minRotation: 0
}
}],
yAxes: [{
ticks:{
suggestedMin: 0,
suggestedMax: 100,
callback: function(value, index, values) {
return value + ' MB';
}
},
}],
},
tooltips: {
callbacks: {
label: function (tooltipItem, chart) {
var label = chart.datasets[tooltipItem.datasetIndex].label || '';
if (label) {
label += ': ';
}
return label += tooltipItem.yLabel + ' MB';
}
}
}
}
});
var memChart = lineChart(document.getElementById("memChart"), "MB", ["Usage"]);
if (Boolean({{ status }}) === true) {
// the CPU sample of the previous answer: usage is taken since then
var cpuSample = null;
@ -231,32 +139,18 @@
return $.getJSON('{% url 'compute_graph' compute_id %}', cpuSample ? {cpu_sample: cpuSample} : {});
}, function graph_usage(data) {
cpuSample = data.cpu_sample || null;
cpuChart.data.labels.push(data.timeline);
memChart.data.labels.push(data.timeline);
cpuChart.data.datasets[0].data.push(data.cpudata);
cpuWindows.push(data.window);
if (cpuChart.data.datasets[0].data.length > 10){
cpuChart.data.labels.shift();
cpuChart.data.datasets[0].data.shift();
if (cpuWindows.length > CHART_POINTS) {
cpuWindows.shift();
}
addPoint(cpuChart, data.timeline, [data.cpudata]);
// a host that could not be read gives no memory value: a gap, same axis
if (data.memdata.total) {
memChart.options.scales.yAxes[0].ticks.max = parseInt(data.memdata.total / 1048576);
memChart.options.scales.yAxes[0].ticks.stepSize = parseInt(data.memdata.total / (1048576 * 5));
memChart.data.datasets[0].data.push(parseInt(data.memdata.usage / 1048576));
memChart.options.scales.y.max = Math.round(data.memdata.total / 1048576);
addPoint(memChart, data.timeline, [Math.round(data.memdata.usage / 1048576)]);
} else {
memChart.data.datasets[0].data.push(null);
addPoint(memChart, data.timeline, [null]);
}
if (memChart.data.datasets[0].data.length > 10){
memChart.data.labels.shift();
memChart.data.datasets[0].data.shift();
}
cpuChart.update();
memChart.update();
});
}
</script>

View file

@ -9,6 +9,7 @@ import unittest
from unittest import TestCase as UnitTestCase
from unittest.mock import MagicMock, PropertyMock, patch
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core import signing
from django.test import TestCase
@ -20,6 +21,9 @@ from computes.models import Compute
from computes.views import CPU_SAMPLE_MAX_AGE, CPU_SAMPLE_SIGNER
from vrtManager.hostdetails import cpu_percent, wvmHostDetails
# the page script runs after the shared chart helpers
CHARTS_JS = settings.BASE_DIR / "static" / "js" / "charts.js"
def host(*readings):
"""A wvmHostDetails whose getCPUStats returns readings, (idle, busy) each."""
@ -133,19 +137,22 @@ class GraphScriptTestCase(TestCase):
const charts = [], requests = [];
let poll;
const page = {
document: {getElementById: () => ({getContext: () => ({})})},
document: {body: {}, getElementById: () => ({})},
// Chart.js adds the lists a config leaves out
Chart: function (ctx, config) {
getComputedStyle: () => ({getPropertyValue: () => ""}),
Chart: Object.assign(function (canvas, 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);
},
}, {defaults: {}}),
$: {getJSON: (url, params) => { requests.push(params); return {}; }},
pollWhileVisible: (interval, request, onData) => { poll = {request, onData}; },
};
vm.runInNewContext(SCRIPT, vm.createContext(page));
const context = vm.createContext(page);
vm.runInContext(CHARTS, context);
vm.runInContext(SCRIPT, context);
const answer = (cpu, sample) => ({cpudata: cpu, window: 5.1, cpu_sample: sample, timeline: "t",
memdata: {total: 1048576, usage: 0}});
poll.request();
@ -153,10 +160,10 @@ class GraphScriptTestCase(TestCase):
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);
const label = charts[0].options.plugins.tooltip.callbacks.label({dataset: {label: "Usage"}, formattedValue: "3", dataIndex: 0});
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))
mem: charts[1].data.datasets[0].data, memMax: charts[1].options.scales.y.max}));
""".replace("SCRIPT", json.dumps(script)).replace("CHARTS", json.dumps(CHARTS_JS.read_text()))
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)

View file

@ -380,7 +380,8 @@
});
});
</script>
<script src="{% static 'js/Chart.bundle.min.js' %}"></script>
<script src="{% static 'js/chart.umd.min.js' %}"></script>
<script src="{% static 'js/charts.js' %}"></script>
<script src="{% static 'js/poll.js' %}"></script>
<script>
{% trans "average over" as average_over %}
@ -399,222 +400,21 @@
stats_poll.start();
return;
}
var cpu_ctx = $("#cpuChart").get(0).getContext("2d");
var cpuChart = new Chart(cpu_ctx, {
type: 'line',
data: {
datasets : [{
backgroundColor: "rgba(44,127,184,0.5)",
label: "Usage"
}]
var cpuChart = lineChart(document.getElementById("cpuChart"), "%", ["Usage"], {
max: 100,
tooltip: function (context, text) {
var span = cpuWindows[context.dataIndex];
return span ? text + ' ({{ average_over|escapejs }} ' + span + ' s)' : text;
},
options: {
responsive: true,
legend: {
display: false
},
scales: {
xAxes:[{
offset: false,
ticks: {
beginAtZero: false,
autoSkip: true,
maxTicksLimit: 10,
maxRotation: 0,
minRotation: 0,
stepSize: 10,
},
}],
yAxes: [{
ticks: {
suggestedMax: 100,
suggestedMin: 0,
stepSize: 20,
callback: function(value, index, values) {
return value + ' %';
}
},
}],
},
tooltips: {
callbacks: {
label: function (tooltipItem, chart) {
var label = chart.datasets[tooltipItem.datasetIndex].label || '';
if (label) {
label += ': ';
}
label += tooltipItem.yLabel + ' %';
var span = cpuWindows[tooltipItem.index];
return span ? label + ' ({{ average_over|escapejs }} ' + span + ' s)' : label;
}
}
}
}
});
var mem_ctx = $("#memChart").get(0).getContext("2d");
var memChart = new Chart(mem_ctx, {
type: 'line',
data: {
datasets : [{
label: "Usage"
}]
},
options: {
responsive: true,
legend: {
display: false
},
scales: {
xAxes:[{
offset: false,
ticks: {
beginAtZero: false,
autoSkip: true,
maxTicksLimit: 10,
maxRotation: 0,
minRotation: 0,
stepSize: 10,
},
}],
yAxes: [{
ticks: {
suggestedMax: 100,
suggestedMin: 0,
stepSize: 20,
callback: function(value, index, values) {
return value + ' MB';
}
},
}],
},
tooltips: {
callbacks: {
label: function (tooltipItem, chart) {
var label = chart.datasets[tooltipItem.datasetIndex].label || '';
if (label) {
label += '(RSS): ';
}
return label += tooltipItem.yLabel + ' MB';
}
}
}
}
});
var memChart = lineChart(document.getElementById("memChart"), "MB", ["Usage (RSS)"]);
var diskChart = {};
{% for disk in instance.disks %}
var disk_ctx_{{ disk.dev }} = $("#blk{{ disk.dev }}Chart").get(0).getContext("2d");
diskChart['{{ disk.dev }}'] = new Chart(disk_ctx_{{ disk.dev }}, {
type: 'line',
data: {
datasets : [{
backgroundColor: "rgba(127,205,187,0.5)",
label: "Read"
},
{
backgroundColor: "rgba(44,127,184,0.5)",
label: "Write"
}]
},
options: {
responsive: true,
legend: {
display: false
},
scales: {
xAxes:[{
offset: false,
ticks: {
beginAtZero: false,
autoSkip: true,
maxTicksLimit: 10,
maxRotation: 0,
minRotation: 0,
stepSize: 10,
},
}],
yAxes: [{
ticks: {
suggestedmax: 100,
suggestedMin: 0,
callback: function(value, index, values) {
return value + ' MB/s';
}
},
}],
},
tooltips: {
callbacks: {
label: function (tooltipItem, chart) {
var label = chart.datasets[tooltipItem.datasetIndex].label || '';
if (label) {
label += ': ';
}
return label += tooltipItem.yLabel + ' MB/s';
}
}
}
}
});
diskChart['{{ disk.dev|escapejs }}'] = lineChart(document.getElementById("blk{{ disk.dev|escapejs }}Chart"), "MB/s", ["Read", "Write"]);
{% endfor %}
var netChart = {};
{% for net in instance.networks %}
var net_ctx_{{ forloop.counter0 }} = $("#netEth{{ forloop.counter0 }}Chart").get(0).getContext("2d");
netChart[{{ forloop.counter0 }}] = new Chart(net_ctx_{{ forloop.counter0 }}, {
type: 'line',
data: {
datasets : [
{
backgroundColor: "rgba(127,205,187,0.5)",
label: "Inbound"
},
{
backgroundColor: "rgba(44,127,184,0.5)",
label: "Outbound"
}]
},
options: {
responsive: true,
legend: {
display: false
},
scales: {
xAxes:[{
offset: false,
ticks: {
beginAtZero: false,
autoSkip: true,
maxTicksLimit: 10,
maxRotation: 0,
minRotation: 0,
stepSize: 10,
},
}],
yAxes: [{
ticks: {
suggestedMax: 100,
suggestedMin: 0,
callback: function(value, index, values) {
return value + ' Mbps';
}
},
}],
},
tooltips: {
callbacks: {
label: function (tooltipItem, chart) {
var label = chart.datasets[tooltipItem.datasetIndex].label || '';
if (label) {
label += ': ';
}
return label += tooltipItem.yLabel + ' Mbps';
}
}
}
}
});
netChart[{{ forloop.counter0 }}] = lineChart(document.getElementById("netEth{{ forloop.counter0 }}Chart"), "Mbit/s", ["Inbound", "Outbound"]);
{% endfor %}
// the first point at once, then every 5 s like the host graph
@ -625,40 +425,23 @@
});
}, function graph_usage(data) {
statsSample = data.sample || null;
cpuChart.data.labels.push(data.timeline);
cpuChart.data.datasets[0].data.push(data.cpudata);
cpuWindows.push(data.window);
if (cpuChart.data.datasets[0].data.length > 10){
cpuChart.data.labels.shift();
cpuChart.data.datasets[0].data.shift();
if (cpuWindows.length > CHART_POINTS) {
cpuWindows.shift();
}
cpuChart.update();
memChart.data.labels.push(data.timeline);
memChart.options.scales.yAxes[0].ticks.max = parseInt(data.memdata.total / 1024);
memChart.options.scales.yAxes[0].ticks.stepSize = parseInt(data.memdata.total / (1024 * 5));
memChart.data.datasets[0].data.push(data.memdata.used / 1024);
if (memChart.data.datasets[0].data.length > 10){
memChart.data.labels.shift();
memChart.data.datasets[0].data.shift();
addPoint(cpuChart, data.timeline, [data.cpudata]);
// a VM whose memory could not be read gives total 0: a gap, same axis
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)]);
} else {
addPoint(memChart, data.timeline, [null]);
}
memChart.update();
// a disk or NIC added after the page loaded has no chart until a reload
function push(chart, values) {
if (!chart) {
return;
if (chart) {
addPoint(chart, data.timeline, values);
}
chart.data.labels.push(data.timeline);
chart.data.datasets[0].data.push(values[0]);
chart.data.datasets[1].data.push(values[1]);
if (chart.data.datasets[0].data.length > 10){
chart.data.labels.shift();
chart.data.datasets[0].data.shift();
chart.data.datasets[1].data.shift();
}
chart.update();
}
data.blkdata.forEach(function (disk) { push(diskChart[disk.dev], disk.data); });
data.netdata.forEach(function (nic) { push(netChart[nic.dev], nic.data); });

View file

@ -24,11 +24,7 @@
</h5>
</div>
<div class="card-body">
<div class="flot-chart">
<div class="flot-chart-content" id="flot-moving-line-chart">
<canvas id="cpuChart" width="735" height="160"></canvas>
</div>
</div>
<div class="chart"><canvas id="cpuChart" role="img" aria-label="{% trans "CPU Usage" %}"></canvas></div>
</div>
</div>
<div class="mb-1 card border-danger">
@ -38,11 +34,7 @@
</h5>
</div>
<div class="card-body">
<div class="flot-chart">
<div class="flot-chart-content" id="flot-moving-line-chart">
<canvas id="memChart" width="735" height="160"></canvas>
</div>
</div>
<div class="chart"><canvas id="memChart" role="img" aria-label="{% trans "Memory Usage" %}"></canvas></div>
</div>
</div>
{% for net in instance.networks %}
@ -53,11 +45,7 @@
</h5>
</div>
<div class="card-body">
<div class="flot-chart">
<div class="flot-chart-content" id="flot-moving-line-chart">
<canvas id="netEth{{ forloop.counter0 }}Chart" width="735" height="160"></canvas>
</div>
</div>
<div class="chart"><canvas id="netEth{{ forloop.counter0 }}Chart" role="img" aria-label="{% trans "Bandwidth Device" %}: eth{{ forloop.counter0 }}"></canvas></div>
</div>
</div>
{% endfor %}
@ -69,11 +57,7 @@
</h5>
</div>
<div class="card-body">
<div class="flot-chart">
<div class="flot-chart-content" id="flot-moving-line-chart">
<canvas id="blk{{ disk.dev }}Chart" width="735" height="160"></canvas>
</div>
</div>
<div class="chart"><canvas id="blk{{ disk.dev }}Chart" role="img" aria-label="{% trans "Disk I/O device" %}: {{ disk.dev }}"></canvas></div>
</div>
</div>
{% endfor %}

View file

@ -10,6 +10,7 @@ from unittest.mock import patch
import libvirt
from computes.models import Compute
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core import signing
from django.test import TestCase
@ -18,6 +19,9 @@ from lxml import html
from instances.models import Instance
from vrtManager.instance import USAGE_MAX_WINDOW
# the page script runs after the shared chart helpers
CHARTS_JS = settings.BASE_DIR / "static" / "js" / "charts.js"
SAMPLE = {"key": [7, 4, ["vda"], [["52:54:00:00:00:01", "vnet0"]]], "counters": [1, [[2, 3]], [[4, 5]], 100.0]}
@ -153,42 +157,51 @@ class StatsScriptTestCase(TestDriverVM, TestCase):
};
const page = {
$,
document: {querySelector: () => ({addEventListener: (name, handler) => { handlers[name] = handler; }})},
document: {body: {}, getElementById: () => ({}),
querySelector: () => ({addEventListener: (name, handler) => { handlers[name] = handler; }})},
// Chart.js adds the lists a config leaves out
Chart: function (ctx, config) {
getComputedStyle: () => ({getPropertyValue: () => ""}),
Chart: Object.assign(function (canvas, 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);
},
}, {defaults: {}}),
pollWhileVisible: (interval, request, onData, first) => { poll = {interval, first, request, onData}; return {}; },
};
vm.runInNewContext(SCRIPT, vm.createContext(page));
const context = vm.createContext(page);
vm.runInContext(CHARTS, context);
vm.runInContext(SCRIPT, context);
handlers["shown.bs.tab"]();
const answer = (cpu, sample) => ({cpudata: cpu, window: 9.9, sample: sample, timeline: "t",
memdata: {used: 0, total: 1024},
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]}]});
poll.request();
poll.onData(answer(3, "token-1"));
poll.request();
// a VM whose memory could not be read
poll.onData(Object.assign(answer(4, "token-2"), {memdata: {used: 0, total: 0}}));
fail = true;
poll.request();
fail = false;
poll.request();
const label = charts[0].options.tooltips.callbacks.label({datasetIndex: 0, index: 0, yLabel: 3}, charts[0].data);
const label = charts[0].options.plugins.tooltip.callbacks.label({dataset: {label: "Usage"}, formattedValue: "3", dataIndex: 0});
console.log(JSON.stringify({requests, label, every: [poll.interval, poll.first], cpu: charts[0].data.datasets[0].data,
disk: charts[2].data.datasets[1].data, net: charts[3].data.datasets[0].data}));
""".replace("SCRIPT", json.dumps(script))
disk: charts[2].data.datasets[1].data, net: charts[3].data.datasets[0].data,
mem: charts[1].data.datasets[0].data, memMax: charts[1].options.scales.y.max}));
""".replace("SCRIPT", json.dumps(script)).replace("CHARTS", json.dumps(CHARTS_JS.read_text()))
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)
# the first poll at once, then every 5 s
self.assertEqual(result["every"], [5000, 0])
# the sample goes with the next poll; a failed poll drops it
self.assertEqual(result["requests"], [{}, {"sample": "token-1"}, {"sample": "token-1"}, {}])
self.assertEqual(result["requests"], [{}, {"sample": "token-1"}, {"sample": "token-2"}, {}])
# a disk without a chart is skipped, the other charts still update
self.assertEqual((result["cpu"], result["disk"], result["net"]), ([3], [None], [2]))
self.assertEqual((result["cpu"], result["disk"], result["net"]), ([3, 4], [None, None], [2, 2]))
# memory that could not be read leaves a gap and keeps the axis
self.assertEqual((result["mem"], result["memMax"]), ([1, None], 1))
self.assertTrue(result["label"].endswith(": 3 % (average over 9.9 s)"), result["label"])

View file

@ -93,8 +93,10 @@ p {
margin-top: 20px;
}
.flot-chart {
max-height: 250px;
/* a live chart (static/js/charts.js) fills the width of its card */
.chart {
position: relative;
height: 160px;
}
.netIN {

File diff suppressed because one or more lines are too long

14
static/js/chart.umd.min.js vendored Normal file

File diff suppressed because one or more lines are too long

70
static/js/charts.js Normal file
View file

@ -0,0 +1,70 @@
// Live line charts of the host overview and the VM page (Chart.js 4): the
// last CHART_POINTS readings, a gap where a reading is missing, and the
// colours of the selected Bootstrap theme.
var CHART_POINTS = 10;
(function () {
var style = getComputedStyle(document.body);
Chart.defaults.color = style.getPropertyValue("--bs-body-color").trim();
Chart.defaults.borderColor = style.getPropertyValue("--bs-border-color").trim();
})();
// labels: one per line. options: max (the y axis top), tooltip(context, text)
// to add to a point's tooltip text.
function lineChart(canvas, unit, labels, options) {
var style = getComputedStyle(document.body);
var colours = ["--bs-primary-rgb", "--bs-info-rgb"].map(function (name) {
return style.getPropertyValue(name).trim();
});
options = options || {};
return new Chart(canvas, {
type: "line",
data: {
labels: [],
datasets: labels.map(function (label, i) {
return {
label: label,
data: [],
borderColor: "rgb(" + colours[i] + ")",
backgroundColor: "rgba(" + colours[i] + ", 0.3)",
fill: true,
pointRadius: 2,
};
}),
},
options: {
animation: false,
maintainAspectRatio: false,
scales: {
x: {ticks: {maxTicksLimit: CHART_POINTS, maxRotation: 0}},
y: {min: 0, max: options.max, ticks: {callback: function (value) { return value + " " + unit; }}},
},
plugins: {
legend: {display: labels.length > 1},
tooltip: {
callbacks: {
label: function (context) {
var text = context.dataset.label + ": " + context.formattedValue + " " + unit;
return options.tooltip ? options.tooltip(context, text) : text;
},
},
},
},
},
});
}
// values: one per line, null for a reading that is missing
function addPoint(chart, label, values) {
chart.data.labels.push(label);
chart.data.datasets.forEach(function (set, i) {
set.data.push(values[i] == null ? null : values[i]);
});
if (chart.data.labels.length > CHART_POINTS) {
chart.data.labels.shift();
chart.data.datasets.forEach(function (set) {
set.data.shift();
});
}
chart.update();
}