add database backup and restore admin page
Some checks failed
deploy / deploy (push) Has been cancelled
Some checks failed
deploy / deploy (push) Has been cancelled
- SQLCipher-encrypted backup creation via VACUUM INTO - Backup history with auto-pruning (keeps last 5) - Pre-restore automatic backup for safety - Restore from history or uploaded file - Stats display with table breakdown - Download hook for client-side file download - SECRET_KEY_DB config for encryption at rest Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
814
lib/berrypod/backup.ex
Normal file
814
lib/berrypod/backup.ex
Normal file
@@ -0,0 +1,814 @@
|
||||
defmodule Berrypod.Backup do
|
||||
@moduledoc """
|
||||
Database backup and restore functionality.
|
||||
|
||||
Provides database statistics, backup creation via VACUUM INTO,
|
||||
backup history management, and restore operations for SQLCipher-encrypted databases.
|
||||
|
||||
Backups are stored in the configured backup directory (default: priv/backups/).
|
||||
Before any restore, an automatic backup of the current database is created.
|
||||
Old backups are automatically pruned to keep the most recent N backups.
|
||||
"""
|
||||
|
||||
alias Berrypod.Repo
|
||||
require Logger
|
||||
|
||||
# Tables to show row counts for in the stats display
|
||||
@key_tables ~w(products product_variants orders images pages newsletter_subscribers)
|
||||
|
||||
# Critical tables that must exist for a valid Berrypod database
|
||||
@required_tables ~w(users settings products orders pages images schema_migrations)
|
||||
|
||||
# Maximum number of backups to keep (configurable via :berrypod, :backup, :max_backups)
|
||||
@default_max_backups 5
|
||||
|
||||
@doc """
|
||||
Returns the directory where backups are stored.
|
||||
Defaults to priv/backups/ but can be configured via :berrypod, :backup, :directory.
|
||||
"""
|
||||
def backup_dir do
|
||||
config = Application.get_env(:berrypod, :backup, [])
|
||||
|
||||
dir =
|
||||
Keyword.get_lazy(config, :directory, fn ->
|
||||
Path.join(:code.priv_dir(:berrypod), "backups")
|
||||
end)
|
||||
|
||||
# Ensure directory exists
|
||||
File.mkdir_p!(dir)
|
||||
dir
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the maximum number of backups to keep.
|
||||
"""
|
||||
def max_backups do
|
||||
config = Application.get_env(:berrypod, :backup, [])
|
||||
Keyword.get(config, :max_backups, @default_max_backups)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all available backups, sorted by date (newest first).
|
||||
|
||||
Returns a list of maps with:
|
||||
- filename: the backup filename
|
||||
- path: full path to the backup file
|
||||
- size: file size in bytes
|
||||
- created_at: DateTime when the backup was created (parsed from filename)
|
||||
- type: :manual or :pre_restore
|
||||
"""
|
||||
def list_backups do
|
||||
dir = backup_dir()
|
||||
|
||||
dir
|
||||
|> File.ls!()
|
||||
|> Enum.filter(&String.ends_with?(&1, ".db"))
|
||||
|> Enum.map(fn filename ->
|
||||
path = Path.join(dir, filename)
|
||||
stat = File.stat!(path)
|
||||
|
||||
%{
|
||||
filename: filename,
|
||||
path: path,
|
||||
size: stat.size,
|
||||
created_at: parse_backup_timestamp(filename),
|
||||
type: parse_backup_type(filename)
|
||||
}
|
||||
end)
|
||||
|> Enum.sort_by(& &1.created_at, {:desc, DateTime})
|
||||
end
|
||||
|
||||
defp parse_backup_timestamp(filename) do
|
||||
# Expected format: berrypod-backup-YYYYMMDD-HHMMSS.db or pre-restore-YYYYMMDD-HHMMSS.db
|
||||
case Regex.run(~r/(\d{8})-(\d{6})\.db$/, filename) do
|
||||
[_, date, time] ->
|
||||
<<y::binary-4, m::binary-2, d::binary-2>> = date
|
||||
<<hh::binary-2, mm::binary-2, ss::binary-2>> = time
|
||||
|
||||
case NaiveDateTime.new(
|
||||
String.to_integer(y),
|
||||
String.to_integer(m),
|
||||
String.to_integer(d),
|
||||
String.to_integer(hh),
|
||||
String.to_integer(mm),
|
||||
String.to_integer(ss)
|
||||
) do
|
||||
{:ok, naive} -> DateTime.from_naive!(naive, "Etc/UTC")
|
||||
_ -> DateTime.utc_now()
|
||||
end
|
||||
|
||||
_ ->
|
||||
DateTime.utc_now()
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_backup_type(filename) do
|
||||
if String.starts_with?(filename, "pre-restore-") do
|
||||
:pre_restore
|
||||
else
|
||||
:manual
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns comprehensive database statistics.
|
||||
|
||||
Includes:
|
||||
- Total database size
|
||||
- Encryption status (SQLCipher version or nil)
|
||||
- Per-table row counts and sizes
|
||||
- Key entity counts
|
||||
"""
|
||||
def get_stats do
|
||||
%{
|
||||
total_size: get_total_size(),
|
||||
encryption_status: get_encryption_status(),
|
||||
tables: get_table_stats(),
|
||||
key_counts: get_key_counts(),
|
||||
schema_version: get_current_schema_version()
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the total database file size in bytes.
|
||||
"""
|
||||
def get_total_size do
|
||||
case Repo.query(
|
||||
"SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()"
|
||||
) do
|
||||
{:ok, %{rows: [[size]]}} -> size
|
||||
_ -> 0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the SQLCipher version if encryption is enabled, nil otherwise.
|
||||
"""
|
||||
def get_encryption_status do
|
||||
case Repo.query("PRAGMA cipher_version") do
|
||||
{:ok, %{rows: [[version]]}} when is_binary(version) and version != "" -> version
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a list of tables with their row counts and sizes.
|
||||
"""
|
||||
def get_table_stats do
|
||||
# Get all user tables (exclude sqlite internals and FTS shadow tables)
|
||||
case Repo.query("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
AND name NOT LIKE '%_content'
|
||||
AND name NOT LIKE '%_data'
|
||||
AND name NOT LIKE '%_idx'
|
||||
AND name NOT LIKE '%_docsize'
|
||||
AND name NOT LIKE '%_config'
|
||||
ORDER BY name
|
||||
""") do
|
||||
{:ok, %{rows: tables}} ->
|
||||
process_table_stats(tables)
|
||||
|
||||
{:error, _} ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp process_table_stats(tables) do
|
||||
|
||||
table_names = Enum.map(tables, fn [name] -> name end)
|
||||
|
||||
# Get sizes via dbstat if available
|
||||
sizes = get_table_sizes()
|
||||
|
||||
# Get row counts
|
||||
Enum.map(table_names, fn name ->
|
||||
count = get_row_count(name)
|
||||
size = Map.get(sizes, name, 0)
|
||||
|
||||
%{
|
||||
name: name,
|
||||
rows: count,
|
||||
size: size
|
||||
}
|
||||
end)
|
||||
|> Enum.sort_by(& &1.size, :desc)
|
||||
end
|
||||
|
||||
defp get_table_sizes do
|
||||
# Try dbstat first (most accurate, but requires ENABLE_DBSTAT_VTAB compile flag)
|
||||
case Repo.query("""
|
||||
SELECT name, SUM(pgsize) as size
|
||||
FROM dbstat
|
||||
GROUP BY name
|
||||
""") do
|
||||
{:ok, %{rows: rows}} ->
|
||||
Map.new(rows, fn [name, size] -> {name, size || 0} end)
|
||||
|
||||
_ ->
|
||||
# Fallback: estimate sizes by summing column data lengths
|
||||
# This gives a reasonable approximation for display purposes
|
||||
estimate_table_sizes()
|
||||
end
|
||||
end
|
||||
|
||||
defp estimate_table_sizes do
|
||||
# Get all user tables
|
||||
case Repo.query("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
AND name NOT LIKE '%_content'
|
||||
AND name NOT LIKE '%_data'
|
||||
AND name NOT LIKE '%_idx'
|
||||
AND name NOT LIKE '%_docsize'
|
||||
AND name NOT LIKE '%_config'
|
||||
""") do
|
||||
{:ok, %{rows: tables}} ->
|
||||
tables
|
||||
|> Enum.map(fn [name] -> {name, estimate_table_size(name)} end)
|
||||
|> Enum.into(%{})
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
defp estimate_table_size(table_name) do
|
||||
# Get column names for this table
|
||||
case Repo.query("PRAGMA table_info(\"#{table_name}\")") do
|
||||
{:ok, %{rows: columns}} when columns != [] ->
|
||||
column_names = Enum.map(columns, fn [_cid, name | _] -> name end)
|
||||
|
||||
# Build a query that sums the length of all columns
|
||||
# Using COALESCE and length() for text, or 8 bytes for numeric types
|
||||
length_exprs =
|
||||
column_names
|
||||
|> Enum.map(fn col ->
|
||||
"COALESCE(LENGTH(CAST(\"#{col}\" AS BLOB)), 0)"
|
||||
end)
|
||||
|> Enum.join(" + ")
|
||||
|
||||
query = "SELECT SUM(#{length_exprs}) FROM \"#{table_name}\""
|
||||
|
||||
case Repo.query(query) do
|
||||
{:ok, %{rows: [[size]]}} when is_integer(size) -> size
|
||||
{:ok, %{rows: [[size]]}} when is_float(size) -> round(size)
|
||||
_ -> 0
|
||||
end
|
||||
|
||||
_ ->
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
defp get_row_count(table_name) do
|
||||
# Safe since table_name comes from sqlite_master
|
||||
case Repo.query("SELECT COUNT(*) FROM \"#{table_name}\"") do
|
||||
{:ok, %{rows: [[count]]}} -> count
|
||||
_ -> 0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns counts for key entities (products, orders, etc).
|
||||
"""
|
||||
def get_key_counts do
|
||||
@key_tables
|
||||
|> Enum.map(fn table ->
|
||||
{table, get_row_count(table)}
|
||||
end)
|
||||
|> Enum.into(%{})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a backup of the database using VACUUM INTO.
|
||||
|
||||
Returns `{:ok, backup_path}` on success.
|
||||
The backup is encrypted with the same key as the source database.
|
||||
"""
|
||||
def create_backup(opts \\ []) do
|
||||
prefix = Keyword.get(opts, :prefix, "berrypod-backup")
|
||||
save_to_history = Keyword.get(opts, :save_to_history, true)
|
||||
|
||||
timestamp = DateTime.utc_now() |> Calendar.strftime("%Y%m%d-%H%M%S")
|
||||
filename = "#{prefix}-#{timestamp}.db"
|
||||
|
||||
backup_path =
|
||||
if save_to_history do
|
||||
Path.join(backup_dir(), filename)
|
||||
else
|
||||
Path.join(System.tmp_dir!(), filename)
|
||||
end
|
||||
|
||||
case Repo.query("VACUUM INTO ?", [backup_path]) do
|
||||
{:ok, _} ->
|
||||
if save_to_history, do: prune_old_backups()
|
||||
{:ok, backup_path}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a pre-restore backup of the current database.
|
||||
This is automatically called before any restore operation.
|
||||
"""
|
||||
def create_pre_restore_backup do
|
||||
create_backup(prefix: "pre-restore", save_to_history: true)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes old backups, keeping only the most recent N backups.
|
||||
"""
|
||||
def prune_old_backups do
|
||||
max = max_backups()
|
||||
backups = list_backups()
|
||||
|
||||
if length(backups) > max do
|
||||
backups
|
||||
|> Enum.drop(max)
|
||||
|> Enum.each(fn backup ->
|
||||
Logger.info("[Backup] Pruning old backup: #{backup.filename}")
|
||||
File.rm(backup.path)
|
||||
end)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a specific backup by filename.
|
||||
"""
|
||||
def delete_backup(filename) do
|
||||
path = Path.join(backup_dir(), filename)
|
||||
|
||||
if File.exists?(path) and String.ends_with?(filename, ".db") do
|
||||
File.rm(path)
|
||||
else
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates that a backup file can be opened and is a valid Berrypod database.
|
||||
|
||||
Performs comprehensive checks:
|
||||
1. File can be opened with current encryption key
|
||||
2. Contains required tables (users, settings, products, etc.)
|
||||
3. Has schema_migrations table with valid versions
|
||||
4. Integrity check passes
|
||||
|
||||
Returns:
|
||||
- `{:ok, validation_result}` with detailed stats and validation info
|
||||
- `{:error, reason}` with specific error details
|
||||
"""
|
||||
def validate_backup(path) do
|
||||
config = Application.get_env(:berrypod, Berrypod.Repo)
|
||||
key = Keyword.get(config, :key)
|
||||
|
||||
case Exqlite.Sqlite3.open(path, mode: :readonly) do
|
||||
{:ok, conn} ->
|
||||
result = validate_backup_connection(conn, key)
|
||||
Exqlite.Sqlite3.close(conn)
|
||||
result
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:open_failed, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_backup_connection(conn, key) do
|
||||
# Set the encryption key if we have one
|
||||
if key do
|
||||
case Exqlite.Sqlite3.execute(conn, "PRAGMA key = #{key}") do
|
||||
:ok -> :ok
|
||||
{:error, reason} -> throw({:error, {:key_failed, reason}})
|
||||
end
|
||||
end
|
||||
|
||||
# Try to read from the database (will fail if wrong key)
|
||||
case Exqlite.Sqlite3.execute(conn, "SELECT COUNT(*) FROM sqlite_master") do
|
||||
:ok -> :ok
|
||||
{:error, "file is not a database"} -> throw({:error, :invalid_key})
|
||||
{:error, reason} -> throw({:error, {:read_failed, reason}})
|
||||
end
|
||||
|
||||
# Run integrity check
|
||||
case run_integrity_check(conn) do
|
||||
:ok -> :ok
|
||||
{:error, reason} -> throw({:error, {:integrity_failed, reason}})
|
||||
end
|
||||
|
||||
# Check required tables exist
|
||||
case check_required_tables(conn) do
|
||||
:ok -> :ok
|
||||
{:error, missing} -> throw({:error, {:missing_tables, missing}})
|
||||
end
|
||||
|
||||
# Check schema migrations
|
||||
case check_schema_migrations(conn) do
|
||||
{:ok, migration_info} -> migration_info
|
||||
{:error, reason} -> throw({:error, {:migrations_failed, reason}})
|
||||
end
|
||||
|
||||
# Get comprehensive stats
|
||||
stats = get_backup_stats(conn)
|
||||
{:ok, stats}
|
||||
catch
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
|
||||
defp run_integrity_check(conn) do
|
||||
{:ok, stmt} = Exqlite.Sqlite3.prepare(conn, "PRAGMA integrity_check(1)")
|
||||
|
||||
case Exqlite.Sqlite3.step(conn, stmt) do
|
||||
{:row, ["ok"]} ->
|
||||
Exqlite.Sqlite3.release(conn, stmt)
|
||||
:ok
|
||||
|
||||
{:row, [error]} ->
|
||||
Exqlite.Sqlite3.release(conn, stmt)
|
||||
{:error, error}
|
||||
|
||||
{:error, reason} ->
|
||||
Exqlite.Sqlite3.release(conn, stmt)
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_required_tables(conn) do
|
||||
{:ok, stmt} =
|
||||
Exqlite.Sqlite3.prepare(conn, "SELECT name FROM sqlite_master WHERE type='table'")
|
||||
|
||||
tables = collect_rows(conn, stmt, [])
|
||||
table_names = Enum.map(tables, fn [name] -> name end)
|
||||
|
||||
missing = @required_tables -- table_names
|
||||
|
||||
if Enum.empty?(missing) do
|
||||
:ok
|
||||
else
|
||||
{:error, missing}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_schema_migrations(conn) do
|
||||
{:ok, stmt} =
|
||||
Exqlite.Sqlite3.prepare(
|
||||
conn,
|
||||
"SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1"
|
||||
)
|
||||
|
||||
case Exqlite.Sqlite3.step(conn, stmt) do
|
||||
{:row, [latest_version]} ->
|
||||
Exqlite.Sqlite3.release(conn, stmt)
|
||||
{:ok, %{latest_migration: latest_version}}
|
||||
|
||||
:done ->
|
||||
Exqlite.Sqlite3.release(conn, stmt)
|
||||
{:error, "no migrations found"}
|
||||
|
||||
{:error, reason} ->
|
||||
Exqlite.Sqlite3.release(conn, stmt)
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp collect_rows(conn, stmt, acc) do
|
||||
case Exqlite.Sqlite3.step(conn, stmt) do
|
||||
{:row, row} -> collect_rows(conn, stmt, [row | acc])
|
||||
:done -> Exqlite.Sqlite3.release(conn, stmt) && Enum.reverse(acc)
|
||||
end
|
||||
end
|
||||
|
||||
defp get_backup_stats(conn) do
|
||||
# Get table count (excluding sqlite internals and FTS shadow tables, same as get_table_stats)
|
||||
{:ok, stmt} =
|
||||
Exqlite.Sqlite3.prepare(conn, """
|
||||
SELECT COUNT(*) FROM sqlite_master
|
||||
WHERE type='table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
AND name NOT LIKE '%_content'
|
||||
AND name NOT LIKE '%_data'
|
||||
AND name NOT LIKE '%_idx'
|
||||
AND name NOT LIKE '%_docsize'
|
||||
AND name NOT LIKE '%_config'
|
||||
""")
|
||||
|
||||
{:row, [table_count]} = Exqlite.Sqlite3.step(conn, stmt)
|
||||
Exqlite.Sqlite3.release(conn, stmt)
|
||||
|
||||
# Get page count for size estimate
|
||||
{:ok, stmt} =
|
||||
Exqlite.Sqlite3.prepare(
|
||||
conn,
|
||||
"SELECT page_count * page_size FROM pragma_page_count(), pragma_page_size()"
|
||||
)
|
||||
|
||||
{:row, [size]} = Exqlite.Sqlite3.step(conn, stmt)
|
||||
Exqlite.Sqlite3.release(conn, stmt)
|
||||
|
||||
# Get latest migration version
|
||||
{:ok, stmt} =
|
||||
Exqlite.Sqlite3.prepare(
|
||||
conn,
|
||||
"SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1"
|
||||
)
|
||||
|
||||
latest_migration =
|
||||
case Exqlite.Sqlite3.step(conn, stmt) do
|
||||
{:row, [version]} -> version
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
Exqlite.Sqlite3.release(conn, stmt)
|
||||
|
||||
# Get key entity counts
|
||||
key_counts = get_backup_key_counts(conn)
|
||||
|
||||
%{
|
||||
table_count: table_count,
|
||||
size: size,
|
||||
latest_migration: latest_migration,
|
||||
key_counts: key_counts
|
||||
}
|
||||
end
|
||||
|
||||
defp get_backup_key_counts(conn) do
|
||||
@key_tables
|
||||
|> Enum.map(fn table ->
|
||||
count =
|
||||
case Exqlite.Sqlite3.prepare(conn, "SELECT COUNT(*) FROM \"#{table}\"") do
|
||||
{:ok, stmt} ->
|
||||
result =
|
||||
case Exqlite.Sqlite3.step(conn, stmt) do
|
||||
{:row, [count]} -> count
|
||||
_ -> 0
|
||||
end
|
||||
|
||||
Exqlite.Sqlite3.release(conn, stmt)
|
||||
result
|
||||
|
||||
_ ->
|
||||
0
|
||||
end
|
||||
|
||||
{table, count}
|
||||
end)
|
||||
|> Enum.into(%{})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Restores a database from a backup file.
|
||||
|
||||
This performs a full file-based restore by:
|
||||
|
||||
1. Validating the backup (including schema version match)
|
||||
2. Stopping Oban completely
|
||||
3. Checkpointing WAL and draining all database connections
|
||||
4. Stopping the Repo
|
||||
5. Replacing the database file
|
||||
6. Restarting the Repo
|
||||
7. Restarting Oban
|
||||
8. Clearing and warming caches
|
||||
|
||||
Returns `:ok` on success, `{:error, reason}` on failure.
|
||||
"""
|
||||
def restore_backup(backup_path) do
|
||||
config = Application.get_env(:berrypod, Berrypod.Repo)
|
||||
db_path = Keyword.fetch!(config, :database)
|
||||
|
||||
Logger.info("[Backup] Starting database restore from #{backup_path}")
|
||||
|
||||
with :ok <- validate_backup_before_restore(backup_path),
|
||||
{:ok, pre_restore_path} <- create_pre_restore_backup(),
|
||||
:ok <- broadcast_maintenance_mode(:entering),
|
||||
:ok <- stop_oban(),
|
||||
:ok <- drain_and_stop_repo(),
|
||||
:ok <- swap_database_file(backup_path, db_path),
|
||||
:ok <- start_repo(),
|
||||
:ok <- start_oban(),
|
||||
:ok <- clear_ets_caches(),
|
||||
:ok <- warm_caches() do
|
||||
broadcast_maintenance_mode(:exited)
|
||||
Logger.info("[Backup] Database restore completed successfully")
|
||||
Logger.info("[Backup] Pre-restore backup saved: #{pre_restore_path}")
|
||||
:ok
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.error("[Backup] Restore failed: #{inspect(reason)}")
|
||||
# Try to recover
|
||||
start_repo()
|
||||
start_oban()
|
||||
broadcast_maintenance_mode(:exited)
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp stop_oban do
|
||||
# Terminate Oban child from the application supervisor
|
||||
# This stops all Oban processes including plugins and reporters
|
||||
try do
|
||||
Supervisor.terminate_child(Berrypod.Supervisor, Oban)
|
||||
catch
|
||||
_, _ -> :ok
|
||||
end
|
||||
|
||||
# Give processes time to fully terminate
|
||||
Process.sleep(500)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp start_oban do
|
||||
# Restart Oban child in the application supervisor
|
||||
try do
|
||||
Supervisor.restart_child(Berrypod.Supervisor, Oban)
|
||||
catch
|
||||
_, _ -> :ok
|
||||
end
|
||||
|
||||
# Wait for Oban to be ready
|
||||
Process.sleep(500)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp drain_and_stop_repo do
|
||||
# First checkpoint WAL while we still have connections
|
||||
try do
|
||||
Repo.query!("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
rescue
|
||||
_ -> :ok
|
||||
end
|
||||
|
||||
# Disconnect all connections - this forces them to close gracefully
|
||||
Ecto.Adapters.SQL.disconnect_all(Repo, 0)
|
||||
|
||||
# Give connections time to release their file handles
|
||||
Process.sleep(500)
|
||||
|
||||
# Use GenServer.stop which properly shuts down the pool
|
||||
repo_pid = Process.whereis(Repo)
|
||||
|
||||
if repo_pid do
|
||||
ref = Process.monitor(repo_pid)
|
||||
|
||||
# GenServer.stop sends a :stop call which is handled gracefully
|
||||
try do
|
||||
GenServer.stop(repo_pid, :normal, 10_000)
|
||||
catch
|
||||
:exit, _ -> :ok
|
||||
end
|
||||
|
||||
# Wait for the process to actually terminate
|
||||
receive do
|
||||
{:DOWN, ^ref, :process, ^repo_pid, _reason} -> :ok
|
||||
after
|
||||
5000 -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
# Wait for file handles to be fully released by the OS
|
||||
Process.sleep(500)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp start_repo do
|
||||
# The supervisor will restart the Repo automatically since we stopped it
|
||||
# Wait for it to come back up
|
||||
wait_for_repo(100)
|
||||
end
|
||||
|
||||
defp wait_for_repo(0), do: {:error, :repo_start_timeout}
|
||||
|
||||
defp wait_for_repo(attempts) do
|
||||
case Process.whereis(Repo) do
|
||||
nil ->
|
||||
Process.sleep(100)
|
||||
wait_for_repo(attempts - 1)
|
||||
|
||||
_pid ->
|
||||
# Give it a moment to fully initialize the connection pool
|
||||
Process.sleep(200)
|
||||
|
||||
# Verify we can actually query
|
||||
try do
|
||||
case Repo.query("SELECT 1") do
|
||||
{:ok, _} -> :ok
|
||||
{:error, _} ->
|
||||
Process.sleep(100)
|
||||
wait_for_repo(attempts - 1)
|
||||
end
|
||||
catch
|
||||
_, _ ->
|
||||
Process.sleep(100)
|
||||
wait_for_repo(attempts - 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp swap_database_file(backup_path, db_path) do
|
||||
# Remove WAL and SHM files if they exist (they're part of the old database state)
|
||||
File.rm("#{db_path}-wal")
|
||||
File.rm("#{db_path}-shm")
|
||||
|
||||
# Replace the database file
|
||||
case File.cp(backup_path, db_path) do
|
||||
:ok ->
|
||||
File.rm(backup_path)
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:file_copy_failed, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_backup_before_restore(path) do
|
||||
case validate_backup(path) do
|
||||
{:ok, backup_stats} ->
|
||||
# Check schema versions match
|
||||
current_version = get_current_schema_version()
|
||||
|
||||
if backup_stats.latest_migration == current_version do
|
||||
:ok
|
||||
else
|
||||
{:error, {:schema_mismatch, backup_stats.latest_migration, current_version}}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:validation_failed, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_current_schema_version do
|
||||
case Repo.query("SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1") do
|
||||
{:ok, %{rows: [[version]]}} -> version
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp broadcast_maintenance_mode(status) do
|
||||
# Broadcast to all connected LiveViews that maintenance is happening
|
||||
Phoenix.PubSub.broadcast(Berrypod.PubSub, "maintenance", {:maintenance, status})
|
||||
:ok
|
||||
end
|
||||
|
||||
|
||||
defp clear_ets_caches do
|
||||
# Clear known ETS caches to ensure they get rebuilt from the new database
|
||||
caches = [
|
||||
Berrypod.Theme.CSSCache,
|
||||
Berrypod.Pages.PageCache,
|
||||
Berrypod.Redirects.Cache
|
||||
]
|
||||
|
||||
for cache <- caches do
|
||||
try do
|
||||
:ets.delete_all_objects(cache)
|
||||
rescue
|
||||
ArgumentError -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp warm_caches do
|
||||
# Warm up caches after restore
|
||||
try do
|
||||
Berrypod.Pages.PageCache.warm()
|
||||
rescue
|
||||
_ -> :ok
|
||||
end
|
||||
|
||||
try do
|
||||
Berrypod.Redirects.warm_cache()
|
||||
rescue
|
||||
_ -> :ok
|
||||
end
|
||||
|
||||
try do
|
||||
Berrypod.Theme.CSSCache.warm()
|
||||
rescue
|
||||
_ -> :ok
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Formats a byte size into a human-readable string.
|
||||
"""
|
||||
def format_size(bytes) when is_integer(bytes) do
|
||||
cond do
|
||||
bytes >= 1_073_741_824 -> "#{Float.round(bytes / 1_073_741_824, 1)} GB"
|
||||
bytes >= 1_048_576 -> "#{Float.round(bytes / 1_048_576, 1)} MB"
|
||||
bytes >= 1024 -> "#{Float.round(bytes / 1024, 1)} KB"
|
||||
true -> "#{bytes} B"
|
||||
end
|
||||
end
|
||||
|
||||
def format_size(_), do: "0 B"
|
||||
end
|
||||
@@ -177,6 +177,14 @@
|
||||
<.icon name="hero-arrow-uturn-right" class="size-5" /> Redirects
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/backup"}
|
||||
class={admin_nav_active?(@current_path, "/admin/backup")}
|
||||
>
|
||||
<.icon name="hero-circle-stack" class="size-5" /> Backup
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
592
lib/berrypod_web/live/admin/backup.ex
Normal file
592
lib/berrypod_web/live/admin/backup.ex
Normal file
@@ -0,0 +1,592 @@
|
||||
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
|
||||
@@ -170,6 +170,7 @@ defmodule BerrypodWeb.Router do
|
||||
live "/providers/:id/edit", Admin.Providers.Form, :edit
|
||||
live "/settings", Admin.Settings, :index
|
||||
live "/settings/email", Admin.EmailSettings, :index
|
||||
live "/backup", Admin.Backup, :index
|
||||
live "/account", Admin.Account, :index
|
||||
live "/pages", Admin.Pages.Index, :index
|
||||
live "/pages/new", Admin.Pages.CustomForm, :new
|
||||
|
||||
Reference in New Issue
Block a user