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:
parent
eda400f1ec
commit
d6398f5e7a
@ -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
23
blazar_dashboard/conf.py
Normal 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, }))
|
@ -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, )
|
||||
|
@ -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 %}
|
@ -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(),
|
||||
|
@ -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'
|
||||
|
@ -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',
|
||||
]
|
||||
|
155
blazar_dashboard/static/leases/js/calendar/lease_chart.js
Normal file
155
blazar_dashboard/static/leases/js/calendar/lease_chart.js
Normal 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);
|
14
blazar_dashboard/static/leases/js/vendor/apexcharts.min.js
vendored
Normal file
14
blazar_dashboard/static/leases/js/vendor/apexcharts.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
55
blazar_dashboard/static/leases/scss/calendar.scss
Normal file
55
blazar_dashboard/static/leases/scss/calendar.scss
Normal 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;
|
||||
}
|
||||
|
8
blazar_dashboard/static/leases/scss/widgets.scss
Normal file
8
blazar_dashboard/static/leases/scss/widgets.scss
Normal file
@ -0,0 +1,8 @@
|
||||
.inline-groups > .control-group {
|
||||
display: inline-block;
|
||||
width: 32%;
|
||||
}
|
||||
|
||||
.control-sublabel {
|
||||
color: #999;
|
||||
}
|
5
releasenotes/notes/host-calendar-2ecf4058929b269e.yaml
Normal file
5
releasenotes/notes/host-calendar-2ecf4058929b269e.yaml
Normal 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.
|
Loading…
Reference in New Issue
Block a user