Compare commits

..

6 commits

Author SHA1 Message Date
371e04d20f Merge pull request 'Update https://github.com/renovatebot/github-action action to v44.2.0' (#339) from renovate/https-github.com-renovatebot-github-action-44.x into main
Reviewed-on: #339
2025-12-16 15:39:48 +00:00
Renovate Bot
6236a07fee Update https://github.com/renovatebot/github-action action to v44.2.0 2025-12-16 03:01:08 +00:00
43d5da8b9e Merge pull request 'Update https://github.com/renovatebot/github-action action to v44.1.0' (#335) from renovate/https-github.com-renovatebot-github-action-44.x into main 2025-12-15 08:48:52 +00:00
9dcd922b20 Merge pull request 'Lock file maintenance' (#336) from renovate/lock-file-maintenance into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 38s
Tests / test (push) Successful in 30s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #336
2025-12-15 08:48:48 +00:00
Renovate Bot
0bfe463282 Lock file maintenance
All checks were successful
Tests / test (push) Successful in 28s
2025-12-15 03:01:19 +00:00
Renovate Bot
17a133fb2b Update https://github.com/renovatebot/github-action action to v44.1.0 2025-12-13 03:00:59 +00:00
12 changed files with 210 additions and 756 deletions

View file

@ -19,7 +19,7 @@ jobs:
node-version: "24"
- name: Renovate
uses: https://github.com/renovatebot/github-action@v44.0.5
uses: https://github.com/renovatebot/github-action@v44.2.0
with:
token: ${{ secrets.RENOVATE_TOKEN }}
env:

View file

@ -1202,50 +1202,5 @@ 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)

View file

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

View file

@ -29,197 +29,136 @@
{% 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">
<!-- Row 1: Product Card and Connection Credentials -->
<div class="row match-height mb-4">
<div class="row match-height mb-5">
<div class="col-12 col-md-5">
{% 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">
<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">
{{ instance.context.service_definition.service.name }}
{% translate "at" %}
</dd>
<dt class="col-sm-4">{% translate "Service Provider" %}</dt>
<dd class="col-sm-8">
{{ instance.context.service_offering.provider.name }}
({{ instance.context.control_plane.name }})
</p>
</div>
<div class="card-body">
</dd>
<dt class="col-sm-4">{% translate "Control Plane" %}</dt>
<dd class="col-sm-8">
{{ instance.context.control_plane.name }}
</dd>
{% if compute_plan_assignment %}
<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">
<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">
<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>
SLA: {{ compute_plan_assignment.get_sla_display }}
<strong>CHF {{ compute_plan_assignment.price }}</strong>/{{ compute_plan_assignment.get_unit_display }}
</div>
<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>
</dd>
{% endif %}
{% if storage_plan %}
<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>
<dt class="col-sm-4">{% translate "Storage Plan" %}</dt>
<dd class="col-sm-8">
<strong>CHF {{ storage_plan.price_per_gib }}</strong> per GiB
<div class="text-muted small">{% translate "Billed separately based on disk usage" %}</div>
</dd>
{% endif %}
{% 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>
<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>
</div>
{% endif %}
</div>
</div>
<div class="col-12 col-md-7">
{% if instance.connection_credentials %}
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">{% translate "Connection Credentials" %}</h4>
<button type="button"
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>
{% if instance.status_conditions %}
<div class="card">
<div class="card-header">
<h4>{% translate "Status" %}</h4>
</div>
<div class="card-body">
<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 %}
<div class="row">
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th>{% translate "Type" %}</th>
<th>{% translate "Status" %}</th>
<th>{% translate "Last Transition Time" %}</th>
<th>{% translate "Reason" %}</th>
<th>{% translate "Message" %}</th>
</tr>
</thead>
<tbody>
{% for condition in instance.status_conditions %}
<tr>
<td class="text-truncate">{{ key }}</td>
<td class="text-truncate">
{% if key == "error" %}
<span class="text-danger">{{ value }}</span>
<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 %}
<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>
<span class="badge text-bg-secondary">{{ condition.status }}</span>
{% endif %}
</td>
<td>{{ condition.lastTransitionTime|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>{{ condition.reason|default:"-" }}</td>
<td>{{ condition.message|truncatewords:20|default:"-" }}</td>
</tr>
{% 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 %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Service Configuration -->
{% if instance.spec and spec_fieldsets %}
<div class="col-12 {% if instance.context.control_plane.user_info %}col-md-8{% endif %}">
<div class="card h-100">
<div class="card-header">
<h4>{% translate "Service Configuration" %}</h4>
</div>
<div class="card-body">
{% if spec_fieldsets|length > 1 %}
<!-- Multiple fieldsets: use tabs -->
{% if control_plane.user_info %}
<div class="card">
<div class="card-header">
<h4 class="card-title">{% translate "Service Provider Zone Information" %}</h4>
</div>
<div class="card-content">
{% include "includes/control_plane_user_info.html" with control_plane=instance.context.control_plane %}
</div>
</div>
{% endif %}
</div>
{% if instance.spec and spec_fieldsets %}
<div class="col-12">
<div class="card">
<div class="card-header">
<h4>{% translate "Specification" %}</h4>
</div>
<div class="card-body">
<div class="row">
<div class="col-12">
<!-- Tabs -->
<ul class="nav nav-tabs" role="tablist">
{% for fieldset in spec_fieldsets %}
<li class="nav-item" role="presentation">
@ -228,162 +167,92 @@
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">
<dl class="row mb-0">
<!-- Main Fields -->
<dl class="row">
{% 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>
<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>
<dd class="col-sm-8">
{{ field.value|render_tree }}
</dd>
{% endfor %}
</dl>
<!-- Nested Fieldsets -->
{% for sub_key, sub_fieldset in fieldset.fieldsets.items %}
<h5 class="mt-3">{{ sub_fieldset.title }}</h5>
<dl class="row mb-0">
<h5>{{ sub_fieldset.title }}</h5>
<dl class="row">
{% for field in sub_fieldset.fields %}
<dt class="col-sm-4">{{ field.label }}</dt>
<dd class="col-sm-8">
{{ field.value|render_tree }}
<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 %}
</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>
</div>
{% endif %}
{% 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>
</section>
<!-- Delete Confirmation Modal -->
<div class="modal fade"
@ -416,8 +285,6 @@
</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() {

View file

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

View file

@ -1,11 +1,6 @@
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()
@ -15,91 +10,3 @@ 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))

View file

@ -377,182 +377,17 @@ 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 _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):
def get_nested_spec(self):
"""
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.
Organize spec data into fieldsets similar to how the form does it.
"""
spec = self.object.spec or {}
if not spec:
return []
others = []
nested_fieldsets = {}
# First pass: organize fields into nested structures
@ -596,6 +431,15 @@ 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()):
@ -607,40 +451,38 @@ class ServiceInstanceDetailView(
group["fields"].append(field)
del group["fieldsets"][sub_key]
# Remove empty groups
# Move single-field groups to others
total_fields = len(group["fields"])
for sub_fieldset in group["fieldsets"].values():
total_fields += len(sub_fieldset["fields"])
if total_fields == 0:
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:
del nested_fieldsets[group_key]
# Create fieldsets from the organized data (no "General" tab)
fieldsets = []
if others:
fieldsets.append(
{
"title": "General",
"fields": others,
"fieldsets": {},
}
)
# Create fieldsets from the organized data
for group in nested_fieldsets.values():
fieldsets.append(group)
return fieldsets
def get_nested_spec(self):
"""
Organize spec data into fieldsets.
Uses form_config when available, otherwise auto-generates from spec structure.
"""
spec = self.object.spec or {}
if not spec:
return []
# Check if form_config exists
if (
self.object.context
and self.object.context.service_definition
and self.object.context.service_definition.form_config
):
return self._get_form_config_fieldsets()
return self._get_auto_generated_fieldsets()
class ServiceInstanceUpdateView(
ServiceInstanceMixin, OrganizationViewMixin, HtmxUpdateView

View file

@ -361,9 +361,3 @@ 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;
}

View file

@ -1,68 +0,0 @@
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,7 +73,9 @@ 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()
});

View file

@ -1,43 +0,0 @@
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)

24
uv.lock generated
View file

@ -87,30 +87,30 @@ wheels = [
[[package]]
name = "boto3"
version = "1.42.4"
version = "1.42.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/31/246916eec4fc5ff7bebf7e75caf47ee4d72b37d4120b6943e3460956e618/boto3-1.42.4.tar.gz", hash = "sha256:65f0d98a3786ec729ba9b5f70448895b2d1d1f27949aa7af5cb4f39da341bbc4", size = 112826, upload-time = "2025-12-05T20:27:14.931Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/07/dfa651dbd57bfc34d952a101280928bab08ed6186f009c660a36c211ccff/boto3-1.42.9.tar.gz", hash = "sha256:cdd4cc3e5bb08ed8a0c5cc77eca78f98f0239521de0991f14e44b788b0c639b2", size = 112827, upload-time = "2025-12-12T20:33:20.236Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/25/9ae819385aad79f524859f7179cecf8ac019b63ac8f150c51b250967f6db/boto3-1.42.4-py3-none-any.whl", hash = "sha256:0f4089e230d55f981d67376e48cefd41c3d58c7f694480f13288e6ff7b1fefbc", size = 140621, upload-time = "2025-12-05T20:27:12.803Z" },
{ url = "https://files.pythonhosted.org/packages/7b/eb/97fdf6fbc8066fb1475b8ef260c1a58798b2b4f1e8839b501550de5d5ba1/boto3-1.42.9-py3-none-any.whl", hash = "sha256:d21d22af9aeb1bad8e9b670a221d6534c0120f7e7baf523dafaca83f1f5c3f90", size = 140561, upload-time = "2025-12-12T20:33:18.035Z" },
]
[[package]]
name = "botocore"
version = "1.42.4"
version = "1.42.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/b7/dec048c124619b2702b5236c5fc9d8e5b0a87013529e9245dc49aaaf31ff/botocore-1.42.4.tar.gz", hash = "sha256:d4816023492b987a804f693c2d76fb751fdc8755d49933106d69e2489c4c0f98", size = 14848605, upload-time = "2025-12-05T20:27:02.919Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/f3/2d2cfb500e2dc00b0e33e3c8743306e6330f3cf219d19e9260dab2f3d6c2/botocore-1.42.9.tar.gz", hash = "sha256:74f69bfd116cc7c8215481284957eecdb48580e071dd50cb8c64356a866abd8c", size = 14861916, upload-time = "2025-12-12T20:33:08.017Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/a2/7b50f12a9c5a33cd85a5f23fdf78a0cbc445c0245c16051bb627f328be06/botocore-1.42.4-py3-none-any.whl", hash = "sha256:c3b091fd33809f187824b6434e518b889514ded5164cb379358367c18e8b0d7d", size = 14519938, upload-time = "2025-12-05T20:26:58.881Z" },
{ url = "https://files.pythonhosted.org/packages/1f/2a/e9275f40042f7a09915c4be86b092cb02dc4bd74e77ab8864f485d998af1/botocore-1.42.9-py3-none-any.whl", hash = "sha256:f99ba2ca34e24c4ebec150376c815646970753c032eb84f230874b2975a185a8", size = 14537810, upload-time = "2025-12-12T20:33:04.069Z" },
]
[[package]]
@ -130,11 +130,11 @@ wheels = [
[[package]]
name = "cachetools"
version = "6.2.2"
version = "6.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/44/5dc354b9f2df614673c2a542a630ef95d578b4a8673a1046d1137a7e2453/cachetools-6.2.3.tar.gz", hash = "sha256:64e0a4ddf275041dd01f5b873efa87c91ea49022b844b8c5d1ad3407c0f42f1f", size = 31641, upload-time = "2025-12-12T21:18:06.011Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" },
{ url = "https://files.pythonhosted.org/packages/ab/de/aa4cfc69feb5b3d604310214369979bb222ed0df0e2575a1b6e7af1a5579/cachetools-6.2.3-py3-none-any.whl", hash = "sha256:3fde34f7033979efb1e79b07ae529c2c40808bdd23b0b731405a48439254fba5", size = 11554, upload-time = "2025-12-12T21:18:04.556Z" },
]
[[package]]
@ -1210,11 +1210,11 @@ wheels = [
[[package]]
name = "tzdata"
version = "2025.2"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
[[package]]