Merge pull request 'Display Instance Details' (#50) from 28-instance-details into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m27s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 8s

Reviewed-on: #50
This commit is contained in:
Tobias Kunze 2025-04-17 15:04:14 +00:00
commit a1ffdf565d
6 changed files with 397 additions and 29 deletions

View file

@ -44,7 +44,7 @@ known_first_party = "servala"
[tool.flake8]
max-line-length = 160
exclude = ".venv"
ignore = "E203"
ignore = "E203,W503"
[tool.djlint]
extend_exclude = "src/servala/static/mazer"

View file

@ -126,6 +126,7 @@ class ControlPlaneAdmin(admin.ModelAdmin):
search_fields = ("name", "description")
autocomplete_fields = ("cloud_provider",)
actions = ["test_kubernetes_connection"]
ordering = ("name",)
fieldsets = (
(

View file

@ -355,11 +355,27 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
def __str__(self):
return f"{self.service_offering} on {self.control_plane} with {self.service_definition}"
@cached_property
def group(self):
return self.service_definition.api_definition["group"]
@cached_property
def version(self):
return self.service_definition.api_definition["version"]
@cached_property
def kind(self):
return self.service_definition.api_definition["kind"]
@cached_property
def kind_plural(self):
plural = self.kind.lower()
if not plural.endswith("s"):
plural = f"{plural}s"
return plural
@cached_property
def resource_definition(self):
kind = self.service_definition.api_definition["kind"]
group = self.service_definition.api_definition["group"]
version = self.service_definition.api_definition["version"]
client = self.control_plane.get_kubernetes_client()
extensions_api = kubernetes.client.ApiextensionsV1Api(client)
@ -368,10 +384,10 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
for crd in crds.items:
if matching_crd:
break
if crd.spec.group == group:
if crd.spec.group == self.group:
for served_version in crd.spec.versions:
if served_version.name == version and served_version.served:
if crd.spec.names.kind == kind:
if served_version.name == self.version and served_version.served:
if crd.spec.names.kind == self.kind:
matching_crd = crd
break
return matching_crd
@ -382,9 +398,8 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
if result := cache.get(cache_key):
return result
version = self.service_definition.api_definition["version"]
for v in self.resource_definition.spec.versions:
if v.name == version:
if v.name == self.version:
result = v.schema.open_apiv3_schema.to_dict()
timeout_seconds = 60 * 60 * 24
cache.set(cache_key, result, timeout=timeout_seconds)
@ -395,9 +410,9 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
from servala.core.crd import generate_django_model
kwargs = {
key: value
for key, value in self.service_definition.api_definition.items()
if key in ("group", "version", "kind")
"group": self.group,
"version": self.version,
"kind": self.kind,
}
return generate_django_model(self.resource_schema, **kwargs)
@ -517,12 +532,9 @@ class ServiceInstance(ServalaModelMixin, models.Model):
)
try:
group = context.service_definition.api_definition["group"]
version = context.service_definition.api_definition["version"]
kind = context.service_definition.api_definition["kind"]
create_data = {
"apiVersion": f"{group}/{version}",
"kind": kind,
"apiVersion": f"{context.group}/{context.version}",
"kind": context.kind,
"metadata": {
"name": name,
"namespace": organization.namespace,
@ -534,15 +546,11 @@ class ServiceInstance(ServalaModelMixin, models.Model):
api_instance = client.CustomObjectsApi(
context.control_plane.get_kubernetes_client()
)
plural = kind.lower()
if not plural.endswith("s"):
plural = f"{plural}s"
api_instance.create_namespaced_custom_object(
group=group,
version=version,
group=context.group,
version=context.version,
namespace=organization.namespace,
plural=plural,
plural=context.kind_plural,
body=create_data,
)
except Exception as e:
@ -556,3 +564,95 @@ class ServiceInstance(ServalaModelMixin, models.Model):
raise ValidationError(_("Kubernetes API error: {}").format(str(e)))
raise ValidationError(_("Error creating instance: {}").format(str(e)))
return instance
@cached_property
def kubernetes_object(self):
"""Fetch the Kubernetes custom resource object"""
try:
api_instance = client.CustomObjectsApi(
self.context.control_plane.get_kubernetes_client()
)
return api_instance.get_namespaced_custom_object(
group=self.context.group,
version=self.context.version,
namespace=self.organization.namespace,
plural=self.context.kind_plural,
name=self.name,
)
except ApiException as e:
if e.status == 404:
return None
raise
@cached_property
def spec(self):
if not self.kubernetes_object:
return {}
if not (spec := self.kubernetes_object.get("spec")):
return {}
# Remove fields that shouldn't be displayed
spec = spec.copy()
spec.pop("resourceRef", None)
spec.pop("writeConnectionSecretToRef", None)
return spec
@cached_property
def status_conditions(self):
if not self.kubernetes_object:
return []
if not (status := self.kubernetes_object.get("status")):
return []
return status.get("conditions") or []
@cached_property
def connection_credentials(self):
"""
Get connection credentials directly from the resource's writeConnectionSecretToRef
after checking that secret conditions are available.
"""
if not self.kubernetes_object:
return {}
# Check if secrets are available based on conditions
secrets_available = any(
[
condition.get("type") == "Ready" and condition.get("status") == "True"
for condition in self.status_conditions
]
)
if not secrets_available:
return {}
spec = self.kubernetes_object.get("spec")
if not (secret_ref := spec.get("writeConnectionSecretToRef")):
return {}
if not (secret_name := secret_ref.get("name")):
return {}
try:
# Get the secret data
v1 = kubernetes.client.CoreV1Api(
self.context.control_plane.get_kubernetes_client()
)
secret = v1.read_namespaced_secret(
name=secret_name, namespace=self.organization.namespace
)
# Secret data is base64 encoded
credentials = {}
if hasattr(secret, "data") and secret.data:
import base64
for key, value in secret.data.items():
try:
credentials[key] = base64.b64decode(value).decode("utf-8")
except Exception:
credentials[key] = f"<binary data: {len(value)} bytes>"
return credentials
except ApiException as e:
return {"error": str(e)}
except Exception as e:
return {"error": str(e)}

View file

@ -7,11 +7,13 @@
{% endblock html_title %}
{% block content %}
<section class="section">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h5>{% translate "Details" %}</h5>
<div class="row">
<div class="col-12 col-md-5">
<div class="card">
<div class="card-header">
<h4>{% translate "Details" %}</h4>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-4">{% translate "Service" %}</dt>
<dd class="col-sm-8">
@ -49,6 +51,148 @@
</div>
</div>
</div>
{% if instance.status_conditions %}
<div class="col-12 col-md-7">
<div class="card">
<div class="card-header">
<h4>{% translate "Status" %}</h4>
</div>
<div class="card-body">
<div class="row">
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th>{% translate "Type" %}</th>
<th>{% translate "Status" %}</th>
<th>{% translate "Last Transition Time" %}</th>
<th>{% translate "Reason" %}</th>
<th>{% translate "Message" %}</th>
</tr>
</thead>
<tbody>
{% for condition in instance.status_conditions %}
<tr>
<td>{{ condition.type }}</td>
<td>
{% if condition.status == "True" %}
<span class="badge text-bg-success">True</span>
{% elif condition.status == "False" %}
<span class="badge text-bg-danger">False</span>
{% else %}
<span class="badge text-bg-secondary">{{ condition.status }}</span>
{% endif %}
</td>
<td>{{ condition.lastTransitionTime }}</td>
<td>{{ condition.reason }}</td>
<td>{{ condition.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if instance.spec %}
<div class="col-12">
<div class="card">
<div class="card-header">
<h4>{% translate "Specification" %}</h4>
</div>
<div class="card-body">
<div class="row">
<div class="col-12">
<!-- Tabs -->
<ul class="nav nav-tabs" role="tablist">
{% for fieldset in spec_fieldsets %}
<li class="nav-item" role="presentation">
<a href="#spec-tab-{{ forloop.counter }}"
class="nav-link {% if forloop.first %}active{% endif %}"
data-bs-toggle="tab"
role="tab">{{ fieldset.title }}</a>
</li>
{% endfor %}
</ul>
<!-- Tab Content -->
<div class="tab-content pt-3">
{% for fieldset in spec_fieldsets %}
<div class="tab-pane fade {% if forloop.first %}show active{% endif %}"
id="spec-tab-{{ forloop.counter }}"
role="tabpanel">
<!-- Main Fields -->
<dl class="row">
{% for field in fieldset.fields %}
<dt class="col-sm-3">{{ field.label }}</dt>
<dd class="col-sm-9">
{% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %}
<pre>{{ field.value|pprint }}</pre>
{% else %}
{{ field.value }}
{% endif %}
</dd>
{% endfor %}
</dl>
<!-- Nested Fieldsets -->
{% for sub_key, sub_fieldset in fieldset.fieldsets.items %}
<h5>{{ sub_fieldset.title }}</h5>
<dl class="row">
{% for field in sub_fieldset.fields %}
<dt class="col-sm-3">{{ field.label }}</dt>
<dd class="col-sm-9">
{% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %}
<pre>{{ field.value|pprint }}</pre>
{% else %}
{{ field.value }}
{% endif %}
</dd>
{% endfor %}
</dl>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if instance.connection_credentials %}
<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>
{% endif %}
</div>
</section>
{% endblock content %}

View file

@ -0,0 +1,12 @@
import json
from django import template
register = template.Library()
@register.filter
def pprint(value):
if isinstance(value, (dict, list)):
return json.dumps(value, indent=2)
return value

View file

@ -4,6 +4,7 @@ from django.shortcuts import redirect
from django.utils.functional import cached_property
from django.views.generic import DetailView, ListView
from servala.core.crd import deslugify
from servala.core.models import (
ControlPlaneCRD,
Service,
@ -168,6 +169,116 @@ class ServiceInstanceDetailView(OrganizationViewMixin, DetailView):
"context__service_definition__service",
)
def get_nested_spec(self):
"""
Organize spec data into fieldsets similar to how the form does it.
"""
spec = self.object.spec or {}
# Process spec fields
others = []
nested_fieldsets = {}
# First pass: organize fields into nested structures
for key, value in spec.items():
if isinstance(value, dict):
# This is a nested structure
if key not in nested_fieldsets:
nested_fieldsets[key] = {
"title": deslugify(key),
"fields": [],
"fieldsets": {},
}
# Process fields in the nested structure
for sub_key, sub_value in value.items():
if isinstance(sub_value, dict):
# Even deeper nesting
if sub_key not in nested_fieldsets[key]["fieldsets"]:
nested_fieldsets[key]["fieldsets"][sub_key] = {
"title": deslugify(sub_key),
"fields": [],
}
# Add fields from the deeper level
for leaf_key, leaf_value in sub_value.items():
nested_fieldsets[key]["fieldsets"][sub_key][
"fields"
].append(
{
"key": leaf_key,
"label": deslugify(leaf_key),
"value": leaf_value,
}
)
else:
# Add field to parent level
nested_fieldsets[key]["fields"].append(
{
"key": sub_key,
"label": deslugify(sub_key),
"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()):
# Promote single sub-fieldsets to parent
for sub_key, sub_fieldset in list(group["fieldsets"].items()):
if len(sub_fieldset["fields"]) == 1:
field = sub_fieldset["fields"][0]
field["label"] = f"{sub_fieldset['title']}: {field['label']}"
group["fields"].append(field)
del group["fieldsets"][sub_key]
# 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 == 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]
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_context_data(self, **kwargs):
"""Return service instance for the current organization."""
context = super().get_context_data(**kwargs)
if self.object.spec:
context["spec_fieldsets"] = self.get_nested_spec()
return context
class ServiceInstanceListView(OrganizationViewMixin, ListView):
template_name = "frontend/organizations/service_instances.html"