feat: enhance volume management with support for multiple volumes and improved API endpoints
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
479d84bc27
commit
57ace80ecd
3 changed files with 133 additions and 41 deletions
|
|
@ -366,10 +366,11 @@ const api = {
|
||||||
restore: (n, f) => api.request(`/apps/${n}/restore`, "POST", { file: f }),
|
restore: (n, f) => api.request(`/apps/${n}/restore`, "POST", { file: f }),
|
||||||
renderRoute: (n) => api.request(`/apps/${n}/render-route`, "POST"),
|
renderRoute: (n) => api.request(`/apps/${n}/render-route`, "POST"),
|
||||||
clearVolume: (n) => api.request(`/apps/${n}/volume-clear`, "POST"),
|
clearVolume: (n) => api.request(`/apps/${n}/volume-clear`, "POST"),
|
||||||
getVolumeFiles: (n, p) => api.request(`/apps/${n}/volume/files?path=${encodeURIComponent(p)}`),
|
getVolumes: (n) => api.request(`/apps/${n}/volumes`),
|
||||||
deleteFile: (n, p) => api.request(`/apps/${n}/volume/files?path=${encodeURIComponent(p)}`, "DELETE"),
|
getVolumeFiles: (n, p, v) => api.request(`/apps/${n}/volume/files?vol=${encodeURIComponent(v || "default")}&path=${encodeURIComponent(p)}`),
|
||||||
uploadFile: async (n, p, f) => {
|
deleteFile: (n, p, v) => api.request(`/apps/${n}/volume/files?vol=${encodeURIComponent(v || "default")}&path=${encodeURIComponent(p)}`, "DELETE"),
|
||||||
const res = await fetch(`/apps/${n}/volume/files?path=${encodeURIComponent(p)}`, {
|
uploadFile: async (n, p, v, f) => {
|
||||||
|
const res = await fetch(`/apps/${n}/volume/files?vol=${encodeURIComponent(v || "default")}&path=${encodeURIComponent(p)}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: f,
|
body: f,
|
||||||
headers: { "Content-Length": f.size.toString() }
|
headers: { "Content-Length": f.size.toString() }
|
||||||
|
|
@ -385,6 +386,7 @@ let apps = [];
|
||||||
let expandedApp = null;
|
let expandedApp = null;
|
||||||
let activeTab = {}; // { appName: tabName }
|
let activeTab = {}; // { appName: tabName }
|
||||||
let volumeLocations = {}; // { appName: currentPathString }
|
let volumeLocations = {}; // { appName: currentPathString }
|
||||||
|
let selectedVolumes = {}; // { appName: currentVolumeString }
|
||||||
|
|
||||||
// ─── Status bar ───
|
// ─── Status bar ───
|
||||||
const statusEl = document.getElementById("status");
|
const statusEl = document.getElementById("status");
|
||||||
|
|
@ -513,10 +515,11 @@ function renderAppCard(app) {
|
||||||
<div class="log-output" id="logs-${app.name}">Click "Refresh Logs" to load.</div>
|
<div class="log-output" id="logs-${app.name}">Click "Refresh Logs" to load.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-panel" id="tab-volumes-${app.name}">
|
<div class="tab-panel" id="tab-volumes-${app.name}">
|
||||||
<div class="btn-group" style="margin-bottom:10px;">
|
<div class="btn-group" style="margin-bottom:10px; align-items:center;">
|
||||||
|
<select id="volume-select-${app.name}" style="width:140px;padding:4px 8px;font-size:0.8rem;"></select>
|
||||||
<button class="btn-sm btn-primary" data-action="volume-refresh" data-app="${app.name}">Refresh Files</button>
|
<button class="btn-sm btn-primary" data-action="volume-refresh" data-app="${app.name}">Refresh Files</button>
|
||||||
<button class="btn-sm" data-action="volume-upload" data-app="${app.name}">Upload</button>
|
<button class="btn-sm" data-action="volume-upload" data-app="${app.name}">Upload</button>
|
||||||
<button class="btn-sm btn-danger" data-action="volume-clear" data-app="${app.name}">Clear Volume</button>
|
<button class="btn-sm btn-danger" data-action="volume-clear" data-app="${app.name}" id="btn-vol-clear-${app.name}">Clear Bind</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="file" id="volume-upload-input-${app.name}" style="display:none;" />
|
<input type="file" id="volume-upload-input-${app.name}" style="display:none;" />
|
||||||
<div style="margin-bottom:8px;font-size:0.85rem;" id="volume-path-${app.name}">/</div>
|
<div style="margin-bottom:8px;font-size:0.85rem;" id="volume-path-${app.name}">/</div>
|
||||||
|
|
@ -601,7 +604,7 @@ function attachCardListeners() {
|
||||||
|
|
||||||
if (tabName === "logs") loadLogs(app);
|
if (tabName === "logs") loadLogs(app);
|
||||||
if (tabName === "backups") loadBackups(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;
|
break;
|
||||||
case "volume-refresh":
|
case "volume-refresh":
|
||||||
const curPath = volumeLocations[name] || "";
|
const curPath = volumeLocations[name] || "";
|
||||||
await loadVolume(name, curPath);
|
await loadVolume(name, curPath, selectedVolumes[name]);
|
||||||
break;
|
break;
|
||||||
case "volume-clear":
|
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}...`);
|
setStatus(`Clearing volume for ${name}...`);
|
||||||
await api.clearVolume(name);
|
await api.clearVolume(name);
|
||||||
setStatus(`Volume cleared for ${name}.`);
|
setStatus(`Volume cleared for ${name}.`);
|
||||||
await loadVolume(name, "");
|
await loadVolume(name, "", selectedVolumes[name]);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "volume-upload":
|
case "volume-upload":
|
||||||
|
|
@ -704,9 +711,9 @@ async function handleAction(action, name, btnElement) {
|
||||||
const uploadPath = (volumeLocations[name] ? volumeLocations[name] + "/" : "") + file.name;
|
const uploadPath = (volumeLocations[name] ? volumeLocations[name] + "/" : "") + file.name;
|
||||||
try {
|
try {
|
||||||
setStatus(`Uploading ${file.name} to ${name}...`);
|
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.`);
|
setStatus(`Uploaded ${file.name} successfully.`);
|
||||||
await loadVolume(name, volumeLocations[name] || "");
|
await loadVolume(name, volumeLocations[name] || "", selectedVolumes[name]);
|
||||||
} catch(uploadErr) {
|
} catch(uploadErr) {
|
||||||
setStatus(`Upload failed: ${uploadErr.message}`, true);
|
setStatus(`Upload failed: ${uploadErr.message}`, true);
|
||||||
}
|
}
|
||||||
|
|
@ -821,7 +828,38 @@ async function loadLogs(name) {
|
||||||
|
|
||||||
window.api = api; // Expose for inline html onclicks
|
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;
|
volumeLocations[name] = currentPath;
|
||||||
const pathLabel = currentPath ? currentPath : "/";
|
const pathLabel = currentPath ? currentPath : "/";
|
||||||
document.getElementById(`volume-path-${name}`).innerHTML = `
|
document.getElementById(`volume-path-${name}`).innerHTML = `
|
||||||
|
|
@ -829,7 +867,7 @@ async function loadVolume(name, currentPath) {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.getVolumeFiles(name, currentPath);
|
const data = await api.getVolumeFiles(name, currentPath, volName);
|
||||||
const container = document.getElementById(`volumes-${name}`);
|
const container = document.getElementById(`volumes-${name}`);
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
|
@ -838,7 +876,7 @@ async function loadVolume(name, currentPath) {
|
||||||
const item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
item.className = "backup-item";
|
item.className = "backup-item";
|
||||||
item.style.cursor = "pointer";
|
item.style.cursor = "pointer";
|
||||||
item.onclick = () => loadVolume(name, upPath);
|
item.onclick = () => loadVolume(name, upPath, volName);
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div style="flex:1;color:var(--accent);font-weight:bold;">
|
<div style="flex:1;color:var(--accent);font-weight:bold;">
|
||||||
📁 ..
|
📁 ..
|
||||||
|
|
@ -886,7 +924,7 @@ async function loadVolume(name, currentPath) {
|
||||||
if (f.is_dir) {
|
if (f.is_dir) {
|
||||||
spanName.style.cursor = "pointer";
|
spanName.style.cursor = "pointer";
|
||||||
spanName.style.color = "var(--accent)";
|
spanName.style.color = "var(--accent)";
|
||||||
spanName.onclick = () => loadVolume(name, fullPath);
|
spanName.onclick = () => loadVolume(name, fullPath, volName);
|
||||||
}
|
}
|
||||||
|
|
||||||
titleDiv.appendChild(spanIcon);
|
titleDiv.appendChild(spanIcon);
|
||||||
|
|
@ -910,7 +948,7 @@ async function loadVolume(name, currentPath) {
|
||||||
downloadBtn.style.padding = "2px 6px";
|
downloadBtn.style.padding = "2px 6px";
|
||||||
downloadBtn.style.fontSize = "0.75rem";
|
downloadBtn.style.fontSize = "0.75rem";
|
||||||
downloadBtn.textContent = "⬇️";
|
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);
|
actionsDiv.appendChild(downloadBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -921,8 +959,8 @@ async function loadVolume(name, currentPath) {
|
||||||
delBtn.textContent = "🗑️";
|
delBtn.textContent = "🗑️";
|
||||||
delBtn.onclick = async () => {
|
delBtn.onclick = async () => {
|
||||||
if (confirm(`Delete ${f.name}?`)) {
|
if (confirm(`Delete ${f.name}?`)) {
|
||||||
await window.api.deleteFile(name, fullPath);
|
await window.api.deleteFile(name, fullPath, volName);
|
||||||
loadVolume(name, currentPath);
|
loadVolume(name, currentPath, volName);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
actionsDiv.appendChild(delBtn);
|
actionsDiv.appendChild(delBtn);
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,16 @@ def parse_env_blob(blob):
|
||||||
return out
|
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):
|
def read_app_info(name):
|
||||||
if not is_safe_name(name):
|
if not is_safe_name(name):
|
||||||
return None, {"ok": False, "error": "invalid app 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"])})
|
self._json(200, {"ok": True, "app": parse_env_blob(result["stdout"])})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# /apps/<name>/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/<name>/volume/files
|
# /apps/<name>/volume/files
|
||||||
if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "files":
|
if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "files":
|
||||||
name = parts[1]
|
name = parts[1]
|
||||||
|
|
@ -319,8 +339,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._json(404, err)
|
self._json(404, err)
|
||||||
return
|
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("/")
|
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))
|
target_dir = os.path.abspath(os.path.join(data_dir, subpath))
|
||||||
|
|
||||||
# Ensure traversal didn't escape data_dir
|
# Ensure traversal didn't escape data_dir
|
||||||
|
|
@ -364,12 +390,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._json(404, err)
|
self._json(404, err)
|
||||||
return
|
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("/")
|
subpath = query.get("path", [""])[0].strip("/")
|
||||||
if not subpath:
|
if not subpath:
|
||||||
self._json(400, {"ok": False, "error": "path parameter required"})
|
self._json(400, {"ok": False, "error": "path parameter required"})
|
||||||
return
|
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))
|
target_file = os.path.abspath(os.path.join(data_dir, subpath))
|
||||||
|
|
||||||
if not target_file.startswith(os.path.abspath(data_dir)):
|
if not target_file.startswith(os.path.abspath(data_dir)):
|
||||||
|
|
@ -406,12 +438,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._json(404, err)
|
self._json(404, err)
|
||||||
return
|
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("/")
|
subpath = query.get("path", [""])[0].strip("/")
|
||||||
if not subpath:
|
if not subpath:
|
||||||
self._json(400, {"ok": False, "error": "path parameter required"})
|
self._json(400, {"ok": False, "error": "path parameter required"})
|
||||||
return
|
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))
|
target_file = os.path.abspath(os.path.join(data_dir, subpath))
|
||||||
if not target_file.startswith(os.path.abspath(data_dir)):
|
if not target_file.startswith(os.path.abspath(data_dir)):
|
||||||
self._json(403, {"ok": False, "error": "path traversal denied"})
|
self._json(403, {"ok": False, "error": "path traversal denied"})
|
||||||
|
|
@ -446,12 +484,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._json(404, err)
|
self._json(404, err)
|
||||||
return
|
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("/")
|
subpath = query.get("path", [""])[0].strip("/")
|
||||||
if not subpath:
|
if not subpath:
|
||||||
self._json(400, {"ok": False, "error": "path parameter required"})
|
self._json(400, {"ok": False, "error": "path parameter required"})
|
||||||
return
|
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))
|
target_file = os.path.abspath(os.path.join(data_dir, subpath))
|
||||||
|
|
||||||
if not target_file.startswith(os.path.abspath(data_dir)):
|
if not target_file.startswith(os.path.abspath(data_dir)):
|
||||||
|
|
|
||||||
|
|
@ -536,24 +536,6 @@ cmd_volume_clear() {
|
||||||
log info "volume data cleared for app '${name}'"
|
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() {
|
cmd_restore() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
local backup_file="$2"
|
local backup_file="$2"
|
||||||
|
|
@ -590,6 +572,26 @@ cmd_restore() {
|
||||||
log info "run 'panelctl deploy ${name}' to start the app'"
|
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() {
|
cmd_list() {
|
||||||
ensure_base_dirs
|
ensure_base_dirs
|
||||||
local found=0
|
local found=0
|
||||||
|
|
@ -668,6 +670,14 @@ main() {
|
||||||
[[ $# -eq 3 ]] || fail "usage: panelctl restore <name> <backup-file>"
|
[[ $# -eq 3 ]] || fail "usage: panelctl restore <name> <backup-file>"
|
||||||
cmd_restore "$2" "$3"
|
cmd_restore "$2" "$3"
|
||||||
;;
|
;;
|
||||||
|
volume-clear)
|
||||||
|
[[ $# -eq 2 ]] || fail "usage: panelctl volume-clear <name>"
|
||||||
|
cmd_volume_clear "$2"
|
||||||
|
;;
|
||||||
|
inspect-volumes)
|
||||||
|
[[ $# -eq 2 ]] || fail "usage: panelctl inspect-volumes <name>"
|
||||||
|
cmd_inspect_volumes "$2"
|
||||||
|
;;
|
||||||
list)
|
list)
|
||||||
[[ $# -eq 1 ]] || fail "usage: panelctl list"
|
[[ $# -eq 1 ]] || fail "usage: panelctl list"
|
||||||
cmd_list
|
cmd_list
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue