Merge pull request 'WIP : Improves handling port mapping' (#29) from fix/port_mapping into master

Reviewed-on: #29
This commit is contained in:
Pratyush Desai 2025-04-23 06:49:43 +02:00
commit c836caa6af
8 changed files with 191 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,45 @@
<div id="{{ widget.name }}_container">
{% 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>
{% empty %}
<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>
{% endfor %}
</div>
<button type="button" id="add-port-{{ widget.name|slugify }}">+ Add</button>
<script>
document.addEventListener("DOMContentLoaded", function () {
const container = document.getElementById("{{ widget.name }}_container");
const addButton = document.getElementById("add-port-{{ widget.name|slugify }}");
addButton.addEventListener("click", () => {
const div = document.createElement("div");
div.className = "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);
});
container.addEventListener("click", (e) => {
if (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