Merge pull request 'Display service catalogs' (#22) from 13-service-catalog into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 52s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 7s

Reviewed-on: https://servala-2nkgm.app.codey.ch/servala/servala-portal/pulls/22
This commit is contained in:
Tobias Kunze 2025-03-25 11:18:19 +00:00
commit a520fdeb4a
21 changed files with 704 additions and 52 deletions

View file

@ -1,7 +1,7 @@
from django.contrib import admin, messages
from django.utils.translation import gettext_lazy as _
from servala.core.forms import ControlPlaneAdminForm
from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm
from servala.core.models import (
BillingEntity,
CloudProvider,
@ -12,7 +12,9 @@ from servala.core.models import (
Plan,
Service,
ServiceCategory,
ServiceDefinition,
ServiceOffering,
ServiceOfferingControlPlane,
User,
)
@ -101,6 +103,7 @@ class ServiceAdmin(admin.ModelAdmin):
list_filter = ("category",)
search_fields = ("name", "description")
autocomplete_fields = ("category",)
prepopulated_fields = {"slug": ["name"]}
@admin.register(CloudProvider)
@ -121,7 +124,7 @@ class ControlPlaneAdmin(admin.ModelAdmin):
fieldsets = (
(
None,
{"fields": ("name", "description", "cloud_provider")},
{"fields": ("name", "description", "cloud_provider", "service_definition")},
),
(
_("API Credentials"),
@ -170,11 +173,54 @@ class PlanAdmin(admin.ModelAdmin):
autocomplete_fields = ("service_offering",)
@admin.register(ServiceDefinition)
class ServiceDefinitionAdmin(admin.ModelAdmin):
form = ServiceDefinitionAdminForm
list_display = ("name", "service")
list_filter = ("service",)
search_fields = ("name", "description")
autocomplete_fields = ("service",)
fieldsets = (
(
None,
{"fields": ("name", "description", "service")},
),
(
_("API Definition"),
{
"fields": ("api_group", "api_version", "api_kind"),
"description": _("API definition for the Kubernetes Custom Resource"),
},
),
)
def get_exclude(self, request, obj=None):
# Exclude the original api_definition field as we're using our custom fields
return ["api_definition"]
class ServiceOfferingControlPlaneInline(admin.TabularInline):
model = ServiceOfferingControlPlane
extra = 1
autocomplete_fields = ("control_plane", "service_definition")
@admin.register(ServiceOfferingControlPlane)
class ServiceOfferingControlPlaneAdmin(admin.ModelAdmin):
list_display = ("service_offering", "control_plane", "service_definition")
list_filter = ("service_offering", "control_plane", "service_definition")
search_fields = ("service_offering__service__name", "control_plane__name")
autocomplete_fields = ("service_offering", "control_plane", "service_definition")
@admin.register(ServiceOffering)
class ServiceOfferingAdmin(admin.ModelAdmin):
list_display = ("id", "service", "provider")
list_filter = ("service", "provider", "control_plane")
list_filter = ("service", "provider")
search_fields = ("description",)
autocomplete_fields = ("service", "provider")
filter_horizontal = ("control_plane",)
inlines = (PlanInline,)
inlines = (
ServiceOfferingControlPlaneInline,
PlanInline,
)

View file

@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from servala.core.models import ControlPlane
from servala.core.models import ControlPlane, ServiceDefinition
class ControlPlaneAdminForm(forms.ModelForm):
@ -70,3 +70,65 @@ class ControlPlaneAdminForm(forms.ModelForm):
def save(self, *args, **kwargs):
self.instance.api_credentials = self.cleaned_data["api_credentials"]
return super().save(*args, **kwargs)
class ServiceDefinitionAdminForm(forms.ModelForm):
api_group = forms.CharField(
required=False,
help_text=_("API Group"),
)
api_version = forms.CharField(
required=False,
help_text=_("API Version"),
)
api_kind = forms.CharField(
required=False,
help_text=_("API Kind"),
)
class Meta:
model = ServiceDefinition
fields = "__all__"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# If we have existing api_definition, populate the individual fields
if self.instance.pk and self.instance.api_definition:
api_def = self.instance.api_definition
self.fields["api_group"].initial = api_def.get("group", "")
self.fields["api_version"].initial = api_def.get("version", "")
self.fields["api_kind"].initial = api_def.get("kind", "")
def clean(self):
cleaned_data = super().clean()
api_group = cleaned_data.get("api_group")
api_version = cleaned_data.get("api_version")
api_kind = cleaned_data.get("api_kind")
if api_group and api_version and api_kind:
cleaned_data["api_definition"] = {
"group": api_group,
"version": api_version,
"kind": api_kind,
}
else:
if not (api_group or api_version or api_kind):
cleaned_data["api_definition"] = {}
else:
# Some fields are filled but not all validation will fail at model level.
api_def = {}
if api_group:
api_def["group"] = api_group
if api_version:
api_def["version"] = api_version
if api_kind:
api_def["kind"] = api_kind
cleaned_data["api_definition"] = api_def
return cleaned_data
def save(self, *args, **kwargs):
self.instance.api_definition = self.cleaned_data["api_definition"]
return super().save(*args, **kwargs)

View file

@ -0,0 +1,21 @@
# Generated by Django 5.2b1 on 2025-03-24 14:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0005_remove_controlplane_k8s_api_endpoint"),
]
operations = [
migrations.AddField(
model_name="service",
name="slug",
field=models.SlugField(
default="slug", max_length=100, unique=True, verbose_name="URL slug"
),
preserve_default=False,
),
]

View file

@ -0,0 +1,115 @@
# Generated by Django 5.2b1 on 2025-03-25 11:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0006_service_slug"),
]
operations = [
migrations.RemoveField(
model_name="serviceoffering",
name="control_plane",
),
migrations.CreateModel(
name="ServiceDefinition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100, verbose_name="Name")),
(
"description",
models.TextField(blank=True, verbose_name="Description"),
),
(
"api_definition",
models.JSONField(
blank=True,
help_text="Contains group, version, and kind information",
null=True,
verbose_name="API Definition",
),
),
(
"service",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="service_definitions",
to="core.service",
verbose_name="Service",
),
),
],
options={
"verbose_name": "Service definition",
"verbose_name_plural": "Service definitions",
},
),
migrations.CreateModel(
name="ServiceOfferingControlPlane",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"control_plane",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="offering_connections",
to="core.controlplane",
verbose_name="Control plane",
),
),
(
"service_definition",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="offering_control_planes",
to="core.servicedefinition",
verbose_name="Service definition",
),
),
(
"service_offering",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="control_plane_connections",
to="core.serviceoffering",
verbose_name="Service offering",
),
),
],
options={
"verbose_name": "Service offering control plane connection",
"verbose_name_plural": "Service offering control planes connections",
"unique_together": {("service_offering", "control_plane")},
},
),
migrations.AddField(
model_name="serviceoffering",
name="control_planes",
field=models.ManyToManyField(
related_name="offerings",
through="core.ServiceOfferingControlPlane",
to="core.controlplane",
verbose_name="Control planes",
),
),
]

View file

@ -11,7 +11,9 @@ from .service import (
Plan,
Service,
ServiceCategory,
ServiceDefinition,
ServiceOffering,
ServiceOfferingControlPlane,
)
from .user import User
@ -26,6 +28,8 @@ __all__ = [
"Plan",
"Service",
"ServiceCategory",
"ServiceDefinition",
"ServiceOffering",
"ServiceOfferingControlPlane",
"User",
]

View file

@ -38,6 +38,7 @@ class Organization(ServalaModelMixin, models.Model):
class urls(urlman.Urls):
base = "/org/{self.slug}/"
details = "{base}details/"
services = "{base}services/"
@cached_property
def slug(self):

View file

@ -44,6 +44,7 @@ class Service(models.Model):
"""
name = models.CharField(max_length=100, verbose_name=_("Name"))
slug = models.SlugField(max_length=100, verbose_name=_("URL slug"), unique=True)
category = models.ForeignKey(
to="ServiceCategory",
on_delete=models.PROTECT,
@ -67,18 +68,13 @@ class Service(models.Model):
return self.name
def validate_api_credentials(value):
"""
Validates that api_credentials either contains all required fields or is empty.
"""
# If empty dict, that's valid
if not value:
return
# Check for required fields
required_fields = ("certificate-authority-data", "server", "token")
missing_fields = required_fields - set(value)
def validate_dict(data, required_fields=None, allow_empty=True):
if not data:
if allow_empty:
return
raise ValidationError(_("Data may not be empty!"))
missing_fields = required_fields - set(data)
if missing_fields:
raise ValidationError(
_("Missing required fields in API credentials: %(fields)s"),
@ -86,6 +82,11 @@ def validate_api_credentials(value):
)
def validate_api_credentials(value):
required_fields = ("certificate-authority-data", "server", "token")
return validate_dict(value, required_fields)
class ControlPlane(models.Model):
name = models.CharField(max_length=100, verbose_name=_("Name"))
description = models.TextField(blank=True, verbose_name=_("Description"))
@ -228,6 +229,88 @@ class Plan(models.Model):
return self.name
def validate_api_definition(value):
required_fields = ("group", "version", "kind")
return validate_dict(value, required_fields)
class ServiceDefinition(models.Model):
"""
Configuration/service implementation: contains information on which
CompositeResourceDefinition (aka XRD) implements a service on a ControlPlane.
Is required in order to query the OpenAPI spec for dynamic form generation.
"""
name = models.CharField(max_length=100, verbose_name=_("Name"))
description = models.TextField(blank=True, verbose_name=_("Description"))
api_definition = models.JSONField(
verbose_name=_("API Definition"),
help_text=_("Contains group, version, and kind information"),
null=True,
blank=True,
)
service = models.ForeignKey(
to="Service",
on_delete=models.CASCADE,
related_name="service_definitions",
verbose_name=_("Service"),
)
@property
def control_planes(self):
return ControlPlane.objects.filter(
offering_connections__service_definition=self
).distinct()
@property
def service_offerings(self):
return ServiceOffering.objects.filter(
control_plane_connections__service_definition=self
).distinct()
class Meta:
verbose_name = _("Service definition")
verbose_name_plural = _("Service definitions")
def __str__(self):
return self.name
class ServiceOfferingControlPlane(models.Model):
"""
Each combination of ServiceOffering and ControlPlane can have a different
ServiceDefinition, which is here modeled as the "through" model.
"""
service_offering = models.ForeignKey(
to="ServiceOffering",
on_delete=models.CASCADE,
related_name="control_plane_connections",
verbose_name=_("Service offering"),
)
control_plane = models.ForeignKey(
to="ControlPlane",
on_delete=models.CASCADE,
related_name="offering_connections",
verbose_name=_("Control plane"),
)
service_definition = models.ForeignKey(
to="ServiceDefinition",
on_delete=models.PROTECT,
related_name="offering_control_planes",
verbose_name=_("Service definition"),
)
class Meta:
verbose_name = _("Service offering control plane connection")
verbose_name_plural = _("Service offering control planes connections")
unique_together = [("service_offering", "control_plane")]
def __str__(self):
return f"{self.service_offering} on {self.control_plane} with {self.service_definition}"
class ServiceOffering(models.Model):
"""
A service offering, e.g. "PostgreSQL on AWS", "MinIO on GCP".
@ -245,8 +328,9 @@ class ServiceOffering(models.Model):
related_name="offerings",
verbose_name=_("Provider"),
)
control_plane = models.ManyToManyField(
control_planes = models.ManyToManyField(
to="ControlPlane",
through="ServiceOfferingControlPlane",
related_name="offerings",
verbose_name=_("Control planes"),
)
@ -255,3 +339,8 @@ class ServiceOffering(models.Model):
class Meta:
verbose_name = _("Service offering")
verbose_name_plural = _("Service offerings")
def __str__(self):
return _("{service_name} at {provider_name}").format(
service_name=self.service.name, provider_name=self.provider.name
)

View file

@ -0,0 +1,22 @@
from django import forms
from servala.core.models import CloudProvider, ServiceCategory
class ServiceFilterForm(forms.Form):
category = forms.ModelChoiceField(
queryset=ServiceCategory.objects.all(), required=False
)
cloud_provider = forms.ModelChoiceField(
queryset=CloudProvider.objects.all(), required=False
)
q = forms.CharField(required=False)
def filter_queryset(self, queryset):
if category := self.cleaned_data.get("category"):
queryset = queryset.filter(category=category)
if cloud_provider := self.cleaned_data.get("cloud_provider"):
queryset = queryset.filter(
offerings__control_planes__cloud_provider=cloud_provider
)
return queryset

View file

@ -0,0 +1,65 @@
{% extends "frontend/base.html" %}
{% load i18n %}
{% load static %}
{% block html_title %}
{% block page_title %}
{{ service.name }}
{% endblock page_title %}
{% endblock html_title %}
{% block content %}
<section class="section">
<div class="card">
<div class="card-header d-flex align-items-center">
{% if service.logo %}
<img src="{{ service.logo.url }}"
alt="{{ service.name }}"
class="me-3"
style="max-width: 48px;
max-height: 48px">
{% endif %}
<div class="d-flex flex-column">
<h4 class="mb-0">{{ service.name }}</h4>
<small class="text-muted">{{ service.category }}</small>
</div>
</div>
<div class="card-body">
<div class="row">
<p>{{ service.description|default:"No description available." }}</p>
</div>
</div>
</div>
{% for offering in service.offerings.all %}
<div class="card col-6 col-lg-3 col-md-4">
<div class="card-header d-flex align-items-center">
{% if offering.provider.logo %}
<img src="{{ offering.provider.logo.url }}"
alt="{{ offering.provider.name }}"
class="me-3"
style="max-width: 48px;
max-height: 48px">
{% endif %}
<div class="d-flex flex-column">
<h4 class="mb-0">{{ offering.provider.name }}</h4>
</div>
</div>
<div class="card-body">
{% if offering.description %}
<p class="card-text">{{ offering.description }}</p>
{% elif offering.provider.description %}
<p class="card-text">{{ offering.provider.description }}</p>
{% endif %}
</div>
<div class="card-footer d-flex justify-content-between">
<span></span>
<a href="offering/{{ offering.pk }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
</div>
</div>
{% empty %}
<div class="card">
<div class="card-body">
<p>{% translate "No offerings found." %}</p>
</div>
</div>
{% endfor %}
</section>
{% endblock content %}

View file

@ -0,0 +1,40 @@
{% extends "frontend/base.html" %}
{% load i18n %}
{% load static %}
{% block html_title %}
{% block page_title %}
{{ offering }}
{% endblock page_title %}
{% endblock html_title %}
{% block content %}
<section class="section">
<div class="card">
<div class="card-header d-flex align-items-center">
{% if service.logo %}
<img src="{{ service.logo.url }}"
alt="{{ service.name }}"
class="me-3"
style="max-width: 48px;
max-height: 48px">
{% endif %}
<div class="d-flex flex-column">
<h4 class="mb-0">{{ offering }}</h4>
<small class="text-muted">{{ offering.service.category }}</small>
</div>
</div>
<div class="card-body">
<div class="row">
{% if offering.control_planes.all.count > 1 %}
<p>{% translate "Please choose your zone." %}</p>
{% else %}
<p>
{% blocktranslate trimmed with zone=offering.control_planes.all.first.name %}
Your zone will be <strong>{{ zone }}</strong>.
{% endblocktranslate %}
</p>
{% endif %}
</div>
</div>
</div>
</section>
{% endblock content %}

View file

@ -0,0 +1,51 @@
{% extends "frontend/base.html" %}
{% load i18n static %}
{% block html_title %}
{% block page_title %}
{% translate "Services" %}
{% endblock page_title %}
{% endblock html_title %}
{% block content %}
<section class="section">
<div class="card">
<div class="card-content">
<div class="card-body">
<form class="search-form" auto-submit>
{{ filter_form }}
</form>
</div>
</div>
</div>
{% for service in services %}
<div class="card col-6 col-lg-3 col-md-4">
<div class="card-header d-flex align-items-center">
{% if service.logo %}
<img src="{{ service.logo.url }}"
alt="{{ service.name }}"
class="me-3"
style="max-width: 48px;
max-height: 48px">
{% endif %}
<div class="d-flex flex-column">
<h4 class="mb-0">{{ service.name }}</h4>
<small class="text-muted">{{ service.category }}</small>
</div>
</div>
<div class="card-body">
{% if service.description %}<p class="card-text">{{ service.description }}</p>{% endif %}
</div>
<div class="card-footer d-flex justify-content-between">
<span></span>
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
</div>
</div>
{% empty %}
<div class="card">
<div class="card-body">
<p>{% translate "No services found." %}</p>
</div>
</div>
{% endfor %}
</section>
<script src="{% static "js/autosubmit.js" %}" defer></script>
{% endblock content %}

View file

@ -73,7 +73,7 @@
<div class="col-md-6 col-12">
<div class="card">
<div class="card-header">
<h4 class="card-title">{% translate "Profile" %}</h4>
<h4 class="">{% translate "Profile" %}</h4>
</div>
<div class="card-content">
<div class="card-body">
@ -106,7 +106,7 @@
<div class="col-md-6 col-12">
<div class="card">
<div class="card-header">
<h4 class="card-title">{% translate "Account" %}</h4>
<h4 class="">{% translate "Account" %}</h4>
</div>
<div class="card-content">
<div class="card-body">

View file

@ -116,6 +116,12 @@
<span>{% translate 'Details' %}</span>
</a>
</li>
<li class="sidebar-item">
<a href="{{ request.organization.urls.services }}" class='sidebar-link'>
<i class="bi bi-card-list"></i>
<span>{% translate 'Services' %}</span>
</a>
</li>
{% endif %}
<li class="sidebar-title">{% translate 'Account' %}</li>
<li class="sidebar-item">

View file

@ -20,6 +20,21 @@ urlpatterns = [
views.OrganizationUpdateView.as_view(),
name="organization.details",
),
path(
"services/",
views.ServiceListView.as_view(),
name="organization.services",
),
path(
"services/<slug:slug>/",
views.ServiceDetailView.as_view(),
name="organization.service",
),
path(
"services/<slug:slug>/offering/<int:pk>/",
views.ServiceOfferingDetailView.as_view(),
name="organization.offering",
),
path(
"",
views.OrganizationDashboardView.as_view(),

View file

@ -5,6 +5,7 @@ from .organization import (
OrganizationDashboardView,
OrganizationUpdateView,
)
from .service import ServiceDetailView, ServiceListView, ServiceOfferingDetailView
__all__ = [
"IndexView",
@ -12,5 +13,8 @@ __all__ = [
"OrganizationCreateView",
"OrganizationDashboardView",
"OrganizationUpdateView",
"ServiceDetailView",
"ServiceListView",
"ServiceOfferingDetailView",
"ProfileView",
]

View file

@ -1,6 +1,8 @@
from django.utils.functional import cached_property
from django.views.generic import UpdateView
from rules.contrib.views import AutoPermissionRequiredMixin
from rules.contrib.views import AutoPermissionRequiredMixin, PermissionRequiredMixin
from servala.core.models import Organization
class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView):
@ -57,3 +59,31 @@ class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView):
if self.is_htmx and self._get_fragment():
return self.get(self.request, *self.args, **self.kwargs)
return result
class OrganizationViewMixin(PermissionRequiredMixin):
model = Organization
context_object_name = "organization"
permission_required = "core.view_organization"
@cached_property
def organization(self):
return self.request.organization
def get_object(self):
if self.model == Organization:
return self.organization
return super().get_object()
@cached_property
def object(self):
return self.get_object()
def get_permission_object(self):
return self.organization
def has_permission(self):
return (
self.request.user.has_perm("core.view_organization", self.organization)
and super().has_permission()
)

View file

@ -1,11 +1,10 @@
from django.shortcuts import redirect
from django.utils.functional import cached_property
from django.views.generic import CreateView, DetailView
from rules.contrib.views import AutoPermissionRequiredMixin
from servala.core.models import Organization
from servala.frontend.forms import OrganizationForm
from servala.frontend.views.mixins import HtmxUpdateView
from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin
class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
@ -20,22 +19,6 @@ class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
return redirect(instance.urls.base)
class OrganizationViewMixin:
model = Organization
context_object_name = "organization"
@cached_property
def organization(self):
return self.request.organization
def get_object(self):
return self.organization
@cached_property
def object(self):
return self.get_object()
class OrganizationDashboardView(
AutoPermissionRequiredMixin, OrganizationViewMixin, DetailView
):

View file

@ -0,0 +1,56 @@
from django.utils.functional import cached_property
from django.views.generic import DetailView, ListView
from servala.core.models import Service, ServiceOffering
from servala.frontend.forms.service import ServiceFilterForm
from servala.frontend.views.mixins import OrganizationViewMixin
class ServiceListView(OrganizationViewMixin, ListView):
"""View to display all available services for an organization."""
template_name = "frontend/organizations/services.html"
context_object_name = "services"
model = Service
permission_type = "view"
def get_queryset(self):
"""Return all services."""
services = Service.objects.all().select_related("category")
if self.filter_form.is_valid():
services = self.filter_form.filter_queryset(services)
return services
@cached_property
def filter_form(self):
return ServiceFilterForm(data=self.request.GET or None)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["filter_form"] = self.filter_form
return context
class ServiceDetailView(OrganizationViewMixin, DetailView):
template_name = "frontend/organizations/service_detail.html"
context_object_name = "service"
model = Service
permission_type = "view"
def get_queryset(self):
return Service.objects.select_related("category").prefetch_related(
"offerings",
"offerings__provider",
)
class ServiceOfferingDetailView(OrganizationViewMixin, DetailView):
template_name = "frontend/organizations/service_offering_detail.html"
context_object_name = "offering"
model = ServiceOffering
permission_type = "view"
def get_queryset(self):
return ServiceOffering.objects.all().select_related(
"service", "service__category", "provider"
)

View file

@ -1,3 +1,11 @@
.form-group.d-inline {
margin-bottom: 0;
}
.search-form .form-body>.row {
display: flex;
&>.col-12 {
width: auto;
flex-grow: 1;
}
}

View file

@ -0,0 +1,25 @@
/**
* Auto-submit functionality for forms
*
* This script looks for forms with the 'auto-submit' attribute
* and automatically submits them when any input, select, or textarea
* within the form changes, useful for search/filter forms.
*/
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('form[auto-submit]').forEach(form => {
const formElements = form.querySelectorAll('input, select, textarea')
formElements.forEach(element => {
if (element.type === 'checkbox' || element.type === 'radio') {
element.addEventListener('change', () => {
form.submit()
})
}
else if (element.tagName.toLowerCase() === 'select') {
element.addEventListener('change', () => {
form.submit()
})
}
})
})
})

View file

@ -2,21 +2,30 @@
* This script marks the current path as active in the sidebar.
*/
const markActive = (link) => {
const parentItem = link.closest('.sidebar-item');
if (parentItem) {
parentItem.classList.add('active');
} else {
link.classList.add('active');
}
}
const checkLink = (fuzzy) => {
}
document.addEventListener('DOMContentLoaded', () => {
const currentPath = window.location.pathname;
const sidebarLinks = document.querySelectorAll('.sidebar-link');
const sidebarLinks = [...document.querySelectorAll('a.sidebar-link')]
sidebarLinks.forEach(link => {
// Skip links that are inside buttons (like logout)
if (link.tagName === 'BUTTON') return;
if (link.getAttribute('href') === currentPath) {
const parentItem = link.closest('.sidebar-item');
if (parentItem) {
parentItem.classList.add('active');
} else {
link.classList.add('active');
}
const exactMatches = sidebarLinks.filter(link => link.getAttribute('href') === currentPath)
if (exactMatches.length > 0) {
markActive(exactMatches[0])
} else {
fuzzyMatches = sidebarLinks.filter(link => currentPath.startsWith(link.getAttribute('href')))
if (fuzzyMatches.length > 0) {
const longestMatch = fuzzyMatches.sort((a, b) => b.href.length - a.href.length)[0]
markActive(longestMatch)
}
})
}
})