Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
126d6b505a | ||
| c758c4d904 | |||
| ffe463e130 | |||
|
|
a8447d6ee1 | ||
| 91d429c4dd | |||
| 505ebd9325 | |||
| d0e39c92b2 |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,3 +1,18 @@
|
|||||||
|
## [1.2.4](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.2.3...v1.2.4) (2026-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bundle latest backup downloads ([ffe463e](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/ffe463e13034601843446514abbd7c69cbaee081))
|
||||||
|
|
||||||
|
## [1.2.3](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.2.2...v1.2.3) (2026-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add scroll to backup history ([505ebd9](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/505ebd9325c0aa54adb034c012c45c913bb36d73))
|
||||||
|
* restore backup history listing ([d0e39c9](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/d0e39c92b270993c99cde0eed8577c6dde817fdd))
|
||||||
|
|
||||||
## [1.2.2](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.2.1...v1.2.2) (2026-03-10)
|
## [1.2.2](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.2.1...v1.2.2) (2026-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ watch(() => props.folder, async (folder) => {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await $fetch<string[]>(`/api/backups?folder=${folder}`)
|
const data = await $fetch<string[]>(`/api/backups?folder=${encodeURIComponent(folder)}`)
|
||||||
backups.value = data.slice(0, 6)
|
backups.value = data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur récupération backups:", error)
|
console.error("Erreur récupération backups:", error)
|
||||||
backups.value = []
|
backups.value = []
|
||||||
@@ -124,6 +124,9 @@ watch(() => props.folder, async (folder) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
|
max-height: calc((2.875rem * 5) + (0.375rem * 4));
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-row {
|
.file-row {
|
||||||
|
|||||||
@@ -132,6 +132,15 @@ const triggerDownload = (folder: string, file: string) => {
|
|||||||
link.remove()
|
link.remove()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const triggerBatchDownload = (folders: string[]) => {
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = `/api/download-latest?folders=${encodeURIComponent(folders.join(","))}`
|
||||||
|
link.style.display = "none"
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
}
|
||||||
|
|
||||||
const downloadLatestBackup = async (folder: string) => {
|
const downloadLatestBackup = async (folder: string) => {
|
||||||
const latestFile = await fetchLatestBackup(folder)
|
const latestFile = await fetchLatestBackup(folder)
|
||||||
|
|
||||||
@@ -147,6 +156,11 @@ const handleScriptResult = async (payload: ScriptResult) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.downloadFolders.length > 1) {
|
||||||
|
triggerBatchDownload(payload.downloadFolders)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for (const folder of payload.downloadFolders) {
|
for (const folder of payload.downloadFolders) {
|
||||||
try {
|
try {
|
||||||
await downloadLatestBackup(folder)
|
await downloadLatestBackup(folder)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { execFile } from "node:child_process"
|
import { execFile } from "node:child_process"
|
||||||
import folderMap from "../config/backup-folders.json"
|
import folderMap from "../config/backup-folders.json"
|
||||||
|
|
||||||
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b@192.168.0.179"
|
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b"
|
||||||
const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
|
const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
|
||||||
const MAX_FILES_PER_FOLDER = Number(process.env.BACKUPS_MAX_FILES || "200")
|
const MAX_FILES_PER_FOLDER = Number(process.env.BACKUPS_MAX_FILES || "200")
|
||||||
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
|
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
|
||||||
|
|||||||
139
server/api/download-latest.get.ts
Normal file
139
server/api/download-latest.get.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { execFile, spawn } from "node:child_process"
|
||||||
|
import folderMap from "../config/backup-folders.json"
|
||||||
|
|
||||||
|
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b"
|
||||||
|
const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
|
||||||
|
const FOLDER_MAP = folderMap as Record<string, string>
|
||||||
|
|
||||||
|
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
|
||||||
|
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
|
||||||
|
|
||||||
|
function runSsh(command: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
"ssh",
|
||||||
|
["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command],
|
||||||
|
{ maxBuffer: 10 * 1024 * 1024 },
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
reject(stderr || error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(stdout)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remoteDirExists(remoteDir: string): Promise<boolean> {
|
||||||
|
const output = await runSsh(`[ -d ${shellQuote(remoteDir)} ] && echo yes || echo no`)
|
||||||
|
return output.trim() === "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveFolderRemoteDir(folderName: string): Promise<string | null> {
|
||||||
|
const mapped = FOLDER_MAP[folderName]
|
||||||
|
if (mapped) {
|
||||||
|
return `${REMOTE_ROOT}/${mapped}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const direct = `${REMOTE_ROOT}/${folderName}`
|
||||||
|
if (await remoteDirExists(direct)) {
|
||||||
|
return direct
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = `${REMOTE_ROOT}/bdd_recette/${folderName}`
|
||||||
|
if (await remoteDirExists(nested)) {
|
||||||
|
return nested
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLatestRemoteFile(remoteDir: string): Promise<string | null> {
|
||||||
|
const output = await runSsh(`cd ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n 1`)
|
||||||
|
const fileName = output.trim()
|
||||||
|
return fileName || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContentDisposition(fileName: string) {
|
||||||
|
const asciiName = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_")
|
||||||
|
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { folders } = getQuery(event)
|
||||||
|
const folderParam = typeof folders === "string" ? folders : ""
|
||||||
|
const folderNames = folderParam
|
||||||
|
.split(",")
|
||||||
|
.map((folder) => folder.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (folderNames.length === 0) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Paramètre folders invalide" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderNames.some((folder) => !isSafeFolder(folder))) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Paramètre folders invalide" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueFolders = [...new Set(folderNames)]
|
||||||
|
const archiveEntries: Array<{ remoteDir: string; fileName: string; archiveName: string }> = []
|
||||||
|
|
||||||
|
for (const folderName of uniqueFolders) {
|
||||||
|
const remoteDir = await resolveFolderRemoteDir(folderName)
|
||||||
|
if (!remoteDir) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = await getLatestRemoteFile(remoteDir)
|
||||||
|
if (!fileName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveEntries.push({
|
||||||
|
remoteDir,
|
||||||
|
fileName,
|
||||||
|
archiveName: `${folderName}/${fileName}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (archiveEntries.length === 0) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: "Aucun fichier a telecharger" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateLabel = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")
|
||||||
|
const archiveName = `backup-latest-${dateLabel}.tar.gz`
|
||||||
|
const tarArgs = archiveEntries.flatMap(({ remoteDir, fileName, archiveName: entryName }) => [
|
||||||
|
"--transform",
|
||||||
|
shellQuote(`s|^${fileName}$|${entryName}|`),
|
||||||
|
"-C",
|
||||||
|
shellQuote(remoteDir),
|
||||||
|
shellQuote(fileName)
|
||||||
|
])
|
||||||
|
const remoteCommand = `tar -czf - ${tarArgs.join(" ")}`
|
||||||
|
|
||||||
|
setHeader(event, "Content-Type", "application/gzip")
|
||||||
|
setHeader(event, "Content-Disposition", buildContentDisposition(archiveName))
|
||||||
|
|
||||||
|
const child = spawn("ssh", [
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"-o",
|
||||||
|
"ConnectTimeout=5",
|
||||||
|
REMOTE_HOST,
|
||||||
|
remoteCommand
|
||||||
|
])
|
||||||
|
|
||||||
|
let stderr = ""
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.error(`Erreur archive SSH (${code}): ${stderr}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return sendStream(event, child.stdout)
|
||||||
|
})
|
||||||
@@ -2,7 +2,7 @@ import { execFile, spawn } from "node:child_process"
|
|||||||
import { Readable } from "node:stream"
|
import { Readable } from "node:stream"
|
||||||
import folderMap from "../config/backup-folders.json"
|
import folderMap from "../config/backup-folders.json"
|
||||||
|
|
||||||
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b@192.168.0.179"
|
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b"
|
||||||
const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
|
const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
|
||||||
const FOLDER_MAP = folderMap as Record<string, string>
|
const FOLDER_MAP = folderMap as Record<string, string>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"ferme": "bdd_recette/ferme",
|
"ferme": "bdd-recette/ferme",
|
||||||
"inventory": "bdd_recette/inventory",
|
"inventory": "bdd-recette/inventory",
|
||||||
"sirh": "bdd_recette/sirh",
|
"sirh": "bdd-recette/sirh",
|
||||||
"user": "bdd_recette/user",
|
"user": "bdd-recette/user",
|
||||||
"bitwarden": "bitwarden"
|
"bitwarden": "bitwarden"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user