feat: add support for app source selection with GitHub and raw compose options

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Jakub Dorfman 2026-04-29 15:39:34 +02:00
parent e7c0b56473
commit 009b8016bb
2 changed files with 169 additions and 6 deletions

View file

@ -297,11 +297,11 @@
<label>Domains</label> <label>Domains</label>
<div class="domain-tags" id="domainTags"></div> <div class="domain-tags" id="domainTags"></div>
<div class="domain-input-row"> <div class="domain-input-row">
<input id="domainInput" placeholder="whoami.srazka.com" /> <input id="domainInput" placeholder="whoami.srazka.com" />
<button class="btn-sm" id="addDomainBtn">Add</button> <button class="btn-sm" id="addDomainBtn">Add</button>
</div> </div>
<p style="font-size:.75rem;color:var(--muted);margin-top:4px;"> <p style="font-size:.75rem;color:var(--muted);margin-top:4px;">
Supports wildcards: <code>*.example.com</code> Supports wildcards: <code>*.example.com</code>
</p> </p>
<div class="row"> <div class="row">
@ -318,6 +318,37 @@
</div> </div>
</div> </div>
<label for="createSource">App Source</label>
<select id="createSource">
<option value="default" selected>Default (whoami)</option>
<option value="raw">Raw Compose</option>
<option value="github">GitHub Repository</option>
</select>
<div id="sourceRaw" style="display:none; margin-top:10px;">
<label for="createCompose">Compose YAML</label>
<textarea id="createCompose" rows="8" placeholder="services:\n app:\n image: nginx\n ports:\n - '127.0.0.1:18080:80'"></textarea>
</div>
<div id="sourceGithub" style="display:none; margin-top:10px;">
<label for="createGithubUrl">Repository URL</label>
<input id="createGithubUrl" placeholder="https://github.com/user/repo" />
<div class="row" style="margin-top:8px;">
<div>
<label for="createGithubBranch">Branch</label>
<input id="createGithubBranch" placeholder="main" value="main" />
</div>
<div>
<label for="createGithubPat">PAT (Optional)</label>
<input id="createGithubPat" type="password" placeholder="ghp_..." autocomplete="new-password" />
</div>
</div>
<p style="font-size:.75rem;color:var(--muted);margin-top:4px;">
Requires <code>compose.yaml</code> at the root.
</p>
</div>
<div class="btn-group" style="margin-top:14px;"> <div class="btn-group" style="margin-top:14px;">
<button class="btn-primary" id="createBtn">Create App</button> <button class="btn-primary" id="createBtn">Create App</button>
</div> </div>
@ -423,22 +454,42 @@ domainInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); document.getElementById("addDomainBtn").click(); } if (e.key === "Enter") { e.preventDefault(); document.getElementById("addDomainBtn").click(); }
}); });
// ─── Create app ─── const sourceSelect = document.getElementById("createSource");
const sourceRaw = document.getElementById("sourceRaw");
const sourceGithub = document.getElementById("sourceGithub");
sourceSelect.addEventListener("change", () => {
sourceRaw.style.display = sourceSelect.value === "raw" ? "block" : "none";
sourceGithub.style.display = sourceSelect.value === "github" ? "block" : "none";
});
document.getElementById("createBtn").onclick = async () => { document.getElementById("createBtn").onclick = async () => {
const name = document.getElementById("name").value.trim(); const name = document.getElementById("name").value.trim();
const port = Number(document.getElementById("port").value); const port = Number(document.getElementById("port").value);
const auth = document.getElementById("auth").value === "true"; const auth = document.getElementById("auth").value === "true";
const domains = [...domainTags]; const domains = [...domainTags];
const source_type = sourceSelect.value;
const compose_content = document.getElementById("createCompose").value;
const github_url = document.getElementById("createGithubUrl").value.trim();
const github_branch = document.getElementById("createGithubBranch").value.trim();
const github_pat = document.getElementById("createGithubPat").value.trim();
if (!name) { setStatus("Name is required.", true); return; } if (!name) { setStatus("Name is required.", true); return; }
if (!domains.length) { setStatus("At least one domain is required.", true); return; } if (!domains.length) { setStatus("At least one domain is required.", true); return; }
if (!port || port < 1024 || port > 65535) { setStatus("Port must be 1024-65535.", true); return; } if (!port || port < 1024 || port > 65535) { setStatus("Port must be 1024-65535.", true); return; }
if (source_type === "raw" && !compose_content.trim()) { setStatus("Compose YAML is required for raw source.", true); return; }
if (source_type === "github" && !github_url) { setStatus("Repository URL is required for GitHub source.", true); return; }
try { try {
setStatus(`Creating ${name}...`); setStatus(`Creating ${name}...`);
await api.init({ name, domain: domains.join(","), port, auth }); await api.init({ name, domain: domains.join(","), port, auth, source_type, compose_content, github_url, github_branch, github_pat });
setStatus(`Created ${name}.`); setStatus(`Created ${name}.`);
document.getElementById("name").value = ""; document.getElementById("name").value = "";
document.getElementById("createCompose").value = "";
document.getElementById("createGithubUrl").value = "";
document.getElementById("createGithubPat").value = "";
domainTags.length = 0; domainTags.length = 0;
renderDomainTags(); renderDomainTags();
await loadApps(); await loadApps();

View file

@ -4,9 +4,12 @@
import json import json
import os import os
import re import re
import shutil
import subprocess import subprocess
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
import urllib.request
import urllib.error
PANELCTL = os.environ.get("PANELCTL_PATH", "/run/current-system/sw/bin/panelctl") PANELCTL = os.environ.get("PANELCTL_PATH", "/run/current-system/sw/bin/panelctl")
BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1") BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1")
@ -536,11 +539,120 @@ class Handler(BaseHTTPRequestHandler):
domain = str(payload.get("domain", "")) domain = str(payload.get("domain", ""))
port = str(payload["port"]) port = str(payload["port"])
auth = str(payload.get("auth", True)).lower() auth = str(payload.get("auth", True)).lower()
source_type = payload.get("source_type", "default")
except Exception as exc: except Exception as exc:
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
return return
if source_type not in ["default", "raw", "github"]:
self._json(400, {"ok": False, "error": "invalid source_type"})
return
result = run_panelctl(["init", name, domain, port, auth]) result = run_panelctl(["init", name, domain, port, auth])
self._json(200 if result["ok"] else 400, result) if not result["ok"]:
self._json(400, result)
return
app, err = read_app_info(name)
if err is not None:
run_panelctl(["remove", name])
self._json(500, {"ok": False, "error": f"failed to read app state: {err.get('error', 'unknown error')}"})
return
if source_type == "raw":
content = payload.get("compose_content", "")
try:
with open(app["APP_COMPOSE_FILE"], "w", encoding="utf-8") as fh:
fh.write(content)
except OSError as exc:
run_panelctl(["remove", name])
self._json(500, {"ok": False, "error": f"failed to write compose: {exc}"})
return
elif source_type == "github":
repo_url = payload.get("github_url", "").strip()
branch = payload.get("github_branch", "main").strip()
pat = payload.get("github_pat", "").strip()
if not repo_url:
run_panelctl(["remove", name])
self._json(400, {"ok": False, "error": "github_url is required"})
return
# Validate URL and extract owner/repo
match = re.match(r'^https?://(?:www\.)?github\.com/([^/]+)/([^/]+?)(?:\.git)?$', repo_url)
if not match:
run_panelctl(["remove", name])
self._json(400, {"ok": False, "error": "invalid github_url format"})
return
owner, repo = match.groups()
if pat:
# Check GitHub API access
api_url = f"https://api.github.com/repos/{owner}/{repo}"
req = urllib.request.Request(api_url, headers={"Authorization": f"Bearer {pat}"})
try:
urllib.request.urlopen(req)
except urllib.error.URLError as e:
run_panelctl(["remove", name])
self._json(400, {"ok": False, "error": f"github api check failed: {e.reason}"})
return
# Clone the repository
target_dir = os.path.join(app["APP_STACK_DIR"], "repo")
if os.path.exists(target_dir):
shutil.rmtree(target_dir)
auth_url = repo_url
if pat:
auth_url = auth_url.replace("://", f"://{pat}@")
if not auth_url.endswith(".git"):
auth_url += ".git"
clone_result = subprocess.run(
["git", "clone", "--branch", branch, auth_url, target_dir],
capture_output=True, text=True
)
if clone_result.returncode != 0:
run_panelctl(["remove", name])
self._json(400, {"ok": False, "error": f"git clone failed: {clone_result.stderr.strip()}"})
return
# Find compose file
compose_path = None
for fname in ["compose.yaml", "docker-compose.yml", "compose.yml", "docker-compose.yaml"]:
candidate = os.path.join(target_dir, fname)
if os.path.isfile(candidate):
compose_path = candidate
break
if not compose_path:
run_panelctl(["remove", name])
self._json(400, {"ok": False, "error": "could not find compose file in repository root"})
return
# Update manifest
manifest_path = os.path.join(BASE_DIR, "state", "apps", f"{name}.env")
try:
with open(manifest_path, "r", encoding="utf-8") as fh:
lines = fh.readlines()
with open(manifest_path, "w", encoding="utf-8") as fh:
for line in lines:
if line.startswith("APP_COMPOSE_FILE="):
fh.write(f'APP_COMPOSE_FILE="{compose_path}"\n')
else:
fh.write(line)
fh.write(f'APP_REPO_URL="{repo_url}"\n')
fh.write(f'APP_REPO_BRANCH="{branch}"\n')
except OSError as exc:
run_panelctl(["remove", name])
self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"})
return
self._json(200, {"ok": True, "code": 0, "stdout": "initialized successfully"})
return return
if len(parts) >= 3 and parts[0] == "apps": if len(parts) >= 3 and parts[0] == "apps":