defmodule CodeNameRaven.MonitorComponents do @moduledoc """ The standard component library for monitor panels. Monitor authors may use these components to build custom renders that fit naturally into the Raven dashboard, or ignore them entirely and write whatever HEEx they want. The `default_render/1` function is the render implementation used by any monitor that does not override `render/1`. ## Platform features available in renders The following are supported platform features that integrations can opt into displaying. All data is available in the assigns passed to `render/1`. ### Silence (`@config.silenced_until`) When a monitor is silenced, `@config.silenced_until` holds a `DateTime` in the future. The platform automatically suppresses warning logs and shows a "SILENCED" badge on the card, but your render can also surface this state. Use `silence_badge/1` to show a compact inline indicator: <.silence_badge silenced_until={@config.silenced_until} /> Silence is managed from the card flip side — no integration code required. """ use Phoenix.Component @doc """ The default panel render used when a monitor does not provide its own. Shows status, last check time, last error, and the collect result as a key-value table when one is available. """ def default_render(assigns) do ~H"""
<.status_chip status={@status} /> <.check_time checked_at={@last_checked_at} />
<.error_message message={@last_error} /> <.result_table :if={is_map(@result) and map_size(@result) > 0} result={@result} />
""" end attr :status, :atom, values: [:up, :degraded, :down, :unknown], required: true @doc """ A status badge showing the monitor's current health. Renders a coloured badge with an indicator dot and label for `:up` (green), `:degraded` (yellow), `:down` (red), or `:unknown` (grey). """ def status_badge(%{status: :up} = assigns) do ~H""" Up """ end def status_badge(%{status: :degraded} = assigns) do ~H""" Degraded """ end def status_badge(%{status: :down} = assigns) do ~H""" Down """ end def status_badge(%{status: :unknown} = assigns) do ~H""" Unknown """ end # Matches status_chip/1's own fallback — any status outside the declared # four renders as "Unknown" instead of a FunctionClauseError. def status_badge(assigns) do ~H""" Unknown """ end attr :silenced_until, :any, default: nil @doc """ A compact badge shown when the monitor's failure warnings are silenced. Renders nothing when `silenced_until` is `nil` or in the past, so it is safe to include unconditionally in any render: <.silence_badge silenced_until={@config.silenced_until} /> The platform already shows a card-level "SILENCED" overlay automatically. Use this component when you want the silence state visible inside your integration's own content area — for example, next to the status badge. """ def silence_badge(%{silenced_until: nil} = assigns) do ~H"" end def silence_badge(assigns) do if DateTime.compare(assigns.silenced_until, DateTime.utc_now()) == :gt do ~H""" Silenced """ else ~H"" end end attr :checked_at, :any, default: nil @doc """ Displays the monitor's last check time in a human-readable `YYYY-MM-DD HH:MM:%S UTC` format. Shows "Not yet checked" when passed `nil`. """ def check_time(%{checked_at: nil} = assigns) do ~H"""

Not yet checked

""" end def check_time(assigns) do ~H"""

Last checked: {Calendar.strftime(@checked_at, "%Y-%m-%d %H:%M:%S UTC")}

""" end attr :message, :string, default: nil @doc """ Displays the monitor's last error message in a red monospace font, if present. Shows nothing when `nil`. Hover for full message text on truncation. """ def error_message(%{message: nil} = assigns), do: ~H"" def error_message(assigns) do ~H"""

{@message}

""" end attr :status, :atom, values: [:up, :degraded, :down, :unknown], required: true @doc """ A compact status chip with a coloured dot and label. Suitable for panel headers and inline status indicators. Uses badge styling matched to the status severity. """ def status_chip(assigns) do {label, cls} = case assigns.status do :up -> {"Up", "badge-success"} :degraded -> {"Degraded", "badge-warning"} :down -> {"Down", "badge-error"} _ -> {"Unknown", "badge-neutral"} end assigns = assign(assigns, label: label, cls: cls) ~H""" {@label} """ end attr :label, :string, required: true attr :value, :string, required: true @doc """ A single metric stat block — label above, value below in monospace. Use within a flex row to display a small set of key measurements side by side (e.g. status code and latency). """ def stat(assigns) do ~H"""

{@label}

{@value}

""" end attr :result, :map, required: true @doc """ Renders a collect result map as a compact key-value table. Used by `default_render/1` to show raw result data for integrations that have not defined a custom panel. Keys are displayed in insertion order. """ def result_table(assigns) do ~H"""
{k} {inspect(v)}
""" end attr :id, :string, required: true attr :samples, :list, required: true attr :local_id, :any, required: true @doc """ Server-rendered SVG latency chart for monitor panels. Thin wrapper around `metric_chart/1` that plots the `latency_ms` metric. Reads from `sample.metrics["latency_ms"]` when present, falling back to `sample.latency_ms` for older samples that pre-date the JSONB column. """ def line_chart(assigns) do assigns = assign(assigns, metric_name: "latency_ms", y_max: nil, y_min: 0.0) metric_chart(assigns) end attr :id, :string, required: true attr :samples, :list, required: true attr :local_id, :any, required: true attr :metric_name, :string, required: true attr :y_max, :float, default: nil attr :y_min, :float, default: 0.0 @doc """ Server-rendered SVG chart for any metric stored in `sample.metrics`. Reads `sample.metrics[metric_name]` (string key, as stored by JSONB) from each sample. For `metric_name: "latency_ms"` falls back to `sample.latency_ms` when the JSONB field is absent, so HTTP's latency chart keeps working against pre-JSONB samples. Pass `y_max` to pin the y-axis ceiling (e.g. `1.0` for a 0–1 ratio chart). Pass `y_min` to pin the floor instead of the default `0.0` — useful for zooming into a metric whose meaningful range sits well above zero (e.g. a healthy buffer-hit-ratio that only ever dips between 0.9 and 1.0). Renders a placeholder when fewer than 2 samples have non-nil values. """ def metric_chart(assigns) do vw = 600 vh = 200 pl = 44 pr = 8 pt = 10 pb = 24 pw = vw - pl - pr ph = vh - pt - pb bottom = pt + ph metric_name = assigns.metric_name by_instance = Enum.group_by(assigns.samples, & &1.instance_id) all_values = assigns.samples |> Enum.map(&mc_value(&1, metric_name)) |> Enum.filter(&is_number/1) if length(all_values) < 2 do ~H"""

Chart appears after the second check…

""" else computed_max = all_values |> Enum.max() |> max(0) |> mc_nice_ceil() # `assigns.y_max || computed_max` would treat an explicitly-pinned # `0.0` ceiling as "unset" (0 is truthy in Elixir) and fall through to # computed_max instead — an is_nil check is the only way to actually # distinguish "not provided" from "provided as zero". y_max = if is_nil(assigns.y_max), do: computed_max, else: assigns.y_max y_min = assigns.y_min t_unix = Enum.map(assigns.samples, &DateTime.to_unix(&1.collected_at)) t_min = Enum.min(t_unix) t_max = Enum.max(t_unix) t_span = max(t_max - t_min, 1) geo = %{t_min: t_min, t_span: t_span, pw: pw, pl: pl, ph: ph, bottom: bottom, y_max: y_max, y_min: y_min} svg_traces = build_svg_traces(by_instance, assigns.local_id, metric_name, geo) y_ticks = [y_min, y_min + (y_max - y_min) * 0.25, y_min + (y_max - y_min) * 0.5, y_min + (y_max - y_min) * 0.75, y_max] |> Enum.map(fn val -> y = bottom - round(safe_ratio(val - y_min, y_max - y_min) * ph) {y, mc_fmt_y(val)} end) |> Enum.uniq_by(&elem(&1, 0)) t_mid = t_min + div(t_span, 2) x_ticks = [{pl, t_min}, {pl + div(pw, 2), t_mid}, {pl + pw, t_max}] |> Enum.map(fn {x, t} -> {x, Calendar.strftime(DateTime.from_unix!(t), "%I:%M %p")} end) assigns = assign(assigns, svg_traces: svg_traces, y_ticks: y_ticks, x_ticks: x_ticks, bottom: bottom, multi: map_size(by_instance) > 1, pl: pl, pw: pw, pt: pt ) ~H""" {label} {label} {t.label} """ end end defp build_svg_traces(by_instance, local_id, metric_name, geo) do by_instance |> Enum.map(fn {instance_id, inst_samples} -> {instance_id == local_id, build_dots(inst_samples, metric_name, geo)} end) # An instance whose samples all lack this particular metric (e.g. a # ratio metric that's only emitted once traffic starts flowing) now # produces no dots at all — nothing to draw a trace from, and hd/1 and # List.last/1 below would raise on an empty list. |> Enum.reject(fn {_is_local, dots} -> dots == [] end) |> Enum.map(fn {is_local, dots} -> {x0, _, _} = hd(dots) {xn, _, _} = List.last(dots) inner = Enum.map_join(dots, " L ", &dot_xy/1) %{ is_local: is_local, line_pts: Enum.map_join(dots, " ", &dot_xy/1), dots: dots, area_d: "M #{x0},#{geo.bottom} L #{inner} L #{xn},#{geo.bottom} Z", label: if(is_local, do: "Local", else: "Sibling") } end) end defp build_dots(inst_samples, metric_name, geo) do inst_samples |> Enum.sort_by(&DateTime.to_unix(&1.collected_at)) |> Enum.map(&build_dot(&1, metric_name, geo)) |> Enum.reject(&is_nil/1) end # A sample with no value for this metric (never checked yet, or a ratio # metric that's only meaningful once some activity has occurred) is a gap # in the data, not a real zero — plotting it as 0 would draw a false dip # to the bottom of the chart that reads as an incident. defp build_dot(s, metric_name, geo) do case mc_value(s, metric_name) do nil -> nil raw_val -> t = DateTime.to_unix(s.collected_at) x = geo.pl + round((t - geo.t_min) / geo.t_span * geo.pw) val = raw_val |> max(geo.y_min) |> min(geo.y_max) y = geo.bottom - round(safe_ratio(val - geo.y_min, geo.y_max - geo.y_min) * geo.ph) {x, y, mc_dot_fill(s.status)} end end # An explicitly-pinned range of zero width (y_max == y_min, e.g. both left # at their 0.0 defaults) is now honored rather than silently swapped for a # computed ceiling (see metric_chart/1) — which means it's real and # reachable, not just a theoretical gap in the public contract. A # zero-width range has no proportional space to plot against, so every # value collapses to the baseline instead of dividing by zero. defp safe_ratio(_numerator, denominator) when denominator == 0, do: 0.0 defp safe_ratio(numerator, denominator), do: numerator / denominator defp dot_xy({x, y, _}), do: "#{x},#{y}" # Reads a metric value from a sample. For "latency_ms" falls back to the # dedicated column so HTTP samples (which don't populate metrics JSONB) # continue to chart correctly via line_chart. defp mc_value(%{metrics: m} = s, "latency_ms") when is_map(m) do Map.get(m, "latency_ms") || s.latency_ms end defp mc_value(s, "latency_ms"), do: s.latency_ms defp mc_value(%{metrics: m}, name) when is_map(m), do: Map.get(m, name) defp mc_value(_s, _name), do: nil defp mc_nice_ceil(n) when n <= 0, do: 1.0 defp mc_nice_ceil(n) when n <= 1.0, do: 1.0 defp mc_nice_ceil(n) when n <= 10, do: 10 defp mc_nice_ceil(n) when n <= 50, do: 50 defp mc_nice_ceil(n) when n <= 100, do: 100 defp mc_nice_ceil(n) when n <= 250, do: 250 defp mc_nice_ceil(n) when n <= 500, do: 500 defp mc_nice_ceil(n) when n <= 1_000, do: 1_000 defp mc_nice_ceil(n) when n <= 2_500, do: 2_500 defp mc_nice_ceil(n) when n <= 5_000, do: 5_000 defp mc_nice_ceil(n) when n <= 10_000, do: 10_000 defp mc_nice_ceil(n) when n <= 100_000, do: 100_000 defp mc_nice_ceil(n) when n <= 1_000_000, do: 1_000_000 defp mc_nice_ceil(n), do: ceil(n / 1_000_000) * 1_000_000 # Its only call site (the y-axis tick labels in metric_chart/1) always # passes a number, so a catch-all clause here is unreachable dead code — # confirmed by the compiler's own "this clause is never used" warning. defp mc_fmt_y(val) when val == 0, do: "0" defp mc_fmt_y(val) when is_number(val) do v = val * 1.0 cond do v >= 1.0e9 -> "#{Float.round(v / 1.0e9, 1)}G" v >= 1.0e6 -> "#{Float.round(v / 1.0e6, 1)}M" v >= 1.0e3 -> "#{Float.round(v / 1.0e3, 1)}k" v < 10.0 -> :erlang.float_to_binary(Float.round(v, 2), decimals: 2) true -> "#{round(v)}" end end defp mc_dot_fill("up"), do: "var(--color-success)" defp mc_dot_fill("degraded"), do: "var(--color-warning)" defp mc_dot_fill("down"), do: "var(--color-error)" defp mc_dot_fill(_), do: "#94a3b8" end