Compare commits
8 commits
1ac799e29c
...
275e5e3cc1
| Author | SHA1 | Date | |
|---|---|---|---|
| 275e5e3cc1 | |||
| 5c39d26b62 | |||
| 5e543b1e4c | |||
| 63b451b234 | |||
| ed0ff0f380 | |||
| c36ee2494d | |||
| feede16265 | |||
| 15b5d78c62 |
10 changed files with 743 additions and 193 deletions
|
|
@ -1152,5 +1152,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)
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -29,132 +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 "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">
|
||||||
|
|
@ -163,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"
|
||||||
|
|
@ -281,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() {
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -63,9 +63,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()
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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