From 15c4dd9a8bdb2f408eb062c9d110cec8d328696d Mon Sep 17 00:00:00 2001 From: Philipp Date: Thu, 21 May 2026 09:17:51 +0200 Subject: [PATCH] added keep_hourly --- .gitignore | 2 +- backend/.env.example | 1 + backend/src/config.js | 3 ++ backend/src/routes/backup.js | 4 ++- backend/src/routes/snapshots.js | 27 +++++++++++++++++- frontend/src/components/SnapshotTable.jsx | 30 +++++++++++++------- frontend/src/components/VMDetail.jsx | 34 ++++++++++++++++++++++- 7 files changed, 87 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 024fc2f..12f230b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ node_modules/ # Local environment and secrets .env .env.* -!.env.example +.env.example backend/.env backend/.env.* !backend/.env.example diff --git a/backend/.env.example b/backend/.env.example index de49d5e..bb414a7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,6 +3,7 @@ AWS_SECRET_ACCESS_KEY="your_s3_secret" RESTIC_REPOSITORY="s3:https://s3.eu-central-1.amazonaws.com/bucket-name" RESTIC_PASSWORD="restic_encryption_password" ZFS_POOL_NAME="incus-pool" +RESTIC_KEEP_HOURLY=0 RESTIC_KEEP_DAILY=7 RESTIC_KEEP_WEEKLY=0 RESTIC_KEEP_MONTHLY=0 diff --git a/backend/src/config.js b/backend/src/config.js index ccddd49..d4b51af 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -25,6 +25,7 @@ export const config = { RESTIC_PASSWORD: process.env.RESTIC_PASSWORD || '', }, retention: { + keepHourly: Number(process.env.RESTIC_KEEP_HOURLY || 0), keepDaily: Number(process.env.RESTIC_KEEP_DAILY || 7), keepWeekly: Number(process.env.RESTIC_KEEP_WEEKLY || 0), keepMonthly: Number(process.env.RESTIC_KEEP_MONTHLY || 0), @@ -37,6 +38,7 @@ export const editableEnv = [ { key: 'RESTIC_REPOSITORY', label: 'Restic repository', required: true, secret: false }, { key: 'RESTIC_PASSWORD', label: 'Restic password', required: true, secret: true }, { key: 'ZFS_POOL_NAME', label: 'ZFS pool name', required: true, secret: false }, + { key: 'RESTIC_KEEP_HOURLY', label: 'Keep hourly snapshots', required: false, secret: false }, { key: 'RESTIC_KEEP_DAILY', label: 'Keep daily snapshots', required: false, secret: false }, { key: 'RESTIC_KEEP_WEEKLY', label: 'Keep weekly snapshots', required: false, secret: false }, { key: 'RESTIC_KEEP_MONTHLY', label: 'Keep monthly snapshots', required: false, secret: false }, @@ -111,6 +113,7 @@ function applyRuntimeEnv(values) { config.resticEnv.AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || ''; config.resticEnv.RESTIC_REPOSITORY = process.env.RESTIC_REPOSITORY || ''; config.resticEnv.RESTIC_PASSWORD = process.env.RESTIC_PASSWORD || ''; + config.retention.keepHourly = Number(process.env.RESTIC_KEEP_HOURLY || 0); config.retention.keepDaily = Number(process.env.RESTIC_KEEP_DAILY || 7); config.retention.keepWeekly = Number(process.env.RESTIC_KEEP_WEEKLY || 0); config.retention.keepMonthly = Number(process.env.RESTIC_KEEP_MONTHLY || 0); diff --git a/backend/src/routes/backup.js b/backend/src/routes/backup.js index 611afb1..96130c2 100644 --- a/backend/src/routes/backup.js +++ b/backend/src/routes/backup.js @@ -117,14 +117,16 @@ function formatBytes(value) { function retentionArgs(vmName) { const args = ['forget', '--tag', vmName, '--prune']; + const keepHourly = positiveInteger(config.retention.keepHourly); const keepDaily = positiveInteger(config.retention.keepDaily); const keepWeekly = positiveInteger(config.retention.keepWeekly); const keepMonthly = positiveInteger(config.retention.keepMonthly); + if (keepHourly) args.push('--keep-hourly', String(keepHourly)); if (keepDaily) args.push('--keep-daily', String(keepDaily)); if (keepWeekly) args.push('--keep-weekly', String(keepWeekly)); if (keepMonthly) args.push('--keep-monthly', String(keepMonthly)); - if (!keepDaily && !keepWeekly && !keepMonthly) args.push('--keep-daily', '7'); + if (!keepHourly && !keepDaily && !keepWeekly && !keepMonthly) args.push('--keep-daily', '7'); return args; } diff --git a/backend/src/routes/snapshots.js b/backend/src/routes/snapshots.js index 066a6b4..2fb8934 100644 --- a/backend/src/routes/snapshots.js +++ b/backend/src/routes/snapshots.js @@ -1,5 +1,6 @@ import { Router } from 'express'; -import { listSnapshotsForVm } from '../validators.js'; +import { runRestic } from '../executor.js'; +import { assertSnapshotIdShape, listSnapshotsForVm, validateSnapshotForVm } from '../validators.js'; export const snapshotsRouter = Router(); @@ -10,3 +11,27 @@ snapshotsRouter.get('/:vmName', async (req, res, next) => { next(error); } }); + +snapshotsRouter.get('/:vmName/:snapshotId/files', async (req, res, next) => { + try { + const snapshot = await validateSnapshotForVm(req.params.vmName, req.params.snapshotId); + assertSnapshotIdShape(snapshot.id); + const result = await runRestic(['ls', '--json', snapshot.id]); + const entries = result.stdout + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + .filter((entry) => entry.struct_type === 'node') + .map((entry) => ({ + name: entry.name, + path: entry.path, + type: entry.type, + size: entry.size || 0, + mode: entry.mode || '', + mtime: entry.mtime || null, + })); + res.json(entries); + } catch (error) { + next(error); + } +}); diff --git a/frontend/src/components/SnapshotTable.jsx b/frontend/src/components/SnapshotTable.jsx index 774afc9..128390e 100644 --- a/frontend/src/components/SnapshotTable.jsx +++ b/frontend/src/components/SnapshotTable.jsx @@ -1,6 +1,6 @@ -import { RotateCcw } from 'lucide-react'; +import { Eye, RotateCcw } from 'lucide-react'; -export function SnapshotTable({ onRestore, snapshots }) { +export function SnapshotTable({ onInspect, onRestore, snapshots }) { return (
@@ -23,14 +23,24 @@ export function SnapshotTable({ onRestore, snapshots }) { {formatTime(snapshot.time)} {(snapshot.tags || []).join(', ') || '-'} - +
+ + +
))} diff --git a/frontend/src/components/VMDetail.jsx b/frontend/src/components/VMDetail.jsx index e7811a4..a3ba214 100644 --- a/frontend/src/components/VMDetail.jsx +++ b/frontend/src/components/VMDetail.jsx @@ -3,11 +3,15 @@ import { useEffect, useMemo, useState } from 'react'; import { api, errorMessage } from '../api.js'; import { JobStatusPanel } from './JobStatusPanel.jsx'; import { RestoreModal } from './RestoreModal.jsx'; +import { SnapshotFilesPanel } from './SnapshotFilesPanel.jsx'; import { SnapshotTable } from './SnapshotTable.jsx'; export function VMDetail({ jobs, onBack, onChanged, vm }) { const [snapshots, setSnapshots] = useState([]); const [selectedSnapshot, setSelectedSnapshot] = useState(null); + const [inspectedSnapshot, setInspectedSnapshot] = useState(null); + const [snapshotFiles, setSnapshotFiles] = useState([]); + const [snapshotFilesLoading, setSnapshotFilesLoading] = useState(false); const [confirmText, setConfirmText] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); @@ -61,6 +65,21 @@ export function VMDetail({ jobs, onBack, onChanged, vm }) { } } + async function inspectSnapshot(snapshot) { + setInspectedSnapshot(snapshot); + setSnapshotFiles([]); + setSnapshotFilesLoading(true); + setError(''); + try { + const result = await api.get(`/snapshots/${encodeURIComponent(vm.name)}/${encodeURIComponent(snapshot.id)}/files`); + setSnapshotFiles(result.data); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setSnapshotFilesLoading(false); + } + } + return (
@@ -95,7 +114,20 @@ export function VMDetail({ jobs, onBack, onChanged, vm }) { {error ?
{error}
: null} - setSelectedSnapshot(snapshot)} /> + setSelectedSnapshot(snapshot)} + /> + { + setInspectedSnapshot(null); + setSnapshotFiles([]); + }} + snapshot={inspectedSnapshot} + />