defmodule BerrypodWeb.Admin.Backup do use BerrypodWeb, :live_view alias Berrypod.Backup @impl true def mount(_params, _session, socket) do stats = Backup.get_stats() backups = Backup.list_backups() {:ok, socket |> assign(:page_title, "Backup") |> assign(:stats, stats) |> assign(:backups, backups) |> assign(:create_backup_status, :idle) |> assign(:uploaded_backup, nil) |> assign(:upload_error, nil) |> assign(:confirming_restore, false) |> assign(:restoring, false) |> assign(:confirming_history_restore, nil) |> assign(:confirming_delete, nil) |> assign(:show_tables, false) |> allow_upload(:backup, accept: :any, max_entries: 1, max_file_size: 500_000_000 )} end @impl true def handle_event("refresh_stats", _params, socket) do stats = Backup.get_stats() backups = Backup.list_backups() {:noreply, socket |> assign(:stats, stats) |> assign(:backups, backups)} end def handle_event("toggle_tables", _params, socket) do {:noreply, assign(socket, :show_tables, !socket.assigns.show_tables)} end def handle_event("create_backup", _params, socket) do if socket.assigns.create_backup_status == :saving do {:noreply, socket} else send(self(), :do_create_backup) {:noreply, assign(socket, :create_backup_status, :saving)} end end def handle_event("download_history_backup", %{"filename" => filename}, socket) do path = Path.join(Backup.backup_dir(), filename) if File.exists?(path) do data = File.read!(path) {:noreply, socket |> push_event("download", %{ filename: filename, content: Base.encode64(data), content_type: "application/octet-stream" })} else {:noreply, put_flash(socket, :error, "Backup file not found")} end end def handle_event("validate_upload", _params, socket) do {:noreply, socket} end def handle_event("upload_backup", _params, socket) do [result] = consume_uploaded_entries(socket, :backup, fn %{path: path}, _entry -> # Copy to temp location since consume deletes the original temp_path = Path.join(System.tmp_dir!(), "berrypod-restore-#{System.unique_integer()}.db") File.cp!(path, temp_path) case Backup.validate_backup(temp_path) do {:ok, backup_stats} -> # Use actual file size instead of internal page calculation file_size = File.stat!(temp_path).size {:ok, {:ok, temp_path, Map.put(backup_stats, :file_size, file_size)}} {:error, reason} -> File.rm(temp_path) {:ok, {:error, reason}} end end) case result do {:ok, path, backup_stats} -> {:noreply, socket |> assign(:uploaded_backup, %{path: path, stats: backup_stats}) |> assign(:upload_error, nil)} {:error, :invalid_key} -> {:noreply, assign( socket, :upload_error, "Wrong encryption key — this backup was created with a different key" )} {:error, reason} -> {:noreply, assign(socket, :upload_error, "Invalid backup file: #{inspect(reason)}")} end end def handle_event("cancel_restore", _params, socket) do # Clean up temp file if socket.assigns.uploaded_backup do File.rm(socket.assigns.uploaded_backup.path) end {:noreply, socket |> assign(:uploaded_backup, nil) |> assign(:confirming_restore, false)} end def handle_event("confirm_restore", _params, socket) do {:noreply, assign(socket, :confirming_restore, true)} end def handle_event("execute_restore", _params, socket) do # Show loading state immediately, then do the restore async send(self(), :do_restore) {:noreply, assign(socket, :restoring, true)} end # Backup history actions def handle_event("confirm_history_restore", %{"filename" => filename}, socket) do {:noreply, assign(socket, :confirming_history_restore, filename)} end def handle_event("cancel_history_restore", _params, socket) do {:noreply, assign(socket, :confirming_history_restore, nil)} end def handle_event("execute_history_restore", %{"filename" => filename}, socket) do send(self(), {:do_history_restore, filename}) {:noreply, socket |> assign(:restoring, true) |> assign(:confirming_history_restore, nil)} end def handle_event("confirm_delete", %{"filename" => filename}, socket) do {:noreply, assign(socket, :confirming_delete, filename)} end def handle_event("cancel_delete", _params, socket) do {:noreply, assign(socket, :confirming_delete, nil)} end def handle_event("execute_delete", %{"filename" => filename}, socket) do case Backup.delete_backup(filename) do :ok -> {:noreply, socket |> assign(:confirming_delete, nil) |> assign(:backups, Backup.list_backups()) |> put_flash(:info, "Backup deleted")} {:error, _} -> {:noreply, socket |> assign(:confirming_delete, nil) |> put_flash(:error, "Failed to delete backup")} end end @impl true def handle_info(:do_create_backup, socket) do case Backup.create_backup() do {:ok, _path} -> {:noreply, socket |> assign(:backups, Backup.list_backups()) |> assign(:create_backup_status, :saved)} {:error, error} -> {:noreply, socket |> assign(:create_backup_status, :error) |> put_flash(:error, "Failed to create backup: #{inspect(error)}")} end end def handle_info(:do_restore, socket) do backup_path = socket.assigns.uploaded_backup.path case Backup.restore_backup(backup_path) do :ok -> {:noreply, socket |> assign(:uploaded_backup, nil) |> assign(:confirming_restore, false) |> assign(:restoring, false) |> assign(:stats, Backup.get_stats()) |> assign(:backups, Backup.list_backups()) |> put_flash(:info, "Database restored successfully")} {:error, {:schema_mismatch, backup_version, current_version}} -> {:noreply, socket |> assign(:confirming_restore, false) |> assign(:restoring, false) |> put_flash( :error, "Schema version mismatch: backup is #{backup_version}, current is #{current_version}. " <> "Backups can only be restored to a database with the same schema version." )} {:error, reason} -> {:noreply, socket |> assign(:confirming_restore, false) |> assign(:restoring, false) |> put_flash(:error, "Restore failed: #{inspect(reason)}")} end end def handle_info({:do_history_restore, filename}, socket) do path = Path.join(Backup.backup_dir(), filename) case Backup.restore_backup(path) do :ok -> {:noreply, socket |> assign(:restoring, false) |> assign(:stats, Backup.get_stats()) |> assign(:backups, Backup.list_backups()) |> put_flash(:info, "Database restored from #{filename}")} {:error, {:schema_mismatch, backup_version, current_version}} -> {:noreply, socket |> assign(:restoring, false) |> put_flash( :error, "Schema version mismatch: backup is #{backup_version}, current is #{current_version}." )} {:error, reason} -> {:noreply, socket |> assign(:restoring, false) |> put_flash(:error, "Restore failed: #{inspect(reason)}")} end end @impl true def render(assigns) do ~H"""
{Backup.format_size(@stats.total_size)} total · {length(@stats.tables)} tables · {@stats.key_counts["products"] || 0} products · {@stats.key_counts["orders"] || 0} orders · {@stats.key_counts["images"] || 0} images
| Table | Rows | Size |
|---|---|---|
| {table.name} | {table.rows} | {Backup.format_size(table.size)} |
Creates an encrypted snapshot of your database. Backups are stored locally and the last 5 are kept automatically.
Restoring database...
This may take a few seconds.
Upload a backup file to restore. Must be encrypted with the same key as this database.
<%= if @upload_error do %>{@upload_error}
<% end %> <%= if @uploaded_backup do %>Restoring database...
This may take a few seconds.
This will replace your current database. A backup will be saved automatically.