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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
12.9 KiB · text
History
b868907