Initial Django project setup for Postgres cluster management - Created Django project structure with postgres_handholder main app - Added clusters, backups, and monitoring apps - Implemented comprehensive models for PostgresCluster, PostgresInstance, ClusterUser, ClusterDatabase - Added forms and views for cluster management - Created Bootstrap 5 templates with modern UI - Added Docker and Docker Compose configuration - Included Celery for background tasks - Added comprehensive requirements.txt with all dependencies - Updated README with installation and usage instructions - Added environment configuration example - Set up proper URL routing and app structure

This commit is contained in:
2025-06-18 06:24:56 +02:00
parent b1d9ec8ec0
commit a856fb73f7
26 changed files with 1577 additions and 1 deletions

35
Dockerfile Normal file
View File

@ -0,0 +1,35 @@
FROM python:3.11-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Set work directory
WORKDIR /app
# Install system dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
postgresql-client \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy project
COPY . .
# Create necessary directories
RUN mkdir -p /app/logs /app/staticfiles /app/media
# Collect static files
RUN python manage.py collectstatic --noinput
# Expose port
EXPOSE 8000
# Run the application
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

190
README.md
View File

@ -1,3 +1,193 @@
# postgres-handholder # postgres-handholder
Django application to manage Postgres clusters, including rolling updates, connection pooling, and more. Django application to manage Postgres clusters, including rolling updates, connection pooling, and more.
## Overview
Postgres hand-holder is a Django application designed to simplify the management of Postgres clusters in Docker and local Linux environments. It provides a user-friendly interface for performing various operations on Postgres databases, such as rolling updates, connection pooling, and backup management.
postgres-handholder will automate the creation and management of Postgres clusters, making it easier for developers and startups to deploy and maintain their database infrastructure. It is particularly useful for those who want to manage Postgres clusters without the complexity of traditional database management systems.
## Features
- Rolling updates on Postgres cluster changes, incl. quick minor version updates
- Database connection pooling with PGBouncer
- Support fast in place major version upgrade. Supports global upgrade of all clusters.
- Restore and cloning Postgres clusters on demand
- Additionally logical backups to S3 or GCS bucket can be configured
- Standby cluster from S3 or GCS WAL archive
- Configurable for non-cloud environments
- Basic credential and user management on Django interface, eases application deployments
- Support for custom TLS certificates
- UI to create and edit Postgres cluster configurations
- Supports PostgreSQL 17, starting from 13+
- Add easy way to preload libraries: bg_mon, pg_stat_statements, pgextwlist, pg_auth_mon
- Add easy way to include popular Postgres extensions such as decoderbufs, hypopg, pg_cron, pg_partman, pg_stat_kcache, pgq, pgvector, plpgsql_check, postgis, set_user and timescaledb
## Quick Start
### Prerequisites
- Python 3.11+
- Docker and Docker Compose
- PostgreSQL 13+ (for the Django app database)
### Installation
1. Clone the repository:
```bash
git clone https://github.com/yourusername/postgres-handholder.git
cd postgres-handholder
```
2. Set up the environment:
```bash
# Copy environment file
cp .env.example .env
# Edit environment variables
nano .env
```
3. Start the application with Docker Compose:
```bash
docker-compose up -d
```
4. Run database migrations:
```bash
docker-compose exec web python manage.py migrate
```
5. Create a superuser:
```bash
docker-compose exec web python manage.py createsuperuser
```
6. Access the application at http://localhost:8000
### Development Setup
1. Create a virtual environment:
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Set up the database:
```bash
# Create PostgreSQL database
createdb postgres_handholder
# Run migrations
python manage.py migrate
# Create superuser
python manage.py createsuperuser
```
4. Start the development server:
```bash
python manage.py runserver
```
## Configuration
### Environment Variables
Create a `.env` file with the following variables:
```env
# Django
DEBUG=True
SECRET_KEY=your-secret-key-here
ALLOWED_HOSTS=localhost,127.0.0.1
# Database
DB_NAME=postgres_handholder
DB_USER=postgres
DB_PASSWORD=your-password
DB_HOST=localhost
DB_PORT=5432
# Celery
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
# Cloud Storage (optional)
AWS_ACCESS_KEY_ID=your-aws-key
AWS_SECRET_ACCESS_KEY=your-aws-secret
AWS_STORAGE_BUCKET_NAME=your-bucket
AWS_S3_REGION_NAME=us-east-1
GOOGLE_CLOUD_STORAGE_BUCKET=your-gcs-bucket
GOOGLE_APPLICATION_CREDENTIALS=path/to/credentials.json
```
## Usage
### Creating a Cluster
1. Navigate to the Clusters section
2. Click "Create Cluster"
3. Fill in the configuration details:
- Cluster name and description
- PostgreSQL version (13-17)
- Deployment type (Docker, Kubernetes, Local)
- Resource limits (CPU, Memory, Storage)
- Network configuration
- Extensions and libraries
### Managing Clusters
- **Start/Stop**: Control cluster lifecycle
- **Update**: Perform rolling updates
- **Monitor**: View real-time metrics and logs
- **Backup**: Create and manage backups
- **Users**: Manage database users and permissions
- **Databases**: Create and manage databases
### Backup Management
- Create automated backups
- Store backups in S3 or Google Cloud Storage
- Restore clusters from backups
- Clone clusters for testing
## Architecture
The application is built with:
- **Django 4.2+**: Web framework
- **PostgreSQL**: Primary database
- **Redis**: Message broker for Celery
- **Celery**: Background task processing
- **Docker**: Containerization
- **Bootstrap 5**: UI framework
### Apps Structure
- `clusters/`: Core cluster management
- `backups/`: Backup and restore functionality
- `monitoring/`: Metrics and monitoring
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests
5. Submit a pull request
## License
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
## Support
For support and questions, please open an issue on GitHub.

1
backups/__init__.py Normal file
View File

@ -0,0 +1 @@
# Backups app for managing Postgres backups

7
backups/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class BackupsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'backups'
verbose_name = 'Database Backups'

13
backups/urls.py Normal file
View File

@ -0,0 +1,13 @@
from django.urls import path
from . import views
app_name = 'backups'
urlpatterns = [
path('', views.backup_list, name='backup_list'),
path('create/', views.backup_create, name='backup_create'),
path('<int:backup_id>/', views.backup_detail, name='backup_detail'),
path('<int:backup_id>/download/', views.backup_download, name='backup_download'),
path('<int:backup_id>/restore/', views.backup_restore, name='backup_restore'),
path('<int:backup_id>/delete/', views.backup_delete, name='backup_delete'),
]

1
clusters/__init__.py Normal file
View File

@ -0,0 +1 @@
# Clusters app for managing Postgres clusters

7
clusters/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class ClustersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'clusters'
verbose_name = 'Postgres Clusters'

152
clusters/forms.py Normal file
View File

@ -0,0 +1,152 @@
from django import forms
from django.contrib.auth.models import User
from .models import PostgresCluster, ClusterUser, ClusterDatabase
class PostgresClusterForm(forms.ModelForm):
"""Form for creating and editing Postgres clusters."""
class Meta:
model = PostgresCluster
fields = [
'name', 'description', 'cluster_type', 'deployment_type',
'postgres_version', 'port', 'data_directory',
'cpu_limit', 'memory_limit', 'storage_size',
'host', 'external_port', 'admin_user', 'admin_password',
'extensions', 'libraries', 'tls_enabled', 'tls_cert_path',
'tls_key_path', 'tls_ca_path', 'pgpool_enabled', 'pgpool_instances'
]
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'cluster_type': forms.Select(attrs={'class': 'form-select'}),
'deployment_type': forms.Select(attrs={'class': 'form-select'}),
'postgres_version': forms.Select(attrs={'class': 'form-select'}, choices=[
('13', 'PostgreSQL 13'),
('14', 'PostgreSQL 14'),
('15', 'PostgreSQL 15'),
('16', 'PostgreSQL 16'),
('17', 'PostgreSQL 17'),
]),
'port': forms.NumberInput(attrs={'class': 'form-control'}),
'data_directory': forms.TextInput(attrs={'class': 'form-control'}),
'cpu_limit': forms.TextInput(attrs={'class': 'form-control'}),
'memory_limit': forms.TextInput(attrs={'class': 'form-control'}),
'storage_size': forms.TextInput(attrs={'class': 'form-control'}),
'host': forms.TextInput(attrs={'class': 'form-control'}),
'external_port': forms.NumberInput(attrs={'class': 'form-control'}),
'admin_user': forms.TextInput(attrs={'class': 'form-control'}),
'admin_password': forms.PasswordInput(attrs={'class': 'form-control'}),
'tls_cert_path': forms.TextInput(attrs={'class': 'form-control'}),
'tls_key_path': forms.TextInput(attrs={'class': 'form-control'}),
'tls_ca_path': forms.TextInput(attrs={'class': 'form-control'}),
'pgpool_instances': forms.NumberInput(attrs={'class': 'form-control'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set default values for extensions and libraries
if not self.instance.pk:
self.fields['extensions'].initial = [
'pg_stat_statements',
'pg_auth_mon',
]
self.fields['libraries'].initial = [
'bg_mon',
'pg_stat_statements',
'pgextwlist',
'pg_auth_mon',
]
def clean_name(self):
name = self.cleaned_data['name']
if not name.replace('-', '').replace('_', '').isalnum():
raise forms.ValidationError(
'Cluster name can only contain letters, numbers, hyphens, and underscores.'
)
return name
def clean_admin_password(self):
password = self.cleaned_data['admin_password']
if len(password) < 8:
raise forms.ValidationError(
'Admin password must be at least 8 characters long.'
)
return password
class ClusterUserForm(forms.ModelForm):
"""Form for creating and editing cluster users."""
confirm_password = forms.CharField(
widget=forms.PasswordInput(attrs={'class': 'form-control'}),
help_text='Confirm the password'
)
class Meta:
model = ClusterUser
fields = [
'username', 'password', 'is_superuser', 'can_create_db',
'can_login', 'permissions'
]
widgets = {
'username': forms.TextInput(attrs={'class': 'form-control'}),
'password': forms.PasswordInput(attrs={'class': 'form-control'}),
'is_superuser': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'can_create_db': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'can_login': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'permissions': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
def clean_username(self):
username = self.cleaned_data['username']
if not username.replace('_', '').isalnum():
raise forms.ValidationError(
'Username can only contain letters, numbers, and underscores.'
)
return username
def clean(self):
cleaned_data = super().clean()
password = cleaned_data.get('password')
confirm_password = cleaned_data.get('confirm_password')
if password and confirm_password and password != confirm_password:
raise forms.ValidationError('Passwords do not match.')
return cleaned_data
class ClusterDatabaseForm(forms.ModelForm):
"""Form for creating and editing cluster databases."""
class Meta:
model = ClusterDatabase
fields = ['name', 'owner', 'encoding', 'collation', 'ctype']
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}),
'owner': forms.Select(attrs={'class': 'form-select'}),
'encoding': forms.Select(attrs={'class': 'form-select'}, choices=[
('UTF8', 'UTF8'),
('LATIN1', 'LATIN1'),
('SQL_ASCII', 'SQL_ASCII'),
]),
'collation': forms.TextInput(attrs={'class': 'form-control'}),
'ctype': forms.TextInput(attrs={'class': 'form-control'}),
}
def __init__(self, *args, **kwargs):
cluster = kwargs.pop('cluster', None)
super().__init__(*args, **kwargs)
if cluster:
self.fields['owner'].queryset = cluster.users.all()
def clean_name(self):
name = self.cleaned_data['name']
if not name.replace('_', '').isalnum():
raise forms.ValidationError(
'Database name can only contain letters, numbers, and underscores.'
)
return name

200
clusters/models.py Normal file
View File

@ -0,0 +1,200 @@
from django.db import models
from django.contrib.auth.models import User
from django.core.validators import MinValueValidator, MaxValueValidator
import json
class PostgresCluster(models.Model):
"""Model for managing Postgres cluster configurations."""
CLUSTER_TYPES = [
('single', 'Single Instance'),
('replica', 'Primary with Replicas'),
('standby', 'Standby Cluster'),
]
DEPLOYMENT_TYPES = [
('docker', 'Docker'),
('kubernetes', 'Kubernetes'),
('local', 'Local Linux'),
]
name = models.CharField(max_length=100, unique=True)
description = models.TextField(blank=True)
cluster_type = models.CharField(max_length=20, choices=CLUSTER_TYPES, default='single')
deployment_type = models.CharField(max_length=20, choices=DEPLOYMENT_TYPES, default='docker')
# PostgreSQL Configuration
postgres_version = models.CharField(max_length=10, default='15')
port = models.IntegerField(default=5432, validators=[MinValueValidator(1024), MaxValueValidator(65535)])
data_directory = models.CharField(max_length=255, default='/var/lib/postgresql/data')
# Resource Configuration
cpu_limit = models.CharField(max_length=20, default='2')
memory_limit = models.CharField(max_length=20, default='4Gi')
storage_size = models.CharField(max_length=20, default='10Gi')
# Network Configuration
host = models.CharField(max_length=255, default='localhost')
external_port = models.IntegerField(default=5432, validators=[MinValueValidator(1024), MaxValueValidator(65535)])
# Authentication
admin_user = models.CharField(max_length=50, default='postgres')
admin_password = models.CharField(max_length=255)
# Extensions and Libraries
extensions = models.JSONField(default=list, blank=True)
libraries = models.JSONField(default=list, blank=True)
# TLS Configuration
tls_enabled = models.BooleanField(default=False)
tls_cert_path = models.CharField(max_length=255, blank=True)
tls_key_path = models.CharField(max_length=255, blank=True)
tls_ca_path = models.CharField(max_length=255, blank=True)
# Connection Pooling
pgpool_enabled = models.BooleanField(default=False)
pgpool_instances = models.IntegerField(default=1, validators=[MinValueValidator(1), MaxValueValidator(10)])
# Status
status = models.CharField(max_length=20, default='stopped', choices=[
('running', 'Running'),
('stopped', 'Stopped'),
('starting', 'Starting'),
('stopping', 'Stopping'),
('error', 'Error'),
('updating', 'Updating'),
])
# Metadata
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.name
def get_connection_string(self):
"""Get the connection string for this cluster."""
return f"postgresql://{self.admin_user}:{self.admin_password}@{self.host}:{self.external_port}/postgres"
def get_extensions_list(self):
"""Get the list of enabled extensions."""
return self.extensions if isinstance(self.extensions, list) else []
def get_libraries_list(self):
"""Get the list of enabled libraries."""
return self.libraries if isinstance(self.libraries, list) else []
class PostgresInstance(models.Model):
"""Model for managing individual Postgres instances within a cluster."""
INSTANCE_TYPES = [
('primary', 'Primary'),
('replica', 'Replica'),
('standby', 'Standby'),
]
cluster = models.ForeignKey(PostgresCluster, on_delete=models.CASCADE, related_name='instances')
name = models.CharField(max_length=100)
instance_type = models.CharField(max_length=20, choices=INSTANCE_TYPES, default='primary')
# Instance Configuration
host = models.CharField(max_length=255)
port = models.IntegerField(validators=[MinValueValidator(1024), MaxValueValidator(65535)])
data_directory = models.CharField(max_length=255)
# Status
status = models.CharField(max_length=20, default='stopped', choices=[
('running', 'Running'),
('stopped', 'Stopped'),
('starting', 'Starting'),
('stopping', 'Stopping'),
('error', 'Error'),
('syncing', 'Syncing'),
])
# Replication Configuration (for replicas)
replication_slot = models.CharField(max_length=100, blank=True)
lag_seconds = models.IntegerField(default=0)
# Resource Usage
cpu_usage = models.FloatField(default=0.0)
memory_usage = models.FloatField(default=0.0)
disk_usage = models.FloatField(default=0.0)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['cluster', 'name']
ordering = ['instance_type', 'name']
def __str__(self):
return f"{self.cluster.name} - {self.name}"
class ClusterUser(models.Model):
"""Model for managing database users within clusters."""
cluster = models.ForeignKey(PostgresCluster, on_delete=models.CASCADE, related_name='users')
username = models.CharField(max_length=50)
password = models.CharField(max_length=255)
is_superuser = models.BooleanField(default=False)
can_create_db = models.BooleanField(default=False)
can_login = models.BooleanField(default=True)
# Permissions
permissions = models.JSONField(default=dict, blank=True)
# Metadata
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['cluster', 'username']
ordering = ['username']
def __str__(self):
return f"{self.cluster.name} - {self.username}"
class ClusterDatabase(models.Model):
"""Model for managing databases within clusters."""
cluster = models.ForeignKey(PostgresCluster, on_delete=models.CASCADE, related_name='databases')
name = models.CharField(max_length=50)
owner = models.ForeignKey(ClusterUser, on_delete=models.CASCADE)
# Configuration
encoding = models.CharField(max_length=20, default='UTF8')
collation = models.CharField(max_length=50, default='en_US.utf8')
ctype = models.CharField(max_length=50, default='en_US.utf8')
# Size tracking
size_bytes = models.BigIntegerField(default=0)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['cluster', 'name']
ordering = ['name']
def __str__(self):
return f"{self.cluster.name} - {self.name}"
def get_size_mb(self):
"""Get database size in MB."""
return round(self.size_bytes / (1024 * 1024), 2)
def get_size_gb(self):
"""Get database size in GB."""
return round(self.size_bytes / (1024 * 1024 * 1024), 2)

19
clusters/urls.py Normal file
View File

@ -0,0 +1,19 @@
from django.urls import path
from . import views
app_name = 'clusters'
urlpatterns = [
path('', views.cluster_list, name='cluster_list'),
path('create/', views.cluster_create, name='cluster_create'),
path('<int:cluster_id>/', views.cluster_detail, name='cluster_detail'),
path('<int:cluster_id>/edit/', views.cluster_edit, name='cluster_edit'),
path('<int:cluster_id>/delete/', views.cluster_delete, name='cluster_delete'),
path('<int:cluster_id>/start/', views.cluster_start, name='cluster_start'),
path('<int:cluster_id>/stop/', views.cluster_stop, name='cluster_stop'),
path('<int:cluster_id>/restart/', views.cluster_restart, name='cluster_restart'),
path('<int:cluster_id>/update/', views.cluster_update, name='cluster_update'),
path('<int:cluster_id>/users/', views.cluster_users, name='cluster_users'),
path('<int:cluster_id>/databases/', views.cluster_databases, name='cluster_databases'),
path('<int:cluster_id>/instances/', views.cluster_instances, name='cluster_instances'),
]

261
clusters/views.py Normal file
View File

@ -0,0 +1,261 @@
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.core.paginator import Paginator
from .models import PostgresCluster, PostgresInstance, ClusterUser, ClusterDatabase
from .forms import PostgresClusterForm, ClusterUserForm, ClusterDatabaseForm
@login_required
def cluster_list(request):
"""Display list of all clusters."""
clusters = PostgresCluster.objects.filter(created_by=request.user)
# Filtering
status_filter = request.GET.get('status')
if status_filter:
clusters = clusters.filter(status=status_filter)
# Pagination
paginator = Paginator(clusters, 10)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
context = {
'page_obj': page_obj,
'status_choices': PostgresCluster._meta.get_field('status').choices,
}
return render(request, 'clusters/cluster_list.html', context)
@login_required
def cluster_create(request):
"""Create a new Postgres cluster."""
if request.method == 'POST':
form = PostgresClusterForm(request.POST)
if form.is_valid():
cluster = form.save(commit=False)
cluster.created_by = request.user
cluster.save()
messages.success(request, f'Cluster "{cluster.name}" created successfully.')
return redirect('clusters:cluster_detail', cluster_id=cluster.id)
else:
form = PostgresClusterForm()
context = {
'form': form,
'title': 'Create New Cluster',
}
return render(request, 'clusters/cluster_form.html', context)
@login_required
def cluster_detail(request, cluster_id):
"""Display detailed information about a cluster."""
cluster = get_object_or_404(PostgresCluster, id=cluster_id, created_by=request.user)
context = {
'cluster': cluster,
'instances': cluster.instances.all(),
'users': cluster.users.all(),
'databases': cluster.databases.all(),
}
return render(request, 'clusters/cluster_detail.html', context)
@login_required
def cluster_edit(request, cluster_id):
"""Edit an existing cluster."""
cluster = get_object_or_404(PostgresCluster, id=cluster_id, created_by=request.user)
if request.method == 'POST':
form = PostgresClusterForm(request.POST, instance=cluster)
if form.is_valid():
form.save()
messages.success(request, f'Cluster "{cluster.name}" updated successfully.')
return redirect('clusters:cluster_detail', cluster_id=cluster.id)
else:
form = PostgresClusterForm(instance=cluster)
context = {
'form': form,
'cluster': cluster,
'title': f'Edit Cluster: {cluster.name}',
}
return render(request, 'clusters/cluster_form.html', context)
@login_required
def cluster_delete(request, cluster_id):
"""Delete a cluster."""
cluster = get_object_or_404(PostgresCluster, id=cluster_id, created_by=request.user)
if request.method == 'POST':
cluster_name = cluster.name
cluster.delete()
messages.success(request, f'Cluster "{cluster_name}" deleted successfully.')
return redirect('clusters:cluster_list')
context = {
'cluster': cluster,
}
return render(request, 'clusters/cluster_confirm_delete.html', context)
@login_required
@require_http_methods(["POST"])
def cluster_start(request, cluster_id):
"""Start a cluster."""
cluster = get_object_or_404(PostgresCluster, id=cluster_id, created_by=request.user)
try:
# TODO: Implement actual cluster start logic
cluster.status = 'starting'
cluster.save()
messages.success(request, f'Starting cluster "{cluster.name}"...')
except Exception as e:
messages.error(request, f'Failed to start cluster: {str(e)}')
return redirect('clusters:cluster_detail', cluster_id=cluster.id)
@login_required
@require_http_methods(["POST"])
def cluster_stop(request, cluster_id):
"""Stop a cluster."""
cluster = get_object_or_404(PostgresCluster, id=cluster_id, created_by=request.user)
try:
# TODO: Implement actual cluster stop logic
cluster.status = 'stopping'
cluster.save()
messages.success(request, f'Stopping cluster "{cluster.name}"...')
except Exception as e:
messages.error(request, f'Failed to stop cluster: {str(e)}')
return redirect('clusters:cluster_detail', cluster_id=cluster.id)
@login_required
@require_http_methods(["POST"])
def cluster_restart(request, cluster_id):
"""Restart a cluster."""
cluster = get_object_or_404(PostgresCluster, id=cluster_id, created_by=request.user)
try:
# TODO: Implement actual cluster restart logic
cluster.status = 'starting'
cluster.save()
messages.success(request, f'Restarting cluster "{cluster.name}"...')
except Exception as e:
messages.error(request, f'Failed to restart cluster: {str(e)}')
return redirect('clusters:cluster_detail', cluster_id=cluster.id)
@login_required
@require_http_methods(["POST"])
def cluster_update(request, cluster_id):
"""Update a cluster (rolling update)."""
cluster = get_object_or_404(PostgresCluster, id=cluster_id, created_by=request.user)
try:
# TODO: Implement actual rolling update logic
cluster.status = 'updating'
cluster.save()
messages.success(request, f'Updating cluster "{cluster.name}"...')
except Exception as e:
messages.error(request, f'Failed to update cluster: {str(e)}')
return redirect('clusters:cluster_detail', cluster_id=cluster.id)
@login_required
def cluster_users(request, cluster_id):
"""Manage users for a cluster."""
cluster = get_object_or_404(PostgresCluster, id=cluster_id, created_by=request.user)
if request.method == 'POST':
form = ClusterUserForm(request.POST)
if form.is_valid():
user = form.save(commit=False)
user.cluster = cluster
user.created_by = request.user
user.save()
messages.success(request, f'User "{user.username}" created successfully.')
return redirect('clusters:cluster_users', cluster_id=cluster.id)
else:
form = ClusterUserForm()
context = {
'cluster': cluster,
'form': form,
'users': cluster.users.all(),
}
return render(request, 'clusters/cluster_users.html', context)
@login_required
def cluster_databases(request, cluster_id):
"""Manage databases for a cluster."""
cluster = get_object_or_404(PostgresCluster, id=cluster_id, created_by=request.user)
if request.method == 'POST':
form = ClusterDatabaseForm(request.POST)
if form.is_valid():
database = form.save(commit=False)
database.cluster = cluster
database.save()
messages.success(request, f'Database "{database.name}" created successfully.')
return redirect('clusters:cluster_databases', cluster_id=cluster.id)
else:
form = ClusterDatabaseForm()
context = {
'cluster': cluster,
'form': form,
'databases': cluster.databases.all(),
}
return render(request, 'clusters/cluster_databases.html', context)
@login_required
def cluster_instances(request, cluster_id):
"""View instances for a cluster."""
cluster = get_object_or_404(PostgresCluster, id=cluster_id, created_by=request.user)
context = {
'cluster': cluster,
'instances': cluster.instances.all(),
}
return render(request, 'clusters/cluster_instances.html', context)
@login_required
def cluster_status_api(request, cluster_id):
"""API endpoint to get cluster status."""
cluster = get_object_or_404(PostgresCluster, id=cluster_id, created_by=request.user)
data = {
'id': cluster.id,
'name': cluster.name,
'status': cluster.status,
'instances': [
{
'id': instance.id,
'name': instance.name,
'type': instance.instance_type,
'status': instance.status,
'host': instance.host,
'port': instance.port,
'cpu_usage': instance.cpu_usage,
'memory_usage': instance.memory_usage,
'disk_usage': instance.disk_usage,
}
for instance in cluster.instances.all()
]
}
return JsonResponse(data)

102
docker-compose.yml Normal file
View File

@ -0,0 +1,102 @@
version: '3.8'
services:
# PostgreSQL database for the Django app
db:
image: postgres:15
environment:
POSTGRES_DB: postgres_handholder
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# Redis for Celery
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# Django web application
web:
build: .
command: python manage.py runserver 0.0.0.0:8000
volumes:
- .:/app
- static_volume:/app/staticfiles
- media_volume:/app/media
ports:
- "8000:8000"
environment:
- DEBUG=True
- DB_HOST=db
- DB_NAME=postgres_handholder
- DB_USER=postgres
- DB_PASSWORD=postgres
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
# Celery worker
celery:
build: .
command: celery -A postgres_handholder worker -l info
volumes:
- .:/app
environment:
- DEBUG=True
- DB_HOST=db
- DB_NAME=postgres_handholder
- DB_USER=postgres
- DB_PASSWORD=postgres
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
# Celery beat scheduler
celery-beat:
build: .
command: celery -A postgres_handholder beat -l info
volumes:
- .:/app
environment:
- DEBUG=True
- DB_HOST=db
- DB_NAME=postgres_handholder
- DB_USER=postgres
- DB_PASSWORD=postgres
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
volumes:
postgres_data:
redis_data:
static_volume:
media_volume:

31
env.example Normal file
View File

@ -0,0 +1,31 @@
# Django Configuration
DEBUG=True
SECRET_KEY=your-secret-key-here-change-this-in-production
ALLOWED_HOSTS=localhost,127.0.0.1
# Database Configuration
DB_NAME=postgres_handholder
DB_USER=postgres
DB_PASSWORD=your-database-password
DB_HOST=localhost
DB_PORT=5432
# Celery Configuration
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
# AWS S3 Configuration (optional)
AWS_ACCESS_KEY_ID=your-aws-access-key
AWS_SECRET_ACCESS_KEY=your-aws-secret-key
AWS_STORAGE_BUCKET_NAME=your-backup-bucket
AWS_S3_REGION_NAME=us-east-1
# Google Cloud Storage Configuration (optional)
GOOGLE_CLOUD_STORAGE_BUCKET=your-gcs-bucket
GOOGLE_APPLICATION_CREDENTIALS=path/to/your/credentials.json
# Docker Configuration
DOCKER_HOST=unix://var/run/docker.sock
# Kubernetes Configuration (optional)
KUBECONFIG=path/to/your/kubeconfig

22
manage.py Normal file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'postgres_handholder.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

1
monitoring/__init__.py Normal file
View File

@ -0,0 +1 @@
# Monitoring app for Postgres cluster monitoring

7
monitoring/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class MonitoringConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'monitoring'
verbose_name = 'Cluster Monitoring'

12
monitoring/urls.py Normal file
View File

@ -0,0 +1,12 @@
from django.urls import path
from . import views
app_name = 'monitoring'
urlpatterns = [
path('', views.dashboard, name='dashboard'),
path('clusters/', views.cluster_metrics, name='cluster_metrics'),
path('clusters/<int:cluster_id>/', views.cluster_detail_metrics, name='cluster_detail_metrics'),
path('alerts/', views.alerts, name='alerts'),
path('logs/', views.logs, name='logs'),
]

View File

@ -0,0 +1 @@
# Postgres Handholder Django Application

View File

@ -0,0 +1,16 @@
"""
ASGI config for postgres_handholder project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'postgres_handholder.settings')
application = get_asgi_application()

View File

@ -0,0 +1,19 @@
import os
from celery import Celery
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'postgres_handholder.settings')
app = Celery('postgres_handholder')
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
app.config_from_object('django.conf:settings', namespace='CELERY')
# Load task modules from all registered Django apps.
app.autodiscover_tasks()
@app.task(bind=True)
def debug_task(self):
print(f'Request: {self.request!r}')

View File

@ -0,0 +1,193 @@
"""
Django settings for postgres_handholder project.
"""
import os
from pathlib import Path
from django.core.management.utils import get_random_secret_key
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get('SECRET_KEY', get_random_secret_key())
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.environ.get('DEBUG', 'True').lower() == 'true'
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
# Third party apps
'allauth',
'allauth.account',
'allauth.socialaccount',
'crispy_forms',
'crispy_bootstrap5',
'django_extensions',
'debug_toolbar',
# Local apps
'clusters',
'backups',
'monitoring',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'allauth.account.middleware.AccountMiddleware',
'debug_toolbar.middleware.DebugToolbarMiddleware',
]
ROOT_URLCONF = 'postgres_handholder.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'postgres_handholder.wsgi.application'
# Database
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME', 'postgres_handholder'),
'USER': os.environ.get('DB_USER', 'postgres'),
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
'HOST': os.environ.get('DB_HOST', 'localhost'),
'PORT': os.environ.get('DB_PORT', '5432'),
}
}
# Password validation
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [
BASE_DIR / 'static',
]
# Media files
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Crispy Forms
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
# Django Allauth
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend',
]
SITE_ID = 1
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_USERNAME_REQUIRED = False
ACCOUNT_AUTHENTICATION_METHOD = 'email'
ACCOUNT_EMAIL_VERIFICATION = 'mandatory'
# Celery Configuration
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
CELERY_RESULT_BACKEND = os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0')
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = TIME_ZONE
# Cloud Storage Configuration
AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY')
AWS_STORAGE_BUCKET_NAME = os.environ.get('AWS_STORAGE_BUCKET_NAME')
AWS_S3_REGION_NAME = os.environ.get('AWS_S3_REGION_NAME', 'us-east-1')
# Google Cloud Storage Configuration
GOOGLE_CLOUD_STORAGE_BUCKET = os.environ.get('GOOGLE_CLOUD_STORAGE_BUCKET')
GOOGLE_APPLICATION_CREDENTIALS = os.environ.get('GOOGLE_APPLICATION_CREDENTIALS')
# Docker Configuration
DOCKER_HOST = os.environ.get('DOCKER_HOST', 'unix://var/run/docker.sock')
# Kubernetes Configuration
KUBERNETES_CONFIG_PATH = os.environ.get('KUBECONFIG')
# Debug Toolbar
INTERNAL_IPS = [
'127.0.0.1',
]
# Logging
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
'style': '{',
},
},
'handlers': {
'file': {
'level': 'INFO',
'class': 'logging.FileHandler',
'filename': BASE_DIR / 'logs' / 'django.log',
'formatter': 'verbose',
},
},
'root': {
'handlers': ['file'],
'level': 'INFO',
},
}

View File

@ -0,0 +1,22 @@
"""
URL configuration for postgres_handholder project.
"""
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('accounts/', include('allauth.urls')),
path('', include('clusters.urls')),
path('backups/', include('backups.urls')),
path('monitoring/', include('monitoring.urls')),
]
if settings.DEBUG:
urlpatterns += [
path('__debug__/', include('debug_toolbar.urls')),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@ -0,0 +1,16 @@
"""
WSGI config for postgres_handholder project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'postgres_handholder.settings')
application = get_wsgi_application()

18
requirements.txt Normal file
View File

@ -0,0 +1,18 @@
Django>=4.2.0,<5.0
psycopg2-binary>=2.9.0
django-environ>=0.11.0
celery>=5.3.0
redis>=4.5.0
boto3>=1.26.0
google-cloud-storage>=2.8.0
docker>=6.1.0
kubernetes>=26.1.0
django-crispy-forms>=2.0
crispy-bootstrap5>=0.7
django-allauth>=0.54.0
django-extensions>=3.2.0
django-debug-toolbar>=4.0.0
pytest-django>=4.5.0
black>=23.0.0
flake8>=6.0.0
isort>=5.12.0

96
templates/base.html Normal file
View File

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Postgres Handholder{% endblock %}</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href="{% url 'clusters:cluster_list' %}">
<i class="fas fa-database me-2"></i>Postgres Handholder
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{% url 'clusters:cluster_list' %}">
<i class="fas fa-server me-1"></i>Clusters
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'backups:backup_list' %}">
<i class="fas fa-download me-1"></i>Backups
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'monitoring:dashboard' %}">
<i class="fas fa-chart-line me-1"></i>Monitoring
</a>
</li>
</ul>
<ul class="navbar-nav">
{% if user.is_authenticated %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
<i class="fas fa-user me-1"></i>{{ user.email }}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'account_logout' %}">Logout</a></li>
</ul>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{% url 'account_login' %}">Login</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="container mt-4">
<!-- Messages -->
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<!-- Page Content -->
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="bg-light mt-5 py-4">
<div class="container text-center">
<p class="text-muted mb-0">
Postgres Handholder - Simplify Postgres cluster management
</p>
</div>
</footer>
<!-- Bootstrap 5 JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,134 @@
{% extends 'base.html' %}
{% block title %}Postgres Clusters - Postgres Handholder{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="fas fa-server me-2"></i>Postgres Clusters</h1>
<a href="{% url 'clusters:cluster_create' %}" class="btn btn-primary">
<i class="fas fa-plus me-1"></i>Create Cluster
</a>
</div>
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-4">
<label for="status" class="form-label">Status</label>
<select name="status" id="status" class="form-select">
<option value="">All Statuses</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if request.GET.status == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<button type="submit" class="btn btn-outline-primary me-2">
<i class="fas fa-filter me-1"></i>Filter
</button>
<a href="{% url 'clusters:cluster_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i>Clear
</a>
</div>
</form>
</div>
</div>
<!-- Clusters List -->
{% if page_obj %}
<div class="row">
{% for cluster in page_obj %}
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">{{ cluster.name }}</h5>
<span class="badge bg-{% if cluster.status == 'running' %}success{% elif cluster.status == 'stopped' %}secondary{% elif cluster.status == 'error' %}danger{% else %}warning{% endif %}">
{{ cluster.get_status_display }}
</span>
</div>
<div class="card-body">
<p class="card-text text-muted">{{ cluster.description|truncatewords:20 }}</p>
<div class="row text-center mb-3">
<div class="col-4">
<small class="text-muted">Type</small>
<div>{{ cluster.get_cluster_type_display }}</div>
</div>
<div class="col-4">
<small class="text-muted">Version</small>
<div>PostgreSQL {{ cluster.postgres_version }}</div>
</div>
<div class="col-4">
<small class="text-muted">Deployment</small>
<div>{{ cluster.get_deployment_type_display }}</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
Created {{ cluster.created_at|date:"M j, Y" }}
</small>
<div class="btn-group" role="group">
<a href="{% url 'clusters:cluster_detail' cluster.id %}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i>
</a>
<a href="{% url 'clusters:cluster_edit' cluster.id %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-edit"></i>
</a>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="Clusters pagination">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.status %}&status={{ request.GET.status }}{% endif %}">
<i class="fas fa-chevron-left"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% if request.GET.status %}&status={{ request.GET.status }}{% endif %}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if request.GET.status %}&status={{ request.GET.status }}{% endif %}">
<i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="fas fa-server fa-3x text-muted mb-3"></i>
<h3 class="text-muted">No clusters found</h3>
<p class="text-muted">Get started by creating your first Postgres cluster.</p>
<a href="{% url 'clusters:cluster_create' %}" class="btn btn-primary">
<i class="fas fa-plus me-1"></i>Create Your First Cluster
</a>
</div>
{% endif %}
{% endblock %}