defmodule DaProductAppWeb.TerminalLive.Index do use DaProductAppWeb, :live_view require Logger alias DaProductApp.TerminalManagement # ETS-based caching for terminals list @ets_table :terminal_cache defp ensure_ets_table do if :ets.whereis(@ets_table) == :undefined do :ets.new(@ets_table, [:named_table, :public, read_concurrency: true]) end end defp fetch_terminals(filters) do # Use the new group-aware terminal listing function when view=groups case Map.get(filters, "view") do "groups" -> # Return terminals grouped by groups for group view TerminalManagement.list_terminals_with_groups(filters) _ -> # Use existing filter-based listing for normal terminal view DaProductApp.TerminalManagement.list_terminals_with_filters(filters) end end @impl true def mount(_params, _session, socket) do if connected?(socket), do: Phoenix.PubSub.subscribe(DaProductApp.PubSub, "tms:terminals") ensure_ets_table() filters = %{"status" => "all", "device_sn" => nil, "area" => nil, "vendor" => nil, "group" => nil, "model" => nil} terminals = fetch_terminals(filters) # Since we're starting with "all" filter, terminals already represents all terminals total_terminals_count = length(terminals) # Keep a copy of all terminals for badge calculation all_terminals_for_badges = terminals app_packages = DaProductApp.TerminalManagement.list_app_packages() # Get terminal groups for dropdown terminal_groups = TerminalManagement.list_terminal_groups(%{"active_only" => true}) # Get dynamic filter options from high-performance cache filter_options = TerminalManagement.get_filter_options() # Push initial data to AG Grid after mounting if connected?(socket) do Process.send_after(self(), :push_initial_data, 100) end {:ok, assign(socket, terminals: terminals, all_terminals_for_badges: all_terminals_for_badges, # For badge calculation total_terminals: total_terminals_count, # This stays constant current_page: "terminals", filters: filters, filterStatus: "all", # Add status filter state selected_terminal: nil, terminal_logs: [], show_panel: false, show_new_terminal: false, new_terminal_form: %{}, new_terminal_errors: %{}, page: 1, per_page: 10, total: length(terminals), # This updates based on filters for backward compatibility config_form: %{}, app_packages: app_packages, tab: "details", edit_mode: false, terminal_form: %{}, terminal_groups: terminal_groups, filter_options: filter_options, # Remote control state remote_session_id: nil, remote_connected: false, remote_logging: false, remote_mode: nil, remote_logs: [], remote_log_count: 0, save_success: false )} end @impl true def handle_params(%{"serial_number" => serial}, _uri, socket) do terminals = fetch_terminals(socket.assigns.filters || %{"status" => "all"}) terminal = Enum.find(terminals, &(&1.serial_number == serial)) logs = if terminal, do: TerminalManagement.list_status_logs(terminal.id), else: [] # Keep the same total and badge data, only update filtered terminals {:noreply, assign(socket, terminals: terminals, selected_terminal: terminal, terminal_logs: logs, show_new_terminal: false, total: length(terminals))} end @impl true def handle_params(params, _uri, %{assigns: %{live_action: :new}} = socket) do # Extract view parameter and update filters view = Map.get(params, "view", "all") filters = Map.put(socket.assigns.filters || %{"status" => "all"}, "view", view) terminals = fetch_terminals(filters) new_terminal_form = %{ "serial_number" => "", "oid" => "", "area" => "", "vendor" => "", "model" => "", "status" => "offline", "remarks" => "", "imei" => "", "imei2" => "", "group" => "", "cpuid" => "", "mac" => "", "app_version" => "", "data_version" => "", "system_version" => "" } {:noreply, assign(socket, terminals: terminals, selected_terminal: nil, terminal_logs: [], show_new_terminal: true, new_terminal_form: new_terminal_form, new_terminal_errors: %{}, filters: filters )} end @impl true def handle_params(params, _uri, socket) do # Extract view parameter and update filters view = Map.get(params, "view", "all") filters = Map.put(socket.assigns.filters || %{"status" => "all"}, "view", view) terminals = fetch_terminals(filters) {:noreply, assign(socket, terminals: terminals, selected_terminal: nil, terminal_logs: [], show_new_terminal: false, filters: filters, total: length(terminals) )} end @impl true def handle_event("show_terminal_details", %{"serial_number" => serial}, socket) do # Find the terminal from the existing terminals list to maintain the same structure terminal = Enum.find(socket.assigns.terminals, &(&1.serial_number == serial)) logs = if terminal, do: DaProductApp.TerminalManagement.list_status_logs(terminal.id), else: [] # Initialize the form with terminal data, safely accessing fields that might not exist terminal_form = if terminal do %{ "serial_number" => terminal.serial_number || "", "oid" => terminal.oid || "", "area" => Map.get(terminal, :area) || "", "vendor" => Map.get(terminal, :vendor) || "", "model" => Map.get(terminal, :model) || "", "status" => terminal.status || "", "remarks" => Map.get(terminal, :remark) || "", # Note: database field is 'remark', form uses 'remarks' "imei" => Map.get(terminal, :imei) || "", "group" => Map.get(terminal, :group) || "" } else %{} end {:noreply, assign(socket, selected_terminal: terminal, terminal_logs: logs, tab: "details", edit_mode: false, terminal_form: terminal_form )} end @impl true def handle_event("set_tab", %{"tab" => tab}, socket) do cond do tab == "location" -> location = case socket.assigns.selected_terminal do nil -> nil terminal -> TerminalManagement.get_latest_terminal_location(terminal.id) end {:noreply, assign(socket, tab: "location", location: location)} tab == "history" -> socket = assign_new(socket, :history_filters, fn -> %{} end) |> assign_new(:history_rows, fn -> [] end) {:noreply, assign(socket, tab: tab)} true -> {:noreply, assign(socket, tab: tab)} end end @impl true def handle_event("close_panel", _params, socket) do {:noreply, assign(socket, selected_terminal: nil, edit_mode: false)} end @impl true def handle_event("close_slide_over", _params, socket) do {:noreply, push_patch(socket, to: "/terminals")} end @impl true def handle_event("close_new_terminal", _params, socket) do {:noreply, push_patch(socket, to: "/terminals")} end @impl true def handle_event("update_new_terminal_form", %{"new_terminal_form" => form_params}, socket) do new_terminal_form = Map.merge(socket.assigns.new_terminal_form, form_params) {:noreply, assign(socket, new_terminal_form: new_terminal_form, new_terminal_errors: %{})} end @impl true def handle_event("update_new_terminal_form", params, socket) do # Handle direct parameter updates (fallback) new_terminal_form = Map.merge(socket.assigns.new_terminal_form, params) {:noreply, assign(socket, new_terminal_form: new_terminal_form, new_terminal_errors: %{})} end @impl true def handle_event("create_terminal", _params, socket) do # Convert form data to appropriate types and prepare for insertion terminal_attrs = socket.assigns.new_terminal_form |> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end) |> Map.new() |> Enum.into(%{}, fn {k, v} -> # Convert string keys to atoms and handle special cases case k do "remarks" -> {:remark, v} # Map form field 'remarks' to database field 'remark' _ -> {String.to_atom(k), v} end end) case DaProductApp.TerminalManagement.create_terminal(terminal_attrs) do {:ok, terminal} -> # Refresh the terminals list to include the new terminal with current filters terminals = fetch_terminals(socket.assigns.filters) # Push the new terminal to AG Grid Phoenix.LiveView.push_event(socket, "add_terminal_row", %{row: terminal}) {:noreply, socket |> put_flash(:info, "Terminal created successfully") |> assign(:terminals, terminals) |> push_patch(to: "/terminals")} {:error, changeset} -> errors = changeset.errors |> Enum.into(%{}, fn {field, {message, _}} -> {to_string(field), message} end) {:noreply, assign(socket, new_terminal_errors: errors)} end end @impl true def handle_event("toggle_edit_mode", _params, socket) do {:noreply, assign(socket, edit_mode: !socket.assigns.edit_mode)} end @impl true def handle_event("update_terminal_form", %{"terminal_form" => form_params}, socket) do terminal_form = Map.merge(socket.assigns.terminal_form, form_params) {:noreply, assign(socket, terminal_form: terminal_form)} end @impl true def handle_event("update_terminal_form", params, socket) do # Handle direct parameter updates (fallback) terminal_form = Map.merge(socket.assigns.terminal_form, params) {:noreply, assign(socket, terminal_form: terminal_form)} end @impl true def handle_event("save_terminal", _params, socket) do case socket.assigns.selected_terminal do nil -> {:noreply, put_flash(socket, :error, "No terminal selected")} terminal -> # Extract only the editable fields from the form and remove empty values editable_fields = ["area", "vendor", "model", "group", "imei", "remarks", "oid"] terminal_attrs = socket.assigns.terminal_form |> Map.take(editable_fields) |> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end) |> Map.new() |> Enum.into(%{}, fn {k, v} -> # Map form field 'remarks' to database field 'remark' case k do "remarks" -> {:remark, v} _ -> {String.to_atom(k), v} end end) # Use the overloaded update_terminal function that handles maps case DaProductApp.TerminalManagement.update_terminal(terminal, terminal_attrs) do {:ok, updated_terminal} -> # Update the terminals list with the updated terminal using current filters updated_terminals = fetch_terminals(socket.assigns.filters) # Find the updated terminal in the new list format updated_selected = Enum.find(updated_terminals, &(&1.id == updated_terminal.id)) # Push the update to AG Grid if updated_selected do Phoenix.LiveView.push_event(socket, "update_terminal_row", %{row: updated_selected}) end {:noreply, assign(socket, terminals: updated_terminals, selected_terminal: updated_selected || terminal, edit_mode: false ) |> put_flash(:info, "Terminal updated successfully")} {:error, changeset} -> error_msg = changeset.errors |> Enum.map(fn {field, {msg, _}} -> "#{field}: #{msg}" end) |> Enum.join(", ") {:noreply, put_flash(socket, :error, "Failed to update terminal: #{error_msg}")} {:error, :not_found} -> {:noreply, put_flash(socket, :error, "Terminal not found")} end end end @impl true def handle_event("cancel_edit", _params, socket) do # Reset form to original terminal data terminal_form = if socket.assigns.selected_terminal do terminal = socket.assigns.selected_terminal %{ "serial_number" => terminal.serial_number || "", "oid" => terminal.oid || "", "area" => terminal.area || "", "vendor" => terminal.vendor || "", "model" => terminal.model || "", "status" => terminal.status || "", "remark" => terminal.remark || "", "imei" => terminal.imei || "", "group" => terminal.group || "" } else %{} end {:noreply, assign(socket, edit_mode: false, terminal_form: terminal_form)} end @impl true def handle_info(:push_initial_data, socket) do # Push initial terminal data to AG Grid after connection is established Phoenix.LiveView.push_event(socket, "update_terminals_data", %{rows: socket.assigns.terminals}) {:noreply, socket} end @impl true def handle_info({:terminal_status_updated, _sn}, socket) do terminals = fetch_terminals(socket.assigns.filters) Logger.info("Updated terminals: #{inspect(terminals)}") selected_terminal = case socket.assigns.selected_terminal do nil -> nil %{serial_number: serial} -> Enum.find(terminals, &(&1.serial_number == serial)) end terminal_logs = if selected_terminal, do: TerminalManagement.list_status_logs(selected_terminal.id), else: [] # Find changed/inserted terminals and push only those updates to AG Grid old_terminals = Map.new(socket.assigns.terminals, &{&1.serial_number, &1}) Enum.each(terminals, fn t -> old = Map.get(old_terminals, t.serial_number) if is_nil(old) or old != t do Phoenix.LiveView.push_event(socket, "update_terminal_row", %{row: t}) end end) {:noreply, assign(socket, terminals: terminals, selected_terminal: selected_terminal, terminal_logs: terminal_logs)} end @impl true def handle_info({:terminal_status_updated, sn, %{itemkey: key, value: value}}, socket) do # Find and update the terminal in assigns.terminals terminals = Enum.map(socket.assigns.terminals, fn t -> if t.serial_number == sn do Map.put(t, String.to_atom(key), value) else t end end) # Push event to AG Grid for live update Phoenix.LiveView.push_event(socket, "update_terminal_metrics", %{serial_number: sn, metrics: %{key => value}}) {:noreply, assign(socket, terminals: terminals)} end @impl true def handle_info({:terminal_status_changed, sn, %{status: status}}, socket) do new_status = String.downcase(status) # Update the master list of all terminals all_terminals_for_badges = Enum.map(socket.assigns.all_terminals_for_badges, fn t -> if t.serial_number == sn do Map.put(t, :status, new_status) else t end end) # Re-apply the current status filter to the updated master list to get the correct grid data terminals = apply_status_filter(all_terminals_for_badges, socket.assigns.filters["status"]) # Push event to AG Grid for status update Phoenix.LiveView.push_event(socket, "update_terminals_data", %{rows: terminals}) Logger.info("Terminal #{sn} status changed to: #{status}. Updated grid with #{length(terminals)} terminals") {:noreply, assign(socket, terminals: terminals, all_terminals_for_badges: all_terminals_for_badges, total: length(terminals))} end # Remote Log PubSub Handlers @impl true def handle_info({:remote_log_status, status}, socket) do {:noreply, assign(socket, remote_connected: status.connected, remote_logging: status.logging, remote_mode: status.mode, save_success: Map.get(socket.assigns, :save_success, false) )} end @impl true def handle_info({:remote_log_update, update}, socket) do {:noreply, assign(socket, remote_logs: update.logs, remote_log_count: update.count, save_success: Map.get(socket.assigns, :save_success, false) )} end @impl true def handle_event("filter", params, socket) do filters = Map.merge(socket.assigns.filters, params) terminals = TerminalManagement.list_terminals_with_filters(filters) # Track filter usage for intelligent sorting Enum.each(params, fn {filter_type, filter_value} -> if filter_value && filter_value != "" && filter_type in ["area", "vendor", "model"] do TerminalManagement.track_filter_usage(String.to_atom(filter_type), filter_value) end end) # Push filtered data to AG Grid Phoenix.LiveView.push_event(socket, "update_terminals_data", %{rows: terminals}) # Keep total_terminals constant, only update the filtered count {:noreply, assign(socket, terminals: terminals, filters: filters, page: 1, total: length(terminals))} end @impl true def handle_event("refresh_filters", _params, socket) do case TerminalManagement.refresh_filter_cache() do {:ok, _options} -> # Get updated filter options filter_options = TerminalManagement.get_filter_options() socket = socket |> assign(:filter_options, filter_options) |> put_flash(:info, "Filter options refreshed successfully!") {:noreply, socket} {:error, _reason} -> socket = put_flash(socket, :error, "Failed to refresh filter options. Please try again.") {:noreply, socket} end end @impl true def handle_event("set_status_tab", %{"status" => status}, socket) do filters = Map.put(socket.assigns.filters, "status", status) terminals = fetch_terminals(filters) # Assign first, then push events to ensure proper socket state socket = assign(socket, terminals: terminals, filters: filters, filterStatus: status, page: 1, total: length(terminals)) socket = push_event(socket, "update_terminals_data", %{rows: terminals}) # Keep total_terminals constant, only update the filtered terminals and filterStatus {:noreply, socket} end @impl true def handle_info({:update_grid, terminals}, socket) do # Push the grid update Phoenix.LiveView.push_event(socket, "update_terminals_data", %{rows: terminals}) {:noreply, socket} end @impl true def handle_event("paginate", %{"page" => page}, socket) do {:noreply, assign(socket, page: String.to_integer(page))} end @impl true def handle_event("history_filter", params, socket) do filters = Map.merge(socket.assigns.history_filters || %{}, params) history_rows = list_terminal_history(socket.assigns.selected_terminal, filters) {:noreply, assign(socket, history_filters: filters, history_rows: history_rows)} end @impl true def handle_event("update_config_form", params, socket) do {:noreply, assign(socket, config_form: params)} end @impl true def handle_event("push_config", params, socket) do serial = params["terminal_id"] || (socket.assigns.selected_terminal && socket.assigns.selected_terminal.serial_number) # Use the OtaService for consistent configuration pushing merchant_config = %{ "merchant_id" => params["merchant_id"] || "900890089008000", "terminal_id" => serial, "mqtt_ip" => params["mqtt_ip"] || "testapp.ariticapp.com", "mqtt_port" => parse_int(params["mqtt_port"], 1883), "http_ip" => params["http_ip"] || "demo.ctrmv.com", "http_port" => parse_int(params["http_port"], 4001), "product_key" => params["product_key"] || "pFppbioOCKlo5c8E", "product_secret" => params["product_secret"] || "sj2AJl102397fQAV", "username" => params["username"] || "user001", "keepalive_time" => parse_int(params["keepalive_time"], 300), "play_language" => parse_int(params["play_language"], 1), "heartbeat_interval" => parse_int(params["heartbeat_interval"], 300) } case DaProductApp.TerminalManagement.OtaService.send_merchant_config_update(serial, merchant_config) do {:ok, message} -> {:noreply, put_flash(socket, :info, message)} {:error, reason} -> {:noreply, put_flash(socket, :error, "Failed to push configuration: #{reason}")} end end @impl true def handle_event("push_app_package", params, socket) do serial = socket.assigns.selected_terminal && socket.assigns.selected_terminal.serial_number package_id = params["package_id"] case DaProductApp.TerminalManagement.AppPackageService.deploy_package_to_device(serial, package_id, nil) do {:ok, message} -> {:noreply, put_flash(socket, :info, message)} {:error, reason} -> {:noreply, put_flash(socket, :error, "Failed to deploy package: #{reason}")} end end @impl true def handle_event("push_parameters", params, socket) do serial = socket.assigns.selected_terminal && socket.assigns.selected_terminal.serial_number case DaProductApp.TerminalManagement.ParameterPushService.send_terminal_parameters(serial, params["parameters"]) do {:ok, message} -> {:noreply, put_flash(socket, :info, message)} {:error, reason} -> {:noreply, put_flash(socket, :error, "Failed to push parameters: #{reason}")} end end @impl true def handle_event("send_file_download", params, socket) do terminal = socket.assigns.selected_terminal case terminal do nil -> {:noreply, put_flash(socket, :error, "No terminal selected")} _ -> serial = terminal.serial_number model = terminal.model download_params = params["download_params"] || %{} # Validate parameters based on device model case DaProductApp.TerminalManagement.MQTTCommandBuilder.validate_device_params(model, download_params) do {:ok, validated_params} -> # Add request_id if not present validated_params = Map.put_new(validated_params, "request_id", DaProductApp.TerminalManagement.MQTTCommandBuilder.generate_request_id()) # Build device-specific payload case DaProductApp.TerminalManagement.MQTTCommandBuilder.build_command(model, validated_params) do {:ok, payload} -> # Send via FileDownloadService case send_mqtt_command(serial, model, validated_params, payload) do {:ok, message} -> {:noreply, put_flash(socket, :info, message)} {:error, reason} -> {:noreply, put_flash(socket, :error, "Failed to send command: #{reason}")} end {:error, reason} -> {:noreply, put_flash(socket, :error, "Failed to build command: #{reason}")} end {:error, reason} -> {:noreply, put_flash(socket, :error, reason)} end end end # Helper function to send MQTT command defp send_mqtt_command(serial, model, params, payload) do require Logger # Determine topic based on model product_key = params["product_key"] || "pFppbioOCKlo5c8E" topic = "/ota/#{product_key}/#{serial}/update" Logger.info("Sending #{model} command to #{serial} on topic: #{topic}") Logger.info("Payload: #{payload}") case DaProductApp.MQTT.publish(topic, payload, qos: 1) do :ok -> {:ok, "#{model} command sent successfully to #{serial}"} {:ok, _reference} -> {:ok, "#{model} command sent successfully to #{serial}"} {:error, reason} -> Logger.error("Failed to send command to #{serial}: #{inspect(reason)}") {:error, "MQTT publish failed: #{inspect(reason)}"} end end # Remote Control Event Handlers @impl true def handle_event("remote_connect", _params, socket) do case socket.assigns.selected_terminal do nil -> {:noreply, put_flash(socket, :error, "No terminal selected")} terminal -> case DaProductApp.TerminalManagement.RemoteLogService.start_log_session( terminal.serial_number, socket.assigns[:current_user_id] || "user" ) do {:ok, session_id, _pid} -> # Subscribe to session updates Phoenix.PubSub.subscribe(DaProductApp.PubSub, "remote_log:#{session_id}") {:noreply, assign(socket, remote_session_id: session_id, remote_connected: true, save_success: false ) |> put_flash(:info, "Connected to terminal #{terminal.serial_number}")} {:error, reason} -> {:noreply, put_flash(socket, :error, "Failed to connect: #{reason}")} end end end @impl true def handle_event("remote_disconnect", _params, socket) do if socket.assigns.remote_session_id do DaProductApp.TerminalManagement.RemoteLogService.stop_log_session(socket.assigns.remote_session_id) {:noreply, assign(socket, remote_session_id: nil, remote_connected: false, remote_logging: false, remote_mode: nil, remote_logs: [], remote_log_count: 0, save_success: false ) |> put_flash(:info, "Disconnected from terminal")} else {:noreply, socket} end end @impl true def handle_event("remote_start_logging", %{"mode" => mode} = params, socket) do if socket.assigns.remote_session_id do opts = case mode do "WITH_DELAY" -> %{ last_lines_count: parse_int(params["last_lines_count"], 10), frequency_send: parse_int(params["frequency_send"], 15), log_level: params["log_level"] || "ALL" } _ -> %{} end case DaProductApp.TerminalManagement.RemoteLogService.start_logging( socket.assigns.remote_session_id, mode, opts ) do {:ok, _request_id} -> {:noreply, assign(socket, remote_logging: true, remote_mode: mode, save_success: false ) |> put_flash(:info, "Started logging in #{mode} mode")} {:error, reason} -> {:noreply, put_flash(socket, :error, "Failed to start logging: #{reason}")} end else {:noreply, put_flash(socket, :error, "Not connected to terminal")} end end @impl true def handle_event("remote_stop_logging", _params, socket) do if socket.assigns.remote_session_id do case DaProductApp.TerminalManagement.RemoteLogService.stop_logging(socket.assigns.remote_session_id) do :ok -> {:noreply, assign(socket, remote_logging: false, remote_mode: nil, save_success: false ) |> put_flash(:info, "Stopped logging")} {:error, reason} -> {:noreply, put_flash(socket, :error, "Failed to stop logging: #{reason}")} end else {:noreply, socket} end end @impl true def handle_event("remote_clear_logs", _params, socket) do if socket.assigns.remote_session_id do DaProductApp.TerminalManagement.RemoteLogService.clear_logs(socket.assigns.remote_session_id) {:noreply, assign(socket, remote_logs: [], remote_log_count: 0, save_success: false) |> put_flash(:info, "Logs cleared")} else {:noreply, socket} end end @impl true def handle_event("remote_save_logs", _params, socket) do if socket.assigns.remote_session_id && length(socket.assigns.remote_logs) > 0 do # Generate log file content timestamp = DateTime.utc_now() |> DateTime.to_string() terminal_serial = socket.assigns.selected_terminal.serial_number log_content = [ "Terminal Debug Logs", "Terminal: #{terminal_serial}", "Generated: #{timestamp}", "Total Lines: #{socket.assigns.remote_log_count}", "=" |> String.duplicate(50), "" ] ++ Enum.map(socket.assigns.remote_logs, fn log -> "#{log.timestamp} | #{log.message}" end) log_text = Enum.join(log_content, "\n") filename = "terminal_#{terminal_serial}_#{DateTime.utc_now() |> DateTime.to_date()}.txt" # Push download event to client (without flash message to prevent panel closing) # Set temporary success state that will auto-clear Process.send_after(self(), :clear_save_success, 2000) {:noreply, assign(socket, save_success: true) |> push_event("download_file", %{ filename: filename, content: log_text, type: "text/plain" })} else {:noreply, socket} end end @impl true def handle_info(:clear_save_success, socket) do {:noreply, assign(socket, save_success: false)} end defp parse_int(nil, default), do: default defp parse_int(<<>>, default), do: default defp parse_int(val, default) when is_binary(val) do case Integer.parse(val) do {int, _} -> int _ -> default end end defp parse_int(val, _default) when is_integer(val), do: val defp list_terminal_history(nil, _filters), do: [] defp list_terminal_history(terminal, filters) do import Ecto.Query base_query = from l in DaProductApp.TerminalManagement.TmsTerminalStatusLog, where: l.terminal_id == ^terminal.id, preload: [:status_items], order_by: [desc: l.inserted_at] query = if filters["app_version"] && filters["app_version"] != "" do from l in base_query, join: i in assoc(l, :status_items), where: i.itemkey == "app_version" and ilike(i.value, ^("%" <> filters["app_version"] <> "%")), preload: [status_items: i] else base_query end query = if filters["data_version"] && filters["data_version"] != "" do from l in query, join: i in assoc(l, :status_items), where: i.itemkey == "data_version" and ilike(i.value, ^("%" <> filters["data_version"] <> "%")), preload: [status_items: i] else query end logs = DaProductApp.Repo.all(query) # Flatten and map to table rows Enum.map(logs, fn log -> row = %{ imei: get_status_item(log, "imei"), app_version: get_status_item(log, "app_version"), data_version: get_status_item(log, "data_version"), system_version: get_status_item(log, "system_version"), secure_firmware: get_status_item(log, "secure_firmware"), boot_version: get_status_item(log, "boot_version"), pubkey: get_status_item(log, "pubkey"), appkey: get_status_item(log, "appkey"), battery: get_status_item(log, "battery"), ip: get_status_item(log, "ip"), area: get_status_item(log, "area"), login_time: log.upload_time } row end) end defp get_status_item(log, key) do log.status_items |> Enum.find_value("", fn i -> if i.itemkey == key, do: i.value, else: nil end) end defp format_timestamp(timestamp) when is_binary(timestamp) do case DateTime.from_iso8601(timestamp) do {:ok, dt, _} -> dt |> DateTime.to_time() |> Time.to_string() |> String.slice(0, 8) # HH:MM:SS _ -> timestamp |> String.slice(0, 8) end end defp format_timestamp(_), do: "00:00:00" defp history_tab(assigns) do ~H"""
Stream live debug logs from terminal over MQTT
No logs available
<%= if @remote_connected do %> Start logging to view debug messages from the terminal <% else %> Connect to the terminal to start viewing logs <% end %>