Skip to content
Merged
6 changes: 4 additions & 2 deletions apps/deployer/lib/deployer/status.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down
15 changes: 14 additions & 1 deletion apps/deployer/lib/deployer/status/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
75 changes: 75 additions & 0 deletions apps/deployer/lib/deployer/status/logo.ex
Original file line number Diff line number Diff line change
@@ -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
`<img>` 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
76 changes: 76 additions & 0 deletions apps/deployer/test/status/logo_test.exs
Original file line number Diff line number Diff line change
@@ -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(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"></svg>)

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
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ defmodule DeployexWeb.Components.ApplicationCard do
<!-- App Header -->
<div class="gap-6">
<div class="flex items-center gap-6 mb-8">
<div class="avatar">
<div class="w-20 h-20 rounded-xl bg-base-200/30 flex items-center justify-center">
<img src={"/images/#{@application.language}.ico"} alt="" class="w-12 h-12" />
</div>
<div class="w-20 h-20 shrink-0 rounded-xl bg-base-200/30 flex items-center justify-center">
<img
src={@application.logo || "/images/#{@application.language}.ico"}
alt=""
class="w-full h-full object-contain"
/>
</div>
<div class="flex-1">
<h1 class="text-3xl font-bold text-base-content mb-2">{@application.name}</h1>
Expand Down
Loading