Compare commits

...

7 commits

Author SHA1 Message Date
1ac799e29c redesign service detail page
All checks were successful
Tests / test (push) Successful in 28s
with the help of Claude Code
2025-12-12 08:51:43 +01:00
605d9941fb Move events to end of page and take up full width 2025-12-12 08:48:02 +01:00
a698d4b0ec Show k8s event on instance page 2025-12-12 08:48:02 +01:00
ee2efe1d47 Prettier rendering for JSON data on instance page 2025-12-12 08:48:02 +01:00
5e92168d34 fixup! Show times as local time 2025-12-12 08:48:02 +01:00
461565cdd4 Toggle credentials in instance detail view 2025-12-12 08:48:02 +01:00
69a0c5bd5d Show times as local time 2025-12-12 08:48:02 +01:00
10 changed files with 743 additions and 197 deletions

View file

@ -1202,5 +1202,50 @@ class ServiceInstance(ServalaModelMixin, models.Model):
except (AttributeError, KeyError, IndexError): except (AttributeError, KeyError, IndexError):
return None 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 []
auditlog.register(ServiceInstance, exclude_fields=["updated_at"], serialize_data=True) auditlog.register(ServiceInstance, exclude_fields=["updated_at"], serialize_data=True)

View file

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

View file

@ -29,136 +29,197 @@
{% endif %} {% endif %}
</div> </div>
{% endblock page_title_extra %} {% 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 %} {% block content %}
<section class="section"> <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="col-12 col-md-5">
<div class="card"> {% if compute_plan_assignment or storage_plan %}
<div class="card-header"> <div class="card h-100">
<h4>{% translate "Details" %}</h4> <div class="card-header">
</div> <h4 class="mb-1">{% translate "Product" %}</h4>
<div class="card-body"> <p class="text-muted mb-0 small">
<dl class="row">
<dt class="col-sm-4">{% translate "Instance ID" %}</dt>
<dd class="col-sm-8">
<code>{{ instance.name }}</code>
</dd>
<dt class="col-sm-4">{% translate "Service" %}</dt>
<dd class="col-sm-8">
{{ instance.context.service_definition.service.name }} {{ instance.context.service_definition.service.name }}
</dd> {% translate "at" %}
<dt class="col-sm-4">{% translate "Service Provider" %}</dt>
<dd class="col-sm-8">
{{ instance.context.service_offering.provider.name }} {{ instance.context.service_offering.provider.name }}
</dd> ({{ instance.context.control_plane.name }})
<dt class="col-sm-4">{% translate "Control Plane" %}</dt> </p>
<dd class="col-sm-8"> </div>
{{ instance.context.control_plane.name }} <div class="card-body">
</dd>
{% if compute_plan_assignment %} {% if compute_plan_assignment %}
<dt class="col-sm-4">{% translate "Compute Plan" %}</dt> <div class="mb-3">
<dd class="col-sm-8"> <h6 class="text-muted mb-2">{% translate "Compute" %}</h6>
{{ compute_plan_assignment.compute_plan.name }} <div class="mb-1">
<span class="badge bg-{% if compute_plan_assignment.sla == 'guaranteed' %}success{% else %}secondary{% endif %} ms-1"> {% translate "Plan:" %} <strong>{{ compute_plan_assignment.compute_plan.name }}</strong>
{{ compute_plan_assignment.get_sla_display }} </div>
</span> <div class="text-muted small">
<div class="text-muted small mt-1">
<i class="bi bi-cpu"></i> {{ compute_plan_assignment.compute_plan.cpu_limits }} vCPU <i class="bi bi-cpu"></i> {{ compute_plan_assignment.compute_plan.cpu_limits }} vCPU
<span class="mx-2"></span> <span class="mx-2"></span>
<i class="bi bi-memory"></i> {{ compute_plan_assignment.compute_plan.memory_limits }} RAM <i class="bi bi-memory"></i> {{ compute_plan_assignment.compute_plan.memory_limits }} RAM
<span class="mx-2"></span> <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> </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 %} {% endif %}
{% if storage_plan %} {% if storage_plan %}
<dt class="col-sm-4">{% translate "Storage Plan" %}</dt> <div class="mb-3">
<dd class="col-sm-8"> <h6 class="text-muted mb-2">{% translate "Storage" %}</h6>
<strong>CHF {{ storage_plan.price_per_gib }}</strong> per GiB <div class="text-muted small">
<div class="text-muted small">{% translate "Billed separately based on disk usage" %}</div> CHF {{ storage_plan.price_per_gib }} / GiB / {% translate "hour" %}
</dd> {% 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>
{% endif %} {% endif %}
<dt class="col-sm-4">{% translate "Created By" %}</dt> {% if pricing.total_monthly %}
<dd class="col-sm-8"> <hr class="my-2">
{{ instance.created_by|default:"-" }} <div class="d-flex justify-content-between align-items-center">
</dd> <span class="text-muted">{% translate "Estimated Total" %}</span>
<dt class="col-sm-4">{% translate "Created At" %}</dt> <div class="text-end">
<dd class="col-sm-8"> <div><strong>~CHF {{ pricing.total_monthly }}</strong> <span class="text-muted small">/ {% translate "month" %}</span></div>
{{ instance.created_at|date:"SHORT_DATETIME_FORMAT" }} <div class="text-muted small">CHF {{ pricing.total_hourly }} / {% translate "hour" %}</div>
</dd> </div>
<dt class="col-sm-4">{% translate "Updated At" %}</dt> </div>
<dd class="col-sm-8"> {% endif %}
{{ instance.updated_at|date:"SHORT_DATETIME_FORMAT" }} </div>
</dd>
</dl>
</div> </div>
</div> {% endif %}
</div> </div>
<div class="col-12 col-md-7"> <div class="col-12 col-md-7">
{% if instance.status_conditions %} {% if instance.connection_credentials %}
<div class="card"> <div class="card h-100">
<div class="card-header"> <div class="card-header d-flex justify-content-between align-items-center">
<h4>{% translate "Status" %}</h4> <h4 class="mb-0">{% translate "Connection Credentials" %}</h4>
<button type="button"
class="btn btn-sm btn-outline-secondary"
id="toggle-credentials-btn"
data-show-text="{% translate "Show All" %}"
data-hide-text="{% translate "Hide All" %}"
onclick="toggleAllCredentials()">
<i class="bi bi-eye me-1"></i>
<span id="toggle-credentials-text">{% translate "Show All" %}</span>
</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="table-responsive">
<div class="table-responsive"> <table class="table table-bordered table-fixed mb-0">
<table class="table table-bordered"> <colgroup>
<thead> <col style="width: 35%;">
<tr> <col style="width: 55%;">
<th>{% translate "Type" %}</th> <col style="width: 10%;">
<th>{% translate "Status" %}</th> </colgroup>
<th>{% translate "Last Transition Time" %}</th> <thead>
<th>{% translate "Reason" %}</th> <tr>
<th>{% translate "Message" %}</th> <th>{% translate "Name" %}</th>
</tr> <th>{% translate "Value" %}</th>
</thead> <th></th>
<tbody> </tr>
{% for condition in instance.status_conditions %} </thead>
<tbody>
{% for key, value in instance.connection_credentials.items %}
{% if "_HOST" not in key and "_URL" not in key %}
<tr> <tr>
<td>{{ condition.type }}</td> <td class="text-truncate">{{ key }}</td>
<td> <td class="text-truncate">
{% if condition.status == "True" %} {% if key == "error" %}
<span class="badge text-bg-success">True</span> <span class="text-danger">{{ value }}</span>
{% elif condition.status == "False" %}
<span class="badge text-bg-danger">False</span>
{% else %} {% else %}
<span class="badge text-bg-secondary">{{ condition.status }}</span> <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 %} {% endif %}
</td> </td>
<td>{{ condition.lastTransitionTime|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>{{ condition.reason|default:"-" }}</td>
<td>{{ condition.message|truncatewords:20|default:"-" }}</td>
</tr> </tr>
{% endfor %} {% endif %}
</tbody> {% endfor %}
</table> </tbody>
</table>
</div>
<div class="text-muted small mt-2">
<i class="bi bi-info-circle me-1"></i>
{% translate "Click the eye icon to reveal individual credentials, or use 'Show All' to reveal all at once." %}
</div>
</div>
</div>
{% 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>
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if control_plane.user_info %} <!-- Service Configuration -->
<div class="card"> {% if instance.spec and spec_fieldsets %}
<div class="card-header"> <div class="col-12 {% if instance.context.control_plane.user_info %}col-md-8{% endif %}">
<h4 class="card-title">{% translate "Service Provider Zone Information" %}</h4> <div class="card h-100">
</div> <div class="card-header">
<div class="card-content"> <h4>{% translate "Service Configuration" %}</h4>
{% include "includes/control_plane_user_info.html" with control_plane=instance.context.control_plane %} </div>
</div> <div class="card-body">
</div> {% if spec_fieldsets|length > 1 %}
{% endif %} <!-- Multiple fieldsets: use tabs -->
</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"> <ul class="nav nav-tabs" role="tablist">
{% for fieldset in spec_fieldsets %} {% for fieldset in spec_fieldsets %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
@ -167,92 +228,162 @@
data-bs-toggle="tab" data-bs-toggle="tab"
role="tab">{{ fieldset.title }}</a> role="tab">{{ fieldset.title }}</a>
</li> </li>
{% empty %}
<li class="nav-item ms-2">{% translate "No specification details available." %}</li>
{% endfor %} {% endfor %}
</ul> </ul>
<!-- Tab Content -->
<div class="tab-content pt-3"> <div class="tab-content pt-3">
{% for fieldset in spec_fieldsets %} {% for fieldset in spec_fieldsets %}
<div class="tab-pane fade {% if forloop.first %}show active{% endif %}" <div class="tab-pane fade {% if forloop.first %}show active{% endif %}"
id="spec-tab-{{ forloop.counter }}" id="spec-tab-{{ forloop.counter }}"
role="tabpanel"> role="tabpanel">
<!-- Main Fields --> <dl class="row mb-0">
<dl class="row">
{% for field in fieldset.fields %} {% for field in fieldset.fields %}
<dt class="col-sm-3">{{ field.label }}</dt> <dt class="col-sm-4">
<dd class="col-sm-9"> {{ field.label }}
{% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %} {% if field.help_text %}
<pre>{{ field.value|pprint }}</pre> <i class="bi bi-question-circle text-muted ms-1"
{% else %} data-bs-toggle="popover"
{{ field.value|default:"-" }} data-bs-trigger="hover"
data-bs-content="{{ field.help_text }}"></i>
{% endif %} {% endif %}
</dt>
<dd class="col-sm-8">
{{ field.value|render_tree }}
</dd> </dd>
{% endfor %} {% endfor %}
</dl> </dl>
<!-- Nested Fieldsets -->
{% for sub_key, sub_fieldset in fieldset.fieldsets.items %} {% for sub_key, sub_fieldset in fieldset.fieldsets.items %}
<h5>{{ sub_fieldset.title }}</h5> <h5 class="mt-3">{{ sub_fieldset.title }}</h5>
<dl class="row"> <dl class="row mb-0">
{% for field in sub_fieldset.fields %} {% for field in sub_fieldset.fields %}
<dt class="col-sm-3">{{ field.label }}</dt> <dt class="col-sm-4">{{ field.label }}</dt>
<dd class="col-sm-9"> <dd class="col-sm-8">
{% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %} {{ field.value|render_tree }}
<pre>{{ field.value|pprint }}</pre>
{% else %}
{{ field.value|default:"-" }}
{% endif %}
</dd> </dd>
{% endfor %} {% endfor %}
</dl> </dl>
{% endfor %} {% endfor %}
</div> </div>
{% empty %}
<p>{% translate "No specification details to display." %}</p>
{% endfor %} {% endfor %}
</div> </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 %}
</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> </div>
</div> </div>
</div> </div>
{% endif %} </div>
{% if instance.connection_credentials %} {% endif %}
<div class="col-12">
<div class="card">
<div class="card-header">
<h4>{% translate "Connection Credentials" %}</h4>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th>{% translate "Name" %}</th>
<th>{% translate "Value" %}</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>{{ value }}</code>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</section> </section>
<!-- Delete Confirmation Modal --> <!-- Delete Confirmation Modal -->
<div class="modal fade" <div class="modal fade"
@ -285,6 +416,8 @@
</div> </div>
{% endblock content %} {% endblock content %}
{% block extra_js %} {% block extra_js %}
<script src="{% static 'js/local-time.js' %}"></script>
<script src="{% static 'js/credentials.js' %}"></script>
<script> <script>
// Initialize Bootstrap popovers for help text // Initialize Bootstrap popovers for help text
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {

View file

@ -102,7 +102,7 @@
</div> </div>
<div class="plan-header"> <div class="plan-header">
<h6>{% trans "Storage" %}</h6> <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>
</div> </div>
{% if storage_plan %} {% if storage_plan %}

View file

@ -1,6 +1,11 @@
import json import json
from datetime import datetime
from django import template from django import template
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from servala.core.crd.utils import deslugify
register = template.Library() register = template.Library()
@ -10,3 +15,91 @@ def pprint(value):
if isinstance(value, (dict, list)): if isinstance(value, (dict, list)):
return json.dumps(value, indent=2) return json.dumps(value, indent=2)
return value return value
@register.filter
def localtime_tag(value, format_str="datetime"):
"""
Render a datetime value as a <time> element with datetime attribute.
JavaScript will convert this to local time on page load.
Usage: {{ instance.created_at|localtime_tag }}
{{ instance.created_at|localtime_tag:"date" }}
{{ instance.created_at|localtime_tag:"time" }}
"""
if value is None:
return "-"
if isinstance(value, str):
iso_value = value
elif isinstance(value, datetime):
iso_value = value.isoformat()
else:
return str(value)
if (
not iso_value.endswith("Z")
and "+" not in iso_value
and "-" not in iso_value[10:]
):
iso_value += "Z"
return format_html(
'<time datetime="{}" data-format="{}" class="local-time">{}</time>',
iso_value,
format_str,
iso_value,
)
@register.filter
def render_tree(value, key=""):
"""
Render a nested dict/list as a collapsible tree structure.
Used for displaying JSON parameters in a user-friendly way.
"""
if value is None:
return mark_safe('<span class="text-muted">-</span>')
if isinstance(value, bool):
badge_class = "bg-success" if value else "bg-secondary"
return format_html(
'<span class="badge {}">{}</span>',
badge_class,
"Yes" if value else "No",
)
if isinstance(value, (str, int, float)):
if isinstance(value, str) and len(value) > 100:
return format_html("<code>{}</code>", value)
return format_html("<span>{}</span>", value)
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">{"".join(items)}</ul>')
if isinstance(value, dict):
if not value:
return mark_safe('<span class="text-muted">{}</span>')
items = []
for k, v in value.items():
rendered_value = render_tree(v, k)
items.append(
f'<dt class="col-sm-4 text-truncate" title="{k}">{deslugify(k)}</dt>'
f'<dd class="col-sm-8">{rendered_value}</dd>'
)
return mark_safe(f'<dl class="row mb-0">{"".join(items)}</dl>')
return format_html("<span>{}</span>", str(value))

View file

@ -377,17 +377,182 @@ class ServiceInstanceDetailView(
"price_per_gib": self.object.context.control_plane.storage_plan_price_per_gib, "price_per_gib": self.object.context.control_plane.storage_plan_price_per_gib,
} }
# Calculate pricing summary
context["pricing"] = self._calculate_pricing()
return context 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 {} spec = self.object.spec or {}
if not spec: if not spec:
return [] return []
others = []
nested_fieldsets = {} nested_fieldsets = {}
# First pass: organize fields into nested structures # First pass: organize fields into nested structures
@ -431,15 +596,6 @@ class ServiceInstanceDetailView(
"value": sub_value, "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 # Second pass: Promote fields based on count
for group_key, group in list(nested_fieldsets.items()): for group_key, group in list(nested_fieldsets.items()):
@ -451,38 +607,40 @@ class ServiceInstanceDetailView(
group["fields"].append(field) group["fields"].append(field)
del group["fieldsets"][sub_key] del group["fieldsets"][sub_key]
# Move single-field groups to others # Remove empty groups
total_fields = len(group["fields"]) total_fields = len(group["fields"])
for sub_fieldset in group["fieldsets"].values(): for sub_fieldset in group["fieldsets"].values():
total_fields += len(sub_fieldset["fields"]) total_fields += len(sub_fieldset["fields"])
if ( if total_fields == 0:
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:
del nested_fieldsets[group_key] del nested_fieldsets[group_key]
# Create fieldsets from the organized data (no "General" tab)
fieldsets = [] fieldsets = []
if others:
fieldsets.append(
{
"title": "General",
"fields": others,
"fieldsets": {},
}
)
# Create fieldsets from the organized data
for group in nested_fieldsets.values(): for group in nested_fieldsets.values():
fieldsets.append(group) fieldsets.append(group)
return fieldsets 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( class ServiceInstanceUpdateView(
ServiceInstanceMixin, OrganizationViewMixin, HtmxUpdateView ServiceInstanceMixin, OrganizationViewMixin, HtmxUpdateView

View file

@ -361,3 +361,9 @@ html[data-bs-theme="dark"] .beta-banner-button:hover {
background-color: white; background-color: white;
color: var(--bs-primary); color: var(--bs-primary);
} }
/* Fixed table layout for credentials */
.table-fixed {
table-layout: fixed;
word-wrap: break-word;
}

View file

@ -0,0 +1,68 @@
const CREDENTIAL_MASK = '••••••••••••';
function toggleCredential(button) {
const row = button.closest('tr');
const codeEl = row.querySelector('.credential-value');
const icon = button.querySelector('i');
if (!codeEl) return;
const isHidden = codeEl.textContent === CREDENTIAL_MASK;
if (isHidden) {
codeEl.textContent = codeEl.getAttribute('data-value');
icon.classList.remove('bi-eye');
icon.classList.add('bi-eye-slash');
} else {
codeEl.textContent = CREDENTIAL_MASK;
icon.classList.remove('bi-eye-slash');
icon.classList.add('bi-eye');
}
updateShowAllButton();
}
function toggleAllCredentials() {
const credentials = document.querySelectorAll('.credential-value');
const anyHidden = Array.from(credentials).some(el => el.textContent === CREDENTIAL_MASK);
credentials.forEach(function (codeEl) {
const row = codeEl.closest('tr');
const button = row.querySelector('.credential-toggle');
const icon = button ? button.querySelector('i') : null;
if (anyHidden) {
codeEl.textContent = codeEl.getAttribute('data-value');
if (icon) {
icon.classList.remove('bi-eye');
icon.classList.add('bi-eye-slash');
}
} else {
codeEl.textContent = CREDENTIAL_MASK;
if (icon) {
icon.classList.remove('bi-eye-slash');
icon.classList.add('bi-eye');
}
}
});
updateShowAllButton();
}
function updateShowAllButton() {
const credentials = document.querySelectorAll('.credential-value');
const anyHidden = Array.from(credentials).some(el => el.textContent === CREDENTIAL_MASK);
const toggleBtn = document.getElementById('toggle-credentials-btn');
const toggleText = document.getElementById('toggle-credentials-text');
const toggleIcon = toggleBtn ? toggleBtn.querySelector('i') : null;
if (toggleText && toggleBtn) {
const showText = toggleBtn.getAttribute('data-show-text') || 'Show All';
const hideText = toggleBtn.getAttribute('data-hide-text') || 'Hide All';
toggleText.textContent = anyHidden ? showText : hideText;
}
if (toggleIcon) {
toggleIcon.classList.toggle('bi-eye', anyHidden);
toggleIcon.classList.toggle('bi-eye-slash', !anyHidden);
}
}

View file

@ -73,9 +73,7 @@ const runFqdnInit = () => {
initializeFqdnGeneration("expert"); initializeFqdnGeneration("expert");
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', runFqdnInit)
runFqdnInit()
});
document.body.addEventListener('htmx:afterSwap', function(event) { document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'service-form') runFqdnInit() if (event.detail.target.id === 'service-form') runFqdnInit()
}); });

View file

@ -0,0 +1,43 @@
const convertToLocalTime = () => {
document.querySelectorAll('time.local-time').forEach((el) => {
const isoDate = el.getAttribute('datetime')
const format = el.getAttribute('data-format') || 'datetime'
if (!isoDate) return
try {
const date = new Date(isoDate)
if (isNaN(date.getTime())) return
let options = {}
if (format === 'date') {
options = {
year: 'numeric',
month: 'short',
day: 'numeric'
}
} else if (format === 'time') {
options = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}
} else {
options = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}
}
el.textContent = date.toLocaleString(undefined, options)
el.title = date.toISOString()
} catch (e) {
console.error('Error parsing date:', isoDate, e)
}
})
}
document.addEventListener('DOMContentLoaded', convertToLocalTime)