Packages

Behaviour and use macro for Raven display integrations

Current section

Files

Jump to
raven_observer_display_sdk lib code_name_raven monitor_components.ex
Raw

lib/code_name_raven/monitor_components.ex

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"""
<div class="space-y-2">
<div class="flex items-center gap-2">
<.status_chip status={@status} />
<.check_time checked_at={@last_checked_at} />
</div>
<.error_message message={@last_error} />
<.result_table :if={is_map(@result) and map_size(@result) > 0} result={@result} />
</div>
"""
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"""
<span class="badge badge-success gap-1">
<span class="w-2 h-2 rounded-full bg-success-content"></span> Up
</span>
"""
end
def status_badge(%{status: :degraded} = assigns) do
~H"""
<span class="badge badge-warning gap-1">
<span class="w-2 h-2 rounded-full bg-warning-content"></span> Degraded
</span>
"""
end
def status_badge(%{status: :down} = assigns) do
~H"""
<span class="badge badge-error gap-1">
<span class="w-2 h-2 rounded-full bg-error-content"></span> Down
</span>
"""
end
def status_badge(%{status: :unknown} = assigns) do
~H"""
<span class="badge badge-neutral gap-1">
<span class="w-2 h-2 rounded-full bg-neutral-content"></span> Unknown
</span>
"""
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"""
<span class="badge badge-warning badge-sm gap-1 font-semibold"
title={"Silenced until #{Calendar.strftime(@silenced_until, "%b %-d %H:%M UTC")}"}>
Silenced
</span>
"""
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"""
<p class="text-xs text-base-content/50">Not yet checked</p>
"""
end
def check_time(assigns) do
~H"""
<p class="text-xs text-base-content/50">
Last checked: {Calendar.strftime(@checked_at, "%Y-%m-%d %H:%M:%S UTC")}
</p>
"""
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"""
<p class="text-xs text-error font-mono truncate" title={@message}>
{@message}
</p>
"""
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"""
<span class={"badge #{@cls} gap-1 px-3 py-3 text-xs font-semibold shrink-0"}>
<span class="w-1.5 h-1.5 rounded-full bg-current"></span>{@label}
</span>
"""
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"""
<div class="bg-base-300 rounded-lg px-3 py-2 flex-1 text-center">
<p class="text-xs text-base-content/40">{@label}</p>
<p class="text-sm font-mono font-semibold text-base-content">{@value}</p>
</div>
"""
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"""
<table class="w-full text-xs font-mono">
<tbody>
<tr :for={{k, v} <- @result} class="border-t border-base-300 first:border-0">
<td class="py-0.5 pr-3 text-base-content/40 whitespace-nowrap">{k}</td>
<td class="py-0.5 text-base-content/80 break-all">{inspect(v)}</td>
</tr>
</tbody>
</table>
"""
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)
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
@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).
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"""
<p class="text-xs text-base-content/20 italic">Chart appears after the second check…</p>
"""
else
computed_max =
all_values
|> Enum.max()
|> max(0)
|> mc_nice_ceil()
y_max = assigns.y_max || computed_max
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}
svg_traces = build_svg_traces(by_instance, assigns.local_id, metric_name, geo)
y_ticks =
[0, y_max * 0.25, y_max * 0.5, y_max * 0.75, y_max]
|> Enum.map(fn val ->
y = bottom - round(val / y_max * 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"""
<svg id={@id} class="chart-svg" viewBox="0 0 600 200" width="100%" height="200" xmlns="http://www.w3.org/2000/svg">
<g :for={{y, label} <- @y_ticks}>
<line x1={@pl} y1={y} x2={@pl + @pw} y2={y} stroke="currentColor" stroke-opacity="0.1" stroke-width="1" />
<text x={@pl - 4} y={y + 3} text-anchor="end" font-size="9" fill="currentColor" fill-opacity="0.45">{label}</text>
</g>
<g :for={t <- @svg_traces}>
<path
d={t.area_d}
fill={if t.is_local, do: "var(--color-primary)", else: "var(--color-warning)"}
fill-opacity={if t.is_local, do: "0.08", else: "0.05"}
/>
<polyline
points={t.line_pts}
fill="none"
stroke={if t.is_local, do: "var(--color-primary)", else: "var(--color-warning)"}
stroke-width="1.5"
stroke-linejoin="round"
stroke-linecap="round"
/>
<circle :for={{x, y, fill} <- t.dots} cx={x} cy={y} r="3" fill={fill} />
</g>
<text
:for={{x, label} <- @x_ticks}
x={x}
y={@bottom + 14}
text-anchor="middle"
font-size="9"
fill="currentColor"
fill-opacity="0.4"
>{label}</text>
<g :if={@multi}>
<g :for={{t, i} <- Enum.with_index(@svg_traces)}>
<rect
x={@pl + i * 72}
y={@pt}
width="10"
height="4"
rx="2"
fill={if t.is_local, do: "var(--color-primary)", else: "var(--color-warning)"}
/>
<text
x={@pl + i * 72 + 14}
y={@pt + 5}
font-size="9"
fill="currentColor"
fill-opacity="0.55"
>{t.label}</text>
</g>
</g>
</svg>
"""
end
end
defp build_svg_traces(by_instance, local_id, metric_name, geo) do
Enum.map(by_instance, fn {instance_id, inst_samples} ->
is_local = instance_id == local_id
dots = build_dots(inst_samples, metric_name, geo)
{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(fn s ->
t = DateTime.to_unix(s.collected_at)
x = geo.pl + round((t - geo.t_min) / geo.t_span * geo.pw)
val = min(mc_value(s, metric_name) || 0, geo.y_max)
y = geo.bottom - round(val / geo.y_max * geo.ph)
{x, y, mc_dot_fill(s.status)}
end)
end
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
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_fmt_y(_), do: ""
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