defmodule PhoenixKitWeb.Live.Modules.Storage.Settings do @moduledoc """ Storage settings management LiveView for PhoenixKit. Provides configuration interface for the distributed file storage system. """ use PhoenixKitWeb, :live_view use Gettext, backend: PhoenixKitWeb.Gettext require Logger import Ecto.Query alias PhoenixKit.Modules.Storage alias PhoenixKit.Settings alias PhoenixKit.System.Dependencies alias PhoenixKit.Utils.Routes def mount(_params, _session, socket) do # Get current path for navigation current_path = get_current_path(socket, %{}) # Get project title from settings project_title = Settings.get_project_title() # Load buckets buckets = Storage.list_buckets() # Load file counts per bucket (unique files, not instances) bucket_file_counts = get_bucket_file_counts(buckets) # Load storage settings from database (using basic function to avoid cache issues) redundancy_copies = Settings.get_setting("storage_redundancy_copies", "1") auto_generate_variants = Settings.get_setting("storage_auto_generate_variants", "true") default_bucket_uuid = Settings.get_setting("storage_default_bucket_uuid", nil) max_upload_size_mb = Settings.get_setting("storage_max_upload_size_mb", "500") tile_generation_enabled = Settings.get_setting("storage_tile_generation_enabled", "false") annotated_thumbnails_enabled = Settings.get_setting("storage_annotated_thumbnails_enabled", "false") # Calculate maximum redundancy based on available buckets active_buckets = Enum.count(buckets, & &1.enabled) max_redundancy = if active_buckets > 0, do: active_buckets, else: 1 # Keep user's current redundancy setting unchanged current_redundancy = String.to_integer(redundancy_copies) # Store form values for batch updates form_redundancy = current_redundancy form_auto_generate_variants = auto_generate_variants == "true" form_tile_generation_enabled = tile_generation_enabled == "true" form_annotated_thumbnails_enabled = annotated_thumbnails_enabled == "true" current_max_upload_size_mb = String.to_integer(max_upload_size_mb) # Check system dependencies imagemagick_status = Dependencies.check_imagemagick_cached() ffmpeg_status = Dependencies.check_ffmpeg_cached() socket = socket |> assign(:current_path, current_path) |> assign(:page_title, "Media Settings") |> assign(:project_title, project_title) |> assign(:buckets, buckets) |> assign(:bucket_file_counts, bucket_file_counts) |> assign(:redundancy_copies, current_redundancy) |> assign(:auto_generate_variants, auto_generate_variants == "true") |> assign(:tile_generation_enabled, tile_generation_enabled == "true") |> assign(:annotated_thumbnails_enabled, annotated_thumbnails_enabled == "true") |> assign(:default_bucket_uuid, default_bucket_uuid) |> assign(:active_buckets_count, active_buckets) |> assign(:max_redundancy, max_redundancy) |> assign(:form_redundancy, form_redundancy) |> assign(:form_auto_generate_variants, form_auto_generate_variants) |> assign(:form_tile_generation_enabled, form_tile_generation_enabled) |> assign(:form_annotated_thumbnails_enabled, form_annotated_thumbnails_enabled) |> assign(:max_upload_size_mb, current_max_upload_size_mb) |> assign(:form_max_upload_size_mb, current_max_upload_size_mb) |> assign(:imagemagick_status, imagemagick_status) |> assign(:ffmpeg_status, ffmpeg_status) {:ok, socket} end def handle_event("update_redundancy", %{"redundancy_copies" => copies}, socket) do requested_copies = String.to_integer(copies) max_redundancy = socket.assigns.max_redundancy if requested_copies > max_redundancy do socket = socket |> put_flash( :error, gettext( "Cannot set redundancy to %{count} copies. Only %{max} active bucket(s) available.", count: requested_copies, max: max_redundancy ) ) {:noreply, socket} else case Settings.update_setting("storage_redundancy_copies", copies) do {:ok, _setting} -> # Settings.update_setting already handles cache invalidation socket = socket |> assign(:redundancy_copies, requested_copies) |> put_flash( :info, ngettext( "Redundancy settings updated to %{count} copy", "Redundancy settings updated to %{count} copies", requested_copies ) ) {:noreply, socket} {:error, _changeset} -> socket = put_flash(socket, :error, gettext("Failed to update redundancy settings")) {:noreply, socket} end end end def handle_event("update_form_redundancy", %{"form_redundancy" => copies}, socket) do # Handle both string and integer inputs form_redundancy = cond do is_integer(copies) -> copies is_binary(copies) -> String.to_integer(copies) # fallback true -> 1 end socket = socket |> assign(:form_redundancy, form_redundancy) {:noreply, socket} end def handle_event("update_form_variants", %{"form_auto_generate_variants" => value}, socket) do form_auto_generate_variants = value == "true" socket = socket |> assign(:form_auto_generate_variants, form_auto_generate_variants) {:noreply, socket} end def handle_event("toggle_form_tile_generation", _params, socket) do new_value = not socket.assigns.form_tile_generation_enabled {:noreply, assign(socket, :form_tile_generation_enabled, new_value)} end def handle_event("toggle_form_annotated_thumbnails", _params, socket) do new_value = not socket.assigns.form_annotated_thumbnails_enabled {:noreply, assign(socket, :form_annotated_thumbnails_enabled, new_value)} end def handle_event("toggle_form_variants", _params, socket) do new_value = not socket.assigns.form_auto_generate_variants socket = socket |> assign(:form_auto_generate_variants, new_value) {:noreply, socket} end def handle_event("update_storage_form", params, socket) do form_redundancy = case params["form_redundancy"] do nil -> socket.assigns.form_redundancy val when is_integer(val) -> val val when is_binary(val) -> parse_integer(val, socket.assigns.form_redundancy) _ -> socket.assigns.form_redundancy end form_max_upload_size_mb = case params["form_max_upload_size_mb"] do nil -> socket.assigns.form_max_upload_size_mb val when is_integer(val) -> max(val, 1) val when is_binary(val) -> max(parse_integer(val, socket.assigns.form_max_upload_size_mb), 1) _ -> socket.assigns.form_max_upload_size_mb end socket = socket |> assign(:form_redundancy, form_redundancy) |> assign(:form_max_upload_size_mb, form_max_upload_size_mb) {:noreply, socket} end def handle_event("apply_storage_settings", _params, socket) do # Get current form values new_redundancy = socket.assigns.form_redundancy new_variants = if socket.assigns.form_auto_generate_variants, do: "true", else: "false" new_tile_generation = if socket.assigns.form_tile_generation_enabled, do: "true", else: "false" new_annotated_thumbnails = if socket.assigns.form_annotated_thumbnails_enabled, do: "true", else: "false" new_max_upload_size_mb = socket.assigns.form_max_upload_size_mb # Validate redundancy doesn't exceed available buckets max_redundancy = socket.assigns.max_redundancy if new_redundancy > max_redundancy do socket = socket |> put_flash( :error, gettext( "Cannot set redundancy to %{count} copies. Only %{max} active bucket(s) available.", count: new_redundancy, max: max_redundancy ) ) {:noreply, socket} else # Update all settings redundancy_result = Settings.update_setting("storage_redundancy_copies", to_string(new_redundancy)) variants_result = Settings.update_setting("storage_auto_generate_variants", new_variants) Settings.update_setting("storage_tile_generation_enabled", new_tile_generation) Settings.update_setting( "storage_annotated_thumbnails_enabled", new_annotated_thumbnails ) Settings.update_setting( "storage_max_upload_size_mb", to_string(new_max_upload_size_mb) ) case {redundancy_result, variants_result} do {{:ok, _}, {:ok, _}} -> # Verify the settings were saved correctly by reading them back saved_redundancy = Settings.get_setting("storage_redundancy_copies", "1") saved_variants = Settings.get_setting("storage_auto_generate_variants", "true") saved_tile_generation = Settings.get_setting("storage_tile_generation_enabled", "false") saved_annotated_thumbnails = Settings.get_setting("storage_annotated_thumbnails_enabled", "false") saved_max_upload = Settings.get_setting("storage_max_upload_size_mb", "500") socket = socket |> assign(:redundancy_copies, String.to_integer(saved_redundancy)) |> assign(:auto_generate_variants, saved_variants == "true") |> assign(:tile_generation_enabled, saved_tile_generation == "true") |> assign(:annotated_thumbnails_enabled, saved_annotated_thumbnails == "true") |> assign(:form_redundancy, String.to_integer(saved_redundancy)) |> assign(:form_auto_generate_variants, saved_variants == "true") |> assign(:form_tile_generation_enabled, saved_tile_generation == "true") |> assign(:form_annotated_thumbnails_enabled, saved_annotated_thumbnails == "true") |> assign(:max_upload_size_mb, String.to_integer(saved_max_upload)) |> assign(:form_max_upload_size_mb, String.to_integer(saved_max_upload)) |> put_flash(:info, gettext("Storage settings updated successfully")) {:noreply, socket} {{:error, _}, {:ok, _}} -> socket = put_flash(socket, :error, gettext("Failed to update redundancy settings")) {:noreply, socket} {{:ok, _}, {:error, _}} -> socket = put_flash(socket, :error, gettext("Failed to update variant settings")) {:noreply, socket} {{:error, _}, {:error, _}} -> socket = put_flash(socket, :error, gettext("Failed to update storage settings")) {:noreply, socket} end end end def handle_event("toggle_variants", _params, socket) do new_value = if socket.assigns.auto_generate_variants, do: "false", else: "true" case Settings.update_setting("storage_auto_generate_variants", new_value) do {:ok, _setting} -> # Settings.update_setting already handles cache invalidation socket = socket |> assign(:auto_generate_variants, new_value == "true") |> put_flash( :info, if(new_value == "true", do: gettext("Auto-variant generation enabled"), else: gettext("Auto-variant generation disabled") ) ) {:noreply, socket} {:error, _changeset} -> socket = put_flash(socket, :error, gettext("Failed to update variant settings")) {:noreply, socket} end end def handle_event("update_default_bucket", %{"bucket_uuid" => bucket_uuid}, socket) do new_value = if bucket_uuid == "", do: nil, else: bucket_uuid case Settings.update_setting("storage_default_bucket_uuid", new_value) do {:ok, _setting} -> socket = socket |> assign(:default_bucket_uuid, new_value) |> put_flash(:info, gettext("Default bucket updated")) {:noreply, socket} {:error, _changeset} -> socket = put_flash(socket, :error, gettext("Failed to update default bucket")) {:noreply, socket} end end def handle_event("toggle_bucket", %{"id" => bucket_uuid}, socket) do case Storage.get_bucket(bucket_uuid) do nil -> {:noreply, put_flash(socket, :error, gettext("Bucket not found"))} bucket -> new_enabled = !bucket.enabled case Storage.update_bucket(bucket, %{enabled: new_enabled}) do {:ok, _bucket} -> message = if new_enabled, do: gettext("Bucket enabled successfully"), else: gettext("Bucket disabled successfully") socket = reload_settings_data(socket) {:noreply, put_flash(socket, :info, message)} {:error, _changeset} -> {:noreply, put_flash(socket, :error, gettext("Failed to update bucket"))} end end end def handle_event("delete_bucket", %{"id" => bucket_uuid}, socket) do bucket = Storage.get_bucket(bucket_uuid) case Storage.delete_bucket(bucket) do {:ok, _bucket} -> # Reload buckets and recalculate max redundancy buckets = Storage.list_buckets() active_buckets_count = Enum.count(buckets, & &1.enabled) max_redundancy = max(1, active_buckets_count) socket = socket |> assign(:buckets, buckets) |> assign(:active_buckets_count, active_buckets_count) |> assign(:max_redundancy, max_redundancy) |> put_flash(:info, gettext("Bucket deleted successfully")) {:noreply, socket} {:error, _changeset} -> socket = put_flash(socket, :error, gettext("Failed to delete bucket")) {:noreply, socket} end end def handle_event("repair_storage_module", _params, socket) do case Storage.repair_storage_module() do {:ok, repairs} -> repair_summary = format_repairs(repairs) socket = socket |> reload_settings_data() |> put_flash( :info, gettext("Storage module repaired: %{summary}", summary: repair_summary) ) {:noreply, socket} {:error, reason} -> socket = put_flash( socket, :error, gettext("Failed to repair: %{reason}", reason: inspect(reason)) ) {:noreply, socket} end end defp get_current_path(_socket, _session) do # For Storage settings page Routes.path("/admin/settings/media") end # Helper function to get full path for a bucket defp get_bucket_full_path(bucket) do case bucket.provider do "local" -> bucket.endpoint || "No path configured" provider when provider in ["s3", "b2", "r2"] -> path_parts = [ provider <> ":", if(bucket.bucket_name, do: bucket.bucket_name, else: "no-bucket"), if(bucket.endpoint, do: bucket.endpoint, else: "/") ] Enum.join(path_parts, "") _ -> "#{bucket.provider}: unknown configuration" end end # Get count of unique files stored on each bucket defp get_bucket_file_counts(buckets) do repo = PhoenixKit.Config.get_repo() Enum.reduce(buckets, %{}, fn bucket, acc -> # Count distinct files that have at least one instance located on this bucket # We count files, not instances or locations count = repo.one( from f in Storage.File, join: fi in Storage.FileInstance, on: fi.file_uuid == f.uuid, join: fl in Storage.FileLocation, on: fl.file_instance_uuid == fi.uuid, where: fl.bucket_uuid == ^bucket.uuid and fl.status == "active", select: count(f.uuid, :distinct) ) Map.put(acc, bucket.uuid, count || 0) end) rescue _ -> %{} end defp format_repairs(repairs) do Enum.map_join(repairs, ", ", fn {:bucket_created, name} -> "created bucket '#{name}'" {:dimensions_reset, count} -> "reset #{count} dimensions" {:settings_reset, count} -> "reset #{count} settings" end) end defp reload_settings_data(socket) do # Reload buckets buckets = Storage.list_buckets() bucket_file_counts = get_bucket_file_counts(buckets) # Reload storage settings redundancy_copies = Settings.get_setting("storage_redundancy_copies", "1") auto_generate_variants = Settings.get_setting("storage_auto_generate_variants", "true") default_bucket_uuid = Settings.get_setting("storage_default_bucket_uuid", nil) max_upload_size_mb = Settings.get_setting("storage_max_upload_size_mb", "500") # Recalculate max redundancy active_buckets_count = Enum.count(buckets, & &1.enabled) max_redundancy = if active_buckets_count > 0, do: active_buckets_count, else: 1 current_redundancy = String.to_integer(redundancy_copies) current_max_upload_size_mb = String.to_integer(max_upload_size_mb) socket |> assign(:buckets, buckets) |> assign(:bucket_file_counts, bucket_file_counts) |> assign(:redundancy_copies, current_redundancy) |> assign(:auto_generate_variants, auto_generate_variants == "true") |> assign(:default_bucket_uuid, default_bucket_uuid) |> assign(:active_buckets_count, active_buckets_count) |> assign(:max_redundancy, max_redundancy) |> assign(:form_redundancy, current_redundancy) |> assign(:form_auto_generate_variants, auto_generate_variants == "true") |> assign(:max_upload_size_mb, current_max_upload_size_mb) |> assign(:form_max_upload_size_mb, current_max_upload_size_mb) end defp parse_integer(val, fallback) do case Integer.parse(val) do {int, _} -> int :error -> fallback end end end