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/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/_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/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/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/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/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/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')); + }); +} 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'] 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 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"