feat: add scheduled daily backup, stored backup listing and download
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\ActivityLog;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
class BackupDatabaseCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'backup:database';
|
||||||
|
protected $description = 'Backup database ke storage/app/backups/';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$db = config('database.connections.mysql');
|
||||||
|
$dir = storage_path('app/backups');
|
||||||
|
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = 'backup-webgis-' . now()->format('Ymd-His') . '.sql';
|
||||||
|
$path = $dir . '/' . $filename;
|
||||||
|
|
||||||
|
$process = new Process([
|
||||||
|
'mysqldump',
|
||||||
|
'-h', $db['host'],
|
||||||
|
'-P', (string) $db['port'],
|
||||||
|
'-u', $db['username'],
|
||||||
|
'--single-transaction',
|
||||||
|
'--skip-lock-tables',
|
||||||
|
'--no-tablespaces',
|
||||||
|
'--result-file=' . $path,
|
||||||
|
$db['database'],
|
||||||
|
], null, ['MYSQL_PWD' => $db['password']]);
|
||||||
|
|
||||||
|
$process->setTimeout(120);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (!$process->isSuccessful()) {
|
||||||
|
$this->error('Backup gagal: ' . $process->getErrorOutput());
|
||||||
|
ActivityLog::create([
|
||||||
|
'user_id' => null,
|
||||||
|
'user_name' => 'Sistem',
|
||||||
|
'action' => 'backup',
|
||||||
|
'description' => 'Backup terjadwal GAGAL: ' . $process->getErrorOutput(),
|
||||||
|
'ip_address' => '127.0.0.1',
|
||||||
|
]);
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune: hapus backup lebih dari 7 file terlama
|
||||||
|
$files = glob($dir . '/backup-webgis-*.sql');
|
||||||
|
usort($files, fn($a, $b) => filemtime($b) - filemtime($a));
|
||||||
|
foreach (array_slice($files, 7) as $old) {
|
||||||
|
unlink($old);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Backup disimpan: {$filename}");
|
||||||
|
ActivityLog::create([
|
||||||
|
'user_id' => null,
|
||||||
|
'user_name' => 'Sistem',
|
||||||
|
'action' => 'backup',
|
||||||
|
'description' => "Backup terjadwal berhasil: {$filename}",
|
||||||
|
'ip_address' => '127.0.0.1',
|
||||||
|
]);
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,4 +38,29 @@ class BackupController extends Controller
|
|||||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function listBackups()
|
||||||
|
{
|
||||||
|
$dir = storage_path('app/backups');
|
||||||
|
$files = is_dir($dir) ? glob($dir . '/backup-webgis-*.sql') : [];
|
||||||
|
usort($files, fn($a, $b) => filemtime($b) - filemtime($a));
|
||||||
|
|
||||||
|
$list = array_map(fn($f) => [
|
||||||
|
'name' => basename($f),
|
||||||
|
'size_kb' => round(filesize($f) / 1024, 1),
|
||||||
|
'created_at' => date('d/m/Y H:i', filemtime($f)),
|
||||||
|
], $files);
|
||||||
|
|
||||||
|
return response()->json($list);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadStored(string $filename)
|
||||||
|
{
|
||||||
|
if (!preg_match('/^backup-webgis-[\d-]+\.sql$/', $filename)) {
|
||||||
|
abort(400, 'Nama file tidak valid');
|
||||||
|
}
|
||||||
|
$path = storage_path('app/backups/' . $filename);
|
||||||
|
if (!file_exists($path)) abort(404);
|
||||||
|
return response()->download($path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,4 +20,6 @@ fi
|
|||||||
php artisan migrate --force --no-ansi
|
php artisan migrate --force --no-ansi
|
||||||
php artisan db:seed --force --no-ansi
|
php artisan db:seed --force --no-ansi
|
||||||
|
|
||||||
|
php artisan schedule:work --no-interaction > /dev/null 2>&1 &
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -126,6 +126,30 @@
|
|||||||
<tbody id="bodyUsers"></tbody>
|
<tbody id="bodyUsers"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Daftar Backup Terjadwal -->
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-base-200 mt-8">
|
||||||
|
<div class="p-4 border-b border-base-200 flex items-center justify-between">
|
||||||
|
<h2 class="font-bold text-base flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-clock-rotate-left text-success"></i> Backup Tersimpan
|
||||||
|
</h2>
|
||||||
|
<button onclick="loadBackupList()" class="btn btn-xs btn-ghost">
|
||||||
|
<i class="fa-solid fa-rotate-right"></i> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-sm w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-xs uppercase opacity-50">
|
||||||
|
<th>File</th><th>Ukuran</th><th>Waktu</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="bodyBackupList">
|
||||||
|
<tr><td colspan="4" class="text-center py-6 opacity-30 italic">Memuat...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- ═══ MODALS ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══ MODALS ═══════════════════════════════════════════════════════════ -->
|
||||||
@@ -259,7 +283,24 @@
|
|||||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', loadUsers);
|
document.addEventListener('DOMContentLoaded', () => { loadUsers(); loadBackupList(); });
|
||||||
|
|
||||||
|
async function loadBackupList() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('{{ url('/api/admin/backup/list') }}', { headers: { 'X-CSRF-TOKEN': csrfToken } });
|
||||||
|
const list = await res.json();
|
||||||
|
document.getElementById('bodyBackupList').innerHTML = list.length
|
||||||
|
? list.map(b => `<tr>
|
||||||
|
<td class="font-mono text-xs">${esc(b.name)}</td>
|
||||||
|
<td class="text-xs opacity-60">${esc(String(b.size_kb))} KB</td>
|
||||||
|
<td class="text-xs opacity-60">${esc(b.created_at)}</td>
|
||||||
|
<td><a href="{{ url('/api/admin/backup/download') }}/${esc(b.name)}" class="btn btn-xs btn-ghost" title="Unduh"><i class="fa-solid fa-download"></i></a></td>
|
||||||
|
</tr>`).join('')
|
||||||
|
: '<tr><td colspan="4" class="text-center py-6 opacity-30 italic">Belum ada backup tersimpan</td></tr>';
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Gagal memuat daftar backup', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
Artisan::command('inspire', function () {
|
||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
})->purpose('Display an inspiring quote');
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
|
Schedule::command('backup:database')->daily()->appendOutputTo(storage_path('logs/backup.log'));
|
||||||
|
|||||||
+4
-1
@@ -68,7 +68,10 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::post('/import/ibadah', [ImportController::class, 'ibadah']);
|
Route::post('/import/ibadah', [ImportController::class, 'ibadah']);
|
||||||
Route::post('/import/miskin', [ImportController::class, 'miskin']);
|
Route::post('/import/miskin', [ImportController::class, 'miskin']);
|
||||||
|
|
||||||
Route::get('/backup', [BackupController::class, 'download'])->name('admin.backup');
|
Route::get('/backup', [BackupController::class, 'download'])->name('admin.backup');
|
||||||
|
Route::get('/backup/list', [BackupController::class, 'listBackups']);
|
||||||
|
Route::get('/backup/download/{filename}', [BackupController::class, 'downloadStored'])
|
||||||
|
->where('filename', '[^/]+');
|
||||||
|
|
||||||
Route::get('/logs', [ActivityLogController::class, 'index']);
|
Route::get('/logs', [ActivityLogController::class, 'index']);
|
||||||
Route::get('/stats', [ActivityLogController::class, 'stats']);
|
Route::get('/stats', [ActivityLogController::class, 'stats']);
|
||||||
|
|||||||
Reference in New Issue
Block a user