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/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..65b610a --- /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..78b5ed6 100644 --- a/cloudkittydashboard/dashboards/project/rating/templates/rating/index.html +++ b/cloudkittydashboard/dashboards/project/rating/templates/rating/index.html @@ -1,14 +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 %} -{{ table.render }} + +
+
+
+
+

{% 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" %}
+
+
+
+
-{{ modules }} {% endblock %} diff --git a/cloudkittydashboard/dashboards/project/rating/views.py b/cloudkittydashboard/dashboards/project/rating/views.py index f1e9139..32ad4ad 100644 --- a/cloudkittydashboard/dashboards/project/rating/views.py +++ b/cloudkittydashboard/dashboards/project/rating/views.py @@ -11,17 +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.api import cloudkitty as api -from cloudkittydashboard.dashboards.project.rating \ - import tables as rating_tables from cloudkittydashboard import utils rate_prefix = getattr(settings, @@ -30,24 +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_data(self): - summary = api.cloudkittyclient( - self.request, version='2').summary.get_summary( - tenant_id=self.request.user.tenant_id, - groupby=['type'], response_format='object') + 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 - data = summary.get('results') - total = sum([r.get('rate') for r in data]) + 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} - data.append({'type': 'TOTAL', 'rate': total}) - for item in data: - item['rate'] = utils.formatRate(item['rate'], - rate_prefix, rate_postfix) - return data + 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) + 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) + + # 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/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..ab54cb5 100644 --- a/cloudkittydashboard/dashboards/project/reporting/views.py +++ b/cloudkittydashboard/dashboards/project/reporting/views.py @@ -18,76 +18,172 @@ 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 _build_reporting_data(client, tenant_id, begin, end): + """Build reporting data using v2 summary API. -def _do_this_month(data): + 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())) - 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])) + # 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' + ) + 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 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) - 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} + + 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-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-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 + 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/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/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..52d6081 --- /dev/null +++ b/cloudkittydashboard/static/cloudkitty/css/grouping.css @@ -0,0 +1,50 @@ +.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; +} + +.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/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/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/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/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 = [ diff --git a/cloudkittydashboard/utils.py b/cloudkittydashboard/utils.py index d39eac7..513d738 100644 --- a/cloudkittydashboard/utils.py +++ b/cloudkittydashboard/utils.py @@ -27,9 +27,15 @@ 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: 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/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. + 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"