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. +