From 5c39d26b62292f07830ce6ac1a443fc772b41650 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Thu, 11 Dec 2025 16:25:29 +0100 Subject: [PATCH] redesign service detail page with the help of Claude Code --- .../frontend/templates/frontend/base.html | 2 + .../service_instance_detail.html | 524 ++++++++++-------- .../templates/includes/plan_selection.html | 2 +- .../frontend/templatetags/pprint_filters.py | 10 +- src/servala/frontend/views/service.py | 222 ++++++-- src/servala/static/css/servala.css | 6 + 6 files changed, 506 insertions(+), 260 deletions(-) diff --git a/src/servala/frontend/templates/frontend/base.html b/src/servala/frontend/templates/frontend/base.html index 620cb6d..8454e0d 100644 --- a/src/servala/frontend/templates/frontend/base.html +++ b/src/servala/frontend/templates/frontend/base.html @@ -35,6 +35,8 @@ {% 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 a0220a7..eaebc94 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html @@ -29,185 +29,94 @@ {% endif %}
{% endblock page_title_extra %} +{% block page_subtitle %} +
+ {% if instance.context.service_definition.service.logo %} + {{ instance.context.service_definition.service.name }} + {% endif %} + | + {% if instance.context.service_offering.provider.logo %} + {{ instance.context.service_offering.provider.name }} + {% endif %} +
+{% endblock page_subtitle %} {% block content %}
-
+ +
-
-
-

{% translate "Details" %}

-
-
-
-
{% translate "Service" %}
-
+ {% if compute_plan_assignment or storage_plan %} +
+
+

{% translate "Product" %}

+

{{ instance.context.service_definition.service.name }} -

-
{% translate "Service Provider" %}
-
+ {% translate "at" %} {{ instance.context.service_offering.provider.name }} -
-
{% translate "Control Plane" %}
-
- {{ instance.context.control_plane.name }} -
+ ({{ instance.context.control_plane.name }}) +

+
+
{% if compute_plan_assignment %} -
{% translate "Compute Plan" %}
-
- {{ compute_plan_assignment.compute_plan.name }} - - {{ compute_plan_assignment.get_sla_display }} - -
+
+
{% translate "Compute" %}
+
+ {% translate "Plan:" %} {{ compute_plan_assignment.compute_plan.name }} +
+
{{ compute_plan_assignment.compute_plan.cpu_limits }} vCPU {{ compute_plan_assignment.compute_plan.memory_limits }} RAM - CHF {{ compute_plan_assignment.price }}/{{ compute_plan_assignment.get_unit_display }} + SLA: {{ compute_plan_assignment.get_sla_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 Plan" %}
-
- CHF {{ storage_plan.price_per_gib }} per GiB -
{% translate "Billed separately based on disk usage" %}
-
- {% endif %} -
{% translate "Created By" %}
-
- {{ instance.created_by|default:"-" }} -
-
{% translate "Created At" %}
-
- {{ instance.created_at|localtime_tag }} -
-
{% translate "Updated At" %}
-
- {{ instance.updated_at|localtime_tag }} -
- -
-
-
-
- {% if instance.status_conditions %} -
-
-

{% translate "Status" %}

-
-
-
-
- - - - - - - - - - - - {% for condition in instance.status_conditions %} - - - - - - - - {% endfor %} - -
{% translate "Type" %}{% translate "Status" %}{% translate "Last Transition Time" %}{% 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:"-" }}
+
+
{% 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 %}
-
-
-
- {% endif %} - {% 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 }}
-
- {{ 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 %} -
- {% empty %} -

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

- {% endfor %} + {% endif %} + {% if pricing.total_monthly %} +
+
+ {% translate "Estimated Total" %} +
+
~CHF {{ pricing.total_monthly }} / {% translate "month" %}
+
CHF {{ pricing.total_hourly }} / {% translate "hour" %}
-
+ {% endif %}
-
- {% endif %} - {% if instance.connection_credentials %} -
-
+ {% endif %} +
+
+ {% if instance.connection_credentials %} +

{% translate "Connection Credentials" %}

-
- {% endif %} - {% if instance.kubernetes_events %} -
-
-
-

{% translate "Events" %}

+ {% 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 %} +
+
-
-
-
- - - - - - - - - - - - {% for event in instance.kubernetes_events %} - - - - - - - + + {% endif %} + + {% if instance.spec and spec_fieldsets %} +
+
+
+

{% translate "Service Configuration" %}

+
+
+ {% if spec_fieldsets|length > 1 %} + + +
+ {% 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 %} +
+ {% else %} + + {% for fieldset in spec_fieldsets %} +
+ {% for field in fieldset.fields %} +
+ {{ field.label }} + {% if field.help_text %} + + {% endif %} +
+
+ {{ field.value|render_tree }} +
{% endfor %} -
-
{% translate "Type" %}{% translate "Reason" %}{% translate "Message" %}{% translate "Count" %}{% translate "Last Seen" %}
- {% if event.type == "Warning" %} - {{ event.type }} - {% elif event.type == "Normal" %} - {{ event.type }} - {% else %} - {{ event.type }} - {% endif %} - {{ event.reason|default:"-" }} - {{ event.message|default:"-"|truncatewords:50 }} - {{ event.count }}{{ event.last_timestamp|localtime_tag }}
+ + {% 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 storage_plan %} diff --git a/src/servala/frontend/templatetags/pprint_filters.py b/src/servala/frontend/templatetags/pprint_filters.py index a5627aa..a4cc654 100644 --- a/src/servala/frontend/templatetags/pprint_filters.py +++ b/src/servala/frontend/templatetags/pprint_filters.py @@ -77,10 +77,18 @@ def render_tree(value, key=""): if isinstance(value, list): if not value: return mark_safe('[]') + # For simple string/number arrays, render as comma-separated or line-separated + if all(isinstance(item, (str, int, float)) for item in value): + if len(value) == 1: + return format_html("{}", value[0]) + # Multiple items: render each on its own line + items = [format_html("
{}
", item) for item in value] + return mark_safe("".join(items)) + # For complex arrays (dicts, nested lists), use list structure items = [] for item in value: items.append(f"
  • {render_tree(item)}
  • ") - return mark_safe(f'') + return mark_safe(f'') if isinstance(value, dict): if not value: diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index b9f7d56..fe439c6 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -377,17 +377,182 @@ class ServiceInstanceDetailView( "price_per_gib": self.object.context.control_plane.storage_plan_price_per_gib, } + # Calculate pricing summary + context["pricing"] = self._calculate_pricing() + return context - def get_nested_spec(self): + def _parse_disk_size_gib(self, disk_value): + """Parse disk size string (e.g., '10Gi', '100G') to GiB as integer.""" + if not disk_value: + return None + disk_str = str(disk_value) + # Handle Gi suffix (GiB) + if disk_str.endswith("Gi"): + try: + return int(disk_str[:-2]) + except ValueError: + return None + # Handle G suffix (assume GB, convert to GiB approximately) + if disk_str.endswith("G"): + try: + return int(float(disk_str[:-1]) * 0.931) # GB to GiB + except ValueError: + return None + # Handle plain number (assume GiB) + try: + return int(disk_str) + except ValueError: + return None + + def _calculate_pricing(self): + """Calculate hourly and monthly pricing for the instance.""" + from decimal import Decimal, ROUND_HALF_UP + + 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 + compute_assignment = self.object.compute_plan_assignment + if compute_assignment: + price = compute_assignment.price + unit = compute_assignment.unit + + # Convert to hourly + if unit == "hour": + hourly = price + elif 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) + else: + hourly = price # fallback + + pricing["compute_hourly"] = hourly.quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + pricing["compute_monthly"] = (hourly * hours_per_month).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + + # Storage pricing + storage_price_per_gib = None + if ( + self.object.context + and self.object.context.control_plane.storage_plan_price_per_gib + ): + storage_price_per_gib = ( + self.object.context.control_plane.storage_plan_price_per_gib + ) + + # Get disk size from spec + disk_size_gib = None + if self.object.spec: + disk_value = self._get_value_from_path( + {"spec": self.object.spec}, "spec.parameters.size.disk" + ) + disk_size_gib = self._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"] = storage_hourly.quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + pricing["storage_monthly"] = (storage_hourly * hours_per_month).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + + # 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"] = (compute_h + storage_h).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + pricing["total_monthly"] = (pricing["total_hourly"] * hours_per_month).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + + return pricing + + def _get_value_from_path(self, data, path): """ - Organize spec data into fieldsets similar to how the form does it. + 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(self): + """ + Generate fieldsets from form_config, extracting values from spec. + """ + form_config = self.object.context.service_definition.form_config + spec = self.object.spec or {} + full_data = {"spec": spec, "name": self.object.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 = self._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(self): + """ + Auto-generate fieldsets from spec structure (fallback when no form_config). + Excludes "General" tab - only returns nested structures. """ spec = self.object.spec or {} if not spec: return [] - others = [] nested_fieldsets = {} # First pass: organize fields into nested structures @@ -431,15 +596,6 @@ class ServiceInstanceDetailView( "value": sub_value, } ) - else: - # This is a top-level field - others.append( - { - "key": key, - "label": deslugify(key), - "value": value, - } - ) # Second pass: Promote fields based on count for group_key, group in list(nested_fieldsets.items()): @@ -451,38 +607,40 @@ class ServiceInstanceDetailView( group["fields"].append(field) del group["fieldsets"][sub_key] - # Move single-field groups to others + # Remove empty groups total_fields = len(group["fields"]) for sub_fieldset in group["fieldsets"].values(): total_fields += len(sub_fieldset["fields"]) - if ( - total_fields == 1 - and len(group["fields"]) == 1 - and not group["fieldsets"] - ): - field = group["fields"][0] - field["label"] = f"{group['title']}: {field['label']}" - others.append(field) - del nested_fieldsets[group_key] - elif total_fields == 0: + if total_fields == 0: del nested_fieldsets[group_key] + # Create fieldsets from the organized data (no "General" tab) fieldsets = [] - if others: - fieldsets.append( - { - "title": "General", - "fields": others, - "fieldsets": {}, - } - ) - # Create fieldsets from the organized data for group in nested_fieldsets.values(): fieldsets.append(group) return fieldsets + def get_nested_spec(self): + """ + Organize spec data into fieldsets. + Uses form_config when available, otherwise auto-generates from spec structure. + """ + spec = self.object.spec or {} + if not spec: + return [] + + # Check if form_config exists + if ( + self.object.context + and self.object.context.service_definition + and self.object.context.service_definition.form_config + ): + return self._get_form_config_fieldsets() + + return self._get_auto_generated_fieldsets() + class ServiceInstanceUpdateView( ServiceInstanceMixin, OrganizationViewMixin, HtmxUpdateView diff --git a/src/servala/static/css/servala.css b/src/servala/static/css/servala.css index cc69a4f..fe9462d 100644 --- a/src/servala/static/css/servala.css +++ b/src/servala/static/css/servala.css @@ -361,3 +361,9 @@ html[data-bs-theme="dark"] .beta-banner-button:hover { background-color: white; color: var(--bs-primary); } + +/* Fixed table layout for credentials */ +.table-fixed { + table-layout: fixed; + word-wrap: break-word; +}