redesign service detail page #334

Merged
rixx merged 1 commit from 257-service-detail-page-tobru into 257-service-detail-page 2025-12-12 07:47:06 +00:00
6 changed files with 506 additions and 260 deletions

View file

@ -35,6 +35,8 @@
{% block page_title_extra %}
{% endblock page_title_extra %}
</h3>
{% block page_subtitle %}
{% endblock page_subtitle %}
</div>
<div class="page-content">
{% for message in messages %}

View file

@ -29,185 +29,94 @@
{% endif %}
</div>
{% endblock page_title_extra %}
{% block page_subtitle %}
<div class="d-flex flex-wrap align-items-center gap-3 mt-2">
{% if instance.context.service_definition.service.logo %}
<img src="{{ instance.context.service_definition.service.logo.url }}"
alt="{{ instance.context.service_definition.service.name }}"
style="height: 24px; width: auto;">
{% endif %}
<span class="text-muted">|</span>
{% if instance.context.service_offering.provider.logo %}
<img src="{{ instance.context.service_offering.provider.logo.url }}"
alt="{{ instance.context.service_offering.provider.name }}"
style="height: 24px; width: auto;">
{% endif %}
</div>
{% endblock page_subtitle %}
{% block content %}
<section class="section">
<div class="row match-height mb-5">
<!-- Row 1: Product Card and Connection Credentials -->
<div class="row match-height mb-4">
<div class="col-12 col-md-5">
<div class="card">
<div class="card-header">
<h4>{% translate "Details" %}</h4>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-4">{% translate "Service" %}</dt>
<dd class="col-sm-8">
{% if compute_plan_assignment or storage_plan %}
<div class="card h-100">
<div class="card-header">
<h4 class="mb-1">{% translate "Product" %}</h4>
<p class="text-muted mb-0 small">
{{ instance.context.service_definition.service.name }}
</dd>
<dt class="col-sm-4">{% translate "Service Provider" %}</dt>
<dd class="col-sm-8">
{% translate "at" %}
{{ instance.context.service_offering.provider.name }}
</dd>
<dt class="col-sm-4">{% translate "Control Plane" %}</dt>
<dd class="col-sm-8">
{{ instance.context.control_plane.name }}
</dd>
({{ instance.context.control_plane.name }})
</p>
</div>
<div class="card-body">
{% if compute_plan_assignment %}
<dt class="col-sm-4">{% translate "Compute Plan" %}</dt>
<dd class="col-sm-8">
{{ compute_plan_assignment.compute_plan.name }}
<span class="badge bg-{% if compute_plan_assignment.sla == 'guaranteed' %}success{% else %}secondary{% endif %} ms-1">
{{ compute_plan_assignment.get_sla_display }}
</span>
<div class="text-muted small mt-1">
<div class="mb-3">
<h6 class="text-muted mb-2">{% translate "Compute" %}</h6>
<div class="mb-1">
{% translate "Plan:" %} <strong>{{ compute_plan_assignment.compute_plan.name }}</strong>
</div>
<div class="text-muted small">
<i class="bi bi-cpu"></i> {{ compute_plan_assignment.compute_plan.cpu_limits }} vCPU
<span class="mx-2"></span>
<i class="bi bi-memory"></i> {{ compute_plan_assignment.compute_plan.memory_limits }} RAM
<span class="mx-2"></span>
<strong>CHF {{ compute_plan_assignment.price }}</strong>/{{ compute_plan_assignment.get_unit_display }}
SLA: {{ compute_plan_assignment.get_sla_display }}
</div>
</dd>
<div class="mt-1 small">
CHF {{ compute_plan_assignment.price }} / {{ compute_plan_assignment.get_unit_display }}
{% if pricing.compute_monthly %}
<span class="text-muted">(~CHF {{ pricing.compute_monthly }} / {% translate "month" %})</span>
{% endif %}
</div>
</div>
{% endif %}
{% if storage_plan %}
<dt class="col-sm-4">{% translate "Storage Plan" %}</dt>
<dd class="col-sm-8">
<strong>CHF {{ storage_plan.price_per_gib }}</strong> per GiB
<div class="text-muted small">{% translate "Billed separately based on disk usage" %}</div>
</dd>
{% endif %}
<dt class="col-sm-4">{% translate "Created By" %}</dt>
<dd class="col-sm-8">
{{ instance.created_by|default:"-" }}
</dd>
<dt class="col-sm-4">{% translate "Created At" %}</dt>
<dd class="col-sm-8">
{{ instance.created_at|localtime_tag }}
</dd>
<dt class="col-sm-4">{% translate "Updated At" %}</dt>
<dd class="col-sm-8">
{{ instance.updated_at|localtime_tag }}
</dd>
</dl>
</div>
</div>
</div>
<div class="col-12 col-md-7">
{% if instance.status_conditions %}
<div class="card">
<div class="card-header">
<h4>{% translate "Status" %}</h4>
</div>
<div class="card-body">
<div class="row">
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th>{% translate "Type" %}</th>
<th>{% translate "Status" %}</th>
<th>{% translate "Last Transition Time" %}</th>
<th>{% translate "Reason" %}</th>
<th>{% translate "Message" %}</th>
</tr>
</thead>
<tbody>
{% for condition in instance.status_conditions %}
<tr>
<td>{{ condition.type }}</td>
<td>
{% if condition.status == "True" %}
<span class="badge text-bg-success">True</span>
{% elif condition.status == "False" %}
<span class="badge text-bg-danger">False</span>
{% else %}
<span class="badge text-bg-secondary">{{ condition.status }}</span>
{% endif %}
</td>
<td>{{ condition.lastTransitionTime|localtime_tag }}</td>
<td>{{ condition.reason|default:"-" }}</td>
<td>{{ condition.message|truncatewords:20|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="mb-3">
<h6 class="text-muted mb-2">{% translate "Storage" %}</h6>
<div class="text-muted small">
CHF {{ storage_plan.price_per_gib }} / GiB / {% translate "hour" %}
{% if pricing.disk_size_gib %}
<span class="mx-2"></span>
{{ pricing.disk_size_gib }} GiB {% translate "configured" %}
{% endif %}
</div>
{% if pricing.storage_monthly %}
<div class="mt-1 small">
CHF {{ pricing.storage_hourly }} / {% translate "hour" %}
<span class="text-muted">(~CHF {{ pricing.storage_monthly }} / {% translate "month" %})</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
{% if control_plane.user_info %}
<div class="card">
<div class="card-header">
<h4 class="card-title">{% translate "Service Provider Zone Information" %}</h4>
</div>
<div class="card-content">
{% include "includes/control_plane_user_info.html" with control_plane=instance.context.control_plane %}
</div>
</div>
{% endif %}
</div>
{% if instance.spec and spec_fieldsets %}
<div class="col-12">
<div class="card">
<div class="card-header">
<h4>{% translate "Specification" %}</h4>
</div>
<div class="card-body">
<div class="row">
<div class="col-12">
<!-- Tabs -->
<ul class="nav nav-tabs" role="tablist">
{% for fieldset in spec_fieldsets %}
<li class="nav-item" role="presentation">
<a href="#spec-tab-{{ forloop.counter }}"
class="nav-link {% if forloop.first %}active{% endif %}"
data-bs-toggle="tab"
role="tab">{{ fieldset.title }}</a>
</li>
{% empty %}
<li class="nav-item ms-2">{% translate "No specification details available." %}</li>
{% endfor %}
</ul>
<!-- Tab Content -->
<div class="tab-content pt-3">
{% for fieldset in spec_fieldsets %}
<div class="tab-pane fade {% if forloop.first %}show active{% endif %}"
id="spec-tab-{{ forloop.counter }}"
role="tabpanel">
<!-- Main Fields -->
<dl class="row">
{% for field in fieldset.fields %}
<dt class="col-sm-3">{{ field.label }}</dt>
<dd class="col-sm-9">
{{ field.value|render_tree }}
</dd>
{% endfor %}
</dl>
<!-- Nested Fieldsets -->
{% for sub_key, sub_fieldset in fieldset.fieldsets.items %}
<h5>{{ sub_fieldset.title }}</h5>
<dl class="row">
{% for field in sub_fieldset.fields %}
<dt class="col-sm-3">{{ field.label }}</dt>
<dd class="col-sm-9">
{{ field.value|render_tree }}
</dd>
{% endfor %}
</dl>
{% endfor %}
</div>
{% empty %}
<p>{% translate "No specification details to display." %}</p>
{% endfor %}
{% endif %}
{% if pricing.total_monthly %}
<hr class="my-2">
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">{% translate "Estimated Total" %}</span>
<div class="text-end">
<div><strong>~CHF {{ pricing.total_monthly }}</strong> <span class="text-muted small">/ {% translate "month" %}</span></div>
<div class="text-muted small">CHF {{ pricing.total_hourly }} / {% translate "hour" %}</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% if instance.connection_credentials %}
<div class="col-12">
<div class="card">
{% endif %}
</div>
<div class="col-12 col-md-7">
{% if instance.connection_credentials %}
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">{% translate "Connection Credentials" %}</h4>
<button type="button"
@ -222,36 +131,43 @@
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered">
<table class="table table-bordered table-fixed mb-0">
<colgroup>
<col style="width: 35%;">
<col style="width: 55%;">
<col style="width: 10%;">
</colgroup>
<thead>
<tr>
<th>{% translate "Name" %}</th>
<th>{% translate "Value" %}</th>
<th style="width: 50px;"></th>
<th></th>
</tr>
</thead>
<tbody>
{% for key, value in instance.connection_credentials.items %}
<tr>
<td>{{ key }}</td>
<td>
{% if key == "error" %}
<span class="text-danger">{{ value }}</span>
{% else %}
<code class="credential-value" data-value="{{ value }}">••••••••••••</code>
{% endif %}
</td>
<td class="text-center">
{% if key != "error" %}
<button type="button"
class="btn btn-sm btn-link p-0 credential-toggle"
onclick="toggleCredential(this)"
title="{% translate 'Show/Hide' %}">
<i class="bi bi-eye"></i>
</button>
{% endif %}
</td>
</tr>
{% if "_HOST" not in key and "_URL" not in key %}
<tr>
<td class="text-truncate">{{ key }}</td>
<td class="text-truncate">
{% if key == "error" %}
<span class="text-danger">{{ value }}</span>
{% else %}
<code class="credential-value" data-value="{{ value }}">••••••••••••</code>
{% endif %}
</td>
<td class="text-center">
{% if key != "error" %}
<button type="button"
class="btn btn-sm btn-link p-0 credential-toggle"
onclick="toggleCredential(this)"
title="{% translate 'Show/Hide' %}">
<i class="bi bi-eye"></i>
</button>
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
@ -262,56 +178,212 @@
</div>
</div>
</div>
</div>
{% endif %}
{% if instance.kubernetes_events %}
<div class="col-12">
<div class="card">
<div class="card-header">
<h4>{% translate "Events" %}</h4>
{% endif %}
</div>
</div>
<!-- Row 2: Zone Information (left) and Service Configuration (right) -->
{% if instance.spec and spec_fieldsets or instance.context.control_plane.user_info %}
<div class="row match-height mb-4">
<!-- Zone Information (collapsible) -->
{% if instance.context.control_plane.user_info %}
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-header">
<h4 class="mb-0">
<a class="text-decoration-none text-dark d-flex align-items-center justify-content-between"
data-bs-toggle="collapse"
href="#zoneInfoCollapse"
role="button"
aria-expanded="false"
aria-controls="zoneInfoCollapse">
{% translate "Zone Documentation" %}
<i class="bi bi-chevron-down ms-2 small"></i>
</a>
</h4>
<p class="text-muted small mb-0 mt-1">{% translate "Technical details for connecting to this zone" %}</p>
</div>
<div class="collapse" id="zoneInfoCollapse">
<div class="card-body pt-0">
{% include "includes/control_plane_user_info.html" with control_plane=instance.context.control_plane %}
</div>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="table-responsive">
<table class="table table-bordered table-sm">
<thead>
<tr>
<th style="width: 80px;">{% translate "Type" %}</th>
<th style="width: 150px;">{% translate "Reason" %}</th>
<th>{% translate "Message" %}</th>
<th style="width: 60px;">{% translate "Count" %}</th>
<th style="width: 180px;">{% translate "Last Seen" %}</th>
</tr>
</thead>
<tbody>
{% for event in instance.kubernetes_events %}
<tr>
<td>
{% if event.type == "Warning" %}
<span class="badge text-bg-warning">{{ event.type }}</span>
{% elif event.type == "Normal" %}
<span class="badge text-bg-success">{{ event.type }}</span>
{% else %}
<span class="badge text-bg-secondary">{{ event.type }}</span>
{% endif %}
</td>
<td>{{ event.reason|default:"-" }}</td>
<td>
<span class="text-break">{{ event.message|default:"-"|truncatewords:50 }}</span>
</td>
<td class="text-center">{{ event.count }}</td>
<td>{{ event.last_timestamp|localtime_tag }}</td>
</tr>
</div>
{% endif %}
<!-- Service Configuration -->
{% if instance.spec and spec_fieldsets %}
<div class="col-12 {% if instance.context.control_plane.user_info %}col-md-8{% endif %}">
<div class="card h-100">
<div class="card-header">
<h4>{% translate "Service Configuration" %}</h4>
</div>
<div class="card-body">
{% if spec_fieldsets|length > 1 %}
<!-- Multiple fieldsets: use tabs -->
<ul class="nav nav-tabs" role="tablist">
{% for fieldset in spec_fieldsets %}
<li class="nav-item" role="presentation">
<a href="#spec-tab-{{ forloop.counter }}"
class="nav-link {% if forloop.first %}active{% endif %}"
data-bs-toggle="tab"
role="tab">{{ fieldset.title }}</a>
</li>
{% endfor %}
</ul>
<div class="tab-content pt-3">
{% for fieldset in spec_fieldsets %}
<div class="tab-pane fade {% if forloop.first %}show active{% endif %}"
id="spec-tab-{{ forloop.counter }}"
role="tabpanel">
<dl class="row mb-0">
{% for field in fieldset.fields %}
<dt class="col-sm-4">
{{ field.label }}
{% if field.help_text %}
<i class="bi bi-question-circle text-muted ms-1"
data-bs-toggle="popover"
data-bs-trigger="hover"
data-bs-content="{{ field.help_text }}"></i>
{% endif %}
</dt>
<dd class="col-sm-8">
{{ field.value|render_tree }}
</dd>
{% endfor %}
</dl>
{% for sub_key, sub_fieldset in fieldset.fieldsets.items %}
<h5 class="mt-3">{{ sub_fieldset.title }}</h5>
<dl class="row mb-0">
{% for field in sub_fieldset.fields %}
<dt class="col-sm-4">{{ field.label }}</dt>
<dd class="col-sm-8">
{{ field.value|render_tree }}
</dd>
{% endfor %}
</dl>
{% endfor %}
</div>
{% endfor %}
</div>
{% else %}
<!-- Single fieldset: no tabs needed -->
{% for fieldset in spec_fieldsets %}
<dl class="row mb-0">
{% for field in fieldset.fields %}
<dt class="col-sm-4">
{{ field.label }}
{% if field.help_text %}
<i class="bi bi-question-circle text-muted ms-1"
data-bs-toggle="popover"
data-bs-trigger="hover"
data-bs-content="{{ field.help_text }}"></i>
{% endif %}
</dt>
<dd class="col-sm-8">
{{ field.value|render_tree }}
</dd>
{% endfor %}
</tbody>
</table>
</dl>
{% for sub_key, sub_fieldset in fieldset.fieldsets.items %}
<h5 class="mt-3">{{ sub_fieldset.title }}</h5>
<dl class="row mb-0">
{% for field in sub_fieldset.fields %}
<dt class="col-sm-4">{{ field.label }}</dt>
<dd class="col-sm-8">
{{ field.value|render_tree }}
</dd>
{% endfor %}
</dl>
{% endfor %}
{% endfor %}
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
{% endif %}
<!-- Row 3: Expert & Metadata (collapsed) -->
{% if instance.status_conditions or instance.created_at %}
<div class="row mb-4">
<div class="col-12">
<div class="accordion" id="expertAccordion">
<div class="accordion-item">
<h2 class="accordion-header" id="expertHeading">
<button class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#expertCollapse"
aria-expanded="false"
aria-controls="expertCollapse">
<i class="bi bi-gear me-2"></i>
{% translate "Technical Details" %}
</button>
</h2>
<div id="expertCollapse"
class="accordion-collapse collapse"
aria-labelledby="expertHeading"
data-bs-parent="#expertAccordion">
<div class="accordion-body">
<div class="row">
<!-- Left column: Status Conditions -->
<div class="col-12 col-md-8">
{% if instance.status_conditions %}
<h5 class="mb-3">{% translate "Status Conditions" %}</h5>
<div class="table-responsive">
<table class="table table-bordered table-sm">
<thead>
<tr>
<th>{% translate "Type" %}</th>
<th>{% translate "Status" %}</th>
<th>{% translate "Last Transition" %}</th>
<th>{% translate "Reason" %}</th>
<th>{% translate "Message" %}</th>
</tr>
</thead>
<tbody>
{% for condition in instance.status_conditions %}
<tr>
<td>{{ condition.type }}</td>
<td>
{% if condition.status == "True" %}
<span class="badge text-bg-success">True</span>
{% elif condition.status == "False" %}
<span class="badge text-bg-danger">False</span>
{% else %}
<span class="badge text-bg-secondary">{{ condition.status }}</span>
{% endif %}
</td>
<td>{{ condition.lastTransitionTime|localtime_tag }}</td>
<td>{{ condition.reason|default:"-" }}</td>
<td>{{ condition.message|truncatewords:20|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
<!-- Right column: Metadata -->
<div class="col-12 col-md-4">
<h5 class="mb-3">{% translate "Metadata" %}</h5>
<dl class="row">
<dt class="col-sm-5">{% translate "Created By" %}</dt>
<dd class="col-sm-7">{{ instance.created_by|default:"-" }}</dd>
<dt class="col-sm-5">{% translate "Created At" %}</dt>
<dd class="col-sm-7">{{ instance.created_at|localtime_tag }}</dd>
<dt class="col-sm-5">{% translate "Updated At" %}</dt>
<dd class="col-sm-7">{{ instance.updated_at|localtime_tag }}</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
</section>
<!-- Delete Confirmation Modal -->
<div class="modal fade"

View file

@ -102,7 +102,7 @@
</div>
<div class="plan-header">
<h6>{% trans "Storage" %}</h6>
<span class="text-muted small">{% trans "Billed separately based on disk usage" %}</span>
<span class="text-muted small">{% trans "Billed based on disk usage" %}</span>
</div>
</div>
{% if storage_plan %}

View file

@ -77,10 +77,18 @@ def render_tree(value, key=""):
if isinstance(value, list):
if not value:
return mark_safe('<span class="text-muted">[]</span>')
# 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("<span>{}</span>", value[0])
# Multiple items: render each on its own line
items = [format_html("<div>{}</div>", 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"<li>{render_tree(item)}</li>")
return mark_safe(f'<ul class="list-unstyled mb-0 ps-3">{"".join(items)}</ul>')
return mark_safe(f'<ul class="list-unstyled mb-0">{"".join(items)}</ul>')
if isinstance(value, dict):
if not value:

View file

@ -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

View file

@ -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;
}