diff --git a/panel/frontend/index.html b/panel/frontend/index.html index f08235b..af3490b 100644 --- a/panel/frontend/index.html +++ b/panel/frontend/index.html @@ -297,11 +297,11 @@
- - + +

- Supports wildcards: *.example.com + Supports wildcards: *.example.com

@@ -318,6 +318,37 @@
+ + + + + + +
@@ -423,22 +454,42 @@ domainInput.addEventListener("keydown", (e) => { 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 () => { const name = document.getElementById("name").value.trim(); const port = Number(document.getElementById("port").value); const auth = document.getElementById("auth").value === "true"; 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 (!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 (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 { 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}.`); document.getElementById("name").value = ""; + document.getElementById("createCompose").value = ""; + document.getElementById("createGithubUrl").value = ""; + document.getElementById("createGithubPat").value = ""; domainTags.length = 0; renderDomainTags(); await loadApps(); diff --git a/panel/panel-api.py b/panel/panel-api.py index 7cc2141..feb77b0 100644 --- a/panel/panel-api.py +++ b/panel/panel-api.py @@ -4,9 +4,12 @@ import json import os import re +import shutil import subprocess from http.server import BaseHTTPRequestHandler, HTTPServer 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") BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1") @@ -536,11 +539,120 @@ class Handler(BaseHTTPRequestHandler): domain = str(payload.get("domain", "")) port = str(payload["port"]) auth = str(payload.get("auth", True)).lower() + source_type = payload.get("source_type", "default") except Exception as exc: self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) 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]) - 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 if len(parts) >= 3 and parts[0] == "apps":