added keep_hourly
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ node_modules/
|
|||||||
# Local environment and secrets
|
# Local environment and secrets
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
.env.example
|
||||||
backend/.env
|
backend/.env
|
||||||
backend/.env.*
|
backend/.env.*
|
||||||
!backend/.env.example
|
!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_REPOSITORY="s3:https://s3.eu-central-1.amazonaws.com/bucket-name"
|
||||||
RESTIC_PASSWORD="restic_encryption_password"
|
RESTIC_PASSWORD="restic_encryption_password"
|
||||||
ZFS_POOL_NAME="incus-pool"
|
ZFS_POOL_NAME="incus-pool"
|
||||||
|
RESTIC_KEEP_HOURLY=0
|
||||||
RESTIC_KEEP_DAILY=7
|
RESTIC_KEEP_DAILY=7
|
||||||
RESTIC_KEEP_WEEKLY=0
|
RESTIC_KEEP_WEEKLY=0
|
||||||
RESTIC_KEEP_MONTHLY=0
|
RESTIC_KEEP_MONTHLY=0
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const config = {
|
|||||||
RESTIC_PASSWORD: process.env.RESTIC_PASSWORD || '',
|
RESTIC_PASSWORD: process.env.RESTIC_PASSWORD || '',
|
||||||
},
|
},
|
||||||
retention: {
|
retention: {
|
||||||
|
keepHourly: Number(process.env.RESTIC_KEEP_HOURLY || 0),
|
||||||
keepDaily: Number(process.env.RESTIC_KEEP_DAILY || 7),
|
keepDaily: Number(process.env.RESTIC_KEEP_DAILY || 7),
|
||||||
keepWeekly: Number(process.env.RESTIC_KEEP_WEEKLY || 0),
|
keepWeekly: Number(process.env.RESTIC_KEEP_WEEKLY || 0),
|
||||||
keepMonthly: Number(process.env.RESTIC_KEEP_MONTHLY || 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_REPOSITORY', label: 'Restic repository', required: true, secret: false },
|
||||||
{ key: 'RESTIC_PASSWORD', label: 'Restic password', required: true, secret: true },
|
{ key: 'RESTIC_PASSWORD', label: 'Restic password', required: true, secret: true },
|
||||||
{ key: 'ZFS_POOL_NAME', label: 'ZFS pool name', required: true, secret: false },
|
{ 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_DAILY', label: 'Keep daily snapshots', required: false, secret: false },
|
||||||
{ key: 'RESTIC_KEEP_WEEKLY', label: 'Keep weekly 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 },
|
{ 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.AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || '';
|
||||||
config.resticEnv.RESTIC_REPOSITORY = process.env.RESTIC_REPOSITORY || '';
|
config.resticEnv.RESTIC_REPOSITORY = process.env.RESTIC_REPOSITORY || '';
|
||||||
config.resticEnv.RESTIC_PASSWORD = process.env.RESTIC_PASSWORD || '';
|
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.keepDaily = Number(process.env.RESTIC_KEEP_DAILY || 7);
|
||||||
config.retention.keepWeekly = Number(process.env.RESTIC_KEEP_WEEKLY || 0);
|
config.retention.keepWeekly = Number(process.env.RESTIC_KEEP_WEEKLY || 0);
|
||||||
config.retention.keepMonthly = Number(process.env.RESTIC_KEEP_MONTHLY || 0);
|
config.retention.keepMonthly = Number(process.env.RESTIC_KEEP_MONTHLY || 0);
|
||||||
|
|||||||
@@ -117,14 +117,16 @@ function formatBytes(value) {
|
|||||||
|
|
||||||
function retentionArgs(vmName) {
|
function retentionArgs(vmName) {
|
||||||
const args = ['forget', '--tag', vmName, '--prune'];
|
const args = ['forget', '--tag', vmName, '--prune'];
|
||||||
|
const keepHourly = positiveInteger(config.retention.keepHourly);
|
||||||
const keepDaily = positiveInteger(config.retention.keepDaily);
|
const keepDaily = positiveInteger(config.retention.keepDaily);
|
||||||
const keepWeekly = positiveInteger(config.retention.keepWeekly);
|
const keepWeekly = positiveInteger(config.retention.keepWeekly);
|
||||||
const keepMonthly = positiveInteger(config.retention.keepMonthly);
|
const keepMonthly = positiveInteger(config.retention.keepMonthly);
|
||||||
|
|
||||||
|
if (keepHourly) args.push('--keep-hourly', String(keepHourly));
|
||||||
if (keepDaily) args.push('--keep-daily', String(keepDaily));
|
if (keepDaily) args.push('--keep-daily', String(keepDaily));
|
||||||
if (keepWeekly) args.push('--keep-weekly', String(keepWeekly));
|
if (keepWeekly) args.push('--keep-weekly', String(keepWeekly));
|
||||||
if (keepMonthly) args.push('--keep-monthly', String(keepMonthly));
|
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;
|
return args;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router } from 'express';
|
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();
|
export const snapshotsRouter = Router();
|
||||||
|
|
||||||
@@ -10,3 +11,27 @@ snapshotsRouter.get('/:vmName', async (req, res, next) => {
|
|||||||
next(error);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<section className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/70">
|
<section className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/70">
|
||||||
<div className="border-b border-zinc-800 px-4 py-3">
|
<div className="border-b border-zinc-800 px-4 py-3">
|
||||||
@@ -23,6 +23,15 @@ export function SnapshotTable({ onRestore, snapshots }) {
|
|||||||
<td className="px-4 py-3 text-zinc-300">{formatTime(snapshot.time)}</td>
|
<td className="px-4 py-3 text-zinc-300">{formatTime(snapshot.time)}</td>
|
||||||
<td className="px-4 py-3 text-zinc-400">{(snapshot.tags || []).join(', ') || '-'}</td>
|
<td className="px-4 py-3 text-zinc-400">{(snapshot.tags || []).join(', ') || '-'}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="inline-flex h-8 items-center gap-2 rounded-md border border-zinc-700 px-2.5 text-xs text-zinc-100 hover:border-cyan-500 hover:text-cyan-200"
|
||||||
|
onClick={() => onInspect(snapshot)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
View
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="inline-flex h-8 items-center gap-2 rounded-md border border-red-900 px-2.5 text-xs text-red-300 hover:border-red-600 hover:text-red-200"
|
className="inline-flex h-8 items-center gap-2 rounded-md border border-red-900 px-2.5 text-xs text-red-300 hover:border-red-600 hover:text-red-200"
|
||||||
onClick={() => onRestore(snapshot)}
|
onClick={() => onRestore(snapshot)}
|
||||||
@@ -31,6 +40,7 @@ export function SnapshotTable({ onRestore, snapshots }) {
|
|||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
Restore
|
Restore
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { api, errorMessage } from '../api.js';
|
import { api, errorMessage } from '../api.js';
|
||||||
import { JobStatusPanel } from './JobStatusPanel.jsx';
|
import { JobStatusPanel } from './JobStatusPanel.jsx';
|
||||||
import { RestoreModal } from './RestoreModal.jsx';
|
import { RestoreModal } from './RestoreModal.jsx';
|
||||||
|
import { SnapshotFilesPanel } from './SnapshotFilesPanel.jsx';
|
||||||
import { SnapshotTable } from './SnapshotTable.jsx';
|
import { SnapshotTable } from './SnapshotTable.jsx';
|
||||||
|
|
||||||
export function VMDetail({ jobs, onBack, onChanged, vm }) {
|
export function VMDetail({ jobs, onBack, onChanged, vm }) {
|
||||||
const [snapshots, setSnapshots] = useState([]);
|
const [snapshots, setSnapshots] = useState([]);
|
||||||
const [selectedSnapshot, setSelectedSnapshot] = useState(null);
|
const [selectedSnapshot, setSelectedSnapshot] = useState(null);
|
||||||
|
const [inspectedSnapshot, setInspectedSnapshot] = useState(null);
|
||||||
|
const [snapshotFiles, setSnapshotFiles] = useState([]);
|
||||||
|
const [snapshotFilesLoading, setSnapshotFilesLoading] = useState(false);
|
||||||
const [confirmText, setConfirmText] = useState('');
|
const [confirmText, setConfirmText] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
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 (
|
return (
|
||||||
<section className="space-y-5">
|
<section className="space-y-5">
|
||||||
<div className="flex flex-col gap-4 border-b border-zinc-800 pb-5 sm:flex-row sm:items-end sm:justify-between">
|
<div className="flex flex-col gap-4 border-b border-zinc-800 pb-5 sm:flex-row sm:items-end sm:justify-between">
|
||||||
@@ -95,7 +114,20 @@ export function VMDetail({ jobs, onBack, onChanged, vm }) {
|
|||||||
{error ? <div className="rounded-md border border-amber-900 bg-amber-950/50 px-4 py-3 text-sm text-amber-200">{error}</div> : null}
|
{error ? <div className="rounded-md border border-amber-900 bg-amber-950/50 px-4 py-3 text-sm text-amber-200">{error}</div> : null}
|
||||||
|
|
||||||
<JobStatusPanel job={visibleJob} />
|
<JobStatusPanel job={visibleJob} />
|
||||||
<SnapshotTable snapshots={snapshots} onRestore={(snapshot) => setSelectedSnapshot(snapshot)} />
|
<SnapshotTable
|
||||||
|
snapshots={snapshots}
|
||||||
|
onInspect={inspectSnapshot}
|
||||||
|
onRestore={(snapshot) => setSelectedSnapshot(snapshot)}
|
||||||
|
/>
|
||||||
|
<SnapshotFilesPanel
|
||||||
|
files={snapshotFiles}
|
||||||
|
loading={snapshotFilesLoading}
|
||||||
|
onClose={() => {
|
||||||
|
setInspectedSnapshot(null);
|
||||||
|
setSnapshotFiles([]);
|
||||||
|
}}
|
||||||
|
snapshot={inspectedSnapshot}
|
||||||
|
/>
|
||||||
<RestoreModal
|
<RestoreModal
|
||||||
busy={busy}
|
busy={busy}
|
||||||
confirmText={confirmText}
|
confirmText={confirmText}
|
||||||
|
|||||||
Reference in New Issue
Block a user