berrypod/lib/berrypod_web/live/admin/backup.ex

593 lines
21 KiB
Elixir
Raw Normal View History

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
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_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_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"""
<div class="admin-backup" phx-hook="Download" id="backup-page">
<.header>
Backup
</.header>
<%!-- Database status --%>
<section class="admin-section">
<div class="admin-section-header">
<h2 class="admin-section-title">Database</h2>
<%= if @stats.encryption_status do %>
<.status_pill color="green">
<.icon name="hero-lock-closed-mini" class="size-3" /> Encrypted
</.status_pill>
<% else %>
<.status_pill color="amber">
<.icon name="hero-lock-open-mini" class="size-3" /> Not encrypted
</.status_pill>
<% end %>
</div>
<p class="admin-section-desc">
{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
</p>
<div class="admin-section-body">
<button
type="button"
phx-click="toggle_tables"
class="admin-link"
>
<%= if @show_tables do %>
<.icon name="hero-chevron-up-mini" class="size-4" /> Hide table details
<% else %>
<.icon name="hero-chevron-down-mini" class="size-4" /> Show table details
<% end %>
</button>
</div>
<%= if @show_tables do %>
<div class="backup-tables">
<table class="admin-table admin-table-compact">
<thead>
<tr>
<th>Table</th>
<th class="text-right">Rows</th>
<th class="text-right">Size</th>
</tr>
</thead>
<tbody>
<tr :for={table <- @stats.tables}>
<td>{table.name}</td>
<td class="text-right tabular-nums">{table.rows}</td>
<td class="text-right tabular-nums">{Backup.format_size(table.size)}</td>
</tr>
</tbody>
</table>
</div>
<% end %>
</section>
<%!-- Create backup --%>
<section class="admin-section">
<div class="admin-section-header">
<h2 class="admin-section-title">Create backup</h2>
<.status_pill color="zinc">{length(@backups)} saved</.status_pill>
</div>
<p class="admin-section-desc">
Creates an encrypted snapshot of your database. Backups are stored locally and the last 5 are kept automatically.
</p>
<div class="admin-section-body">
<div class="backup-actions">
<button
type="button"
phx-click="create_backup"
class="admin-btn admin-btn-primary admin-btn-sm"
>
<.icon name="hero-plus-mini" class="size-4" /> Create backup
</button>
<.inline_feedback status={@create_backup_status} />
</div>
</div>
</section>
<%!-- Backup history --%>
<%= if @backups != [] do %>
<section class="admin-section">
<h2 class="admin-section-title">Saved backups</h2>
<%= if @restoring do %>
<div class="backup-progress">
<.icon name="hero-arrow-path" class="size-5 animate-spin" />
<div>
<p class="backup-progress-text">Restoring database...</p>
<p class="backup-progress-hint">This may take a few seconds.</p>
</div>
</div>
<% else %>
<div class="backup-list">
<%= for backup <- @backups do %>
<div class="backup-item">
<div class="backup-item-info">
<span class="backup-item-date">{format_backup_date(backup.created_at)}</span>
<span class="backup-item-meta">
{Backup.format_size(backup.size)}
<%= if backup.type == :pre_restore do %>
· auto-saved before restore
<% end %>
</span>
</div>
<div class="backup-item-actions">
<%= if @confirming_history_restore == backup.filename do %>
<span class="backup-item-confirm">Replace current database?</span>
<button
type="button"
class="admin-btn admin-btn-danger admin-btn-sm"
phx-click="execute_history_restore"
phx-value-filename={backup.filename}
>
Restore
</button>
<button
type="button"
class="admin-btn admin-btn-outline admin-btn-sm"
phx-click="cancel_history_restore"
>
Cancel
</button>
<% else %>
<%= if @confirming_delete == backup.filename do %>
<span class="backup-item-confirm">Delete this backup?</span>
<button
type="button"
class="admin-btn admin-btn-danger admin-btn-sm"
phx-click="execute_delete"
phx-value-filename={backup.filename}
>
Delete
</button>
<button
type="button"
class="admin-btn admin-btn-outline admin-btn-sm"
phx-click="cancel_delete"
>
Cancel
</button>
<% else %>
<button
type="button"
class="admin-btn admin-btn-outline admin-btn-sm"
phx-click="download_history_backup"
phx-value-filename={backup.filename}
title="Download"
>
<.icon name="hero-arrow-down-tray-mini" class="size-4" />
</button>
<button
type="button"
class="admin-btn admin-btn-outline admin-btn-sm"
phx-click="confirm_history_restore"
phx-value-filename={backup.filename}
>
Restore
</button>
<button
type="button"
class="admin-btn admin-btn-outline admin-btn-sm"
phx-click="confirm_delete"
phx-value-filename={backup.filename}
title="Delete"
>
<.icon name="hero-trash-mini" class="size-4" />
</button>
<% end %>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>
</section>
<% end %>
<%!-- Restore from file --%>
<section class="admin-section">
<h2 class="admin-section-title">Restore from file</h2>
<p class="admin-section-desc">
Upload a backup file to restore. Must be encrypted with the same key as this database.
</p>
<%= if @upload_error do %>
<p class="admin-error">{@upload_error}</p>
<% end %>
<%= if @uploaded_backup do %>
<div class="backup-comparison">
<div class="backup-comparison-grid">
<div class="backup-comparison-col">
<h4 class="backup-comparison-label">Current</h4>
<dl class="backup-comparison-stats">
<div><dt>Size</dt><dd>{Backup.format_size(@stats.total_size)}</dd></div>
<div><dt>Products</dt><dd>{@stats.key_counts["products"] || 0}</dd></div>
<div><dt>Orders</dt><dd>{@stats.key_counts["orders"] || 0}</dd></div>
<div><dt>Images</dt><dd>{@stats.key_counts["images"] || 0}</dd></div>
</dl>
</div>
<div class="backup-comparison-arrow">
<.icon name="hero-arrow-right" class="size-5" />
</div>
<div class="backup-comparison-col">
<h4 class="backup-comparison-label">Uploaded</h4>
<dl class="backup-comparison-stats">
<div><dt>Size</dt><dd>{Backup.format_size(@uploaded_backup.stats.file_size)}</dd></div>
<div><dt>Products</dt><dd>{@uploaded_backup.stats.key_counts["products"] || 0}</dd></div>
<div><dt>Orders</dt><dd>{@uploaded_backup.stats.key_counts["orders"] || 0}</dd></div>
<div><dt>Images</dt><dd>{@uploaded_backup.stats.key_counts["images"] || 0}</dd></div>
</dl>
</div>
</div>
<%= if @uploaded_backup.stats.latest_migration == @stats.schema_version do %>
<div class="backup-validation backup-validation-ok">
<.icon name="hero-check-circle-mini" class="size-4" />
<span>Backup validated · Schema version {@uploaded_backup.stats.latest_migration}</span>
</div>
<%= if @restoring do %>
<div class="backup-progress">
<.icon name="hero-arrow-path" class="size-5 animate-spin" />
<div>
<p class="backup-progress-text">Restoring database...</p>
<p class="backup-progress-hint">This may take a few seconds.</p>
</div>
</div>
<% else %>
<%= if @confirming_restore do %>
<div class="backup-warning">
<p>This will replace your current database. A backup will be saved automatically.</p>
<div class="backup-actions">
<button type="button" class="admin-btn admin-btn-danger admin-btn-sm" phx-click="execute_restore">
Replace database
</button>
<button type="button" class="admin-btn admin-btn-outline admin-btn-sm" phx-click="cancel_restore">
Cancel
</button>
</div>
</div>
<% else %>
<div class="backup-actions">
<button type="button" class="admin-btn admin-btn-primary admin-btn-sm" phx-click="confirm_restore">
Restore this backup
</button>
<button type="button" class="admin-btn admin-btn-outline admin-btn-sm" phx-click="cancel_restore">
Cancel
</button>
</div>
<% end %>
<% end %>
<% else %>
<div class="backup-validation backup-validation-error">
<.icon name="hero-x-circle-mini" class="size-4" />
<span>
Schema mismatch: backup is v{@uploaded_backup.stats.latest_migration},
current is v{@stats.schema_version}
</span>
</div>
<div class="backup-actions">
<button type="button" class="admin-btn admin-btn-outline admin-btn-sm" phx-click="cancel_restore">
Cancel
</button>
</div>
<% end %>
</div>
<% else %>
<form phx-submit="upload_backup" phx-change="validate_upload">
<div class="backup-dropzone" phx-drop-target={@uploads.backup.ref}>
<.live_file_input upload={@uploads.backup} class="sr-only" />
<div class="backup-dropzone-content">
<.icon name="hero-arrow-up-tray" class="size-6" />
<p>
Drop a backup file here or
<label for={@uploads.backup.ref} class="backup-dropzone-link">browse</label>
</p>
</div>
</div>
<%= for entry <- @uploads.backup.entries do %>
<div class="backup-upload-entry">
<span>{entry.client_name}</span>
<span class="tabular-nums">{Backup.format_size(entry.client_size)}</span>
<progress value={entry.progress} max="100">{entry.progress}%</progress>
</div>
<%= for err <- upload_errors(@uploads.backup, entry) do %>
<p class="admin-error">{upload_error_to_string(err)}</p>
<% end %>
<% end %>
<%= if length(@uploads.backup.entries) > 0 do %>
<div class="backup-actions">
<button type="submit" class="admin-btn admin-btn-primary admin-btn-sm">
<.icon name="hero-arrow-up-tray-mini" class="size-4" /> Upload and validate
</button>
</div>
<% end %>
</form>
<% end %>
</section>
</div>
"""
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"""
<span class={["admin-status-pill", @modifier]}>
{render_slot(@inner_block)}
</span>
"""
end
end