diff --git a/apps/deployer/lib/deployer/status.ex b/apps/deployer/lib/deployer/status.ex index d15c3e7c..3cad3b63 100644 --- a/apps/deployer/lib/deployer/status.ex +++ b/apps/deployer/lib/deployer/status.ex @@ -36,7 +36,8 @@ defmodule Deployer.Status do certificates: [], otp_version: String.t() | nil, elixir_version: String.t() | nil, - phoenix_version: String.t() | nil + phoenix_version: String.t() | nil, + logo: String.t() | nil } defstruct name: nil, @@ -63,7 +64,8 @@ defmodule Deployer.Status do certificates: [], otp_version: nil, elixir_version: nil, - phoenix_version: nil + phoenix_version: nil, + logo: nil @behaviour Deployer.Status.Adapter diff --git a/apps/deployer/lib/deployer/status/application.ex b/apps/deployer/lib/deployer/status/application.ex index 82984d78..393ba8f1 100644 --- a/apps/deployer/lib/deployer/status/application.ex +++ b/apps/deployer/lib/deployer/status/application.ex @@ -12,6 +12,7 @@ defmodule Deployer.Status.Application do alias Deployer.Monitor alias Deployer.Status alias Deployer.Status.Endpoints + alias Deployer.Status.Logo alias Deployer.Status.Versions alias Foundation.Catalog alias Foundation.Certificates.PublicKey @@ -384,7 +385,8 @@ defmodule Deployer.Status.Application do monitoring: monitoring, otp_version: node_otp_version(node), elixir_version: node_elixir_version(node), - phoenix_version: node_phoenix_version(node) + phoenix_version: node_phoenix_version(node), + logo: node_logo(node, otp == :connected, name) } end @@ -452,4 +454,15 @@ defmodule Deployer.Status.Application do Versions.phoenix_version(node) end end + + defp node_logo(node, connected?, name) do + if connected? do + cache_in_process({node, :logo}) do + Logo.image(node, name) + end + else + Process.delete({node, :logo}) + nil + end + end end diff --git a/apps/deployer/lib/deployer/status/logo.ex b/apps/deployer/lib/deployer/status/logo.ex new file mode 100644 index 00000000..da3b5196 --- /dev/null +++ b/apps/deployer/lib/deployer/status/logo.ex @@ -0,0 +1,75 @@ +defmodule Deployer.Status.Logo do + @moduledoc """ + Reads the logo of a monitored application so it can be shown in the UI. + + The logo is fetched over RPC from the monitored node, at + `priv/static/images/logo.svg` inside the application's own `priv` + directory, and returned as a base64 `data:` URI ready to be used as an + `` source. + + Lookup is best effort and never prevents an application from being + rendered. It distinguishes an application that answered and has no logo, + which is a definitive answer, from a lookup that could not be performed + because the node did not reply, so that a transient failure is not + remembered as a missing logo. + """ + + alias Foundation.Rpc + + @rpc_timeout 1_000 + + ### ========================================================================== + ### Public APIs + ### ========================================================================== + + @doc """ + Return the logo of the application `name` running on `node` as a base64 + `data:` URI. + + Returns `{:ok, nil}` when the node answered but has no `logo.svg`, which + is a definitive answer and safe for the caller to cache. + + Returns `:error` when the question could not be answered at all, because + the name is not a known application or the node did not reply. The caller + is expected to treat this as "unknown for now" and ask again later rather + than remembering the absence of a logo. + """ + @spec image(node :: node(), name :: String.t()) :: {:ok, String.t() | nil} | :error + def image(node, name) do + with {:ok, app} <- existing_atom(name), + {:ok, priv_dir} <- app_priv_dir(node, app), + path <- logo_path(priv_dir), + {:ok, binary} <- Rpc.call(node, :file, :read_file, [path], @rpc_timeout) do + {:ok, "data:image/svg+xml;base64," <> Base.encode64(binary)} + else + :error -> :error + _ -> {:ok, nil} + end + end + + ### ========================================================================== + ### Private functions + ### ========================================================================== + + # The application name only maps to an atom when the application is known + # to this node, so an unknown name is a normal miss rather than a failure. + defp existing_atom(name) do + {:ok, String.to_existing_atom(name)} + rescue + ArgumentError -> :error + end + + defp app_priv_dir(node, app) do + case Rpc.call(node, :code, :priv_dir, [app], @rpc_timeout) do + priv_dir when is_list(priv_dir) -> + {:ok, to_string(priv_dir)} + + _err -> + :error + end + end + + defp logo_path(priv_dir) do + Path.join([priv_dir, "static", "images", "logo.svg"]) + end +end diff --git a/apps/deployer/test/status/logo_test.exs b/apps/deployer/test/status/logo_test.exs new file mode 100644 index 00000000..a3502b54 --- /dev/null +++ b/apps/deployer/test/status/logo_test.exs @@ -0,0 +1,76 @@ +defmodule Deployer.Status.LogoTest do + use ExUnit.Case, async: false + + import Mox + + alias Deployer.Status.Logo + + setup [ + :set_mox_global, + :verify_on_exit! + ] + + @node :"myelixir-abc123@nohost" + @name "myelixir" + @app :myelixir + @priv_dir ~c"/opt/myelixir/lib/myelixir-1.0.0/priv" + @svg ~s() + + test "image/2 returns the logo as a base64 svg data uri" do + Foundation.RpcMock + |> expect(:call, fn @node, :code, :priv_dir, [@app], _timeout -> @priv_dir end) + |> expect(:call, fn @node, :file, :read_file, [_path], _timeout -> {:ok, @svg} end) + + assert {:ok, "data:image/svg+xml;base64," <> encoded} = Logo.image(@node, @name) + assert {:ok, @svg} = Base.decode64(encoded) + end + + test "image/2 reads logo.svg from the application's own priv directory" do + Foundation.RpcMock + |> expect(:call, fn @node, :code, :priv_dir, [@app], _timeout -> @priv_dir end) + |> expect(:call, fn @node, :file, :read_file, [path], _timeout -> + assert path == "/opt/myelixir/lib/myelixir-1.0.0/priv/static/images/logo.svg" + {:ok, @svg} + end) + + assert {:ok, _data_uri} = Logo.image(@node, @name) + end + + test "image/2 returns nil when the application has no logo.svg" do + Foundation.RpcMock + |> expect(:call, fn @node, :code, :priv_dir, [@app], _timeout -> @priv_dir end) + |> expect(:call, fn @node, :file, :read_file, [_path], _timeout -> {:error, :enoent} end) + + assert {:ok, nil} = Logo.image(@node, @name) + end + + test "image/2 errors when the application is not loaded on the node" do + Foundation.RpcMock + |> expect(:call, fn @node, :code, :priv_dir, [@app], _timeout -> {:error, :bad_name} end) + + assert :error = Logo.image(@node, @name) + end + + test "image/2 errors when the node is unreachable" do + Foundation.RpcMock + |> expect(:call, fn @node, :code, :priv_dir, [@app], _timeout -> {:badrpc, :nodedown} end) + + assert :error = Logo.image(@node, @name) + end + + test "image/2 errors for an unknown application name without calling the node" do + # verify_on_exit! fails the test if the unknown name reaches the node + assert :error = Logo.image(@node, "unknown_#{System.unique_integer([:positive])}") + end + + test "image/2 separates a node with no logo from a node that did not answer" do + # only the first is a definitive answer, so only it is safe for the caller to cache + Foundation.RpcMock + |> expect(:call, fn @node, :code, :priv_dir, [@app], _timeout -> @priv_dir end) + |> expect(:call, fn @node, :file, :read_file, [_path], _timeout -> {:error, :enoent} end) + |> expect(:call, fn @node, :code, :priv_dir, [@app], _timeout -> {:badrpc, :nodedown} end) + + assert {:ok, nil} = Logo.image(@node, @name) + assert :error = Logo.image(@node, @name) + end +end diff --git a/apps/deployex_web/lib/deployex_web/live/components/application_card.ex b/apps/deployex_web/lib/deployex_web/live/components/application_card.ex index d9dda39e..852d2cc5 100644 --- a/apps/deployex_web/lib/deployex_web/live/components/application_card.ex +++ b/apps/deployex_web/lib/deployex_web/live/components/application_card.ex @@ -35,10 +35,12 @@ defmodule DeployexWeb.Components.ApplicationCard do
-
-
- -
+
+

{@application.name}