diff --git a/.env.example b/.env.example index 63df700..9e31fbd 100644 --- a/.env.example +++ b/.env.example @@ -77,3 +77,7 @@ SERVALA_ODOO_HELPDESK_TEAM_ID='5' # OSB API authentication settings SERVALA_OSB_USERNAME='' SERVALA_OSB_PASSWORD='' + +# Prefix for auto-generated Kubernetes resource names for service instances. +# Format: {prefix}-{hash}. Defaults to 'si' (service instance). +SERVALA_INSTANCE_NAME_PREFIX='si' diff --git a/README.md b/README.md index eaa1cdd..f435a90 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,32 @@ Then access it with http://localhost:8080/ and the Django admin with http://loca Deployment files are in the `deployment/kustomize` folder and makes use of [Kustomize](https://kustomize.io/) to account for differences between the deployment stages. Stages are configured with overlays in `deployment/kustomize/overlays/$environment`. +### Resource Requests and Limits + +Resources are configured to comply with APPUiO Cloud's memory-to-CPU ratio of 4096 MiB/core. +See [APPUiO Cloud documentation](https://docs.appuio.cloud/user/how-to/check-cpu-requests.html) for details. + +**Production:** + +| Container | CPU Request | Memory Request | CPU Limit | Memory Limit | +|-----------|-------------|----------------|-----------|--------------| +| servala | 500m | 2Gi | 2 | 4Gi | + +**Staging:** + +| Container | CPU Request | Memory Request | CPU Limit | Memory Limit | +|------------------|-------------|----------------|-----------|--------------| +| servala | 250m | 1Gi | 1 | 2Gi | +| ssh-tunnel-dev | 50m | 204Mi | 100m | 256Mi | +| ssh-tunnel-talos | 50m | 204Mi | 100m | 256Mi | + +**Ratio Calculation:** + +The ratio is calculated as: `Sum of Memory Requests / Sum of CPU Requests` + +- Production: 2048 MiB / 0.5 cores = **4096 MiB/core** ✓ +- Staging: (1024 + 204 + 204) MiB / (0.25 + 0.05 + 0.05) cores = 1432 MiB / 0.35 cores = **4091 MiB/core** ✓ + ### Staging The code is automatically built and deployed on a push to the main branch. diff --git a/deployment/kustomize/base/portal/deployment.yaml b/deployment/kustomize/base/portal/deployment.yaml index 03deb98..1b33aae 100644 --- a/deployment/kustomize/base/portal/deployment.yaml +++ b/deployment/kustomize/base/portal/deployment.yaml @@ -21,6 +21,13 @@ spec: - name: servala image: servala.app.codey.ch/servala/servala-portal:latest imagePullPolicy: Always + resources: + requests: + cpu: 500m + memory: 2Gi + limits: + cpu: 2 + memory: 4Gi ports: - name: http containerPort: 8080 diff --git a/deployment/kustomize/overlays/production/portal-deployment.yaml b/deployment/kustomize/overlays/production/portal-deployment.yaml index 47574f4..bc6050f 100644 --- a/deployment/kustomize/overlays/production/portal-deployment.yaml +++ b/deployment/kustomize/overlays/production/portal-deployment.yaml @@ -32,10 +32,3 @@ spec: secretKeyRef: name: portal-storage-creds key: AWS_SECRET_ACCESS_KEY - resources: - limits: - cpu: 2 - memory: 2Gi - requests: - cpu: 500m - memory: 512Mi diff --git a/deployment/kustomize/overlays/staging/portal-deployment.yaml b/deployment/kustomize/overlays/staging/portal-deployment.yaml index 0c13f2d..e978ccd 100644 --- a/deployment/kustomize/overlays/staging/portal-deployment.yaml +++ b/deployment/kustomize/overlays/staging/portal-deployment.yaml @@ -7,6 +7,13 @@ spec: spec: containers: - name: servala + resources: + requests: + cpu: 250m + memory: 1Gi + limits: + cpu: 1 + memory: 2Gi env: - name: SERVALA_ENVIRONMENT value: staging @@ -32,8 +39,15 @@ spec: secretKeyRef: name: portal-storage-creds key: AWS_SECRET_ACCESS_KEY - - name: ssh-tunnel + - name: ssh-tunnel-dev image: servala.app.codey.ch/servala/servala-portal:latest + resources: + requests: + cpu: 50m + memory: 204Mi + limits: + cpu: 100m + memory: 256Mi command: - "/bin/bash" - "-c" @@ -41,13 +55,40 @@ spec: mkdir -p /app/.ssh && chmod 700 /app/.ssh echo "$SSH_PRIVATE_KEY" > /app/.ssh/id chmod 600 /app/.ssh/id - ssh $SSH_HOST -l $SSH_USER -o StrictHostKeyChecking=no -L 8443:127.0.0.1:8443 -N -i /app/.ssh/id -v + ssh $SSH_HOST -l $SSH_USER -o StrictHostKeyChecking=no -L 6443:127.0.0.1:6443 -N -i /app/.ssh/id -v env: - name: SSH_HOST - valueFrom: - secretKeyRef: - name: servala-sshclient - key: ssh-host + value: "78.47.176.209" + - name: SSH_USER + valueFrom: + secretKeyRef: + name: servala-sshclient + key: ssh-user + - name: SSH_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: servala-sshclient + key: ssh-private-key + - name: ssh-tunnel-talos + image: servala.app.codey.ch/servala/servala-portal:latest + resources: + requests: + cpu: 50m + memory: 204Mi + limits: + cpu: 100m + memory: 256Mi + command: + - "/bin/bash" + - "-c" + - | + mkdir -p /app/.ssh && chmod 700 /app/.ssh + echo "$SSH_PRIVATE_KEY" > /app/.ssh/id + chmod 600 /app/.ssh/id + ssh $SSH_HOST -l $SSH_USER -o StrictHostKeyChecking=no -L 6444:172.18.200.10:6443 -N -i /app/.ssh/id -v + env: + - name: SSH_HOST + value: mgmt.cls-rma1-9c02.servala.com - name: SSH_USER valueFrom: secretKeyRef: diff --git a/pyproject.toml b/pyproject.toml index 0e895cc..bb254a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,15 +28,15 @@ dependencies = [ [dependency-groups] dev = [ - "black>=25.11.0", + "black>=25.12.0", "bumpver>=2025.1131", - "coverage>=7.12.0", + "coverage>=7.13.0", "djlint>=1.36.4", "flake8>=7.3.0", "flake8-bugbear>=25.11.29", "flake8-pyproject>=1.2.4", "isort>=7.0.0", - "pytest>=9.0.1", + "pytest>=9.0.2", "pytest-cov>=7.0.0", "pytest-django>=4.11.1", "pytest-mock>=3.15.1", diff --git a/src/servala/api/views.py b/src/servala/api/views.py index 9b0082d..0bca7a7 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -301,7 +301,7 @@ The Servala Team""" "core_serviceinstance_change", service_instance.pk ) description_parts.append( - f"Instance: {service_instance.name} - {instance_url}" + f"Instance: {service_instance.display_name} ({service_instance.name}) - {instance_url}" ) else: description_parts.append(f"Instance: {instance_id}") diff --git a/src/servala/core/crd/forms.py b/src/servala/core/crd/forms.py index 18df8bc..6bde2c9 100644 --- a/src/servala/core/crd/forms.py +++ b/src/servala/core/crd/forms.py @@ -9,17 +9,17 @@ from servala.core.models import ControlPlaneCRD from servala.frontend.forms.widgets import DynamicArrayWidget, NumberInputWithAddon # Fields that must be present in every form -MANDATORY_FIELDS = ["name"] +MANDATORY_FIELDS = ["display_name"] # Default field configurations - fields that can be included with just a mapping # to avoid administrators having to duplicate common information DEFAULT_FIELD_CONFIGS = { - "name": { + "display_name": { "type": "text", "label": "Instance Name", - "help_text": "Unique name for the new instance", + "help_text": "", "required": True, - "max_length": 63, + "max_length": 100, }, "spec.parameters.service.fqdn": { "type": "array", @@ -51,11 +51,6 @@ class FormGeneratorMixin: crd = getattr(crd, "pk", crd) # can be int or object self.fields["context"].queryset = ControlPlaneCRD.objects.filter(pk=crd) - if self.instance and hasattr(self.instance, "name") and self.instance.name: - if "name" in self.fields: - self.fields["name"].disabled = True - self.fields["name"].widget = forms.HiddenInput() - def has_mandatory_fields(self, field_list): for field_name in field_list: if field_name in self.fields and self.fields[field_name].required: @@ -307,15 +302,6 @@ class CustomFormMixin(FormGeneratorMixin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._apply_field_config() - if ( - self.instance - and hasattr(self.instance, "name") - and self.instance.name - and "name" in self.fields - ): - self.fields["name"].widget = forms.HiddenInput() - self.fields["name"].disabled = True - self.fields.pop("context", None) def _apply_field_config(self): for fieldset in self.form_config.get("fieldsets", []): @@ -472,7 +458,7 @@ def generate_custom_form_class(form_config, model): """ Generate a custom (user-friendly) form class from form_config JSON. """ - field_list = ["context", "name"] + field_list = ["context", "display_name"] for fieldset in form_config.get("fieldsets", []): for field_config in fieldset.get("fields", []): diff --git a/src/servala/core/crd/models.py b/src/servala/core/crd/models.py index 86df97f..a4fcc28 100644 --- a/src/servala/core/crd/models.py +++ b/src/servala/core/crd/models.py @@ -23,9 +23,9 @@ def generate_django_model(schema, group, version, kind): """ Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema. """ - # We always need these three fields to know our own name and our full namespace + # We always need these fields to know our display name and our full namespace model_fields = {"__module__": "crd_models"} - for field_name in ("name", "context"): + for field_name in ("display_name", "context"): model_fields[field_name] = duplicate_field(field_name, ServiceInstance) # All other fields are generated from the schema, except for the diff --git a/src/servala/core/forms.py b/src/servala/core/forms.py index 090abba..3bad850 100644 --- a/src/servala/core/forms.py +++ b/src/servala/core/forms.py @@ -254,7 +254,7 @@ class ServiceDefinitionAdminForm(forms.ModelForm): if not schema or not (spec_schema := schema.get("properties", {}).get("spec")): return - valid_paths = self._extract_field_paths(spec_schema, "spec") | {"name"} + valid_paths = self._extract_field_paths(spec_schema, "spec") | {"display_name"} included_mappings = set() errors = [] for fieldset in form_config.get("fieldsets", []): diff --git a/src/servala/core/management/commands/sync_billing_metadata.py b/src/servala/core/management/commands/sync_billing_metadata.py index 2093948..ad1d74b 100644 --- a/src/servala/core/management/commands/sync_billing_metadata.py +++ b/src/servala/core/management/commands/sync_billing_metadata.py @@ -195,6 +195,7 @@ class Command(BaseCommand): compute_plan_assignment=instance.compute_plan_assignment, control_plane=instance.context.control_plane, instance_name=instance.name, + display_name=instance.display_name, organization=instance.organization, service=instance.context.service_offering.service, ) diff --git a/src/servala/core/migrations/0019_add_display_name.py b/src/servala/core/migrations/0019_add_display_name.py new file mode 100644 index 0000000..4ff3b92 --- /dev/null +++ b/src/servala/core/migrations/0019_add_display_name.py @@ -0,0 +1,46 @@ +from django.db import migrations, models + +import servala.core.validators + + +def populate_display_name(apps, schema_editor): + """ + For existing instances, copy name to display_name. + Existing instances already have their name matching the k8s resource, + so we cannot add a prefix. + """ + ServiceInstance = apps.get_model("core", "ServiceInstance") + for instance in ServiceInstance.objects.all(): + instance.display_name = instance.name + instance.save(update_fields=["display_name"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0018_add_invoice_grouping_to_organization_origin"), + ] + + operations = [ + migrations.AlterField( + model_name="serviceinstance", + name="name", + field=models.CharField( + max_length=63, + validators=[servala.core.validators.kubernetes_name_validator], + verbose_name="Instance ID", + ), + ), + # Add display_name field with a temporary default + migrations.AddField( + model_name="serviceinstance", + name="display_name", + field=models.CharField( + default="", + help_text="Display name for this instance", + max_length=100, + verbose_name="Name", + ), + preserve_default=False, + ), + migrations.RunPython(populate_display_name, migrations.RunPython.noop), + ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index c6552c4..31527c5 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -1,4 +1,5 @@ import copy +import hashlib import html import json import re @@ -615,8 +616,16 @@ class ServiceInstance(ServalaModelMixin, models.Model): on the fly. """ + # The Kubernetes resource name (metadata.name). This field is immutable after + # creation and is auto-generated for new instances. Do not modify directly! name = models.CharField( - max_length=63, verbose_name=_("Name"), validators=[kubernetes_name_validator] + max_length=63, + verbose_name=_("Instance ID"), + validators=[kubernetes_name_validator], + ) + display_name = models.CharField( + max_length=100, + verbose_name=_("Name"), ) organization = models.ForeignKey( to="core.Organization", @@ -686,6 +695,24 @@ class ServiceInstance(ServalaModelMixin, models.Model): spec_data = prune_empty_data(spec_data) return spec_data + @staticmethod + def generate_resource_name(organization, display_name, service, attempt=0): + """ + Generate a unique Kubernetes-compatible resource name. + + Format: {prefix}-{sha256[:8]} + + The hash input is: org_slug:display_name:service_slug[:attempt if > 0] + On collision, we retry with an incremented attempt number included in hash. + """ + hash_input = ( + f"{organization.slug}:{display_name.lower().strip()}:{service.slug}" + ) + if attempt > 0: + hash_input += f":{attempt}" + hash_value = hashlib.sha256(hash_input.encode("utf-8")).hexdigest()[:8] + return f"{settings.SERVALA_INSTANCE_NAME_PREFIX}-{hash_value}" + @staticmethod def _apply_compute_plan_to_spec(spec_data, compute_plan_assignment): """ @@ -719,16 +746,20 @@ class ServiceInstance(ServalaModelMixin, models.Model): compute_plan_assignment, control_plane, instance_name=None, + display_name=None, organization=None, service=None, ): """ - Build Kubernetes annotations for billing integration. + Build Kubernetes annotations for billing integration and display name. """ from servala.core.models.organization import InvoiceGroupingChoice annotations = {} + if display_name: + annotations["servala.com/displayName"] = display_name + if compute_plan_assignment: annotations["servala.com/erp_product_id_resource"] = str( compute_plan_assignment.odoo_product_id @@ -830,18 +861,35 @@ class ServiceInstance(ServalaModelMixin, models.Model): @transaction.atomic def create_instance( cls, - name, + display_name, organization, context, created_by, spec_data, compute_plan_assignment=None, ): + service = context.service_offering.service + name = None + for attempt in range(10): + name = cls.generate_resource_name( + organization, display_name, service, attempt + ) + if not cls.objects.filter( + name=name, organization=organization, context=context + ).exists(): + break + else: + message = _( + "Could not generate a unique resource name. Please try a different display name." + ) + raise ValidationError(organization.add_support_message(message)) + # Ensure the namespace exists context.control_plane.get_or_create_namespace(organization) try: instance = cls.objects.create( name=name, + display_name=display_name, organization=organization, created_by=created_by, context=context, @@ -883,8 +931,9 @@ class ServiceInstance(ServalaModelMixin, models.Model): compute_plan_assignment=compute_plan_assignment, control_plane=context.control_plane, instance_name=name, + display_name=display_name, organization=organization, - service=context.service_offering.service, + service=service, ) if annotations: create_data["metadata"]["annotations"] = annotations @@ -941,6 +990,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): compute_plan_assignment=plan_to_use, control_plane=self.context.control_plane, instance_name=self.name, + display_name=self.display_name, organization=self.organization, service=self.context.service_offering.service, ) @@ -1065,7 +1115,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): if not self.context.django_model: return return self.context.django_model( - name=self.name, + display_name=self.display_name, context=self.context, spec=self.spec, # We pass -1 as ID in order to make it clear that a) this object exists (remotely), @@ -1152,5 +1202,50 @@ class ServiceInstance(ServalaModelMixin, models.Model): except (AttributeError, KeyError, IndexError): return None + @cached_property + def kubernetes_events(self) -> dict: + """ + Returns a list of event dictionaries sorted by last timestamp (newest first). + """ + if not self.kubernetes_object: + return [] + + try: + v1 = kubernetes.client.CoreV1Api( + self.context.control_plane.get_kubernetes_client() + ) + events = v1.list_namespaced_event( + namespace=self.organization.namespace, + field_selector=f"involvedObject.name={self.name},involvedObject.kind={self.context.kind}", + ) + event_list = [] + for event in events.items: + event_dict = { + "type": event.type, # Normal or Warning + "reason": event.reason, + "message": event.message, + "count": event.count or 1, + "first_timestamp": ( + event.first_timestamp.isoformat() + if event.first_timestamp + else None + ), + "last_timestamp": ( + event.last_timestamp.isoformat() + if event.last_timestamp + else None + ), + "source": event.source.component if event.source else None, + } + event_list.append(event_dict) + + event_list.sort(key=lambda x: x.get("last_timestamp") or "", reverse=True) + + return event_list + except ApiException: + return [] + except Exception: + return [] + auditlog.register(ServiceInstance, exclude_fields=["updated_at"], serialize_data=True) diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 169d6ea..26c9c70 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -123,7 +123,7 @@ class ServiceInstanceFilterForm(forms.Form): class ServiceInstanceDeleteForm(forms.ModelForm): name = forms.CharField( - label=_("Instance Name"), + label=_("Instance ID"), max_length=63, widget=forms.TextInput(attrs={"class": "form-control"}), ) @@ -132,7 +132,7 @@ class ServiceInstanceDeleteForm(forms.ModelForm): kwargs["initial"] = {"name": ""} super().__init__(*args, **kwargs) self.fields["name"].help_text = _( - "To confirm deletion, please type the instance name: {instance_name}" + "To confirm deletion, please type the instance ID: {instance_name}" ).format(instance_name=self.instance.name) def clean_name(self): @@ -140,7 +140,7 @@ class ServiceInstanceDeleteForm(forms.ModelForm): if entered_name != self.instance.name: raise forms.ValidationError( _( - "The entered name does not match the instance name. Deletion not confirmed." + "The entered name does not match the instance ID. Deletion not confirmed." ) ) return entered_name diff --git a/src/servala/frontend/templates/frontend/base.html b/src/servala/frontend/templates/frontend/base.html index 620cb6d..8454e0d 100644 --- a/src/servala/frontend/templates/frontend/base.html +++ b/src/servala/frontend/templates/frontend/base.html @@ -35,6 +35,8 @@ {% block page_title_extra %} {% endblock page_title_extra %} + {% block page_subtitle %} + {% endblock page_subtitle %}
{% for message in messages %} diff --git a/src/servala/frontend/templates/frontend/forms/errors.html b/src/servala/frontend/templates/frontend/forms/errors.html index 964687d..1d31737 100644 --- a/src/servala/frontend/templates/frontend/forms/errors.html +++ b/src/servala/frontend/templates/frontend/forms/errors.html @@ -11,7 +11,7 @@ {{ form.non_field_errors.0 }} {% endif %} {% else %} - {% translate "We could not save your changes." %} + {% translate "Please review and correct the errors highlighted in the form below." %} {% endif %}
diff --git a/src/servala/frontend/templates/frontend/organizations/dashboard.html b/src/servala/frontend/templates/frontend/organizations/dashboard.html index 441d0be..9a309f1 100644 --- a/src/servala/frontend/templates/frontend/organizations/dashboard.html +++ b/src/servala/frontend/templates/frontend/organizations/dashboard.html @@ -96,7 +96,7 @@ {{ instance.name }} + class="fw-semibold text-decoration-none">{{ instance.display_name }}
diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_instance_create.html similarity index 98% rename from src/servala/frontend/templates/frontend/organizations/service_offering_detail.html rename to src/servala/frontend/templates/frontend/organizations/service_instance_create.html index 39b69a8..3c41ddf 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_create.html @@ -26,7 +26,8 @@ {% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
{% else %} - {% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form %} + {% translate "Create" as create_label %} + {% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form form_submit_label=create_label hide_form_errors=True %} {% endif %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_delete_form.html b/src/servala/frontend/templates/frontend/organizations/service_instance_delete_form.html index 5296c56..3129814 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_delete_form.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_delete_form.html @@ -7,7 +7,7 @@ {% csrf_token %}
-
-
- - - - - - - - - - - - {% for condition in instance.status_conditions %} +
+
{% translate "Type" %}{% translate "Status" %}{% translate "Last Transition Time" %}{% translate "Reason" %}{% translate "Message" %}
+ + + + + + + + + + + + + + {% for key, value in instance.connection_credentials.items %} + {% if "_HOST" not in key and "_URL" not in key %} - - + + - - - - {% endfor %} - -
{% translate "Name" %}{% translate "Value" %}
{{ condition.type }} - {% if condition.status == "True" %} - True - {% elif condition.status == "False" %} - False + {{ key }} + {% if key == "error" %} + {{ value }} {% else %} - {{ condition.status }} + •••••••••••• + {% endif %} + + {% if key != "error" %} + {% endif %} {{ condition.lastTransitionTime|date:"SHORT_DATETIME_FORMAT" }}{{ condition.reason|default:"-" }}{{ condition.message|truncatewords:20|default:"-" }}
+ {% endif %} + {% endfor %} + + +
+
+ + {% translate "Click the eye icon to reveal individual credentials, or use 'Show All' to reveal all at once." %} +
+
+
+ {% endif %} + + + + {% if instance.spec and spec_fieldsets or instance.context.control_plane.user_info %} +
+ + {% if instance.context.control_plane.user_info %} +
+
+
+

+ +

+

{% translate "Technical details for connecting to this zone" %}

+
+
+
+ {% include "includes/control_plane_user_info.html" with control_plane=instance.context.control_plane %}
{% endif %} - {% if control_plane.user_info %} -
-
-

{% translate "Service Provider Zone Information" %}

-
-
- {% include "includes/control_plane_user_info.html" with control_plane=instance.context.control_plane %} -
-
- {% endif %} -
- {% if instance.spec and spec_fieldsets %} -
-
-
-

{% translate "Specification" %}

-
-
-
-
- + + {% if instance.spec and spec_fieldsets %} +
+
+
+

{% translate "Service Configuration" %}

+
+
+ {% if spec_fieldsets|length > 1 %} + -
{% for fieldset in spec_fieldsets %}
- -
+
{% for field in fieldset.fields %} -
{{ field.label }}
-
- {% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %} -
{{ field.value|pprint }}
- {% else %} - {{ field.value|default:"-" }} +
+ {{ field.label }} + {% if field.help_text %} + {% endif %} +
+
+ {{ field.value|render_tree }}
{% endfor %}
- {% for sub_key, sub_fieldset in fieldset.fieldsets.items %} -
{{ sub_fieldset.title }}
-
+
{{ sub_fieldset.title }}
+
{% for field in sub_fieldset.fields %} -
{{ field.label }}
-
- {% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %} -
{{ field.value|pprint }}
- {% else %} - {{ field.value|default:"-" }} - {% endif %} +
{{ field.label }}
+
+ {{ field.value|render_tree }}
{% endfor %}
{% endfor %}
- {% empty %} -

{% translate "No specification details to display." %}

{% endfor %}
+ {% else %} + + {% for fieldset in spec_fieldsets %} +
+ {% for field in fieldset.fields %} +
+ {{ field.label }} + {% if field.help_text %} + + {% endif %} +
+
+ {{ field.value|render_tree }} +
+ {% endfor %} +
+ {% for sub_key, sub_fieldset in fieldset.fieldsets.items %} +
{{ sub_fieldset.title }}
+
+ {% for field in sub_fieldset.fields %} +
{{ field.label }}
+
+ {{ field.value|render_tree }} +
+ {% endfor %} +
+ {% endfor %} + {% endfor %} + {% endif %} +
+
+
+ {% endif %} +
+ {% endif %} + + {% if instance.status_conditions or instance.created_at %} +
+
+
+
+

+ +

+
+
+
+ +
+ {% if instance.status_conditions %} +
{% translate "Status Conditions" %}
+
+ + + + + + + + + + + + {% for condition in instance.status_conditions %} + + + + + + + + {% endfor %} + +
{% translate "Type" %}{% translate "Status" %}{% translate "Last Transition" %}{% translate "Reason" %}{% translate "Message" %}
{{ condition.type }} + {% if condition.status == "True" %} + True + {% elif condition.status == "False" %} + False + {% else %} + {{ condition.status }} + {% endif %} + {{ condition.lastTransitionTime|localtime_tag }}{{ condition.reason|default:"-" }}{{ condition.message|truncatewords:20|default:"-" }}
+
+ {% endif %} +
+ +
+
{% translate "Metadata" %}
+
+
{% translate "Created By" %}
+
{{ instance.created_by|default:"-" }}
+
{% translate "Created At" %}
+
{{ instance.created_at|localtime_tag }}
+
{% translate "Updated At" %}
+
{{ instance.updated_at|localtime_tag }}
+
+
+
- {% endif %} - {% if instance.connection_credentials %} -
-
-
-

{% translate "Connection Credentials" %}

-
-
-
- - - - - - - - - {% for key, value in instance.connection_credentials.items %} - - - - - {% endfor %} - -
{% translate "Name" %}{% translate "Value" %}
{{ key }} - {% if key == "error" %} - {{ value }} - {% else %} - {{ value }} - {% endif %} -
-
-
-
-
- {% endif %} -
+
+ {% endif %}