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 <pratyush.desai@liberta.casa>
This commit is contained in:
Pratyush Desai 2025-04-23 03:08:36 +05:30
parent 8dc855548e
commit 87feb8fda6
Signed by untrusted user: pratyush
GPG Key ID: DBA5BB7505946FAD
8 changed files with 247 additions and 21 deletions

View File

@ -1,7 +1,27 @@
from django.contrib import admin, messages from django.contrib import admin, messages
from .models import Game, Server 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") @admin.action(description="Launch selected servers")
def launch_servers(modeladmin, request, queryset): def launch_servers(modeladmin, request, queryset):
for server in queryset: for server in queryset:
@ -26,10 +46,19 @@ class GameAdmin(admin.ModelAdmin):
search_fields = ('name', 'genre') search_fields = ('name', 'genre')
ordering = ('name',) ordering = ('name',)
@admin.register(Server) @admin.register(Server)
class ServerAdmin(admin.ModelAdmin): 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') 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): def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
@ -41,7 +70,3 @@ class ServerAdmin(admin.ModelAdmin):
server.sync_status() server.sync_status()
return queryset return queryset
list_filter = ('status', 'game')
search_fields = ('ip_address', 'game__name', 'image')
ordering = ('game', 'ip_address')
actions = [ stop_servers, remove_servers, launch_servers]

View File

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

View File

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

View File

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

View File

@ -21,7 +21,7 @@ class Server(models.Model):
game = models.ForeignKey(Game, on_delete=models.CASCADE) game = models.ForeignKey(Game, on_delete=models.CASCADE)
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
ip_address = models.GenericIPAddressField(null=True) 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) image = models.CharField(max_length=200, null=True)
run_command = models.CharField(max_length=500, blank=True, null=True) run_command = models.CharField(max_length=500, blank=True, null=True)
command_args = models.TextField(blank=True, null=True) command_args = models.TextField(blank=True, null=True)
@ -57,22 +57,28 @@ class Server(models.Model):
self.status = "offline" self.status = "offline"
self.save() 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: def launch_pod_container(self) -> str:
try: 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( container = self._get_podman_client().containers.create(
name=self.safe_name, name=self.safe_name,
image=self.image, image=self.image,
ports={ ports=port_bindings,
f'{self.port}/udp': ('0.0.0.0', self.port),
f'{self.port}/tcp': ('0.0.0.0', self.port),
},
command=shlex.split(self.run_command), command=shlex.split(self.run_command),
detach=True, detach=True,
) )
container.start() container.start()
self.container_id = container.id self.container_id = container.id
self.last_log = f"Launched container {container.id}" self.last_log = f"Launched container {container.id}"
# self.is_running = True
self.sync_status() self.sync_status()
self.save() self.save()
return f"Container launched successfully: {container.id}" return f"Container launched successfully: {container.id}"

View File

@ -5,15 +5,6 @@
{% block content %} {% block content %}
<h2>{{ game.name }}</h2> <h2>{{ game.name }}</h2>
<!-- <nav class="sub-nav">
<a href="{% url 'home' %}">Home</a>
<a href="{% url 'games' %}">Games</a>
<a href="#">Mods</a>
<div class="right-links">
<a href="#">Show All Active Servers</a>
<a href="#">Request A Server</a>
</div>
</nav> -->
<div class="game-detail"> <div class="game-detail">
<div class="game-box" style="width: 150px; text-align: center;"> <div class="game-box" style="width: 150px; text-align: center;">

View File

@ -0,0 +1,101 @@
<!-- <div id="{{ widget.name }}_container">
{% if widget.value %}
{% for k, v in widget.value.items %}
<div class="port-entry">
<input type="text" name="{{ widget.name }}_key" value="{{ k }}" placeholder="port/proto">
<input type="number" name="{{ widget.name }}_value" value="{{ v }}" placeholder="host port">
<button type="button" onclick="this.parentElement.remove()">×</button>
</div>
{% endfor %}
{% else %}
<div class="port-entry">
<input type="text" name="{{ widget.name }}_key" placeholder="port/proto">
<input type="number" name="{{ widget.name }}_value" placeholder="host port">
<button type="button" onclick="this.parentElement.remove()">×</button>
</div>
{% endif %}
<div id="{{ widget.name }}_add"></div>
<button type="button" onclick="addPortField_{{ widget.name|slugify }}()">+ Add</button>
</div>
<script>
function addPortField_{{ widget.name|slugify }}() {
const container = document.getElementById("{{ widget.name }}_add");
const div = document.createElement("div");
div.classList.add("port-entry");
div.innerHTML = `
<input type="text" name="{{ widget.name }}_key" placeholder="port/proto">
<input type="number" name="{{ widget.name }}_value" placeholder="host port">
<button type="button" onclick="this.parentElement.remove()">×</button>
`;
container.appendChild(div);
}
</script>
<style>
.port-entry {
margin-bottom: 5px;
}
.port-entry input {
margin-right: 5px;
}
</style>
-->
<div id="{{ widget.name }}_container">
{% if widget.value %}
{% for k, v in widget.value.items %}
<div class="port-entry">
<input type="text" name="{{ widget.name }}_key" value="{{ k }}" placeholder="port/proto">
<input type="number" name="{{ widget.name }}_value" value="{{ v }}" placeholder="host port">
<button type="button" class="remove-port-entry">×</button>
</div>
{% endfor %}
{% else %}
<div class="port-entry">
<input type="text" name="{{ widget.name }}_key" placeholder="port/proto">
<input type="number" name="{{ widget.name }}_value" placeholder="host port">
<button type="button" class="remove-port-entry">×</button>
</div>
{% endif %}
<div id="{{ widget.name }}_add"></div>
<button type="button" id="add-port-{{ widget.name|slugify }}">+ Add</button>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
const addButton = document.getElementById("add-port-{{ widget.name|slugify }}");
const container = document.getElementById("{{ widget.name }}_add");
// Event listener for the add button
addButton.addEventListener("click", function () {
const div = document.createElement("div");
div.classList.add("port-entry");
div.innerHTML = `
<input type="text" name="{{ widget.name }}_key" placeholder="port/proto">
<input type="number" name="{{ widget.name }}_value" placeholder="host port">
<button type="button" class="remove-port-entry">×</button>
`;
container.appendChild(div);
});
// Delegate event listener for the remove button
container.addEventListener("click", function (e) {
if (e.target && e.target.classList.contains("remove-port-entry")) {
e.target.parentElement.remove();
}
});
});
</script>
<style>
.port-entry {
margin-bottom: 5px;
}
.port-entry input {
margin-right: 5px;
}
</style>

35
webpanel/widgets.py Normal file
View File

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