diff --git a/panel/frontend/index.html b/panel/frontend/index.html index 415034f..f08235b 100644 --- a/panel/frontend/index.html +++ b/panel/frontend/index.html @@ -366,10 +366,11 @@ const api = { 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)}`, { + getVolumes: (n) => api.request(`/apps/${n}/volumes`), + getVolumeFiles: (n, p, v) => api.request(`/apps/${n}/volume/files?vol=${encodeURIComponent(v || "default")}&path=${encodeURIComponent(p)}`), + deleteFile: (n, p, v) => api.request(`/apps/${n}/volume/files?vol=${encodeURIComponent(v || "default")}&path=${encodeURIComponent(p)}`, "DELETE"), + uploadFile: async (n, p, v, f) => { + const res = await fetch(`/apps/${n}/volume/files?vol=${encodeURIComponent(v || "default")}&path=${encodeURIComponent(p)}`, { method: "PUT", body: f, headers: { "Content-Length": f.size.toString() } @@ -385,6 +386,7 @@ let apps = []; let expandedApp = null; let activeTab = {}; // { appName: tabName } let volumeLocations = {}; // { appName: currentPathString } +let selectedVolumes = {}; // { appName: currentVolumeString } // ─── Status bar ─── const statusEl = document.getElementById("status"); @@ -513,10 +515,11 @@ function renderAppCard(app) {
Click "Refresh Logs" to load.
-
+
+ - +
/
@@ -601,7 +604,7 @@ function attachCardListeners() { if (tabName === "logs") loadLogs(app); if (tabName === "backups") loadBackups(app); - if (tabName === "volumes") loadVolume(app, ""); + if (tabName === "volumes") initVolumesTab(app); }; }); @@ -686,14 +689,18 @@ async function handleAction(action, name, btnElement) { break; case "volume-refresh": const curPath = volumeLocations[name] || ""; - await loadVolume(name, curPath); + await loadVolume(name, curPath, selectedVolumes[name]); break; case "volume-clear": - if (confirm("Are you sure? This deletes ALL data inside the volume right now!")) { + if (selectedVolumes[name] && selectedVolumes[name] !== "default") { + setStatus("Clear is only supported for the 'default' managed local bind right now.", true); + break; + } + if (confirm("Are you sure? This deletes ALL data inside the default volume right now!")) { setStatus(`Clearing volume for ${name}...`); await api.clearVolume(name); setStatus(`Volume cleared for ${name}.`); - await loadVolume(name, ""); + await loadVolume(name, "", selectedVolumes[name]); } break; case "volume-upload": @@ -704,9 +711,9 @@ async function handleAction(action, name, btnElement) { const uploadPath = (volumeLocations[name] ? volumeLocations[name] + "/" : "") + file.name; try { setStatus(`Uploading ${file.name} to ${name}...`); - await api.uploadFile(name, uploadPath, file); + await api.uploadFile(name, uploadPath, selectedVolumes[name], file); setStatus(`Uploaded ${file.name} successfully.`); - await loadVolume(name, volumeLocations[name] || ""); + await loadVolume(name, volumeLocations[name] || "", selectedVolumes[name]); } catch(uploadErr) { setStatus(`Upload failed: ${uploadErr.message}`, true); } @@ -821,7 +828,38 @@ async function loadLogs(name) { window.api = api; // Expose for inline html onclicks -async function loadVolume(name, currentPath) { +async function initVolumesTab(name) { + if (!selectedVolumes[name]) { + try { + const vdata = await api.getVolumes(name); + const sel = document.getElementById(`volume-select-${name}`); + sel.innerHTML = ""; + Object.keys(vdata.volumes).forEach(vKey => { + const opt = document.createElement("option"); + opt.value = vKey; + opt.textContent = vKey; + sel.appendChild(opt); + }); + selectedVolumes[name] = sel.value || "default"; + + sel.onchange = () => { + selectedVolumes[name] = sel.value; + const btnClear = document.getElementById(`btn-vol-clear-${name}`); + if(btnClear) btnClear.style.display = sel.value === "default" ? "inline-block" : "none"; + loadVolume(name, "", sel.value); + }; + + const btnClear = document.getElementById(`btn-vol-clear-${name}`); + if(btnClear) btnClear.style.display = selectedVolumes[name] === "default" ? "inline-block" : "none"; + } catch(err) { + console.error(err); + selectedVolumes[name] = "default"; + } + } + loadVolume(name, "", selectedVolumes[name]); +} + +async function loadVolume(name, currentPath, volName) { volumeLocations[name] = currentPath; const pathLabel = currentPath ? currentPath : "/"; document.getElementById(`volume-path-${name}`).innerHTML = ` @@ -829,7 +867,7 @@ async function loadVolume(name, currentPath) { `; try { - const data = await api.getVolumeFiles(name, currentPath); + const data = await api.getVolumeFiles(name, currentPath, volName); const container = document.getElementById(`volumes-${name}`); container.innerHTML = ""; @@ -838,7 +876,7 @@ async function loadVolume(name, currentPath) { const item = document.createElement("div"); item.className = "backup-item"; item.style.cursor = "pointer"; - item.onclick = () => loadVolume(name, upPath); + item.onclick = () => loadVolume(name, upPath, volName); item.innerHTML = `
📁 .. @@ -886,7 +924,7 @@ async function loadVolume(name, currentPath) { if (f.is_dir) { spanName.style.cursor = "pointer"; spanName.style.color = "var(--accent)"; - spanName.onclick = () => loadVolume(name, fullPath); + spanName.onclick = () => loadVolume(name, fullPath, volName); } titleDiv.appendChild(spanIcon); @@ -910,7 +948,7 @@ async function loadVolume(name, currentPath) { downloadBtn.style.padding = "2px 6px"; downloadBtn.style.fontSize = "0.75rem"; downloadBtn.textContent = "⬇️"; - downloadBtn.onclick = () => window.open(`/apps/${name}/volume/download?path=${encodeURIComponent(fullPath)}`); + downloadBtn.onclick = () => window.open(`/apps/${name}/volume/download?vol=${encodeURIComponent(volName)}&path=${encodeURIComponent(fullPath)}`); actionsDiv.appendChild(downloadBtn); } @@ -921,8 +959,8 @@ async function loadVolume(name, currentPath) { delBtn.textContent = "🗑️"; delBtn.onclick = async () => { if (confirm(`Delete ${f.name}?`)) { - await window.api.deleteFile(name, fullPath); - loadVolume(name, currentPath); + await window.api.deleteFile(name, fullPath, volName); + loadVolume(name, currentPath, volName); } }; actionsDiv.appendChild(delBtn); diff --git a/panel/panel-api.py b/panel/panel-api.py index 5cf63c6..7cc2141 100644 --- a/panel/panel-api.py +++ b/panel/panel-api.py @@ -48,6 +48,16 @@ def parse_env_blob(blob): return out +def get_app_volumes(name): + result = run_panelctl(["inspect-volumes", name]) + volumes = {} + if result["ok"]: + for line in result["stdout"].splitlines(): + if "|" in line: + vname, vpath = line.split("|", 1) + volumes[vname.strip()] = vpath.strip() + return volumes + def read_app_info(name): if not is_safe_name(name): return None, {"ok": False, "error": "invalid app name"} @@ -311,6 +321,16 @@ class Handler(BaseHTTPRequestHandler): self._json(200, {"ok": True, "app": parse_env_blob(result["stdout"])}) return + # /apps//volumes + if len(parts) == 3 and parts[0] == "apps" and parts[2] == "volumes": + name = parts[1] + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + return + volumes = get_app_volumes(name) + self._json(200, {"ok": True, "name": name, "volumes": volumes}) + return + # /apps//volume/files if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "files": name = parts[1] @@ -319,8 +339,14 @@ class Handler(BaseHTTPRequestHandler): self._json(404, err) return + volumes = get_app_volumes(name) + vol_key = query.get("vol", ["default"])[0] + if vol_key not in volumes: + self._json(400, {"ok": False, "error": "invalid volume specified"}) + return + subpath = query.get("path", [""])[0].strip("/") - data_dir = os.path.join(app["APP_VOLUME_DIR"], "data") + data_dir = volumes[vol_key] target_dir = os.path.abspath(os.path.join(data_dir, subpath)) # Ensure traversal didn't escape data_dir @@ -364,12 +390,18 @@ class Handler(BaseHTTPRequestHandler): self._json(404, err) return + volumes = get_app_volumes(name) + vol_key = query.get("vol", ["default"])[0] + if vol_key not in volumes: + self._json(400, {"ok": False, "error": "invalid volume specified"}) + 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") + data_dir = volumes[vol_key] target_file = os.path.abspath(os.path.join(data_dir, subpath)) if not target_file.startswith(os.path.abspath(data_dir)): @@ -406,12 +438,18 @@ class Handler(BaseHTTPRequestHandler): self._json(404, err) return + volumes = get_app_volumes(name) + vol_key = query.get("vol", ["default"])[0] + if vol_key not in volumes: + self._json(400, {"ok": False, "error": "invalid volume specified"}) + 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") + data_dir = volumes[vol_key] 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"}) @@ -446,12 +484,18 @@ class Handler(BaseHTTPRequestHandler): self._json(404, err) return + volumes = get_app_volumes(name) + vol_key = query.get("vol", ["default"])[0] + if vol_key not in volumes: + self._json(400, {"ok": False, "error": "invalid volume specified"}) + 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") + data_dir = volumes[vol_key] target_file = os.path.abspath(os.path.join(data_dir, subpath)) if not target_file.startswith(os.path.abspath(data_dir)): diff --git a/panel/panelctl.sh b/panel/panelctl.sh index 9b26765..cda8969 100644 --- a/panel/panelctl.sh +++ b/panel/panelctl.sh @@ -536,24 +536,6 @@ cmd_volume_clear() { 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" @@ -590,6 +572,26 @@ cmd_restore() { log info "run 'panelctl deploy ${name}' to start the app'" } +cmd_inspect_volumes() { + local name="$1" + validate_name "${name}" + load_app "${name}" + + echo "default|${APP_VOLUME_DIR}/data" + + local podman_bin="" + if command -v podman >/dev/null 2>&1; then + podman_bin="$(command -v podman)" + elif [[ -x /run/current-system/sw/bin/podman ]]; then + podman_bin="/run/current-system/sw/bin/podman" + fi + + if [[ -n "${podman_bin}" ]]; then + "${podman_bin}" volume ls --filter label=com.docker.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true + "${podman_bin}" volume ls --filter label=io.podman.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true + fi | sort -u | grep -v '^$' +} + cmd_list() { ensure_base_dirs local found=0 @@ -668,6 +670,14 @@ main() { [[ $# -eq 3 ]] || fail "usage: panelctl restore " cmd_restore "$2" "$3" ;; + volume-clear) + [[ $# -eq 2 ]] || fail "usage: panelctl volume-clear " + cmd_volume_clear "$2" + ;; + inspect-volumes) + [[ $# -eq 2 ]] || fail "usage: panelctl inspect-volumes " + cmd_inspect_volumes "$2" + ;; list) [[ $# -eq 1 ]] || fail "usage: panelctl list" cmd_list