Adds datepicker and improved graph/piechart

Adds ability to control time period shown on graph and pie chart in
reporting tab. Preset range buttons have been included under two
dropdown buttons, and it is possible to see the previous/next set range
with the arrow buttons.

Interactive legends have been added to both the datepicker and the
piechart.

Change-Id: Ieea8f22a5ac7e21996d4bd4223ea01694591bd72
Signed-off-by: Leonie Chamberlin-Medd <leonie@stackhpc.com>
This commit is contained in:
Leonie Chamberlin-Medd
2025-08-13 14:56:56 +00:00
parent d2c85051c5
commit 6c76a41a58
8 changed files with 665 additions and 77 deletions

View File

@@ -0,0 +1,19 @@
{% load i18n %}
{% block trimmed %}
<div class="input-group date">
{{ datepicker_input }}
<label class="sr-only">{{ datepicker_label }}</label>
<span class="input-group-addon datepicker-addon">
<span class="fa fa-calendar"></span>
</span>
</div>
<script>
$(function () {
$('.datepicker input').datepicker({
todayBtn: "linked", // Enables the today button which quickly links back to today
endDate: '0d', // Prevents the user from picking a date in the future
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,61 @@
{% load i18n %}
{%load static %}
<link rel="stylesheet" href="{% static 'cloudkitty/css/datepicker.css' %}">
<script src="{% static 'cloudkitty/js/datepicker.js' %}" type="text/javascript" charset="utf-8"></script>
<form action="?" method="get" id="{{ datepicker_id }}" class="form-inline">
<h4>{% trans "Select a period of time to view data in:" %}
<span class="small help-block">{% trans "The date should be in YYYY-MM-DD format." %}</span>
</h4>
<div class="datepicker form-group">
{% with datepicker_input=form.start datepicker_label="From" %}
{% include 'project/reporting/_datepicker_reporting.html' %}
{% endwith %}
</div>
<span class="datepicker-delimiter">
<span class="datepicker-delimiter-text">{% trans 'to' %}</span>
</span>
<div class="datepicker form-group">
{% with datepicker_input=form.end datepicker_label="To" %}
{% include 'project/reporting/_datepicker_reporting.html' %}
{% endwith %}
</div>
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
</form>
<div class="controls-container">
<div class="dropdown default-ranges">
<button class="dropbtn">{% trans "Period Range" %}</button>
<div class="dropdown-content">
<button data-period="day" data-view="current">{% trans "Day View" %}</button>
<button data-period="week" data-view="current">{% trans "Week View" %}</button>
<button data-period="month" data-view="current">{% trans "Month View" %}</button>
<button data-period="year" data-view="current">{% trans "Year View" %}</button>
</div>
</div>
<div class="dropdown preset-ranges">
<button class="dropbtn">{% trans "Preset Ranges" %}</button>
<div class="dropdown-content">
<button data-period="day" data-view="yesterday">{% trans "Yesterday" %}</button>
<button data-period="week" data-view="last">{% trans "Last Week" %}</button>
<button data-period="month" data-view="last">{% trans "Last Month" %}</button>
<button data-period="month" data-view="last" data-amount="3">{% trans "Last 3 Months" %}</button>
<button data-period="month" data-view="last" data-amount="6">{% trans "Last 6 Months" %}</button>
<button data-period="year" data-view="last">{% trans "Last Year" %}</button>
</div>
</div>
<button class="arrow-btn" data-direction="prev">&laquo;</button>
<button class="arrow-btn" data-direction="next">&raquo;</button>
</div>
<script>
$(function () {
initDatepicker({
formId: '{{ datepicker_id }}',
startFieldName: '{{ form.start.name }}',
endFieldName: '{{ form.end.name }}'
});
});
</script>

View File

@@ -2,9 +2,12 @@
{% load l10n %}
{% load static %}
<link rel="stylesheet" href="{% static 'cloudkitty/css/this_month.css' %}">
<div class="container-fluid">
<div class="col-lg-3 col-md-4">
<h4>{% trans "Legend" %}</h4>
<span class="small help-block">{% trans "Click on a metric to remove it from the pie chart." %}</span>
<div id="graph_legend"></div>
</div>
<div class="col-lg-4 col-md-8" style="max-width:25vw;">
@@ -15,27 +18,39 @@
<h4>{% trans "Cost Per Service Per Hour" %}</h4>
<div id="cost_progress" style="max-width:100%;"></div>
<div id="cost_progress_legend"></div>
<div id="datepicker_range">
{% with start=form.start end=form.end datepicker_id='date_form' %}
{% include 'project/reporting/_datepicker_reporting_form.html' %}
{% endwith %}
</div>
</div>
</div>
<script type="text/javascript">
var data = [
{% for service, data in repartition_data.items %}
{"label": "{{ service }}",
"value": {{ data.cumulated|unlocalize }}
},
{% endfor %}
<script>
window.addEventListener('load', function () {
var data = [
{% for service, data in repartition_data.items %}
{
"label": "{{ service }}",
"value": {{ data.cumulated | unlocalize }}
},
{% endfor %}
]
// Pie Chart
var innerRadius = 75;
var outerRadius = 150;
var height = 300;
var width = 300;
data.forEach(function (d) {
d.value = +d.value; // Ensure value is a number
d.enabled = true;
});
var colors = d3.scale.category20c();
// Pie Chart
var innerRadius = 75;
var outerRadius = 150;
var height = 300;
var width = 300;
var vis = d3.select("#repartition_cumulated")
var colors = d3.scale.category20c();
var vis = d3.select("#repartition_cumulated")
.append("svg:svg") // create the SVG element inside the DOM
.data([data]) // associate our data
.attr("width", "75%")
@@ -44,35 +59,78 @@
.append("svg:g")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")") // move the center of the pie chart from 0, 0 to radius, radius
var arc = d3.svg.arc() // Creating <path> elements using arc data
var arc = d3.svg.arc() // Creating <path> elements using arc data
.outerRadius(outerRadius)
.innerRadius(innerRadius);
var pie = d3.layout.pie() // Creating arc data for us given a list of values
.value(function(d) { return d.value; });
var pie = d3.layout.pie() // Creating arc data for us given a list of values
.value(function (d) { return d.enabled ? d.value : 0; });
var arcs = vis.selectAll("g.slice") // Selecting all <g> elements (there are none yet)
var arcs = vis.selectAll("g.slice") // Selecting all <g> elements (there are none yet)
.data(pie) // associate data
.enter() // creating a <g> for each element of data
.append("svg:g")
.attr("class", "slice");
arcs.append("svg:path")
.attr("fill", function(d, i) { return colors(i); } ) // Setting the color of each slice
.attr("d", arc); // creating the actual svg
arcs.append("svg:title") //add a label to each slice
.attr("text-anchor", "middle") //center the text on it's origin
.text(function(d, i) { return data[i].label; }); //get the label from our original data array
var div = d3.select("body").append("div")
.attr("class", "tooltip-donut")
.style("position", "absolute")
.style("text-align", "center")
.style("padding", ".5rem")
.style("background", "#FFFFFF")
.style("color", "#313639")
.style("border", "1px solid #313639")
.style("border-radius", "8px")
.style("pointer-events", "none")
.style("font-size", "1.3rem")
.style("opacity", 0);
var path = arcs.append("svg:path")
.attr("fill", function (d, i) { return colors(i); }) // Setting the color of each slice
.attr("d", arc) // creating the actual svg
.each(function (d) { this._current = d; }) // store the initial angles
.style("pointer-events", "none") // Disable hover events during initial animation
.on('mouseover', function (d, i) {
d3.select(this).transition()
.duration('50')
.attr('opacity', '.85');
div.transition()
.duration(50)
.style("opacity", 1);
// Calculate total of all enabled values
var total = d3.sum(data, function (item) {
return item.enabled ? item.value : 0;
});
let percentage = total > 0 ? Math.round((d.value / total) * 100) : 0;
let label = percentage.toString() + '%';
div.html(label) // shows percentage label to be near cursor
.style("left", (d3.mouse(document.body)[0] + 10) + "px")
.style("top", (d3.mouse(document.body)[1] - 15) + "px");
})
.on('mouseout', function (d, i) {
d3.select(this).transition()
.duration('50')
.attr('opacity', '1');
div.transition()
.duration('50')
.style("opacity", 0);
});
arcs.append("svg:title") //add a label to each slice
.attr("text-anchor", "middle") //center the text on its origin
.text(function (d, i) { return data[i].label; }); //get the label from our original data array
// Legend
var legendHeight = 20;
var legendSpace = 5;
var viewBoxHeight = data.length * (legendHeight + legendSpace);
console.log('data length', data.length)
// Legend
var legendHeight = 20;
var legendSpace = 5;
var viewBoxHeight = data.length * (legendHeight + legendSpace);
console.log('data length', data.length)
var legend_vis = d3.select("#graph_legend")
var legend_vis = d3.select("#graph_legend")
.append("svg:svg")
.data([data])
.attr("viewBox", "0 0 250 " + viewBoxHeight)
@@ -81,69 +139,153 @@
.append("svg:g")
.attr("transform", "translate(0,0)");
var legend = legend_vis.selectAll("g")
var legend = legend_vis.selectAll("g")
.data(data)
.enter()
.append('g')
.attr('class', 'legend')
.attr('transform', function(d, i) {
.attr('transform', function (d, i) {
var x = 0;
var y = i * (legendHeight + legendSpace);
return 'translate(' + x + ',' + y + ')';
});
var legendRectSize = 20;
var legendSpacing = 5;
legend.append('rect')
var legendRectSize = 20;
var legendSpacing = 5;
legend.append('rect')
.attr('width', legendRectSize)
.attr('height', legendRectSize)
.style('fill', function(d, i) { return colors(i); })
.style('stroke', function(d, i) { return colors(i); });
.style('fill', function (d, i) { return colors(i); })
.style('stroke', function (d, i) { return colors(i); })
.on('click.removeSlice', function (d) {
var rect = d3.select(this);
var enabled = true;
legend.append('text')
// Find the corresponding data point in the original data array
var dataItem = data.find(item => item.label === d.label);
// Calculate total enabled slices
var totalEnabled = d3.sum(data.map(function (d) {
return (d.enabled) ? 1 : 0;
}));
if (rect.attr('class') === 'disabled') {
rect.attr('class', '');
if (dataItem) dataItem.enabled = true; // Enable the data point
}
else {
if (totalEnabled < 2) return; // Prevent disabling of the last slice
rect.attr('class', 'disabled');
if (dataItem) dataItem.enabled = false;
}
// Rebind data to the pie slices, filtered by enabled state
// You need to re-select 'arcs' and then re-bind data
arcs = vis.selectAll("g.slice")
.data(pie);
// Disable hover events during legend-triggered animation
path.style("pointer-events", "none");
// Update the path 'd' attribute with a transition
arcs.select("path")
.transition()
.duration(750)
.attrTween('d', function (d) {
var interpolate = d3.interpolate(this._current, d);
this._current = interpolate(0); // Update _current for next transition
return function (t) {
return arc(interpolate(t));
};
})
.each("end", function () {
// Re-enable hover events after legend animation completes
path.style("pointer-events", "auto");
});
});
legend.append('text')
.attr('x', legendRectSize + legendSpacing)
.attr('y', legendRectSize - legendSpacing)
.text(function(d) { return d.label; });
</script>
.text(function (d) { return d.label; });
<script>
var colors = d3.scale.category20c();
var graph = new Rickshaw.Graph({
element: document.querySelector('#cost_progress'),
interpolation: 'linear',
unstack: 'true',
onComplete: function(w) {
var legend = new Rickshaw.Graph.Legend({
element: document.querySelector('#cost_progress_legend'),
graph: w.graph
path.transition()
.ease("sin")
.duration(2000)
.attrTween("d", tweenPie)
.each("end", function () {
path.style("pointer-events", "auto");
});
},
series: [
{% for service, data in repartition_data.items %}
function tweenPie(b) {
var i = d3.interpolate({ startAngle: 0, endAngle: 0 }, b);
return function (t) { return arc(i(t)); };
}
});
</script>
<script>
var colors = d3.scale.category20c();
var graph = new Rickshaw.Graph({
element: document.querySelector('#cost_progress'),
interpolation: 'linear',
unstack: true,
series: [
{% for service, data in repartition_data.items %}
{
color: colors({{ forloop.counter }} - 1),
name: "{{ service }}",
data: [
{% for timestamp, rating in data.hourly.items %}{x: {{ timestamp }}, y: {{ rating|unlocalize }}},{% endfor %}
{% for timestamp, rating in data.hourly.items %}{ x: {{ timestamp }}, y: {{ rating | unlocalize }} }, {% endfor %}
]
},
{% endfor %}
{% endfor %}
]
});
graph.render();
graph.render();
var hoverDetail = new Rickshaw.Graph.HoverDetail( {
graph: graph
});
var legend = new Rickshaw.Graph.Legend({
element: document.querySelector('#cost_progress_legend'),
graph: graph
});
var yAxis = new Rickshaw.Graph.Axis.Y({
graph: graph,
});
yAxis.render();
var hoverDetail = new Rickshaw.Graph.HoverDetail({
graph: graph
});
var xAxis = new Rickshaw.Graph.Axis.Time({
graph: graph
});
xAxis.render();
</script>
var yAxis = new Rickshaw.Graph.Axis.Y({
graph: graph,
});
yAxis.render();
var xAxis = new Rickshaw.Graph.Axis.Time({
graph: graph
});
xAxis.render();
// This allows you to toggle the visibility of series in the graph
var shelving = new Rickshaw.Graph.Behavior.Series.Toggle({
graph: graph,
legend: legend
});
// This allows you to highlight a series when you hover over it in the legend
var highlighter = new Rickshaw.Graph.Behavior.Series.Highlight({
graph: graph,
legend: legend
});
//This allows you to change which metric is in front
var order = new Rickshaw.Graph.Behavior.Series.Order({
graph: graph,
legend: legend
});
</script>

View File

@@ -18,9 +18,15 @@ import datetime
import decimal
import time
from horizon import messages
from horizon import tabs
from django.conf import settings
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from cloudkittydashboard.api import cloudkitty as api
from cloudkittydashboard import forms
def _do_this_month(data):
@@ -33,9 +39,12 @@ def _do_this_month(data):
end_timestamp = None
for dataframe in data.get('dataframes', []):
begin = dataframe['begin']
timestamp = int(time.mktime(
datetime.datetime.strptime(begin[:16],
"%Y-%m-%dT%H:%M").timetuple()))
timestamp = int(
time.mktime(
datetime.datetime.strptime(
begin[:16], "%Y-%m-%dT%H:%M").timetuple()
)
)
if start_timestamp is None or timestamp < start_timestamp:
start_timestamp = timestamp
if end_timestamp is None or timestamp > end_timestamp:
@@ -69,25 +78,102 @@ def _do_this_month(data):
class CostRepartitionTab(tabs.Tab):
name = "This month"
name = _("This month")
slug = "this_month"
template_name = 'project/reporting/this_month.html'
def get_context_data(self, request, **kwargs):
today = datetime.datetime.today()
day_start, day_end = calendar.monthrange(today.year, today.month)
begin = "%4d-%02d-01T00:00:00" % (today.year, today.month)
end = "%4d-%02d-%02dT23:59:59" % (today.year, today.month, day_end)
form = self.get_form()
if form.is_valid():
# set values to be from datepicker form
start = form.cleaned_data['start']
end = form.cleaned_data['end']
begin = "%4d-%02d-%02dT00:00:00" % (start.year,
start.month, start.day)
end = "%4d-%02d-%02dT23:59:59" % (end.year, end.month, end.day)
if end < begin:
messages.error(
self.request,
_("Invalid time period. The end date should be "
"more recent than the start date."
" Setting the end as today."))
end = "%4d-%02d-%02dT23:59:59" % (today.year,
today.month, day_end)
elif start > today.date():
messages.error(
self.request,
_("Invalid time period. You are requesting "
"data from the future which may not exist."))
elif form.is_bound:
messages.error(
self.request, _(
"Invalid date format: Using this month as default.")
)
begin = "%4d-%02d-%02dT00:00:00" % (today.year,
today.month, day_start)
end = "%4d-%02d-%02dT23:59:59" % (today.year, today.month, day_end)
else: # set default date values (before form is filled in)
begin = "%4d-%02d-%02dT00:00:00" % (today.year,
today.month, day_start)
end = "%4d-%02d-%02dT23:59:59" % (today.year, today.month, day_end)
client = api.cloudkittyclient(request)
data = client.storage.get_dataframes(
begin=begin, end=end, tenant_id=request.user.tenant_id)
parsed_data = _do_this_month(data)
return {'repartition_data': parsed_data}
return {'repartition_data': parsed_data, 'form': form}
@property
def today(self):
return timezone.now()
@property
def first_day(self):
days_range = settings.OVERVIEW_DAYS_RANGE
if days_range:
return self.today.date() - datetime.timedelta(days=days_range)
return datetime.date(self.today.year, self.today.month, 1)
def init_form(self):
self.start = self.first_day
self.end = self.today.date()
return self.start, self.end
def get_form(self):
if not hasattr(self, "form"):
req = self.request
start = req.GET.get('start', req.session.get('usage_start'))
end = req.GET.get('end', req.session.get('usage_end'))
if start and end:
# bound form
self.form = forms.DateForm({'start': start, 'end': end})
else:
# non-bound form
init = self.init_form()
start = init[0].isoformat()
end = init[1].isoformat()
self.form = forms.DateForm(
initial={'start': start, 'end': end})
req.session['usage_start'] = start
req.session['usage_end'] = end
return self.form
class ReportingTabs(tabs.TabGroup):
slug = "reporting_tabs"
tabs = (CostRepartitionTab, )
tabs = (CostRepartitionTab,)
sticky = True

View File

@@ -13,6 +13,8 @@
# under the License.
#
AUTO_DISCOVER_STATIC_FILES = True
PANEL_GROUP = 'rating'
PANEL_DASHBOARD = 'project'
PANEL = 'reporting'

View File

@@ -0,0 +1,75 @@
.form-inline {
text-align: left;
margin: 0;
padding: 0;
}
.form-inline h4 {
text-align: left;
margin: 0 0 10px 0;
}
.form-inline .datepicker,
.form-inline .datepicker-delimiter,
.form-inline .btn {
margin: 0 10px 0 0;
}
.controls-container {
text-align: left;
margin: 10px 0;
display: flex;
align-items: center;
gap: 10px;
}
.dropbtn,
.arrow-btn {
background-color: #EEEEEE;
color: #6E6E6E;
padding: 5px 10px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
background-color: #f1f1f1;
min-width: 160px;
box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2);
z-index: 1;
}
.dropdown-content button {
color: black;
background-color: #f1f1f1;
border: none;
padding: 12px 16px;
display: block;
width: 100%;
text-align: left;
cursor: pointer;
margin: 0;
border-radius: 0;
}
.dropdown-content button:hover {
background-color: #ddd;
}
.dropdown:hover .dropdown-content {
display: block;
}

View File

@@ -0,0 +1,23 @@
div.tooltip-donut {
position: absolute;
text-align: center;
padding: .5rem;
background: #FFFFFF;
color: #313639;
border: 1px solid #313639;
border-radius: 8px;
pointer-events: none;
font-size: 1.3rem;
}
/* Styling for disabled legend items */
.legend rect.disabled {
opacity: 0.3;
stroke-dasharray: 3,3;
}
.legend rect.disabled + text {
opacity: 0.5;
text-decoration: line-through;
}

View File

@@ -0,0 +1,180 @@
/**
* CloudKitty Datepicker functionality
* Handles date range selection with preset ranges and navigation
*/
function initDatepicker(options) {
const {
formId,
startFieldName,
endFieldName
} = options;
const $form = $('#' + formId);
const $startInput = $('[name="' + startFieldName + '"]');
const $endInput = $('[name="' + endFieldName + '"]');
let lastClicked = sessionStorage.getItem('datepicker_lastClicked') || 'week';
// Utility functions
const formatDate = (date) => {
return date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0');
};
const disableButtons = () => {
$('.controls-container button').prop('disabled', true);
};
const submitForm = (startDate, endDate, periodType) => {
$startInput.val(formatDate(startDate));
$endInput.val(formatDate(endDate));
lastClicked = periodType;
sessionStorage.setItem('datepicker_lastClicked', lastClicked);
$form.submit();
};
// Date calculation functions
const dateCalculators = {
day: {
current: () => {
const date = new Date();
return { start: new Date(date), end: new Date(date) };
},
yesterday: () => {
const date = new Date();
date.setDate(date.getDate() - 1);
return { start: new Date(date), end: new Date(date) };
}
},
week: {
current: () => {
const start = new Date();
start.setDate(start.getDate() - start.getDay() + 1);
const end = new Date(start);
end.setDate(start.getDate() + 6);
return { start, end };
},
last: () => {
const today = new Date();
const currentWeekStart = new Date(today);
currentWeekStart.setDate(today.getDate() - today.getDay() + 1);
const start = new Date(currentWeekStart);
start.setDate(currentWeekStart.getDate() - 7);
const end = new Date(start);
end.setDate(start.getDate() + 6);
return { start, end };
}
},
month: {
current: () => {
const today = new Date();
const start = new Date(today.getFullYear(), today.getMonth(), 1);
const end = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return { start, end };
},
last: (amount = 1) => {
const end = new Date();
end.setDate(0);
const start = new Date();
start.setMonth(end.getMonth() - (amount - 1), 1);
return { start, end };
}
},
year: {
current: () => {
const year = new Date().getFullYear();
return {
start: new Date(year, 0, 1),
end: new Date(year, 11, 31)
};
},
last: () => {
const year = new Date().getFullYear() - 1;
return {
start: new Date(year, 0, 1),
end: new Date(year, 11, 31)
};
}
}
};
// Navigation functions
const navigate = (direction) => {
if (!$startInput.val() || !$endInput.val()) return;
const currentStart = new Date($startInput.val());
const currentEnd = new Date($endInput.val());
const multiplier = direction === 'next' ? 1 : -1;
const navigators = {
day: () => {
currentStart.setDate(currentStart.getDate() + multiplier);
currentEnd.setDate(currentEnd.getDate() + multiplier);
},
week: () => {
currentStart.setDate(currentStart.getDate() + (7 * multiplier));
currentEnd.setDate(currentEnd.getDate() + (7 * multiplier));
},
month: () => {
if (direction === 'next') {
currentStart.setMonth(currentStart.getMonth() + 1, 1);
currentEnd.setMonth(currentEnd.getMonth() + 2, 0);
} else {
currentStart.setMonth(currentStart.getMonth() - 1, 1);
currentEnd.setMonth(currentEnd.getMonth(), 0);
}
},
last3Month: () => {
if (direction === 'next') {
currentStart.setMonth(currentStart.getMonth() + 3, 1);
currentEnd.setMonth(currentEnd.getMonth() + 4, 0);
} else {
currentStart.setMonth(currentStart.getMonth() - 3, 1);
currentEnd.setMonth(currentEnd.getMonth() - 2, 0);
}
},
last6Month: () => {
if (direction === 'next') {
currentStart.setMonth(currentStart.getMonth() + 6, 1);
currentEnd.setMonth(currentEnd.getMonth() + 7, 0);
} else {
currentStart.setMonth(currentStart.getMonth() - 6, 1);
currentEnd.setMonth(currentEnd.getMonth() - 5, 0);
}
},
year: () => {
currentStart.setFullYear(currentStart.getFullYear() + multiplier);
currentEnd.setFullYear(currentEnd.getFullYear() + multiplier);
}
};
if (navigators[lastClicked]) {
navigators[lastClicked]();
submitForm(currentStart, currentEnd, lastClicked);
}
};
// Event handlers
$('.dropdown-content button[data-period]').on('click', function () {
disableButtons();
const period = $(this).data('period');
const view = $(this).data('view');
const amount = $(this).data('amount') || 1;
const { start, end } = dateCalculators[period][view](amount);
const periodType = amount > 1 ? `last${amount}Month` : period;
submitForm(start, end, periodType);
});
$('.arrow-btn').on('click', function () {
disableButtons();
navigate($(this).data('direction'));
});
}