ogcp: add cache management page

Implement cache management through cache/list and cache/delete
API REST methods.
The view corresponds to the URL action/cache and contains three
main parts:
- Free cache available in the client's bubbles.
- Image selection form to request the deletion and view the
client with that image in cache.
- Cache details per client to see total, used and available cache.

The main usecase for the view is the removal of cache to make room
for new images in clients with slow connections. Checking clients
where available cache space is not enough to hold the new image and
then requesting removal of specific images.

The html template receives the following structures from the view:
storage_data[ip] = {'used': 223452345,
                    'total': 2345234523452}

client_images[ip] = [f'{image_name}.{image_checksum}', ...]

image_data[f'{image_name}.{image_checksum}'] = {
    clients: ['192.168.0.1', ...],
    size: 34534530850,
    name: image_name,
}
master
Alejandro Sirgo Rica 2024-05-16 12:38:28 +02:00
parent f896107375
commit 9cb3a952b4
4 changed files with 301 additions and 1 deletions

View File

@ -87,6 +87,15 @@ class SessionForm(FlaskForm):
ips = HiddenField()
os = RadioField(label=_l('Session'), choices=[])
class CacheImage(FlaskForm):
selected = BooleanField()
image_name = HiddenField()
clients = HiddenField()
class CacheForm(FlaskForm):
ips = HiddenField()
images = FieldList(FormField(CacheImage))
class ImageRestoreForm(FlaskForm):
ips = HiddenField()
partition = SelectField(label=_l('Partition'), choices=[])

View File

@ -0,0 +1,192 @@
{% extends 'commands.html' %}
{% import "bootstrap/wtf.html" as wtf %}
{% set sidebar_state = 'disabled' %}
{% set btn_back = true %}
{% block nav_client %} active{% endblock %}
{% block nav_client_cache %} active{% endblock %}
{% block content %}
{% set ip_list = form.ips.data.split(' ') %}
{% set ip_count = ip_list | length %}
<h1 class="m-5">
{{ _('Manage Cache') }}
</h1>
<p>{{ _('Check free cache space in the client\'s bubbles:') }}</p>
{{ macros.cmd_selected_clients(selected_clients) }}
<p>{{ _('Select the images to be deleted:') }}</p>
<form class="form-inline" method="POST" id="cacheForm">
<table class="table table-hover">
<thead class="thead-light">
<tr>
<th>{{ _('Image') }}</th>
<th>{{ _('Clients') }}</th>
</tr>
</thead>
<tbody data-target="cache-fieldset" id="cacheTable" class="text-left">
{{ form.hidden_tag() }}
{% for image in form.images %}
<tr data-toggle="fieldset-entry">
<td class="radio-container">
{{ image.selected(class_="form-control") }}
{{ image.image_name() }}
{{ image.clients() }}
<b>{{ image.image_name.data }} ({{ (images_data[image.selected.label.text]['size'] | int / 2**20)|round(3) }} MiB)</b>
</td>
<td>{{ ', '.join(images_data[image.selected.label.text]['clients']) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<button class="btn btn-danger" form="cacheForm">
{{ _('Delete') }}
</button>
</form>
<br>
<div class="card text-center">
<div class="card-header">
{{ _('Detailed cache usage') }}
</div>
<div class="card-body">
<label for="cacheSelect">Choose a client:</label>
<select id="cacheSelect" onchange="onClientSelected()">
{% for client_ip in ip_list %}
<option value="{{ client_ip }}">{{ client_ip }}</option>
{% endfor %}
</select>
<ul class="list-group list-group-horizontal">
<li class="list-group-item w-50">
<canvas id="cacheChart" class="mb-2"></canvas>
</li>
<li class="list-group-item w-50">
<p>{{ _('Images in cache:') }}</p>
<div id="cacheList"></div>
</li>
</ul>
<ul class="list-group list-group-horizontal">
<li class="list-group-item w-50">
{{ _('Disk size') }}
</li>
<li class="list-group-item w-50">
{{ _('used') }} (%)
</li>
<li class="list-group-item w-50">
{{ _('available') }} (%)
</li>
</ul>
<ul class="list-group list-group-horizontal">
<li id="totalCacheItem" class="list-group-item w-50"></li>
<li id="usedCacheItem" class="list-group-item w-50"></li>
<li id="freeCacheItem" class="list-group-item w-50"></li>
</ul>
</div>
</div>
<!-- jQuery -->
<script src="{{ url_for('static', filename='AdminLTE/plugins/jquery/jquery.min.js') }}"></script>
<!-- ChartJS -->
<script src="{{ url_for('static', filename='AdminLTE/plugins/chart.js/Chart.min.js') }}"></script>
<script>
var cacheChartConfig = {
type: 'doughnut',
data: {
labels: ['Used', 'Available'],
datasets: [
{
label: 'Disk usage',
data: [
0,
1,
],
backgroundColor: [
'rgb(255, 99, 132)',
'rgb(54, 162, 235)',
],
},
],
},
options: {
responsive: true,
plugins: {
legend: {
position: 'top',
},
title: {
display: true,
text: 'Chart.js Doughnut Chart'
},
},
},
};
var cacheChart = new Chart(
document.getElementById('cacheChart'),
cacheChartConfig,
);
var storageData = {{ storage_data|tojson|safe }};
var imageData = {{ images_data|tojson|safe }};
var clientImages = {{ client_images|tojson|safe }};
function onClientSelected() {
var selectElement = document.getElementById("cacheSelect");
var selectedOption = selectElement.options[selectElement.selectedIndex].text;
updateChart(selectedOption);
}
function toGiB(v, decimals) {
return (v / Math.pow(2, 30)).toFixed(decimals);
}
function updateChart(ip) {
var totalCache = toGiB(storageData[ip].total, 3);
var usedCache = toGiB(storageData[ip].used, 3);
var freeCache = toGiB(storageData[ip].total - storageData[ip].used, 3)
cacheChart.data.datasets[0].data = [
usedCache,
freeCache,
]
cacheChart.update();
var totalCacheItem = document.getElementById("totalCacheItem");
totalCacheItem.innerHTML = totalCache + " GiB";
var usedCacheItem = document.getElementById("usedCacheItem");
usedCacheItem.innerHTML = usedCache + " GiB (" + Math.round((usedCache / totalCache) * 100) + "%)";
var freeCacheItem = document.getElementById("freeCacheItem");
freeCacheItem.innerHTML = freeCache + " GiB (" + Math.round((freeCache / totalCache) * 100) + "%)";
var cacheList = document.getElementById("cacheList");
cacheList.innerHTML = "";
clientImages[ip].forEach(function(img) {
cacheList.innerHTML += imageData[img]["name"] + " (" + (imageData[img]["size"] / Math.pow(2, 20)).toFixed(3) + " MiB)<br>";
});
}
updateChart("{{ ip_list[0] }}");
// Update pill data
$('.badge-pill').each(function(index) {
for (var ip in storageData) {
if ($(this).html().includes(ip)) {
var totalCache = storageData[ip].total;
var usedCache = storageData[ip].used;
var freeCache = toGiB(totalCache - usedCache, 1)
$(this).html($(this).html() + '<br>free: ' + freeCache + ' GiB');
break;
}
}
});
</script>
{% endblock %}

View File

@ -28,6 +28,8 @@
form="scopesForm" formaction="{{ url_for('action_session') }}" formmethod="get">
<input class="btn btn-light dropdown-item{% block nav_client_details %}{% endblock %}" type="submit" value="{{ _('Client details') }}"
form="scopesForm" formaction="{{ url_for('action_client_info') }}" formmethod="get">
<input class="btn btn-light dropdown-item{% block nav_client_cache %}{% endblock %}" type="submit" value="{{ _('Manage cache') }}"
form="scopesForm" formaction="{{ url_for('action_client_cache') }}" formmethod="get">
</div>
</div>

View File

@ -13,7 +13,7 @@ from ogcp.forms.action_forms import (
SessionForm, ImageRestoreForm, ImageCreateForm, SoftwareForm, BootModeForm,
RoomForm, DeleteRoomForm, CenterForm, DeleteCenterForm, OgliveForm,
GenericForm, SelectClientForm, ImageUpdateForm, ImportClientsForm,
ServerForm, DeleteRepositoryForm, RepoForm, FolderForm
ServerForm, DeleteRepositoryForm, RepoForm, FolderForm, CacheForm
)
from flask_login import (
current_user, LoginManager,
@ -1033,6 +1033,103 @@ def action_session():
selected_clients=selected_clients,
scopes=scopes, os_groups=os_groups)
@app.route('/action/cache', methods=['GET', 'POST'])
@login_required
def action_client_cache():
form = CacheForm(request.form)
if request.method == 'POST':
ips = form.ips.data.split(' ')
server = get_server_from_clients(list(ips))
client_list = []
image_list = []
for entry in form.images.entries:
if not entry.selected.data:
continue
image_list.append(entry.image_name.data)
for client_ip in entry.clients.data.split(' '):
if not client_ip in client_list:
client_list.append(client_ip)
if not image_list:
flash(_(f'No selected images to delete'), category='error')
return redirect(url_for('commands'))
r = server.post('/cache/delete',
payload={'clients': client_list,
'images': image_list})
if r.status_code == requests.codes.ok:
flash(_('Cache delete request sent successfully'), category='info')
else:
flash(_(f'Invalid cache delete form'), category='error')
return redirect(url_for('commands'))
else:
ips = parse_elements(request.args.to_dict())
ips_list = list(ips)
if not validate_elements(ips):
return redirect(url_for('commands'))
server = get_server_from_clients(ips_list)
form.ips.data = ' '.join(ips_list)
r = server.get('/cache/list', payload={'clients': ips_list})
if not r:
return ogserver_down('commands')
if r.status_code != requests.codes.ok:
return ogserver_error('commands')
clients_info = r.json()['clients']
if not clients_info:
flash(_('ogServer returned an empty client list'), category='error')
return redirect(url_for('commands'))
storage_data = {}
images_data = {}
client_images = {}
for client_info in clients_info:
ip = client_info['ip']
cache_size = int(client_info['cache_size'])
used_cache = 0
for image_info in client_info['images']:
image_name = image_info['name']
checksum = image_info['checksum']
used_cache += int(image_info['size'])
img_identifier = f'{image_name}.{checksum}'
if ip in client_images:
client_images[ip].append(img_identifier)
else:
client_images[ip] = [img_identifier]
if img_identifier in images_data:
images_data[img_identifier]['clients'].append(ip)
else:
images_data[img_identifier] = {
'clients': [ip],
'size': int(image_info['size']),
'name': image_name,
}
storage_data[ip] = {'used': used_cache,
'total': cache_size}
for img_identifier in images_data:
image_data = images_data[img_identifier]
checkbox_entry = form.images.append_entry()
checkbox_entry.selected.label.text = img_identifier
checkbox_entry.image_name.data = image_data['name']
checkbox_entry.clients.data = ' '.join(image_data['clients'])
scopes, clients = get_scopes(set(ips))
selected_clients = list(get_selected_clients(scopes['scope']).items())
return render_template('actions/cache.html', form=form,
selected_clients=selected_clients,
scopes=scopes, images_data=images_data,
storage_data=storage_data,
client_images=client_images)
@app.route('/action/client/info', methods=['GET'])
@login_required
def action_client_info():