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 # License for the specific language governing permissions and limitations
# under the License. # under the License.
from datetime import datetime
from itertools import chain
import json import json
import logging import logging
from pytz import UTC
from blazar_dashboard import conf
from horizon import exceptions from horizon import exceptions
from horizon.utils.memoized import memoized from horizon.utils.memoized import memoized
from openstack_dashboard.api import base from openstack_dashboard.api import base
@ -57,6 +61,11 @@ class Host(base.APIDictWrapper):
return excaps return excaps
class Allocation(base.APIDictWrapper):
_attrs = ['resource_id', 'reservations']
@memoized @memoized
def blazarclient(request): def blazarclient(request):
try: try:
@ -129,3 +138,62 @@ def host_update(request, host_id, values):
def host_delete(request, host_id): def host_delete(request, host_id):
"""Delete a host.""" """Delete a host."""
blazarclient(request).host.delete(host_id) 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 import pytz
from blazar_dashboard import api from blazar_dashboard import api
from blazar_dashboard import conf
class CreateLease(tables.LinkAction): class CreateLease(tables.LinkAction):
@ -47,6 +48,14 @@ class UpdateLease(tables.LinkAction):
return False return False
class ViewHostReservationCalendar(tables.LinkAction):
name = "calendar"
verbose_name = _("Host Calendar")
url = "calendar/host/"
classes = ("btn-default", )
icon = "calendar"
class DeleteLease(tables.DeleteAction): class DeleteLease(tables.DeleteAction):
name = "delete" name = "delete"
data_type_singular = _("Lease") data_type_singular = _("Lease")
@ -92,5 +101,7 @@ class LeasesTable(tables.DataTable):
class Meta(object): class Meta(object):
name = "leases" name = "leases"
verbose_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, ) 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 = [ 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'^$', leases_views.IndexView.as_view(), name='index'),
url(r'^create/$', leases_views.CreateView.as_view(), name='create'), url(r'^create/$', leases_views.CreateView.as_view(), name='create'),
url(r'^(?P<lease_id>[^/]+)/$', leases_views.DetailView.as_view(), 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 # License for the specific language governing permissions and limitations
# under the License. # under the License.
from django.http import JsonResponse
from django.urls import reverse from django.urls import reverse
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -21,8 +22,10 @@ from horizon import forms
from horizon import tables from horizon import tables
from horizon import tabs from horizon import tabs
from horizon.utils import memoized from horizon.utils import memoized
from horizon import views
from blazar_dashboard import api 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 forms as project_forms
from blazar_dashboard.content.leases import tables as project_tables from blazar_dashboard.content.leases import tables as project_tables
from blazar_dashboard.content.leases import tabs as project_tabs from blazar_dashboard.content.leases import tabs as project_tabs
@ -42,6 +45,38 @@ class IndexView(tables.DataTableView):
return leases 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): class DetailView(tabs.TabView):
tab_group_class = project_tabs.LeaseDetailTabs tab_group_class = project_tabs.LeaseDetailTabs
template_name = 'project/leases/detail.html' template_name = 'project/leases/detail.html'

View File

@ -19,3 +19,12 @@ PANEL_DASHBOARD = 'project'
# Python panel class of the PANEL to be added. # Python panel class of the PANEL to be added.
ADD_PANEL = 'blazar_dashboard.content.leases.panel.Leases' 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.