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:
parent
57ace80ecd
commit
2c1d267652
2 changed files with 169 additions and 6 deletions
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue