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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Requires compose.yaml at the root.
+
+
+
@@ -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":