Skip to main content

lib/durable_dashboard/components/workflow/log_line.ex

defmodule DurableDashboard.Components.Workflow.LogLine do
  @moduledoc """
  A single dense, Grafana-style log line — the shared visual unit used by both
  the page-level `LogsTab` and the step inspector's logs tab so they render
  identically. Stateless `Phoenix.Component`.

  Each `entry` is a map with `"level"`, `"message"`, `"timestamp"`, optional
  `"source"`, `"metadata"`, and `"__step__"` (the originating step name, only
  meaningful on the page-level tab).

  The collapsed line shows a one-liner; clicking expands a clean key/value
  fields table plus the message. A message that embeds structured data — pure
  JSON *or* an Elixir map (`%{...}`, including the common
  `"label: \#{inspect(map)}"` form) — is pretty-printed with syntax
  highlighting via `Core.json`.
  """

  use Phoenix.Component

  alias DurableDashboard.Components.Core

  attr :entry, :map, required: true
  attr :show_step, :boolean, default: true

  def row(assigns) do
    entry = assigns.entry
    level = to_string(entry["level"] || "")

    assigns =
      assign(assigns,
        level: level,
        tint: row_tint(level),
        labels: meta_labels(entry),
        parsed: parse_message(entry["message"]),
        rest_fields: rest_fields(entry)
      )

    ~H"""
    <details class={["group/row border-l-2", @tint]}>
      <summary class={[
        "flex cursor-pointer items-baseline gap-3 px-3 py-[3px] font-mono text-[11px] leading-relaxed",
        "list-none hover:bg-accent/30 [&::-webkit-details-marker]:hidden"
      ]}>
        <Core.local_time
          at={@entry["timestamp"]}
          format="time"
          class="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground/70"
        />
        <span class={["w-12 shrink-0 font-semibold uppercase", level_class(@level)]}>{@entry["level"] || "—"}</span>
        <span
          :if={@show_step}
          class="hidden shrink-0 truncate text-muted-foreground/55 md:inline md:max-w-[150px]"
        >{@entry["__step__"]}</span>
        <span class="min-w-0 flex-1 break-words text-foreground/90">{inline_message(@parsed, @entry["message"])}</span>
        <span :if={@labels != ""} class="hidden shrink-0 whitespace-nowrap text-[10px] text-muted-foreground/45 group-open/row:hidden lg:inline">{@labels}</span>
        <Core.icon
          name="chevron-down"
          class="size-3 shrink-0 self-center text-muted-foreground/40 transition-transform group-open/row:rotate-180"
        />
      </summary>

      <div class="space-y-3 border-t border-border/50 bg-muted/20 px-4 py-3">
        <div class="space-y-1.5">
          <Core.label>message</Core.label>
          <%= case @parsed do %>
            <% {:term, term} -> %>
              <Core.json value={term} class="max-h-[40vh]" />
            <% {:prefix_term, prefix, term} -> %>
              <p :if={prefix != ""} class="break-words font-mono text-[11px] text-foreground/70">{prefix}</p>
              <Core.json value={term} class="max-h-[40vh]" />
            <% :text -> %>
              <div class="group/msg relative">
                <Core.copy_button
                  text={to_string(@entry["message"])}
                  label="Copy message"
                  class="absolute right-1.5 top-1.5 z-10 opacity-0 transition-opacity group-hover/msg:opacity-100 focus-visible:opacity-100"
                />
                <div class="thin-scroll max-h-[40vh] overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background/50 p-3 font-mono text-[11px] text-foreground/90">{@entry["message"]}</div>
              </div>
          <% end %>
        </div>

        <Core.field_list class="border-t border-border/40 pt-3">
          <Core.field :if={@entry["timestamp"]} key="time">
            <Core.local_time at={@entry["timestamp"]} format="datetime" />
          </Core.field>
          <Core.field :for={{k, v} <- @rest_fields} key={k}>{v}</Core.field>
        </Core.field_list>
      </div>
    </details>
    """
  end

  # ============================================================================
  # Level styling
  # ============================================================================

  def level_class("error"), do: "text-destructive"
  def level_class("warning"), do: "text-warning"
  def level_class("info"), do: "text-info"
  def level_class("debug"), do: "text-muted-foreground"
  def level_class(_), do: "text-muted-foreground"

  # Per-level left-bar accent + faint row tint so error/warning stand out.
  defp row_tint("error"), do: "border-l-destructive bg-destructive/5"
  defp row_tint("warning"), do: "border-l-warning bg-warning/5"
  defp row_tint("info"), do: "border-l-info/50"
  defp row_tint("debug"), do: "border-l-border"
  defp row_tint(_), do: "border-l-transparent"

  # ============================================================================
  # Inline metadata labels
  # ============================================================================

  # Meaningful metadata as a compact inline `key=value` string. Framework /
  # source-location internals are filtered so the lines stay clean.
  @noise_meta ~w(line module function file pid mfa domain gl time application erl_level
                 otel_span_id otel_trace_id ansi_color)
  def meta_labels(entry) do
    case entry["metadata"] do
      m when is_map(m) and map_size(m) > 0 ->
        m
        |> Enum.reject(fn {k, _} -> to_string(k) in @noise_meta end)
        |> Enum.map_join(" ", fn {k, v} -> "#{k}=#{inline_value(v)}" end)

      _ ->
        ""
    end
  end

  defp inline_value(v) when is_binary(v) or is_number(v) or is_atom(v) do
    s = to_string(v)
    if String.length(s) > 40, do: String.slice(s, 0, 39) <> "…", else: s
  end

  defp inline_value(v), do: v |> inspect() |> inline_value()

  # ============================================================================
  # Expanded fields table
  # ============================================================================

  # Non-message fields: core fields followed by every metadata key (source
  # noise included — it's fine in a deliberate detail view). The message and
  # the (localized) time render separately; everything else lands here.
  def rest_fields(entry) do
    base = [
      {"level", to_string(entry["level"])},
      {"step", entry["__step__"]},
      {"source", entry["source"]}
    ]

    meta =
      case entry["metadata"] do
        m when is_map(m) -> Enum.map(m, fn {k, v} -> {to_string(k), detail_value(v)} end)
        _ -> []
      end

    Enum.reject(base ++ meta, fn {_k, v} -> v in [nil, ""] end)
  end

  defp detail_value(v) when is_binary(v), do: v
  defp detail_value(v) when is_number(v) or is_atom(v), do: to_string(v)
  defp detail_value(v), do: inspect(v)

  # ============================================================================
  # Message parsing — JSON or embedded Elixir term, highlighted via Core.json
  # ============================================================================

  @doc """
  Classify a log message for rendering:

    * `{:term, term}` — the whole message is a JSON value or Elixir literal
    * `{:prefix_term, prefix, term}` — a text prefix followed by a `%{...}`
      Elixir map (the `"label: \#{inspect(map)}"` shape)
    * `:text` — plain text, render raw

  """
  def parse_message(msg) when is_binary(msg) do
    trimmed = String.trim(msg)

    cond do
      term = whole_term(trimmed) -> {:term, term}
      true -> prefix_term(msg)
    end
  end

  def parse_message(_), do: :text

  # The inline (collapsed) one-liner: raw text for plain messages; a compact,
  # whitespace-collapsed one-liner for structured ones (full pretty form lives
  # in the expanded block).
  defp inline_message(:text, msg), do: msg
  defp inline_message(_structured, msg), do: truncate_inline(to_string(msg))

  defp whole_term(trimmed) do
    cond do
      String.starts_with?(trimmed, ["{", "["]) -> json_term(trimmed)
      String.starts_with?(trimmed, "%{") -> elixir_term(trimmed)
      true -> nil
    end
  end

  defp json_term(s) do
    case Jason.decode(s) do
      {:ok, t} when is_map(t) or is_list(t) -> t
      _ -> nil
    end
  end

  defp elixir_term(s) do
    case safe_literal(s) do
      {:ok, t} -> t
      :error -> nil
    end
  end

  # A "label: %{...}" message — text prefix + trailing Elixir map. Split on the
  # first `%{` only (so a leading `[tag]` in the prefix is never mistaken for a
  # list literal); fall back to plain text if the tail isn't a clean literal.
  defp prefix_term(msg) do
    case String.split(msg, "%{", parts: 2) do
      [prefix, rest] when prefix != "" ->
        case safe_literal("%{" <> rest) do
          {:ok, term} -> {:prefix_term, String.trim(prefix), term}
          :error -> :text
        end

      _ ->
        :text
    end
  end

  # Parse a string to AST (no evaluation) and rebuild it as a plain value ONLY
  # if every node is a literal (map/list/number/string/atom/bool/nil). Anything
  # else — a function call, tuple, struct, interpolation — fails the whole
  # parse and we fall back to rendering the raw text. Safe: code is never run.
  defp safe_literal(str) do
    case Code.string_to_quoted(str) do
      {:ok, ast} -> eval_literal(ast)
      _ -> :error
    end
  rescue
    _ -> :error
  end

  defp eval_literal(n)
       when is_number(n) or is_binary(n) or is_boolean(n) or is_nil(n) or is_atom(n),
       do: {:ok, n}

  defp eval_literal({:-, _, [n]}) when is_number(n), do: {:ok, -n}

  defp eval_literal({:%{}, _, pairs}) when is_list(pairs), do: build_map(pairs)

  defp eval_literal(list) when is_list(list), do: build_list(list)

  defp eval_literal(_), do: :error

  defp build_map(pairs) do
    Enum.reduce_while(pairs, {:ok, %{}}, fn
      {k_ast, v_ast}, {:ok, acc} ->
        with {:ok, k} <- eval_literal(k_ast),
             {:ok, v} <- eval_literal(v_ast) do
          {:cont, {:ok, Map.put(acc, k, v)}}
        else
          _ -> {:halt, :error}
        end

      _, _ ->
        {:halt, :error}
    end)
  end

  defp build_list(elems) do
    elems
    |> Enum.reduce_while({:ok, []}, fn el, {:ok, acc} ->
      case eval_literal(el) do
        {:ok, v} -> {:cont, {:ok, [v | acc]}}
        :error -> {:halt, :error}
      end
    end)
    |> case do
      {:ok, rev} -> {:ok, Enum.reverse(rev)}
      :error -> :error
    end
  end

  defp truncate_inline(s) when is_binary(s) do
    collapsed = s |> String.replace(~r/\s+/, " ") |> String.trim()
    if String.length(collapsed) > 160, do: String.slice(collapsed, 0, 159) <> "…", else: collapsed
  end
end