defmodule GitGudWeb.OrgLive.Packages do
@moduledoc """
What an organization publishes, and what its registries cost.
Two questions that belong on one page. "What would someone get if
they pointed a package manager at us" is answered from the same
visibility filter the `_all` endpoints use, so the listing can't
promise something a consumer then can't fetch.
The same numbers as the admin view, narrowed to one org and visible
to its members rather than only to instance admins — an org that
publishes nightlies should be able to see that for itself without
filing a ticket.
Three tabs, because those are three different visits: **Catalog** —
cards for what's published, with icons and descriptions from
`GitGud.Packages.About`; **Registries** — the namespace-wide URL per
format and what each offers; **Storage** — what it all costs. The tab
and the catalog's format filter are path segments
(`/packages/storage`, `/packages/catalog/fdroid`), so a link to "our
Android apps" is a link.
Read-only on purpose. Retention is set per package (on the
repository's packages page) or instance-wide (by an admin); an
org-level policy would be a third place for the same decision to
live, and the first two already have to agree.
"""
use GitGudWeb, :live_view
alias GitGud.Organizations
alias GitGud.Packages
alias GitGud.Packages.About
alias GitGud.Packages.DiskUsage
alias GitGud.Packages.Format
alias GitGud.Packages.Usage
alias GitGud.Packages.Pruner
alias GitGud.Repositories
@tabs [{"catalog", "Catalog"}, {"registries", "Registries"}, {"storage", "Storage"}]
@tab_ids Enum.map(@tabs, &elem(&1, 0))
@format_labels %{
"oci" => "Containers",
"pypi" => "Python",
"hex" => "Hex",
"cargo" => "Crates",
"deb" => "Debian",
"rpm" => "RPM",
"fdroid" => "Android",
"raw" => "Files"
}
# A glyph per format for cards with no icon of their own, so a grid
# of crates doesn't read as a grid of blanks.
@format_icons %{
"oci" => "hero-cube",
"pypi" => "hero-code-bracket",
"hex" => "hero-beaker",
"cargo" => "hero-wrench-screwdriver",
"deb" => "hero-server-stack",
"rpm" => "hero-server-stack",
"fdroid" => "hero-device-phone-mobile",
"raw" => "hero-document"
}
@impl true
def mount(%{"handle" => handle}, _session, socket) do
org = Organizations.get_organization_by_handle!(handle)
user = socket.assigns.current_scope && socket.assigns.current_scope.user
if can_view?(org, user) do
{:ok,
socket
|> assign(:org, org)
|> assign(:is_admin?, !!(user && Organizations.org_admin?(org, user)))
|> assign(:page_title, "Packages — @#{org.handle}")
|> assign(:tabs, @tabs)
|> load(org, user)}
else
{:ok,
socket
|> put_flash(:error, "You don't have access to this organization.")
|> redirect(to: ~p"/orgs")}
end
end
defp load(socket, org, user) do
# Scoped to what this viewer can read, so the total on the page is
# a total of things they could have opened themselves.
repo_ids = Repositories.visible_repo_ids_for_org(org, user)
socket
|> assign(:total, totals(repo_ids))
|> assign(:by_format, merge_rows(repo_ids, &DiskUsage.by_format/1))
|> assign(:by_repository, merge_rows(repo_ids, &DiskUsage.by_repository/1))
|> assign(:top_packages, top_packages(repo_ids))
|> assign_catalog(Packages.namespace_catalog(org, principal(user)))
|> assign(:registry_root, registry_root())
|> assign_async(:plan, fn -> {:ok, %{plan: plan_for(repo_ids)}} end)
end
@impl true
def handle_params(params, _uri, socket) do
tab = if params["tab"] in @tab_ids, do: params["tab"], else: "catalog"
format = if Map.has_key?(socket.assigns.catalog, params["format"]), do: params["format"]
{:noreply, socket |> assign(:tab, tab) |> assign(:format_filter, format)}
end
# Flattened once for the cards; `@catalog` keeps the per-format shape
# the Registries tab reads.
defp assign_catalog(socket, catalog) do
entries =
catalog
|> Enum.flat_map(fn {_format, entry} -> entry.packages end)
about = entries |> Enum.map(& &1.package) |> About.for_packages()
cards =
entries
|> Enum.map(&Map.put(&1, :about, Map.fetch!(about, &1.package.id)))
|> Enum.sort_by(&{String.downcase(&1.about.title), &1.package.format})
socket
|> assign(:catalog, catalog)
|> assign(:cards, cards)
end
# Deep links: `/packages/registries`, `/packages/catalog/fdroid`. The
# catalog is the bare path, so it has no segment of its own until a
# format narrows it.
defp tab_path(org, "catalog", nil), do: ~p"/orgs/#{org.handle}/packages"
defp tab_path(org, "catalog", format), do: ~p"/orgs/#{org.handle}/packages/catalog/#{format}"
defp tab_path(org, tab, _format), do: ~p"/orgs/#{org.handle}/packages/#{tab}"
defp visible_cards(cards, nil), do: cards
defp visible_cards(cards, format), do: Enum.filter(cards, &(&1.package.format == format))
defp format_label(format), do: Map.get(@format_labels, format, format)
defp format_icon(format), do: Map.get(@format_icons, format, "hero-archive-box")
# The same principal shape the registry controller authorizes with, so
# this page and the `_all` endpoints filter identically. Anything else
# risks listing a package a visitor then can't fetch.
defp principal(nil), do: :anonymous
defp principal(user), do: {:user, user, :all}
defp registry_root do
(Application.get_env(:git_gud, :package_registry_url) || GitGudWeb.Endpoint.url())
|> String.trim_trailing("/")
end
# What an anonymous client would actually be offered. The rest need a
# token, and a page that doesn't say so is how "it lists nine and my
# phone shows five" happens.
defp public_count(entry),
do: Enum.count(entry.packages, &(&1.repository.visibility == "public"))
defp namespace_url(root, handle, format),
do: "#{root}/pkg/#{format}/#{handle}/#{Format.namespace_marker()}"
# F-Droid's address has to end in /repo or the client rewrites it —
# see GitGud.Packages.Formats.Fdroid.repo_url/1.
defp consumer_url(root, handle, "fdroid"),
do: GitGud.Packages.Formats.Fdroid.repo_url(namespace_url(root, handle, "fdroid"))
defp consumer_url(root, handle, format), do: namespace_url(root, handle, format)
# `DiskUsage` scopes by one repository at a time; an org is a list of
# them, so the rows are summed here rather than teaching the query a
# shape only this page needs.
defp merge_rows(repo_ids, fun) do
repo_ids
|> Enum.flat_map(&fun.(repository_id: &1))
|> Enum.group_by(& &1.key)
|> Enum.map(fn {key, rows} ->
%{
key: key,
label: hd(rows).label,
bytes: Enum.sum(Enum.map(rows, & &1.bytes)),
versions: Enum.sum(Enum.map(rows, & &1.versions)),
packages: Enum.sum(Enum.map(rows, & &1.packages))
}
end)
|> Enum.sort_by(& &1.bytes, :desc)
end
defp totals(repo_ids) do
repo_ids
|> Enum.map(&DiskUsage.total(repository_id: &1))
|> Enum.reduce(%{bytes: 0, packages: 0, versions: 0}, fn row, acc ->
%{
bytes: acc.bytes + row.bytes,
packages: acc.packages + row.packages,
versions: acc.versions + row.versions
}
end)
end
defp top_packages(repo_ids) do
repo_ids
|> Enum.flat_map(&DiskUsage.top_packages(repository_id: &1, limit: 10))
|> Enum.sort_by(& &1.bytes, :desc)
|> Enum.take(15)
end
defp plan_for(repo_ids) do
Enum.reduce(repo_ids, %{pruned: 0, bytes: 0}, fn id, acc ->
{:ok, result} = Pruner.prune_all(repository_id: id)
%{pruned: acc.pruned + result.pruned, bytes: acc.bytes + result.bytes}
end)
end
defp can_view?(%{visibility: "public"}, _user), do: true
defp can_view?(%{visibility: "internal"}, %{} = _user), do: true
defp can_view?(%{visibility: "internal"}, nil), do: false
defp can_view?(%{visibility: "private"} = org, %{} = user), do: Organizations.member?(org, user)
defp can_view?(_, _), do: false
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="space-y-6 max-w-5xl">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold">Packages</h1>
<p class="text-sm opacity-70 mt-1">
<.link navigate={~p"/orgs/#{@org.handle}"} class="link link-hover">
@{@org.handle}
</.link>
publishes {@total.packages} packages across {map_size(@catalog)} registries · {DiskUsage.humanize(
@total.bytes
)} in {@total.versions} versions.
</p>
</div>
</div>
<nav
id="org-packages-tabs"
role="tablist"
class="flex gap-1 border-b border-base-300"
>
<.link
:for={{id, label} <- @tabs}
id={"org-packages-tab-#{id}"}
patch={tab_path(@org, id, nil)}
role="tab"
aria-selected={to_string(@tab == id)}
class={[
"px-3 py-2 text-sm -mb-px border-b-2 transition-colors duration-150",
if(@tab == id,
do: "border-primary font-medium",
else: "border-transparent opacity-60 hover:opacity-100 hover:border-base-300"
)
]}
>
{label}
<span :if={id == "catalog"} class="ml-1 text-xs opacity-60 tabular-nums">
{length(@cards)}
</span>
</.link>
</nav>
<%!-- ── Catalog ─────────────────────────────────────────── --%>
<section :if={@tab == "catalog"} id="org-packages-catalog" class="space-y-4">
<div :if={map_size(@catalog) > 1} class="flex flex-wrap gap-2" id="catalog-format-filter">
<.link
patch={tab_path(@org, "catalog", nil)}
class={[
"px-2.5 py-1 rounded-full text-xs border transition-colors duration-150",
if(is_nil(@format_filter),
do: "bg-base-content text-base-100 border-base-content",
else: "border-base-300 hover:border-base-content/40"
)
]}
>
All
</.link>
<.link
:for={format <- @catalog |> Map.keys() |> Enum.sort()}
id={"catalog-filter-#{format}"}
patch={tab_path(@org, "catalog", format)}
class={[
"px-2.5 py-1 rounded-full text-xs border inline-flex items-center gap-1 transition-colors duration-150",
if(@format_filter == format,
do: "bg-base-content text-base-100 border-base-content",
else: "border-base-300 hover:border-base-content/40"
)
]}
>
<.icon name={format_icon(format)} class="size-3.5" />
{format_label(format)}
<span class="opacity-60 tabular-nums">{length(@catalog[format].packages)}</span>
</.link>
</div>
<p
:if={@cards == []}
class="px-3 py-10 text-sm opacity-60 text-center border border-base-300 border-dashed rounded-lg"
>
Nothing published yet. Each repository's packages page has the
instructions for every format.
</p>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<.link
:for={card <- visible_cards(@cards, @format_filter)}
id={"package-card-#{card.package.id}"}
navigate={~p"/r/#{@org.handle}/#{card.repository.name}/packages"}
class="group flex flex-col gap-3 rounded-xl border border-base-300 bg-base-100 p-4 transition duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-base-content/20"
>
<div class="flex items-start gap-3">
<%= if card.about.icon? do %>
<img
src={
~p"/r/#{@org.handle}/#{card.repository.name}/packages/#{card.package.id}/icon"
}
alt=""
loading="lazy"
class="size-12 shrink-0 rounded-xl bg-base-200 object-cover ring-1 ring-base-300 transition-transform duration-200 group-hover:scale-105"
/>
<% else %>
<div class="size-12 shrink-0 rounded-xl bg-base-200 ring-1 ring-base-300 grid place-items-center transition-transform duration-200 group-hover:scale-105">
<.icon name={format_icon(card.package.format)} class="size-6 opacity-50" />
</div>
<% end %>
<div class="min-w-0 flex-1">
<p class="font-semibold leading-tight truncate" title={card.about.title}>
{card.about.title}
</p>
<p
:if={card.about.title != card.package.name}
class="font-mono text-[11px] opacity-50 truncate"
title={card.package.name}
>
{card.package.name}
</p>
<div class="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
<span class="px-1.5 py-0.5 rounded bg-base-200 opacity-80">
{format_label(card.package.format)}
</span>
<span :if={card.package.latest_version} class="font-mono opacity-60 truncate">
{card.package.latest_version}
</span>
</div>
</div>
</div>
<p
:if={card.about.summary}
class="text-sm opacity-80 line-clamp-2"
title={card.about.description || card.about.summary}
>
{card.about.summary}
</p>
<p :if={!card.about.summary} class="text-sm opacity-40 italic">
No description yet.
</p>
<div class="mt-auto flex items-center justify-between gap-2 text-[11px] opacity-60">
<span class="inline-flex items-center gap-1 truncate">
<.icon name="hero-folder" class="size-3.5" />
{card.repository.name}
<span
:if={card.repository.visibility != "public"}
class="px-1 rounded border border-base-300"
title={"#{card.repository.visibility} — an anonymous client won't be offered this"}
>
{card.repository.visibility}
</span>
</span>
<span class="tabular-nums shrink-0">
{card.versions} {if card.versions == 1, do: "version", else: "versions"}
</span>
</div>
</.link>
</div>
</section>
<%!-- ── Registries ──────────────────────────────────────── --%>
<section :if={@tab == "registries"} id="org-packages-registries" class="space-y-3">
<div>
<h2 class="font-semibold">What consumers see</h2>
<p class="text-xs opacity-70">
Each registry's namespace-wide view — one URL per format, covering every
repository @{@org.handle} publishes. This is <strong>your</strong>
view: a client with no credentials is offered only packages from public
repositories, so anything badged below needs a registry token configured
in the client.
</p>
</div>
<p :if={@catalog == %{}} class="text-sm opacity-60">
Nothing published yet. The per-repository packages pages have the
instructions for each format.
</p>
<div
:for={{format, entry} <- Enum.sort_by(@catalog, &elem(&1, 0))}
class="border border-base-300 rounded-lg overflow-hidden"
>
<div class="px-3 py-2 bg-base-200 space-y-1">
<div class="flex flex-wrap items-baseline gap-2">
<span class="badge badge-sm badge-ghost">{format}</span>
<span class="text-xs opacity-70">
{length(entry.packages)} packages · {Enum.count(entry.repositories)} repositories
</span>
<span
:if={public_count(entry) < length(entry.packages)}
class="text-xs opacity-70"
title="A client with no credentials only gets the public ones"
>
· <strong>{public_count(entry)}</strong> visible without a token
</span>
</div>
<p :if={Usage.summary(format)} class="text-xs opacity-60">{Usage.summary(format)}</p>
<pre class="text-[11px] bg-base-100 rounded px-2 py-1 overflow-x-auto select-all"><code>{consumer_url(@registry_root, @org.handle, format)}</code></pre>
</div>
<table class="table table-sm">
<thead>
<tr>
<th>Package</th>
<th>From</th>
<th class="text-right">Versions</th>
</tr>
</thead>
<tbody>
<tr :for={row <- entry.packages}>
<td class="font-mono text-xs">{row.package.name}</td>
<td class="text-xs opacity-70">
<.link
navigate={~p"/r/#{@org.handle}/#{row.repository.name}/packages"}
class="link link-hover"
>
{row.repository.name}
</.link>
<span
:if={row.repository.visibility != "public"}
class="badge badge-xs badge-ghost ml-1"
title={"#{row.repository.visibility} — an anonymous client won't be offered this"}
>
{row.repository.visibility}
</span>
</td>
<td class="text-right text-xs tabular-nums">{row.versions}</td>
</tr>
</tbody>
</table>
</div>
</section>
<%!-- ── Storage ─────────────────────────────────────────── --%>
<section :if={@tab == "storage"} id="org-packages-storage" class="space-y-6">
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<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.versions} versions</div>
</div>
</div>
<.async_result :let={plan} assign={@plan}>
<:loading>
<p class="text-sm opacity-70">Checking what retention would free…</p>
</:loading>
<:failed :let={_}><span /></:failed>
<p :if={plan.pruned > 0} class="text-sm opacity-80">
Retention would remove {plan.pruned} version(s) here, freeing {DiskUsage.humanize(
plan.bytes
)}. Pins and per-package policies are set on each
repository's packages page.
</p>
</.async_result>
<section :if={@by_repository != []} class="space-y-2">
<h2 class="font-semibold">By repository</h2>
<div class="overflow-x-auto border border-base-300 rounded-lg">
<table class="table table-sm">
<tbody>
<tr :for={row <- @by_repository}>
<td>
<.link
navigate={~p"/r/#{@org.handle}/#{row.label}/packages"}
class="link link-hover"
>
{row.label}
</.link>
</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>
</tbody>
</table>
</div>
</section>
<section :if={@top_packages != []} class="space-y-2">
<h2 class="font-semibold">Biggest packages</h2>
<div class="overflow-x-auto border border-base-300 rounded-lg">
<table class="table table-sm">
<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 opacity-70">{row.versions} versions</td>
<td class="text-right tabular-nums">{DiskUsage.humanize(row.bytes)}</td>
</tr>
</tbody>
</table>
</div>
</section>
<p :if={@total.bytes == 0} class="text-sm opacity-70">
Nothing published in this organization yet.
</p>
</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.
21.4 KiB · text
History
b868907