12.9 KiB · text History b868907
defmodule GitGudWeb.AdminPackagesLive.Index do
  @moduledoc """
  Registry disk, and the levers over it.

  Two halves, in the order an operator needs them: what the disk is
  doing, then what can be done about it. The retention numbers are
  shown *before* enforcement can be turned on, because "this would
  delete 412 versions" is the only honest way to ask for that decision.
  """
  use GitGudWeb, :live_view

  alias GitGud.Packages.DiskUsage
  alias GitGud.Packages.Pruner
  alias GitGud.Settings

  @setting_keys ~w(
    packages.retention_enforced
    packages.retention_mode
    packages.retention_keep
    packages.retention_bytes
    packages.max_pins
  )

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:page_title, "Package storage")
     |> assign(:pruning, false)
     |> load()}
  end

  defp load(socket) do
    socket
    |> assign(:settings, Settings.get_all(@setting_keys))
    |> assign(:total, DiskUsage.total())
    |> assign(:by_format, DiskUsage.by_format())
    |> assign(:by_repository, DiskUsage.by_repository() |> Enum.take(10))
    |> assign(:top_packages, DiskUsage.top_packages(limit: 15))
    |> assign_async(:plan, fn -> {:ok, %{plan: instance_plan()}} end)
  end

  # What retention would do instance-wide. A walk rather than a query —
  # the policy is per package and involves pins — so it runs off the
  # LiveView process and the page renders without it.
  defp instance_plan do
    {:ok, result} = Pruner.prune_all([])
    result
  end

  @impl true
  def handle_event("save", %{"settings" => attrs}, socket) do
    actor = socket.assigns.current_scope.user
    keys = for {key, _} <- attrs, key in @setting_keys, do: key
    before = Settings.get_all(keys)

    # All the keys or none, with an entry for each that changed.
    saved =
      GitGud.Audit.transact_all(
        socket.assigns.current_scope,
        ["instance_setting.changed"],
        fn ->
          Enum.reduce_while(attrs, {:ok, %{}}, fn
            {key, value}, {:ok, acc} when key in @setting_keys ->
              case Settings.put(key, normalize(key, value), actor) do
                {:ok, _} -> {:cont, {:ok, Map.put(acc, key, Settings.get(key))}}
                {:error, reason} -> {:halt, {:error, {key, reason}}}
              end

            _, acc ->
              {:cont, acc}
          end)
        end,
        fn after_save ->
          for {key, value} <- Enum.sort(after_save), value != before[key] do
            {"instance_setting.changed",
             %{target_label: key, metadata: %{from: before[key], to: value}}}
          end
        end
      )

    case saved do
      {:ok, _} ->
        {:noreply, socket |> put_flash(:info, "Saved.") |> load()}

      {:error, {key, reason}} ->
        {:noreply, put_flash(socket, :error, "#{key}: #{describe(reason)}")}
    end
  end

  def handle_event("prune_now", _params, socket) do
    {:noreply,
     socket
     |> assign(:pruning, true)
     |> start_async(:prune, fn -> Pruner.prune_all(force: true) end)}
  end

  @impl true
  def handle_async(:prune, {:ok, {:ok, result}}, socket) do
    GitGud.Audit.record(socket.assigns.current_scope, "packages.pruned", %{
      metadata: %{versions: result.pruned, bytes: result.bytes, failed: length(result.failed)}
    })

    {:noreply,
     socket
     |> assign(:pruning, false)
     |> put_flash(
       :info,
       "Pruned #{result.pruned} version(s), freed #{DiskUsage.humanize(result.bytes)}." <>
         if(result.failed == [], do: "", else: " #{length(result.failed)} kept after errors.")
     )
     |> load()}
  end

  def handle_async(:prune, {:exit, reason}, socket) do
    {:noreply,
     socket
     |> assign(:pruning, false)
     |> put_flash(:error, "Prune crashed: #{inspect(reason)}")}
  end

  # An empty quota field means "no ceiling", not zero — zero would
  # prune every registry on the instance down to one version each.
  defp normalize("packages.retention_bytes", ""), do: nil
  defp normalize("packages.retention_enforced", value), do: value == "true"
  defp normalize(_key, value), do: value

  defp describe({:too_small, min}), do: "must be at least #{min}"
  defp describe({:too_large, max}), do: "must be at most #{max}"
  defp describe({:not_allowed, allowed}), do: "must be one of #{Enum.join(allowed, ", ")}"
  defp describe(:not_a_number), do: "must be a number"
  defp describe(other), do: inspect(other)

  defp mib(nil), do: ""
  defp mib(bytes), do: to_string(bytes)

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <div class="space-y-8 max-w-5xl">
        <div>
          <h1 class="text-2xl font-semibold">Package storage</h1>
          <p class="text-sm opacity-70 mt-1">
            {DiskUsage.humanize(@total.bytes)} across {@total.packages} packages
            and {@total.versions} versions.
          </p>
        </div>

        <section class="space-y-3">
          <h2 class="font-semibold">By format</h2>
          <div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
            <div :for={row <- @by_format} class="border border-base-300 rounded-lg px-3 py-2">
              <div class="text-xs opacity-60 uppercase tracking-wide">{row.label}</div>
              <div class="text-xl font-semibold tabular-nums">{DiskUsage.humanize(row.bytes)}</div>
              <div class="text-xs opacity-60">
                {row.packages} packages · {row.versions} versions
              </div>
            </div>
            <p :if={@by_format == []} class="text-sm opacity-70">Nothing published yet.</p>
          </div>
          <p :if={Enum.any?(@by_format, &(&1.key == "oci"))} class="text-xs opacity-60">
            OCI bytes live in the registry sidecar, not this instance's storage. Pruning an OCI
            version deletes its manifest there; the disk comes back when zot's own GC runs.
          </p>
        </section>

        <section class="space-y-3">
          <h2 class="font-semibold">Retention</h2>

          <.async_result :let={plan} assign={@plan}>
            <:loading>
              <p class="text-sm opacity-70">Working out what retention would do…</p>
            </:loading>
            <:failed :let={_reason}>
              <p class="text-sm opacity-70">Couldn't compute the retention plan.</p>
            </:failed>

            <div class={[
              "alert text-sm",
              @settings["packages.retention_enforced"] && "alert-success",
              !@settings["packages.retention_enforced"] && "alert-warning"
            ]}>
              <.icon
                name={
                  if @settings["packages.retention_enforced"],
                    do: "hero-check-circle",
                    else: "hero-exclamation-triangle"
                }
                class="w-5 h-5"
              />
              <span :if={@settings["packages.retention_enforced"]}>
                Enforcing. The nightly pass would remove {plan.pruned} version(s),
                freeing {DiskUsage.humanize(plan.bytes)}.
              </span>
              <span :if={!@settings["packages.retention_enforced"]}>
                Reporting only — nothing is being deleted. Enabling enforcement would remove
                <strong>{plan.pruned} version(s)</strong>
                and free <strong>{DiskUsage.humanize(plan.bytes)}</strong>.
              </span>
            </div>

            <button
              :if={plan.pruned > 0}
              type="button"
              phx-click="prune_now"
              disabled={@pruning}
              data-confirm={"Delete #{plan.pruned} version(s) now, freeing #{DiskUsage.humanize(plan.bytes)}? The bytes are gone."}
              class="btn btn-sm btn-outline btn-warning"
            >
              {if @pruning, do: "Pruning…", else: "Prune now"}
            </button>
          </.async_result>

          <form phx-submit="save" class="space-y-3 max-w-xl">
            <label class="flex items-start gap-2 text-sm">
              <input type="hidden" name="settings[packages.retention_enforced]" value="false" />
              <input
                type="checkbox"
                name="settings[packages.retention_enforced]"
                value="true"
                checked={@settings["packages.retention_enforced"]}
                class="checkbox checkbox-sm mt-0.5"
              />
              <span>
                Enforce retention
                <span class="block text-xs opacity-60">
                  Off, the nightly pass only reports. On, it deletes.
                </span>
              </span>
            </label>

            <label class="flex flex-col gap-1">
              <span class="text-sm opacity-70">Default mode</span>
              <select
                name="settings[packages.retention_mode]"
                class="select select-bordered select-sm"
              >
                <option
                  value="keep_last"
                  selected={@settings["packages.retention_mode"] == "keep_last"}
                >
                  Keep the newest N versions
                </option>
                <option value="quota" selected={@settings["packages.retention_mode"] == "quota"}>
                  Keep what fits in a byte budget
                </option>
              </select>
            </label>

            <div class="grid gap-3 sm:grid-cols-2">
              <label class="flex flex-col gap-1">
                <span class="text-sm opacity-70">Versions kept (ceiling)</span>
                <input
                  type="number"
                  name="settings[packages.retention_keep]"
                  value={@settings["packages.retention_keep"]}
                  min="1"
                  class="input input-bordered input-sm"
                />
              </label>
              <label class="flex flex-col gap-1">
                <span class="text-sm opacity-70">
                  Byte budget <span class="opacity-60">(blank = none)</span>
                </span>
                <input
                  type="number"
                  name="settings[packages.retention_bytes]"
                  value={mib(@settings["packages.retention_bytes"])}
                  min="0"
                  class="input input-bordered input-sm"
                />
              </label>
            </div>

            <label class="flex flex-col gap-1 max-w-xs">
              <span class="text-sm opacity-70">Pins allowed per package</span>
              <input
                type="number"
                name="settings[packages.max_pins]"
                value={@settings["packages.max_pins"]}
                min="0"
                class="input input-bordered input-sm"
              />
            </label>

            <p class="text-xs opacity-60">
              A package may set its own policy, but only a stricter one — fewer versions or
              fewer bytes than these. Lowering a ceiling tightens every package under it
              immediately.
            </p>

            <button type="submit" class="btn btn-sm btn-primary">Save</button>
          </form>
        </section>

        <section class="space-y-3">
          <h2 class="font-semibold">Biggest packages</h2>
          <div class="overflow-x-auto border border-base-300 rounded-lg">
            <table class="table table-sm">
              <thead>
                <tr>
                  <th>Package</th>
                  <th>Repository</th>
                  <th class="w-20">Format</th>
                  <th class="w-24 text-right">Versions</th>
                  <th class="w-28 text-right">Size</th>
                </tr>
              </thead>
              <tbody>
                <tr :for={row <- @top_packages}>
                  <td class="font-mono">{row.name}</td>
                  <td class="opacity-70">{row.repository}</td>
                  <td><span class="badge badge-sm badge-ghost">{row.format}</span></td>
                  <td class="text-right tabular-nums">{row.versions}</td>
                  <td class="text-right tabular-nums">{DiskUsage.humanize(row.bytes)}</td>
                </tr>
                <tr :if={@top_packages == []}>
                  <td colspan="5" class="text-sm opacity-70">Nothing published yet.</td>
                </tr>
              </tbody>
            </table>
          </div>
        </section>

        <section class="space-y-3">
          <h2 class="font-semibold">Biggest repositories</h2>
          <div class="overflow-x-auto border border-base-300 rounded-lg">
            <table class="table table-sm">
              <tbody>
                <tr :for={row <- @by_repository}>
                  <td>{row.label}</td>
                  <td class="text-right tabular-nums opacity-70">{row.packages} packages</td>
                  <td class="text-right tabular-nums">{DiskUsage.humanize(row.bytes)}</td>
                </tr>
                <tr :if={@by_repository == []}>
                  <td class="text-sm opacity-70">Nothing published yet.</td>
                </tr>
              </tbody>
            </table>
          </div>
        </section>
      </div>
    </Layouts.app>
    """
  end
end