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"""
<.header> Backup <%!-- Database status --%>

Database

<%= if @stats.encryption_status do %> <.status_pill color="green"> <.icon name="hero-lock-closed-mini" class="size-3" /> Encrypted <% else %> <.status_pill color="amber"> <.icon name="hero-lock-open-mini" class="size-3" /> Not encrypted <% end %>

{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

<%= if @show_tables do %>
Table Rows Size
{table.name} {table.rows} {Backup.format_size(table.size)}
<% end %>
<%!-- Create backup --%>

Create backup

<.status_pill color="zinc">{length(@backups)} saved

Creates an encrypted snapshot of your database. Backups are stored locally and the last 5 are kept automatically.

<.inline_feedback status={@create_backup_status} />
<%!-- Backup history --%> <%= if @backups != [] do %>

Saved backups

<%= if @restoring do %>
<.icon name="hero-arrow-path" class="size-5 animate-spin" />

Restoring database...

This may take a few seconds.

<% else %>
<%= for backup <- @backups do %>
{format_backup_date(backup.created_at)} {Backup.format_size(backup.size)} <%= if backup.type == :pre_restore do %> · auto-saved before restore <% end %>
<%= if @confirming_history_restore == backup.filename do %> Replace current database? <% else %> <%= if @confirming_delete == backup.filename do %> Delete this backup? <% else %> <% end %> <% end %>
<% end %>
<% end %>
<% end %> <%!-- Restore from file --%>

Restore from file

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 %>

Current

Size
{Backup.format_size(@stats.total_size)}
Products
{@stats.key_counts["products"] || 0}
Orders
{@stats.key_counts["orders"] || 0}
Images
{@stats.key_counts["images"] || 0}
<.icon name="hero-arrow-right" class="size-5" />

Uploaded

Size
{Backup.format_size(@uploaded_backup.stats.file_size)}
Products
{@uploaded_backup.stats.key_counts["products"] || 0}
Orders
{@uploaded_backup.stats.key_counts["orders"] || 0}
Images
{@uploaded_backup.stats.key_counts["images"] || 0}
<%= if @uploaded_backup.stats.latest_migration == @stats.schema_version do %>
<.icon name="hero-check-circle-mini" class="size-4" /> Backup validated · Schema version {@uploaded_backup.stats.latest_migration}
<%= if @restoring do %>
<.icon name="hero-arrow-path" class="size-5 animate-spin" />

Restoring database...

This may take a few seconds.

<% else %> <%= if @confirming_restore do %>

This will replace your current database. A backup will be saved automatically.

<% else %>
<% end %> <% end %> <% else %>
<.icon name="hero-x-circle-mini" class="size-4" /> Schema mismatch: backup is v{@uploaded_backup.stats.latest_migration}, current is v{@stats.schema_version}
<% end %>
<% else %>
<.live_file_input upload={@uploads.backup} class="sr-only" />
<.icon name="hero-arrow-up-tray" class="size-6" />

Drop a backup file here or

<%= for entry <- @uploads.backup.entries do %>
{entry.client_name} {Backup.format_size(entry.client_size)} {entry.progress}%
<%= for err <- upload_errors(@uploads.backup, entry) do %>

{upload_error_to_string(err)}

<% end %> <% end %> <%= if length(@uploads.backup.entries) > 0 do %>
<% end %>
<% end %>
""" end defp format_backup_date(nil), do: "unknown date" defp format_backup_date(datetime) do Calendar.strftime(datetime, "%d %b %Y, %H:%M") end defp upload_error_to_string(:too_large), do: "File is too large (max 500 MB)" defp upload_error_to_string(:too_many_files), do: "Only one file allowed" defp upload_error_to_string(err), do: "Upload error: #{inspect(err)}" attr :color, :string, default: "zinc" slot :inner_block, required: true defp status_pill(assigns) do modifier = case assigns.color do "green" -> "admin-status-pill-green" "amber" -> "admin-status-pill-amber" _ -> "admin-status-pill-zinc" end assigns = assign(assigns, :modifier, modifier) ~H""" {render_slot(@inner_block)} """ end end