21.4 KiB · text History b868907
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