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