From c9ac1604a45ed6765c07d21aca6318a7f9282848 Mon Sep 17 00:00:00 2001 From: Leonie Chamberlin-Medd Date: Wed, 27 Aug 2025 14:42:06 +0000 Subject: [PATCH 1/5] 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 (cherry picked from commit 9e3657d84f53e60dd9a4f79a9d1b72d690727b2c) --- .../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 7f011a8d4534d6e64a0b8ee85989f82d74b8b9f1 Mon Sep 17 00:00:00 2001 From: Leonie Chamberlin-Medd Date: Wed, 13 Aug 2025 14:56:56 +0000 Subject: [PATCH 2/5] 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 (cherry picked from commit 6c76a41a582e2bf1b752e90aa8cb21972ecec9da) --- .../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 66c39c07068ac5817f472c290c8da93cbec6170c Mon Sep 17 00:00:00 2001 From: Carlos Giraldo Date: Tue, 26 May 2026 07:48:22 +0200 Subject: [PATCH 3/5] fix quote json: put resource description inside "metadata" field Change-Id: I81ae21f9aaa2b44eedf54a82f0110a1bc7816719 Signed-off-by: Carlos Giraldo --- .../static/cloudkitty/js/cloudkitty.controller.js | 2 +- cloudkittydashboard/static/cloudkitty/js/pricing.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cloudkittydashboard/static/cloudkitty/js/cloudkitty.controller.js b/cloudkittydashboard/static/cloudkitty/js/cloudkitty.controller.js index fb71ed0..5f7ad83 100644 --- a/cloudkittydashboard/static/cloudkitty/js/cloudkitty.controller.js +++ b/cloudkittydashboard/static/cloudkitty/js/cloudkitty.controller.js @@ -34,7 +34,7 @@ 'image_id': $scope.model.newInstanceSpec.source[0].id, } - var form_data = [{"desc": desc_form, "volume": $scope.model.newInstanceSpec.instance_count}]; + var form_data = [{"desc": {"metadata": desc_form }, "volume": $scope.model.newInstanceSpec.instance_count}]; $http.post($window.WEBROOT + 'project/rating/quote', form_data).then(function(res, status) { $scope.price = res.data; diff --git a/cloudkittydashboard/static/cloudkitty/js/pricing.js b/cloudkittydashboard/static/cloudkitty/js/pricing.js index 01972bd..ab216b4 100644 --- a/cloudkittydashboard/static/cloudkitty/js/pricing.js +++ b/cloudkittydashboard/static/cloudkitty/js/pricing.js @@ -78,7 +78,7 @@ pricing = { if (_image != undefined) { desc_form['image_id'] = _image.id } - var form_data = [{"desc": desc_form, "volume": instance_count}]; + var form_data = [{"desc": {"metadata": desc_form }, "volume": instance_count}]; // send the JSON by a POST request var url_data = [ From 58100fbc33a30985e0e8c5c55d72690112640578 Mon Sep 17 00:00:00 2001 From: Juan Larriba Date: Tue, 13 Jan 2026 11:55:21 +0100 Subject: [PATCH 4/5] Enhance rating dashboard with summary cards and admin manage panel This change enhances the project rating dashboard to display: - Summary cards for current month, forecasted month-end, and last month costs - Cost breakdown table with percentage progress bars - Cost breakdown comparison visualization - Top cost generators table Also enhances the Admin > Rating > Hashmap view to display all configured hashmap rating rules, allowing administrators to view the pricing applied to resources. Switches the reporting panel from v1 storage.get_dataframes API to v2 summary API for better performance on large date ranges, and defaults the datepicker to 1st-of-month through today. Assisted-By: claude-code opus 4.6 Change-Id: I1422135aaa294f4c9e1addd44a3aa70adedcd205 Signed-off-by: Juan Larriba (cherry picked from commit bf9df5acb1655945ea789c64dfca0bb85da19a32) --- .../templates/hashmap/services_list.html | 29 ++++ .../dashboards/admin/hashmap/views.py | 82 ++++++++- .../rating/templates/rating/groupby.html | 8 +- .../rating/templates/rating/index.html | 160 +++++++++++++++++- .../dashboards/project/rating/views.py | 151 +++++++++++++---- .../dashboards/project/reporting/views.py | 114 +++++++------ .../static/cloudkitty/css/grouping.css | 24 ++- cloudkittydashboard/utils.py | 2 +- ...hboard-summary-cards-5a454d412b294fc3.yaml | 12 ++ 9 files changed, 485 insertions(+), 97 deletions(-) create mode 100644 releasenotes/notes/enhance-rating-dashboard-summary-cards-5a454d412b294fc3.yaml diff --git a/cloudkittydashboard/dashboards/admin/hashmap/templates/hashmap/services_list.html b/cloudkittydashboard/dashboards/admin/hashmap/templates/hashmap/services_list.html index 1dbd77e..a069207 100644 --- a/cloudkittydashboard/dashboards/admin/hashmap/templates/hashmap/services_list.html +++ b/cloudkittydashboard/dashboards/admin/hashmap/templates/hashmap/services_list.html @@ -11,6 +11,35 @@ {{ table.render }} {{ modules }} + +

{% trans "Applied Rating Rules" %}

+ + + + + + + + + + + + {% for rule in rating_rules %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Service" %}{% trans "Field" %}{% trans "Value" %}{% trans "Type" %}{% trans "Cost" %}
{{ rule.service }}{{ rule.field }}{{ rule.value }}{{ rule.type }}{{ rule.cost_display }}
{% trans "No rating rules configured" %}
+ {% endblock %} diff --git a/cloudkittydashboard/dashboards/admin/hashmap/views.py b/cloudkittydashboard/dashboards/admin/hashmap/views.py index b981def..3d6b3c0 100644 --- a/cloudkittydashboard/dashboards/admin/hashmap/views.py +++ b/cloudkittydashboard/dashboards/admin/hashmap/views.py @@ -13,7 +13,7 @@ # under the License. -from cloudkittyclient import exc as ck_exc +from django.conf import settings from django.urls import reverse from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ @@ -21,18 +21,94 @@ from horizon import tables from horizon import tabs from horizon import views -from keystoneauth1 import exceptions from cloudkittydashboard.api import cloudkitty as api from cloudkittydashboard.dashboards.admin.hashmap import forms as hashmap_forms from cloudkittydashboard.dashboards.admin.hashmap \ import tables as hashmap_tables +from cloudkittydashboard import utils + +rate_prefix = getattr(settings, + 'OPENSTACK_CLOUDKITTY_RATE_PREFIX', None) +rate_postfix = getattr(settings, + 'OPENSTACK_CLOUDKITTY_RATE_POSTFIX', None) class IndexView(tables.DataTableView): table_class = hashmap_tables.ServicesTable template_name = "admin/hashmap/services_list.html" + def _get_rating_rules(self): + """Fetch all hashmap rating rules (services, fields, and mappings).""" + try: + client = api.cloudkittyclient(self.request, version='1') + hashmap = client.rating.hashmap + rating_rules = [] + + # Get all services + services_response = hashmap.get_service() + services = services_response.get('services', []) + + for service in services: + service_id = service.get('service_id') + service_name = service.get('name', 'Unknown') + + # Get service-level mappings (no field) + try: + service_mappings = hashmap.get_mapping( + service_id=service_id) + for mapping in service_mappings.get('mappings', []): + cost = float(mapping.get('cost', 0)) + rating_rules.append({ + 'service': service_name, + 'field': '-', + 'value': mapping.get('value') or '(all)', + 'type': mapping.get('type', 'flat'), + 'cost': cost, + 'cost_display': utils.formatRate( + cost, rate_prefix, rate_postfix), + }) + except Exception: + pass + + # Get fields for this service + try: + fields_response = hashmap.get_field(service_id=service_id) + fields = fields_response.get('fields', []) + + for field in fields: + field_id = field.get('field_id') + field_name = field.get('name', 'Unknown') + + # Get field-level mappings + try: + field_mappings = hashmap.get_mapping( + field_id=field_id) + for mapping in field_mappings.get('mappings', []): + cost = float(mapping.get('cost', 0)) + rating_rules.append({ + 'service': service_name, + 'field': field_name, + 'value': mapping.get('value') or '(all)', + 'type': mapping.get('type', 'flat'), + 'cost': cost, + 'cost_display': utils.formatRate( + cost, rate_prefix, rate_postfix), + }) + except Exception: + pass + except Exception: + pass + + return rating_rules + except Exception: + return [] + + def get_context_data(self, **kwargs): + context = super(IndexView, self).get_context_data(**kwargs) + context['rating_rules'] = self._get_rating_rules() + return context + def get_data(self): manager = api.cloudkittyclient(self.request) services = manager.rating.hashmap.get_service().get('services', []) @@ -42,7 +118,7 @@ def get_data(self): try: service = manager.info.get_metric(metric_name=s['name']) unit = service['unit'] - except (exceptions.NotFound, ck_exc.HTTPNotFound): + except Exception: unit = "-" list_services.append({ diff --git a/cloudkittydashboard/dashboards/project/rating/templates/rating/groupby.html b/cloudkittydashboard/dashboards/project/rating/templates/rating/groupby.html index f15084b..65b610a 100644 --- a/cloudkittydashboard/dashboards/project/rating/templates/rating/groupby.html +++ b/cloudkittydashboard/dashboards/project/rating/templates/rating/groupby.html @@ -13,11 +13,11 @@ -
-

{% trans "Group by:" %}

+ + {% trans "Group by:" %}
- - +
diff --git a/cloudkittydashboard/dashboards/project/rating/templates/rating/index.html b/cloudkittydashboard/dashboards/project/rating/templates/rating/index.html index 068df1b..78b5ed6 100644 --- a/cloudkittydashboard/dashboards/project/rating/templates/rating/index.html +++ b/cloudkittydashboard/dashboards/project/rating/templates/rating/index.html @@ -1,15 +1,163 @@ {% extends 'base.html' %} {% load i18n %} -{% block title %}{% trans "Rating Summary" %}{% endblock %} +{% block title %}{% trans "Rating Dashboard" %}{% endblock %} {% block page_header %} -{% include "horizon/common/_page_header.html" with title=_("Rating Summary") %} + {% include "horizon/common/_page_header.html" with title=_("Rating Dashboard") %} {% endblock page_header %} {% block main %} -{{ groupby_list|json_script:"groupby_list_config" }} -{% include "project/rating/groupby.html" %} -{{ table.render }} -{{ modules }} + +
+
+
+
+

{% trans "Current Month" %}

+ {{ current_month_name }} +
+
+

{{ current_month_total }}

+ {% trans "Day" %} {{ days_elapsed }} {% trans "of" %} {{ days_in_month }} +
+
+
+ +
+
+
+

{% trans "Forecasted Month End" %}

+ {% trans "Based on current usage" %} +
+
+

{{ forecast_total }}

+ {% trans "Projected total" %} +
+
+
+ +
+
+
+

{% trans "Last Month" %}

+ {{ last_month_name }} +
+
+

{{ last_month_total }}

+ {% trans "Total spent" %} +
+
+
+
+ + +
+
+
+
+

{% trans "Current Month Breakdown" %}

+
+
+ + + + + + + + + + {% for item in breakdown_data %} + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Metric Type" %}{% trans "Cost" %}{% trans "Percentage" %}
{{ item.type }}{{ item.rate_display }} +
+
+ {{ item.percentage }}% +
+
+
{% trans "No data available" %}
+
+
+
+ +
+
+
+

{% trans "Costs Breakdown Comparison" %}

+
+
+
+ {% for item in breakdown_data %} +
+
+ {{ item.type }} + {{ item.rate_display }} +
+
+
+
+
+
+ {% empty %} +

{% trans "No data available for chart" %}

+ {% endfor %} +
+
+
+
+
+ + +
+
+
+
+

{% trans "Top Cost Generators" %}

+
+
+ + + + + + + + + + {% for resource in top_resources %} + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Resource ID" %}{% trans "Type" %}{% trans "Current Cost" %}
{{ resource.resource_id|default:"-" }}{{ resource.type|default:"-" }}{{ resource.rate_display }}
{% trans "No resources found" %}
+
+
+
+
+ {% endblock %} diff --git a/cloudkittydashboard/dashboards/project/rating/views.py b/cloudkittydashboard/dashboards/project/rating/views.py index a435219..32ad4ad 100644 --- a/cloudkittydashboard/dashboards/project/rating/views.py +++ b/cloudkittydashboard/dashboards/project/rating/views.py @@ -11,19 +11,19 @@ # 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 calendar import monthrange +from datetime import datetime +from datetime import timedelta +from datetime import timezone import json from django.conf import settings from django import http from django.utils.translation import gettext_lazy as _ +from django.views import generic 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 from cloudkittydashboard import utils rate_prefix = getattr(settings, @@ -32,36 +32,127 @@ 'OPENSTACK_CLOUDKITTY_RATE_POSTFIX', None) -class IndexView(tables.DataTableView): - table_class = rating_tables.SummaryTable +class IndexView(generic.TemplateView): template_name = 'project/rating/index.html' + def _get_month_dates(self, year, month): + """Get start and end dates for a given month.""" + start = datetime(year, month, 1) + _, last_day = monthrange(year, month) + end = datetime(year, month, last_day, 23, 59, 59) + return start, end + + def _get_summary_for_period(self, client, tenant_id, begin, end, + groupby=None): + """Fetch summary data for a specific period.""" + try: + kwargs = { + 'tenant_id': tenant_id, + 'begin': begin.isoformat(), + 'end': end.isoformat(), + 'response_format': 'object' + } + if groupby: + kwargs['groupby'] = groupby + return client.summary.get_summary(**kwargs) + except Exception: + return {'results': [], 'total': 0} + + def _calculate_forecast(self, current_total, days_elapsed, days_in_month): + """Calculate forecasted month-end total based on current spending.""" + if days_elapsed <= 0: + return current_total + daily_rate = current_total / days_elapsed + return daily_rate * days_in_month + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['groupby_list'] = getattr(settings, - 'OPENSTACK_CLOUDKITTY_GROUPBY_LIST', - ['type']) - return context + client = api.cloudkittyclient(self.request, version='2') + tenant_id = self.request.user.tenant_id + + now = datetime.now(timezone.utc) + _, days_in_month = monthrange(now.year, now.month) + days_elapsed = now.day + + # Current month dates + current_month_start, current_month_end = self._get_month_dates( + now.year, now.month) + + # Last month dates + last_month = now.replace(day=1) - timedelta(days=1) + last_month_start, last_month_end = self._get_month_dates( + last_month.year, last_month.month) + + # Fetch current month summary by type + current_month_by_type = self._get_summary_for_period( + client, tenant_id, current_month_start, now, groupby=['type']) + current_month_data = current_month_by_type.get('results', []) + current_month_total = sum( + r.get('rate', 0) for r in current_month_data) - 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=groupby, response_format='object') - 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}] - - 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 + # Fetch last month summary + last_month_summary = self._get_summary_for_period( + client, tenant_id, last_month_start, last_month_end) + last_month_total = sum( + r.get('rate', 0) for r in last_month_summary.get('results', [])) + + # Calculate forecast + forecast_total = self._calculate_forecast( + current_month_total, days_elapsed, days_in_month) + + # Fetch top cost generators (group by resource_id) + top_resources = self._get_summary_for_period( + client, tenant_id, current_month_start, now, + groupby=['type', 'resource_id']) + top_resources_data = sorted( + top_resources.get('results', []), + key=lambda x: x.get('rate', 0), + reverse=True + )[:10] + + # Format rates for display + for item in current_month_data: + item['rate_display'] = utils.formatRate( + item['rate'], rate_prefix, rate_postfix) + + for item in top_resources_data: + item['rate_display'] = utils.formatRate( + item.get('rate', 0), rate_prefix, rate_postfix) + + # Prepare breakdown data for chart (percentages) + breakdown_data = [] + for item in current_month_data: + percentage = (item['rate'] / current_month_total * 100 + if current_month_total > 0 else 0) + percentage_rounded = round(percentage, 1) + breakdown_data.append({ + 'type': item.get('type', 'Unknown'), + 'rate': item.get('rate', 0), + 'rate_display': item['rate_display'], + 'percentage': percentage_rounded, + 'percentage_css': str(percentage_rounded).replace(',', '.') + }) + + context.update({ + 'current_month_total': utils.formatRate( + current_month_total, rate_prefix, rate_postfix), + 'current_month_total_raw': current_month_total, + 'last_month_total': utils.formatRate( + last_month_total, rate_prefix, rate_postfix), + 'last_month_total_raw': last_month_total, + 'forecast_total': utils.formatRate( + forecast_total, rate_prefix, rate_postfix), + 'forecast_total_raw': forecast_total, + 'breakdown_data': breakdown_data, + 'breakdown_data_json': json.dumps(breakdown_data), + 'top_resources': top_resources_data, + 'current_month_name': now.strftime('%B %Y'), + 'last_month_name': last_month.strftime('%B %Y'), + 'days_elapsed': days_elapsed, + 'days_in_month': days_in_month, + }) + + return context def quote(request): diff --git a/cloudkittydashboard/dashboards/project/reporting/views.py b/cloudkittydashboard/dashboards/project/reporting/views.py index de834df..ab54cb5 100644 --- a/cloudkittydashboard/dashboards/project/reporting/views.py +++ b/cloudkittydashboard/dashboards/project/reporting/views.py @@ -29,50 +29,61 @@ from cloudkittydashboard import forms -def _do_this_month(data): +def _build_reporting_data(client, tenant_id, begin, end): + """Build reporting data using v2 summary API. + + Returns a dict of services with cumulated totals (for the pie chart) + and daily breakdown (for the Rickshaw time-series graph). + """ services = {} - # these variables will keep track of the time span to fill the dicts with - # empty values after the parsing. This is needed by rickshaw to display - # stacked graphs - start_timestamp = None - 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() - ) + # Get cumulated totals by type (single fast API call) + try: + summary = client.summary.get_summary( + tenant_id=tenant_id, + begin=begin, end=end, + groupby=['type'], + response_format='object' ) - if start_timestamp is None or timestamp < start_timestamp: - start_timestamp = timestamp - if end_timestamp is None or timestamp > end_timestamp: - end_timestamp = timestamp - - for resource in dataframe['resources']: - service_id = resource['service'] - service_data = services.setdefault( - service_id, {'cumulated': 0, 'hourly': {}}) - service_data['cumulated'] += decimal.Decimal(resource['rating']) - hourly_data = service_data['hourly'] - hourly_data.setdefault(timestamp, 0) - hourly_data[timestamp] += float(resource['rating']) - - service_names = services.keys() - t = start_timestamp - if end_timestamp: - while t <= end_timestamp: - for service in service_names: - hourly_d = services[service]['hourly'] - hourly_d.setdefault(t, 0) - t += 3600 - - # now sort the dicts - for service in service_names: - d = services[service]['hourly'] - services[service]['hourly'] = collections.OrderedDict( - sorted(d.items(), key=lambda t: t[0])) + except Exception: + return {} + + for item in summary.get('results', []): + service_id = item.get('type', 'Unknown') + services[service_id] = { + 'cumulated': decimal.Decimal(str(item.get('rate', 0))), + 'hourly': collections.OrderedDict() + } + + # Get daily breakdown for time-series chart + start_dt = datetime.datetime.strptime(begin[:10], "%Y-%m-%d") + end_dt = datetime.datetime.strptime(end[:10], "%Y-%m-%d") + current = start_dt + while current <= end_dt: + day_begin = current.strftime("%Y-%m-%dT00:00:00") + day_end = current.strftime("%Y-%m-%dT23:59:59") + timestamp = int(time.mktime(current.timetuple())) + + # Initialize all services for this timestamp + for service_id in services: + services[service_id]['hourly'][timestamp] = 0 + + try: + day_summary = client.summary.get_summary( + tenant_id=tenant_id, + begin=day_begin, end=day_end, + groupby=['type'], + response_format='object' + ) + for item in day_summary.get('results', []): + service_id = item.get('type', 'Unknown') + if service_id in services: + services[service_id]['hourly'][timestamp] = float( + item.get('rate', 0)) + except Exception: + pass + + current += datetime.timedelta(days=1) return services @@ -118,19 +129,18 @@ def get_context_data(self, request, **kwargs): "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) + begin = "%4d-%02d-01T00:00:00" % (today.year, today.month) + end = "%4d-%02d-%02dT23:59:59" % (today.year, today.month, + today.day) 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) + begin = "%4d-%02d-01T00:00:00" % (today.year, today.month) + end = "%4d-%02d-%02dT23:59:59" % (today.year, today.month, + today.day) + + client = api.cloudkittyclient(request, version='2') + parsed_data = _build_reporting_data( + client, request.user.tenant_id, begin, end) return {'repartition_data': parsed_data, 'form': form} @property diff --git a/cloudkittydashboard/static/cloudkitty/css/grouping.css b/cloudkittydashboard/static/cloudkitty/css/grouping.css index 94a0834..52d6081 100644 --- a/cloudkittydashboard/static/cloudkitty/css/grouping.css +++ b/cloudkittydashboard/static/cloudkitty/css/grouping.css @@ -25,4 +25,26 @@ font-weight: normal; .group-checkbox { margin-right: 5px; -} \ No newline at end of file +} + +.groupby-inline { +display: flex; +align-items: center; +gap: 10px; +margin-bottom: 15px; +} + +.groupby-inline strong { +white-space: nowrap; +} + +.groupby-inline #checkboxes { +display: flex; +align-items: center; +gap: 8px; +margin-bottom: 0; +} + +.groupby-inline .btn { +margin: 0; +} diff --git a/cloudkittydashboard/utils.py b/cloudkittydashboard/utils.py index 8701819..513d738 100644 --- a/cloudkittydashboard/utils.py +++ b/cloudkittydashboard/utils.py @@ -27,7 +27,7 @@ def __setattr__(self, key, val): def formatRate(rate: float, prefix: str, postfix: str) -> str: - rate = str(rate) + rate = "{:.2f}".format(round(rate, 2)) if prefix: rate = prefix + rate if postfix: diff --git a/releasenotes/notes/enhance-rating-dashboard-summary-cards-5a454d412b294fc3.yaml b/releasenotes/notes/enhance-rating-dashboard-summary-cards-5a454d412b294fc3.yaml new file mode 100644 index 0000000..a80b5ea --- /dev/null +++ b/releasenotes/notes/enhance-rating-dashboard-summary-cards-5a454d412b294fc3.yaml @@ -0,0 +1,12 @@ +--- +features: + - | + The project rating dashboard has been enhanced with summary cards + showing current month cost, forecasted month-end total, and last + month cost. A cost breakdown table with percentage bars and a top + cost generators table have also been added. + - | + The admin hashmap panel now displays all configured rating rules + (services, fields, and mappings) in a summary table, giving + administrators a quick overview of the pricing applied to resources. + From 75951c5e173a91825eb596d329438edda62c3bd3 Mon Sep 17 00:00:00 2001 From: OpenStack Proposal Bot Date: Tue, 16 Jun 2026 02:14:08 +0000 Subject: [PATCH 5/5] Imported Translations from Zanata For more information about this automatic import see: https://docs.openstack.org/i18n/latest/reviewing-translation-import.html Change-Id: I7acaa976d4e9b751ab9c31bb17c07e988e15688d Signed-off-by: OpenStack Proposal Bot Generated-By: openstack/openstack-zuul-jobs:roles/prepare-zanata-client/files/common_translation_update.sh --- .../locale/ru/LC_MESSAGES/django.po | 178 +++++++++++++- .../locale/de/LC_MESSAGES/releasenotes.po | 69 ------ .../locale/en_GB/LC_MESSAGES/releasenotes.po | 231 ------------------ .../locale/fr/LC_MESSAGES/releasenotes.po | 45 ---- .../locale/ru/LC_MESSAGES/releasenotes.po | 226 ----------------- 5 files changed, 169 insertions(+), 580 deletions(-) delete mode 100644 releasenotes/source/locale/de/LC_MESSAGES/releasenotes.po delete mode 100644 releasenotes/source/locale/en_GB/LC_MESSAGES/releasenotes.po delete mode 100644 releasenotes/source/locale/fr/LC_MESSAGES/releasenotes.po delete mode 100644 releasenotes/source/locale/ru/LC_MESSAGES/releasenotes.po diff --git a/cloudkittydashboard/locale/ru/LC_MESSAGES/django.po b/cloudkittydashboard/locale/ru/LC_MESSAGES/django.po index db1d86e..ea290c5 100644 --- a/cloudkittydashboard/locale/ru/LC_MESSAGES/django.po +++ b/cloudkittydashboard/locale/ru/LC_MESSAGES/django.po @@ -1,14 +1,13 @@ -# Dmitriy Chubinidze , 2025. #zanata -# Ivan Anfimov , 2025. #zanata +# Ivan Anfimov , 2026. #zanata msgid "" msgstr "" "Project-Id-Version: cloudkitty-dashboard VERSION\n" "Report-Msgid-Bugs-To: https://bugs.launchpad.net/openstack-i18n/\n" -"POT-Creation-Date: 2025-11-24 11:31+0000\n" +"POT-Creation-Date: 2026-06-15 12:56+0000\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"PO-Revision-Date: 2025-11-24 11:30+0000\n" +"PO-Revision-Date: 2026-06-15 10:09+0000\n" "Last-Translator: Ivan Anfimov \n" "Language-Team: Russian\n" "Language: ru\n" @@ -19,7 +18,23 @@ msgstr "" msgid "$" msgstr "$" -msgid "A mapping is the final object, it’s what triggers calculation." +msgid "A field is referring to a metadata field of a resource." +msgstr "Поле ссылается на поле метаданных ресурса." + +msgid "" +"A group lets you organize mapping calculations into separate sets. For " +"example, you might create one group with rules for rating instance uptime " +"and another for evaluating block storage volumes. Keeping them in separate " +"groups ensures the calculations remain independent and don’t interfere with " +"each other." +msgstr "" +"Группа позволяет организовать вычисления сопоставления в отдельные наборы. " +"Например, можно создать одну группу с правилами оценки времени бесперебойной " +"работы инстанса, а другую — для оценки объёмов блочного хранилища. " +"Разделение групп гарантирует независимость вычислений и отсутствие помех " +"друг другу." + +msgid "A mapping is the final object that triggers calculation." msgstr "Сопоставление — это конечный объект, именно он запускает расчет." msgid "A script or set of python commands to modify rating calculations." @@ -38,9 +53,21 @@ msgstr "" "уровня. Его поведение похоже на сопоставление, за исключением того, что оно " "применяет базовую стоимость на основе уровня." +msgid "Applied Rating Rules" +msgstr "Примененные правила оценки" + +msgid "Based on current usage" +msgstr "Исходя из текущего использования" + msgid "Checksum" msgstr "Контрольная сумма" +msgid "Click on a metric to remove it from the pie chart." +msgstr "Нажмите на показатель, чтобы удалить его с круговой диаграммы." + +msgid "Cloud Total" +msgstr "Облака всего" + msgid "Configurable" msgstr "Настраиваемый" @@ -50,6 +77,9 @@ msgstr "Стоимость" msgid "Cost Per Service Per Hour" msgstr "Стоимость услуги в час" +msgid "Costs Breakdown Comparison" +msgstr "Сравнение структуры затрат" + msgid "Create Field" msgstr "Создать поле" @@ -98,6 +128,15 @@ msgstr "Создать новый порог услуги" msgid "Cumulative Cost Repartition" msgstr "Перераспределение совокупной стоимости" +msgid "Current Cost" +msgstr "Текущая стоимость" + +msgid "Current Month" +msgstr "Текущий месяц" + +msgid "Current Month Breakdown" +msgstr "Разбивка по текущему месяцу" + msgid "Custom service" msgstr "Пользовательская услуга" @@ -111,6 +150,12 @@ msgstr "" msgid "Data" msgstr "Данные" +msgid "Day" +msgstr "День" + +msgid "Day View" +msgstr "Просмотр по дням" + msgid "Delete Field" msgid_plural "Delete Fields" msgstr[0] "Удалить поле" @@ -237,6 +282,10 @@ msgstr "Изменить порог услуги" msgid "Edit module priority" msgstr "Изменить приоритет модуля" +#, python-format +msgid "Edit the priority for the %(module_id)s module." +msgstr "Изменить приоритет для модуля %(module_id)s." + msgid "Enable Module" msgid_plural "Enable Modules" msgstr[0] "Включить модуль" @@ -279,6 +328,9 @@ msgstr "Файл" msgid "Flat" msgstr "Фиксированный" +msgid "Forecasted Month End" +msgstr "Прогнозируемый конец месяца" + msgid "Group" msgstr "Группа" @@ -288,6 +340,9 @@ msgstr "Подробности группы" msgid "Group Name" msgstr "Имя группы" +msgid "Group by:" +msgstr "Группировать по:" + msgid "Group was successfully created" msgstr "Группа была успешно создана" @@ -303,6 +358,38 @@ msgstr "Активная конфигурация" msgid "Id" msgstr "Id" +msgid "Invalid date format: Using this month as default." +msgstr "Неверный формат даты: по умолчанию используется этот месяц." + +msgid "" +"Invalid time period. The end date should be more recent than the start date. " +"Setting the end as today." +msgstr "" +"Неверный временной период. Дата окончания должна быть более поздней, чем " +"дата начала. Установлена ​​дата окончания на сегодняшний день." + +msgid "" +"Invalid time period. You are requesting data from the future which may not " +"exist." +msgstr "" +"Неверный временной период. Вы запрашиваете данные из будущего, которого " +"может и не существовать." + +msgid "Last 3 Months" +msgstr "Прошлые 3 месяца" + +msgid "Last 6 Months" +msgstr "Прошлые 6 месяцев" + +msgid "Last Month" +msgstr "Прошлый месяц" + +msgid "Last Week" +msgstr "Прошлая неделя" + +msgid "Last Year" +msgstr "Прошлый год" + msgid "Legend" msgstr "Легенда" @@ -327,12 +414,39 @@ msgstr "Модуль" msgid "Modules" msgstr "Модули" +msgid "Month View" +msgstr "Просмотр по месяцам" + msgid "Name" msgstr "Имя" +msgid "No IDs found" +msgstr "ID не найдены" + +msgid "No data available" +msgstr "Данные отсутствуют" + +msgid "No data available for chart" +msgstr "Данные для диаграммы отсутствуют" + +msgid "No rating rules configured" +msgstr "Правила оценки не настроены" + +msgid "No resources found" +msgstr "Ресурсы не найдены" + msgid "Not available" msgstr "Недоступно" +msgid "Percentage" +msgstr "Процент" + +msgid "Period Range" +msgstr "Диапазон периодов" + +msgid "Preset Ranges" +msgstr "Заданные диапазоны" + msgid "Price" msgstr "Цена" @@ -360,6 +474,9 @@ msgstr "Сводка по проекту" msgid "Project Total" msgstr "Итого по проекту" +msgid "Projected total" +msgstr "Прогнозируемый итог" + msgid "PyScript" msgstr "PyScript" @@ -372,6 +489,9 @@ msgstr "Процентный" msgid "Rating" msgstr "Оценка" +msgid "Rating Dashboard" +msgstr "Панель оценок" + msgid "Rating Information" msgstr "Информация об оценке" @@ -390,8 +510,8 @@ msgstr "Сводка оценок" msgid "Reporting" msgstr "Отчетность" -msgid "Res Type" -msgstr "Тип результата" +msgid "Resource ID" +msgstr "ID ресурса" msgid "Script Data" msgstr "Данные скрипта" @@ -408,8 +528,11 @@ msgstr "ID скрипта" msgid "Script details: {{ script.name }}" msgstr "Подробности скрипта: {{ script.name }}" -msgid "Script details: {{ table.project_id }}" -msgstr "Подробности скрипта: {{ table.project_id }}" +msgid "Select All" +msgstr "Выбрать все" + +msgid "Select a period of time to view data in:" +msgstr "Выберите период времени для просмотра данных:" msgid "Service" msgstr "Услуга" @@ -441,6 +564,9 @@ msgstr "Услуги" msgid "Services are provided by main collector." msgstr "Услуги предоставляются основным сборщиком." +msgid "Submit" +msgstr "Отправить" + msgid "Successfully created script" msgstr "Скрипт успешно создан" @@ -453,16 +579,28 @@ msgstr "Скрипт успешно обновлен" msgid "Summary" msgstr "Сводка" +msgid "The date should be in YYYY-MM-DD format." +msgstr "Дата должна быть в формате ГГГГ-ММ-ДД." + #, python-format msgid "There was a problem parsing the %(prefix)s: %(error)s" msgstr "Возникла проблема при парсинге %(prefix)s: %(error)s" +msgid "This month" +msgstr "В этом месяце" + msgid "Threshold ID" msgstr "ID порога" msgid "Thresholds" msgstr "Пороги" +msgid "Top Cost Generators" +msgstr "Генераторы самых высоких затрат" + +msgid "Total spent" +msgstr "Израсходовано всего" + msgid "Type" msgstr "Тип" @@ -490,6 +628,9 @@ msgstr "Не удалось обновить скрипт." msgid "Unit" msgstr "Единица" +msgid "Unselect All" +msgstr "Снять выбор со всех" + msgid "Update Field Mapping" msgstr "Обновить сопоставление поля" @@ -505,11 +646,30 @@ msgstr "Обновить скрипт" msgid "Update Service Threshold" msgstr "Обновить порог услуги" +#, python-format +msgid "Usage data is collected every %(period)s seconds." +msgstr "Данные о потреблении собираются каждые %(period)s сек.." + msgid "Value" msgstr "Значение" +msgid "Week View" +msgstr "Просмотр по неделям" + +msgid "Year View" +msgstr "Просмотр по годам" + +msgid "Yesterday" +msgstr "Вчера" + msgid "id" msgstr "id" +msgid "of" +msgstr "из" + msgid "pyscripts" msgstr "pyscripts" + +msgid "to" +msgstr "до" diff --git a/releasenotes/source/locale/de/LC_MESSAGES/releasenotes.po b/releasenotes/source/locale/de/LC_MESSAGES/releasenotes.po deleted file mode 100644 index d750b2c..0000000 --- a/releasenotes/source/locale/de/LC_MESSAGES/releasenotes.po +++ /dev/null @@ -1,69 +0,0 @@ -# Andreas Jaeger , 2018. #zanata -# Andreas Jaeger , 2019. #zanata -# Andreas Jaeger , 2020. #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" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"PO-Revision-Date: 2020-04-25 09:14+0000\n" -"Last-Translator: Andreas Jaeger \n" -"Language-Team: German\n" -"Language: de\n" -"X-Generator: Zanata 4.3.3\n" -"Plural-Forms: nplurals=2; plural=(n != 1)\n" - -msgid "8.1.0" -msgstr "8.1.0" - -msgid ":ref:`genindex`" -msgstr ":ref:`genindex`" - -msgid ":ref:`search`" -msgstr ":ref:`search`" - -msgid "Contents" -msgstr "Inhalt" - -msgid "Current Series Release Notes" -msgstr "Aktuelle Serie Releasenotes" - -msgid "Indices and tables" -msgstr "Indizes und Tabellen" - -msgid "New Features" -msgstr "Neue Funktionen" - -msgid "Ocata Series Release Notes" -msgstr "Ocata Serie Releasenotes" - -msgid "Pike Series Release Notes" -msgstr "Pike Serie Releasenotes" - -msgid "" -"Python 2.7 support has been dropped. Last release of cloudkitty-dashboard to " -"support py2.7 is OpenStack Train. The minimum version of Python now " -"supported by cloudkitty-dashboard is Python 3.6." -msgstr "" -"Python 2.7 Unterstützung wurde beendet. Der letzte Release von cloudkitty-" -"dashboard welcher Python 2.7 unterstützt ist OpenStack Train. Die minimal " -"Python Version welche von cloudkitty-dashboard unterstützt wird ist Python " -"3.6." - -msgid "Queens Series Release Notes" -msgstr "Queens Serie Releasenotes" - -msgid "Rocky Series Release Notes" -msgstr "Rocky Serie Releasenotes" - -msgid "Stein Series Release Notes" -msgstr "Stein Serie Releasenotes" - -msgid "Train Series Release Notes" -msgstr "Train Serie Releasenotes" - -msgid "Upgrade Notes" -msgstr "Aktualisierungsnotizen" diff --git a/releasenotes/source/locale/en_GB/LC_MESSAGES/releasenotes.po b/releasenotes/source/locale/en_GB/LC_MESSAGES/releasenotes.po deleted file mode 100644 index 160ae00..0000000 --- a/releasenotes/source/locale/en_GB/LC_MESSAGES/releasenotes.po +++ /dev/null @@ -1,231 +0,0 @@ -# Andi Chandler , 2018. #zanata -# Andi Chandler , 2019. #zanata -# Andi Chandler , 2020. #zanata -# Andi Chandler , 2021. #zanata -# Andi Chandler , 2022. #zanata -# Andi Chandler , 2023. #zanata -# Andi Chandler , 2024. #zanata -# Andi Chandler , 2026. #zanata -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" -"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" -"Last-Translator: Andi Chandler \n" -"Language-Team: English (United Kingdom)\n" -"Language: en_GB\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 "2023.1 Series Release Notes" - -msgid "2023.2 Series Release Notes" -msgstr "2023.2 Series Release Notes" - -msgid "2024.1 Series Release Notes" -msgstr "2024.1 Series Release Notes" - -msgid "2024.2 Series Release Notes" -msgstr "2024.2 Series Release Notes" - -msgid "2025.1 Series Release Notes" -msgstr "2025.1 Series Release Notes" - -msgid "2025.2 Series Release Notes" -msgstr "2025.2 Series Release Notes" - -msgid "21.0.0" -msgstr "21.0.0" - -msgid "8.1.0" -msgstr "8.1.0" - -msgid ":ref:`genindex`" -msgstr ":ref:`genindex`" - -msgid ":ref:`search`" -msgstr ":ref:`search`" - -msgid "" -"Adds optional Horizon settings variable OPENSTACK_CLOUDKITTY_RATE_PREFIX and " -"OPENSTACK_CLOUDKITTY_RATE_POSTFIX. These allow users to attach pre/postfix " -"to their rate vaules shown at the dashboard such as currency. These values " -"can be set in ``.py`` settings snippets under ``openstack_dashboard/local/" -"local_settings.d`` directory. Follow https://docs.openstack.org/horizon/" -"latest/configuration/settings.html for more details." -msgstr "" -"Adds optional Horizon settings variable OPENSTACK_CLOUDKITTY_RATE_PREFIX and " -"OPENSTACK_CLOUDKITTY_RATE_POSTFIX. These allow users to attach pre/postfix " -"to their rate vaules shown at the dashboard such as currency. These values " -"can be set in ``.py`` settings snippets under ``openstack_dashboard/local/" -"local_settings.d`` directory. Follow https://docs.openstack.org/horizon/" -"latest/configuration/settings.html for more details." - -msgid "" -"An \"Admin/Rating Summary\" tab has been added. An admin user can now have " -"the cost of every rated tenant at once. By clicking on a tenant, a per-" -"resource total for the given tenant can be obtained (this view is similar to " -"the \"Project/Rating\" tab). A per-resource total for the whole cloud is " -"also available." -msgstr "" -"An \"Admin/Rating Summary\" tab has been added. An admin user can now have " -"the cost of every rated tenant at once. By clicking on a tenant, a per-" -"resource total for the given tenant can be obtained (this view is similar to " -"the \"Project/Rating\" tab). A per-resource total for the whole cloud is " -"also available." - -msgid "Bug Fixes" -msgstr "Bug Fixes" - -msgid "CloudKitty Dashboard Release Notes" -msgstr "CloudKitty Dashboard Release Notes" - -msgid "Contents" -msgstr "Contents" - -msgid "Current Series Release Notes" -msgstr "Current Series Release Notes" - -msgid "" -"Fixes compatibility with Horizon 21.0.0 and newer following the removal of " -"the Django-based implementation of launch instance." -msgstr "" -"Fixes compatibility with Horizon 21.0.0 and newer following the removal of " -"the Django-based implementation of launch instance." - -msgid "Indices and tables" -msgstr "Indices and tables" - -msgid "New Features" -msgstr "New Features" - -msgid "Ocata Series Release Notes" -msgstr "Ocata Series Release Notes" - -msgid "Other Notes" -msgstr "Other Notes" - -msgid "Pike Series Release Notes" -msgstr "Pike Series Release Notes" - -msgid "" -"Python 2.7 support has been dropped. Last release of cloudkitty-dashboard to " -"support py2.7 is OpenStack Train. The minimum version of Python now " -"supported by cloudkitty-dashboard is Python 3.6." -msgstr "" -"Python 2.7 support has been dropped. Last release of cloudkitty-dashboard to " -"support py2.7 is OpenStack Train. The minimum version of Python now " -"supported by cloudkitty-dashboard is Python 3.6." - -msgid "Queens Series Release Notes" -msgstr "Queens Series Release Notes" - -msgid "Rocky Series Release Notes" -msgstr "Rocky Series Release Notes" - -msgid "Stein Series Release Notes" -msgstr "Stein Series Release Notes" - -msgid "Support for Python 3.8 and 3.9 has been dropped." -msgstr "Support for Python 3.8 and 3.9 has been dropped." - -msgid "" -"The \"Cost Per Service Per Hour\" graph no longer stacks series on the Y " -"axis." -msgstr "" -"The \"Cost Per Service Per Hour\" graph no longer stacks series on the Y " -"axis." - -msgid "" -"The \"Project/Rating\" tab has been improved: it does now provide a total by " -"metric type. This make use of the /summary endpoint instead of /total (/" -"total is deprecated)." -msgstr "" -"The \"Project/Rating\" tab has been improved: it does now provide a total by " -"metric type. This make use of the /summary endpoint instead of /total (/" -"total is deprecated)." - -msgid "" -"The \"reporting\" tab has been reworked and the dashboard does not require " -"D3pie anymore. The colors between the charts are now consistent and a color " -"legend has been added." -msgstr "" -"The \"reporting\" tab has been reworked and the dashboard does not require " -"D3pie anymore. The colors between the charts are now consistent and a colour " -"legend has been added." - -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 "" -"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." - -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 "" -"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." - -msgid "" -"The ratings panel in the project dashboard has been converted to use the v2 " -"API." -msgstr "" -"The ratings panel in the project dashboard has been converted to use the v2 " -"API." - -msgid "Train Series Release Notes" -msgstr "Train Series Release Notes" - -msgid "Upgrade Notes" -msgstr "Upgrade Notes" - -msgid "Ussuri Series Release Notes" -msgstr "Ussuri Series Release Notes" - -msgid "Victoria Series Release Notes" -msgstr "Victoria Series Release Notes" - -msgid "Wallaby Series Release Notes" -msgstr "Wallaby Series Release Notes" - -msgid "Xena Series Release Notes" -msgstr "Xena Series Release Notes" - -msgid "Yoga Series Release Notes" -msgstr "Yoga Series Release Notes" - -msgid "Zed Series Release Notes" -msgstr "Zed Series Release Notes" diff --git a/releasenotes/source/locale/fr/LC_MESSAGES/releasenotes.po b/releasenotes/source/locale/fr/LC_MESSAGES/releasenotes.po deleted file mode 100644 index 5344ae2..0000000 --- a/releasenotes/source/locale/fr/LC_MESSAGES/releasenotes.po +++ /dev/null @@ -1,45 +0,0 @@ -# François Magimel , 2018. #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" -"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" -"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 ":ref:`genindex`" -msgstr ":ref:`genindex`" - -msgid ":ref:`search`" -msgstr ":ref:`search`" - -msgid "Contents" -msgstr "Contenu" - -msgid "Indices and tables" -msgstr "Index et table des matières" - -msgid "New Features" -msgstr "Nouvelles fonctionnalités" - -msgid "Ocata Series Release Notes" -msgstr "Notes de version pour Ocata " - -msgid "Pike Series Release Notes" -msgstr "Notes de version pour Pike" - -msgid "Queens Series Release Notes" -msgstr "Notes de version pour Queens" - -msgid "Rocky Series Release Notes" -msgstr "Notes de version pour Rocky" - -msgid "Upgrade Notes" -msgstr "Notes de mises à jour" diff --git a/releasenotes/source/locale/ru/LC_MESSAGES/releasenotes.po b/releasenotes/source/locale/ru/LC_MESSAGES/releasenotes.po deleted file mode 100644 index b064fd0..0000000 --- a/releasenotes/source/locale/ru/LC_MESSAGES/releasenotes.po +++ /dev/null @@ -1,226 +0,0 @@ -# Dmitriy Chubinidze , 2025. #zanata -# Ivan Anfimov , 2025. #zanata -msgid "" -msgstr "" -"Project-Id-Version: Cloudkitty Dashboard Release Notes\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-08 10:48+0000\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"PO-Revision-Date: 2025-11-19 10:58+0000\n" -"Last-Translator: Ivan Anfimov \n" -"Language-Team: Russian\n" -"Language: ru\n" -"X-Generator: Zanata 4.3.3\n" -"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " -"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\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 "Примечания к выпуску 2023.1" - -msgid "2023.2 Series Release Notes" -msgstr "Примечания к выпуску 2023.2" - -msgid "2024.1 Series Release Notes" -msgstr "Примечания к выпуску 2024.1" - -msgid "2024.2 Series Release Notes" -msgstr "Примечания к выпуску 2024.2" - -msgid "2025.1 Series Release Notes" -msgstr "Примечания к выпуску 2025.1" - -msgid "2025.2 Series Release Notes" -msgstr "Примечания к выпуску 2025.2" - -msgid "21.0.0" -msgstr "21.0.0" - -msgid "8.1.0" -msgstr "8.1.0" - -msgid ":ref:`genindex`" -msgstr ":ref:`genindex`" - -msgid ":ref:`search`" -msgstr ":ref:`search`" - -msgid "" -"Adds optional Horizon settings variable OPENSTACK_CLOUDKITTY_RATE_PREFIX and " -"OPENSTACK_CLOUDKITTY_RATE_POSTFIX. These allow users to attach pre/postfix " -"to their rate vaules shown at the dashboard such as currency. These values " -"can be set in ``.py`` settings snippets under ``openstack_dashboard/local/" -"local_settings.d`` directory. Follow https://docs.openstack.org/horizon/" -"latest/configuration/settings.html for more details." -msgstr "" -"Добавлены опциональные переменные настроек Horizon " -"OPENSTACK_CLOUDKITTY_RATE_PREFIX и OPENSTACK_CLOUDKITTY_RATE_POSTFIX. Они " -"позволяют пользователям добавлять префиксы и постфиксы к значениям курса, " -"отображаемым в панели управления, например, к валюте. Эти значения можно " -"задать в файлах настроек ``.py`` в каталоге ``openstack_dashboard/local/" -"local_settings.d``. Подробнее см. на странице https://docs.openstack.org/" -"horizon/latest/configuration/settings.html" - -msgid "" -"An \"Admin/Rating Summary\" tab has been added. An admin user can now have " -"the cost of every rated tenant at once. By clicking on a tenant, a per-" -"resource total for the given tenant can be obtained (this view is similar to " -"the \"Project/Rating\" tab). A per-resource total for the whole cloud is " -"also available." -msgstr "" -"Добавлена ​​вкладка \"Администратор/Сводка оценок\". Теперь администратор " -"может видеть стоимость каждого оцененного проекта сразу. Щелкнув по проекту, " -"можно получить общую стоимость по каждому ресурсу для данного проекта (это " -"представление аналогично вкладке \"Проект/Оценка\"). Также доступна общая " -"стоимость по ресурсу для всего облака." - -msgid "Bug Fixes" -msgstr "Исправления ошибок" - -msgid "CloudKitty Dashboard Release Notes" -msgstr "Примечания к выпуску панели управления CloudKitty" - -msgid "Contents" -msgstr "Содержание" - -msgid "Current Series Release Notes" -msgstr "Примечания к текущему выпуску" - -msgid "" -"Fixes compatibility with Horizon 21.0.0 and newer following the removal of " -"the Django-based implementation of launch instance." -msgstr "" -"Исправлена ​​совместимость с Horizon 21.0.0 и более поздними версиями после " -"удаления реализации запуска инстанса на базе Django." - -msgid "Indices and tables" -msgstr "Указатели и таблицы" - -msgid "New Features" -msgstr "Новые возможности" - -msgid "Ocata Series Release Notes" -msgstr "Примечания к выпуску Ocata" - -msgid "Other Notes" -msgstr "Другие примечания" - -msgid "Pike Series Release Notes" -msgstr "Примечания к выпуску Pike" - -msgid "" -"Python 2.7 support has been dropped. Last release of cloudkitty-dashboard to " -"support py2.7 is OpenStack Train. The minimum version of Python now " -"supported by cloudkitty-dashboard is Python 3.6." -msgstr "" -"Поддержка Python 2.7 была прекращена. Последний выпуск cloudkitty-dashboard, " -"поддерживающий py2.7 — OpenStack Train. Минимальная версия Python, " -"поддерживаемая cloudkitty-dashboard — Python 3.6." - -msgid "Queens Series Release Notes" -msgstr "Примечания к выпуску Queens" - -msgid "Rocky Series Release Notes" -msgstr "Примечания к выпуску Rocky" - -msgid "Stein Series Release Notes" -msgstr "Примечания к выпуску Stein" - -msgid "Support for Python 3.8 and 3.9 has been dropped." -msgstr "Поддержка Python 3.8 и 3.9 была прекращена." - -msgid "" -"The \"Cost Per Service Per Hour\" graph no longer stacks series on the Y " -"axis." -msgstr "График \"Стоимость услуги в час\" больше не суммирует ряды на оси Y." - -msgid "" -"The \"Project/Rating\" tab has been improved: it does now provide a total by " -"metric type. This make use of the /summary endpoint instead of /total (/" -"total is deprecated)." -msgstr "" -"Вкладка \"Проект/Оценка\" была улучшена: теперь она отображает итоговые " -"данные по метрикам. Это позволяет использовать точку доступа /summary, " -"вместо /total (/total - устарело)." - -msgid "" -"The \"reporting\" tab has been reworked and the dashboard does not require " -"D3pie anymore. The colors between the charts are now consistent and a color " -"legend has been added." -msgstr "" -"Вкладка \"Отчетность\" была переработана, и для панели управления больше не " -"требуется D3pie. Цвета на диаграммах теперь согласованы, и добавлена ​​" -"цветовая легенда." - -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 "" -"Панель управления CloudKitty теперь наследует тип интерфейса от Horizon. Это " -"упрощает тестирование, например, использование внутреннего URL в решениях " -"\"всё в одном\"." - -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 "" -"Обновлена ​​функция прогнозного ценообразования. Теперь можно указать службу " -"хэш-карты для использования в конфигурационном файле Horizon с помощью " -"параметра ``CLOUDKITTY_QUOTATION_SERVICE``." - -msgid "" -"The ratings panel in the project dashboard has been converted to use the v2 " -"API." -msgstr "" -"Панель оценок в панели управления проектом была преобразована для " -"использования API v2." - -msgid "Train Series Release Notes" -msgstr "Примечания к выпуску Train" - -msgid "Upgrade Notes" -msgstr "Примечания к обновлению" - -msgid "Ussuri Series Release Notes" -msgstr "Примечания к выпуску Ussuri" - -msgid "Victoria Series Release Notes" -msgstr "Примечания к выпуску Victoria" - -msgid "Wallaby Series Release Notes" -msgstr "Примечания к выпуску Wallaby" - -msgid "Xena Series Release Notes" -msgstr "Примечания к выпуску Xena" - -msgid "Yoga Series Release Notes" -msgstr "Примечания к выпуску Yoga" - -msgid "Zed Series Release Notes" -msgstr "Примечания к выпуску Zed"