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

- {% if instance.context.service_offering.provider.logo %}
|{% endif %}
- {% endif %}
- {% if instance.context.service_offering.provider.logo %}
-

- {% endif %}
-
-{% endblock page_subtitle %}
{% block content %}
-
-
+
- {% if compute_plan_assignment or storage_plan %}
-
-
- {% if instance.connection_credentials %}
-
-
{% endif %}
-
- {% if instance.spec and spec_fieldsets %}
-
-
-
-
- {% if spec_fieldsets|length > 1 %}
-
+ {% if control_plane.user_info %}
+
+
+
+ {% include "includes/control_plane_user_info.html" with control_plane=instance.context.control_plane %}
+
+
+ {% endif %}
+
+ {% if instance.spec and spec_fieldsets %}
+
+
+
+
+
+
+
{% for fieldset in spec_fieldsets %}
-
@@ -233,168 +167,92 @@
data-bs-toggle="tab"
role="tab">{{ fieldset.title }}
+ {% empty %}
+ - {% translate "No specification details available." %}
{% endfor %}
+
{% 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" %}
-
-
-
-
- | {% translate "Type" %} |
- {% translate "Status" %} |
- {% translate "Last Transition" %} |
- {% translate "Reason" %} |
- {% translate "Message" %} |
-
-
-
- {% for condition in instance.status_conditions %}
-
- | {{ 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:"-" }} |
-
- {% endfor %}
-
-
-
- {% 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 "Name" %} |
+ {% translate "Value" %} |
+
+
+
+ {% for key, value in instance.connection_credentials.items %}
+
+ | {{ key }} |
+
+ {% if key == "error" %}
+ {{ value }}
+ {% else %}
+ {{ value }}
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+ {% endif %}
+
{% endblock content %}
{% block extra_js %}
-
-