Add a resource availability calendar for hosts

This adds a calendar view where users can see a timeline of resources,
showing when each resource is reserved.

Implements: blueprint calendar-view
Change-Id: I4f2649d6c9538037dff4747ef4a8210da3666354
This commit is contained in:
Mark Powers 2021-08-20 21:29:13 +00:00 committed by Pierre Riteau
parent eda400f1ec
commit d6398f5e7a
12 changed files with 425 additions and 1 deletions

View File

@ -10,9 +10,13 @@
# License for the specific language governing permissions and limitations
# under the License.
from datetime import datetime
from itertools import chain
import json
import logging
from pytz import UTC
from blazar_dashboard import conf
from horizon import exceptions
from horizon.utils.memoized import memoized
from openstack_dashboard.api import base
@ -57,6 +61,11 @@ class Host(base.APIDictWrapper):
return excaps
class Allocation(base.APIDictWrapper):
_attrs = ['resource_id', 'reservations']
@memoized
def blazarclient(request):
try:
@ -129,3 +138,62 @@ def host_update(request, host_id, values):
def host_delete(request, host_id):
"""Delete a host."""
blazarclient(request).host.delete(host_id)
def host_allocations_list(request):
"""List allocations for all hosts."""
request_manager = blazarclient(request).host.request_manager
resp, body = request_manager.get('/os-hosts/allocations')
allocations = body['allocations']
return [Allocation(a) for a in allocations]
def reservation_calendar(request):
"""Return a list of all scheduled leases."""
def compute_host2dict(h):
dictionary = dict(
hypervisor_hostname=h.hypervisor_hostname, vcpus=h.vcpus,
memory_mb=h.memory_mb, local_gb=h.local_gb, cpu_info=h.cpu_info,
hypervisor_type=h.hypervisor_type,)
# Ensure config attribute is copied over
calendar_attribute = conf.host_reservation.get('calendar_attribute')
dictionary[calendar_attribute] = (
h[calendar_attribute]
)
return dictionary
# NOTE: This filters by reservable hosts
hosts_by_id = {h.id: h for h in host_list(request) if h.reservable}
def host_reservation_dict(reservation, resource_id):
host_reservation = dict(
start_date=_parse_api_datestr(reservation['start_date']),
end_date=_parse_api_datestr(reservation['end_date']),
reservation_id=reservation['id'],
)
calendar_attribute = conf.host_reservation.get('calendar_attribute')
host_reservation[calendar_attribute] = (
hosts_by_id[resource_id][calendar_attribute]
)
return {k: v for k, v in host_reservation.items() if v is not None}
host_reservations = [
[host_reservation_dict(r, alloc.resource_id)
for r in alloc.reservations
if alloc.resource_id in hosts_by_id]
for alloc in host_allocations_list(request)]
compute_hosts = [compute_host2dict(h) for h in hosts_by_id.values()]
return compute_hosts, list(chain(*host_reservations))
def _parse_api_datestr(datestr):
if datestr is None:
return datestr
dateobj = datetime.strptime(datestr, "%Y-%m-%dT%H:%M:%S.%f")
return dateobj.replace(tzinfo=UTC)

23
blazar_dashboard/conf.py Normal file
View File

@ -0,0 +1,23 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from django.conf import settings
host_reservation = (
getattr(settings, 'OPENSTACK_BLAZAR_HOST_RESERVATION', {
'enabled': True,
'calendar_attribute': 'hypervisor_hostname',
}))
floatingip_reservation = (
getattr(settings, 'OPENSTACK_BLAZAR_FLOATINGIP_RESERVATION', {
'enabled': False, }))

View File

@ -24,6 +24,7 @@ from horizon.utils import filters
import pytz
from blazar_dashboard import api
from blazar_dashboard import conf
class CreateLease(tables.LinkAction):
@ -47,6 +48,14 @@ class UpdateLease(tables.LinkAction):
return False
class ViewHostReservationCalendar(tables.LinkAction):
name = "calendar"
verbose_name = _("Host Calendar")
url = "calendar/host/"
classes = ("btn-default", )
icon = "calendar"
class DeleteLease(tables.DeleteAction):
name = "delete"
data_type_singular = _("Lease")
@ -92,5 +101,7 @@ class LeasesTable(tables.DataTable):
class Meta(object):
name = "leases"
verbose_name = _("Leases")
table_actions = (CreateLease, DeleteLease, )
table_actions = [CreateLease, DeleteLease, ]
if conf.host_reservation.get('enabled'):
table_actions.insert(0, ViewHostReservationCalendar)
row_actions = (UpdateLease, DeleteLease, )

View File

@ -0,0 +1,36 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Leases" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=calendar_title %}
{% endblock page_header %}
{% block main %}
<form class="form-inline" name="blazar-calendar-controls">
<div class="form-group calendar-group">
<button class="btn btn-sm btn-default calendar-quickdays" data-calendar-days="1">1 {% trans "day" %}</button>
<button class="btn btn-sm btn-default calendar-quickdays" data-calendar-days="7">7 {% trans "days" %}</button>
<button class="btn btn-sm btn-default calendar-quickdays" data-calendar-days="30">30 {% trans "days" %}</button>
</div>
<div class="form-group calendar-group">
<label for="dateStart">{% trans "Start" %}</label>
<input class="form-control calendar-datebox-date" type="text" name="dateStart" id="dateStart" placeholder="MM/DD/YYYY">
<input class="form-control calendar-datebox-hour" type="number" min="00" max="23" name="timeStartHours" id="timeStartHours" placeholder="hh">
:
00
</div>
<div class="form-group calendar-group">
<label for="dateEnd">{% trans "End" %}</label>
<input class="form-control calendar-datebox-date" type="text" name="dateEnd" id="dateEnd" placeholder="MM/DD/YYYY">
<input class="form-control calendar-datebox-hour" type="number" min="00" max="23" name="timeEndHours" id="timeEndHours" placeholder="hh">
:
00
</div>
</form>
<div class="blazar-calendar" id="blazar-calendar-{{resource_type}}">
<div class="text-center">
<h2>{% trans "Loading Reservations" %}<br><i class="fa fa-spinner fa-spin"></i></h2>
</div>
</div>
{% endblock %}

View File

@ -19,6 +19,11 @@ from blazar_dashboard.content.leases import views as leases_views
urlpatterns = [
url(r'^calendar/(?P<resource_type>[^/]+)/$',
leases_views.CalendarView.as_view(), name='calendar'),
url(r'^calendar/(?P<resource_type>[^/]+)/resources\.json$',
leases_views.calendar_data_view,
name='calendar_data'),
url(r'^$', leases_views.IndexView.as_view(), name='index'),
url(r'^create/$', leases_views.CreateView.as_view(), name='create'),
url(r'^(?P<lease_id>[^/]+)/$', leases_views.DetailView.as_view(),

View File

@ -13,6 +13,7 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.http import JsonResponse
from django.urls import reverse
from django.urls import reverse_lazy
from django.utils.translation import ugettext_lazy as _
@ -21,8 +22,10 @@ from horizon import forms
from horizon import tables
from horizon import tabs
from horizon.utils import memoized
from horizon import views
from blazar_dashboard import api
from blazar_dashboard import conf
from blazar_dashboard.content.leases import forms as project_forms
from blazar_dashboard.content.leases import tables as project_tables
from blazar_dashboard.content.leases import tabs as project_tabs
@ -42,6 +45,38 @@ class IndexView(tables.DataTableView):
return leases
class CalendarView(views.APIView):
template_name = 'project/leases/calendar.html'
titles = {
"host": _("Host Calendar"),
}
def get_data(self, request, context, *args, **kwargs):
if context["resource_type"] not in self.titles:
raise exceptions.NotFound
context["calendar_title"] = self.titles[context["resource_type"]]
return context
def calendar_data_view(request, resource_type):
api_mapping = {
"host": api.client.reservation_calendar,
}
attribute_mapping = {
"host": conf.host_reservation.get('calendar_attribute'),
}
data = {}
if resource_type not in api_mapping:
raise exceptions.NotFound
resources, reservations = api_mapping[resource_type](request)
data['resources'] = resources
data['reservations'] = reservations
# Which attribute to use to determine calendar rows
data['row_attr'] = attribute_mapping[resource_type]
return JsonResponse(data)
class DetailView(tabs.TabView):
tab_group_class = project_tabs.LeaseDetailTabs
template_name = 'project/leases/detail.html'

View File

@ -19,3 +19,12 @@ PANEL_DASHBOARD = 'project'
# Python panel class of the PANEL to be added.
ADD_PANEL = 'blazar_dashboard.content.leases.panel.Leases'
ADD_SCSS_FILES = [
'leases/scss/calendar.scss',
]
ADD_JS_FILES = [
'leases/js/calendar/lease_chart.js',
'leases/js/vendor/apexcharts.min.js',
]

View File

@ -0,0 +1,155 @@
(function(window, horizon, $, undefined) {
'use strict';
const CHART_TITLE_HEIGHT = 68;
const ROW_HEIGHT = 60;
const selector = '#blazar-calendar-host';
const pluralResourceType = gettext("Hosts");
if ($(selector).length < 1) return;
const calendarElement = $(selector);
const form = $('form[name="blazar-calendar-controls"]');
function init() {
calendarElement.addClass('loaded');
$.getJSON("resources.json")
.done(function(resp) {
const rowAttr = resp.row_attr;
// For this row shows up at all, we need at least 1 data point.
const reservationsById = {}
resp.reservations.forEach(function(reservation){
if(!(reservation.reservation_id in reservationsById)){
reservationsById[reservation.reservation_id] = reservation
reservation.name = reservation.reservation_id
reservation.data = []
}
const newReservation = {
'start_date': new Date(reservation.start_date),
'end_date': new Date(reservation.end_date),
'x': reservation[rowAttr],
'y': [
new Date(reservation.start_date).getTime(),
new Date(reservation.end_date).getTime()
],
}
reservationsById[reservation.reservation_id].data.push(newReservation)
})
reservationsById["0"] = {"name": "0", "data": []}
resp.resources.forEach(function(resource){
reservationsById["0"].data.push({x: resource[rowAttr], y: [0, 0]})
})
const allReservations = Object.values(reservationsById)
constructCalendar(allReservations, computeTimeDomain(7), resp.resources)
})
.fail(function() {
calendarElement.html(`<div class="alert alert-danger">${gettext("Unable to load reservations")}.</div>`);
});
function constructCalendar(rows, timeDomain, resources){
calendarElement.empty();
const options = {
series: rows,
chart: {
type: 'rangeBar',
toolbar: {show: false},
zoom: {enabled: false, type: 'xy'},
height: ROW_HEIGHT * resources.length + CHART_TITLE_HEIGHT,
width: "100%",
},
plotOptions: { bar: {horizontal: true, rangeBarGroupRows: true}},
xaxis: { type: 'datetime' },
legend: { show: false },
tooltip: {
custom: function({series, seriesIndex, dataPointIndex, w}) {
const datum = rows[seriesIndex];
const resourcesReserved = datum.data.map(function(el){ return el.x }).join("<br>");
const project_dt = "";
if(datum.project_id){
project_dt = `<dt>${gettext("Project")}</dt>
<dd>${datum.project_id}</dd>`;
}
return `<div class='tooltip-content'><dl>
${project_dt}
<dt>${pluralResourceType}</dt>
<dd>${resourcesReserved}</dd>
<dt>${gettext("Reserved")}</dt>
<dd>${datum.start_date} <strong>${gettext("to")}</strong> ${datum.end_date}</dd>
</dl></div>`;
}
},
annotations: {
xaxis: [
{
x: new Date().getTime(),
borderColor: '#00E396',
}
]
},
}
const chart = new ApexCharts(document.querySelector(selector), options);
chart.render();
setTimeDomain(timeDomain, chart); // Also sets the yaxis limits
$('input[data-datepicker]', form).datepicker({
dateFormat: 'mm/dd/yyyy'
});
$('input', form).on('change', function() {
if (form.hasClass('time-domain-processed')) {
const timeDomain = getTimeDomain();
// If invalid ordering is chosen, set period to 1 day
if (timeDomain[0] >= timeDomain[1]) {
timeDomain[1] = d3.time.day.offset(timeDomain[0], +1);
}
setTimeDomain(timeDomain, chart);
}
});
$('.calendar-quickdays').click(function() {
const days = parseInt($(this).data("calendar-days"));
if (!isNaN(days)) {
const timeDomain = computeTimeDomain(days);
setTimeDomain(timeDomain, chart);
}
})
}
function computeTimeDomain(days) {
const padFraction = 1/8; // chart default is 3 hours for 1 day
return [
d3.time.day.offset(Date.now(), -days * padFraction),
d3.time.day.offset(Date.now(), days * (1 + padFraction))
];
}
function setTimeDomain(timeDomain, chart) {
// Set the input elements
form.removeClass('time-domain-processed');
$('#dateStart').datepicker('setDate', timeDomain[0]);
$('#timeStartHours').val(timeDomain[0].getHours());
$('#dateEnd').datepicker('setDate', timeDomain[1]);
$('#timeEndHours').val(timeDomain[1].getHours());
form.addClass('time-domain-processed');
const options = { yaxis: {min: timeDomain[0].getTime(), max: timeDomain[1].getTime()}}
chart.updateOptions(options)
}
function getTimeDomain() {
const timeDomain = [
$('#dateStart').datepicker('getDate'),
$('#dateEnd').datepicker('getDate')
];
timeDomain[0].setHours($('#timeStartHours').val());
timeDomain[0].setMinutes(0);
timeDomain[1].setHours($('#timeEndHours').val());
timeDomain[1].setMinutes(0);
return timeDomain;
}
}
horizon.addInitFunction(init);
})(window, horizon, jQuery);

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,55 @@
.blazar-calendar {
width: 100%;
min-height: 800px;
position: relative;
}
.blazar-calendar .chart {
width: 100%;
}
.apexcharts-tooltip {
position: absolute;
top: 0;
left: 0;
width: 300px;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.1);
}
.apexcharts-series {
opacity: 0.9;
}
.apexcharts-series.apexcharts-active {
stroke: yellow;
}
.apexcharts-xaxis-annotations line {
stroke: #A8DE52;
stroke-width: 3;
}
.apexcharts-yaxis-label {
stroke: rgba(#000, 0.25);
}
.axis line,
.axis path {
fill: none;
stroke: #000;
stroke-width: 2;
shape-rendering: crispEdges;
}
.calendar-group {
margin: 5px;
}
.form-control.calendar-datebox-date {
width: 120px;
}
.form-control.calendar-datebox-hour {
width: 55px;
}

View File

@ -0,0 +1,8 @@
.inline-groups > .control-group {
display: inline-block;
width: 32%;
}
.control-sublabel {
color: #999;
}

View File

@ -0,0 +1,5 @@
features:
- |
A calendar view of host availability is added. Any reservable host will
be shown in its own row, over a period of time. Allocations for the host
are indicated along the horizontal axis.