@@ -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-api.py b/panel-api.py
index 8bfd28a..5cf63c6 100644
--- a/panel-api.py
+++ b/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/panelctl.sh b/panelctl.sh
index 9334356..9b26765 100644
--- a/panelctl.sh
+++ b/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"