All checks were successful
deploy / deploy (push) Successful in 3m35s
Use async message passing for create_backup to update UI immediately. Add phx-throttle and pointer-events:none to fully prevent double-clicks. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
604 lines
22 KiB
Elixir
604 lines
22 KiB
Elixir
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"""
|
|
<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"
|
|
phx-throttle="1000"
|
|
class="admin-btn admin-btn-primary admin-btn-sm"
|
|
disabled={@create_backup_status == :saving}
|
|
>
|
|
<.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
|