feat: add volume management features including file upload, download, and clear actions
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
e3e910c9cd
commit
9b0c3aa190
3 changed files with 379 additions and 1 deletions
|
|
@ -365,12 +365,26 @@ const api = {
|
||||||
backup: (n) => api.request(`/apps/${n}/backup`, "POST"),
|
backup: (n) => api.request(`/apps/${n}/backup`, "POST"),
|
||||||
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"),
|
||||||
|
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 ───
|
// ─── State ───
|
||||||
let apps = [];
|
let apps = [];
|
||||||
let expandedApp = null;
|
let expandedApp = null;
|
||||||
let activeTab = {}; // { appName: tabName }
|
let activeTab = {}; // { appName: tabName }
|
||||||
|
let volumeLocations = {}; // { appName: currentPathString }
|
||||||
|
|
||||||
// ─── Status bar ───
|
// ─── Status bar ───
|
||||||
const statusEl = document.getElementById("status");
|
const statusEl = document.getElementById("status");
|
||||||
|
|
@ -474,6 +488,7 @@ function renderAppCard(app) {
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab active" data-tab="compose" data-app="${app.name}">Compose</button>
|
<button class="tab active" data-tab="compose" data-app="${app.name}">Compose</button>
|
||||||
<button class="tab" data-tab="logs" data-app="${app.name}">Logs</button>
|
<button class="tab" data-tab="logs" data-app="${app.name}">Logs</button>
|
||||||
|
<button class="tab" data-tab="volumes" data-app="${app.name}">Volumes</button>
|
||||||
<button class="tab" data-tab="backups" data-app="${app.name}">Backups</button>
|
<button class="tab" data-tab="backups" data-app="${app.name}">Backups</button>
|
||||||
<button class="tab" data-tab="routing" data-app="${app.name}">Routing</button>
|
<button class="tab" data-tab="routing" data-app="${app.name}">Routing</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -497,6 +512,18 @@ function renderAppCard(app) {
|
||||||
</div>
|
</div>
|
||||||
<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="btn-group" style="margin-bottom:10px;">
|
||||||
|
<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 btn-danger" data-action="volume-clear" data-app="${app.name}">Clear Volume</button>
|
||||||
|
</div>
|
||||||
|
<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 class="backup-list" id="volumes-${app.name}">
|
||||||
|
<div style="color:var(--muted);font-size:.85rem;">Click "Refresh Files" to load volume browser.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="tab-panel" id="tab-backups-${app.name}">
|
<div class="tab-panel" id="tab-backups-${app.name}">
|
||||||
<div class="btn-group" style="margin-bottom:10px;">
|
<div class="btn-group" style="margin-bottom:10px;">
|
||||||
<button class="btn-sm btn-primary" data-action="create-backup" data-app="${app.name}">Create Backup</button>
|
<button class="btn-sm btn-primary" data-action="create-backup" data-app="${app.name}">Create Backup</button>
|
||||||
|
|
@ -568,11 +595,13 @@ function attachCardListeners() {
|
||||||
// Update panels
|
// Update panels
|
||||||
document.getElementById(`tab-compose-${app}`).classList.toggle("active", tabName === "compose");
|
document.getElementById(`tab-compose-${app}`).classList.toggle("active", tabName === "compose");
|
||||||
document.getElementById(`tab-logs-${app}`).classList.toggle("active", tabName === "logs");
|
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-backups-${app}`).classList.toggle("active", tabName === "backups");
|
||||||
document.getElementById(`tab-routing-${app}`).classList.toggle("active", tabName === "routing");
|
document.getElementById(`tab-routing-${app}`).classList.toggle("active", tabName === "routing");
|
||||||
|
|
||||||
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, "");
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -655,6 +684,36 @@ async function handleAction(action, name, btnElement) {
|
||||||
await api.renderRoute(name);
|
await api.renderRoute(name);
|
||||||
setStatus(`Route rendered for ${name}.`);
|
setStatus(`Route rendered for ${name}.`);
|
||||||
break;
|
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":
|
case "fetch-routing":
|
||||||
setStatus(`Fetching routing details for ${name}...`);
|
setStatus(`Fetching routing details for ${name}...`);
|
||||||
const appRes = await api.getApp(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 = `
|
||||||
|
<span>${escHtml(pathLabel)}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div style="flex:1;color:var(--accent);font-weight:bold;">
|
||||||
|
📁 ..
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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) {
|
async function loadBackups(name) {
|
||||||
const el = document.getElementById(`backups-${name}`);
|
const el = document.getElementById(`backups-${name}`);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
|
||||||
164
panel-api.py
164
panel-api.py
|
|
@ -311,6 +311,168 @@ 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>/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/<name>/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"})
|
self._json(404, {"ok": False, "error": "not found"})
|
||||||
|
|
||||||
# ── POST ──
|
# ── POST ──
|
||||||
|
|
@ -401,7 +563,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Simple panelctl pass-through actions
|
# 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):
|
if not is_safe_name(name):
|
||||||
self._json(400, {"ok": False, "error": "invalid app name"})
|
self._json(400, {"ok": False, "error": "invalid app name"})
|
||||||
return
|
return
|
||||||
|
|
|
||||||
38
panelctl.sh
38
panelctl.sh
|
|
@ -31,6 +31,7 @@ Usage:
|
||||||
panelctl backup <name>
|
panelctl backup <name>
|
||||||
panelctl list-backups <name>
|
panelctl list-backups <name>
|
||||||
panelctl restore <name> <backup-file>
|
panelctl restore <name> <backup-file>
|
||||||
|
panelctl volume-clear <name>
|
||||||
panelctl validate-compose <name>
|
panelctl validate-compose <name>
|
||||||
panelctl list
|
panelctl list
|
||||||
panelctl show <name>
|
panelctl show <name>
|
||||||
|
|
@ -497,6 +498,7 @@ cmd_backup() {
|
||||||
cmd_list_backups() {
|
cmd_list_backups() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
validate_name "${name}"
|
validate_name "${name}"
|
||||||
|
load_app "${name}"
|
||||||
|
|
||||||
ensure_base_dirs
|
ensure_base_dirs
|
||||||
|
|
||||||
|
|
@ -516,6 +518,42 @@ cmd_list_backups() {
|
||||||
fi
|
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() {
|
cmd_restore() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
local backup_file="$2"
|
local backup_file="$2"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue