From 87feb8fda6f00af45f45395f447a32a9914b69d3 Mon Sep 17 00:00:00 2001 From: Pratyush Desai Date: Wed, 23 Apr 2025 03:08:36 +0530 Subject: [PATCH] Improves handling port mapping switched port field to a JSONField adds a helper function to display server port mapping in admin launching a container will map the ports appropriately Instead of typing json to map ports we have a widget replaced by such text fields. where one can + button to add or - to remove i.e. [container_port/proto]:[host_port] At first port was an IntegerField. Hence i added a manual migration which is the latest one to get them to be default:port(old value) Signed-off-by: Pratyush Desai --- webpanel/admin.py | 37 +++++-- webpanel/migrations/0011_alter_server_port.py | 18 ++++ webpanel/migrations/0012_alter_server_port.py | 18 ++++ .../migrations/0013_migrate_port_to_dict.py | 32 ++++++ webpanel/models.py | 18 ++-- webpanel/templates/webpanel/game_detail.html | 9 -- .../webpanel/port_mapping_widget.html | 101 ++++++++++++++++++ webpanel/widgets.py | 35 ++++++ 8 files changed, 247 insertions(+), 21 deletions(-) create mode 100644 webpanel/migrations/0011_alter_server_port.py create mode 100644 webpanel/migrations/0012_alter_server_port.py create mode 100644 webpanel/migrations/0013_migrate_port_to_dict.py create mode 100644 webpanel/templates/webpanel/port_mapping_widget.html create mode 100644 webpanel/widgets.py diff --git a/webpanel/admin.py b/webpanel/admin.py index 15b2572..ac83979 100644 --- a/webpanel/admin.py +++ b/webpanel/admin.py @@ -1,7 +1,27 @@ from django.contrib import admin, messages from .models import Game, Server -import podman +from django import forms +from .widgets import PortMappingWidget +class ServerForm(forms.ModelForm): + class Meta: + model = Server + fields = "__all__" + widgets = { + "port": PortMappingWidget(), + } + def clean_port(self): + keys = self.data.getlist("port_key") + values = self.data.getlist("port_value") + + try: + return { + k.strip(): int(v) + for k, v in zip(keys, values) + if k.strip() and v.strip() + } + except ValueError: + raise forms.ValidationError("Port values must be integers.") @admin.action(description="Launch selected servers") def launch_servers(modeladmin, request, queryset): for server in queryset: @@ -26,10 +46,19 @@ class GameAdmin(admin.ModelAdmin): search_fields = ('name', 'genre') ordering = ('name',) + @admin.register(Server) class ServerAdmin(admin.ModelAdmin): - list_display = ('game', 'name', 'ip_address', 'port', 'status', 'image', 'run_command', 'command_args') + form = ServerForm + list_display = ( + 'game', 'name', 'ip_address', 'get_ports_display', 'status', 'image', 'run_command', 'command_args' + ) search_fields = ('game__name', 'ip_address', 'port') + list_filter = ('status', 'game') + ordering = ('game', 'ip_address') + actions = [stop_servers, remove_servers, launch_servers] + + readonly_fields = ('get_ports_display',) def save_model(self, request, obj, form, change): super().save_model(request, obj, form, change) @@ -41,7 +70,3 @@ class ServerAdmin(admin.ModelAdmin): server.sync_status() return queryset - list_filter = ('status', 'game') - search_fields = ('ip_address', 'game__name', 'image') - ordering = ('game', 'ip_address') - actions = [ stop_servers, remove_servers, launch_servers] diff --git a/webpanel/migrations/0011_alter_server_port.py b/webpanel/migrations/0011_alter_server_port.py new file mode 100644 index 0000000..6f27ca7 --- /dev/null +++ b/webpanel/migrations/0011_alter_server_port.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-04-22 19:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("webpanel", "0010_alter_game_thumbnail"), + ] + + operations = [ + migrations.AlterField( + model_name="server", + name="port", + field=models.JSONField(default=dict), + ), + ] diff --git a/webpanel/migrations/0012_alter_server_port.py b/webpanel/migrations/0012_alter_server_port.py new file mode 100644 index 0000000..b2dcdf4 --- /dev/null +++ b/webpanel/migrations/0012_alter_server_port.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-04-22 19:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("webpanel", "0011_alter_server_port"), + ] + + operations = [ + migrations.AlterField( + model_name="server", + name="port", + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/webpanel/migrations/0013_migrate_port_to_dict.py b/webpanel/migrations/0013_migrate_port_to_dict.py new file mode 100644 index 0000000..0424b29 --- /dev/null +++ b/webpanel/migrations/0013_migrate_port_to_dict.py @@ -0,0 +1,32 @@ +# Generated by Django 5.1.5 on 2025-04-22 19:57 + +from django.db import migrations +import json + +def migrate_port_to_dict(apps, schema_editor): + Server = apps.get_model("webpanel", "Server") + for gs in Server.objects.all(): + port = gs.port + if isinstance(port, int): + gs.port = {"default": port} + gs.save() + elif isinstance(port, str): + try: + parsed = json.loads(port) + if isinstance(parsed, dict): + gs.port = parsed + gs.save() + except json.JSONDecodeError: + # Fallback: store as a default port string + gs.port = {"default": port} + gs.save() + +class Migration(migrations.Migration): + + dependencies = [ + ("webpanel", "0012_alter_server_port"), + ] + + operations = [ + migrations.RunPython(migrate_port_to_dict), + ] diff --git a/webpanel/models.py b/webpanel/models.py index 6e2c8a6..675192a 100644 --- a/webpanel/models.py +++ b/webpanel/models.py @@ -21,7 +21,7 @@ class Server(models.Model): game = models.ForeignKey(Game, on_delete=models.CASCADE) name = models.CharField(max_length=100) ip_address = models.GenericIPAddressField(null=True) - port = models.PositiveIntegerField(null=True) + port = models.JSONField(default=dict, blank=True) image = models.CharField(max_length=200, null=True) run_command = models.CharField(max_length=500, blank=True, null=True) command_args = models.TextField(blank=True, null=True) @@ -57,22 +57,28 @@ class Server(models.Model): self.status = "offline" self.save() + def get_ports_display(self): + return ", ".join(f"{k}→{v}" for k, v in self.port.items()) + get_ports_display.short_description = "Ports" # display name in admin + + def launch_pod_container(self) -> str: try: + port_bindings = { + container_port: ('0.0.0.0', host_port) + for container_port, host_port in self.port.items() + } + container = self._get_podman_client().containers.create( name=self.safe_name, image=self.image, - ports={ - f'{self.port}/udp': ('0.0.0.0', self.port), - f'{self.port}/tcp': ('0.0.0.0', self.port), - }, + ports=port_bindings, command=shlex.split(self.run_command), detach=True, ) container.start() self.container_id = container.id self.last_log = f"Launched container {container.id}" - # self.is_running = True self.sync_status() self.save() return f"Container launched successfully: {container.id}" diff --git a/webpanel/templates/webpanel/game_detail.html b/webpanel/templates/webpanel/game_detail.html index e37a9b1..7151114 100644 --- a/webpanel/templates/webpanel/game_detail.html +++ b/webpanel/templates/webpanel/game_detail.html @@ -5,15 +5,6 @@ {% block content %}

{{ game.name }}

-
diff --git a/webpanel/templates/webpanel/port_mapping_widget.html b/webpanel/templates/webpanel/port_mapping_widget.html new file mode 100644 index 0000000..164c366 --- /dev/null +++ b/webpanel/templates/webpanel/port_mapping_widget.html @@ -0,0 +1,101 @@ + + +
+ {% if widget.value %} + {% for k, v in widget.value.items %} +
+ + + +
+ {% endfor %} + {% else %} +
+ + + +
+ {% endif %} + +
+ +
+ + + + \ No newline at end of file diff --git a/webpanel/widgets.py b/webpanel/widgets.py new file mode 100644 index 0000000..8d919ce --- /dev/null +++ b/webpanel/widgets.py @@ -0,0 +1,35 @@ +from django import forms +from django.utils.safestring import mark_safe +import json + +class PortMappingWidget(forms.Widget): + template_name = "webpanel/port_mapping_widget.html" + + def format_value(self, value): + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError: + value = {} + return value or {} + + def value_from_datadict(self, data, files, name): + raw = {} + keys = data.getlist(f"{name}_key") + values = data.getlist(f"{name}_value") + for k, v in zip(keys, values): + if k: + try: + raw[k] = int(v) + except ValueError: + raw[k] = v + return json.dumps(raw) # <-- FIX: return JSON string instead of dict + + def get_context(self, name, value, attrs): + value = self.format_value(value) + context = super().get_context(name, value, attrs) + context["widget"].update({ + "name": name, + "value": value, + }) + return context