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" }}
+
+
+
+
+
+
+
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 %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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"