diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index a2577d9..23ef6d5 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -19,7 +19,7 @@ jobs: node-version: "24" - name: Renovate - uses: https://github.com/renovatebot/github-action@v44.0.5 + uses: https://github.com/renovatebot/github-action@v44.2.0 with: token: ${{ secrets.RENOVATE_TOKEN }} env: diff --git a/pyproject.toml b/pyproject.toml index bb254a9..3f47bc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "pyjwt>=2.10.1", "requests>=2.32.5", "rules>=3.5", - "sentry-sdk[django]>=2.47.0", + "sentry-sdk[django]>=2.48.0", "urlman>=2.0.2", ] diff --git a/src/servala/core/crd/data.py b/src/servala/core/crd/data.py deleted file mode 100644 index d77907d..0000000 --- a/src/servala/core/crd/data.py +++ /dev/null @@ -1,149 +0,0 @@ -""" -Data handling for CRD data. -Follows the custom/generated split from servala.core.crd.forms. -""" - -from servala.core.crd.utils import deslugify - - -def get_value_from_path(data, path): - """ - Get a value from nested dict using dot notation path. - e.g., 'spec.parameters.size.disk' from {'spec': {'parameters': {'size': {'disk': '10Gi'}}}} - """ - parts = path.split(".") - current = data - for part in parts: - if isinstance(current, dict) and part in current: - current = current[part] - else: - return None - return current - - -def get_form_config_fieldsets(spec, form_config, name): - full_data = {"spec": spec, "name": name} - fieldsets = [] - for fieldset_config in form_config.get("fieldsets", []): - fields = [] - for field_config in fieldset_config.get("fields", []): - mapping = field_config.get("controlplane_field_mapping", "") - # Skip the name field as it's shown in the header - if mapping == "name": - continue - - value = get_value_from_path(full_data, mapping) - if value is None: - continue - - label = field_config.get("label") or deslugify(mapping.split(".")[-1]) - - fields.append( - { - "key": mapping, - "label": label, - "value": value, - "help_text": field_config.get("help_text", ""), - } - ) - - if fields: - fieldsets.append( - { - "title": fieldset_config.get("title", "Configuration"), - "fields": fields, - "fieldsets": {}, - } - ) - - return fieldsets - - -def get_auto_generated_fieldsets(spec): - """ - Auto-generate fieldsets from spec structure (fallback when no form_config). - Excludes "General" tab - only returns nested structures. - """ - nested_fieldsets = {} - - # First pass: organize fields into nested structures - for key, value in spec.items(): - if isinstance(value, dict): - # This is a nested structure - if key not in nested_fieldsets: - nested_fieldsets[key] = { - "title": deslugify(key), - "fields": [], - "fieldsets": {}, - } - - # Process fields in the nested structure - for sub_key, sub_value in value.items(): - if isinstance(sub_value, dict): - # Even deeper nesting - if sub_key not in nested_fieldsets[key]["fieldsets"]: - nested_fieldsets[key]["fieldsets"][sub_key] = { - "title": deslugify(sub_key), - "fields": [], - } - - # Add fields from the deeper level - for leaf_key, leaf_value in sub_value.items(): - nested_fieldsets[key]["fieldsets"][sub_key]["fields"].append( - { - "key": leaf_key, - "label": deslugify(leaf_key), - "value": leaf_value, - } - ) - else: - # Add field to parent level - nested_fieldsets[key]["fields"].append( - { - "key": sub_key, - "label": deslugify(sub_key), - "value": sub_value, - } - ) - - # Second pass: Promote fields based on count - for group_key, group in list(nested_fieldsets.items()): - # Promote single sub-fieldsets to parent - for sub_key, sub_fieldset in list(group["fieldsets"].items()): - if len(sub_fieldset["fields"]) == 1: - field = sub_fieldset["fields"][0] - field["label"] = f"{sub_fieldset['title']}: {field['label']}" - group["fields"].append(field) - del group["fieldsets"][sub_key] - - # Remove empty groups - total_fields = len(group["fields"]) - for sub_fieldset in group["fieldsets"].values(): - total_fields += len(sub_fieldset["fields"]) - - if total_fields == 0: - del nested_fieldsets[group_key] - - # Create fieldsets from the organized data (no "General" tab) - fieldsets = [] - for group in nested_fieldsets.values(): - fieldsets.append(group) - - return fieldsets - - -def get_nested_data(instance): - """ - Organize spec data into fieldsets. - Uses form_config when available, otherwise auto-generates from spec structure. - """ - if not instance.spec: - return [] - if instance.context and instance.context.use_custom_form: - return get_form_config_fieldsets( - instance.spec, - instance.context.service_definition.form_config, - instance.name, - ) - - return get_auto_generated_fieldsets(instance.spec) diff --git a/src/servala/core/crd/utils.py b/src/servala/core/crd/utils.py index 7b7b06e..a537fd9 100644 --- a/src/servala/core/crd/utils.py +++ b/src/servala/core/crd/utils.py @@ -113,26 +113,3 @@ def deslugify(title): result.append(word.capitalize()) return " ".join(result) - - -def parse_disk_size_gib(value): - """Parse disk size string (e.g., '10Gi', '100G') to GiB as integer.""" - if not value: - return None - value = str(value) - # Handle Gi suffix (GiB) - if value.endswith("Gi"): - try: - return int(value[:-2]) - except ValueError: - return None - # Handle G suffix (assume GB, convert to GiB approximately) - if value.endswith("G"): - try: - return int(float(value[:-1]) * 0.931) # GB to GiB - except ValueError: - return None - try: - return int(value) - except ValueError: - return None diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 138c8ba..c2fedad 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -3,7 +3,6 @@ import hashlib import html import json import re -from decimal import Decimal import kubernetes import rules @@ -22,7 +21,6 @@ from kubernetes.client.rest import ApiException from servala.core import rules as perms from servala.core.models.mixins import ServalaModelMixin -from servala.core.utils import to_money from servala.core.validators import kubernetes_name_validator @@ -546,14 +544,6 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): return return generate_model_form_class(self.django_model) - @cached_property - def use_custom_form(self): - return ( - self.service_definition - and self.service_definition.form_config - and self.service_definition.form_config.get("fieldsets") - ) - @cached_property def custom_model_form_class(self): from servala.core.crd import generate_custom_form_class @@ -1212,118 +1202,5 @@ class ServiceInstance(ServalaModelMixin, models.Model): except (AttributeError, KeyError, IndexError): return None - @cached_property - def kubernetes_events(self) -> dict: - """ - Returns a list of event dictionaries sorted by last timestamp (newest first). - """ - if not self.kubernetes_object: - return [] - - try: - v1 = kubernetes.client.CoreV1Api( - self.context.control_plane.get_kubernetes_client() - ) - events = v1.list_namespaced_event( - namespace=self.organization.namespace, - field_selector=f"involvedObject.name={self.name},involvedObject.kind={self.context.kind}", - ) - event_list = [] - for event in events.items: - event_dict = { - "type": event.type, # Normal or Warning - "reason": event.reason, - "message": event.message, - "count": event.count or 1, - "first_timestamp": ( - event.first_timestamp.isoformat() - if event.first_timestamp - else None - ), - "last_timestamp": ( - event.last_timestamp.isoformat() - if event.last_timestamp - else None - ), - "source": event.source.component if event.source else None, - } - event_list.append(event_dict) - - event_list.sort(key=lambda x: x.get("last_timestamp") or "", reverse=True) - - return event_list - except ApiException: - return [] - except Exception: - return [] - - def calculate_pricing(self): - """Calculate hourly and monthly pricing.""" - from servala.core.crd.data import get_value_from_path - from servala.core.crd.utils import parse_disk_size_gib - - pricing = { - "compute_hourly": None, - "compute_monthly": None, - "storage_hourly": None, - "storage_monthly": None, - "total_hourly": None, - "total_monthly": None, - "disk_size_gib": None, - } - - hours_per_month = 720 - - # Compute plan pricing - if self.compute_plan_assignment: - price = self.compute_plan_assignment.price - unit = self.compute_plan_assignment.unit - hourly = price - - if unit == "day": - hourly = price / Decimal("24") - elif unit == "month": - hourly = price / Decimal(hours_per_month) - elif unit == "year": - hourly = price / Decimal(hours_per_month * 12) - - pricing["compute_hourly"] = to_money(hourly) - pricing["compute_monthly"] = to_money(hourly * hours_per_month) - - # Storage pricing - storage_price_per_gib = None - if self.context and self.context.control_plane.storage_plan_price_per_gib: - storage_price_per_gib = ( - self.context.control_plane.storage_plan_price_per_gib - ) - - # Get disk size from spec - disk_size_gib = None - if self.spec: - disk_value = get_value_from_path( - {"spec": self.spec}, "spec.parameters.size.disk" - ) - disk_size_gib = parse_disk_size_gib(disk_value) - pricing["disk_size_gib"] = disk_size_gib - - if storage_price_per_gib and disk_size_gib: - storage_hourly = storage_price_per_gib * disk_size_gib - pricing["storage_hourly"] = to_money(storage_hourly) - pricing["storage_monthly"] = to_money(storage_hourly * hours_per_month) - - # Total pricing - if ( - pricing["compute_hourly"] is not None - or pricing["storage_hourly"] is not None - ): - compute_h = pricing["compute_hourly"] or Decimal("0") - storage_h = pricing["storage_hourly"] or Decimal("0") - pricing["total_hourly"] = to_money(compute_h + storage_h) - pricing["total_monthly"] = to_money( - pricing["total_hourly"] * hours_per_month - ) - - return pricing - auditlog.register(ServiceInstance, exclude_fields=["updated_at"], serialize_data=True) diff --git a/src/servala/core/utils.py b/src/servala/core/utils.py deleted file mode 100644 index f9de1f0..0000000 --- a/src/servala/core/utils.py +++ /dev/null @@ -1,10 +0,0 @@ -from decimal import ROUND_HALF_UP, Decimal - - -def to_money(value): - """Consistent monetary values by handling quantizing and rounding""" - if not value: - value = Decimal("0") - if not isinstance(value, Decimal): - raise ValueError("Expected a Decimal") - return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) diff --git a/src/servala/frontend/templates/frontend/base.html b/src/servala/frontend/templates/frontend/base.html index 8454e0d..620cb6d 100644 --- a/src/servala/frontend/templates/frontend/base.html +++ b/src/servala/frontend/templates/frontend/base.html @@ -35,8 +35,6 @@ {% block page_title_extra %} {% endblock page_title_extra %} - {% block page_subtitle %} - {% endblock page_subtitle %}
{% for message in messages %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html index 0ab3e30..c0924ed 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html @@ -1,11 +1,10 @@ {% extends "frontend/base.html" %} {% load i18n static pprint_filters %} {% block html_title %} - {{ instance.display_name }} ({{ instance.name }}) + {% block page_title %} + {{ instance.display_name }} + {% endblock page_title %} {% endblock html_title %} -{% block page_title %} - {{ instance.display_name }} ({{ instance.name }}) -{% endblock page_title %} {% block page_title_extra %}
{% if instance.fqdn_url %} @@ -30,201 +29,136 @@ {% endif %}
{% endblock page_title_extra %} -{% block page_subtitle %} -
- {% if instance.context.service_definition.service.logo %} - {{ instance.context.service_definition.service.name }} - {% if instance.context.service_offering.provider.logo %}|{% endif %} - {% endif %} - {% if instance.context.service_offering.provider.logo %} - {{ instance.context.service_offering.provider.name }} - {% endif %} -
-{% endblock page_subtitle %} {% block content %}
- -
+
- {% if compute_plan_assignment or storage_plan %} -
-
-

{% translate "Product" %}

-

+

+
+

{% translate "Details" %}

+
+
+
+
{% translate "Instance ID" %}
+
+ {{ instance.name }} +
+
{% translate "Service" %}
+
{{ instance.context.service_definition.service.name }} - {% translate "at" %} +
+
{% translate "Service Provider" %}
+
{{ instance.context.service_offering.provider.name }} - ({{ instance.context.control_plane.name }}) -

-
-
+ +
{% translate "Control Plane" %}
+
+ {{ instance.context.control_plane.name }} +
{% if compute_plan_assignment %} -
-
{% translate "Compute" %}
-
- {% translate "Plan:" %} {{ compute_plan_assignment.compute_plan.name }} -
-
+
{% translate "Compute Plan" %}
+
+ {{ compute_plan_assignment.compute_plan.name }} + + {{ compute_plan_assignment.get_sla_display }} + +
{{ compute_plan_assignment.compute_plan.cpu_limits }} vCPU {{ compute_plan_assignment.compute_plan.memory_limits }} RAM - SLA: {{ compute_plan_assignment.get_sla_display }} + CHF {{ compute_plan_assignment.price }}/{{ compute_plan_assignment.get_unit_display }}
-
- CHF {{ compute_plan_assignment.price }} / {{ compute_plan_assignment.get_unit_display }} - {% if pricing.compute_monthly %} - (~CHF {{ pricing.compute_monthly }} / {% translate "month" %}) - {% endif %} -
-
+ {% endif %} {% if storage_plan %} -
-
{% translate "Storage" %}
-
- CHF {{ storage_plan.price_per_gib }} / GiB / {% translate "hour" %} - {% if pricing.disk_size_gib %} - - {{ pricing.disk_size_gib }} GiB {% translate "configured" %} - {% endif %} -
- {% if pricing.storage_monthly %} -
- CHF {{ pricing.storage_hourly }} / {% translate "hour" %} - (~CHF {{ pricing.storage_monthly }} / {% translate "month" %}) -
- {% endif %} -
+
{% translate "Storage Plan" %}
+
+ CHF {{ storage_plan.price_per_gib }} per GiB +
{% translate "Billed separately based on disk usage" %}
+
{% endif %} - {% if pricing.total_monthly %} -
-
- {% translate "Estimated Total" %} -
-
- ~CHF {{ pricing.total_monthly }} / {% translate "month" %} -
-
CHF {{ pricing.total_hourly }} / {% translate "hour" %}
-
-
- {% endif %} -
+
{% translate "Created By" %}
+
+ {{ instance.created_by|default:"-" }} +
+
{% translate "Created At" %}
+
+ {{ instance.created_at|date:"SHORT_DATETIME_FORMAT" }} +
+
{% translate "Updated At" %}
+
+ {{ instance.updated_at|date:"SHORT_DATETIME_FORMAT" }} +
+
- {% endif %} +
- {% if instance.connection_credentials %} -
-
-

{% translate "Connection Credentials" %}

- + {% if instance.status_conditions %} +
+
+

{% translate "Status" %}

-
- - - - - - - - - - - - - - - {% for key, value in instance.connection_credentials.items %} - {% if "_HOST" not in key and "_URL" not in key %} +
+
+
{% translate "Name" %}{% translate "Value" %}
+ + + + + + + + + + + {% for condition in instance.status_conditions %} - - + - + + + - {% endif %} - {% endfor %} - -
{% translate "Type" %}{% translate "Status" %}{% translate "Last Transition Time" %}{% translate "Reason" %}{% translate "Message" %}
{{ key }} - {% if key == "error" %} - {{ value }} + {{ condition.type }} + {% if condition.status == "True" %} + True + {% elif condition.status == "False" %} + False {% else %} - •••••••••••• - {% endif %} - - {% if key != "error" %} - + {{ condition.status }} {% endif %} {{ condition.lastTransitionTime|date:"SHORT_DATETIME_FORMAT" }}{{ condition.reason|default:"-" }}{{ condition.message|truncatewords:20|default:"-" }}
-
-
- - {% translate "Click the eye icon to reveal individual credentials, or use 'Show All' to reveal all at once." %} -
-
-
- {% endif %} -
-
- - {% if instance.spec and spec_fieldsets or instance.context.control_plane.user_info %} -
- - {% if instance.context.control_plane.user_info %} -
-
-
-

- -

-

{% translate "Technical details for connecting to this zone" %}

-
-
-
- {% include "includes/control_plane_user_info.html" with control_plane=instance.context.control_plane %} + {% endfor %} + +
{% endif %} - - {% if instance.spec and spec_fieldsets %} -
-
-
-

{% translate "Service Configuration" %}

-
-
- {% if spec_fieldsets|length > 1 %} - + {% if control_plane.user_info %} +
+
+

{% translate "Service Provider Zone Information" %}

+
+
+ {% include "includes/control_plane_user_info.html" with control_plane=instance.context.control_plane %} +
+
+ {% endif %} +
+ {% if instance.spec and spec_fieldsets %} +
+
+
+

{% translate "Specification" %}

+
+
+
+
+ +
{% for fieldset in spec_fieldsets %}
-
+ +
{% for field in fieldset.fields %} -
- {{ field.label }} - {% if field.help_text %} - +
{{ field.label }}
+
+ {% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %} +
{{ field.value|pprint }}
+ {% else %} + {{ field.value|default:"-" }} {% endif %} - -
- {{ field.value|render_tree }}
{% endfor %}
+ {% for sub_key, sub_fieldset in fieldset.fieldsets.items %} -
{{ sub_fieldset.title }}
-
+
{{ sub_fieldset.title }}
+
{% for field in sub_fieldset.fields %} -
{{ field.label }}
-
- {{ field.value|render_tree }} +
{{ field.label }}
+
+ {% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %} +
{{ field.value|pprint }}
+ {% else %} + {{ field.value|default:"-" }} + {% endif %}
{% endfor %}
{% endfor %}
+ {% empty %} +

{% translate "No specification details to display." %}

{% endfor %}
- {% else %} - - {% for fieldset in spec_fieldsets %} -
- {% for field in fieldset.fields %} -
- {{ field.label }} - {% if field.help_text %} - - {% endif %} -
-
- {{ field.value|render_tree }} -
- {% endfor %} -
- {% for sub_key, sub_fieldset in fieldset.fieldsets.items %} -
{{ sub_fieldset.title }}
-
- {% for field in sub_fieldset.fields %} -
{{ field.label }}
-
- {{ field.value|render_tree }} -
- {% endfor %} -
- {% endfor %} - {% endfor %} - {% endif %} -
-
-
- {% endif %} -
- {% endif %} - - {% if instance.status_conditions or instance.created_at %} -
-
-
-
-

- -

-
-
-
- -
- {% if instance.status_conditions %} -
{% translate "Status Conditions" %}
-
- - - - - - - - - - - - {% for condition in instance.status_conditions %} - - - - - - - - {% endfor %} - -
{% translate "Type" %}{% translate "Status" %}{% translate "Last Transition" %}{% translate "Reason" %}{% translate "Message" %}
{{ condition.type }} - {% if condition.status == "True" %} - True - {% elif condition.status == "False" %} - False - {% else %} - {{ condition.status }} - {% endif %} - {{ condition.lastTransitionTime|localtime_tag }}{{ condition.reason|default:"-" }}{{ condition.message|truncatewords:20|default:"-" }}
-
- {% endif %} -
- -
-
{% translate "Metadata" %}
-
-
{% translate "Created By" %}
-
- {{ instance.created_by|default:"-" }} -
-
{% translate "Created At" %}
-
- {{ instance.created_at|localtime_tag }} -
-
{% translate "Updated At" %}
-
- {{ instance.updated_at|localtime_tag }} -
-
-
-
-
- {% endif %} + {% endif %} + {% if instance.connection_credentials %} +
+
+
+

{% translate "Connection Credentials" %}

+
+
+
+ + + + + + + + + {% for key, value in instance.connection_credentials.items %} + + + + + {% endfor %} + +
{% translate "Name" %}{% translate "Value" %}
{{ key }} + {% if key == "error" %} + {{ value }} + {% else %} + {{ value }} + {% endif %} +
+
+
+
+
+ {% endif %} +