Compare commits
7 commits
4ae27e4109
...
1ac799e29c
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ac799e29c | |||
| 605d9941fb | |||
| a698d4b0ec | |||
| ee2efe1d47 | |||
| 5e92168d34 | |||
| 461565cdd4 | |||
| 69a0c5bd5d |
10 changed files with 743 additions and 197 deletions
|
|
@ -1202,5 +1202,50 @@ 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 []
|
||||
|
||||
|
||||
auditlog.register(ServiceInstance, exclude_fields=["updated_at"], serialize_data=True)
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -29,136 +29,197 @@
|
|||
{% 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 "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">
|
||||
{% 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>
|
||||
<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>
|
||||
{% 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|date:"SHORT_DATETIME_FORMAT" }}
|
||||
</dd>
|
||||
<dt class="col-sm-4">{% translate "Updated At" %}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ instance.updated_at|date:"SHORT_DATETIME_FORMAT" }}
|
||||
</dd>
|
||||
</dl>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-12 col-md-7">
|
||||
{% if instance.status_conditions %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>{% translate "Status" %}</h4>
|
||||
{% 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"
|
||||
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 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 %}
|
||||
<div class="table-responsive">
|
||||
<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></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, value in instance.connection_credentials.items %}
|
||||
{% if "_HOST" not in key and "_URL" not in key %}
|
||||
<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>
|
||||
<td class="text-truncate">{{ key }}</td>
|
||||
<td class="text-truncate">
|
||||
{% if key == "error" %}
|
||||
<span class="text-danger">{{ value }}</span>
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td>{{ condition.lastTransitionTime|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
<td>{{ condition.reason|default:"-" }}</td>
|
||||
<td>{{ condition.message|truncatewords:20|default:"-" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</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>
|
||||
{% 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 -->
|
||||
<!-- 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">
|
||||
|
|
@ -167,92 +228,162 @@
|
|||
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">
|
||||
<dl class="row mb-0">
|
||||
{% for field in fieldset.fields %}
|
||||
<dt class="col-sm-3">{{ field.label }}</dt>
|
||||
<dd class="col-sm-9">
|
||||
{% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %}
|
||||
<pre>{{ field.value|pprint }}</pre>
|
||||
{% else %}
|
||||
{{ field.value|default:"-" }}
|
||||
<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>
|
||||
<!-- Nested Fieldsets -->
|
||||
{% for sub_key, sub_fieldset in fieldset.fieldsets.items %}
|
||||
<h5>{{ sub_fieldset.title }}</h5>
|
||||
<dl class="row">
|
||||
<h5 class="mt-3">{{ sub_fieldset.title }}</h5>
|
||||
<dl class="row mb-0">
|
||||
{% for field in sub_fieldset.fields %}
|
||||
<dt class="col-sm-3">{{ field.label }}</dt>
|
||||
<dd class="col-sm-9">
|
||||
{% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %}
|
||||
<pre>{{ field.value|pprint }}</pre>
|
||||
{% else %}
|
||||
{{ field.value|default:"-" }}
|
||||
{% endif %}
|
||||
<dt class="col-sm-4">{{ field.label }}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ field.value|render_tree }}
|
||||
</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<p>{% translate "No specification details to display." %}</p>
|
||||
{% 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 %}
|
||||
</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 %}
|
||||
{% if instance.connection_credentials %}
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade"
|
||||
|
|
@ -285,6 +416,8 @@
|
|||
</div>
|
||||
{% endblock content %}
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'js/local-time.js' %}"></script>
|
||||
<script src="{% static 'js/credentials.js' %}"></script>
|
||||
<script>
|
||||
// Initialize Bootstrap popovers for help text
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
|
||||
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()
|
||||
|
||||
|
|
@ -10,3 +15,91 @@ def pprint(value):
|
|||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, indent=2)
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
68
src/servala/static/js/credentials.js
Normal file
68
src/servala/static/js/credentials.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -73,9 +73,7 @@ const runFqdnInit = () => {
|
|||
initializeFqdnGeneration("expert");
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
runFqdnInit()
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', runFqdnInit)
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'service-form') runFqdnInit()
|
||||
});
|
||||
|
|
|
|||
43
src/servala/static/js/local-time.js
Normal file
43
src/servala/static/js/local-time.js
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue