From 6fab2860cbe8ca3541d7c9d2ef5f7c721c70a324 Mon Sep 17 00:00:00 2001 From: Masatoshi N Date: Mon, 28 Nov 2022 18:25:10 -0500 Subject: [PATCH 1/2] Extract view helpers into SystemInfo module --- lib/nerves_motd.ex | 149 ++----------- lib/nerves_motd/system_info.ex | 296 ++++++++++++++++++++++++++ lib/nerves_motd/utils.ex | 157 -------------- test/nerves_motd/system_info_test.exs | 57 +++++ test/nerves_motd/utils_test.exs | 57 ----- 5 files changed, 374 insertions(+), 342 deletions(-) create mode 100644 lib/nerves_motd/system_info.ex delete mode 100644 lib/nerves_motd/utils.ex create mode 100644 test/nerves_motd/system_info_test.exs delete mode 100644 test/nerves_motd/utils_test.exs diff --git a/lib/nerves_motd.ex b/lib/nerves_motd.ex index 3c43d72..c8c10c8 100644 --- a/lib/nerves_motd.ex +++ b/lib/nerves_motd.ex @@ -14,7 +14,7 @@ defmodule NervesMOTD do \e[38;5;24m███▌ \e[38;5;74m▀▀████\e[0m """ - alias NervesMOTD.Utils + alias NervesMOTD.SystemInfo @excluded_ifnames ['lo', 'lo0'] @@ -55,7 +55,7 @@ defmodule NervesMOTD do [ logo(opts), IO.ANSI.reset(), - uname(), + SystemInfo.uname(), "\n", Enum.map(rows(apps, opts), &format_row/1), "\n", @@ -82,13 +82,22 @@ defmodule NervesMOTD do @spec rows(map(), list()) :: [[cell()]] defp rows(apps, opts) do [ - [{"Uptime", uptime()}], - [{"Clock", Utils.formatted_local_time()}], - temperature_row(), + [{"Uptime", SystemInfo.uptime_text()}], + [{"Clock", SystemInfo.clock_text()}], + if(text = SystemInfo.cpu_temperature_text(), do: [{"Temperature", text}]), [], - [firmware_cell(), applications_cell(apps)], - [memory_usage_cell(), active_application_partition_cell()], - [{"Hostname", hostname()}, {"Load average", load_average()}], + [ + {"Firmware", SystemInfo.firmware_status_text()}, + {"Applications", SystemInfo.applications_text(apps)} + ], + [ + {"Memory usage", SystemInfo.memory_usage_text()}, + {"Part usage", SystemInfo.active_part_usage_text()} + ], + [ + {"Hostname", SystemInfo.hostname_text()}, + {"Load average", SystemInfo.load_average_text()} + ], [] ] ++ ip_address_rows() ++ @@ -111,129 +120,18 @@ defmodule NervesMOTD do defp format_row(nil), do: [] - @spec temperature_row() :: [cell()] | nil - defp temperature_row() do - case runtime_mod().cpu_temperature() do - {:ok, temperature_c} -> - [{"Temperature", [:erlang.float_to_binary(temperature_c, decimals: 1), "°C"]}] - - _ -> - nil - end - end - @spec format_cell(cell(), 0 | 1) :: IO.ANSI.ansidata() defp format_cell({label, value}, column_index) do [format_cell_label(label), " : ", format_cell_value(value, column_index, 24), :reset] end @spec format_cell_label(IO.ANSI.ansidata()) :: IO.ANSI.ansidata() - defp format_cell_label(label), do: Utils.fit_ansidata(label, 12) + defp format_cell_label(label), do: SystemInfo.fit_ansidata(label, 12) @spec format_cell_value(IO.ANSI.ansidata(), 0 | 1, pos_integer()) :: IO.ANSI.ansidata() - defp format_cell_value(value, 0, width), do: Utils.fit_ansidata(value, width) + defp format_cell_value(value, 0, width), do: SystemInfo.fit_ansidata(value, width) defp format_cell_value(value, 1, _width), do: value - @spec firmware_cell() :: cell() - defp firmware_cell() do - fw_active = Nerves.Runtime.KV.get("nerves_fw_active") |> String.upcase() - - if runtime_mod().firmware_valid?() do - {"Firmware", [:green, "Valid (#{fw_active})"]} - else - {"Firmware", [:red, "Not validated (#{fw_active})"]} - end - end - - @spec applications_cell(%{loaded: list(), started: list()}) :: cell() - defp applications_cell(apps) do - started_count = length(apps[:started]) - loaded_count = length(apps[:loaded]) - - if started_count == loaded_count do - {"Applications", "#{started_count} started"} - else - not_started = Enum.join(apps[:loaded] -- apps[:started], ", ") - {"Applications", [:yellow, "#{started_count} started (#{not_started} not started)"]} - end - end - - @spec memory_usage_cell() :: cell() - defp memory_usage_cell() do - case runtime_mod().memory_stats() do - {:ok, stats} -> - text = :io_lib.format("~p MB (~p%)", [stats.used_mb, stats.used_percent]) - - if stats.used_percent < 85 do - {"Memory usage", text} - else - {"Memory usage", [:red, text]} - end - - :error -> - {"Memory usage", [:red, "not available"]} - end - end - - @spec active_application_partition_cell() :: cell() - defp active_application_partition_cell() do - label = "Part usage" - app_partition_path = Nerves.Runtime.KV.get_active("nerves_fw_application_part0_devpath") - - with true <- devpath_specified?(app_partition_path), - {:ok, stats} <- runtime_mod().filesystem_stats(app_partition_path) do - text = :io_lib.format("~p MB (~p%)", [stats.used_mb, stats.used_percent]) - - if stats.used_percent < 85 do - {label, text} - else - {label, [:red, text]} - end - else - _ -> - {label, [:red, "not available"]} - end - end - - defp devpath_specified?(nil), do: false - defp devpath_specified?(""), do: false - defp devpath_specified?(path) when is_binary(path), do: true - - @spec uname() :: iolist() - defp uname() do - fw_architecture = Nerves.Runtime.KV.get_active("nerves_fw_architecture") - fw_platform = Nerves.Runtime.KV.get_active("nerves_fw_platform") - fw_product = Nerves.Runtime.KV.get_active("nerves_fw_product") - fw_version = Nerves.Runtime.KV.get_active("nerves_fw_version") - fw_uuid = Nerves.Runtime.KV.get_active("nerves_fw_uuid") - [fw_product, " ", fw_version, " (", fw_uuid, ") ", fw_architecture, " ", fw_platform] - end - - # https://github.com/erlang/otp/blob/1c63b200a677ec7ac12202ddbcf7710884b16ff2/lib/stdlib/src/c.erl#L1118 - @spec uptime() :: iolist() - defp uptime() do - {uptime, _} = :erlang.statistics(:wall_clock) - {d, {h, m, s}} = :calendar.seconds_to_daystime(div(uptime, 1000)) - days = if d > 0, do: :io_lib.format("~p days, ", [d]) - hours = if d + h > 0, do: :io_lib.format("~p hours, ", [h]) - minutes = if d + h + m > 0, do: :io_lib.format("~p minutes and ", [m]) - seconds = :io_lib.format("~p seconds", [s]) - Enum.reject([days, hours, minutes, seconds], &is_nil/1) - end - - @spec load_average() :: iodata() - defp load_average() do - case runtime_mod().load_average() do - [a, b, c | _] -> [a, " ", b, " ", c] - _ -> "error" - end - end - - @spec hostname() :: [byte()] - defp hostname() do - :inet.gethostname() |> elem(1) - end - @spec ip_address_rows() :: [[cell()]] defp ip_address_rows() do {:ok, if_addresses} = :inet.getifaddrs() @@ -245,19 +143,14 @@ defmodule NervesMOTD do @spec ip_address_row({charlist(), keyword()}) :: [cell()] defp ip_address_row({name, ifaddrs}) when name not in @excluded_ifnames do - case Utils.extract_ifaddr_addresses(ifaddrs) do + case SystemInfo.extract_ifaddr_addresses(ifaddrs) do [] -> # Skip interfaces without addresses [] addresses -> # Create a comma-separated list of IP addresses - formatted_list = - addresses - |> Enum.map(&Utils.ip_address_mask_to_string/1) - |> Enum.intersperse(", ") - - [{name, formatted_list}] + [{name, SystemInfo.join_ip_addresses(addresses, ", ")}] end end diff --git a/lib/nerves_motd/system_info.ex b/lib/nerves_motd/system_info.ex new file mode 100644 index 0000000..3f875b0 --- /dev/null +++ b/lib/nerves_motd/system_info.ex @@ -0,0 +1,296 @@ +defmodule NervesMOTD.SystemInfo do + @moduledoc false + + @doc """ + Create a comma-separated list of IP addresses + + ## Example: + + iex> if_addresses = [ + ...> {{10, 0, 0, 202}, {255, 255, 255, 0}}, + ...> {{65152, 0, 0, 0, 47655, 60415, 65227, 8746}, {65535, 65535, 65535, 65535, 0, 0, 0, 0}} + ...> ] + iex> join_ip_addresses(if_addresses, ", ") + ["10.0.0.202/24", ", ", "fe80::ba27:ebff:fecb:222a/64"] + """ + @spec join_ip_addresses(list(), String.t()) :: iolist() + def join_ip_addresses(addresses, sep) do + addresses + |> Enum.map(&ip_address_tuple_to_string/1) + |> Enum.intersperse(sep) + end + + @doc """ + Extract IP addresses for one interface returned by `:inet.getifaddrs/0` + + ## Example: + + iex> if_addresses = [ + ...> flags: [:up, :broadcast, :running, :multicast], + ...> addr: {10, 0, 0, 202}, + ...> netmask: {255, 255, 255, 0}, + ...> broadaddr: {10, 0, 0, 202}, + ...> addr: {65152, 0, 0, 0, 47655, 60415, 65227, 8746}, + ...> netmask: {65535, 65535, 65535, 65535, 0, 0, 0, 0}, + ...> hwaddr: [184, 39, 235, 203, 34, 42] + ...> ] + iex> extract_ifaddr_addresses(if_addresses) + [ + {{10, 0, 0, 202}, {255, 255, 255, 0}}, + {{65152, 0, 0, 0, 47655, 60415, 65227, 8746}, {65535, 65535, 65535, 65535, 0, 0, 0, 0}} + ] + """ + @spec extract_ifaddr_addresses(keyword()) :: [String.t()] + def extract_ifaddr_addresses(kv_pairs, acc \\ []) + + def extract_ifaddr_addresses([], acc), do: Enum.reverse(acc) + + def extract_ifaddr_addresses([{:addr, addr}, {:netmask, netmask} | rest], acc) do + extract_ifaddr_addresses(rest, [{addr, netmask} | acc]) + end + + def extract_ifaddr_addresses([_other | rest], acc) do + extract_ifaddr_addresses(rest, acc) + end + + @doc """ + Convert an IP address and subnet mask to a nice string + + Examples: + + iex> ip_address_tuple_to_string({{10, 0, 0, 202}, {255, 255, 255, 0}}) + "10.0.0.202/24" + iex> ip_address_tuple_to_string({{65152, 0, 0, 0, 47655, 60415, 65227, 8746}, {65535, 65535, 65535, 65535, 0, 0, 0, 0}}) + "fe80::ba27:ebff:fecb:222a/64" + + """ + @spec ip_address_tuple_to_string({:inet.ip_address(), :inet.ip_address()}) :: String.t() + def ip_address_tuple_to_string({address, mask}) do + "#{:inet.ntoa(address)}/#{subnet_mask_to_prefix(mask)}" + end + + @doc """ + Convert a subnet mask tuple to a prefix length + + Examples: + + iex> subnet_mask_to_prefix({255, 255, 255, 0}) + 24 + + iex> subnet_mask_to_prefix({65535, 65535, 65535, 65535, 0, 0, 0, 0}) + 64 + """ + @spec subnet_mask_to_prefix(:inet.ip_address()) :: 0..128 + def subnet_mask_to_prefix(address) do + address |> ip_to_binary() |> leading_ones(0) + end + + defp ip_to_binary({a, b, c, d}), do: <> + + defp ip_to_binary({a, b, c, d, e, f, g, h}), + do: <> + + defp leading_ones(<<0b11111111, rest::binary>>, sum), do: leading_ones(rest, sum + 8) + defp leading_ones(<<0b11111110, _rest::binary>>, sum), do: sum + 7 + defp leading_ones(<<0b11111100, _rest::binary>>, sum), do: sum + 6 + defp leading_ones(<<0b11111000, _rest::binary>>, sum), do: sum + 5 + defp leading_ones(<<0b11110000, _rest::binary>>, sum), do: sum + 4 + defp leading_ones(<<0b11100000, _rest::binary>>, sum), do: sum + 3 + defp leading_ones(<<0b11000000, _rest::binary>>, sum), do: sum + 2 + defp leading_ones(<<0b10000000, _rest::binary>>, sum), do: sum + 1 + defp leading_ones(_, sum), do: sum + + @doc """ + Fit ansidata to a specified column width + + This function first trims the ansidata so that it doesn't exceed the specified + width. Then if it's not long enough, it will pad the ansidata to either left or + right justify it. + + ## Examples + + iex> s = [:red, "r", :yellow, "a", :light_yellow, "i", :green, "n", :blue, "b", :magenta, "o", :white, "w"] + ...> fit_ansidata(s, 4) + [:red, "r", :yellow, "a", :light_yellow, "i", :green, "n"] + + iex> s = [:red, "r", :yellow, "a", :light_yellow, "i", :green, "n", :blue, "b", :magenta, "o", :white, "w"] + ...> fit_ansidata(s, 10) + [[:red, "r", :yellow, "a", :light_yellow, "i", :green, "n", :blue, "b", :magenta, "o", :white, "w"], " "] + + iex> fit_ansidata([:red, ["Hello"], [" ", "world!"]], 20, :right) + [" ", :red, "Hello", " ", "world!"] + + iex> fit_ansidata([:red, [["Hello"]], " ", "world!"], 2, :right) + [:red, "He"] + """ + @spec fit_ansidata(IO.ANSI.ansidata(), non_neg_integer(), :left | :right) :: IO.ANSI.ansidata() + def fit_ansidata(ansidata, width, justification \\ :left) do + {result, length_left} = trim_ansidata(ansidata, [], width) + + result + |> Enum.reverse() + |> add_padding(length_left, justification) + end + + defp add_padding(ansidata, 0, _justification), do: ansidata + defp add_padding(ansidata, count, :left), do: [ansidata, :binary.copy(" ", count)] + defp add_padding(ansidata, count, :right), do: [:binary.copy(" ", count) | ansidata] + + defp trim_ansidata(_remainder, acc, 0), do: {acc, 0} + defp trim_ansidata([], acc, length), do: {acc, length} + defp trim_ansidata(char, acc, length) when is_integer(char), do: {[char | acc], length - 1} + defp trim_ansidata(ansicode, acc, length) when is_atom(ansicode), do: {[ansicode | acc], length} + + defp trim_ansidata(str, acc, length) when is_binary(str) do + sliced_string = String.slice(str, 0, length) + {[sliced_string | acc], length - String.length(sliced_string)} + end + + defp trim_ansidata([head | rest], acc, length) do + {result, length_left} = trim_ansidata(head, acc, length) + + trim_ansidata(rest, result, length_left) + end + + if Version.match?(System.version(), ">= 1.11.0") and Code.ensure_loaded?(NervesTimeZones) do + # NervesTimeZones and Calendar.strftime require Elixir 1.11 + @spec clock_text() :: binary() + def clock_text() do + # NervesTimeZones is an optional dependency so make sure its started + {:ok, _} = Application.ensure_all_started(:nerves_time_zones) + + NervesTimeZones.get_time_zone() + |> DateTime.now!() + |> DateTime.truncate(:second) + |> Calendar.strftime("%c %Z") + end + else + @spec clock_text() :: binary() + def clock_text() do + NaiveDateTime.utc_now() + |> NaiveDateTime.truncate(:second) + |> NaiveDateTime.to_string() + |> Kernel.<>(" UTC") + end + end + + # https://github.com/erlang/otp/blob/1c63b200a677ec7ac12202ddbcf7710884b16ff2/lib/stdlib/src/c.erl#L1118 + @spec uptime_text() :: iolist() + def uptime_text() do + {uptime, _} = :erlang.statistics(:wall_clock) + {d, {h, m, s}} = :calendar.seconds_to_daystime(div(uptime, 1000)) + days = if d > 0, do: :io_lib.format("~p days, ", [d]) + hours = if d + h > 0, do: :io_lib.format("~p hours, ", [h]) + minutes = if d + h + m > 0, do: :io_lib.format("~p minutes and ", [m]) + seconds = :io_lib.format("~p seconds", [s]) + Enum.reject([days, hours, minutes, seconds], &is_nil/1) + end + + @spec cpu_temperature_text() :: [String.t()] | nil + def cpu_temperature_text() do + case runtime_mod().cpu_temperature() do + {:ok, temperature_c} -> + [:erlang.float_to_binary(temperature_c, decimals: 1), "°C"] + + _ -> + nil + end + end + + @spec firmware_status_text() :: [String.t()] + def firmware_status_text() do + Nerves.Runtime.KV.get("nerves_fw_active") + |> String.upcase() + |> format_firmware_status(runtime_mod().firmware_valid?()) + end + + @spec format_firmware_status(String.t(), boolean()) :: IO.ANSI.ansidata() + defp format_firmware_status(active_part, true = _validated) do + [:green, "Valid (#{active_part})"] + end + + defp format_firmware_status(active_part, false = _validated) do + [:red, "Not validated (#{active_part})"] + end + + @spec load_average_text() :: iodata() + def load_average_text() do + case runtime_mod().load_average() do + [a, b, c | _] -> [a, " ", b, " ", c] + _ -> "error" + end + end + + @spec hostname_text() :: [byte()] + def hostname_text() do + {:ok, value} = :inet.gethostname() + value + end + + @spec uname() :: iolist() + def uname() do + fw_architecture = Nerves.Runtime.KV.get_active("nerves_fw_architecture") + fw_platform = Nerves.Runtime.KV.get_active("nerves_fw_platform") + fw_product = Nerves.Runtime.KV.get_active("nerves_fw_product") + fw_version = Nerves.Runtime.KV.get_active("nerves_fw_version") + fw_uuid = Nerves.Runtime.KV.get_active("nerves_fw_uuid") + [fw_product, " ", fw_version, " (", fw_uuid, ") ", fw_architecture, " ", fw_platform] + end + + @spec applications_text(%{loaded: list(), started: list()}) :: IO.ANSI.ansidata() + def applications_text(%{loaded: loaded_apps, started: started_apps}) do + loaded_count = length(loaded_apps) + started_count = length(started_apps) + + if started_count == loaded_count do + "#{started_count} started" + else + not_started_apps = Enum.join(loaded_apps -- started_apps, ", ") + [:yellow, "#{started_count} started (#{not_started_apps} not started)"] + end + end + + @spec active_part_usage_text() :: IO.ANSI.ansidata() + def active_part_usage_text() do + app_partition_path = Nerves.Runtime.KV.get_active("nerves_fw_application_part0_devpath") + + with true <- devpath_specified?(app_partition_path), + {:ok, stats} <- runtime_mod().filesystem_stats(app_partition_path) do + formatted = :io_lib.format("~p MB (~p%)", [stats.used_mb, stats.used_percent]) + + if stats.used_percent < 85 do + formatted + else + [:red, formatted] + end + else + _ -> + [:red, "not available"] + end + end + + defp devpath_specified?(nil), do: false + defp devpath_specified?(""), do: false + defp devpath_specified?(path) when is_binary(path), do: true + + @spec memory_usage_text() :: IO.ANSI.ansidata() + def memory_usage_text() do + case runtime_mod().memory_stats() do + {:ok, stats} -> + formatted = :io_lib.format("~p MB (~p%)", [stats.used_mb, stats.used_percent]) + + if stats.used_percent < 85 do + formatted + else + [:red, formatted] + end + + :error -> + [:red, "not available"] + end + end + + defp runtime_mod() do + Application.get_env(:nerves_motd, :runtime_mod, NervesMOTD.Runtime.Target) + end +end diff --git a/lib/nerves_motd/utils.ex b/lib/nerves_motd/utils.ex deleted file mode 100644 index cf3d08f..0000000 --- a/lib/nerves_motd/utils.ex +++ /dev/null @@ -1,157 +0,0 @@ -defmodule NervesMOTD.Utils do - @moduledoc false - - @doc """ - Extract IP addresses for one interface returned by `:inet.getifaddrs/0` - - ## Example: - - iex> if_addresses = [ - ...> flags: [:up, :broadcast, :running, :multicast], - ...> addr: {10, 0, 0, 202}, - ...> netmask: {255, 255, 255, 0}, - ...> broadaddr: {10, 0, 0, 202}, - ...> addr: {65152, 0, 0, 0, 47655, 60415, 65227, 8746}, - ...> netmask: {65535, 65535, 65535, 65535, 0, 0, 0, 0}, - ...> hwaddr: [184, 39, 235, 203, 34, 42] - ...> ] - iex> NervesMOTD.Utils.extract_ifaddr_addresses(if_addresses) - [ - {{10, 0, 0, 202}, {255, 255, 255, 0}}, - {{65152, 0, 0, 0, 47655, 60415, 65227, 8746}, {65535, 65535, 65535, 65535, 0, 0, 0, 0}} - ] - """ - @spec extract_ifaddr_addresses(keyword()) :: [String.t()] - def extract_ifaddr_addresses(kv_pairs, acc \\ []) - - def extract_ifaddr_addresses([], acc), do: Enum.reverse(acc) - - def extract_ifaddr_addresses([{:addr, addr}, {:netmask, netmask} | rest], acc) do - extract_ifaddr_addresses(rest, [{addr, netmask} | acc]) - end - - def extract_ifaddr_addresses([_other | rest], acc) do - extract_ifaddr_addresses(rest, acc) - end - - @doc """ - Convert an IP address and subnet mask to a nice string - - Examples: - - iex> NervesMOTD.Utils.ip_address_mask_to_string({{10, 0, 0, 202}, {255, 255, 255, 0}}) - "10.0.0.202/24" - iex> NervesMOTD.Utils.ip_address_mask_to_string({{65152, 0, 0, 0, 47655, 60415, 65227, 8746}, {65535, 65535, 65535, 65535, 0, 0, 0, 0}}) - "fe80::ba27:ebff:fecb:222a/64" - - """ - @spec ip_address_mask_to_string({:inet.ip_address(), :inet.ip_address()}) :: String.t() - def ip_address_mask_to_string({address, mask}) do - "#{:inet.ntoa(address)}/#{subnet_mask_to_prefix(mask)}" - end - - @doc """ - Convert a subnet mask tuple to a prefix length - - Examples: - - iex> NervesMOTD.Utils.subnet_mask_to_prefix({255, 255, 255, 0}) - 24 - - iex> NervesMOTD.Utils.subnet_mask_to_prefix({65535, 65535, 65535, 65535, 0, 0, 0, 0}) - 64 - """ - @spec subnet_mask_to_prefix(:inet.ip_address()) :: 0..128 - def subnet_mask_to_prefix(address) do - address |> ip_to_binary() |> leading_ones(0) - end - - defp ip_to_binary({a, b, c, d}), do: <> - - defp ip_to_binary({a, b, c, d, e, f, g, h}), - do: <> - - defp leading_ones(<<0b11111111, rest::binary>>, sum), do: leading_ones(rest, sum + 8) - defp leading_ones(<<0b11111110, _rest::binary>>, sum), do: sum + 7 - defp leading_ones(<<0b11111100, _rest::binary>>, sum), do: sum + 6 - defp leading_ones(<<0b11111000, _rest::binary>>, sum), do: sum + 5 - defp leading_ones(<<0b11110000, _rest::binary>>, sum), do: sum + 4 - defp leading_ones(<<0b11100000, _rest::binary>>, sum), do: sum + 3 - defp leading_ones(<<0b11000000, _rest::binary>>, sum), do: sum + 2 - defp leading_ones(<<0b10000000, _rest::binary>>, sum), do: sum + 1 - defp leading_ones(_, sum), do: sum - - @doc """ - Fit ansidata to a specified column width - - This function first trims the ansidata so that it doesn't exceed the specified - width. Then if it's not long enough, it will pad the ansidata to either left or - right justify it. - - ## Examples - - iex> s = [:red, "r", :yellow, "a", :light_yellow, "i", :green, "n", :blue, "b", :magenta, "o", :white, "w"] - ...> NervesMOTD.Utils.fit_ansidata(s, 4) - [:red, "r", :yellow, "a", :light_yellow, "i", :green, "n"] - - iex> s = [:red, "r", :yellow, "a", :light_yellow, "i", :green, "n", :blue, "b", :magenta, "o", :white, "w"] - ...> NervesMOTD.Utils.fit_ansidata(s, 10) - [[:red, "r", :yellow, "a", :light_yellow, "i", :green, "n", :blue, "b", :magenta, "o", :white, "w"], " "] - - iex> NervesMOTD.Utils.fit_ansidata([:red, ["Hello"], [" ", "world!"]], 20, :right) - [" ", :red, "Hello", " ", "world!"] - - iex> NervesMOTD.Utils.fit_ansidata([:red, [["Hello"]], " ", "world!"], 2, :right) - [:red, "He"] - """ - @spec fit_ansidata(IO.ANSI.ansidata(), non_neg_integer(), :left | :right) :: IO.ANSI.ansidata() - def fit_ansidata(ansidata, width, justification \\ :left) do - {result, length_left} = trim_ansidata(ansidata, [], width) - - result - |> Enum.reverse() - |> add_padding(length_left, justification) - end - - defp add_padding(ansidata, 0, _justification), do: ansidata - defp add_padding(ansidata, count, :left), do: [ansidata, :binary.copy(" ", count)] - defp add_padding(ansidata, count, :right), do: [:binary.copy(" ", count) | ansidata] - - defp trim_ansidata(_remainder, acc, 0), do: {acc, 0} - defp trim_ansidata([], acc, length), do: {acc, length} - defp trim_ansidata(char, acc, length) when is_integer(char), do: {[char | acc], length - 1} - defp trim_ansidata(ansicode, acc, length) when is_atom(ansicode), do: {[ansicode | acc], length} - - defp trim_ansidata(str, acc, length) when is_binary(str) do - sliced_string = String.slice(str, 0, length) - {[sliced_string | acc], length - String.length(sliced_string)} - end - - defp trim_ansidata([head | rest], acc, length) do - {result, length_left} = trim_ansidata(head, acc, length) - - trim_ansidata(rest, result, length_left) - end - - if Version.match?(System.version(), ">= 1.11.0") and Code.ensure_loaded?(NervesTimeZones) do - # NervesTimeZones and Calendar.strftime require Elixir 1.11 - @spec formatted_local_time() :: binary() - def formatted_local_time() do - # NervesTimeZones is an optional dependency so make sure its started - {:ok, _} = Application.ensure_all_started(:nerves_time_zones) - - NervesTimeZones.get_time_zone() - |> DateTime.now!() - |> DateTime.truncate(:second) - |> Calendar.strftime("%c %Z") - end - else - @spec formatted_local_time() :: binary() - def formatted_local_time() do - NaiveDateTime.utc_now() - |> NaiveDateTime.truncate(:second) - |> NaiveDateTime.to_string() - |> Kernel.<>(" UTC") - end - end -end diff --git a/test/nerves_motd/system_info_test.exs b/test/nerves_motd/system_info_test.exs new file mode 100644 index 0000000..0d58ed9 --- /dev/null +++ b/test/nerves_motd/system_info_test.exs @@ -0,0 +1,57 @@ +defmodule NervesMOTD.SystemInfoTest do + use ExUnit.Case + alias NervesMOTD.SystemInfo + import NervesMOTD.SystemInfo + doctest NervesMOTD.SystemInfo + + defp ipv6(str) do + {:ok, address} = :inet.parse_ipv6_address(to_charlist(str)) + address + end + + describe "subnet_mask_to_prefix/1" do + test "ipv4 subnet masks" do + assert SystemInfo.subnet_mask_to_prefix({255, 255, 255, 255}) == 32 + assert SystemInfo.subnet_mask_to_prefix({255, 255, 255, 252}) == 30 + assert SystemInfo.subnet_mask_to_prefix({255, 255, 255, 248}) == 29 + assert SystemInfo.subnet_mask_to_prefix({255, 255, 255, 240}) == 28 + assert SystemInfo.subnet_mask_to_prefix({255, 255, 255, 224}) == 27 + assert SystemInfo.subnet_mask_to_prefix({255, 255, 255, 192}) == 26 + assert SystemInfo.subnet_mask_to_prefix({255, 255, 255, 128}) == 25 + assert SystemInfo.subnet_mask_to_prefix({255, 255, 255, 0}) == 24 + assert SystemInfo.subnet_mask_to_prefix({255, 255, 0, 0}) == 16 + assert SystemInfo.subnet_mask_to_prefix({255, 0, 0, 0}) == 8 + assert SystemInfo.subnet_mask_to_prefix({0, 0, 0, 0}) == 0 + end + + test "ipv6 subnet masks" do + assert SystemInfo.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")) == + 128 + + assert SystemInfo.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:ffff::")) == 64 + assert SystemInfo.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:fffe::")) == 63 + assert SystemInfo.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:fffc::")) == 62 + assert SystemInfo.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:fff8::")) == 61 + assert SystemInfo.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:fff0::")) == 60 + assert SystemInfo.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:ffe0::")) == 59 + assert SystemInfo.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:ffc0::")) == 58 + assert SystemInfo.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:ff80::")) == 57 + assert SystemInfo.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:ff00::")) == 56 + assert SystemInfo.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:fe00::")) == 55 + assert SystemInfo.subnet_mask_to_prefix(ipv6("::")) == 0 + end + end + + describe "clock_text/0" do + @tag :has_nerves_time_zones + test "formats correctly with zone information" do + # Japan doesn't observe daylight savings time so the time zone is JST all year + assert SystemInfo.clock_text() =~ ~r/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} JST/ + end + + @tag :no_nerves_time_zones + test "formats correctly without zone information" do + assert SystemInfo.clock_text() =~ ~r/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/ + end + end +end diff --git a/test/nerves_motd/utils_test.exs b/test/nerves_motd/utils_test.exs deleted file mode 100644 index 34fc8be..0000000 --- a/test/nerves_motd/utils_test.exs +++ /dev/null @@ -1,57 +0,0 @@ -defmodule NervesMOTD.UtilsTest do - use ExUnit.Case - doctest NervesMOTD.Utils - - alias NervesMOTD.Utils - - defp ipv6(str) do - {:ok, address} = :inet.parse_ipv6_address(to_charlist(str)) - address - end - - describe "subnet_mask_to_prefix/1" do - test "ipv4 subnet masks" do - assert Utils.subnet_mask_to_prefix({255, 255, 255, 255}) == 32 - assert Utils.subnet_mask_to_prefix({255, 255, 255, 252}) == 30 - assert Utils.subnet_mask_to_prefix({255, 255, 255, 248}) == 29 - assert Utils.subnet_mask_to_prefix({255, 255, 255, 240}) == 28 - assert Utils.subnet_mask_to_prefix({255, 255, 255, 224}) == 27 - assert Utils.subnet_mask_to_prefix({255, 255, 255, 192}) == 26 - assert Utils.subnet_mask_to_prefix({255, 255, 255, 128}) == 25 - assert Utils.subnet_mask_to_prefix({255, 255, 255, 0}) == 24 - assert Utils.subnet_mask_to_prefix({255, 255, 0, 0}) == 16 - assert Utils.subnet_mask_to_prefix({255, 0, 0, 0}) == 8 - assert Utils.subnet_mask_to_prefix({0, 0, 0, 0}) == 0 - end - - test "ipv6 subnet masks" do - assert Utils.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")) == - 128 - - assert Utils.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:ffff::")) == 64 - assert Utils.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:fffe::")) == 63 - assert Utils.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:fffc::")) == 62 - assert Utils.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:fff8::")) == 61 - assert Utils.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:fff0::")) == 60 - assert Utils.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:ffe0::")) == 59 - assert Utils.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:ffc0::")) == 58 - assert Utils.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:ff80::")) == 57 - assert Utils.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:ff00::")) == 56 - assert Utils.subnet_mask_to_prefix(ipv6("ffff:ffff:ffff:fe00::")) == 55 - assert Utils.subnet_mask_to_prefix(ipv6("::")) == 0 - end - end - - describe "formatted_local_time/0" do - @tag :has_nerves_time_zones - test "formats correctly with zone information" do - # Japan doesn't observe daylight savings time so the time zone is JST all year - assert Utils.formatted_local_time() =~ ~r/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} JST/ - end - - @tag :no_nerves_time_zones - test "formats correctly without zone information" do - assert Utils.formatted_local_time() =~ ~r/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/ - end - end -end From 2d7a200e714e3492bd26cd50174f8d4a01e455f6 Mon Sep 17 00:00:00 2001 From: Masatoshi N Date: Tue, 29 Nov 2022 08:27:54 -0500 Subject: [PATCH 2/2] Move layout-related code to LayoutView module --- lib/nerves_motd.ex | 95 ++++++---------- lib/nerves_motd/layout_view.ex | 152 ++++++++++++++++++++++++++ lib/nerves_motd/system_info.ex | 52 --------- test/nerves_motd/layout_view_test.exs | 5 + 4 files changed, 189 insertions(+), 115 deletions(-) create mode 100644 lib/nerves_motd/layout_view.ex create mode 100644 test/nerves_motd/layout_view_test.exs diff --git a/lib/nerves_motd.ex b/lib/nerves_motd.ex index c8c10c8..8308e06 100644 --- a/lib/nerves_motd.ex +++ b/lib/nerves_motd.ex @@ -6,7 +6,9 @@ defmodule NervesMOTD do your Nerves project. """ - @logo """ + alias NervesMOTD.{LayoutView, SystemInfo} + + @nerves_logo """ \e[38;5;24m████▄▄ \e[38;5;74m▐███ \e[38;5;24m█▌ ▀▀██▄▄ \e[38;5;74m▐█ \e[38;5;24m█▌ \e[38;5;74m▄▄ \e[38;5;24m▀▀ \e[38;5;74m▐█ \e[39mN E R V E S @@ -14,7 +16,9 @@ defmodule NervesMOTD do \e[38;5;24m███▌ \e[38;5;74m▀▀████\e[0m """ - alias NervesMOTD.SystemInfo + @help_text """ + Nerves CLI help: https://hexdocs.pm/nerves/iex-with-nerves.html + """ @excluded_ifnames ['lo', 'lo0'] @@ -28,12 +32,12 @@ defmodule NervesMOTD do A row may contain 0, 1 or 2 cells. """ - @type row() :: [cell()] + @type row() :: LayoutView.row() @typedoc """ A label and value """ - @type cell() :: {String.t(), IO.ANSI.ansidata()} + @type cell() :: LayoutView.cell() @doc """ Print the message of the day @@ -53,17 +57,12 @@ defmodule NervesMOTD do if ready?(apps) do [ - logo(opts), - IO.ANSI.reset(), - SystemInfo.uname(), - "\n", - Enum.map(rows(apps, opts), &format_row/1), - "\n", - """ - Nerves CLI help: https://hexdocs.pm/nerves/iex-with-nerves.html - """ + logo: logo(opts), + header: header(), + rows: rows(apps, opts), + help_text: help_text() ] - |> IO.ANSI.format() + |> LayoutView.render() |> IO.puts() end @@ -75,63 +74,30 @@ defmodule NervesMOTD do defp ready?(apps), do: :nerves_runtime in apps.started @spec logo([option()]) :: IO.ANSI.ansidata() - defp logo(opts) do - Keyword.get(opts, :logo, @logo) - end + defp logo(opts), do: Keyword.get(opts, :logo, @nerves_logo) + + @spec header() :: IO.ANSI.ansidata() + defp header(), do: SystemInfo.uname() @spec rows(map(), list()) :: [[cell()]] defp rows(apps, opts) do + main_rows(apps) ++ ip_address_rows() ++ Keyword.get(opts, :extra_rows, []) + end + + @spec main_rows(map()) :: [[cell()]] + defp main_rows(apps) do [ - [{"Uptime", SystemInfo.uptime_text()}], - [{"Clock", SystemInfo.clock_text()}], - if(text = SystemInfo.cpu_temperature_text(), do: [{"Temperature", text}]), + [LayoutView.uptime_cell()], + [LayoutView.clock_cell()], + if(temp_cell = LayoutView.cpu_temperature_cell(), do: [temp_cell]), [], - [ - {"Firmware", SystemInfo.firmware_status_text()}, - {"Applications", SystemInfo.applications_text(apps)} - ], - [ - {"Memory usage", SystemInfo.memory_usage_text()}, - {"Part usage", SystemInfo.active_part_usage_text()} - ], - [ - {"Hostname", SystemInfo.hostname_text()}, - {"Load average", SystemInfo.load_average_text()} - ], + [LayoutView.firmware_cell(), LayoutView.applications_cell(apps)], + [LayoutView.memory_usage_cell(), LayoutView.part_usage_cell()], + [LayoutView.hostname_cell(), LayoutView.load_average_cell()], [] - ] ++ - ip_address_rows() ++ - Keyword.get(opts, :extra_rows, []) - end - - @spec format_row([cell()]) :: iolist() - # A blank line - defp format_row([]), do: ["\n"] - - # A row with full width - defp format_row([{label, value}]) do - [" ", format_cell_label(label), " : ", value, "\n", :reset] - end - - # A row with two columns - defp format_row([col0, col1]) do - [" ", format_cell(col0, 0), format_cell(col1, 1), "\n"] - end - - defp format_row(nil), do: [] - - @spec format_cell(cell(), 0 | 1) :: IO.ANSI.ansidata() - defp format_cell({label, value}, column_index) do - [format_cell_label(label), " : ", format_cell_value(value, column_index, 24), :reset] + ] end - @spec format_cell_label(IO.ANSI.ansidata()) :: IO.ANSI.ansidata() - defp format_cell_label(label), do: SystemInfo.fit_ansidata(label, 12) - - @spec format_cell_value(IO.ANSI.ansidata(), 0 | 1, pos_integer()) :: IO.ANSI.ansidata() - defp format_cell_value(value, 0, width), do: SystemInfo.fit_ansidata(value, width) - defp format_cell_value(value, 1, _width), do: value - @spec ip_address_rows() :: [[cell()]] defp ip_address_rows() do {:ok, if_addresses} = :inet.getifaddrs() @@ -156,6 +122,9 @@ defmodule NervesMOTD do defp ip_address_row(_), do: [] + @spec help_text() :: IO.ANSI.ansidata() + defp help_text(), do: @help_text + defp runtime_mod() do Application.get_env(:nerves_motd, :runtime_mod, NervesMOTD.Runtime.Target) end diff --git a/lib/nerves_motd/layout_view.ex b/lib/nerves_motd/layout_view.ex new file mode 100644 index 0000000..8ca9103 --- /dev/null +++ b/lib/nerves_motd/layout_view.ex @@ -0,0 +1,152 @@ +defmodule NervesMOTD.LayoutView do + @moduledoc false + + alias NervesMOTD.SystemInfo + + @typedoc """ + One row of information + + A row may contain 0, 1 or 2 cells. + """ + @type row() :: [cell()] + + @typedoc """ + A label and value + """ + @type cell() :: {String.t(), IO.ANSI.ansidata()} + + @spec render(keyword()) :: IO.chardata() + def render(opts) do + logo = opts[:logo] + header = opts[:header] + rows = Keyword.fetch!(opts, :rows) + help_text = opts[:help_text] + + [ + logo, + IO.ANSI.reset(), + header, + "\n", + rows |> Enum.map(&format_row/1), + "\n", + help_text + ] + |> List.flatten() + |> IO.ANSI.format() + end + + ## formatters + + @spec format_row([cell()]) :: iolist() + # A blank line + def format_row([]), do: ["\n"] + + # A row with full width + def format_row([{label, value}]) do + [" ", format_cell_label(label), " : ", value, "\n", :reset] + end + + # A row with two columns + def format_row([col0, col1]) do + [" ", format_cell(col0, 0), format_cell(col1, 1), "\n"] + end + + def format_row(nil), do: [] + + @spec format_cell(cell(), 0 | 1) :: IO.ANSI.ansidata() + def format_cell({label, value}, column_index) do + [format_cell_label(label), " : ", format_cell_value(value, column_index, 24), :reset] + end + + @spec format_cell_label(IO.ANSI.ansidata()) :: IO.ANSI.ansidata() + def format_cell_label(label), do: fit_ansidata(label, 12) + + @spec format_cell_value(IO.ANSI.ansidata(), 0 | 1, pos_integer()) :: IO.ANSI.ansidata() + def format_cell_value(value, 0, width), do: fit_ansidata(value, width) + def format_cell_value(value, 1, _width), do: value + + @doc """ + Fit ansidata to a specified column width + + This function first trims the ansidata so that it doesn't exceed the specified + width. Then if it's not long enough, it will pad the ansidata to either left or + right justify it. + + ## Examples + + iex> s = [:red, "r", :yellow, "a", :light_yellow, "i", :green, "n", :blue, "b", :magenta, "o", :white, "w"] + ...> fit_ansidata(s, 4) + [:red, "r", :yellow, "a", :light_yellow, "i", :green, "n"] + + iex> s = [:red, "r", :yellow, "a", :light_yellow, "i", :green, "n", :blue, "b", :magenta, "o", :white, "w"] + ...> fit_ansidata(s, 10) + [[:red, "r", :yellow, "a", :light_yellow, "i", :green, "n", :blue, "b", :magenta, "o", :white, "w"], " "] + + iex> fit_ansidata([:red, ["Hello"], [" ", "world!"]], 20, :right) + [" ", :red, "Hello", " ", "world!"] + + iex> fit_ansidata([:red, [["Hello"]], " ", "world!"], 2, :right) + [:red, "He"] + """ + @spec fit_ansidata(IO.ANSI.ansidata(), non_neg_integer(), :left | :right) :: IO.ANSI.ansidata() + def fit_ansidata(ansidata, width, justification \\ :left) do + {result, length_left} = trim_ansidata(ansidata, [], width) + + result + |> Enum.reverse() + |> add_padding(length_left, justification) + end + + defp add_padding(ansidata, 0, _justification), do: ansidata + defp add_padding(ansidata, count, :left), do: [ansidata, :binary.copy(" ", count)] + defp add_padding(ansidata, count, :right), do: [:binary.copy(" ", count) | ansidata] + + defp trim_ansidata(_remainder, acc, 0), do: {acc, 0} + defp trim_ansidata([], acc, length), do: {acc, length} + defp trim_ansidata(char, acc, length) when is_integer(char), do: {[char | acc], length - 1} + defp trim_ansidata(ansicode, acc, length) when is_atom(ansicode), do: {[ansicode | acc], length} + + defp trim_ansidata(str, acc, length) when is_binary(str) do + sliced_string = String.slice(str, 0, length) + {[sliced_string | acc], length - String.length(sliced_string)} + end + + defp trim_ansidata([head | rest], acc, length) do + {result, length_left} = trim_ansidata(head, acc, length) + + trim_ansidata(rest, result, length_left) + end + + ## cells + + @spec uptime_cell :: cell() + def uptime_cell(), do: {"Uptime", SystemInfo.uptime_text()} + + @spec clock_cell :: cell() + def clock_cell(), do: {"Clock", SystemInfo.clock_text()} + + @spec cpu_temperature_cell :: cell() | nil + def cpu_temperature_cell() do + if text = SystemInfo.cpu_temperature_text() do + {"Temperature", text} + end + end + + @spec firmware_cell :: cell() + def firmware_cell(), do: {"Firmware", SystemInfo.firmware_status_text()} + + @spec applications_cell(%{loaded: [atom], started: [atom]}) :: cell() + def applications_cell(apps), do: {"Applications", SystemInfo.applications_text(apps)} + + @spec memory_usage_cell :: cell() + def memory_usage_cell(), do: {"Memory usage", SystemInfo.memory_usage_text()} + + @spec part_usage_cell :: cell() + def part_usage_cell(), do: {"Part usage", SystemInfo.active_part_usage_text()} + + @spec hostname_cell :: cell() + def hostname_cell(), do: {"Hostname", SystemInfo.hostname_text()} + + @spec load_average_cell :: cell() + def load_average_cell(), do: {"Load average", SystemInfo.load_average_text()} +end diff --git a/lib/nerves_motd/system_info.ex b/lib/nerves_motd/system_info.ex index 3f875b0..bd8e4cd 100644 --- a/lib/nerves_motd/system_info.ex +++ b/lib/nerves_motd/system_info.ex @@ -100,58 +100,6 @@ defmodule NervesMOTD.SystemInfo do defp leading_ones(<<0b10000000, _rest::binary>>, sum), do: sum + 1 defp leading_ones(_, sum), do: sum - @doc """ - Fit ansidata to a specified column width - - This function first trims the ansidata so that it doesn't exceed the specified - width. Then if it's not long enough, it will pad the ansidata to either left or - right justify it. - - ## Examples - - iex> s = [:red, "r", :yellow, "a", :light_yellow, "i", :green, "n", :blue, "b", :magenta, "o", :white, "w"] - ...> fit_ansidata(s, 4) - [:red, "r", :yellow, "a", :light_yellow, "i", :green, "n"] - - iex> s = [:red, "r", :yellow, "a", :light_yellow, "i", :green, "n", :blue, "b", :magenta, "o", :white, "w"] - ...> fit_ansidata(s, 10) - [[:red, "r", :yellow, "a", :light_yellow, "i", :green, "n", :blue, "b", :magenta, "o", :white, "w"], " "] - - iex> fit_ansidata([:red, ["Hello"], [" ", "world!"]], 20, :right) - [" ", :red, "Hello", " ", "world!"] - - iex> fit_ansidata([:red, [["Hello"]], " ", "world!"], 2, :right) - [:red, "He"] - """ - @spec fit_ansidata(IO.ANSI.ansidata(), non_neg_integer(), :left | :right) :: IO.ANSI.ansidata() - def fit_ansidata(ansidata, width, justification \\ :left) do - {result, length_left} = trim_ansidata(ansidata, [], width) - - result - |> Enum.reverse() - |> add_padding(length_left, justification) - end - - defp add_padding(ansidata, 0, _justification), do: ansidata - defp add_padding(ansidata, count, :left), do: [ansidata, :binary.copy(" ", count)] - defp add_padding(ansidata, count, :right), do: [:binary.copy(" ", count) | ansidata] - - defp trim_ansidata(_remainder, acc, 0), do: {acc, 0} - defp trim_ansidata([], acc, length), do: {acc, length} - defp trim_ansidata(char, acc, length) when is_integer(char), do: {[char | acc], length - 1} - defp trim_ansidata(ansicode, acc, length) when is_atom(ansicode), do: {[ansicode | acc], length} - - defp trim_ansidata(str, acc, length) when is_binary(str) do - sliced_string = String.slice(str, 0, length) - {[sliced_string | acc], length - String.length(sliced_string)} - end - - defp trim_ansidata([head | rest], acc, length) do - {result, length_left} = trim_ansidata(head, acc, length) - - trim_ansidata(rest, result, length_left) - end - if Version.match?(System.version(), ">= 1.11.0") and Code.ensure_loaded?(NervesTimeZones) do # NervesTimeZones and Calendar.strftime require Elixir 1.11 @spec clock_text() :: binary() diff --git a/test/nerves_motd/layout_view_test.exs b/test/nerves_motd/layout_view_test.exs new file mode 100644 index 0000000..f3e0a14 --- /dev/null +++ b/test/nerves_motd/layout_view_test.exs @@ -0,0 +1,5 @@ +defmodule NervesMOTD.LayoutViewTest do + use ExUnit.Case + import NervesMOTD.LayoutView + doctest NervesMOTD.LayoutView +end