From 6c76a41a582e2bf1b752e90aa8cb21972ecec9da Mon Sep 17 00:00:00 2001 From: Leonie Chamberlin-Medd Date: Wed, 13 Aug 2025 14:56:56 +0000 Subject: [PATCH 1/4] 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 --- .../reporting/_datepicker_reporting.html | 19 ++ .../reporting/_datepicker_reporting_form.html | 61 ++++ .../templates/reporting/this_month.html | 286 +++++++++++++----- .../dashboards/project/reporting/views.py | 102 ++++++- .../enabled/_32031_project_reporting_panel.py | 2 + .../static/cloudkitty/css/datepicker.css | 75 +++++ .../static/cloudkitty/css/this_month.css | 23 ++ .../static/cloudkitty/js/datepicker.js | 180 +++++++++++ 8 files changed, 668 insertions(+), 80 deletions(-) create mode 100644 cloudkittydashboard/dashboards/project/reporting/templates/reporting/_datepicker_reporting.html create mode 100644 cloudkittydashboard/dashboards/project/reporting/templates/reporting/_datepicker_reporting_form.html create mode 100644 cloudkittydashboard/static/cloudkitty/css/datepicker.css create mode 100644 cloudkittydashboard/static/cloudkitty/css/this_month.css create mode 100644 cloudkittydashboard/static/cloudkitty/js/datepicker.js diff --git a/cloudkittydashboard/dashboards/project/reporting/templates/reporting/_datepicker_reporting.html b/cloudkittydashboard/dashboards/project/reporting/templates/reporting/_datepicker_reporting.html new file mode 100644 index 0000000..0f1474c --- /dev/null +++ b/cloudkittydashboard/dashboards/project/reporting/templates/reporting/_datepicker_reporting.html @@ -0,0 +1,19 @@ +{% load i18n %} + +{% block trimmed %} +
+ {{ datepicker_input }} + + + + +
+ +{% endblock %} diff --git a/cloudkittydashboard/dashboards/project/reporting/templates/reporting/_datepicker_reporting_form.html b/cloudkittydashboard/dashboards/project/reporting/templates/reporting/_datepicker_reporting_form.html new file mode 100644 index 0000000..2a891e7 --- /dev/null +++ b/cloudkittydashboard/dashboards/project/reporting/templates/reporting/_datepicker_reporting_form.html @@ -0,0 +1,61 @@ +{% load i18n %} +{%load static %} + + + + +
+

{% trans "Select a period of time to view data in:" %} + {% trans "The date should be in YYYY-MM-DD format." %} +

+
+ {% with datepicker_input=form.start datepicker_label="From" %} + {% include 'project/reporting/_datepicker_reporting.html' %} + {% endwith %} +
+ + {% trans 'to' %} + +
+ {% with datepicker_input=form.end datepicker_label="To" %} + {% include 'project/reporting/_datepicker_reporting.html' %} + {% endwith %} +
+ +
+ +
+ + + + +
+ + + diff --git a/cloudkittydashboard/dashboards/project/reporting/templates/reporting/this_month.html b/cloudkittydashboard/dashboards/project/reporting/templates/reporting/this_month.html index 4c29543..fa6f2ef 100644 --- a/cloudkittydashboard/dashboards/project/reporting/templates/reporting/this_month.html +++ b/cloudkittydashboard/dashboards/project/reporting/templates/reporting/this_month.html @@ -2,9 +2,12 @@ {% load l10n %} {% load static %} + +

{% trans "Legend" %}

+ {% trans "Click on a metric to remove it from the pie chart." %}
@@ -15,27 +18,39 @@

{% trans "Cumulative Cost Repartition" %}

{% trans "Cost Per Service Per Hour" %}

+
+ {% with start=form.start end=form.end datepicker_id='date_form' %} + {% include 'project/reporting/_datepicker_reporting_form.html' %} + {% endwith %} +
-
- - - - + + + 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 + }); + + diff --git a/cloudkittydashboard/dashboards/project/reporting/views.py b/cloudkittydashboard/dashboards/project/reporting/views.py index 8523ab4..de834df 100644 --- a/cloudkittydashboard/dashboards/project/reporting/views.py +++ b/cloudkittydashboard/dashboards/project/reporting/views.py @@ -18,9 +18,15 @@ 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 diff --git a/cloudkittydashboard/enabled/_32031_project_reporting_panel.py b/cloudkittydashboard/enabled/_32031_project_reporting_panel.py index 9562ee6..c6056b2 100644 --- a/cloudkittydashboard/enabled/_32031_project_reporting_panel.py +++ b/cloudkittydashboard/enabled/_32031_project_reporting_panel.py @@ -13,6 +13,8 @@ # under the License. # +AUTO_DISCOVER_STATIC_FILES = True + PANEL_GROUP = 'rating' PANEL_DASHBOARD = 'project' PANEL = 'reporting' diff --git a/cloudkittydashboard/static/cloudkitty/css/datepicker.css b/cloudkittydashboard/static/cloudkitty/css/datepicker.css new file mode 100644 index 0000000..df27ce4 --- /dev/null +++ b/cloudkittydashboard/static/cloudkitty/css/datepicker.css @@ -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; +} diff --git a/cloudkittydashboard/static/cloudkitty/css/this_month.css b/cloudkittydashboard/static/cloudkitty/css/this_month.css new file mode 100644 index 0000000..4dff901 --- /dev/null +++ b/cloudkittydashboard/static/cloudkitty/css/this_month.css @@ -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; +} diff --git a/cloudkittydashboard/static/cloudkitty/js/datepicker.js b/cloudkittydashboard/static/cloudkitty/js/datepicker.js new file mode 100644 index 0000000..a052b45 --- /dev/null +++ b/cloudkittydashboard/static/cloudkitty/js/datepicker.js @@ -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')); + }); +} From 9e3657d84f53e60dd9a4f79a9d1b72d690727b2c Mon Sep 17 00:00:00 2001 From: Leonie Chamberlin-Medd Date: Wed, 27 Aug 2025 14:42:06 +0000 Subject: [PATCH 2/4] Add grouping to rating panels Adds grouping functionality to the rating panels, allowing for grouping based on certain configurable metrics. Defaults to group by type if no configuration is set. Configuration instructions are in installation.rst. Change-Id: Iac784f141dc24a0154d71f10f21c605738d96ff6 Signed-off-by: Leonie Chamberlin-Medd --- .../dashboards/admin/summary/tables.py | 31 ++++- .../templates/rating_summary/details.html | 2 + .../dashboards/admin/summary/views.py | 41 +++++-- .../dashboards/project/rating/tables.py | 31 ++++- .../rating/templates/rating/groupby.html | 23 ++++ .../rating/templates/rating/index.html | 5 +- .../dashboards/project/rating/views.py | 28 +++-- .../enabled/_32030_project_rating_panel.py | 2 + cloudkittydashboard/forms/base.py | 5 +- .../static/cloudkitty/css/grouping.css | 28 +++++ .../static/cloudkitty/js/grouping.js | 111 ++++++++++++++++++ cloudkittydashboard/utils.py | 6 + doc/source/installation.rst | 15 +++ 13 files changed, 302 insertions(+), 26 deletions(-) create mode 100644 cloudkittydashboard/dashboards/project/rating/templates/rating/groupby.html create mode 100644 cloudkittydashboard/static/cloudkitty/css/grouping.css create mode 100644 cloudkittydashboard/static/cloudkitty/js/grouping.js diff --git a/cloudkittydashboard/dashboards/admin/summary/tables.py b/cloudkittydashboard/dashboards/admin/summary/tables.py index b41b47b..4adc9e0 100644 --- a/cloudkittydashboard/dashboards/admin/summary/tables.py +++ b/cloudkittydashboard/dashboards/admin/summary/tables.py @@ -12,11 +12,14 @@ # License for the specific language governing permissions and limitations # under the License. +from django.conf import settings from django.urls import reverse from django.utils.translation import gettext_lazy as _ from horizon import tables +from cloudkittydashboard.utils import formatTitle + def get_details_link(datum): if datum.tenant_id: @@ -37,12 +40,36 @@ class Meta(object): class TenantSummaryTable(tables.DataTable): - res_type = tables.Column('type', verbose_name=_("Resource Type")) + groupby_list = getattr(settings, + 'OPENSTACK_CLOUDKITTY_GROUPBY_LIST', + ['type']) + + # Dynamically create columns based on groupby_list + for field in groupby_list: + locals()[field] = tables.Column( + field, verbose_name=_(formatTitle(field))) + rate = tables.Column('rate', verbose_name=_("Rate")) + def __init__(self, request, data=None, needs_form_wrapper=None, **kwargs): + super().__init__(request, data, needs_form_wrapper, **kwargs) + + # Hide columns based on checkbox selection + for field in self.groupby_list: + if request.GET.get(field) != 'true': + self.columns[field].classes = ['hidden'] + class Meta(object): name = "tenant_summary" verbose_name = _("Project Summary") def get_object_id(self, datum): - return datum.get('type') + # Prevents the table from displaying the same ID for different rows + id_parts = [] + for field in self.groupby_list: + if field in datum and datum[field]: + id_parts.append(str(datum[field])) + + if id_parts: + return '_'.join(id_parts) + return _('No IDs found') diff --git a/cloudkittydashboard/dashboards/admin/summary/templates/rating_summary/details.html b/cloudkittydashboard/dashboards/admin/summary/templates/rating_summary/details.html index d9bfa71..e7efb58 100644 --- a/cloudkittydashboard/dashboards/admin/summary/templates/rating_summary/details.html +++ b/cloudkittydashboard/dashboards/admin/summary/templates/rating_summary/details.html @@ -10,6 +10,8 @@ {% trans "Project ID:" %} {{ project_id }} +{{ groupby_list|json_script:"groupby_list_config" }} + {% include "project/rating/groupby.html" %} {{ table.render }} {{ modules }} diff --git a/cloudkittydashboard/dashboards/admin/summary/views.py b/cloudkittydashboard/dashboards/admin/summary/views.py index bc4c813..1b80e41 100644 --- a/cloudkittydashboard/dashboards/admin/summary/views.py +++ b/cloudkittydashboard/dashboards/admin/summary/views.py @@ -22,6 +22,8 @@ from cloudkittydashboard.dashboards.admin.summary import tables as sum_tables from cloudkittydashboard import utils +from cloudkittydashboard import forms + rate_prefix = getattr(settings, 'OPENSTACK_CLOUDKITTY_RATE_PREFIX', None) rate_postfix = getattr(settings, @@ -35,8 +37,7 @@ class IndexView(tables.DataTableView): def get_data(self): summary = api.cloudkittyclient( self.request, version='2').summary.get_summary( - groupby=['project_id'], - response_format='object') + groupby=['project_id'], response_format='object') tenants, unused = api_keystone.tenant_list(self.request) tenants = {tenant.id: tenant.name for tenant in tenants} @@ -47,6 +48,7 @@ def get_data(self): 'project_id': 'ALL', 'rate': total, }) + data = api.identify(data, key='project_id') for tenant in data: tenant['tenant_id'] = tenant.get('project_id') @@ -60,27 +62,42 @@ def get_data(self): class TenantDetailsView(tables.DataTableView): template_name = 'admin/rating_summary/details.html' table_class = sum_tables.TenantSummaryTable - page_title = _("Script details: {{ table.project_id }}") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['groupby_list'] = getattr(settings, + 'OPENSTACK_CLOUDKITTY_GROUPBY_LIST', + ['type']) + return context def get_data(self): tenant_id = self.kwargs['project_id'] + form = forms.CheckBoxForm(self.request.GET) + groupby = form.get_selected_fields() if tenant_id == 'ALL': summary = api.cloudkittyclient( - self.request, version='2').summary.get_summary( - groupby=['type'], response_format='object') + self.request, version='2' + ).summary.get_summary(groupby=groupby, response_format='object') else: summary = api.cloudkittyclient( - self.request, version='2').summary.get_summary( - filters={'project_id': tenant_id}, - groupby=['type'], response_format='object') + self.request, version='2' + ).summary.get_summary( + filters={'project_id': tenant_id}, + groupby=groupby, + response_format='object', + ) data = summary.get('results') total = sum([r.get('rate') for r in data]) - data.append({'type': 'TOTAL', 'rate': total}) - for item in data: - item['rate'] = utils.formatRate(item['rate'], - rate_prefix, rate_postfix) + if not groupby: + data = [{'type': 'TOTAL', 'rate': total}] + + else: + data.append({'type': 'TOTAL', 'rate': total}) + for item in data: + item['rate'] = utils.formatRate( + item['rate'], rate_prefix, rate_postfix) return data diff --git a/cloudkittydashboard/dashboards/project/rating/tables.py b/cloudkittydashboard/dashboards/project/rating/tables.py index 1855e6f..53dbe6d 100644 --- a/cloudkittydashboard/dashboards/project/rating/tables.py +++ b/cloudkittydashboard/dashboards/project/rating/tables.py @@ -11,20 +11,47 @@ # 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 from django.utils.translation import gettext_lazy as _ from horizon import tables +from cloudkittydashboard.utils import formatTitle + class SummaryTable(tables.DataTable): """This table formats a summary for the given tenant.""" - res_type = tables.Column('type', verbose_name=_('Metric Type')) + groupby_list = getattr(settings, + 'OPENSTACK_CLOUDKITTY_GROUPBY_LIST', + ['type']) + + # Dynamically create columns based on groupby_list + for field in groupby_list: + locals()[field] = tables.Column( + field, verbose_name=_(formatTitle(field))) + rate = tables.Column('rate', verbose_name=_('Rate')) + def __init__(self, request, data=None, needs_form_wrapper=None, **kwargs): + super().__init__(request, data, needs_form_wrapper, **kwargs) + + # Hide columns based on checkbox selection + for field in self.groupby_list: + if request.GET.get(field) != 'true': + self.columns[field].classes = ['hidden'] + class Meta(object): name = "summary" verbose_name = _("Summary") def get_object_id(self, datum): - return datum.get('type') + # prevents the table from displaying the same ID for different rows + id_parts = [] + for field in self.groupby_list: + if field in datum and datum[field]: + id_parts.append(str(datum[field])) + + if id_parts: + return '_'.join(id_parts) + return _('No IDs found') diff --git a/cloudkittydashboard/dashboards/project/rating/templates/rating/groupby.html b/cloudkittydashboard/dashboards/project/rating/templates/rating/groupby.html new file mode 100644 index 0000000..f15084b --- /dev/null +++ b/cloudkittydashboard/dashboards/project/rating/templates/rating/groupby.html @@ -0,0 +1,23 @@ +{% load i18n %} +{% load static %} + +{{ groupby_list|json_script:"groupby_list_config" }} + + + + + + +
+

{% trans "Group by:" %}

+
+ + +
diff --git a/cloudkittydashboard/dashboards/project/rating/templates/rating/index.html b/cloudkittydashboard/dashboards/project/rating/templates/rating/index.html index e12aaea..068df1b 100644 --- a/cloudkittydashboard/dashboards/project/rating/templates/rating/index.html +++ b/cloudkittydashboard/dashboards/project/rating/templates/rating/index.html @@ -3,11 +3,12 @@ {% block title %}{% trans "Rating Summary" %}{% endblock %} {% block page_header %} - {% include "horizon/common/_page_header.html" with title=_("Rating Summary") %} +{% include "horizon/common/_page_header.html" with title=_("Rating Summary") %} {% endblock page_header %} {% block main %} - +{{ groupby_list|json_script:"groupby_list_config" }} +{% include "project/rating/groupby.html" %} {{ table.render }} {{ modules }} diff --git a/cloudkittydashboard/dashboards/project/rating/views.py b/cloudkittydashboard/dashboards/project/rating/views.py index f1e9139..a435219 100644 --- a/cloudkittydashboard/dashboards/project/rating/views.py +++ b/cloudkittydashboard/dashboards/project/rating/views.py @@ -19,6 +19,8 @@ from horizon import exceptions from horizon import tables +from cloudkittydashboard import forms + from cloudkittydashboard.api import cloudkitty as api from cloudkittydashboard.dashboards.project.rating \ import tables as rating_tables @@ -34,19 +36,31 @@ class IndexView(tables.DataTableView): table_class = rating_tables.SummaryTable template_name = 'project/rating/index.html' + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['groupby_list'] = getattr(settings, + 'OPENSTACK_CLOUDKITTY_GROUPBY_LIST', + ['type']) + return context + def get_data(self): + form = forms.CheckBoxForm(self.request.GET) + groupby = form.get_selected_fields() summary = api.cloudkittyclient( self.request, version='2').summary.get_summary( tenant_id=self.request.user.tenant_id, - groupby=['type'], response_format='object') + groupby=groupby, response_format='object') + data = summary.get("results") + total = sum([r.get("rate") for r in data]) - data = summary.get('results') - total = sum([r.get('rate') for r in data]) + if not groupby: # No checkboxes are selected, display total rate only + data = [{"type": "TOTAL", "rate": total}] - data.append({'type': 'TOTAL', 'rate': total}) - for item in data: - item['rate'] = utils.formatRate(item['rate'], - rate_prefix, rate_postfix) + else: # Some checkboxes are selected - use groupby + data.append({'type': 'TOTAL', 'rate': total}) + for item in data: + item['rate'] = utils.formatRate(item['rate'], + rate_prefix, rate_postfix) return data diff --git a/cloudkittydashboard/enabled/_32030_project_rating_panel.py b/cloudkittydashboard/enabled/_32030_project_rating_panel.py index 7dd8f79..c3f8f58 100644 --- a/cloudkittydashboard/enabled/_32030_project_rating_panel.py +++ b/cloudkittydashboard/enabled/_32030_project_rating_panel.py @@ -12,6 +12,8 @@ # License for the specific language governing permissions and limitations # under the License. +AUTO_DISCOVER_STATIC_FILES = True + PANEL_GROUP = 'rating' PANEL_DASHBOARD = 'project' PANEL = 'rating' diff --git a/cloudkittydashboard/forms/base.py b/cloudkittydashboard/forms/base.py index ee5bfb8..e240329 100644 --- a/cloudkittydashboard/forms/base.py +++ b/cloudkittydashboard/forms/base.py @@ -15,6 +15,7 @@ # 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 from django import forms @@ -32,7 +33,9 @@ def __init__(self, *args, **kwargs): class CheckBoxForm(forms.Form): """A form for selecting fields to group by in the rating summary.""" - checkbox_fields = ["type", "id", "user_id"] + checkbox_fields = getattr(settings, + 'OPENSTACK_CLOUDKITTY_GROUPBY_LIST', + ['type']) for field in checkbox_fields: locals()[field] = forms.BooleanField(required=False) diff --git a/cloudkittydashboard/static/cloudkitty/css/grouping.css b/cloudkittydashboard/static/cloudkitty/css/grouping.css new file mode 100644 index 0000000..94a0834 --- /dev/null +++ b/cloudkittydashboard/static/cloudkitty/css/grouping.css @@ -0,0 +1,28 @@ +.groupby-form { +text-align: left; +margin: 0; +padding: 0; +} + +.groupby-form h4 { +margin: 0 0 10px 0; +} + +.groupby-form #checkboxes { +margin-bottom: 10px; +} + +.groupby-form .btn { +margin: 0 10px 10px 0; +} + +.group-label { +display: inline-block; +margin-right: 8px; +margin-bottom: 5px; +font-weight: normal; +} + +.group-checkbox { +margin-right: 5px; +} \ No newline at end of file diff --git a/cloudkittydashboard/static/cloudkitty/js/grouping.js b/cloudkittydashboard/static/cloudkitty/js/grouping.js new file mode 100644 index 0000000..7fc3e6e --- /dev/null +++ b/cloudkittydashboard/static/cloudkitty/js/grouping.js @@ -0,0 +1,111 @@ +const groupbyList = JSON.parse(document.getElementById( + 'groupby_list_config').textContent); +const translations = JSON.parse(document.getElementById( + 'groupby_translations').textContent); + +class GroupByManager { +constructor() { + this.groupby = groupbyList || ['type']; + this.checkboxContainer = document.getElementById('checkboxes'); + this.urlParams = new URLSearchParams(window.location.search); + this.form = document.getElementById('groupby_checkbox'); + this.toggleButton = document.getElementById('toggleAll'); + + this.init(); +} + +// Convert field names to readable format +formatLabel(word) { + return word.replace(/_/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()) + .replace(/\bId\b/g, 'ID'); +} + +// Create checkbox with label +createCheckbox(name) { + const label = document.createElement('label'); + label.className = 'group-label'; + label.innerHTML = ` + + ${this.formatLabel(name)} + `; + return label; +} + +// Determine checkbox state from URL params or session storage +getCheckboxState(name) { + if (this.urlParams.has(name)) { + return this.urlParams.get(name) === 'true'; + } + + if (this.urlParams.toString()) { + return false; // URL has params but not this one + } + + //if we have a saved state use it, otherwise default to type checked + const saved = sessionStorage.getItem(`checkbox-${name}`); + return saved !== null ? saved === 'true' : name === 'type'; +} + +// Set up all checkboxes +setupCheckboxes() { + let shouldAutoSubmit = false; + + this.groupby.forEach(name => { + const label = this.createCheckbox(name); + const checkbox = label.querySelector('input'); + + checkbox.checked = this.getCheckboxState(name); + + if (checkbox.checked && !this.urlParams.toString()) { + shouldAutoSubmit = true; + } + + // Save state and add listener + sessionStorage.setItem(`checkbox-${name}`, checkbox.checked); + checkbox.addEventListener('change', () => { + sessionStorage.setItem(`checkbox-${name}`, checkbox.checked); + this.updateToggleButton(); + }); + + this.checkboxContainer.appendChild(label); + }); + + if (shouldAutoSubmit) { + this.form.submit(); + } +} + +// Update toggle button text based on current state +updateToggleButton() { + const checkboxes = this.checkboxContainer.querySelectorAll('.group-checkbox'); + const allChecked = Array.from(checkboxes).every(cb => cb.checked); + this.toggleButton.textContent = allChecked ? + translations.unselect_all : translations.select_all; +} + +// Toggle all checkboxes +toggleAll() { + const checkboxes = this.checkboxContainer.querySelectorAll('.group-checkbox'); + const allChecked = Array.from(checkboxes).every(cb => cb.checked); + + checkboxes.forEach(cb => { + cb.checked = !allChecked; + sessionStorage.setItem(`checkbox-${cb.name}`, cb.checked); + }); + + this.updateToggleButton(); + this.form.submit(); +} + +// Initialize the component +init() { + this.setupCheckboxes(); + this.updateToggleButton(); + this.toggleButton.addEventListener('click', () => this.toggleAll()); +} +} + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', () => new GroupByManager()); diff --git a/cloudkittydashboard/utils.py b/cloudkittydashboard/utils.py index d39eac7..8701819 100644 --- a/cloudkittydashboard/utils.py +++ b/cloudkittydashboard/utils.py @@ -33,3 +33,9 @@ def formatRate(rate: float, prefix: str, postfix: str) -> str: if postfix: rate = rate + postfix return rate + + +def formatTitle(word): + if word == 'type': + return 'Resource Type' + return word.title().replace('_', ' ').replace('Id', 'ID') diff --git a/doc/source/installation.rst b/doc/source/installation.rst index 1cd37c9..0ea910f 100644 --- a/doc/source/installation.rst +++ b/doc/source/installation.rst @@ -74,3 +74,18 @@ Some symbols (Such as Non-ASCII) might require to use unicode value directly. # British Pound OPENSTACK_CLOUDKITTY_RATE_PREFIX = '\xA3' OPENSTACK_CLOUDKITTY_RATE_POSTFIX = 'GBP' + + +Rating Panel Grouping List +-------------------------- + +You can configure the list of metrics used for grouping in the rating panels. + +If no list is provided, it defaults to ``['type']``. + +Here's an example of setting the grouping list to include +``type``, ``id`` and ``user_id``: + +.. code-block:: python + + OPENSTACK_CLOUDKITTY_GROUPBY_LIST = ['type', 'id', 'user_id'] From 0cb0d3f93fca52b495747f37e87ae29017c002bf Mon Sep 17 00:00:00 2001 From: OpenStack Release Bot Date: Fri, 13 Mar 2026 20:15:33 +0000 Subject: [PATCH 3/4] Update master for stable/2026.1 Add file to the reno documentation build to show release notes for stable/2026.1. Use pbr instruction to increment the minor version number automatically so that master versions are higher than the versions on stable/2026.1. Sem-Ver: feature Change-Id: Ia4dd1dc716b5686d0335975fd8a25338a068f665 Signed-off-by: OpenStack Release Bot Generated-By: openstack/project-config:roles/copy-release-tools-scripts/files/release-tools/add_release_note_page.sh --- releasenotes/source/2026.1.rst | 6 ++++++ releasenotes/source/index.rst | 1 + 2 files changed, 7 insertions(+) create mode 100644 releasenotes/source/2026.1.rst diff --git a/releasenotes/source/2026.1.rst b/releasenotes/source/2026.1.rst new file mode 100644 index 0000000..3d28615 --- /dev/null +++ b/releasenotes/source/2026.1.rst @@ -0,0 +1,6 @@ +=========================== +2026.1 Series Release Notes +=========================== + +.. release-notes:: + :branch: stable/2026.1 diff --git a/releasenotes/source/index.rst b/releasenotes/source/index.rst index cb4da33..6e913f0 100644 --- a/releasenotes/source/index.rst +++ b/releasenotes/source/index.rst @@ -8,6 +8,7 @@ Contents :maxdepth: 2 unreleased + 2026.1 2025.2 2025.1 2024.2 From df5533397318381864a6d57718f42f6de81a8555 Mon Sep 17 00:00:00 2001 From: OpenStack Proposal Bot Date: Mon, 23 Mar 2026 04:06:30 +0000 Subject: [PATCH 4/4] Imported Translations from Zanata For more information about this automatic import see: https://docs.openstack.org/i18n/latest/reviewing-translation-import.html Change-Id: I382dbf5da8293b42accb2831c279b3870f08510a Signed-off-by: OpenStack Proposal Bot Generated-By: openstack/openstack-zuul-jobs:roles/prepare-zanata-client/files/common_translation_update.sh --- .../locale/en_GB/LC_MESSAGES/releasenotes.po | 10 +- .../locale/fr/LC_MESSAGES/releasenotes.po | 124 +++++++++++++++++- 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/releasenotes/source/locale/en_GB/LC_MESSAGES/releasenotes.po b/releasenotes/source/locale/en_GB/LC_MESSAGES/releasenotes.po index 160ae00..7912e68 100644 --- a/releasenotes/source/locale/en_GB/LC_MESSAGES/releasenotes.po +++ b/releasenotes/source/locale/en_GB/LC_MESSAGES/releasenotes.po @@ -10,11 +10,11 @@ msgid "" msgstr "" "Project-Id-Version: Cloudkitty Dashboard Release Notes\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-26 12:19+0000\n" +"POT-Creation-Date: 2026-03-02 14:54+0000\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"PO-Revision-Date: 2026-01-07 11:22+0000\n" +"PO-Revision-Date: 2026-03-14 07:18+0000\n" "Last-Translator: Andi Chandler \n" "Language-Team: English (United Kingdom)\n" "Language: en_GB\n" @@ -66,6 +66,9 @@ msgstr "2025.2 Series Release Notes" msgid "21.0.0" msgstr "21.0.0" +msgid "21.0.0-19" +msgstr "21.0.0-19" + msgid "8.1.0" msgstr "8.1.0" @@ -212,6 +215,9 @@ msgstr "Train Series Release Notes" msgid "Upgrade Notes" msgstr "Upgrade Notes" +msgid "Upgrades the CloudKitty API from v1 to v2 in the admin/rating panel." +msgstr "Upgrades the CloudKitty API from v1 to v2 in the admin/rating panel." + msgid "Ussuri Series Release Notes" msgstr "Ussuri Series Release Notes" diff --git a/releasenotes/source/locale/fr/LC_MESSAGES/releasenotes.po b/releasenotes/source/locale/fr/LC_MESSAGES/releasenotes.po index 5344ae2..7e2f007 100644 --- a/releasenotes/source/locale/fr/LC_MESSAGES/releasenotes.po +++ b/releasenotes/source/locale/fr/LC_MESSAGES/releasenotes.po @@ -1,28 +1,89 @@ # François Magimel , 2018. #zanata +# François Magimel , 2026. #zanata msgid "" msgstr "" "Project-Id-Version: Cloudkitty Dashboard Release Notes\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-05-11 10:25+0000\n" +"POT-Creation-Date: 2026-03-02 14:54+0000\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"PO-Revision-Date: 2018-12-26 11:44+0000\n" +"PO-Revision-Date: 2026-03-22 02:05+0000\n" "Last-Translator: François Magimel \n" "Language-Team: French\n" "Language: fr\n" "X-Generator: Zanata 4.3.3\n" "Plural-Forms: nplurals=2; plural=(n > 1)\n" +msgid "10.0.0" +msgstr "10.0.0" + +msgid "11.0.1" +msgstr "11.0.1" + +msgid "12.0.0" +msgstr "12.0.0" + +msgid "12.0.0-4" +msgstr "12.0.0-4" + +msgid "13.0.0" +msgstr "13.0.0" + +msgid "14.0.1" +msgstr "14.0.1" + +msgid "15.0.0" +msgstr "15.0.0" + +msgid "19.0.0" +msgstr "19.0.0" + +msgid "2023.1 Series Release Notes" +msgstr "Notes de version pour la version 2023.1" + +msgid "2023.2 Series Release Notes" +msgstr "Notes de version pour la version 2023.2" + +msgid "2024.1 Series Release Notes" +msgstr "Notes de version pour la version 2024.1" + +msgid "2024.2 Series Release Notes" +msgstr "Notes de version pour la version 2024.2" + +msgid "2025.1 Series Release Notes" +msgstr "Notes de version pour la version 2025.1" + +msgid "2025.2 Series Release Notes" +msgstr "Notes de version pour la version 2025.2" + +msgid "21.0.0" +msgstr "21.0.0" + +msgid "21.0.0-19" +msgstr "21.0.0-19" + +msgid "8.1.0" +msgstr "8.1.0" + msgid ":ref:`genindex`" msgstr ":ref:`genindex`" msgid ":ref:`search`" msgstr ":ref:`search`" +msgid "Bug Fixes" +msgstr "Résolutions de bugs" + +msgid "CloudKitty Dashboard Release Notes" +msgstr "Notes de version pour le tableau de bord de CloudKitty" + msgid "Contents" msgstr "Contenu" +msgid "Current Series Release Notes" +msgstr "Notes de version pour la version actuelle" + msgid "Indices and tables" msgstr "Index et table des matières" @@ -32,6 +93,9 @@ msgstr "Nouvelles fonctionnalités" msgid "Ocata Series Release Notes" msgstr "Notes de version pour Ocata " +msgid "Other Notes" +msgstr "Autres notes" + msgid "Pike Series Release Notes" msgstr "Notes de version pour Pike" @@ -41,5 +105,61 @@ msgstr "Notes de version pour Queens" msgid "Rocky Series Release Notes" msgstr "Notes de version pour Rocky" +msgid "Stein Series Release Notes" +msgstr "Notes de version pour Stein" + +msgid "Support for Python 3.8 and 3.9 has been dropped." +msgstr "La prise en charge de Python 3.8 et 3.9 a été supprimée." + +msgid "" +"The CloudKitty dashboard now inherits the interface type from Horizon. This " +"allows for easier testing, like in an all-in-one to use the internalURL." +msgstr "" +"Le tableau de bord CloudKitty hérite désormais du type d'interface " +"d'Horizon. Cela facilite les tests, par exemple dans une solution tout-en-un " +"où l'on utilise l'option internalURL." + +msgid "" +"The predictive pricing has been updated. It is now possible to specify the " +"HashMap service to use for predictive pricing in Horizon's configuration " +"file through the ``CLOUDKITTY_QUOTATION_SERVICE`` option." +msgstr "" +"La tarification prédictive a été mise à jour. Il est désormais possible de " +"spécifier le service HashMap à utiliser pour la tarification prédictive dans " +"le fichier de configuration d'Horizon via l'option " +"``CLOUDKITTY_QUOTATION_SERVICE``." + +msgid "" +"The ratings panel in the project dashboard has been converted to use the v2 " +"API." +msgstr "" +"Dans le projet du tableau de bord , la fenêtre des taux a été mis à jour " +"pour utiliser l'API v2." + +msgid "Train Series Release Notes" +msgstr "Notes de version pour Train" + msgid "Upgrade Notes" msgstr "Notes de mises à jour" + +msgid "Upgrades the CloudKitty API from v1 to v2 in the admin/rating panel." +msgstr "" +"Mettez à jour l´API CloudKitty de la v1 à la v2 dans la fenêtre admin/rating." + +msgid "Ussuri Series Release Notes" +msgstr "Notes de version pour Ussuri" + +msgid "Victoria Series Release Notes" +msgstr "Notes de version pour Victoria" + +msgid "Wallaby Series Release Notes" +msgstr "Notes de version pour Wallaby" + +msgid "Xena Series Release Notes" +msgstr "Notes de version pour Xena" + +msgid "Yoga Series Release Notes" +msgstr "Notes de version pour Yoga" + +msgid "Zed Series Release Notes" +msgstr "Notes de version pour Zed"