Add automatic backups and an admin restore page
Scheduler ticks every 5 minutes and snapshots data/avtoambor.db (via better-sqlite3's online backup API) when the DB file's mtime has advanced. After each new backup, prune older snapshots: keep everything from the last 7 days, then one per calendar day. New /admin page lists backups with Download and Restore actions, plus a Back-up-now button. Restore takes a safety snapshot first, closes the live connection, swaps the .db file, and lets the next request reopen. Also: TZ=Asia/Dushanbe in the container so backup filenames use local time, and tzdata added to the image so TZ takes effect. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,6 +2,7 @@ node_modules/
|
||||
.svelte-kit/
|
||||
build/
|
||||
data/
|
||||
backups/
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@ -2,7 +2,7 @@ FROM node:20-bookworm-slim
|
||||
|
||||
# Tools needed to compile better-sqlite3
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Run as the node user (uid 1000) — already exists in node images
|
||||
|
||||
@ -15,4 +15,5 @@ services:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- TZ=Asia/Dushanbe
|
||||
command: npm run dev
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { getDb } from '$lib/server/db.js';
|
||||
import { startBackupScheduler } from '$lib/server/backup.js';
|
||||
|
||||
// Open (and warm) the database on server startup so the first request
|
||||
// doesn't pay the cost.
|
||||
getDb();
|
||||
startBackupScheduler();
|
||||
|
||||
/** @type {import('@sveltejs/kit').Handle} */
|
||||
export async function handle({ event, resolve }) {
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
<a href="/parts" class:active={isActive('/parts')}>{$t('nav.parts')}</a>
|
||||
<a href="/movements/new" class:active={isActive('/movements')}>{$t('nav.movements')}</a>
|
||||
<a href="/suppliers" class:active={isActive('/suppliers')}>{$t('nav.suppliers')}</a>
|
||||
<a href="/admin" class:active={isActive('/admin')}>{$t('nav.admin')}</a>
|
||||
</nav>
|
||||
|
||||
<button class="lang" type="button" on:click={toggleLocale} aria-label="Switch language">
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"parts": "Parts",
|
||||
"movements": "Movements",
|
||||
"suppliers": "Suppliers",
|
||||
"admin": "Backups",
|
||||
"new_part": "New part",
|
||||
"new_movement": "Record movement"
|
||||
},
|
||||
@ -97,6 +98,27 @@
|
||||
"not_enough_stock": "Not enough stock on hand."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Backups & Restore",
|
||||
"warning_title": "Important: copy backups to a USB stick regularly!",
|
||||
"warning_body": "Backups are kept on this computer only. If the hard drive fails, all of your data and all backups will be lost. At least once a week, plug in a USB stick and click the Download button next to a recent backup, then save the file onto the stick.",
|
||||
"backup_now": "Back up now",
|
||||
"auto_note": "A backup is taken automatically every 5 minutes when there are changes.",
|
||||
"list_title": "Available backups",
|
||||
"no_backups": "No backups yet.",
|
||||
"created_at": "When",
|
||||
"size": "Size",
|
||||
"download": "Download",
|
||||
"restore": "Restore",
|
||||
"restore_confirm": "Restore the database from this backup? The current data will be replaced (a safety backup of the current data will be made first).",
|
||||
"prune_note": "Backups from the last 7 days are kept in full. Older backups are thinned to one per day automatically.",
|
||||
"flash": {
|
||||
"backup_taken": "Backup created.",
|
||||
"backup_failed": "Backup failed. See the server logs.",
|
||||
"restored": "Database restored.",
|
||||
"restore_failed": "Restore failed. See the server logs."
|
||||
}
|
||||
},
|
||||
"suppliers": {
|
||||
"title": "Suppliers",
|
||||
"name": "Name",
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"parts": "Қисмҳо",
|
||||
"movements": "Ҳаракатҳо",
|
||||
"suppliers": "Таъминкунандагон",
|
||||
"admin": "Нусхаҳо",
|
||||
"new_part": "Қисми нав",
|
||||
"new_movement": "Сабти ҳаракат"
|
||||
},
|
||||
@ -97,6 +98,27 @@
|
||||
"not_enough_stock": "Дар анбор миқдори кофӣ нест."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Нусхабардорӣ ва барқарорсозӣ",
|
||||
"warning_title": "Муҳим: нусхаҳоро мунтазам ба USB-флешка нусхабардорӣ кунед!",
|
||||
"warning_body": "Нусхаҳо танҳо дар ин компютер нигоҳ дошта мешаванд. Агар диски сахт вайрон шавад, ҳамаи маълумот ва ҳамаи нусхаҳо нест мешаванд. Ҳафтае як маротиба USB-флешкаро пайваст кунед, тугмаи «Зеркашӣ»-ро дар сатри як нусхаи нав пахш кунед ва файлро ба флешка захира кунед.",
|
||||
"backup_now": "Ҳозир нусха гирифтан",
|
||||
"auto_note": "Ҳар 5 дақиқа агар тағйирот бошад, нусха худкор гирифта мешавад.",
|
||||
"list_title": "Нусхаҳои мавҷуда",
|
||||
"no_backups": "Ҳоло нусхае нест.",
|
||||
"created_at": "Сана",
|
||||
"size": "Андоза",
|
||||
"download": "Зеркашӣ",
|
||||
"restore": "Барқарор кардан",
|
||||
"restore_confirm": "Маълумотро аз ин нусха барқарор мекунед? Маълумоти ҷорӣ иваз карда мешавад (аввал нусхаи эҳтиётии маълумоти ҷорӣ гирифта мешавад).",
|
||||
"prune_note": "Нусхаҳои 7 рӯзи охир пурра нигоҳ дошта мешаванд. Нусхаҳои кӯҳна то як адад дар як рӯз худкор кам карда мешаванд.",
|
||||
"flash": {
|
||||
"backup_taken": "Нусха сохта шуд.",
|
||||
"backup_failed": "Нусхабардорӣ ноком шуд. Логи серверро бинед.",
|
||||
"restored": "Маълумот барқарор карда шуд.",
|
||||
"restore_failed": "Барқарорсозӣ ноком шуд. Логи серверро бинед."
|
||||
}
|
||||
},
|
||||
"suppliers": {
|
||||
"title": "Таъминкунандагон",
|
||||
"name": "Ном",
|
||||
|
||||
165
src/lib/server/backup.js
Normal file
165
src/lib/server/backup.js
Normal file
@ -0,0 +1,165 @@
|
||||
// Backup / restore for data/avtoambor.db.
|
||||
//
|
||||
// - Scheduler ticks every 5 minutes; takes a backup only if the DB file's
|
||||
// mtime has advanced since the previous backup (so an idle shop doesn't
|
||||
// accumulate identical snapshots).
|
||||
// - Backups land in ./backups/ at the repo root, named
|
||||
// avtoambor-YYYY-MM-DD_HH-MM-SS.db (sortable, human-readable).
|
||||
// - After each new backup, prune older snapshots: keep ALL backups from the
|
||||
// last 7 days; for anything older, keep only the most recent backup of
|
||||
// each calendar day.
|
||||
// - Restore closes the live DB, removes WAL/SHM, copies the chosen backup
|
||||
// over the .db file, and lets the next getDb() reopen.
|
||||
|
||||
import { getDb, closeDb, DB_FILE } from './db.js';
|
||||
import {
|
||||
mkdirSync, existsSync, readdirSync, statSync, unlinkSync, copyFileSync
|
||||
} from 'node:fs';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
|
||||
const DATA_DIR = dirname(DB_FILE);
|
||||
export const BACKUP_DIR = resolve(DATA_DIR, '..', 'backups');
|
||||
const PREFIX = 'avtoambor-';
|
||||
const EXT = '.db';
|
||||
const FILE_RE = /^avtoambor-(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.db$/;
|
||||
const RETAIN_DAYS = 7;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const TICK_MS = 5 * 60 * 1000;
|
||||
|
||||
function ensureDir() {
|
||||
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
function stamp(d = new Date()) {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
||||
`_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function parseStamp(name) {
|
||||
const m = name.match(FILE_RE);
|
||||
if (!m) return null;
|
||||
const [, Y, Mo, D, H, Mi, S] = m;
|
||||
return new Date(+Y, +Mo - 1, +D, +H, +Mi, +S);
|
||||
}
|
||||
|
||||
function dayKey(d) {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
export function safeName(name) {
|
||||
return typeof name === 'string'
|
||||
&& !name.includes('/')
|
||||
&& !name.includes('\\')
|
||||
&& FILE_RE.test(name);
|
||||
}
|
||||
|
||||
export function listBackups() {
|
||||
ensureDir();
|
||||
return readdirSync(BACKUP_DIR)
|
||||
.filter((n) => n.startsWith(PREFIX) && n.endsWith(EXT) && FILE_RE.test(n))
|
||||
.map((name) => {
|
||||
const p = join(BACKUP_DIR, name);
|
||||
const st = statSync(p);
|
||||
return {
|
||||
name,
|
||||
path: p,
|
||||
size: st.size,
|
||||
createdAt: parseStamp(name) ?? st.mtime
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
// Latest mtime across the SQLite triplet (.db, -wal, -shm).
|
||||
function dbSourceMtime() {
|
||||
let max = 0;
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
const p = DB_FILE + suffix;
|
||||
if (existsSync(p)) max = Math.max(max, statSync(p).mtimeMs);
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
export async function takeBackup() {
|
||||
ensureDir();
|
||||
const name = `${PREFIX}${stamp()}${EXT}`;
|
||||
const dest = join(BACKUP_DIR, name);
|
||||
await getDb().backup(dest);
|
||||
const st = statSync(dest);
|
||||
pruneOldBackups();
|
||||
return { name, path: dest, createdAt: new Date(), size: st.size };
|
||||
}
|
||||
|
||||
// Keep everything in the last RETAIN_DAYS; for older snapshots, keep only
|
||||
// the newest one per calendar day. Deletions are silent on error.
|
||||
export function pruneOldBackups(now = new Date()) {
|
||||
const cutoff = now.getTime() - RETAIN_DAYS * DAY_MS;
|
||||
const all = listBackups(); // newest first
|
||||
const seenDays = new Set();
|
||||
for (const b of all) {
|
||||
const t = b.createdAt.getTime();
|
||||
if (t >= cutoff) continue; // inside retention window — keep
|
||||
const key = dayKey(b.createdAt);
|
||||
if (seenDays.has(key)) {
|
||||
try { unlinkSync(b.path); } catch { /* ignore */ }
|
||||
} else {
|
||||
seenDays.add(key); // first (newest) of this day — keep
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreBackup(name) {
|
||||
if (!safeName(name)) throw new Error('Invalid backup name');
|
||||
const src = join(BACKUP_DIR, name);
|
||||
if (!existsSync(src)) throw new Error('Backup not found');
|
||||
|
||||
// Snapshot the current DB first so a misclick is recoverable.
|
||||
try { await takeBackup(); } catch (e) { console.error('[backup] pre-restore snapshot failed', e); }
|
||||
|
||||
// From here on: no awaits. Node is single-threaded so other requests
|
||||
// cannot interleave and reopen the DB on partial state.
|
||||
closeDb();
|
||||
for (const suffix of ['-wal', '-shm']) {
|
||||
const p = DB_FILE + suffix;
|
||||
if (existsSync(p)) {
|
||||
try { unlinkSync(p); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
copyFileSync(src, DB_FILE);
|
||||
// Next getDb() reopens against the restored file.
|
||||
}
|
||||
|
||||
// Module-level scheduler state. globalThis-guarded so dev HMR doesn't stack
|
||||
// multiple intervals on top of each other.
|
||||
const STARTED_FLAG = '__avtoambor_backup_started';
|
||||
|
||||
export function startBackupScheduler() {
|
||||
if (globalThis[STARTED_FLAG]) return;
|
||||
globalThis[STARTED_FLAG] = true;
|
||||
|
||||
ensureDir();
|
||||
|
||||
// Baseline: pretend the most recent backup's timestamp is "what we've
|
||||
// already captured", so we don't immediately take a duplicate at boot.
|
||||
let lastSeenMtime = 0;
|
||||
const existing = listBackups();
|
||||
if (existing.length > 0) lastSeenMtime = existing[0].createdAt.getTime();
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const mtime = dbSourceMtime();
|
||||
if (mtime > lastSeenMtime) {
|
||||
await takeBackup();
|
||||
lastSeenMtime = mtime;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[backup] scheduled tick failed', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Run once shortly after boot, then every TICK_MS.
|
||||
setTimeout(tick, 5_000);
|
||||
setInterval(tick, TICK_MS);
|
||||
}
|
||||
@ -20,4 +20,13 @@ export function getDb() {
|
||||
return _db;
|
||||
}
|
||||
|
||||
// Close the live connection. Used by the restore flow before
|
||||
// overwriting the .db file; next getDb() call will reopen.
|
||||
export function closeDb() {
|
||||
if (_db) {
|
||||
try { _db.close(); } catch { /* ignore */ }
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const DB_FILE = DB_PATH;
|
||||
|
||||
35
src/routes/admin/+page.server.js
Normal file
35
src/routes/admin/+page.server.js
Normal file
@ -0,0 +1,35 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { listBackups, takeBackup, restoreBackup } from '$lib/server/backup.js';
|
||||
|
||||
export function load() {
|
||||
return {
|
||||
backups: listBackups().map((b) => ({
|
||||
name: b.name,
|
||||
size: b.size,
|
||||
createdAt: b.createdAt.toISOString()
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
backup: async () => {
|
||||
try {
|
||||
const b = await takeBackup();
|
||||
return { ok: true, message: 'admin.flash.backup_taken', name: b.name };
|
||||
} catch (e) {
|
||||
console.error('[admin] manual backup failed', e);
|
||||
return fail(500, { message: 'admin.flash.backup_failed' });
|
||||
}
|
||||
},
|
||||
restore: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const name = String(form.get('name') ?? '');
|
||||
try {
|
||||
await restoreBackup(name);
|
||||
return { ok: true, message: 'admin.flash.restored' };
|
||||
} catch (e) {
|
||||
console.error('[admin] restore failed', e);
|
||||
return fail(500, { message: 'admin.flash.restore_failed' });
|
||||
}
|
||||
}
|
||||
};
|
||||
118
src/routes/admin/+page.svelte
Normal file
118
src/routes/admin/+page.svelte
Normal file
@ -0,0 +1,118 @@
|
||||
<script>
|
||||
import { t, locale } from '$lib/i18n/store.js';
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
|
||||
export let data;
|
||||
export let form;
|
||||
$: ({ backups } = data);
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatWhen(iso, lang) {
|
||||
const d = new Date(iso);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
return `${date} ${time}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>{$t('admin.title')}</h1>
|
||||
|
||||
<div class="warning">
|
||||
<strong>{$t('admin.warning_title')}</strong>
|
||||
<p>{$t('admin.warning_body')}</p>
|
||||
</div>
|
||||
|
||||
{#if form?.message}
|
||||
<div class={form.ok ? 'flash ok' : 'flash err'}>
|
||||
{$t(form.message)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="toolbar">
|
||||
<form method="POST" action="?/backup" use:enhance={() => async ({ update }) => { await update(); }}>
|
||||
<button type="submit">{$t('admin.backup_now')}</button>
|
||||
</form>
|
||||
<p class="muted">{$t('admin.auto_note')}</p>
|
||||
</div>
|
||||
|
||||
<h2>{$t('admin.list_title')}</h2>
|
||||
|
||||
{#if backups.length === 0}
|
||||
<p class="muted">{$t('admin.no_backups')}</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t('admin.created_at')}</th>
|
||||
<th class="num">{$t('admin.size')}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each backups as b}
|
||||
<tr>
|
||||
<td>{formatWhen(b.createdAt, $locale)}</td>
|
||||
<td class="num">{formatSize(b.size)}</td>
|
||||
<td class="actions">
|
||||
<a class="btn-link" href="/admin/download/{b.name}" download={b.name}>
|
||||
{$t('admin.download')}
|
||||
</a>
|
||||
<form method="POST" action="?/restore" use:enhance={() => async ({ update }) => { await update(); await invalidateAll(); }}
|
||||
on:submit={(e) => { if (!confirm($t('admin.restore_confirm'))) e.preventDefault(); }}>
|
||||
<input type="hidden" name="name" value={b.name} />
|
||||
<button type="submit" class="secondary">{$t('admin.restore')}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted small">{$t('admin.prune_note')}</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.warning {
|
||||
background: #fff7e6;
|
||||
border: 2px solid #d9821a;
|
||||
border-radius: 6px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.warning strong { font-size: 1.1rem; color: #8a4a00; display: block; margin-bottom: 0.4rem; }
|
||||
.warning p { margin: 0.4rem 0; }
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0 1.5rem;
|
||||
}
|
||||
.toolbar form { margin: 0; }
|
||||
.flash {
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.flash.ok { background: #e6f4ec; border: 1px solid #9bd1b1; color: #154d2a; }
|
||||
.flash.err { background: #fdecea; border: 1px solid #f5c2c0; color: #8a1f1b; }
|
||||
.small { font-size: 0.85rem; margin-top: 0.75rem; }
|
||||
td.actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||
td.actions form { margin: 0; }
|
||||
.btn-link {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #006a4e;
|
||||
background: #006a4e;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.btn-link:hover { background: #00553e; color: #fff; }
|
||||
</style>
|
||||
24
src/routes/admin/download/[name]/+server.js
Normal file
24
src/routes/admin/download/[name]/+server.js
Normal file
@ -0,0 +1,24 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { BACKUP_DIR, safeName } from '$lib/server/backup.js';
|
||||
|
||||
export function GET({ params }) {
|
||||
const { name } = params;
|
||||
if (!safeName(name)) throw error(400, 'Invalid backup name');
|
||||
|
||||
const path = join(BACKUP_DIR, name);
|
||||
if (!existsSync(path)) throw error(404, 'Backup not found');
|
||||
|
||||
const buf = readFileSync(path);
|
||||
const size = statSync(path).size;
|
||||
|
||||
return new Response(buf, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': String(size),
|
||||
'Content-Disposition': `attachment; filename="${name}"`,
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user