This commit is contained in:
Holger Sielaff
2025-08-27 09:55:55 +02:00
commit 90c0ff61ed
107 changed files with 8535 additions and 0 deletions

0
frontend/__init__.py Normal file
View File

24
frontend/admin.py Normal file
View 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
View 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
View 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
View 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

View 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)),
],
),
]

View 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'}),
),
]

View 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),
),
]

View File

54
frontend/models.py Normal file
View 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
View 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
View 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))

View 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);
}
}

View 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();
});

View 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>

View 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 %}

View 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 %}

View 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">&nbsp;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">&nbsp;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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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">&nbsp;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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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">&laquo;&laquo;</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">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">&laquo;&laquo;</span></li>
<li class="page-item disabled"><span class="page-link">&laquo;</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">&raquo;</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">&raquo;&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">&raquo;</span></li>
<li class="page-item disabled"><span class="page-link">&raquo;&raquo;</span></li>
{% endif %}
</ul>
</nav>

View 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">&laquo;&laquo;</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">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">&laquo;&laquo;</span></li>
<li class="page-item disabled"><span class="page-link">&laquo;</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">&raquo;</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">&raquo;&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">&raquo;</span></li>
<li class="page-item disabled"><span class="page-link">&raquo;&raquo;</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>

View 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 %}

View 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 %}

View 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 %}

View File

@@ -0,0 +1 @@
# frontend/templatetags/__init__.py

View 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
View 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
View 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,
})