initial
This commit is contained in:
0
frontend/__init__.py
Normal file
0
frontend/__init__.py
Normal file
24
frontend/admin.py
Normal file
24
frontend/admin.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# frontend/admin.py
|
||||
from django.contrib import admin
|
||||
from .models import UserProfile, FAQ
|
||||
|
||||
@admin.register(UserProfile)
|
||||
class UserProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'ldap_uid', 'user_group')
|
||||
search_fields = ('user__username', 'ldap_uid')
|
||||
list_filter = ('user__groups',)
|
||||
|
||||
|
||||
@admin.register(FAQ)
|
||||
class FAQAdmin(admin.ModelAdmin):
|
||||
list_display = ('title', 'order', 'created_at', 'updated_at')
|
||||
list_filter = ('created_at',)
|
||||
search_fields = ('title', 'content')
|
||||
list_editable = ('order',)
|
||||
ordering = ('order', 'title')
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('title', 'content', 'order')
|
||||
}),
|
||||
)
|
||||
9
frontend/apps.py
Normal file
9
frontend/apps.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# frontend/apps.py
|
||||
from django.apps import AppConfig
|
||||
|
||||
class FrontendConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'frontend'
|
||||
|
||||
def ready(self):
|
||||
import frontend.signals
|
||||
352
frontend/forms.py
Normal file
352
frontend/forms.py
Normal file
@@ -0,0 +1,352 @@
|
||||
# frontend/forms.py
|
||||
import re
|
||||
from functools import cached_property
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from django_proxmox_mikrotik.settings import ProxmoxConfig
|
||||
from manager.models import CloneContainer, DevContainer
|
||||
from mikrotik.models import DNSStatic, IPAddress, IPDHCPLease
|
||||
from proxmox.models import Lxc
|
||||
from .models import FAQ
|
||||
|
||||
|
||||
def fk_to_hidden_input(formobj: forms.Form, instance, fieldname: str):
|
||||
initial_value = str(getattr(instance, fieldname)) if instance else ''
|
||||
formobj.fields[fieldname] = forms.CharField(
|
||||
label=fieldname.title(),
|
||||
initial=initial_value,
|
||||
widget=forms.TextInput(
|
||||
attrs={'readonly': 'readonly', 'style': 'background-color: #eee;', 'class': 'form-control'}),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class DevContainerForm(forms.ModelForm):
|
||||
disksize = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
max_value=100,
|
||||
help_text="Festplattengröße (GB) - kann nicht verkleinert werden"
|
||||
)
|
||||
|
||||
cores = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
help_text="Anzahl der CPU-Kerne"
|
||||
)
|
||||
|
||||
memory = forms.CharField(
|
||||
required=False,
|
||||
help_text="Arbeitsspeicher in MB"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DevContainer
|
||||
fields = ['lxc', 'lease', 'dns']
|
||||
|
||||
@cached_property
|
||||
def dns_obj(self):
|
||||
return DNSStatic.objects.get(pk=self.cleaned_data['dns'])
|
||||
|
||||
@cached_property
|
||||
def lease_obj(self):
|
||||
return IPDHCPLease.objects.get(pk=self.cleaned_data['lease'])
|
||||
|
||||
@cached_property
|
||||
def lxc_obj(self):
|
||||
return Lxc.objects.get(pk=self.cleaned_data['lxc'])
|
||||
|
||||
def _clean_dns(self):
|
||||
if self.dns_obj.address != self.lease_obj.address:
|
||||
raise ValidationError("DNS has not the same address as Lease. Please check your input.")
|
||||
return self.cleaned_data['dns']
|
||||
|
||||
def _clean_lease(self):
|
||||
"""Brauchts eigentlich nicht"""
|
||||
if self.lease_obj.address != self.dns_obj.address:
|
||||
raise ValidationError("Lease has not the same address as DNS. Please check your input.")
|
||||
return self.cleaned_data['lease']
|
||||
|
||||
def _clean_lxc(self):
|
||||
if self.lxc_obj.hwaddr != self.lease_obj.mac_address:
|
||||
raise ValidationError("LXC has not the same MAC address as Lease. Please check your input.")
|
||||
return self.cleaned_data['lxc']
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
self.cleaned_data['dns'] = self._clean_dns()
|
||||
self.cleaned_data['lease'] = self._clean_lease()
|
||||
self.cleaned_data['lxc'] = self._clean_lxc()
|
||||
return self.cleaned_data
|
||||
|
||||
def clean_disksize(self):
|
||||
if self.cleaned_data['disksize'] and int(self.cleaned_data['disksize']) > int(ProxmoxConfig.MAX_DISK):
|
||||
raise ValidationError(
|
||||
f"Disksize > {ProxmoxConfig.MAX_DISK}GB not allowed. Please check your input.")
|
||||
return self.cleaned_data['disksize']
|
||||
|
||||
def clean_memory(self):
|
||||
if self.cleaned_data['memory'] and int(self.cleaned_data['memory']) > ProxmoxConfig.MAX_MEM:
|
||||
raise ValidationError(f"Memory > {ProxmoxConfig.MAX_MEM}MB not allowed. Please check your input.")
|
||||
return self.cleaned_data['memory']
|
||||
|
||||
def clean_cores(self):
|
||||
if self.cleaned_data['cores'] and int(self.cleaned_data['cores']) > ProxmoxConfig.MAX_CORES:
|
||||
raise ValidationError(f"Cores > {ProxmoxConfig.MAX_CORES} not allowed. Please check your input.")
|
||||
return self.cleaned_data['cores']
|
||||
|
||||
def __init__(self, *args, user_profile=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user_profile = user_profile
|
||||
instance = kwargs.get('instance')
|
||||
|
||||
# Für externe Benutzer: IP-Adressen filtern
|
||||
if user_profile and user_profile.is_external():
|
||||
ldap_uid = user_profile.ldap_uid
|
||||
ip_addresses = IPAddress.objects.filter(comment__icontains=f' {ldap_uid} ')
|
||||
networks = [ip.network for ip in ip_addresses]
|
||||
|
||||
# Lease-Feld einschränken
|
||||
self.fields['lease'].queryset = self.fields['lease'].queryset.filter(
|
||||
address__regex=r'^(' + '|'.join([re.escape(net) for net in networks]) + r')'
|
||||
)
|
||||
|
||||
self.fields['dns'].queryset = self.fields['dns'].queryset.filter(address=instance.lease.address)
|
||||
|
||||
if instance and hasattr(instance, 'lxc') and instance.lxc:
|
||||
self.fields['disksize'].initial = instance.lxc.disksize
|
||||
self.fields['disksize'].min_value = instance.lxc.disksize
|
||||
self.fields['cores'].initial = instance.lxc.cores
|
||||
self.fields['memory'].initial = instance.lxc.memory
|
||||
fk_to_hidden_input(self, instance, 'lxc')
|
||||
fk_to_hidden_input(self, instance, 'lease')
|
||||
fk_to_hidden_input(self, instance, 'dns')
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
|
||||
if hasattr(instance, 'lxc') and instance.lxc:
|
||||
lxc = instance.lxc
|
||||
|
||||
if 'disksize' in self.cleaned_data and self.cleaned_data['disksize']:
|
||||
lxc.disksize = self.cleaned_data['disksize']
|
||||
|
||||
if 'cores' in self.cleaned_data and self.cleaned_data['cores']:
|
||||
lxc.cores = self.cleaned_data['cores']
|
||||
|
||||
if 'memory' in self.cleaned_data and self.cleaned_data['memory']:
|
||||
lxc.memory = self.cleaned_data['memory']
|
||||
|
||||
lxc.save()
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class CloneContainerForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = CloneContainer
|
||||
fields = ['hostname', 'network', 'cores', 'memory', 'disksize', 'as_regexp', 'vm', 'template']
|
||||
|
||||
def __init__(self, *args, user_profile=None, vm=None, template=None, hostname=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user_profile = user_profile
|
||||
|
||||
if vm:
|
||||
self.fields['vm'].initial = vm[0].pk
|
||||
self.fields['template'].disabled = True
|
||||
elif template:
|
||||
self.fields['template'].initial = template[0].pk
|
||||
self.fields['vm'].disabled = True
|
||||
if hostname:
|
||||
self.fields['hostname'].initial = hostname
|
||||
# Für externe Benutzer: Netzwerke filtern
|
||||
if user_profile and user_profile.is_external():
|
||||
ldap_uid = user_profile.ldap_uid
|
||||
ip_addresses = IPAddress.objects.filter(comment__icontains=f' {ldap_uid} ')
|
||||
self.fields['network'].queryset = ip_addresses
|
||||
|
||||
def clean_hostname(self):
|
||||
hostname = self.cleaned_data.get('hostname')
|
||||
if not hostname:
|
||||
raise ValidationError("Der Hostname ist erforderlich.")
|
||||
|
||||
# Prüfe, ob der Name bereits als LXC-Hostname existiert
|
||||
if Lxc.objects.filter(hostname=hostname).exists():
|
||||
raise ValidationError(f"Ein LXC mit dem Hostnamen '{hostname}' existiert bereits.")
|
||||
|
||||
# Prüfe, ob der Name bereits als DNS-Name existiert
|
||||
from mikrotik.models import DNSStatic
|
||||
if DNSStatic.objects.filter(name=hostname).exists():
|
||||
raise ValidationError(f"Ein DNS-Eintrag mit dem Namen '{hostname}' existiert bereits.")
|
||||
|
||||
# Prüfe auf Regex-Übereinstimmung in DNS-Einträgen
|
||||
dns_with_regex = DNSStatic.objects.exclude(regexp='')
|
||||
for dns in dns_with_regex:
|
||||
if dns.regexp and re.search(hostname + '$', dns.regexp):
|
||||
raise ValidationError(
|
||||
f"Der Name '{hostname}' entspricht dem regulären Ausdruck '{dns.regexp}' eines existierenden DNS-Eintrags.")
|
||||
|
||||
# Prüfe, ob der Name bereits als CloneContainer-Name existiert
|
||||
existing_clone_query = CloneContainer.objects.filter(hostname=hostname, status__in=('pending', 'running'))
|
||||
if self.instance.pk:
|
||||
existing_clone_query = existing_clone_query.exclude(pk=self.instance.pk)
|
||||
|
||||
if existing_clone_query.exists():
|
||||
raise ValidationError(f"Ein CloneContainer mit dem Namen '{hostname}' existiert bereits.")
|
||||
|
||||
return hostname
|
||||
|
||||
def clean_disksize(self):
|
||||
disksize = self.cleaned_data.get('disksize')
|
||||
if disksize and disksize > 100:
|
||||
raise ValidationError("Die Festplattengröße darf maximal 100 GB betragen.")
|
||||
return disksize
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if not cleaned_data.get('template') and not cleaned_data.get('vm'):
|
||||
raise ValidationError("Es muss entweder ein Template oder eine VM ausgewählt werden.")
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class DNSStaticForm(forms.ModelForm):
|
||||
# Optional: Container selection for automatic IP assignment
|
||||
container = forms.ModelChoiceField(
|
||||
queryset=DevContainer.objects.all(),
|
||||
required=False,
|
||||
empty_label="Select container (optional)",
|
||||
help_text="Select a container to automatically use its IP address"
|
||||
)
|
||||
|
||||
# Manual IP input
|
||||
address = forms.GenericIPAddressField(
|
||||
required=False,
|
||||
help_text="IP address for the DNS entry"
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Add CSS classes
|
||||
for field in self.fields:
|
||||
if isinstance(self.fields[field].widget, forms.widgets.TextInput):
|
||||
self.fields[field].widget.attrs.update({'class': 'form-control'})
|
||||
elif isinstance(self.fields[field].widget, forms.widgets.Select):
|
||||
self.fields[field].widget.attrs.update({'class': 'form-select'})
|
||||
|
||||
# Make container field use Select2
|
||||
self.fields['container'].widget.attrs.update({
|
||||
'class': 'form-select container-select',
|
||||
'data-placeholder': 'Search containers...'
|
||||
})
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
container = cleaned_data.get('container')
|
||||
address = cleaned_data.get('address')
|
||||
name = cleaned_data.get('name')
|
||||
regexp = cleaned_data.get('regexp')
|
||||
|
||||
# Either container or manual address must be provided
|
||||
if not container and not address:
|
||||
raise ValidationError("Either select a container or enter an IP address manually")
|
||||
|
||||
# Either name or regexp must be provided (but not both)
|
||||
if not name and not regexp:
|
||||
raise ValidationError("Either DNS name or regexp must be provided")
|
||||
|
||||
if name and regexp:
|
||||
raise ValidationError("Provide either DNS name or regexp, not both")
|
||||
|
||||
# If container is selected, use its IP address
|
||||
if container:
|
||||
try:
|
||||
cleaned_data['address'] = container.lease.address
|
||||
except AttributeError:
|
||||
raise ValidationError("Selected container has no IP lease")
|
||||
|
||||
# Check for existing DNS entries
|
||||
if name:
|
||||
existing = DNSStatic.objects.filter(name=name)
|
||||
if self.instance.pk:
|
||||
existing = existing.exclude(pk=self.instance.pk)
|
||||
if existing.exists():
|
||||
raise ValidationError(f"DNS name '{name}' already exists")
|
||||
|
||||
if regexp:
|
||||
existing = DNSStatic.objects.filter(regexp=regexp)
|
||||
if self.instance.pk:
|
||||
existing = existing.exclude(pk=self.instance.pk)
|
||||
if existing.exists():
|
||||
raise ValidationError(f"DNS regexp '{regexp}' already exists")
|
||||
|
||||
return cleaned_data
|
||||
|
||||
class Meta:
|
||||
model = DNSStatic
|
||||
fields = ['name', 'regexp', 'address', 'comment', 'container']
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'placeholder': 'e.g. server.example.com'}),
|
||||
'regexp': forms.TextInput(attrs={'placeholder': 'e.g. .*\\.test\\.com'}),
|
||||
'comment': forms.TextInput(attrs={'placeholder': 'Optional description'}),
|
||||
}
|
||||
help_texts = {
|
||||
'name': 'Specific DNS name (e.g. server.example.com)',
|
||||
'regexp': 'DNS regexp pattern (e.g. .*\\.test\\.com)',
|
||||
'comment': 'Optional description for this DNS entry'
|
||||
}
|
||||
|
||||
|
||||
class DNSSearchForm(forms.Form):
|
||||
search = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Search DNS entries...',
|
||||
'id': 'dns-search'
|
||||
})
|
||||
)
|
||||
|
||||
entry_type = forms.ChoiceField(
|
||||
choices=[
|
||||
('', 'All types'),
|
||||
('name', 'Names only'),
|
||||
('regexp', 'Regexps only'),
|
||||
],
|
||||
required=False,
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
|
||||
class FAQForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = FAQ
|
||||
fields = ['title', 'content', 'order']
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Enter FAQ question/title...'
|
||||
}),
|
||||
'content': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 10,
|
||||
'placeholder': 'Enter FAQ answer/content using Markdown syntax...\n\n# Heading\n## Subheading\n\n**Bold text**\n*Italic text*\n\n- List item 1\n- List item 2\n\n```python\ncode block\n```\n\n[Link text](http://example.com)',
|
||||
'style': 'font-family: monospace;'
|
||||
}),
|
||||
'order': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'min': 0
|
||||
})
|
||||
}
|
||||
help_texts = {
|
||||
'title': 'The FAQ question or title',
|
||||
'content': 'The detailed answer or content (supports Markdown formatting)',
|
||||
'order': 'Display order (lower numbers appear first)'
|
||||
}
|
||||
15
frontend/middleware.py
Normal file
15
frontend/middleware.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from django.conf import settings
|
||||
|
||||
class FrontendSessionMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
self.default_session_cookie_name = 'sessionid'
|
||||
|
||||
def __call__(self, request):
|
||||
if request.path.startswith('/frontend/'):
|
||||
settings.SESSION_COOKIE_NAME = settings.SESSION_COOKIE_NAME_FRONTEND
|
||||
else:
|
||||
settings.SESSION_COOKIE_NAME = self.default_session_cookie_name
|
||||
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
25
frontend/migrations/0001_initial.py
Normal file
25
frontend/migrations/0001_initial.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.4 on 2025-07-10 09:30
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ldap_uid', models.CharField(max_length=100, unique=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
18
frontend/migrations/0002_userprofile_settings.py
Normal file
18
frontend/migrations/0002_userprofile_settings.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.4 on 2025-07-18 11:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('frontend', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='settings',
|
||||
field=models.JSONField(default={'dashboard_view': 'list'}),
|
||||
),
|
||||
]
|
||||
19
frontend/migrations/0003_alter_userprofile_settings.py
Normal file
19
frontend/migrations/0003_alter_userprofile_settings.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.4 on 2025-07-21 11:03
|
||||
|
||||
import frontend.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('frontend', '0002_userprofile_settings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userprofile',
|
||||
name='settings',
|
||||
field=models.JSONField(default=frontend.models.default_settings),
|
||||
),
|
||||
]
|
||||
0
frontend/migrations/__init__.py
Normal file
0
frontend/migrations/__init__.py
Normal file
54
frontend/models.py
Normal file
54
frontend/models.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# frontend/models.py
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from lib.db import BaseModel, SearchableMixin
|
||||
from django.db.models import Q
|
||||
|
||||
def default_settings():
|
||||
return {
|
||||
'dashboard_view': 'list',
|
||||
}
|
||||
class UserProfile(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
||||
ldap_uid = models.CharField(max_length=100, unique=True)
|
||||
settings = models.JSONField(default=default_settings)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.ldap_uid}"
|
||||
|
||||
@property
|
||||
def user_group(self):
|
||||
if self.user.groups.filter(name='root').exists():
|
||||
return 'root'
|
||||
elif self.user.groups.filter(name='intern').exists():
|
||||
return 'intern'
|
||||
elif self.user.groups.filter(name='extern').exists():
|
||||
return 'extern'
|
||||
return None
|
||||
|
||||
def is_external(self):
|
||||
return self.user_group == 'extern'
|
||||
|
||||
def is_internal(self):
|
||||
return self.user_group in ['intern', 'root']
|
||||
|
||||
def is_root(self):
|
||||
return self.user_group == 'root'
|
||||
|
||||
|
||||
class FAQ(BaseModel, SearchableMixin):
|
||||
title = models.CharField(max_length=200, help_text='FAQ Question/Title')
|
||||
content = models.TextField(help_text='FAQ Answer/Content')
|
||||
order = models.IntegerField(default=0, help_text='Order for display (lower numbers first)')
|
||||
|
||||
class Meta:
|
||||
ordering = ['order', 'title']
|
||||
verbose_name = 'FAQ'
|
||||
verbose_name_plural = 'FAQs'
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@classmethod
|
||||
def term_filter(cls, search_string):
|
||||
return Q(title__icontains=search_string) | Q(content__icontains=search_string)
|
||||
22
frontend/permissions.py
Normal file
22
frontend/permissions.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# frontend/permissions.py
|
||||
from mikrotik.models import IPAddress
|
||||
|
||||
def user_can_access_container(user_profile, container):
|
||||
"""Prüft, ob der Benutzer Zugriff auf den Container hat."""
|
||||
if user_profile.is_internal():
|
||||
return True
|
||||
if user_profile.user.is_superuser:
|
||||
return True
|
||||
|
||||
# Für externe Benutzer: Nur Container im eigenen Netzwerkbereich
|
||||
ldap_uid = user_profile.ldap_uid
|
||||
ip_addresses = IPAddress.objects.filter(comment__icontains=f' {ldap_uid} ')
|
||||
|
||||
# Prüfen, ob Container-Netzwerk mit einer der IP-Adressen übereinstimmt
|
||||
networks = [ip.network for ip in ip_addresses]
|
||||
|
||||
if hasattr(container, 'lease') and container.lease:
|
||||
container_network = '.'.join(container.lease.address.split('.')[:3])
|
||||
return any(network.startswith(container_network) for network in networks)
|
||||
|
||||
return False
|
||||
23
frontend/signals.py
Normal file
23
frontend/signals.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import logging
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from .models import UserProfile
|
||||
from lib.ldap import Ldap
|
||||
from lib.decorators import skip_signal
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
@skip_signal()
|
||||
def post_save_user_profile(sender, instance: User, created, **kwargs):
|
||||
with Ldap() as ldap:
|
||||
try:
|
||||
ldap.set_user_groups(instance, save_instance=True)
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
return
|
||||
try:
|
||||
if created or not UserProfile.objects.filter(ldap_uid=instance.username).exists():
|
||||
UserProfile.objects.create(user=instance,ldap_uid=instance.username)
|
||||
except Exception as e:
|
||||
logging.exception("WTF???", str(e))
|
||||
117
frontend/static/frontend/js/live-status.js
Normal file
117
frontend/static/frontend/js/live-status.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Live Status Monitor for Container Creation
|
||||
* Provides real-time updates similar to Proxmox task viewer
|
||||
*/
|
||||
|
||||
class LiveStatusMonitor {
|
||||
constructor(taskId, logContainer) {
|
||||
this.taskId = taskId;
|
||||
this.logContainer = logContainer;
|
||||
this.pollInterval = 1000; // Poll every second
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
console.log(`Initializing live status for task: ${this.taskId}`);
|
||||
|
||||
// Initial request with error handling
|
||||
$.get(`/manager/task/${this.taskId}/status/`)
|
||||
.done((data) => {
|
||||
console.log('Initial status data received:', data);
|
||||
this.updateDisplay(data);
|
||||
|
||||
// Start polling only after successful initial request
|
||||
this.startPolling();
|
||||
})
|
||||
.fail((xhr, status, error) => {
|
||||
console.error('Failed to get initial status:', error, xhr.responseText);
|
||||
this.showError(`Failed to connect to live status: ${error}`);
|
||||
|
||||
// Still try to start polling in case it's a temporary issue
|
||||
setTimeout(() => {
|
||||
this.startPolling();
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
startPolling() {
|
||||
setInterval(() => {
|
||||
$.get(`/manager/task/${this.taskId}/status/`)
|
||||
.done((data) => {
|
||||
this.updateDisplay(data);
|
||||
})
|
||||
.fail((xhr, status, error) => {
|
||||
console.error('Polling error:', error);
|
||||
// Don't show error for every polling failure
|
||||
});
|
||||
}, this.pollInterval);
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
const logContent = this.logContainer.querySelector('.live-log-content');
|
||||
if (logContent) {
|
||||
logContent.innerHTML = `<div class="error" style="color: red; padding: 10px;">${message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
updateDisplay(data) {
|
||||
const logContent = this.logContainer.querySelector('.live-log-content');
|
||||
if (!logContent) return;
|
||||
|
||||
const logs = data.logs || {};
|
||||
const steps = logs.steps || [];
|
||||
|
||||
if (steps.length === 0) {
|
||||
logContent.innerHTML = '<div class="loading">Waiting for status updates...</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Track existing entries to highlight new ones
|
||||
const existingCount = logContent.querySelectorAll('.log-entry').length;
|
||||
|
||||
logContent.innerHTML = '';
|
||||
|
||||
steps.forEach((step, index) => {
|
||||
const entry = this.createLogEntry(step);
|
||||
if (index >= existingCount) {
|
||||
entry.classList.add('new');
|
||||
}
|
||||
logContent.appendChild(entry);
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
}
|
||||
|
||||
createLogEntry(step) {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'log-entry';
|
||||
|
||||
const timestamp = new Date(step.timestamp).toLocaleTimeString();
|
||||
const message = step.message || '';
|
||||
|
||||
entry.innerHTML = `
|
||||
<div class="log-timestamp">${timestamp}</div>
|
||||
<div class="log-message">${this.escapeHtml(message)}</div>
|
||||
`;
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
let liveStatus = null;
|
||||
|
||||
function initLiveStatus(taskId, containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (container && taskId) {
|
||||
liveStatus = new LiveStatusMonitor(taskId, container);
|
||||
}
|
||||
}
|
||||
338
frontend/static/frontend/js/proxmox-tasks.js
Normal file
338
frontend/static/frontend/js/proxmox-tasks.js
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Proxmox Task Management - Generelle Funktionen für Task-Überwachung
|
||||
*/
|
||||
|
||||
try {
|
||||
console.log('Proxmox Task Manager will start with ', taskMonitorOptions);
|
||||
}catch(e){
|
||||
console.log('Proxmox Task Manager will start without options');
|
||||
const taskMonitorOptions = {};
|
||||
}
|
||||
|
||||
class ProxmoxTaskManager {
|
||||
constructor(options = taskMonitorOptions || {}) {
|
||||
this.checkInterval = options.checkInterval || 2000; // 1 second default
|
||||
this.maxTimeout = options.maxTimeout || 50000; // 30 seconds default
|
||||
this.taskStatusUrl = options.taskStatusUrl || '/api/task-status/';
|
||||
this.activeMonitors = new Map(); // Track active monitoring processes
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet die Überwachung einer Proxmox Task
|
||||
* @param {string} taskId - Die Proxmox Task ID
|
||||
* @param {Object} callbacks - Callback-Funktionen für verschiedene Events
|
||||
* @param {function} callbacks.onProgress - Wird bei jedem Status-Update aufgerufen
|
||||
* @param {function} callbacks.onSuccess - Wird bei erfolgreichem Abschluss aufgerufen
|
||||
* @param {function} callbacks.onError - Wird bei Fehlern aufgerufen
|
||||
* @param {function} callbacks.onTimeout - Wird bei Timeout aufgerufen
|
||||
* @param {Object} context - Zusätzlicher Kontext, der an Callbacks weitergegeben wird
|
||||
*/
|
||||
monitorTask(taskId, callbacks = {}, context = {}) {
|
||||
// Stoppe eventuell bereits laufende Überwachung für diese Task
|
||||
this.stopMonitoring(taskId);
|
||||
|
||||
const monitor = {
|
||||
taskId: taskId,
|
||||
callbacks: callbacks,
|
||||
context: context,
|
||||
interval: null,
|
||||
timeout: null,
|
||||
startTime: Date.now()
|
||||
};
|
||||
|
||||
// Starte Intervall für Status-Checks
|
||||
monitor.interval = setInterval(() => {
|
||||
this._checkTaskStatus(monitor);
|
||||
}, this.checkInterval);
|
||||
|
||||
// Safety timeout
|
||||
monitor.timeout = setTimeout(() => {
|
||||
this._handleTimeout(monitor);
|
||||
}, this.maxTimeout);
|
||||
|
||||
// Speichere Monitor für spätere Referenz
|
||||
this.activeMonitors.set(taskId, monitor);
|
||||
|
||||
// Ersten Check sofort ausführen
|
||||
this._checkTaskStatus(monitor);
|
||||
|
||||
return taskId; // Return taskId for reference
|
||||
}
|
||||
|
||||
/**
|
||||
* Stoppt die Überwachung einer Task
|
||||
* @param {string} taskId - Die Task ID
|
||||
*/
|
||||
stopMonitoring(taskId) {
|
||||
const monitor = this.activeMonitors.get(taskId);
|
||||
if (monitor) {
|
||||
if (monitor.interval) clearInterval(monitor.interval);
|
||||
if (monitor.timeout) clearTimeout(monitor.timeout);
|
||||
this.activeMonitors.delete(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stoppt alle aktiven Überwachungen
|
||||
*/
|
||||
stopAllMonitoring() {
|
||||
for (const [taskId] of this.activeMonitors) {
|
||||
this.stopMonitoring(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft den Status einer Task
|
||||
* @private
|
||||
*/
|
||||
_checkTaskStatus(monitor) {
|
||||
$.get(this.taskStatusUrl, { 'task_id': monitor.taskId })
|
||||
.done((data) => {
|
||||
if (data.status === 'success') {
|
||||
const taskStatus = data.task_status;
|
||||
const progress = data.progress || 0;
|
||||
|
||||
// Progress callback
|
||||
if (monitor.callbacks.onProgress) {
|
||||
monitor.callbacks.onProgress(taskStatus, progress, data, monitor.context);
|
||||
}
|
||||
|
||||
// Check if task is completed
|
||||
if (taskStatus === 'OK' || taskStatus === 'completed') {
|
||||
this._handleSuccess(monitor, data);
|
||||
} else if (taskStatus === 'stopped' || taskStatus === 'error') {
|
||||
this._handleError(monitor, data);
|
||||
}
|
||||
// If still running, continue monitoring
|
||||
} else {
|
||||
this._handleError(monitor, data);
|
||||
}
|
||||
})
|
||||
.fail((xhr) => {
|
||||
let errorMsg = 'Network error';
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
errorMsg = response.message || errorMsg;
|
||||
} catch (e) {}
|
||||
|
||||
this._handleError(monitor, { message: errorMsg });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandelt erfolgreichen Task-Abschluss
|
||||
* @private
|
||||
*/
|
||||
_handleSuccess(monitor, data) {
|
||||
this.stopMonitoring(monitor.taskId);
|
||||
if (monitor.callbacks.onSuccess) {
|
||||
monitor.callbacks.onSuccess(data, monitor.context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandelt Task-Fehler
|
||||
* @private
|
||||
*/
|
||||
_handleError(monitor, data) {
|
||||
this.stopMonitoring(monitor.taskId);
|
||||
if (monitor.callbacks.onError) {
|
||||
monitor.callbacks.onError(data, monitor.context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandelt Timeout
|
||||
* @private
|
||||
*/
|
||||
_handleTimeout(monitor) {
|
||||
this.stopMonitoring(monitor.taskId);
|
||||
if (monitor.callbacks.onTimeout) {
|
||||
monitor.callbacks.onTimeout(monitor.context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Anzahl aktiver Überwachungen zurück
|
||||
*/
|
||||
getActiveMonitorCount() {
|
||||
return this.activeMonitors.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle aktiven Task IDs zurück
|
||||
*/
|
||||
getActiveTaskIds() {
|
||||
return Array.from(this.activeMonitors.keys());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard Proxmox Task Manager Instanz
|
||||
* Kann global verwendet werden
|
||||
*/
|
||||
const proxmoxTaskManager = new ProxmoxTaskManager();
|
||||
|
||||
/**
|
||||
* Hilfsfunktion für einfache Task-Überwachung mit Standard-Callbacks
|
||||
* @param {string} taskId - Die Task ID
|
||||
* @param {Object} element - DOM Element das visuell aktualisiert werden soll
|
||||
* @param {string} action - Aktion die ausgeführt wird (für Logging)
|
||||
* @param {function} onComplete - Optional: Custom completion callback
|
||||
*/
|
||||
function monitorProxmoxTask(taskId, element, action, onComplete = null) {
|
||||
const $element = $(element);
|
||||
|
||||
return proxmoxTaskManager.monitorTask(taskId, {
|
||||
onProgress: (status, progress, data, context) => {
|
||||
console.log(`Task ${taskId} progress: ${status} (${progress}%)`);
|
||||
|
||||
// Update tooltip with progress
|
||||
if (progress > 0) {
|
||||
$element.attr('title', `${action}: ${progress}%`);
|
||||
}
|
||||
},
|
||||
|
||||
onSuccess: (data, context) => {
|
||||
console.log(`Task ${taskId} completed successfully`);
|
||||
$element.removeClass('pending error');
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(true, data, context);
|
||||
} else {
|
||||
// Standard success handling
|
||||
$element.attr('title', `${action} completed successfully`);
|
||||
}
|
||||
},
|
||||
|
||||
onError: (data, context) => {
|
||||
console.error(`Task ${taskId} failed:`, data.message);
|
||||
$element.removeClass('pending').addClass('error');
|
||||
$element.attr('title', `${action} failed: ${data.message}`);
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(false, data, context);
|
||||
}
|
||||
},
|
||||
|
||||
onTimeout: (context) => {
|
||||
console.warn(`Task ${taskId} monitoring timed out`);
|
||||
$element.removeClass('pending').addClass('error');
|
||||
$element.attr('title', `${action} timed out`);
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(false, { message: 'Timeout' }, context);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
element: element,
|
||||
action: action,
|
||||
taskId: taskId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Erweiterte Task-Überwachung mit Live-Log-Container (für Container-Erstellung etc.)
|
||||
* @param {string} taskId - Die Task ID
|
||||
* @param {string} logContainerId - ID des Log-Containers
|
||||
* @param {function} onComplete - Callback bei Completion
|
||||
*/
|
||||
function monitorProxmoxTaskWithLiveLog(taskId, logContainerId, onComplete = null) {
|
||||
const logContainer = document.getElementById(logContainerId);
|
||||
const logContent = logContainer?.querySelector('.live-log-content');
|
||||
|
||||
if (!logContainer || !logContent) {
|
||||
console.error('Log container not found:', logContainerId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing content
|
||||
logContent.innerHTML = '<div class="loading">Monitoring task...</div>';
|
||||
|
||||
return proxmoxTaskManager.monitorTask(taskId, {
|
||||
onProgress: (status, progress, data, context) => {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const progressText = progress > 0 ? ` (${progress}%)` : '';
|
||||
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = 'log-entry new';
|
||||
logEntry.innerHTML = `
|
||||
<span class="log-timestamp">${timestamp}</span>
|
||||
<span class="log-level info">INFO</span>
|
||||
<span class="log-message">Task ${status}${progressText}</span>
|
||||
`;
|
||||
|
||||
// Remove loading message if present
|
||||
const loading = logContent.querySelector('.loading');
|
||||
if (loading) loading.remove();
|
||||
|
||||
logContent.appendChild(logEntry);
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
|
||||
// Remove 'new' class after animation
|
||||
setTimeout(() => logEntry.classList.remove('new'), 500);
|
||||
},
|
||||
|
||||
onSuccess: (data, context) => {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = 'log-entry new';
|
||||
logEntry.innerHTML = `
|
||||
<span class="log-timestamp">${timestamp}</span>
|
||||
<span class="log-level success">✓</span>
|
||||
<span class="log-message">Task completed successfully</span>
|
||||
`;
|
||||
|
||||
logContent.appendChild(logEntry);
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
|
||||
if (onComplete) {
|
||||
setTimeout(() => onComplete(true, data, context), 1000);
|
||||
}
|
||||
},
|
||||
|
||||
onError: (data, context) => {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = 'log-entry new';
|
||||
logEntry.innerHTML = `
|
||||
<span class="log-timestamp">${timestamp}</span>
|
||||
<span class="log-level error">✗</span>
|
||||
<span class="log-message">Task failed: ${data.message}</span>
|
||||
`;
|
||||
|
||||
logContent.appendChild(logEntry);
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
|
||||
if (onComplete) {
|
||||
setTimeout(() => onComplete(false, data, context), 1000);
|
||||
}
|
||||
},
|
||||
|
||||
onTimeout: (context) => {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = 'log-entry new';
|
||||
logEntry.innerHTML = `
|
||||
<span class="log-timestamp">${timestamp}</span>
|
||||
<span class="log-level warning">⚠</span>
|
||||
<span class="log-message">Task monitoring timed out</span>
|
||||
`;
|
||||
|
||||
logContent.appendChild(logEntry);
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
|
||||
if (onComplete) {
|
||||
setTimeout(() => onComplete(false, { message: 'Timeout' }, context), 1000);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
taskId: taskId,
|
||||
logContainerId: logContainerId
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup bei Seitenwechsel
|
||||
$(window).on('beforeunload', function() {
|
||||
proxmoxTaskManager.stopAllMonitoring();
|
||||
});
|
||||
99
frontend/templates/frontend/base.html
Normal file
99
frontend/templates/frontend/base.html
Normal file
@@ -0,0 +1,99 @@
|
||||
<!-- frontend/templates/frontend/base.html -->
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Container Management{% block title %}{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet"/>
|
||||
|
||||
<style>
|
||||
body {
|
||||
padding-top: 70px;
|
||||
}
|
||||
|
||||
.form-control, select, input, textarea {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
select, input, textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
i.bi {
|
||||
margin-right:.5em;
|
||||
}
|
||||
.btn-group i.bi{
|
||||
margin: initial;
|
||||
}
|
||||
</style>
|
||||
{% block extra_head %}{% endblock %}
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{% url 'frontend:dashboard' %}">Container Management</a>
|
||||
{% if user.is_authenticated %}
|
||||
<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 'frontend:dashboard' %}">Dev Container</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'frontend:dns_list' %}">DNS Management</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'frontend:faq_list' %}">FAQ</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'tasklogger:task_list' %}">Tasks</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<span class="nav-link">{{ user.username }} ({{ user.profile.user_group }})</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'frontend:logout' %}?next={{ request.path }}">Logout</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'manager:sync_all' %}"><i class="btn bi bi-arrow-repeat"></i> </a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container mt-5">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show"
|
||||
onclick="$(this).fadeOut(function(){$(this).slideUp();})" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
48
frontend/templates/frontend/container_details.html
Normal file
48
frontend/templates/frontend/container_details.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!-- frontend/templates/frontend/container_detail.html -->
|
||||
{% extends 'frontend/base.html' %}
|
||||
|
||||
{% block title %} - Container Details{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% url 'frontend:edit_container' as editurl %}
|
||||
{% url 'frontend:delete_container' as deleteurl %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Container: {{ container.name }}</h2>
|
||||
<div>
|
||||
<a href="{{ editurl }}" class="btn btn-warning">Edit</a>
|
||||
<a href="{{ deleteurl }}" class="btn btn-danger">Delete</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5>Container details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Name:</strong> {{ container.name }}</p>
|
||||
<p><strong>IP-Adresse:</strong> {{ container.address }}</p>
|
||||
<p><strong>Hostname:</strong> {{ container.hostname }}</p>
|
||||
<p><strong>MAC-Adresse:</strong> {{ container.hwaddr }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>Status:</strong> {{ container.status }}</p>
|
||||
<p><strong>Speicher:</strong> {{ container.lxc.memory }} MB</p>
|
||||
<p><strong>CPU-Kerne:</strong> {{ container.lxc.cores }}</p>
|
||||
<p><strong>Festplatte:</strong> {{ container.lxc.disksize }} GB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>DNS-Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>DNS-Name:</strong> {{ container.dns.name|default:"--" }}</p>
|
||||
<p><strong>DNS-Regex:</strong> {{ container.dns.regexp|default:"--" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
342
frontend/templates/frontend/create_container.html
Normal file
342
frontend/templates/frontend/create_container.html
Normal file
@@ -0,0 +1,342 @@
|
||||
{% extends 'frontend/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %} - Create Dev Container{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
.select2-container {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<!-- Rest of your existing content -->
|
||||
<div class="col-12 col-md-10 col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title mb-0">Create Dev Container</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="task_uuid" value="{{ task_uuid }}">
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-10">
|
||||
<label for="{{ form.hostname.id_for_label }}"
|
||||
class="form-label fw-bold">{{ form.hostname.label }}</label>
|
||||
{{ form.hostname }}
|
||||
{% if form.hostname.help_text %}
|
||||
<div class="form-text small text-muted mt-1">{{ form.hostname.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.hostname.errors %}
|
||||
<div class="invalid-feedback d-block mt-1">
|
||||
{% for error in form.hostname.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-2" title="{{ form.as_regexp.help_text }}">
|
||||
<label for="{{ form.as_regexp.id_for_label }}"
|
||||
class="form-label fw-bold">{{ form.as_regexp.label }}</label>
|
||||
{{ form.as_regexp }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-{% if request.GET.clone_lxc %}12{% else %}6{% endif %}">
|
||||
|
||||
<label for="{{ form.vm.id_for_label }}"
|
||||
class="form-label fw-bold">{{ form.vm.label }}</label>
|
||||
{{ form.vm }}
|
||||
{% if form.vm.help_text %}
|
||||
<div class="form-text small text-muted mt-1">{{ form.vm.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.vm.errors %}
|
||||
<div class="invalid-feedback d-block mt-1">
|
||||
{% for error in form.vm.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% if not request.GET.clone_lxc %}
|
||||
<div class="col-md-1 text-center">
|
||||
<label class="form-label fw-bold">or</label>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label for="{{ form.template.id_for_label }}"
|
||||
class="form-label fw-bold">{{ form.template.label }}</label>
|
||||
{{ form.template }}
|
||||
{% if form.template.help_text %}
|
||||
<div class="form-text small text-muted mt-1">{{ form.template.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.template.errors %}
|
||||
<div class="invalid-feedback d-block mt-1">
|
||||
{% for error in form.template.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Network-Feld -->
|
||||
<div class="mb-4">
|
||||
<label for="{{ form.network.id_for_label }}"
|
||||
class="form-label fw-bold">{{ form.network.label }}</label>
|
||||
{{ form.network }}
|
||||
{% if form.network.help_text %}
|
||||
<div class="form-text small text-muted mt-1">{{ form.network.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.network.errors %}
|
||||
<div class="invalid-feedback d-block mt-1">
|
||||
{% for error in form.network.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Dritte Zeile: cores, memory und disksize -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<label for="{{ form.cores.id_for_label }}"
|
||||
class="form-label fw-bold">{{ form.cores.label }}</label>
|
||||
{{ form.cores }}
|
||||
{% if form.cores.help_text %}
|
||||
<div class="form-text small text-muted mt-1">{{ form.cores.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.cores.errors %}
|
||||
<div class="invalid-feedback d-block mt-1">
|
||||
{% for error in form.cores.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="{{ form.memory.id_for_label }}"
|
||||
class="form-label fw-bold">{{ form.memory.label }}</label>
|
||||
{{ form.memory }}
|
||||
{% if form.memory.help_text %}
|
||||
<div class="form-text small text-muted mt-1">{{ form.memory.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.memory.errors %}
|
||||
<div class="invalid-feedback d-block mt-1">
|
||||
{% for error in form.memory.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="{{ form.disksize.id_for_label }}"
|
||||
class="form-label fw-bold">{{ form.disksize.label }}</label>
|
||||
{{ form.disksize }}
|
||||
{% if form.disksize.help_text %}
|
||||
<div class="form-text small text-muted mt-1">{{ form.disksize.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.disksize.errors %}
|
||||
<div class="invalid-feedback d-block mt-1">
|
||||
{% for error in form.disksize.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Beschreibung (falls vorhanden) -->
|
||||
{% if form.description %}
|
||||
<div class="mb-4">
|
||||
<label for="{{ form.description.id_for_label }}"
|
||||
class="form-label fw-bold">{{ form.description.label }}</label>
|
||||
{{ form.description }}
|
||||
{% if form.description.help_text %}
|
||||
<div class="form-text small text-muted mt-1">{{ form.description.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.description.errors %}
|
||||
<div class="invalid-feedback d-block mt-1">
|
||||
{% for error in form.description.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for field in form %}
|
||||
{% if field.name != 'name' and field.name != 'hostname' and field.name != 'as_regexp' and field.name != 'template' and field.name != 'vm' and field.name != 'network' and field.name != 'cores' and field.name != 'memory' and field.name != 'disksize' and field.name != 'description' %}
|
||||
<div class="mb-4">
|
||||
<label for="{{ field.id_for_label }}"
|
||||
class="form-label fw-bold">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}
|
||||
<div class="form-text small text-muted mt-1">{{ field.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback d-block mt-1">
|
||||
{% for error in field.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end mt-4">
|
||||
<a href="{% url 'frontend:dashboard' %}"
|
||||
class="btn btn-outline-secondary me-md-2 mb-2 mb-md-0 w-100 w-md-auto">Cancel</a>
|
||||
<button type="submit" class="btn btn-outline-primary w-100 w-md-auto" id="create-btn">Create
|
||||
Container
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Progress Overlay -->
|
||||
<div id="task-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 9999; color: white;">
|
||||
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #1e1e1e; padding: 30px; border-radius: 10px; min-width: 500px; max-width: 700px;">
|
||||
<h4>Creating Container...</h4>
|
||||
<div class="task-info">
|
||||
<small>Task UUID: <span id="task-uuid">{{ task_uuid }}</span></small>
|
||||
</div>
|
||||
<div id="task-logs" style="background: #000; padding: 15px; border-radius: 5px; margin: 15px 0; max-height: 400px; overflow-y: auto; font-family: 'Courier New', monospace; font-size: 12px;">
|
||||
<div class="log-entry">Initializing...</div>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- jQuery und Select2 JS -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
<script>
|
||||
let taskMonitorInterval = null;
|
||||
|
||||
$(document).ready(function () {
|
||||
// Select2 für alle select-Elemente aktivieren
|
||||
$('#id_network').select2({
|
||||
width: '100%'
|
||||
});
|
||||
$('#id_vm').select2({
|
||||
width: '100%'
|
||||
});
|
||||
$('#id_template').children('option').each(function () {
|
||||
$(this).text($(this).text().split('/')[1]);
|
||||
});
|
||||
$('#id_template').select2({
|
||||
width: '100%'
|
||||
});
|
||||
|
||||
// Form submission with async task monitoring
|
||||
$('form').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
$('#create-btn').prop('disabled', true).text('Creating Container...');
|
||||
|
||||
// Submit form via AJAX
|
||||
$.ajax({
|
||||
url: $(this).attr('action'),
|
||||
method: 'POST',
|
||||
data: $(this).serialize(),
|
||||
success: function(response) {
|
||||
if (response.status === 'task_started') {
|
||||
// Show overlay and start monitoring
|
||||
$('#task-uuid').text(response.task_uuid);
|
||||
$('#task-overlay').show();
|
||||
startTaskMonitoring(response.task_uuid);
|
||||
} else {
|
||||
alert('Error: ' + response.message);
|
||||
$('#create-btn').prop('disabled', false).text('Create Container');
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
let errorMsg = 'Network error';
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
errorMsg = response.message || errorMsg;
|
||||
} catch (e) {}
|
||||
|
||||
alert('Error: ' + errorMsg);
|
||||
$('#create-btn').prop('disabled', false).text('Create Container');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function startTaskMonitoring(taskUuid) {
|
||||
let entryCount = 0;
|
||||
|
||||
taskMonitorInterval = setInterval(function() {
|
||||
$.get('{% url "tasklogger:task_status" %}', {'task_uuid': taskUuid})
|
||||
.done(function(data) {
|
||||
if (data.status === 'success') {
|
||||
const task = data.task;
|
||||
const entries = task.entries || [];
|
||||
|
||||
// Update logs (only new entries)
|
||||
if (entries.length > entryCount) {
|
||||
const newEntries = entries.slice(entryCount);
|
||||
newEntries.forEach(function(entry) {
|
||||
const logEntry = $('<div class="log-entry"></div>');
|
||||
const timestamp = new Date(entry.created_at).toLocaleTimeString();
|
||||
let message = '';
|
||||
|
||||
if (typeof entry.message === 'object') {
|
||||
message = JSON.stringify(entry.message, null, 2);
|
||||
} else {
|
||||
message = entry.message || 'No message';
|
||||
}
|
||||
|
||||
logEntry.html('<span style="color: #666;">' + timestamp + '</span> ' + message);
|
||||
$('#task-logs').append(logEntry);
|
||||
});
|
||||
|
||||
// Scroll to bottom
|
||||
$('#task-logs').scrollTop($('#task-logs')[0].scrollHeight);
|
||||
entryCount = entries.length;
|
||||
}
|
||||
|
||||
// Check if task completed
|
||||
if (task.status === 'completed') {
|
||||
clearInterval(taskMonitorInterval);
|
||||
$('.progress-bar').removeClass('progress-bar-animated').addClass('bg-success').css('width', '100%');
|
||||
|
||||
setTimeout(function() {
|
||||
window.location.href = "{% url 'frontend:dashboard' %}";
|
||||
}, 2000);
|
||||
} else if (task.status === 'error') {
|
||||
clearInterval(taskMonitorInterval);
|
||||
$('.progress-bar').removeClass('progress-bar-animated').addClass('bg-danger').css('width', '100%');
|
||||
|
||||
// Add close button
|
||||
$('#task-overlay .progress').after('<button class="btn btn-danger mt-3" onclick="window.location.href=\'{% url "frontend:dashboard" %}\'">Close</button>');
|
||||
}
|
||||
}
|
||||
})
|
||||
.fail(function() {
|
||||
console.error('Failed to get task status');
|
||||
});
|
||||
}, 1000); // Check every second
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
387
frontend/templates/frontend/dashboard.html
Normal file
387
frontend/templates/frontend/dashboard.html
Normal file
@@ -0,0 +1,387 @@
|
||||
{% extends 'frontend/base.html' %}
|
||||
|
||||
{% block title %} - Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h3>
|
||||
<i class="bi bi-pc-display-horizontal"></i> Dev Container
|
||||
</h3>
|
||||
{% if request.user.is_staff %}
|
||||
<div role="group">
|
||||
<a href="{% url 'frontend:create_container' %}"
|
||||
class="btn btn-primary"
|
||||
><i class="bi bi-plus-circle"></i> Create Dev Container</a>
|
||||
{% if default_template %}
|
||||
<a href="{% url 'frontend:create_container' %}?clone_template={{ default_template.pk }}"
|
||||
class="btn btn-secondary"
|
||||
><i class="bi bi-plus-circle"></i> Create from default Template</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% url 'frontend:dashboard' as search_action_url %}
|
||||
{% include 'frontend/includes/pagination_snippet.html' %}
|
||||
|
||||
<div id="cardView" class="row">
|
||||
{% for container in page_obj %}
|
||||
<div class="col-12 col-md-6 col-lg-4 mb-3 container-item" data-crow="{{ container.internal_id }}">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center">
|
||||
<!-- LXC Status -->
|
||||
<div class="me-2" title="LXC Status: {{ container.lxc.status }}">
|
||||
<span class="status-circle status-lxc {{ container.lxc.status }} lxc-status-{{ container.internal_id }}"></span>
|
||||
</div>
|
||||
<div class="me-3" title="Network Status: {{ container.lease.status }}">
|
||||
<span class="status-circle status-lease {{ container.lxc.status }} lease_status_{{ container.internal_id }}"></span>
|
||||
</div>
|
||||
|
||||
<h5 class="mb-0">{{ container.vmid }} {{ container.name }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2"><strong
|
||||
style="width:25%;display: inline-block">Hostname:</strong> {{ container.hostname }}
|
||||
</div>
|
||||
<div class="mb-2"><strong
|
||||
style="width:25%;display: inline-block">IP-Address:</strong> {{ container.address }}
|
||||
</div>
|
||||
<div class="mb-2"><strong style="width: 25%;display: inline-block;">Root
|
||||
Disk:</strong> {{ container.lxc.disksize }} GB
|
||||
</div>
|
||||
<div class="mb-2"><strong
|
||||
style="width: 25%;display: inline-block;">Memory:</strong> {{ container.lxc.memory }}
|
||||
MB
|
||||
</div>
|
||||
<div class="mb-2"><strong
|
||||
style="width: 25%;display: inline-block;">Cores:</strong> {{ container.lxc.cores }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="btn-group w-100" role="group">
|
||||
<button onclick="openProxmoxConsole('{{ container.lxc.proxmox_console_url }}', '{{ container.lxc.vmid }}')"
|
||||
class="btn btn-secondary terminal terminal-{{ container.pk }}"
|
||||
title="Open Proxmox Console">
|
||||
<i class="bi bi-terminal"></i>
|
||||
</button>
|
||||
<a href="{% url 'frontend:container_detail' container.pk %}"
|
||||
class="btn btn-info" title="Details">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
</a>
|
||||
<a href="{% url 'frontend:edit_container' container.pk %}"
|
||||
class="btn btn-warning" title="Edit">
|
||||
<i class="bi bi-gear"></i>
|
||||
</a>
|
||||
<a href="{% url 'frontend:create_container' %}?clone_lxc={{ container.pk }}"
|
||||
class="btn btn-secondary btn-sm" title="Clone">
|
||||
<i class="bi bi-copy"></i>
|
||||
</a>
|
||||
<a href="{% url 'frontend:delete_container' container.pk %}"
|
||||
class="btn btn-danger" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info text-center">
|
||||
{% if request.GET.search %}
|
||||
No Container for "{{ request.GET.search }}".
|
||||
<div class="mt-3 text-center">
|
||||
<a href="{% url 'frontend:dashboard' %}" class="btn btn-sm btn-secondary mt-2"><i
|
||||
class="bi bi-trash"> Clear Search</i></a>
|
||||
</div>
|
||||
{% else %}
|
||||
No Containers found
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="listView" class="row d-none">
|
||||
<div class="col-12">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Stati</th>
|
||||
<th>Name</th>
|
||||
<th>Hostname</th>
|
||||
<th>IP-Address</th>
|
||||
<th>Root Disk</th>
|
||||
<th>Memory</th>
|
||||
<th>Cores</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for container in page_obj %}
|
||||
<tr class="container-item" data-crow="{{ container.internal_id }}">
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<!-- LXC Status -->
|
||||
<div class="me-2" title="LXC Status: {{ container.lxc.status }}">
|
||||
<span class="status-circle {{ container.lxc.status }} lxc_status_{{ container.internal_id }}"></span>
|
||||
</div>
|
||||
<!-- Network Status -->
|
||||
<div title="Network Status: {{ container.status }}">
|
||||
<span class="status-circle {{ container.lease.status }} lease_status_{{ container.internal_id }}"></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ container.vmid }} {{ container.name }}</td>
|
||||
<td>{{ container.hostname }}</td>
|
||||
<td>{{ container.address }}</td>
|
||||
<td>{{ container.lxc.disksize }} GB</td>
|
||||
<td>{{ container.lxc.memory }} MB</td>
|
||||
<td>{{ container.lxc.cores }}</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<button onclick="openProxmoxConsole('{{ container.lxc.proxmox_console_url }}', '{{ container.lxc.vmid }}')"
|
||||
class="btn btn-sm btn-secondary terminal terminal-{{ container.pk }}"
|
||||
title="Open Proxmox Console">
|
||||
<i class="bi bi-terminal"></i>
|
||||
</button>
|
||||
<a href="{% url 'frontend:container_detail' container.pk %}"
|
||||
class="btn btn-info btn-sm" title="Details">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
</a>
|
||||
<a href="{% url 'frontend:edit_container' container.pk %}"
|
||||
class="btn btn-warning btn-sm" title="Edit">
|
||||
<i class="bi bi-gear"></i>
|
||||
</a>
|
||||
<a href="{% url 'frontend:create_container' %}?clone_lxc={{ container.lxc.pk }}"
|
||||
class="btn btn-secondary btn-sm" title="Clone">
|
||||
<i class="bi bi-copy"></i>
|
||||
</a>
|
||||
<a href="{% url 'frontend:delete_container' container.pk %}"
|
||||
class="btn btn-danger btn-sm" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center">
|
||||
{% if request.GET.search %}
|
||||
No Container for "{{ request.GET.search }}".
|
||||
<div class="mt-3 text-center">
|
||||
<a href="{% url 'frontend:dashboard' %}"
|
||||
class="btn btn-sm btn-secondary mt-2"><i class="bi bi-trash"> Clear
|
||||
Search</i></a>
|
||||
</div>
|
||||
{% else %}
|
||||
No Containers found
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'frontend/includes/pagination_snippet.html' %}
|
||||
|
||||
<style>
|
||||
.status-circle {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: darkred;
|
||||
}
|
||||
|
||||
.status-low,
|
||||
.status-low td,
|
||||
.status-low div {
|
||||
background-color: #f9aea8 !important;
|
||||
}
|
||||
|
||||
.status-circle.active,
|
||||
.status-circle.running,
|
||||
.status-circle.bound {
|
||||
background-color: darkgreen;
|
||||
}
|
||||
|
||||
.status-circle.pending {
|
||||
background-color: orange;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.status-circle.error {
|
||||
background-color: red;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.btn.terminal {
|
||||
background-color: #444444;
|
||||
border-color: #444444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.terminal-none {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
var respd = false;
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const cardViewBtn = document.getElementById('cardViewBtn');
|
||||
const listViewBtn = document.getElementById('listViewBtn');
|
||||
const cardView = document.getElementById('cardView');
|
||||
const listView = document.getElementById('listView');
|
||||
|
||||
let viewMode = localStorage.getItem('containerViewMode') || '{{ user_profile.settings.dashboard_view }}';
|
||||
|
||||
'{{page_ids }}'.split(',').forEach(function (id) {
|
||||
if (!id) {
|
||||
console.warn('Current Container called but id is empty - {{ page_ids }}');
|
||||
return;
|
||||
}
|
||||
$.get("{% url 'frontend:current_container_details' %}", {'ids': id}, function (container) {
|
||||
container = container[0]
|
||||
if (container.is_low) {
|
||||
$('[data-crow="' + id + '"]').addClass(
|
||||
'status-low'
|
||||
).attr(
|
||||
'title',
|
||||
'Low Warning: Disk: ' + container.disk_percent + '%, Memory: ' + container.mem_percent + '%'
|
||||
);
|
||||
}
|
||||
let lxc_status = $('.lxc_status_' + id);
|
||||
let lease_status = $('.lease_status_' + id);
|
||||
lxc_status.removeClass('stopped,running)').addClass(container.lxc_status).css('cursor', 'pointer');
|
||||
/*
|
||||
if(container.lxc_status === 'running'){
|
||||
$('.terminal-' + id).removeClass('terminal-none')
|
||||
}else{
|
||||
$('.terminal-' + id).addClass('terminal-none')
|
||||
}
|
||||
*/
|
||||
lxc_status.attr('title', 'LXC Status: ' + container.lxc_status);
|
||||
let stopurl = "{% url 'frontend:stop_lxc' 0 %}";
|
||||
let starturl = "{% url 'frontend:start_lxc' 0 %}";
|
||||
|
||||
lxc_status.click(function () {
|
||||
if (container.lxc_status === 'stopped') {
|
||||
performContainerAction(id, 'start', starturl, lxc_status);
|
||||
} else {
|
||||
performContainerAction(id, 'stop', stopurl, lxc_status);
|
||||
}
|
||||
});
|
||||
lease_status.removeClass('bound,waiting)').addClass(container.lease_status);
|
||||
lease_status.attr('title', 'Network Status: ' + container.lease_status);
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
function checkScreenSizeAndAdjustView() {
|
||||
const isMobileOrTablet = window.innerWidth < 768;
|
||||
|
||||
if (isMobileOrTablet && viewMode === 'list') {
|
||||
cardView.classList.remove('d-none');
|
||||
listView.classList.add('d-none');
|
||||
cardViewBtn.classList.add('active');
|
||||
listViewBtn.classList.remove('active');
|
||||
} else {
|
||||
if (viewMode === 'list') {
|
||||
cardView.classList.add('d-none');
|
||||
listView.classList.remove('d-none');
|
||||
cardViewBtn.classList.remove('active');
|
||||
listViewBtn.classList.add('active');
|
||||
} else {
|
||||
cardView.classList.remove('d-none');
|
||||
listView.classList.add('d-none');
|
||||
cardViewBtn.classList.add('active');
|
||||
listViewBtn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkScreenSizeAndAdjustView();
|
||||
|
||||
window.addEventListener('resize', checkScreenSizeAndAdjustView);
|
||||
|
||||
function chView(vm) {
|
||||
viewMode = vm;
|
||||
localStorage.setItem('containerViewMode', viewMode);
|
||||
checkScreenSizeAndAdjustView();
|
||||
/*
|
||||
$.post('{ % url 'manager:set_view_mode' % }', {'mode': viewMode}, function (data) {
|
||||
console.log(data);
|
||||
});
|
||||
*/
|
||||
}
|
||||
|
||||
cardViewBtn.addEventListener('click', function () {
|
||||
chView('card');
|
||||
});
|
||||
|
||||
listViewBtn.addEventListener('click', function () {
|
||||
chView('list');
|
||||
});
|
||||
});
|
||||
|
||||
function openProxmoxConsole(url, vmid) {
|
||||
if (!localStorage.getItem('pm-warning-confirmed')) {
|
||||
if (!confirm('You have to be logged in in the Proxmox to open a Console here.\n\n')) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('pm-warning-confirmed', 'true');
|
||||
}
|
||||
window.open(
|
||||
url,
|
||||
'proxmox-console-' + vmid,
|
||||
'width=800,height=600,scrollbars=yes,resizable=yes,status=yes,toolbar=yes,menubar=no,location=yes'
|
||||
);
|
||||
}
|
||||
|
||||
function performContainerAction(containerId, action, url, statusElement) {
|
||||
// Simply redirect to the URL (synchronous operation with form-blocking)
|
||||
window.location.href = url + '?id=' + containerId;
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
138
frontend/templates/frontend/delete_container.html
Normal file
138
frontend/templates/frontend/delete_container.html
Normal file
@@ -0,0 +1,138 @@
|
||||
<!-- frontend/templates/frontend/delete_container.html -->
|
||||
{% extends 'frontend/base.html' %}
|
||||
|
||||
{% block title %} - Container löschen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>Container löschen</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Sind Sie sicher, dass Sie den Container <strong>{{ container.name }}</strong> löschen möchten?</p>
|
||||
<p class="text-danger">Diese Aktion kann nicht rückgängig gemacht werden!</p>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="task_uuid" value="{{ task_uuid }}" />
|
||||
<button type="submit" class="btn btn-danger" id="delete-btn">Ja, löschen</button>
|
||||
<a href="{% url 'frontend:container_detail' container.internal_id %}" class="btn btn-secondary">Abbrechen</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Progress Overlay -->
|
||||
<div id="task-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 9999; color: white;">
|
||||
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #1e1e1e; padding: 30px; border-radius: 10px; min-width: 500px; max-width: 700px;">
|
||||
<h4>Deleting Container...</h4>
|
||||
<div class="task-info">
|
||||
<small>Task UUID: <span id="task-uuid"></span></small>
|
||||
</div>
|
||||
<div id="task-logs" style="background: #000; padding: 15px; border-radius: 5px; margin: 15px 0; max-height: 400px; overflow-y: auto; font-family: 'Courier New', monospace; font-size: 12px;">
|
||||
<div class="log-entry">Initializing...</div>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let taskMonitorInterval = null;
|
||||
|
||||
$(document).ready(function () {
|
||||
// Form submission with async task monitoring
|
||||
$('form').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
$('#delete-btn').prop('disabled', true).text('Lösche Container...');
|
||||
|
||||
// Submit form via AJAX
|
||||
$.ajax({
|
||||
url: $(this).attr('action'),
|
||||
method: 'POST',
|
||||
data: $(this).serialize(),
|
||||
success: function(response) {
|
||||
if (response.status === 'task_started') {
|
||||
// Show overlay and start monitoring
|
||||
$('#task-uuid').text(response.task_uuid);
|
||||
$('#task-overlay').show();
|
||||
startTaskMonitoring(response.task_uuid);
|
||||
} else {
|
||||
alert('Error: ' + response.message);
|
||||
$('#delete-btn').prop('disabled', false).text('Ja, löschen');
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
let errorMsg = 'Network error';
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
errorMsg = response.message || errorMsg;
|
||||
} catch (e) {}
|
||||
|
||||
alert('Error: ' + errorMsg);
|
||||
$('#delete-btn').prop('disabled', false).text('Ja, löschen');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function startTaskMonitoring(taskUuid) {
|
||||
let entryCount = 0;
|
||||
|
||||
taskMonitorInterval = setInterval(function() {
|
||||
$.get('{% url "tasklogger:task_status" %}', {'task_uuid': taskUuid})
|
||||
.done(function(data) {
|
||||
if (data.status === 'success') {
|
||||
const task = data.task;
|
||||
const entries = task.entries || [];
|
||||
|
||||
// Update logs (only new entries)
|
||||
if (entries.length > entryCount) {
|
||||
const newEntries = entries.slice(entryCount);
|
||||
newEntries.forEach(function(entry) {
|
||||
const logEntry = $('<div class="log-entry"></div>');
|
||||
const timestamp = new Date(entry.created_at).toLocaleTimeString();
|
||||
let message = '';
|
||||
|
||||
if (typeof entry.message === 'object') {
|
||||
message = JSON.stringify(entry.message, null, 2);
|
||||
} else {
|
||||
message = entry.message || 'No message';
|
||||
}
|
||||
|
||||
logEntry.html('<span style="color: #666;">' + timestamp + '</span> ' + message);
|
||||
$('#task-logs').append(logEntry);
|
||||
});
|
||||
|
||||
// Scroll to bottom
|
||||
$('#task-logs').scrollTop($('#task-logs')[0].scrollHeight);
|
||||
entryCount = entries.length;
|
||||
}
|
||||
|
||||
// Check if task completed
|
||||
if (task.status === 'completed') {
|
||||
clearInterval(taskMonitorInterval);
|
||||
$('.progress-bar').removeClass('progress-bar-animated').addClass('bg-success').css('width', '100%');
|
||||
|
||||
setTimeout(function() {
|
||||
window.location.href = "{% url 'frontend:dashboard' %}";
|
||||
}, 2000);
|
||||
} else if (task.status === 'error') {
|
||||
clearInterval(taskMonitorInterval);
|
||||
$('.progress-bar').removeClass('progress-bar-animated').addClass('bg-danger').css('width', '100%');
|
||||
|
||||
// Add close button
|
||||
$('#task-overlay .progress').after('<button class="btn btn-danger mt-3" onclick="window.location.href=\'{% url "frontend:dashboard" %}\'">Close</button>');
|
||||
}
|
||||
}
|
||||
})
|
||||
.fail(function() {
|
||||
console.error('Failed to get task status');
|
||||
});
|
||||
}, 1000); // Check every second
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
75
frontend/templates/frontend/dns_delete.html
Normal file
75
frontend/templates/frontend/dns_delete.html
Normal file
@@ -0,0 +1,75 @@
|
||||
{% extends 'frontend/base.html' %}
|
||||
|
||||
{% block title %} - Delete DNS Entry{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-danger">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-trash"></i>
|
||||
Delete DNS Entry
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
<strong>Warning!</strong> This action cannot be undone.
|
||||
</div>
|
||||
|
||||
<p>Are you sure you want to delete the following DNS entry?</p>
|
||||
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Type:</dt>
|
||||
<dd class="col-sm-9">
|
||||
{% if dns_entry.name %}
|
||||
<span class="badge bg-primary">DNS Name</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info">RegExp Pattern</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">
|
||||
{% if dns_entry.name %}Name:{% else %}Pattern:{% endif %}
|
||||
</dt>
|
||||
<dd class="col-sm-9">
|
||||
{% if dns_entry.name %}
|
||||
<strong>{{ dns_entry.name }}</strong>
|
||||
{% else %}
|
||||
<code>{{ dns_entry.regexp }}</code>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">IP Address:</dt>
|
||||
<dd class="col-sm-9">
|
||||
<span class="badge bg-secondary">{{ dns_entry.address }}</span>
|
||||
</dd>
|
||||
|
||||
{% if dns_entry.comment %}
|
||||
<dt class="col-sm-3">Comment:</dt>
|
||||
<dd class="col-sm-9">{{ dns_entry.comment }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="mt-4">
|
||||
{% csrf_token %}
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{% url 'frontend:dns_list' %}" class="btn btn-outline-secondary me-md-2">
|
||||
<i class="bi bi-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash"></i> Delete DNS Entry
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
266
frontend/templates/frontend/dns_form.html
Normal file
266
frontend/templates/frontend/dns_form.html
Normal file
@@ -0,0 +1,266 @@
|
||||
{% extends 'frontend/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %} - {{ title }}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ block.super }}
|
||||
<!-- Select2 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
<style>
|
||||
.form-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-section h5 {
|
||||
color: #495057;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.exclusive-fields {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.select2-container {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-book"></i>
|
||||
{{ title }}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- IP Address Section -->
|
||||
<div class="form-section">
|
||||
<h5><i class="bi bi-router"></i> IP Address</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.container.id_for_label }}" class="form-label fw-bold">
|
||||
{{ form.container.label }}
|
||||
</label>
|
||||
{{ form.container }}
|
||||
{% if form.container.help_text %}
|
||||
<div class="form-text">{{ form.container.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.container.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.container.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-center my-3">
|
||||
<strong>OR</strong>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.address.id_for_label }}" class="form-label fw-bold">
|
||||
{{ form.address.label }}
|
||||
</label>
|
||||
{{ form.address }}
|
||||
{% if form.address.help_text %}
|
||||
<div class="form-text">{{ form.address.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.address.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.address.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DNS Configuration Section -->
|
||||
<div class="form-section">
|
||||
<h5><i class="bi bi-dns"></i> DNS Configuration</h5>
|
||||
|
||||
<div class="exclusive-fields">
|
||||
<strong>Choose ONE of the following:</strong>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.name.id_for_label }}" class="form-label fw-bold">
|
||||
{{ form.name.label }}
|
||||
</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.help_text %}
|
||||
<div class="form-text">{{ form.name.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.name.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.name.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-center my-3">
|
||||
<strong>OR</strong>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.regexp.id_for_label }}" class="form-label fw-bold">
|
||||
{{ form.regexp.label }}
|
||||
</label>
|
||||
{{ form.regexp }}
|
||||
{% if form.regexp.help_text %}
|
||||
<div class="form-text">{{ form.regexp.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.regexp.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.regexp.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comment Section -->
|
||||
<div class="form-section">
|
||||
<h5><i class="bi bi-chat-text"></i> Additional Information</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.comment.id_for_label }}" class="form-label fw-bold">
|
||||
{{ form.comment.label }}
|
||||
</label>
|
||||
{{ form.comment }}
|
||||
{% if form.comment.help_text %}
|
||||
<div class="form-text">{{ form.comment.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.comment.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.comment.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Validation Errors -->
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger">
|
||||
<strong>Please correct the following errors:</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
{% for error in form.non_field_errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{% url 'frontend:dns_list' %}" class="btn btn-outline-secondary me-md-2">
|
||||
<i class="bi bi-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> {{ submit_text }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- jQuery and Select2 JS -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Initialize Select2 for container selection
|
||||
$('.container-select').select2({
|
||||
placeholder: 'Search containers...',
|
||||
allowClear: true,
|
||||
ajax: {
|
||||
url: '{% url "frontend:dns_container_api" %}',
|
||||
dataType: 'json',
|
||||
delay: 250,
|
||||
data: function (params) {
|
||||
return {
|
||||
search: params.term,
|
||||
page: params.page || 1
|
||||
};
|
||||
},
|
||||
processResults: function (data) {
|
||||
return {
|
||||
results: data.results
|
||||
};
|
||||
},
|
||||
cache: true
|
||||
},
|
||||
minimumInputLength: 0,
|
||||
});
|
||||
|
||||
// Auto-fill IP address when container is selected
|
||||
$('.container-select').on('select2:select', function (e) {
|
||||
var data = e.params.data;
|
||||
if (data.address) {
|
||||
$('#id_address').val(data.address);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear IP address when container selection is cleared
|
||||
$('.container-select').on('select2:clear', function (e) {
|
||||
$('#id_address').val('');
|
||||
});
|
||||
|
||||
// Mutual exclusion for name/regexp fields
|
||||
$('#id_name').on('input', function() {
|
||||
if ($(this).val()) {
|
||||
$('#id_regexp').prop('disabled', true);
|
||||
} else {
|
||||
$('#id_regexp').prop('disabled', false);
|
||||
}
|
||||
});
|
||||
|
||||
$('#id_regexp').on('input', function() {
|
||||
if ($(this).val()) {
|
||||
$('#id_name').prop('disabled', true);
|
||||
} else {
|
||||
$('#id_name').prop('disabled', false);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize mutual exclusion on page load
|
||||
if ($('#id_name').val()) {
|
||||
$('#id_regexp').prop('disabled', true);
|
||||
}
|
||||
if ($('#id_regexp').val()) {
|
||||
$('#id_name').prop('disabled', true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
144
frontend/templates/frontend/dns_list.html
Normal file
144
frontend/templates/frontend/dns_list.html
Normal file
@@ -0,0 +1,144 @@
|
||||
{% extends 'frontend/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %} - DNS Management{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
.dns-type-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.dns-name { background-color: #e3f2fd; color: #1565c0; }
|
||||
.dns-regexp { background-color: #f3e5f5; color: #7b1fa2; }
|
||||
|
||||
.search-form {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h3>
|
||||
<i class="bi bi-book"></i> DNS Management
|
||||
</h3>
|
||||
<a href="{% url 'frontend:dns_create' %}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add DNS Entry
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% include 'frontend/includes/dns_pagination_snippet.html' %}
|
||||
|
||||
<!-- DNS Entries Table -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% if page_obj.object_list %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>DNS Name/Pattern</th>
|
||||
<th>IP Address</th>
|
||||
<th>Comment</th>
|
||||
<th>Created</th>
|
||||
<th width="120">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for dns in page_obj %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if dns.name %}
|
||||
<span class="badge dns-name dns-type-badge">Name</span>
|
||||
{% else %}
|
||||
<span class="badge dns-regexp dns-type-badge">RegExp</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if dns.name %}
|
||||
<strong>{{ dns.name }}</strong>
|
||||
{% else %}
|
||||
<code>{{ dns.regexp }}</code>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">{{ dns.address }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">{{ dns.comment|default:"-" }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{% if dns.created_at %}
|
||||
{{ dns.created_at|date:"M d, Y" }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if dns.pk not in dev_container_dns %}
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{% url 'frontend:dns_edit' dns.id %}"
|
||||
class="btn btn-sm btn-outline-warning"
|
||||
title="Edit DNS Entry">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="{% url 'frontend:dns_delete' dns.id %}"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
title="Delete DNS Entry">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-globe" style="font-size: 3rem; color: #6c757d;"></i>
|
||||
<h4 class="mt-3 text-muted">No DNS Entries Found</h4>
|
||||
<p class="text-muted">
|
||||
{% if request.GET.search %}
|
||||
No DNS entries match your search criteria.
|
||||
{% else %}
|
||||
Start by creating your first DNS entry.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if not request.GET.search %}
|
||||
<a href="{% url 'frontend:dns_create' %}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Create First DNS Entry
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'frontend/includes/dns_pagination_snippet.html' %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Auto-submit search form on type filter change
|
||||
document.getElementById('entryTypeSelect').addEventListener('change', function() {
|
||||
this.form.submit();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
69
frontend/templates/frontend/edit_container.html
Normal file
69
frontend/templates/frontend/edit_container.html
Normal file
@@ -0,0 +1,69 @@
|
||||
<!-- frontend/templates/frontend/edit_container.html -->
|
||||
{% extends 'frontend/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %} - Edit Container{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-10">
|
||||
<h4>Edit {{ container.name }}</h4>
|
||||
</div>
|
||||
<div class="col-12 col-md-2 text-end">
|
||||
<a href="{% url 'frontend:create_container' %}?clone_lxc={{ container.lxc.pk }}"
|
||||
class="btn btn-secondary">
|
||||
<i class="bi bi-copy"> Clone</i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="task_uuid" value="{{ task_uuid }}"/>
|
||||
|
||||
<div class="row">
|
||||
{% for field in form %}
|
||||
<div class="col-{% if field.name in 'lxcdnslease' %}12 mb-3{% else %}4{% endif %}">
|
||||
<label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
|
||||
<input type="hidden" value="{{ field.value }}" name="{{ field.name }}"/>
|
||||
{{ field }}
|
||||
{% if field.help_text %}
|
||||
<div class="form-text">{{ field.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in field.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary" id="save-btn">Speichern</button>
|
||||
<a href="{% url 'frontend:container_detail' container.internal_id %}" class="btn btn-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
// Form blocking - disable submit button on form submission
|
||||
$('form').on('submit', function () {
|
||||
$('#save-btn').prop('disabled', true).text('Aktualisiere Container...');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
52
frontend/templates/frontend/faq_delete.html
Normal file
52
frontend/templates/frontend/faq_delete.html
Normal file
@@ -0,0 +1,52 @@
|
||||
{% extends 'frontend/base.html' %}
|
||||
{% load markdown_filters %}
|
||||
|
||||
{% block title %} - Delete FAQ Entry{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h3>
|
||||
<i class="bi bi-trash"></i> Delete FAQ Entry
|
||||
</h3>
|
||||
<a href="{% url 'frontend:faq_list' %}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to FAQ List
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
<h5><i class="bi bi-exclamation-triangle"></i> Confirm Deletion</h5>
|
||||
<p>Are you sure you want to delete this FAQ entry? This action cannot be undone.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h5 class="text-muted">FAQ Entry Details:</h5>
|
||||
<div class="border rounded p-3 bg-light">
|
||||
<h6><strong>Title:</strong></h6>
|
||||
<p>{{ faq.title }}</p>
|
||||
|
||||
<h6><strong>Content:</strong></h6>
|
||||
<div style="max-height: 200px; overflow-y: auto;">{{ faq.content|markdown }}</div>
|
||||
|
||||
<h6><strong>Order:</strong></h6>
|
||||
<p>{{ faq.order }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'frontend:faq_list' %}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash"></i> Delete FAQ Entry
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
92
frontend/templates/frontend/faq_form.html
Normal file
92
frontend/templates/frontend/faq_form.html
Normal file
@@ -0,0 +1,92 @@
|
||||
{% extends 'frontend/base.html' %}
|
||||
|
||||
{% block title %} - {{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h3>
|
||||
<i class="bi bi-question-circle"></i> {{ title }}
|
||||
</h3>
|
||||
<a href="{% url 'frontend:faq_list' %}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to FAQ List
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.title.id_for_label }}" class="form-label fw-bold">
|
||||
{{ form.title.label }}
|
||||
</label>
|
||||
{{ form.title }}
|
||||
{% if form.title.help_text %}
|
||||
<div class="form-text">{{ form.title.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.title.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.title.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.content.id_for_label }}" class="form-label fw-bold">
|
||||
{{ form.content.label }}
|
||||
</label>
|
||||
{{ form.content }}
|
||||
{% if form.content.help_text %}
|
||||
<div class="form-text">{{ form.content.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.content.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.content.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.order.id_for_label }}" class="form-label fw-bold">
|
||||
{{ form.order.label }}
|
||||
</label>
|
||||
{{ form.order }}
|
||||
{% if form.order.help_text %}
|
||||
<div class="form-text">{{ form.order.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.order.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.order.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger">
|
||||
{% for error in form.non_field_errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'frontend:faq_list' %}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> {{ submit_text }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
153
frontend/templates/frontend/faq_list.html
Normal file
153
frontend/templates/frontend/faq_list.html
Normal file
@@ -0,0 +1,153 @@
|
||||
{% extends 'frontend/base.html' %}
|
||||
{% load markdown_filters %}
|
||||
|
||||
{% block title %} - FAQ{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
.faq-item {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
background: #fff;
|
||||
}
|
||||
.faq-header {
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.faq-header:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
.faq-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
.faq-content h1, .faq-content h2, .faq-content h3,
|
||||
.faq-content h4, .faq-content h5, .faq-content h6 {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.faq-content p {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.faq-content code {
|
||||
background-color: #f8f9fa;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.faq-content pre {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 5px;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.faq-content blockquote {
|
||||
border-left: 4px solid #007bff;
|
||||
padding-left: 1rem;
|
||||
margin-left: 0;
|
||||
font-style: italic;
|
||||
color: #6c757d;
|
||||
}
|
||||
.faq-content ul, .faq-content ol {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
.faq-category-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h3>
|
||||
<i class="bi bi-question-circle"></i> FAQ - Frequently Asked Questions
|
||||
</h3>
|
||||
<a href="{% url 'frontend:faq_create' %}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add FAQ Entry
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- FAQ Items -->
|
||||
<div class="row">
|
||||
<form action="{% url 'frontend:faq_list' %}" method="GET" class="col-12">
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" placeholder="Search FAQ Entries" name="search" value="{{ request.GET.search }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% if faqs %}
|
||||
<div class="faq-container">
|
||||
{% for faq in faqs %}
|
||||
<div class="faq-item">
|
||||
<div class="faq-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center flex-grow-1" onclick="toggleFAQ({{ faq.pk }})" style="cursor: pointer;">
|
||||
<h5 class="mb-0 me-2">{{ faq.title }}</h5>
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="btn-group me-2" role="group">
|
||||
<a href="{% url 'frontend:faq_edit' faq.pk %}"
|
||||
class="btn btn-sm btn-outline-warning"
|
||||
title="Edit FAQ">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="{% url 'frontend:faq_delete' faq.pk %}"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
title="Delete FAQ">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
</div>
|
||||
<i class="bi bi-chevron-down" id="icon-{{ faq.pk }}" onclick="toggleFAQ({{ faq.pk }})" style="cursor: pointer;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="faq-content collapse" id="faq-{{ faq.pk }}">
|
||||
{{ faq.content|markdown }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-question-circle" style="font-size: 3rem; color: #6c757d;"></i>
|
||||
<h4 class="mt-3 text-muted">No FAQ Entries Available</h4>
|
||||
<p class="text-muted">
|
||||
FAQ entries will appear here once they are added by an administrator.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function toggleFAQ(faqId) {
|
||||
const content = document.getElementById('faq-' + faqId);
|
||||
const icon = document.getElementById('icon-' + faqId);
|
||||
|
||||
if (content.classList.contains('show')) {
|
||||
content.classList.remove('show');
|
||||
icon.classList.remove('bi-chevron-up');
|
||||
icon.classList.add('bi-chevron-down');
|
||||
} else {
|
||||
content.classList.add('show');
|
||||
icon.classList.remove('bi-chevron-down');
|
||||
icon.classList.add('bi-chevron-up');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,81 @@
|
||||
<!-- DNS Pagination -->
|
||||
<nav aria-label="DNS-Paginierung" class="mt-4 search-form">
|
||||
<ul class="pagination justify-content-left">
|
||||
<div class="d-flex align-items-center mb-3 me-3 flex-wrap" style="width: 100%">
|
||||
<form action="{% url 'frontend:dns_list' %}" method="get" class="me-3" style="flex: 1;">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" placeholder="Search DNS entries..." name="search"
|
||||
value="{{ request.GET.search }}"/>
|
||||
<button type="submit" class="btn btn-outline-secondary" title="Search"><i class="bi bi-search"></i>
|
||||
</button>
|
||||
{% if request.GET.search %}
|
||||
<a href="{% url 'frontend:dns_list' %}{% if request.GET.entry_type %}?entry_type={{ request.GET.entry_type }}{% endif %}"
|
||||
class="btn btn-outline-secondary" title="Clear Search"><i class="bi bi-trash"></i></a>
|
||||
{% endif %}
|
||||
<a class="btn btn-outline-secondary" style="font-weight: bold;color:darkred">{{ dns_entries.count }}</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="filterForm" action="{% url 'frontend:dns_list' %}" method="GET" class="d-flex align-items-center">
|
||||
<select name="entry_type" class="form-select me-2" id="entryTypeSelect" onchange="this.form.submit()">
|
||||
<option value="" {% if not request.GET.entry_type %}selected{% endif %}>All Types</option>
|
||||
<option value="name" {% if request.GET.entry_type == 'name' %}selected{% endif %}>Names only</option>
|
||||
<option value="regexp" {% if request.GET.entry_type == 'regexp' %}selected{% endif %}>RegExp only</option>
|
||||
</select>
|
||||
{% if request.GET.search %}
|
||||
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
||||
{% endif %}
|
||||
{% if request.GET.page %}
|
||||
<input type="hidden" name="page" value="{{ request.GET.page }}">
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="?page=1{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.entry_type %}&entry_type={{ request.GET.entry_type }}{% endif %}"
|
||||
aria-label="First">
|
||||
<span aria-hidden="true">««</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.entry_type %}&entry_type={{ request.GET.entry_type }}{% endif %}"
|
||||
aria-label="Prev">
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">««</span></li>
|
||||
<li class="page-item disabled"><span class="page-link">«</span></li>
|
||||
{% endif %}
|
||||
|
||||
{% for i in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == i %}
|
||||
<li class="page-item active"><span class="page-link">{{ i }}</span></li>
|
||||
{% elif i > page_obj.number|add:'-3' and i < page_obj.number|add:'3' %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ i }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.entry_type %}&entry_type={{ request.GET.entry_type }}{% endif %}">{{ i }}</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.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.entry_type %}&entry_type={{ request.GET.entry_type }}{% endif %}"
|
||||
aria-label="Next">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.entry_type %}&entry_type={{ request.GET.entry_type }}{% endif %}"
|
||||
aria-label="Last">
|
||||
<span aria-hidden="true">»»</span>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">»</span></li>
|
||||
<li class="page-item disabled"><span class="page-link">»»</span></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
95
frontend/templates/frontend/includes/pagination_snippet.html
Normal file
95
frontend/templates/frontend/includes/pagination_snippet.html
Normal file
@@ -0,0 +1,95 @@
|
||||
<!-- Pagination -->
|
||||
<nav aria-label="Container-Paginierung" class="mt-4 search-form">
|
||||
<ul class="pagination justify-content-left">
|
||||
<div class="d-flex align-items-center mb-3 me-3 flex-wrap" style="width: 100%">
|
||||
<!-- Suchfeld -->
|
||||
<form action="{{ search_action_url }}" method="get" class="me-3" style="flex: 1;">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" placeholder="Search" name="search"
|
||||
value="{{ request.GET.search }}"/>
|
||||
<button type="submit" class="btn btn-outline-secondary" title="Search"><i class="bi bi-search"></i>
|
||||
</button>
|
||||
{% if request.GET.search %}<a href="{% url 'frontend:dashboard' %}{% if request.GET.lxc_status %}?lxc_status={{ request.GET.lxc_status }}{% endif %}{% if request.GET.network_status %}{% if request.GET.lxc_status %}&{% else %}?{% endif %}network_status={{ request.GET.network_status }}{% endif %}"
|
||||
class="btn btn-outline-secondary" title="Clear Search"><i class="bi bi-trash"></i></a>{% endif %}
|
||||
<a class="btn btn-outline-secondary" style="font-weight: bold;color:darkred">{{ containers.count }}</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
<!-- Filter als Select-Boxen mit auto-submit -->
|
||||
<form id="filterForm" action="{{ search_action_url }}" method="GET" class="d-flex align-items-center">
|
||||
<select name="lxc_status" class="form-select me-2" id="lxcStatusSelect" onchange="this.form.submit()">
|
||||
<option value="" {% if not request.GET.lxc_status %}selected{% endif %}>All LXC</option>
|
||||
<option value="running" {% if request.GET.lxc_status == 'running' %}selected{% endif %}>Running Lxc</option>
|
||||
<option value="stopped" {% if request.GET.lxc_status == 'stopped' %}selected{% endif %}>Stopped Lxc</option>
|
||||
</select>
|
||||
<select name="network_status" class="form-select" id="networkStatusSelect"
|
||||
onchange="this.form.submit()">
|
||||
<option value="" {% if not request.GET.network_status %}selected{% endif %}>All Net</option>
|
||||
<option value="bound" {% if request.GET.network_status == 'bound' %}selected{% endif %}> Bound </option>
|
||||
<option value="waiting" {% if request.GET.network_status == 'waiting' %}selected{% endif %}>Waiting</option>
|
||||
</select>
|
||||
{% if request.GET.search %}
|
||||
<input type="hidden" name="search" value="{{ request.GET.search }}">{% endif %}
|
||||
{% if request.GET.page %}<input type="hidden" name="page" value="{{ request.GET.page }}">{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="?page=1{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.lxc_status %}&lxc_status={{ request.GET.lxc_status }}{% endif %}{% if request.GET.network_status %}&network_status={{ request.GET.network_status }}{% endif %}"
|
||||
aria-label="First">
|
||||
<span aria-hidden="true">««</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.lxc_status %}&lxc_status={{ request.GET.lxc_status }}{% endif %}{% if request.GET.network_status %}&network_status={{ request.GET.network_status }}{% endif %}"
|
||||
aria-label="Prev">
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">««</span></li>
|
||||
<li class="page-item disabled"><span class="page-link">«</span></li>
|
||||
{% endif %}
|
||||
|
||||
{% for i in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == i %}
|
||||
<li class="page-item active"><span class="page-link">{{ i }}</span></li>
|
||||
{% elif i > page_obj.number|add:'-3' and i < page_obj.number|add:'3' %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ i }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.lxc_status %}&lxc_status={{ request.GET.lxc_status }}{% endif %}{% if request.GET.network_status %}&network_status={{ request.GET.network_status }}{% endif %}">{{ i }}</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.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.lxc_status %}&lxc_status={{ request.GET.lxc_status }}{% endif %}{% if request.GET.network_status %}&network_status={{ request.GET.network_status }}{% endif %}"
|
||||
aria-label="Next">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.lxc_status %}&lxc_status={{ request.GET.lxc_status }}{% endif %}{% if request.GET.network_status %}&network_status={{ request.GET.network_status }}{% endif %}"
|
||||
aria-label="Last">
|
||||
<span aria-hidden="true">»»</span>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">»</span></li>
|
||||
<li class="page-item disabled"><span class="page-link">»»</span></li>
|
||||
{% endif %}
|
||||
<div class="d-flex align-items-center mb-3 flex-wrap ms-2">
|
||||
|
||||
<!-- Ansichtsumschalter (Client-seitig per JS) -->
|
||||
<div class="btn-group me-3" role="group">
|
||||
<button type="button" id="cardViewBtn" class="btn btn-outline-secondary active"><i
|
||||
class="bi bi-grid-3x3-gap"></i></button>
|
||||
<button type="button" id="listViewBtn" class="btn btn-outline-secondary"><i class="bi bi-list-ul"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ul>
|
||||
</nav>
|
||||
30
frontend/templates/frontend/login.html
Normal file
30
frontend/templates/frontend/login.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!-- frontend/templates/frontend/login.html -->
|
||||
{% extends 'frontend/base.html' %}
|
||||
|
||||
{% block title %} - Login{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>Anmelden</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Benutzername</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Passwort</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
142
frontend/templates/frontend/lxc_console.html
Normal file
142
frontend/templates/frontend/lxc_console.html
Normal file
@@ -0,0 +1,142 @@
|
||||
{% extends 'frontend/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %} - Console: {{ container.name }}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
.console-container {
|
||||
height: 80vh;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.console-header {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 10px 15px;
|
||||
border-bottom: 1px solid #444;
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.console-info {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.console-controls {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.console-iframe {
|
||||
width: 100%;
|
||||
height: calc(100% - 50px);
|
||||
border: none;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.console-status {
|
||||
color: #28a745;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.console-warning {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>
|
||||
<i class="bi bi-terminal"></i>
|
||||
Console: {{ container.name }}
|
||||
<small class="text-muted">({{ container.address }})</small>
|
||||
</h2>
|
||||
<a href="{% url 'frontend:dashboard' %}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="console-warning">
|
||||
<h5><i class="bi bi-exclamation-triangle text-warning"></i> Console Access</h5>
|
||||
<p class="mb-0">
|
||||
This opens a direct console connection to the LXC container <strong>{{ container.name }}</strong> (VM-ID: {{ container.lxc.vmid }}).
|
||||
The console connection is handled through the Proxmox web interface.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="console-container">
|
||||
<div class="console-header">
|
||||
<div class="console-info">
|
||||
<span class="console-status">●</span>
|
||||
{{ container.hostname }} | VM-ID: {{ container.lxc.vmid }} | IP: {{ container.address }}
|
||||
</div>
|
||||
<div class="console-controls">
|
||||
<button class="btn btn-sm btn-outline-light" onclick="reloadConsole()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Reload
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-light" onclick="openInNewTab()">
|
||||
<i class="bi bi-box-arrow-up-right"></i> New Tab
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
id="console-iframe"
|
||||
class="console-iframe"
|
||||
src="https://{{ proxmox_host }}:8006/?console=lxc&vmid={{ container.lxc.vmid }}&node={{ container.lxc.node }}"
|
||||
title="LXC Console for {{ container.name }}">
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="alert alert-info">
|
||||
<h6><i class="bi bi-info-circle"></i> Console Help</h6>
|
||||
<ul class="mb-0">
|
||||
<li>This console provides direct terminal access to your LXC container</li>
|
||||
<li>You may need to press Enter to activate the console</li>
|
||||
<li>Use Ctrl+Alt+Del to send reset signal (if supported)</li>
|
||||
<li>If the console doesn't load, try refreshing or opening in a new tab</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function reloadConsole() {
|
||||
const iframe = document.getElementById('console-iframe');
|
||||
iframe.src = iframe.src;
|
||||
}
|
||||
|
||||
function openInNewTab() {
|
||||
const iframe = document.getElementById('console-iframe');
|
||||
window.open(iframe.src, '_blank');
|
||||
}
|
||||
|
||||
// Auto-reload console every 30 seconds to keep session alive
|
||||
setInterval(function() {
|
||||
const iframe = document.getElementById('console-iframe');
|
||||
// Ping the iframe to keep session alive without full reload
|
||||
try {
|
||||
iframe.contentWindow.postMessage('ping', '*');
|
||||
} catch (e) {
|
||||
// Cross-origin, ignore
|
||||
}
|
||||
}, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
163
frontend/templates/frontend/lxc_console_popup.html
Normal file
163
frontend/templates/frontend/lxc_console_popup.html
Normal file
@@ -0,0 +1,163 @@
|
||||
{% extends 'frontend/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %} - Console: {{ container.name }}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
.console-launch {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.console-icon {
|
||||
font-size: 64px;
|
||||
color: #28a745;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.console-info {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.btn-console {
|
||||
background: linear-gradient(45deg, #28a745, #20c997);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 30px;
|
||||
font-size: 18px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 15px rgba(40, 167, 69, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-console:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(40, 167, 69, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.console-warning {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-terminal"></i>
|
||||
LXC Console Access
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="console-launch">
|
||||
<div class="console-icon">
|
||||
<i class="bi bi-terminal-fill"></i>
|
||||
</div>
|
||||
|
||||
<h5>{{ container.name }}</h5>
|
||||
<p class="text-muted">VM-ID: {{ container.lxc.vmid }} | IP: {{ container.address }}</p>
|
||||
|
||||
<div class="console-info">
|
||||
<h6><i class="bi bi-info-circle text-info"></i> Console Information</h6>
|
||||
<div class="row text-start">
|
||||
<div class="col-sm-4"><strong>Hostname:</strong></div>
|
||||
<div class="col-sm-8">{{ container.hostname }}</div>
|
||||
<div class="col-sm-4"><strong>Node:</strong></div>
|
||||
<div class="col-sm-8">{{ container.lxc.node }}</div>
|
||||
<div class="col-sm-4"><strong>Status:</strong></div>
|
||||
<div class="col-sm-8">
|
||||
<span class="badge bg-{{ container.lxc.status|yesno:'success,danger' }}">
|
||||
{{ container.lxc.status|title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="openConsole" class="btn btn-console">
|
||||
<i class="bi bi-box-arrow-up-right"></i>
|
||||
Open Console in New Window
|
||||
</button>
|
||||
|
||||
<div class="console-warning mt-3">
|
||||
<h6><i class="bi bi-exclamation-triangle text-warning"></i> Important Notes</h6>
|
||||
<ul class="text-start mb-0">
|
||||
<li>The console will open in the Proxmox web interface</li>
|
||||
<li>You will need to login to Proxmox if not already authenticated</li>
|
||||
<li>Please allow popups for this site if blocked</li>
|
||||
<li>Navigate to the container console once in Proxmox</li>
|
||||
<li>Alternatively, you can manually access: <code>https://{{ proxmox_host }}:8006</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<a href="{% url 'frontend:dashboard' %}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Dashboard
|
||||
</a>
|
||||
<a href="{% url 'frontend:container_detail' container.pk %}" class="btn btn-info ms-2">
|
||||
<i class="bi bi-info-circle"></i> Container Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.getElementById('openConsole').addEventListener('click', function() {
|
||||
const consoleUrl = '{{ console_url|escapejs }}';
|
||||
|
||||
// Open console in popup window
|
||||
const popup = window.open(
|
||||
consoleUrl,
|
||||
'lxc-console-{{ container.lxc.vmid }}',
|
||||
'width=1024,height=768,scrollbars=yes,resizable=yes,status=yes,toolbar=no,menubar=no'
|
||||
);
|
||||
|
||||
if (!popup) {
|
||||
alert('Popup blocked! Please allow popups for this site and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus the popup
|
||||
popup.focus();
|
||||
|
||||
// Show success message
|
||||
this.innerHTML = '<i class="bi bi-check-circle"></i> Console Opened';
|
||||
this.disabled = true;
|
||||
this.classList.remove('btn-console');
|
||||
this.classList.add('btn', 'btn-success');
|
||||
|
||||
// Re-enable button after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> Open Console in New Window';
|
||||
this.disabled = false;
|
||||
this.classList.remove('btn', 'btn-success');
|
||||
this.classList.add('btn-console');
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
// Auto-open console if requested
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('autoopen') === 'true') {
|
||||
document.getElementById('openConsole').click();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
1
frontend/templatetags/__init__.py
Normal file
1
frontend/templatetags/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# frontend/templatetags/__init__.py
|
||||
29
frontend/templatetags/markdown_filters.py
Normal file
29
frontend/templatetags/markdown_filters.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import markdown
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.filter(name='markdown')
|
||||
def markdown_format(text):
|
||||
"""
|
||||
Convert markdown text to HTML
|
||||
Usage: {{ content|markdown }}
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Configure markdown with useful extensions
|
||||
md = markdown.Markdown(extensions=[
|
||||
'extra', # Tables, fenced code blocks, etc.
|
||||
'codehilite', # Syntax highlighting
|
||||
'toc', # Table of contents
|
||||
'nl2br' # Convert newlines to <br>
|
||||
], extension_configs={
|
||||
'codehilite': {
|
||||
'css_class': 'highlight',
|
||||
'use_pygments': True
|
||||
}
|
||||
})
|
||||
|
||||
return mark_safe(md.convert(text))
|
||||
32
frontend/urls.py
Normal file
32
frontend/urls.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# frontend/urls.py
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'frontend'
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', views.login_view, name='login'),
|
||||
path('logout/', views.logout_view, name='logout'),
|
||||
path('', views.dashboard, name='dashboard'),
|
||||
path('current-containers/', views.container_details, name='current_container_details'),
|
||||
path('container/<int:container_id>/', views.container_detail, name='container_detail'),
|
||||
path('container/<int:container_id>/edit/', views.edit_container, name='edit_container'),
|
||||
path('container/<int:container_id>/delete/', views.delete_container, name='delete_container'),
|
||||
path('container/<int:id>/stop/', views.stop_lxc, name='stop_lxc'),
|
||||
path('container/<int:id>/start/', views.start_lxc, name='start_lxc'),
|
||||
path('container/create/', views.create_container, name='create_container'),
|
||||
# DNS Management
|
||||
path('dns/', views.dns_list, name='dns_list'),
|
||||
path('dns/create/', views.dns_create, name='dns_create'),
|
||||
path('dns/<str:dns_id>/edit/', views.dns_edit, name='dns_edit'),
|
||||
path('dns/<str:dns_id>/delete/', views.dns_delete, name='dns_delete'),
|
||||
path('api/containers/', views.dns_container_api, name='dns_container_api'),
|
||||
# FAQ
|
||||
path('faq/', views.faq_list, name='faq_list'),
|
||||
path('faq/raw/', views.faq_raw, name='faq_raw'),
|
||||
path('faq/raw/<int:id>/', views.faq_raw, name='faq_raw_single'),
|
||||
path('faq/create/', views.faq_create, name='faq_create'),
|
||||
path('faq/<int:faq_id>/edit/', views.faq_edit, name='faq_edit'),
|
||||
path('faq/<int:faq_id>/delete/', views.faq_delete, name='faq_delete'),
|
||||
]
|
||||
637
frontend/views.py
Normal file
637
frontend/views.py
Normal file
@@ -0,0 +1,637 @@
|
||||
import json
|
||||
import threading
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import authenticate, login, logout
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.forms import model_to_dict
|
||||
from django.http import HttpResponse, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
from django_proxmox_mikrotik.settings import ProxmoxConfig
|
||||
from lib.decorators import readonly
|
||||
from lib.proxmox import Proxmox
|
||||
from lib.task_decorator import (
|
||||
create_container_with_task,
|
||||
delete_container_with_task,
|
||||
resize_container_disk_with_task,
|
||||
start_container_with_task,
|
||||
stop_container_with_task,
|
||||
update_container_config_sync,
|
||||
)
|
||||
from lib.utils import paginator
|
||||
from manager.models import DevContainer
|
||||
from mikrotik.models import DNSStatic
|
||||
from proxmox.models import Lxc, LxcTemplate
|
||||
from tasklogger.models import TaskFactory
|
||||
from .forms import CloneContainerForm, DNSSearchForm, DNSStaticForm, DevContainerForm, FAQForm
|
||||
from .models import FAQ, UserProfile
|
||||
from .permissions import user_can_access_container
|
||||
|
||||
|
||||
# Oben in den Imports hinzufügen:
|
||||
|
||||
|
||||
def login_view(request):
|
||||
TaskFactory.reset_current_task(request=request)
|
||||
if request.method == 'POST':
|
||||
username = request.POST.get('username')
|
||||
password = request.POST.get('password')
|
||||
user = authenticate(request, username=username, password=password)
|
||||
|
||||
if user is not None:
|
||||
login(request, user)
|
||||
if next_url := request.GET.get('next'):
|
||||
return redirect(next_url)
|
||||
else:
|
||||
return redirect('frontend:dashboard')
|
||||
else:
|
||||
messages.error(request, 'Ungültige Anmeldedaten')
|
||||
|
||||
return render(request, 'frontend/login.html')
|
||||
|
||||
|
||||
def logout_view(request):
|
||||
TaskFactory.reset_current_task(request=request)
|
||||
logout(request)
|
||||
if next_url := request.GET.get('next'):
|
||||
return redirect('/frontend/login?next=' + next_url)
|
||||
return redirect('frontend:login')
|
||||
|
||||
|
||||
@login_required
|
||||
def dashboard(request):
|
||||
TaskFactory.reset_current_task(request=request)
|
||||
try:
|
||||
user_profile = request.user.profile
|
||||
except UserProfile.DoesNotExist:
|
||||
user_profile = UserProfile.objects.create(
|
||||
user=request.user,
|
||||
ldap_uid=request.user.username # Annahme: LDAP-UID = Username
|
||||
)
|
||||
|
||||
searchdomain = Q()
|
||||
if s := request.GET.get('search', ''):
|
||||
searchdomain &= DevContainer.term_filter(s)
|
||||
if user_profile.is_internal():
|
||||
from mikrotik.models import IPAddress
|
||||
ip_addresses = IPAddress.objects.filter(comment__icontains=f' {user_profile.ldap_uid} ')
|
||||
networks = [ip.network for ip in ip_addresses]
|
||||
leasefilter = Q()
|
||||
for nw in networks:
|
||||
leasefilter |= Q(lease__address__startswith=nw)
|
||||
searchdomain &= leasefilter
|
||||
if user_profile.is_external():
|
||||
searchdomain &= Q(lease__address__startswith='172.2')
|
||||
if lxc_status := request.GET.get('lxc_status', ''):
|
||||
searchdomain &= Q(lxc__status=lxc_status)
|
||||
if network_status := request.GET.get('network_status', ''):
|
||||
searchdomain &= Q(lease__status=network_status)
|
||||
|
||||
# Paginierung hinzufügen
|
||||
containers = DevContainer.objects.filter(searchdomain)
|
||||
page_obj = paginator(containers, request=request)
|
||||
default_template = LxcTemplate.objects.filter(is_default_template=True).first()
|
||||
return render(request, 'frontend/dashboard.html', {
|
||||
'containers': containers,
|
||||
'page_obj': page_obj,
|
||||
'user_profile': user_profile,
|
||||
'page_vmids': ','.join(map(str, page_obj.object_list.values_list('lxc__vmid', flat=True))),
|
||||
'page_ids': ','.join(map(str, page_obj.object_list.values_list('internal_id', flat=True))),
|
||||
'proxmox_host': ProxmoxConfig.HOST,
|
||||
'default_template': default_template,
|
||||
})
|
||||
|
||||
|
||||
class ContainerStatus:
|
||||
low_limit = 80
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.cpus = kwargs.get('cpus', 0)
|
||||
self.cpu = kwargs.get('cpu', 0)
|
||||
self.maxmem = kwargs.get('maxmem', 256)
|
||||
self.mem = kwargs.get('mem', 256)
|
||||
self.maxdisk = kwargs.get('maxdisk', 6)
|
||||
self.disk = kwargs.get('disk', 6)
|
||||
self.maxswap = kwargs.get('maxswap', 0)
|
||||
self.swap = kwargs.get('swap', 0)
|
||||
self.status = kwargs.get('status')
|
||||
self.vmid = kwargs.get('vmid')
|
||||
self.lease_status = kwargs.get('lease_status', 'waiting')
|
||||
self.lxc_status = kwargs.get('lxc_status', 'stopped')
|
||||
|
||||
def __hash__(self):
|
||||
return int(self.vmid)
|
||||
|
||||
def __getattr__(self, item):
|
||||
if item.endswith('percent'):
|
||||
act = int(getattr(self, item[:-8]) or 0)
|
||||
soll = int(getattr(self, 'max' + item[:-8]) or 1)
|
||||
return round(act / soll * 100, 2)
|
||||
raise AttributeError(f'{item} not found')
|
||||
|
||||
@property
|
||||
def cpu_percent(self):
|
||||
return round((self.cpu or 0) * 100, 2)
|
||||
|
||||
@property
|
||||
def is_low(self):
|
||||
if not self.cpu_percent and not self.mem_percent and not self.disk_percent:
|
||||
return False
|
||||
return self.cpu_percent > self.low_limit or self.mem_percent > self.low_limit or self.disk_percent > self.low_limit
|
||||
|
||||
@property
|
||||
def to_json(self):
|
||||
return {
|
||||
'cpus': self.cpus,
|
||||
'cpu': self.cpu,
|
||||
'maxmem': self.maxmem,
|
||||
'mem': self.mem,
|
||||
'maxdisk': self.maxdisk,
|
||||
'disk': self.disk,
|
||||
'maxswap': self.maxswap,
|
||||
'swap': self.swap,
|
||||
'is_low': self.is_low,
|
||||
'cpu_percent': self.cpu_percent,
|
||||
'mem_percent': self.mem_percent,
|
||||
'disk_percent': self.disk_percent,
|
||||
'swap_percent': self.swap_percent,
|
||||
'vmid': self.vmid,
|
||||
'lease_status': self.lease_status,
|
||||
'lxc_status': self.lxc_status,
|
||||
}
|
||||
|
||||
|
||||
@readonly
|
||||
def container_details(request):
|
||||
ids = filter(None, request.GET.get('ids', '').split(','))
|
||||
ret = []
|
||||
if ids:
|
||||
with Proxmox() as pm:
|
||||
for id in ids:
|
||||
container = DevContainer.objects.get(internal_id=id)
|
||||
ret.append(ContainerStatus(**(container.statuscache or {})).to_json)
|
||||
return HttpResponse(json.dumps(ret), content_type='application/json')
|
||||
|
||||
|
||||
@login_required
|
||||
def start_lxc(request, id=None):
|
||||
|
||||
if request.GET.get('id'):
|
||||
id = request.GET.get('id')
|
||||
container = get_object_or_404(DevContainer, internal_id=id)
|
||||
|
||||
try:
|
||||
# Create task and execute synchronously
|
||||
task = TaskFactory(request=request)
|
||||
success = start_container_with_task(str(task.uuid), container.lxc.vmid, request=request)
|
||||
|
||||
if success:
|
||||
messages.success(request, f'Container {container.name} wurde gestartet!')
|
||||
else:
|
||||
messages.error(request, f'Fehler beim Starten von Container {container.name}')
|
||||
|
||||
return redirect('frontend:dashboard')
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f'Fehler beim Starten des Containers: {str(e)}')
|
||||
return redirect('frontend:dashboard')
|
||||
|
||||
|
||||
@login_required
|
||||
def stop_lxc(request, id=None):
|
||||
|
||||
if request.GET.get('id'):
|
||||
id = request.GET.get('id')
|
||||
container = get_object_or_404(DevContainer, internal_id=id)
|
||||
|
||||
try:
|
||||
# Create task and execute synchronously
|
||||
task = TaskFactory(request=request)
|
||||
success = stop_container_with_task(str(task.uuid), container.lxc.vmid, request=request)
|
||||
|
||||
if success:
|
||||
messages.success(request, f'Container {container.name} wurde gestoppt!')
|
||||
else:
|
||||
messages.error(request, f'Fehler beim Stoppen von Container {container.name}')
|
||||
|
||||
return redirect('frontend:dashboard')
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f'Fehler beim Stoppen des Containers: {str(e)}')
|
||||
return redirect('frontend:dashboard')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@login_required
|
||||
def container_detail(request, container_id):
|
||||
container = get_object_or_404(DevContainer, internal_id=container_id)
|
||||
user_profile = request.user.profile
|
||||
|
||||
if False and not user_can_access_container(user_profile, container):
|
||||
return HttpResponseForbidden("Sie haben keine Berechtigung, diesen Container zu sehen.")
|
||||
|
||||
return render(request, 'frontend/container_details.html', {
|
||||
'container': container
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_container(request, container_id):
|
||||
container = get_object_or_404(DevContainer, internal_id=container_id)
|
||||
user_profile = request.user.profile
|
||||
task = TaskFactory(request=request)
|
||||
|
||||
# Berechtigungsprüfung
|
||||
if not user_can_access_container(user_profile, container):
|
||||
return HttpResponseForbidden("Sie haben keine Berechtigung, diesen Container zu bearbeiten.")
|
||||
|
||||
if request.method == 'POST':
|
||||
form = DevContainerForm(request.POST, instance=container, user_profile=user_profile)
|
||||
if form.is_valid():
|
||||
# Check if resource changes require updates
|
||||
old_disksize = container.lxc.disksize
|
||||
old_cores = container.lxc.cores
|
||||
old_memory = container.lxc.memory
|
||||
|
||||
new_disksize = form.cleaned_data.get('disksize')
|
||||
new_cores = form.cleaned_data.get('cores')
|
||||
new_memory = form.cleaned_data.get('memory')
|
||||
|
||||
try:
|
||||
# Save form first (updates local database)
|
||||
form.save()
|
||||
|
||||
# Handle disk resize (synchronously with TaskLogger)
|
||||
if new_disksize and new_disksize != old_disksize:
|
||||
success = resize_container_disk_with_task(str(task.uuid), container.lxc.vmid, new_disksize, request=request)
|
||||
if not success:
|
||||
messages.error(request, 'Fehler beim Vergrößern der Festplatte. Siehe Task-Log für Details.')
|
||||
return redirect('frontend:dashboard')
|
||||
|
||||
# Handle memory/cores changes (synchronously)
|
||||
config_updates = {}
|
||||
if new_cores and new_cores != old_cores:
|
||||
config_updates['cores'] = new_cores
|
||||
if new_memory and new_memory != old_memory:
|
||||
config_updates['memory'] = new_memory
|
||||
|
||||
if config_updates:
|
||||
success = update_container_config_sync(container.lxc.vmid, task=task, **config_updates)
|
||||
|
||||
if not success:
|
||||
messages.error(request, 'Fehler beim Aktualisieren der Container-Konfiguration')
|
||||
return redirect('frontend:dashboard')
|
||||
|
||||
messages.success(request, 'Container wurde erfolgreich aktualisiert')
|
||||
return redirect('frontend:dashboard')
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f'Fehler beim Aktualisieren des Containers: {str(e)}')
|
||||
return redirect('frontend:dashboard')
|
||||
finally:
|
||||
TaskFactory.reset_current_task(request=request)
|
||||
else:
|
||||
TaskFactory.reset_current_task(request=request)
|
||||
task = TaskFactory(request=request)
|
||||
form = DevContainerForm(instance=container, user_profile=user_profile)
|
||||
|
||||
return render(request, 'frontend/edit_container.html', {
|
||||
'form': form,
|
||||
'container': container,
|
||||
'task_uuid': str(task.uuid),
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def delete_container(request, container_id):
|
||||
container = get_object_or_404(DevContainer, internal_id=container_id)
|
||||
user_profile = request.user.profile
|
||||
|
||||
# Berechtigungsprüfung
|
||||
if not user_can_access_container(user_profile, container):
|
||||
return HttpResponseForbidden("Sie haben keine Berechtigung, diesen Container zu löschen.")
|
||||
|
||||
if request.method == 'GET':
|
||||
TaskFactory.reset_current_task(request=request)
|
||||
|
||||
task = TaskFactory(request=request)
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
|
||||
# Execute container deletion in background thread
|
||||
def _delete_container_async():
|
||||
delete_container_with_task(str(task.uuid), container, request=request)
|
||||
|
||||
thread = threading.Thread(target=_delete_container_async)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Return JSON response for async handling with task_uuid
|
||||
return HttpResponse(json.dumps({
|
||||
'status': 'task_started',
|
||||
'task_uuid': str(task.uuid),
|
||||
'message': 'Container deletion initiated'
|
||||
}), content_type='application/json')
|
||||
|
||||
except Exception as e:
|
||||
return HttpResponse(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Fehler beim Löschen des Containers: {str(e)}'
|
||||
}), content_type='application/json', status=500)
|
||||
finally:
|
||||
TaskFactory.reset_current_task(request=request)
|
||||
|
||||
return render(request, 'frontend/delete_container.html', {
|
||||
'container': container,
|
||||
'task_id': str(task.uuid),
|
||||
})
|
||||
|
||||
|
||||
def create_container(request):
|
||||
user_profile = request.user.profile
|
||||
|
||||
if request.method == 'POST':
|
||||
# Get task_uuid from form
|
||||
task_uuid = request.POST.get('task_uuid')
|
||||
|
||||
form = CloneContainerForm(request.POST, user_profile=user_profile)
|
||||
if form.is_valid():
|
||||
container = form.save()
|
||||
container.is_active = True
|
||||
container.save()
|
||||
|
||||
# Start container creation asynchronously with TaskLogger
|
||||
try:
|
||||
|
||||
# Get the task created when form was loaded
|
||||
# Create new task if not found
|
||||
task = TaskFactory(request=request)
|
||||
task_uuid = str(task.uuid)
|
||||
|
||||
# Execute container creation in background thread
|
||||
def _create_container_async():
|
||||
create_container_with_task(task_uuid, container, request=request)
|
||||
|
||||
thread = threading.Thread(target=_create_container_async)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Return JSON response for async handling with task_uuid
|
||||
return HttpResponse(json.dumps({
|
||||
'status': 'task_started',
|
||||
'task_uuid': task_uuid,
|
||||
'message': 'Container creation initiated'
|
||||
}), content_type='application/json')
|
||||
|
||||
except Exception as e:
|
||||
return HttpResponse(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Fehler beim Erstellen des Containers: {str(e)}'
|
||||
}), content_type='application/json', status=500)
|
||||
else:
|
||||
if cloneid := request.GET.get('clone_lxc'):
|
||||
lxc = Lxc.objects.filter(pk=cloneid)
|
||||
else:
|
||||
lxc = None
|
||||
if template_id := request.GET.get('clone_template'):
|
||||
template = LxcTemplate.objects.filter(pk=template_id)
|
||||
else:
|
||||
template = None
|
||||
form = CloneContainerForm(
|
||||
user_profile=user_profile,
|
||||
vm=lxc,
|
||||
template=template,
|
||||
hostname=request.GET.get('clone_hostname'),
|
||||
)
|
||||
|
||||
# Create new task when form is loaded and put UUID in form
|
||||
task = TaskFactory(request=request)
|
||||
task_uuid = str(task.uuid)
|
||||
|
||||
return render(request, 'frontend/create_container.html', {
|
||||
'form': form,
|
||||
'task_uuid': task_uuid
|
||||
})
|
||||
|
||||
|
||||
# DNS Management Views
|
||||
|
||||
@login_required
|
||||
def dns_list(request):
|
||||
"""List all DNS entries with search functionality"""
|
||||
search_form = DNSSearchForm(request.GET or None)
|
||||
dev_container_dns = list(DevContainer.objects.all().values_list('dns_id', flat=True))
|
||||
dns_entries = DNSStatic.objects.all().order_by('name', 'regexp', 'address')
|
||||
|
||||
# Apply search filters
|
||||
if search_form.is_valid():
|
||||
search_query = search_form.cleaned_data.get('search')
|
||||
entry_type = search_form.cleaned_data.get('entry_type')
|
||||
|
||||
if search_query:
|
||||
dns_entries = dns_entries.filter(
|
||||
Q(name__icontains=search_query) |
|
||||
Q(regexp__icontains=search_query) |
|
||||
Q(address__icontains=search_query) |
|
||||
Q(comment__icontains=search_query)
|
||||
)
|
||||
|
||||
if entry_type == 'name':
|
||||
dns_entries = dns_entries.exclude(name__isnull=True).exclude(name='')
|
||||
elif entry_type == 'regexp':
|
||||
dns_entries = dns_entries.exclude(regexp__isnull=True).exclude(regexp='')
|
||||
|
||||
# Pagination
|
||||
page_obj = paginator(dns_entries, request=request)
|
||||
|
||||
return render(request, 'frontend/dns_list.html', {
|
||||
'dns_entries': dns_entries,
|
||||
'page_obj': page_obj,
|
||||
'search_form': search_form,
|
||||
'dev_container_dns': dev_container_dns,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def dns_create(request):
|
||||
"""Create a new DNS entry"""
|
||||
if request.method == 'POST':
|
||||
form = DNSStaticForm(request.POST)
|
||||
if form.is_valid():
|
||||
# Remove container field before saving (it's just for UI)
|
||||
dns_entry = form.save(commit=False)
|
||||
dns_entry.save()
|
||||
messages.success(request, f'DNS entry "{dns_entry}" created successfully')
|
||||
return redirect('frontend:dns_list')
|
||||
else:
|
||||
form = DNSStaticForm()
|
||||
|
||||
return render(request, 'frontend/dns_form.html', {
|
||||
'form': form,
|
||||
'title': 'Create DNS Entry',
|
||||
'submit_text': 'Create DNS Entry'
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def dns_edit(request, dns_id):
|
||||
"""Edit an existing DNS entry"""
|
||||
dns_entry = get_object_or_404(DNSStatic, id=dns_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = DNSStaticForm(request.POST, instance=dns_entry)
|
||||
if form.is_valid():
|
||||
dns_entry = form.save()
|
||||
messages.success(request, f'DNS entry "{dns_entry}" updated successfully')
|
||||
return redirect('frontend:dns_list')
|
||||
else:
|
||||
form = DNSStaticForm(instance=dns_entry)
|
||||
|
||||
return render(request, 'frontend/dns_form.html', {
|
||||
'form': form,
|
||||
'dns_entry': dns_entry,
|
||||
'title': 'Edit DNS Entry',
|
||||
'submit_text': 'Update DNS Entry'
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def dns_delete(request, dns_id):
|
||||
"""Delete a DNS entry"""
|
||||
dns_entry = get_object_or_404(DNSStatic, id=dns_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
dns_name = str(dns_entry)
|
||||
dns_entry.delete()
|
||||
messages.success(request, f'DNS entry "{dns_name}" deleted successfully')
|
||||
return redirect('frontend:dns_list')
|
||||
|
||||
return render(request, 'frontend/dns_delete.html', {
|
||||
'dns_entry': dns_entry,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def dns_container_api(request):
|
||||
"""API endpoint for container selection in DNS forms"""
|
||||
search = request.GET.get('search', '')
|
||||
containers = DevContainer.objects.all()
|
||||
|
||||
if search:
|
||||
containers = containers.filter(
|
||||
Q(lxc__hostname__icontains=search) |
|
||||
Q(lxc__name__icontains=search) |
|
||||
Q(lease__address__icontains=search)
|
||||
)
|
||||
|
||||
results = []
|
||||
for container in containers:
|
||||
try:
|
||||
results.append({
|
||||
'id': container.pk,
|
||||
'text': f"{container.name} ({container.address})",
|
||||
'address': container.address,
|
||||
})
|
||||
except:
|
||||
continue
|
||||
|
||||
return HttpResponse(json.dumps({
|
||||
'results': results
|
||||
}), content_type='application/json')
|
||||
|
||||
|
||||
@login_required
|
||||
def faq_list(request):
|
||||
"""Simple FAQ list view with accordion display"""
|
||||
term = request.GET.get('search', '')
|
||||
faqs = FAQ.term_search(term).order_by('order', 'title')
|
||||
|
||||
return render(request, 'frontend/faq_list.html', {
|
||||
'faqs': faqs,
|
||||
})
|
||||
|
||||
|
||||
def faq_raw(request, id=None):
|
||||
response_type = 'text/markdown' if not request.GET.get('type', '') == 'json' else 'applicaton/json'
|
||||
indent = int(request.GET.get('indent', 0)) or None
|
||||
if id:
|
||||
faq = get_object_or_404(FAQ, pk=id)
|
||||
if response_type == 'text/markdown':
|
||||
content = f"# {faq.title}\n\n{faq.content}"
|
||||
else:
|
||||
content = json.dumps(model_to_dict(faq), indent=indent)
|
||||
else:
|
||||
term = request.GET.get('search', '')
|
||||
faqs = FAQ.term_search(term).order_by('order', 'title')
|
||||
result = []
|
||||
if response_type == 'text/markdown':
|
||||
for faq in faqs:
|
||||
result.append(f"# {faq.title}\n{faq.content}\n\n")
|
||||
content = '\n-----------------------------------------------\n\n'.join(result)
|
||||
else:
|
||||
for faq in faqs:
|
||||
result.append(model_to_dict(faq))
|
||||
content = json.dumps(result, default=str, indent=indent)
|
||||
return HttpResponse(content, content_type=response_type)
|
||||
|
||||
|
||||
@login_required
|
||||
def faq_create(request):
|
||||
"""Create new FAQ entry"""
|
||||
if request.method == 'POST':
|
||||
form = FAQForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, 'FAQ entry created successfully!')
|
||||
return redirect('frontend:faq_list')
|
||||
else:
|
||||
form = FAQForm()
|
||||
|
||||
return render(request, 'frontend/faq_form.html', {
|
||||
'form': form,
|
||||
'title': 'Create FAQ Entry',
|
||||
'submit_text': 'Create FAQ'
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def faq_edit(request, faq_id):
|
||||
"""Edit existing FAQ entry"""
|
||||
faq = get_object_or_404(FAQ, pk=faq_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = FAQForm(request.POST, instance=faq)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, 'FAQ entry updated successfully!')
|
||||
return redirect('frontend:faq_list')
|
||||
else:
|
||||
form = FAQForm(instance=faq)
|
||||
|
||||
return render(request, 'frontend/faq_form.html', {
|
||||
'form': form,
|
||||
'faq': faq,
|
||||
'title': 'Edit FAQ Entry',
|
||||
'submit_text': 'Update FAQ'
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def faq_delete(request, faq_id):
|
||||
"""Delete FAQ entry"""
|
||||
faq = get_object_or_404(FAQ, pk=faq_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
faq.delete()
|
||||
messages.success(request, 'FAQ entry deleted successfully!')
|
||||
return redirect('frontend:faq_list')
|
||||
|
||||
return render(request, 'frontend/faq_delete.html', {
|
||||
'faq': faq,
|
||||
})
|
||||
Reference in New Issue
Block a user