defmodule DalaDev.Server.DashboardLive do use Phoenix.LiveView, layout: {DalaDev.Server.Layouts, :app} alias DalaDev.Server.{LogFilter, WatchWorker} @log_limit 500 @elixir_limit 200 @log_topic "logs" @elixir_topic "elixir_logs" @device_topic "devices" @watch_topic "watch" @impl Phoenix.LiveView @spec mount(map(), map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()} def mount(_params, _session, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(DalaDev.PubSub, @device_topic) Phoenix.PubSub.subscribe(DalaDev.PubSub, @log_topic) Phoenix.PubSub.subscribe(DalaDev.PubSub, @elixir_topic) Phoenix.PubSub.subscribe(DalaDev.PubSub, @watch_topic) end devices = DalaDev.Server.DevicePoller.get_devices() # newest-first list all_lines = DalaDev.Server.LogBuffer.get() elixir_lines = DalaDev.Server.ElixirLogBuffer.get() lan_url = Application.get_env(:dala_dev, :dashboard_lan_url) {qr_small, qr_large} = if lan_url do encoded = EQRCode.encode(lan_url) {EQRCode.svg(encoded, width: 32), EQRCode.svg(encoded, width: 160)} else {nil, nil} end watch = WatchWorker.status() socket = socket |> assign( devices: devices, all_log_lines: all_lines, log_filter: :app, text_filter: "", # serial => :update | :first_deploy deploying: %{}, # serial => [line, ...] deploy_output: %{}, lan_url: lan_url, qr_small: qr_small, qr_large: qr_large, watch_active: watch.watching, watch_nodes: watch.nodes, watch_last_push: watch.last_push, all_elixir_lines: elixir_lines, elixir_text_filter: "" ) |> stream(:log_lines, LogFilter.apply(all_lines, :app, "") |> Enum.reverse()) |> stream(:elixir_lines, Enum.reverse(elixir_lines)) {:ok, socket} end # ── PubSub handlers ────────────────────────────────────────────────────────── @impl Phoenix.LiveView @spec handle_info(term(), Phoenix.LiveView.Socket.t()) :: {:noreply, Phoenix.LiveView.Socket.t()} def handle_info({:devices_updated, devices}, socket) do {:noreply, assign(socket, :devices, devices)} end def handle_info({:log_line, _serial, line}, socket) do all_lines = [line | socket.assigns.all_log_lines] |> Enum.take(@log_limit) socket = assign(socket, :all_log_lines, all_lines) socket = if LogFilter.matches?(line, socket.assigns.log_filter, socket.assigns.text_filter) do stream_insert(socket, :log_lines, line, at: -1, limit: @log_limit) else socket end {:noreply, socket} end def handle_info({:elixir_log_line, line}, socket) do all = [line | socket.assigns.all_elixir_lines] |> Enum.take(@elixir_limit) socket = assign(socket, :all_elixir_lines, all) socket = if elixir_matches?(line, socket.assigns.elixir_text_filter) do stream_insert(socket, :elixir_lines, line, at: -1, limit: @elixir_limit) else socket end {:noreply, socket} end def handle_info({:watch_status, status}, socket) do {:noreply, assign(socket, watch_active: status == :watching)} end def handle_info({:watch_push, info}, socket) do {:noreply, assign(socket, watch_nodes: info.nodes, watch_last_push: info)} end def handle_info({:deploy_line, serial, line}, socket) do output = Map.get(socket.assigns.deploy_output, serial, []) {:noreply, assign( socket, :deploy_output, Map.put(socket.assigns.deploy_output, serial, [line | output]) )} end def handle_info({:deploy_done, serial}, socket) do deploying = Map.delete(socket.assigns.deploying, serial) {:noreply, assign(socket, :deploying, deploying)} end # ── Events ─────────────────────────────────────────────────────────────────── @impl Phoenix.LiveView @spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) :: {:noreply, Phoenix.LiveView.Socket.t()} def handle_event("deploy", %{"serial" => serial, "mode" => mode}, socket) do deploying = Map.put(socket.assigns.deploying, serial, String.to_atom(mode)) device = Enum.find(socket.assigns.devices, &(&1.serial == serial)) socket = assign(socket, deploying: deploying, deploy_output: Map.put(socket.assigns.deploy_output, serial, []) ) spawn_deploy(serial, String.to_atom(mode), device.platform, self()) {:noreply, socket} end def handle_event("toggle_watch", _, socket) do if socket.assigns.watch_active do WatchWorker.stop_watching() else WatchWorker.start_watching() end {:noreply, socket} end def handle_event("set_log_filter", %{"filter" => raw_filter}, socket) do filter = case raw_filter do "all" -> :all "app" -> :app serial -> serial end filtered = LogFilter.apply(socket.assigns.all_log_lines, filter, socket.assigns.text_filter) |> Enum.reverse() socket = socket |> assign(:log_filter, filter) |> stream(:log_lines, filtered, reset: true) {:noreply, socket} end # phx-change on a
sends {name => value} pairs; the input is named "text_filter". def handle_event("set_text_filter", %{"text_filter" => text}, socket) do filtered = LogFilter.apply(socket.assigns.all_log_lines, socket.assigns.log_filter, text) |> Enum.reverse() socket = socket |> assign(:text_filter, text) |> stream(:log_lines, filtered, reset: true) {:noreply, socket} end def handle_event("clear_text_filter", _, socket) do filtered = LogFilter.apply(socket.assigns.all_log_lines, socket.assigns.log_filter, "") |> Enum.reverse() socket = socket |> assign(:text_filter, "") |> stream(:log_lines, filtered, reset: true) {:noreply, socket} end def handle_event("clear_logs", _, socket) do DalaDev.Server.LogBuffer.clear() socket = socket |> assign(:all_log_lines, []) |> stream(:log_lines, [], reset: true) {:noreply, socket} end def handle_event("set_elixir_text_filter", %{"elixir_text_filter" => text}, socket) do filtered = apply_elixir_filter(socket.assigns.all_elixir_lines, text) socket = socket |> assign(:elixir_text_filter, text) |> stream(:elixir_lines, filtered, reset: true) {:noreply, socket} end def handle_event("clear_elixir_text_filter", _, socket) do socket = socket |> assign(:elixir_text_filter, "") |> stream(:elixir_lines, Enum.reverse(socket.assigns.all_elixir_lines), reset: true) {:noreply, socket} end def handle_event("clear_elixir_logs", _, socket) do DalaDev.Server.ElixirLogBuffer.clear() socket = socket |> assign(:all_elixir_lines, []) |> stream(:elixir_lines, [], reset: true) {:noreply, socket} end # ── Deploy task ────────────────────────────────────────────────────────────── defp spawn_deploy(serial, mode, platform, lv_pid) do mix = System.find_executable("mix") || "mix" platform_flag = if platform == :ios, do: "--ios", else: "--android" args = case mode do :first_deploy -> ["dala.deploy", "--native", platform_flag] :update -> ["dala.deploy", platform_flag] end _ = serial Task.start(fn -> # Stream output line by line via a Port so the UI updates in real time port = Port.open( {:spawn_executable, mix}, [ :binary, :exit_status, :stderr_to_stdout, {:args, args}, {:line, 2048}, {:cd, File.cwd!()} ] ) stream_port(port, serial, lv_pid) send(lv_pid, {:deploy_done, serial}) end) end defp stream_port(port, serial, lv_pid) do receive do {^port, {:data, {:eol, line}}} -> send(lv_pid, {:deploy_line, serial, line}) stream_port(port, serial, lv_pid) {^port, {:exit_status, _}} -> :done after 120_000 -> :timeout end end # ── Helpers ────────────────────────────────────────────────────────────────── # Elixir log filter — matches message or module name; comma separates OR terms defp elixir_matches?(_line, ""), do: true defp elixir_matches?(line, filter) do terms = filter |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) text = [line.message, inspect(line.module)] |> Enum.join(" ") |> String.downcase() Enum.any?(terms, &String.contains?(text, String.downcase(&1))) end defp apply_elixir_filter(lines, filter) do lines |> Enum.filter(&elixir_matches?(&1, filter)) |> Enum.reverse() end defp level_class("E"), do: "log-E" defp level_class("W"), do: "log-W" defp level_class("I"), do: "log-I" defp level_class(_), do: "log-D" defp platform_badge(:android), do: {"Android", "bg-green-900 text-green-300"} defp platform_badge(:ios), do: {"iOS", "bg-blue-900 text-blue-300"} defp platform_badge(_), do: {"?", "bg-zinc-700 text-zinc-300"} defp short_serial(serial) do if String.contains?(serial, ":"), # IP:port — show as-is do: serial, # USB serial — last 8 chars is enough else: String.slice(serial, -8, 8) end # ── Template ───────────────────────────────────────────────────────────────── @impl Phoenix.LiveView @spec render(map()) :: Phoenix.LiveView.Rendered.t() def render(assigns) do ~H""" <%!-- Header --%>
Dala Dev dala_dev v0.2.2
<%= length(@devices) %> device(s) connected <%!-- Watch toggle --%>
<%= length(@watch_nodes) %> node(s) last push: <%= @watch_last_push.pushed %> module(s) · <%= Calendar.strftime(@watch_last_push.at, "%H:%M:%S") %>
<%!-- Small QR trigger — always visible in header --%>
<%= Phoenix.HTML.raw(@qr_small) %>
<%!-- Large QR popup on hover --%>
<%!-- Device cards --%>
No devices connected. Run adb connect IP:5555 or start the iOS simulator.
<% {badge_label, badge_class} = platform_badge(device.platform) %> <% deploying_mode = @deploying[device.serial] %> <% is_deploying = deploying_mode != nil %> <% deploy_out = Map.get(@deploy_output, device.serial, []) %> <%!-- Name + platform badge --%>
<%= device.name || short_serial(device.serial) %> badge_class}> <%= badge_label %>
<%!-- Status row --%>
BEAM running BEAM stopped 🔋 <%= device.battery %>%
<%!-- Deploy buttons --%> <% platform_flag = if device.platform == :ios, do: "--ios", else: "--android" %>
<%!-- Terminal equivalents --%>
update: mix dala.deploy <%= platform_flag %>
first deploy: mix dala.deploy --native <%= platform_flag %>
<%!-- Deploy output (shown while deploying) --%>
<%= deploy_line %>
<%!-- Log panels — device logs (left) + Elixir server logs (right) --%>
<%!-- Device logs --%>
Device Logs
<%= if Map.get(line, :restart) do %>
<%= line.ts %> Restart
<% else %>
level_class(line.level) <> if(line.dala, do: " log-dala", else: "")}> <%= line.ts %> [<%= line.tag %>] <%= line.message %>
<% end %>
<%!-- Draggable divider --%>
<%!-- Elixir / IEx logs --%>
Elixir
level_class(line.level)}> <%= line.ts %> [<%= inspect(line.module) %>] <%= line.message %>
""" end end