From ddbc97721c25d87f912d4a81ade4a003ddf90153 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Sun, 22 Jun 2025 22:30:16 +0200 Subject: [PATCH 1/2] Add organization and global dashboards --- .../frontend/templates/frontend/base.html | 2 + .../frontend/organization_selection.html | 111 ++++++++ .../frontend/organizations/dashboard.html | 259 ++++++++++++++++++ src/servala/frontend/urls.py | 11 +- src/servala/frontend/views/__init__.py | 10 +- src/servala/frontend/views/generic.py | 28 +- src/servala/frontend/views/organization.py | 38 ++- src/servala/static/css/servala.css | 75 +++++ 8 files changed, 530 insertions(+), 4 deletions(-) create mode 100644 src/servala/frontend/templates/frontend/organization_selection.html diff --git a/src/servala/frontend/templates/frontend/base.html b/src/servala/frontend/templates/frontend/base.html index 89d363f..77cdc50 100644 --- a/src/servala/frontend/templates/frontend/base.html +++ b/src/servala/frontend/templates/frontend/base.html @@ -10,6 +10,8 @@ + {% block extra_css %} + {% endblock extra_css %} {% block html_title %} diff --git a/src/servala/frontend/templates/frontend/organization_selection.html b/src/servala/frontend/templates/frontend/organization_selection.html new file mode 100644 index 0000000..04db6ec --- /dev/null +++ b/src/servala/frontend/templates/frontend/organization_selection.html @@ -0,0 +1,111 @@ +{% extends "frontend/base.html" %} +{% load i18n static %} +{% block html_title %} + {% block page_title %} + {% translate "Welcome to Servala" %} + {% endblock page_title %} +{% endblock html_title %} +{% block content %} + <section class="section"> + {% if not user_organizations %} + <div class="row justify-content-center"> + <div class="col-md-8"> + <div class="card"> + <div class="card-header"> + <h4 class="card-title">{% translate "Welcome to Servala!" %}</h4> + </div> + <div class="card-content"> + <div class="card-body text-center"> + <div class="mb-4"> + <i class="bi bi-building fs-1 text-primary"></i> + </div> + <h5 class="mb-3">{% translate "Get Started with Your First Organization" %}</h5> + <p class="text-muted mb-4"> + {% blocktranslate trimmed %} + Organizations help you manage your services and resources. + Create your first organization to start using Servala's service catalog. + {% endblocktranslate %} + </p> + <div class="d-grid gap-2 d-md-flex justify-content-md-center"> + <a href="{% url 'frontend:organization.create' %}" + class="btn btn-primary btn-lg"> + <i class="bi bi-plus-circle me-2"></i> + {% translate "Create Organization" %} + </a> + </div> + <div class="mt-5 pt-4 border-top"> + <h6 class="text-muted mb-3">{% translate "Next Steps" %}</h6> + <div class="row"> + <div class="col-md-4"> + <div class="text-center"> + <i class="bi bi-1-circle fs-2 text-success mb-2"></i> + <h6>{% translate "Create Organization" %}</h6> + <small class="text-muted">{% translate "Set up your workspace" %}</small> + </div> + </div> + <div class="col-md-4"> + <div class="text-center"> + <i class="bi bi-2-circle fs-2 text-info mb-2"></i> + <h6>{% translate "Discover Services" %}</h6> + <small class="text-muted">{% translate "Browse available services" %}</small> + </div> + </div> + <div class="col-md-4"> + <div class="text-center"> + <i class="bi bi-3-circle fs-2 text-warning mb-2"></i> + <h6>{% translate "Create Instances" %}</h6> + <small class="text-muted">{% translate "Deploy your first service" %}</small> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + {% else %} + <div class="row justify-content-center"> + {% for organization in user_organizations %} + <div class="col-md-6 col-lg-4 mb-4"> + <div class="card h-100 border-2 organization-card"> + <div class="card-body d-flex flex-column"> + <div class="text-center mb-3"> + <i class="bi bi-building fs-1 text-primary"></i> + </div> + <h5 class="card-title text-center mb-3">{{ organization.name }}</h5> + <div class="flex-grow-1"> + <div class="d-flex justify-content-between"> + <small class="text-muted"> + <i class="bi bi-server me-1"></i> + {{ organization.instance_count }} + {% blocktranslate trimmed count count=organization.instance_count %} + instance + {% plural %} + instances + {% endblocktranslate %} + </small> + <small class="text-muted"> + <i class="bi bi-people me-1"></i> + {{ organization.members.count }} + {% blocktranslate trimmed count count=organization.members.count %} + member + {% plural %} + members + {% endblocktranslate %} + </small> + </div> + </div> + <div class="mt-3"> + <a href="{% url 'frontend:organization.dashboard' organization=organization.slug %}" + class="btn btn-primary w-100">{% translate "Enter Organization" %}</a> + </div> + </div> + </div> + </div> + {% endfor %} + <div class="text-center mt-4 pt-4 border-top"></div> + </div> + {% endif %} + </section> +{% endblock content %} diff --git a/src/servala/frontend/templates/frontend/organizations/dashboard.html b/src/servala/frontend/templates/frontend/organizations/dashboard.html index 88cb596..d3283ba 100644 --- a/src/servala/frontend/templates/frontend/organizations/dashboard.html +++ b/src/servala/frontend/templates/frontend/organizations/dashboard.html @@ -1 +1,260 @@ {% extends "frontend/base.html" %} +{% load i18n static %} +{% block html_title %} + {{ object.name }} {% translate "Dashboard" %} +{% endblock html_title %} +{% block page_title %}{% endblock %} +{% block content %} + <section class="section"> + <div class="row"> + <div class="col-12"> + <div class="card"> + <div class="card-body"> + <div class="d-flex justify-content-between align-items-start"> + <div> + <h4 class="mb-2">{% translate "Welcome to" %} {{ object.name }}</h4> + <p class="text-muted mb-0"> + {% if has_instances %} + {% translate "Here's an overview of your organization's services and resources." %} + {% else %} + {% translate "Ready to get started? Discover services and create your first instance." %} + {% endif %} + </p> + </div> + {% if user_role %} + <div class="text-end"> + <span class="badge bg-light text-dark"> + {% if user_role == "owner" %} + <i class="bi bi-star-fill me-1"></i>{% translate "Owner" %} + {% elif user_role == "admin" %} + <i class="bi bi-gear me-1"></i>{% translate "Administrator" %} + {% else %} + <i class="bi bi-person me-1"></i>{% translate "Member" %} + {% endif %} + </span> + </div> + {% endif %} + </div> + </div> + </div> + </div> + </div> + {% if has_instances %} + <div class="row"> + <div class="col-md-4"> + <div class="card"> + <div class="card-body"> + <div class="d-flex align-items-center"> + <div class="stats-icon purple me-3"> + <i class="bi bi-hdd-stack"></i> + </div> + <div> + <h6 class="text-muted font-semibold mb-0">{% translate "Service Instances" %}</h6> + <h4 class="font-extrabold mb-0 mt-1">{{ service_instances_count }}</h4> + </div> + </div> + </div> + </div> + </div> + <div class="col-md-4"> + <div class="card"> + <div class="card-body"> + <div class="d-flex align-items-center"> + <div class="stats-icon blue me-3"> + <i class="bi bi-people"></i> + </div> + <div> + <h6 class="text-muted font-semibold mb-0">{% translate "Team Members" %}</h6> + <h4 class="font-extrabold mb-0 mt-1">{{ members_count }}</h4> + </div> + </div> + </div> + </div> + </div> + </div> + {% endif %} + <div class="row"> + {% if has_instances %} + <div class="col-md-8"> + <div class="card"> + <div class="card-header"> + <h4 class="card-title">{% translate "Recent Service Instances" %}</h4> + </div> + <div class="card-body"> + <div class="table-responsive"> + <table class="table table-hover"> + <thead> + <tr> + <th>{% translate "Name" %}</th> + <th>{% translate "Service" %}</th> + <th>{% translate "Status" %}</th> + <th>{% translate "Created" %}</th> + <th>{% translate "Actions" %}</th> + </tr> + </thead> + <tbody> + {% for instance in service_instances %} + <tr> + <td> + <a href="{% url 'frontend:organization.instance' organization=object.slug slug=instance.name %}" + class="fw-semibold text-decoration-none">{{ instance.name }}</a> + </td> + <td> + <div class="d-flex align-items-center"> + {% if instance.context.service_offering.service.logo %} + <img src="{{ instance.context.service_offering.service.logo.url }}" + alt="{{ instance.context.service_offering.service.name }}" + class="me-2" + style="width: 20px; + height: 20px; + object-fit: contain"> + {% endif %} + <span class="small text-muted">{{ instance.context.service_offering.service.name }}</span> + </div> + </td> + <td> + {% if instance.is_deleted %} + <span class="badge bg-danger">{% translate "Deleted" %}</span> + {% else %} + <span class="badge bg-success">{% translate "Active" %}</span> + {% endif %} + </td> + <td> + <span class="small text-muted">{{ instance.created_at|date:"M d, Y" }}</span> + </td> + <td> + <div class="btn-group btn-group-sm" role="group"> + <a href="{% url 'frontend:organization.instance' organization=object.slug slug=instance.name %}" + class="btn btn-outline-primary btn-sm" + title="{% translate 'View Details' %}"> + <i class="bi bi-eye"></i> + </a> + {% if instance.has_change_permission %} + <a href="{% url 'frontend:organization.instance.update' organization=object.slug slug=instance.name %}" + class="btn btn-outline-secondary btn-sm" + title="{% translate 'Edit' %}"> + <i class="bi bi-pencil"></i> + </a> + {% endif %} + </div> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + <div class="text-center mt-3"> + <a href="{% url 'frontend:organization.instances' organization=object.slug %}" + class="btn btn-outline-primary me-2">{% translate "View All Instances" %}</a> + <a href="{% url 'frontend:organization.services' organization=object.slug %}" + class="btn btn-primary">{% translate "Create New Instance" %}</a> + </div> + </div> + </div> + </div> + <div class="col-md-4"> + <div class="card"> + <div class="card-header"> + <h4 class="card-title">{% translate "Quick Actions" %}</h4> + </div> + <div class="card-body"> + <div class="list-group list-group-flush quick-actions"> + <a href="{{ object.urls.services }}" + class="list-group-item list-group-item-action d-flex align-items-center"> + <i class="bi h2 bi-grid-3x3-gap me-3 text-primary"></i> + <div> + <h6 class="mb-1">{% translate "Browse Services" %}</h6> + <small class="text-muted">{% translate "Explore available services" %}</small> + </div> + </a> + <a href="{{ object.urls.instances }}" + class="list-group-item list-group-item-action d-flex align-items-center"> + <i class="bi h2 bi-server me-3 text-success"></i> + <div> + <h6 class="mb-1">{% translate "Manage Instances" %}</h6> + <small class="text-muted">{% translate "View and configure instances" %}</small> + </div> + </a> + <a href="{{ object.urls.details }}" + class="list-group-item list-group-item-action d-flex align-items-center"> + <i class="bi h2 bi-gear me-3 text-warning"></i> + <div> + <h6 class="mb-1">{% translate "Organization Settings" %}</h6> + <small class="text-muted">{% translate "Configure organization details" %}</small> + </div> + </a> + {% comment %} + {# TODO: use once support form is merged #} + <a href="{{ organization.urls.support }}" class="list-group-item list-group-item-action d-flex align-items-center"> + <i class="bi h2 bi-life-preserver me-3 text-info"></i> + <div> + <h6 class="mb-1">{% translate "Get Support" %}</h6> + <small class="text-muted">{% translate "Access help and documentation" %}</small> + </div> + </a> + {% endcomment %} + </div> + </div> + </div> + </div> + {% else %} + {# if has_instances #} + <div class="col-md-8"> + <div class="card"> + <div class="card-header"> + <h4 class="card-title">{% translate "Get Started" %}</h4> + </div> + <div class="card-body"> + <div class="text-center py-4"> + <div class="mb-4"> + <i class="bi bi-rocket fs-1 text-primary"></i> + </div> + <h5 class="mb-3">{% translate "Ready to deploy your first service?" %}</h5> + <p class="text-muted mb-4"> + {% translate "Browse our service catalog and deploy databases, storage, and other services with just a few clicks." %} + </p> + <a href="{% url 'frontend:organization.services' organization=object.slug %}" + class="btn btn-primary btn-lg"> + <i class="bi bi-search me-2"></i> + {% translate "Discover Services" %} + </a> + </div> + </div> + </div> + </div> + <div class="col-md-4"> + <div class="card"> + <div class="card-header"> + <h4 class="card-title">{% translate "Next Steps" %}</h4> + </div> + <div class="card-body"> + <div class="timeline"> + <div class="timeline-item"> + <div class="timeline-marker bg-success"></div> + <div class="timeline-content"> + <h6 class="text-success">{% translate "Organization Created" %}</h6> + <small class="text-muted">{% translate "You're all set up!" %}</small> + </div> + </div> + <div class="timeline-item"> + <div class="timeline-marker bg-primary"></div> + <div class="timeline-content"> + <h6>{% translate "Discover Services" %}</h6> + <small class="text-muted">{% translate "Browse what's available" %}</small> + </div> + </div> + <div class="timeline-item"> + <div class="timeline-marker"></div> + <div class="timeline-content"> + <h6 class="text-muted">{% translate "Deploy Your First Service" %}</h6> + <small class="text-muted">{% translate "Create an instance" %}</small> + </div> + </div> + </div> + </div> + </div> + </div> + {% endif %} + </div> + </section> +{% endblock content %} diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index 52a048e..7790b22 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -6,6 +6,11 @@ from servala.frontend import views urlpatterns = [ path("accounts/profile/", views.ProfileView.as_view(), name="profile"), path("accounts/logout/", views.LogoutView.as_view(), name="logout"), + path( + "organizations/", + views.OrganizationSelectionView.as_view(), + name="organization.selection", + ), path( "organizations/create", views.OrganizationCreateView.as_view(), @@ -68,5 +73,9 @@ urlpatterns = [ ] ), ), - path("", RedirectView.as_view(pattern_name="frontend:profile"), name="index"), + path( + "", + RedirectView.as_view(pattern_name="frontend:organization.selection"), + name="index", + ), ] diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index 5ae01ec..5f11a75 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -1,5 +1,12 @@ from .auth import LogoutView -from .generic import IndexView, ProfileView, custom_403, custom_404, custom_500 +from .generic import ( + IndexView, + OrganizationSelectionView, + ProfileView, + custom_403, + custom_404, + custom_500, +) from .organization import ( OrganizationCreateView, OrganizationDashboardView, @@ -21,6 +28,7 @@ __all__ = [ "LogoutView", "OrganizationCreateView", "OrganizationDashboardView", + "OrganizationSelectionView", "OrganizationUpdateView", "ServiceDetailView", "ServiceInstanceDeleteView", diff --git a/src/servala/frontend/views/generic.py b/src/servala/frontend/views/generic.py index 7d1a3a0..16b1ec2 100644 --- a/src/servala/frontend/views/generic.py +++ b/src/servala/frontend/views/generic.py @@ -1,5 +1,7 @@ from django.conf import settings -from django.shortcuts import render +from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Count, Q +from django.shortcuts import redirect, render from django.urls import reverse_lazy from django.utils.functional import cached_property from django.views.generic import TemplateView @@ -13,6 +15,30 @@ class IndexView(TemplateView): template_name = "frontend/index.html" +class OrganizationSelectionView(LoginRequiredMixin, TemplateView): + template_name = "frontend/organization_selection.html" + + @cached_property + def user_organizations(self): + return self.request.user.organizations.all() + + def dispatch(self, request, *args, **kwargs): + # Users with a single organization get redirected to the organization’s dashboard + if self.user_organizations.count() == 1: + organization = self.user_organizations.first() + return redirect(organization.urls.base) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["user_organizations"] = self.user_organizations.annotate( + instance_count=Count( + "service_instances", filter=Q(service_instances__is_deleted=False) + ) + ) + return context + + class ProfileView(HtmxUpdateView): template_name = "frontend/profile.html" form_class = UserProfileForm diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 29eb5f5..6545d39 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -3,7 +3,12 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import CreateView, DetailView from rules.contrib.views import AutoPermissionRequiredMixin -from servala.core.models import BillingEntity, Organization +from servala.core.models import ( + BillingEntity, + Organization, + OrganizationMembership, + ServiceInstance, +) from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin @@ -61,6 +66,37 @@ class OrganizationDashboardView( ): template_name = "frontend/organizations/dashboard.html" + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + organization = self.get_object() + + service_instances = ServiceInstance.objects.filter( + organization=organization, is_deleted=False + ) + recent_instances = service_instances.order_by("-created_at")[:5] + + for instance in recent_instances: + instance.has_change_permission = self.request.user.has_perm( + "core.change_serviceinstance", instance + ) + + user_membership = OrganizationMembership.objects.filter( + user=self.request.user, organization=organization + ).first() + user_role = user_membership.role if user_membership else None + + context.update( + { + "service_instances_count": service_instances.count(), + "service_instances": recent_instances, + "members_count": organization.members.count(), + "has_instances": service_instances.exists(), + "user_role": user_role, + } + ) + + return context + class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): template_name = "frontend/organizations/update.html" diff --git a/src/servala/static/css/servala.css b/src/servala/static/css/servala.css index b7a5b8b..db9052a 100644 --- a/src/servala/static/css/servala.css +++ b/src/servala/static/css/servala.css @@ -99,3 +99,78 @@ a.btn-keycloak { .hide-form-errors .alert.form-errors { display: none; } +.organization-card { + transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; + cursor: pointer; +} + +.organization-card:hover { + transform: translateY(-5px); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +} +.stats-icon { + width: 60px; + height: 60px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + color: white; + i::before { + display: block; + margin-top: -4px; + margin-left: -5px; + } +} + +.stats-icon.purple { + background-color: var(--bs-primary); +} + +.stats-icon.blue { + background-color: var(--bs-info); +} + +.stats-icon.green { + background-color: var(--brand-success); +} + +.timeline { + position: relative; + padding-left: 20px; +} + +.timeline::before { + content: ''; + position: absolute; + left: 8px; + top: 0; + bottom: 0; + width: 2px; + background-color: #e5e7eb; +} + +.timeline-item { + position: relative; + margin-bottom: 1rem; +} + +.timeline-marker { + position: absolute; + left: -16px; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: #d1d5db; + border: 3px solid white; + box-shadow: 0 0 0 3px #f3f4f6; +} + +.timeline-content { + margin-left: 10px; +} +.quick-actions i.bi { + margin-top: -16px; + padding-right: 28px; +} -- 2.47.3 From 58250bfe6dfef3719ba4bfb0d2dc3edb9d6065b7 Mon Sep 17 00:00:00 2001 From: Tobias Kunze <r@rixx.de> Date: Mon, 23 Jun 2025 11:37:22 +0200 Subject: [PATCH 2/2] Add support link to dashboard --- .../frontend/templates/frontend/base.html | 2 -- .../frontend/organizations/dashboard.html | 18 ++++++++---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/servala/frontend/templates/frontend/base.html b/src/servala/frontend/templates/frontend/base.html index 77cdc50..89d363f 100644 --- a/src/servala/frontend/templates/frontend/base.html +++ b/src/servala/frontend/templates/frontend/base.html @@ -10,8 +10,6 @@ <link rel="stylesheet" href="{% static 'mazer/compiled/css/iconly.css' %}"> <link rel="stylesheet" href="{% static 'css/servala.css' %}"> <script src="{% static "js/htmx.min.js" %}" defer></script> - {% block extra_css %} - {% endblock extra_css %} </head> <title> {% block html_title %} diff --git a/src/servala/frontend/templates/frontend/organizations/dashboard.html b/src/servala/frontend/templates/frontend/organizations/dashboard.html index d3283ba..b0dd273 100644 --- a/src/servala/frontend/templates/frontend/organizations/dashboard.html +++ b/src/servala/frontend/templates/frontend/organizations/dashboard.html @@ -183,16 +183,14 @@ <small class="text-muted">{% translate "Configure organization details" %}</small> </div> </a> - {% comment %} - {# TODO: use once support form is merged #} - <a href="{{ organization.urls.support }}" class="list-group-item list-group-item-action d-flex align-items-center"> - <i class="bi h2 bi-life-preserver me-3 text-info"></i> - <div> - <h6 class="mb-1">{% translate "Get Support" %}</h6> - <small class="text-muted">{% translate "Access help and documentation" %}</small> - </div> - </a> - {% endcomment %} + <a href="{{ organization.urls.support }}" + class="list-group-item list-group-item-action d-flex align-items-center"> + <i class="bi h2 bi-life-preserver me-3 text-info"></i> + <div> + <h6 class="mb-1">{% translate "Get Support" %}</h6> + <small class="text-muted">{% translate "Access help and documentation" %}</small> + </div> + </a> </div> </div> </div> -- 2.47.3