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