diff --git a/panel/frontend/index.html b/panel/frontend/index.html index 3943d3b..415034f 100644 --- a/panel/frontend/index.html +++ b/panel/frontend/index.html @@ -365,12 +365,26 @@ const api = { backup: (n) => api.request(`/apps/${n}/backup`, "POST"), restore: (n, f) => api.request(`/apps/${n}/restore`, "POST", { file: f }), renderRoute: (n) => api.request(`/apps/${n}/render-route`, "POST"), + clearVolume: (n) => api.request(`/apps/${n}/volume-clear`, "POST"), + getVolumeFiles: (n, p) => api.request(`/apps/${n}/volume/files?path=${encodeURIComponent(p)}`), + deleteFile: (n, p) => api.request(`/apps/${n}/volume/files?path=${encodeURIComponent(p)}`, "DELETE"), + uploadFile: async (n, p, f) => { + const res = await fetch(`/apps/${n}/volume/files?path=${encodeURIComponent(p)}`, { + method: "PUT", + body: f, + headers: { "Content-Length": f.size.toString() } + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "upload failed"); + return data; + }, }; // ─── State ─── let apps = []; let expandedApp = null; let activeTab = {}; // { appName: tabName } +let volumeLocations = {}; // { appName: currentPathString } // ─── Status bar ─── const statusEl = document.getElementById("status"); @@ -474,6 +488,7 @@ function renderAppCard(app) {
+
@@ -497,6 +512,18 @@ function renderAppCard(app) {
Click "Refresh Logs" to load.
+
+
+ + + +
+ +
/
+
+
Click "Refresh Files" to load volume browser.
+
+
@@ -568,11 +595,13 @@ function attachCardListeners() { // Update panels document.getElementById(`tab-compose-${app}`).classList.toggle("active", tabName === "compose"); document.getElementById(`tab-logs-${app}`).classList.toggle("active", tabName === "logs"); + document.getElementById(`tab-volumes-${app}`).classList.toggle("active", tabName === "volumes"); document.getElementById(`tab-backups-${app}`).classList.toggle("active", tabName === "backups"); document.getElementById(`tab-routing-${app}`).classList.toggle("active", tabName === "routing"); if (tabName === "logs") loadLogs(app); if (tabName === "backups") loadBackups(app); + if (tabName === "volumes") loadVolume(app, ""); }; }); @@ -655,6 +684,36 @@ async function handleAction(action, name, btnElement) { await api.renderRoute(name); setStatus(`Route rendered for ${name}.`); break; + case "volume-refresh": + const curPath = volumeLocations[name] || ""; + await loadVolume(name, curPath); + break; + case "volume-clear": + if (confirm("Are you sure? This deletes ALL data inside the volume right now!")) { + setStatus(`Clearing volume for ${name}...`); + await api.clearVolume(name); + setStatus(`Volume cleared for ${name}.`); + await loadVolume(name, ""); + } + break; + case "volume-upload": + const uploadInput = document.getElementById(`volume-upload-input-${name}`); + uploadInput.onchange = async (e) => { + if (!e.target.files.length) return; + const file = e.target.files[0]; + const uploadPath = (volumeLocations[name] ? volumeLocations[name] + "/" : "") + file.name; + try { + setStatus(`Uploading ${file.name} to ${name}...`); + await api.uploadFile(name, uploadPath, file); + setStatus(`Uploaded ${file.name} successfully.`); + await loadVolume(name, volumeLocations[name] || ""); + } catch(uploadErr) { + setStatus(`Upload failed: ${uploadErr.message}`, true); + } + uploadInput.value = ""; + }; + uploadInput.click(); + break; case "fetch-routing": setStatus(`Fetching routing details for ${name}...`); const appRes = await api.getApp(name); @@ -760,6 +819,125 @@ async function loadLogs(name) { } } +window.api = api; // Expose for inline html onclicks + +async function loadVolume(name, currentPath) { + volumeLocations[name] = currentPath; + const pathLabel = currentPath ? currentPath : "/"; + document.getElementById(`volume-path-${name}`).innerHTML = ` + ${escHtml(pathLabel)} + `; + + try { + const data = await api.getVolumeFiles(name, currentPath); + const container = document.getElementById(`volumes-${name}`); + container.innerHTML = ""; + + if (currentPath) { + const upPath = currentPath.split("/").slice(0, -1).join("/"); + const item = document.createElement("div"); + item.className = "backup-item"; + item.style.cursor = "pointer"; + item.onclick = () => loadVolume(name, upPath); + item.innerHTML = ` +
+ 📁 .. +
+ `; + container.appendChild(item); + } + + if (!data.files || data.files.length === 0) { + const msg = document.createElement("div"); + msg.style.color = "var(--muted)"; + msg.style.padding = "10px"; + msg.textContent = "Directory is empty."; + container.appendChild(msg); + return; + } + + data.files.forEach(f => { + const fullPath = currentPath ? currentPath + "/" + f.name : f.name; + const item = document.createElement("div"); + item.className = "backup-item"; + item.style.gap = "8px"; + + const icon = f.is_dir ? "📁" : "📄"; + const sizeStr = f.is_dir ? "" : (f.size > 1024 * 1024 ? (f.size / 1024 / 1024).toFixed(1) + "MB" : (f.size / 1024).toFixed(1) + "KB"); + + const titleDiv = document.createElement("div"); + titleDiv.style.flex = "1"; + titleDiv.style.display = "flex"; + titleDiv.style.alignItems = "center"; + titleDiv.style.minWidth = "0"; + + const spanIcon = document.createElement("span"); + spanIcon.style.marginRight = "8px"; + spanIcon.style.fontSize = "1.2rem"; + spanIcon.textContent = icon; + + const spanName = document.createElement("span"); + spanName.style.fontFamily = "monospace"; + spanName.style.fontSize = "0.85rem"; + spanName.style.whiteSpace = "nowrap"; + spanName.style.overflow = "hidden"; + spanName.style.textOverflow = "ellipsis"; + spanName.textContent = f.name; + if (f.is_dir) { + spanName.style.cursor = "pointer"; + spanName.style.color = "var(--accent)"; + spanName.onclick = () => loadVolume(name, fullPath); + } + + titleDiv.appendChild(spanIcon); + titleDiv.appendChild(spanName); + + const sizeDiv = document.createElement("div"); + sizeDiv.style.color = "var(--muted)"; + sizeDiv.style.fontSize = "0.8rem"; + sizeDiv.style.whiteSpace = "nowrap"; + sizeDiv.style.width = "60px"; + sizeDiv.style.textAlign = "right"; + sizeDiv.textContent = sizeStr; + + const actionsDiv = document.createElement("div"); + actionsDiv.style.display = "flex"; + actionsDiv.style.gap = "4px"; + + if (!f.is_dir) { + const downloadBtn = document.createElement("button"); + downloadBtn.className = "btn-sm"; + downloadBtn.style.padding = "2px 6px"; + downloadBtn.style.fontSize = "0.75rem"; + downloadBtn.textContent = "⬇️"; + downloadBtn.onclick = () => window.open(`/apps/${name}/volume/download?path=${encodeURIComponent(fullPath)}`); + actionsDiv.appendChild(downloadBtn); + } + + const delBtn = document.createElement("button"); + delBtn.className = "btn-sm btn-danger"; + delBtn.style.padding = "2px 6px"; + delBtn.style.fontSize = "0.75rem"; + delBtn.textContent = "🗑️"; + delBtn.onclick = async () => { + if (confirm(`Delete ${f.name}?`)) { + await window.api.deleteFile(name, fullPath); + loadVolume(name, currentPath); + } + }; + actionsDiv.appendChild(delBtn); + + item.appendChild(titleDiv); + item.appendChild(sizeDiv); + item.appendChild(actionsDiv); + + container.appendChild(item); + }); + } catch (err) { + document.getElementById(`volumes-${name}`).textContent = "Failed to load files: " + err.message; + } +} + async function loadBackups(name) { const el = document.getElementById(`backups-${name}`); if (!el) return; diff --git a/panel/panel-api.py b/panel/panel-api.py index 8bfd28a..5cf63c6 100644 --- a/panel/panel-api.py +++ b/panel/panel-api.py @@ -311,6 +311,168 @@ class Handler(BaseHTTPRequestHandler): self._json(200, {"ok": True, "app": parse_env_blob(result["stdout"])}) return + # /apps//volume/files + if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "files": + name = parts[1] + app, err = read_app_info(name) + if err is not None: + self._json(404, err) + return + + subpath = query.get("path", [""])[0].strip("/") + data_dir = os.path.join(app["APP_VOLUME_DIR"], "data") + target_dir = os.path.abspath(os.path.join(data_dir, subpath)) + + # Ensure traversal didn't escape data_dir + if not target_dir.startswith(os.path.abspath(data_dir)): + self._json(403, {"ok": False, "error": "path traversal denied"}) + return + + if not os.path.exists(target_dir): + self._json(404, {"ok": False, "error": "directory not found"}) + return + + if not os.path.isdir(target_dir): + self._json(400, {"ok": False, "error": "target is not a directory"}) + return + + files = [] + for item in os.listdir(target_dir): + if item == "." or item == "..": + continue + item_path = os.path.join(target_dir, item) + try: + stat = os.stat(item_path) + files.append({ + "name": item, + "is_dir": os.path.isdir(item_path), + "size": stat.st_size, + "mtime": stat.st_mtime + }) + except OSError: + continue + + files.sort(key=lambda x: (not x["is_dir"], x["name"].lower())) + self._json(200, {"ok": True, "path": subpath, "files": files}) + return + + # /apps//volume/download + if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "download": + name = parts[1] + app, err = read_app_info(name) + if err is not None: + self._json(404, err) + return + + subpath = query.get("path", [""])[0].strip("/") + if not subpath: + self._json(400, {"ok": False, "error": "path parameter required"}) + return + + data_dir = os.path.join(app["APP_VOLUME_DIR"], "data") + target_file = os.path.abspath(os.path.join(data_dir, subpath)) + + if not target_file.startswith(os.path.abspath(data_dir)): + self._json(403, {"ok": False, "error": "path traversal denied"}) + return + + if not os.path.isfile(target_file): + self._json(404, {"ok": False, "error": "file not found"}) + return + + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Disposition", f'attachment; filename="{os.path.basename(target_file)}"') + size = os.path.getsize(target_file) + self.send_header("Content-Length", str(size)) + self.end_headers() + with open(target_file, "rb") as fh: + while True: + chunk = fh.read(65536) + if not chunk: + break + self.wfile.write(chunk) + return + + self._json(404, {"ok": False, "error": "not found"}) + + # ── PUT ── + def do_PUT(self): + path, parts, query = self._parse_path() + if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "files": + name = parts[1] + app, err = read_app_info(name) + if err is not None: + self._json(404, err) + return + + subpath = query.get("path", [""])[0].strip("/") + if not subpath: + self._json(400, {"ok": False, "error": "path parameter required"}) + return + + data_dir = os.path.join(app["APP_VOLUME_DIR"], "data") + target_file = os.path.abspath(os.path.join(data_dir, subpath)) + if not target_file.startswith(os.path.abspath(data_dir)): + self._json(403, {"ok": False, "error": "path traversal denied"}) + return + + try: + os.makedirs(os.path.dirname(target_file), exist_ok=True) + length = int(self.headers.get("Content-Length", "0")) + with open(target_file, "wb") as fh: + bytes_read = 0 + while bytes_read < length: + chunk = self.rfile.read(min(65536, length - bytes_read)) + if not chunk: + break + fh.write(chunk) + bytes_read += len(chunk) + self._json(200, {"ok": True, "path": subpath}) + except Exception as exc: + self._json(500, {"ok": False, "error": str(exc)}) + return + + self._json(404, {"ok": False, "error": "not found"}) + + # ── DELETE ── + def do_DELETE(self): + path, parts, query = self._parse_path() + + if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "files": + name = parts[1] + app, err = read_app_info(name) + if err is not None: + self._json(404, err) + return + + subpath = query.get("path", [""])[0].strip("/") + if not subpath: + self._json(400, {"ok": False, "error": "path parameter required"}) + return + + data_dir = os.path.join(app["APP_VOLUME_DIR"], "data") + target_file = os.path.abspath(os.path.join(data_dir, subpath)) + + if not target_file.startswith(os.path.abspath(data_dir)): + self._json(403, {"ok": False, "error": "path traversal denied"}) + return + + if not os.path.exists(target_file): + self._json(404, {"ok": False, "error": "file or directory not found"}) + return + + try: + if os.path.isdir(target_file): + import shutil + shutil.rmtree(target_file) + else: + os.remove(target_file) + self._json(200, {"ok": True, "deleted": subpath}) + except Exception as exc: + self._json(500, {"ok": False, "error": str(exc)}) + return + self._json(404, {"ok": False, "error": "not found"}) # ── POST ── @@ -401,7 +563,7 @@ class Handler(BaseHTTPRequestHandler): return # Simple panelctl pass-through actions - if action in {"deploy", "stop", "restart", "render-route"}: + if action in {"deploy", "stop", "restart", "render-route", "volume-clear"}: if not is_safe_name(name): self._json(400, {"ok": False, "error": "invalid app name"}) return diff --git a/panel/panelctl.sh b/panel/panelctl.sh index 9334356..9b26765 100644 --- a/panel/panelctl.sh +++ b/panel/panelctl.sh @@ -31,6 +31,7 @@ Usage: panelctl backup panelctl list-backups panelctl restore + panelctl volume-clear panelctl validate-compose panelctl list panelctl show @@ -497,6 +498,7 @@ cmd_backup() { cmd_list_backups() { local name="$1" validate_name "${name}" + load_app "${name}" ensure_base_dirs @@ -516,6 +518,42 @@ cmd_list_backups() { fi } +cmd_volume_clear() { + local name="$1" + validate_name "${name}" + load_app "${name}" + + log info "clearing volume data for app '${name}'" + run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans 2>/dev/null || true + + local data_dir="${APP_VOLUME_DIR}/data" + if [[ -d "${data_dir}" ]]; then + rm -rf "${data_dir:?}"/* + rm -rf "${data_dir:?}"/.[!.]* 2>/dev/null || true + fi + mkdir -p "${APP_VOLUME_DIR}/data" + + log info "volume data cleared for app '${name}'" +} + +cmd_volume_clear() { + local name="$1" + validate_name "${name}" + load_app "${name}" + + log info "clearing volume data for app '${name}'" + run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans 2>/dev/null || true + + local data_dir="${APP_VOLUME_DIR}/data" + if [[ -d "${data_dir}" ]]; then + rm -rf "${data_dir:?}"/* + rm -rf "${data_dir:?}"/.[!.]* 2>/dev/null || true + fi + mkdir -p "${APP_VOLUME_DIR}/data" + + log info "volume data cleared for app '${name}'" +} + cmd_restore() { local name="$1" local backup_file="$2"