Skip to content

Commit 00119db

Browse files
feat: add SLO/SLI dashboard, alerting UI, and periodic SLI computation worker
Wire up the existing Observability backend (SLOs, alert rules, alert states) with a full LiveView UI layer, routing, sidebar navigation, and a periodic SLI recomputation Oban worker. Adds PromEx metrics and dashboard integration.
1 parent d1ad0d7 commit 00119db

19 files changed

Lines changed: 2638 additions & 1 deletion

File tree

lib/sentinel_cp/dashboard.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,16 @@ defmodule SentinelCp.Dashboard do
3737
Returns overview for a single project.
3838
"""
3939
def get_project_overview(project_id) do
40+
alias SentinelCp.Observability
41+
4042
%{
4143
node_stats: get_project_node_stats(project_id),
4244
active_rollouts: count_active_rollouts([project_id]),
4345
recent_bundles: count_recent_bundles([project_id], 7),
4446
latest_bundles: list_latest_bundles(project_id, 5),
45-
latest_rollouts: list_latest_rollouts(project_id, 5)
47+
latest_rollouts: list_latest_rollouts(project_id, 5),
48+
slo_summary: Observability.slo_summary(project_id),
49+
firing_alert_count: Observability.firing_alert_count(project_id)
4650
}
4751
end
4852

lib/sentinel_cp/observability.ex

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ defmodule SentinelCp.Observability do
3131
@doc "Gets an SLO by ID."
3232
def get_slo(id), do: Repo.get(Slo, id)
3333

34+
@doc "Gets an SLO by ID, raising if not found."
35+
def get_slo!(id), do: Repo.get!(Slo, id)
36+
3437
@doc "Lists all SLOs for a project."
3538
def list_slos(project_id) do
3639
from(s in Slo, where: s.project_id == ^project_id, order_by: [asc: s.name])
@@ -43,6 +46,31 @@ defmodule SentinelCp.Observability do
4346
|> Repo.all()
4447
end
4548

49+
@doc "Lists all enabled SLOs across all projects (for the SLI worker)."
50+
def list_all_enabled_slos do
51+
from(s in Slo, where: s.enabled == true, order_by: [asc: s.inserted_at])
52+
|> Repo.all()
53+
end
54+
55+
@doc "Returns a summary of SLO statuses for a project."
56+
def slo_summary(project_id) do
57+
slos = list_slos(project_id)
58+
59+
Enum.reduce(slos, %{total: 0, healthy: 0, warning: 0, breached: 0}, fn slo, acc ->
60+
status = slo_status(slo)
61+
62+
acc
63+
|> Map.update!(:total, &(&1 + 1))
64+
|> Map.update!(status, &(&1 + 1))
65+
end)
66+
end
67+
68+
@doc "Returns the status atom for an SLO based on error budget remaining."
69+
def slo_status(%Slo{error_budget_remaining: nil}), do: :healthy
70+
def slo_status(%Slo{error_budget_remaining: budget}) when budget >= 50.0, do: :healthy
71+
def slo_status(%Slo{error_budget_remaining: budget}) when budget > 0.0, do: :warning
72+
def slo_status(%Slo{}), do: :breached
73+
4674
@doc "Deletes an SLO."
4775
def delete_slo(slo), do: Repo.delete(slo)
4876

@@ -75,6 +103,9 @@ defmodule SentinelCp.Observability do
75103
@doc "Gets an alert rule by ID."
76104
def get_alert_rule(id), do: Repo.get(AlertRule, id)
77105

106+
@doc "Gets an alert rule by ID, raising if not found."
107+
def get_alert_rule!(id), do: Repo.get!(AlertRule, id)
108+
78109
@doc "Lists alert rules for a project."
79110
def list_alert_rules(project_id) do
80111
from(r in AlertRule, where: r.project_id == ^project_id, order_by: [asc: r.name])
@@ -140,4 +171,31 @@ defmodule SentinelCp.Observability do
140171
)
141172
|> Repo.aggregate(:count)
142173
end
174+
175+
@doc "Gets an alert state by ID, raising if not found."
176+
def get_alert_state!(id), do: Repo.get!(AlertState, id)
177+
178+
@doc "Lists firing and pending alert states for a project, with preloaded rules."
179+
def list_firing_alerts(project_id) do
180+
from(s in AlertState,
181+
join: r in AlertRule,
182+
on: s.alert_rule_id == r.id,
183+
where: r.project_id == ^project_id and s.state in ["firing", "pending"],
184+
order_by: [desc: s.started_at],
185+
preload: [alert_rule: r]
186+
)
187+
|> Repo.all()
188+
end
189+
190+
@doc "Lists recent alert states for a rule (paginated history)."
191+
def list_recent_alert_states(alert_rule_id, opts \\ []) do
192+
limit = Keyword.get(opts, :limit, 50)
193+
194+
from(s in AlertState,
195+
where: s.alert_rule_id == ^alert_rule_id,
196+
order_by: [desc: s.started_at],
197+
limit: ^limit
198+
)
199+
|> Repo.all()
200+
end
143201
end
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
defmodule SentinelCp.Observability.SliWorker do
2+
@moduledoc """
3+
Oban worker that periodically recomputes SLI values for all enabled SLOs.
4+
5+
Runs every 5 minutes, fetches all enabled SLOs across all projects,
6+
and calls `SliComputer.compute/1` for each.
7+
"""
8+
use Oban.Worker,
9+
queue: :maintenance,
10+
max_attempts: 1,
11+
unique: [period: 300]
12+
13+
require Logger
14+
15+
alias SentinelCp.Observability
16+
alias SentinelCp.Observability.SliComputer
17+
18+
@check_interval_seconds 300
19+
20+
@impl Oban.Worker
21+
def perform(%Oban.Job{}) do
22+
slos = Observability.list_all_enabled_slos()
23+
24+
results =
25+
Enum.map(slos, fn slo ->
26+
case SliComputer.compute(slo) do
27+
{:ok, updated} ->
28+
{:ok, updated}
29+
30+
{:error, reason} ->
31+
Logger.warning("SliWorker: failed to compute SLO #{slo.id}: #{inspect(reason)}")
32+
{:error, reason}
33+
end
34+
end)
35+
36+
ok_count = Enum.count(results, &match?({:ok, _}, &1))
37+
Logger.debug("SliWorker: computed #{ok_count}/#{length(slos)} SLOs")
38+
39+
schedule_next()
40+
:ok
41+
end
42+
43+
@doc """
44+
Ensures the SLI worker is scheduled. Safe to call multiple times.
45+
"""
46+
def ensure_started do
47+
oban_config = Application.get_env(:sentinel_cp, Oban, [])
48+
49+
unless oban_config[:testing] do
50+
%{} |> __MODULE__.new(schedule_in: 120) |> Oban.insert()
51+
end
52+
end
53+
54+
defp schedule_next do
55+
oban_config = Application.get_env(:sentinel_cp, Oban, [])
56+
57+
unless oban_config[:testing] do
58+
%{} |> __MODULE__.new(schedule_in: @check_interval_seconds) |> Oban.insert()
59+
end
60+
end
61+
end

lib/sentinel_cp/prom_ex/sentinel_plugin.ex

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ defmodule SentinelCp.PromEx.SentinelPlugin do
4646
event_name: [:sentinel_cp, :drift, :nodes, :drifted_count],
4747
description: "Number of currently drifted nodes",
4848
measurement: :count
49+
),
50+
last_value(
51+
[:sentinel_cp, :slos, :total],
52+
event_name: [:sentinel_cp, :slos, :status_count],
53+
description: "Total number of SLOs by status",
54+
measurement: :count,
55+
tags: [:status]
56+
),
57+
last_value(
58+
[:sentinel_cp, :alerts, :firing],
59+
event_name: [:sentinel_cp, :alerts, :firing_count],
60+
description: "Number of currently firing alerts",
61+
measurement: :count
4962
)
5063
]
5164
)
@@ -120,6 +133,26 @@ defmodule SentinelCp.PromEx.SentinelPlugin do
120133
%{count: drifted_nodes},
121134
%{}
122135
)
136+
137+
# SLO status counts
138+
for status <- ["healthy", "warning", "breached"] do
139+
count = poll_slo_count(status)
140+
141+
:telemetry.execute(
142+
[:sentinel_cp, :slos, :status_count],
143+
%{count: count},
144+
%{status: status}
145+
)
146+
end
147+
148+
# Firing alerts
149+
firing_alerts = poll_firing_alerts()
150+
151+
:telemetry.execute(
152+
[:sentinel_cp, :alerts, :firing_count],
153+
%{count: firing_alerts},
154+
%{}
155+
)
123156
end
124157

125158
defp poll_node_count(status) do
@@ -170,6 +203,40 @@ defmodule SentinelCp.PromEx.SentinelPlugin do
170203
_ -> 0
171204
end
172205

206+
defp poll_slo_count(status) do
207+
import Ecto.Query
208+
209+
budget_filter =
210+
case status do
211+
"healthy" ->
212+
dynamic([s], is_nil(s.error_budget_remaining) or s.error_budget_remaining >= 50.0)
213+
214+
"warning" ->
215+
dynamic([s], s.error_budget_remaining < 50.0 and s.error_budget_remaining > 0.0)
216+
217+
"breached" ->
218+
dynamic([s], s.error_budget_remaining <= 0.0)
219+
end
220+
221+
SentinelCp.Repo.aggregate(
222+
from(s in "slos", where: ^budget_filter),
223+
:count
224+
)
225+
rescue
226+
_ -> 0
227+
end
228+
229+
defp poll_firing_alerts do
230+
import Ecto.Query
231+
232+
SentinelCp.Repo.aggregate(
233+
from(s in "alert_states", where: s.state == "firing"),
234+
:count
235+
)
236+
rescue
237+
_ -> 0
238+
end
239+
173240
@doc """
174241
Emits a telemetry event for drift detection.
175242
Call this when a drift event is created.

lib/sentinel_cp_web/components/layouts.ex

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,20 @@ defmodule SentinelCpWeb.Layouts do
190190
current={@path}
191191
match="/analytics"
192192
/>
193+
<.sidebar_link
194+
path={~p"/orgs/#{@org_slug}/projects/#{@project_slug}/slos"}
195+
icon="hero-chart-pie"
196+
label="SLOs"
197+
current={@path}
198+
match="/slos"
199+
/>
200+
<.sidebar_link
201+
path={~p"/orgs/#{@org_slug}/projects/#{@project_slug}/alerts"}
202+
icon="hero-bell-alert"
203+
label="Alerts"
204+
current={@path}
205+
match="/alerts"
206+
/>
193207
194208
<div class="sidebar-section-title mt-4">Settings</div>
195209
<.sidebar_link
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
defmodule SentinelCpWeb.AlertsLive.Index do
2+
use SentinelCpWeb, :live_view
3+
4+
import SentinelCpWeb.SlosLive.Helpers
5+
6+
alias SentinelCp.{Observability, Projects}
7+
8+
@refresh_interval 15_000
9+
10+
@impl true
11+
def mount(%{"project_slug" => slug} = params, _session, socket) do
12+
org = resolve_org(params)
13+
14+
case Projects.get_project_by_slug(slug) do
15+
nil ->
16+
{:ok, push_navigate(socket, to: ~p"/orgs")}
17+
18+
project ->
19+
if connected?(socket), do: :timer.send_interval(@refresh_interval, :refresh)
20+
alerts = Observability.list_firing_alerts(project.id)
21+
22+
{:ok,
23+
assign(socket,
24+
page_title: "Active Alerts - #{project.name}",
25+
org: org,
26+
project: project,
27+
alerts: alerts
28+
)}
29+
end
30+
end
31+
32+
@impl true
33+
def handle_info(:refresh, socket) do
34+
alerts = Observability.list_firing_alerts(socket.assigns.project.id)
35+
{:noreply, assign(socket, :alerts, alerts)}
36+
end
37+
38+
@impl true
39+
def handle_event("acknowledge", %{"id" => id}, socket) do
40+
alert_state = Observability.get_alert_state!(id)
41+
user_id = socket.assigns.current_user.id
42+
{:ok, _} = Observability.acknowledge_alert(alert_state, user_id)
43+
alerts = Observability.list_firing_alerts(socket.assigns.project.id)
44+
45+
{:noreply,
46+
socket
47+
|> assign(:alerts, alerts)
48+
|> put_flash(:info, "Alert acknowledged.")}
49+
end
50+
51+
@impl true
52+
def render(assigns) do
53+
~H"""
54+
<div class="space-y-4">
55+
<div class="flex items-center justify-between">
56+
<h1 class="text-xl font-bold">Alerts</h1>
57+
<.link navigate={new_alert_rule_path(@org, @project)} class="btn btn-primary btn-sm">
58+
New Rule
59+
</.link>
60+
</div>
61+
62+
<.alert_tabs org={@org} project={@project} active="active" />
63+
64+
<div :if={@alerts == []} class="text-center py-12 text-base-content/50">
65+
<.icon name="hero-check-circle" class="size-12 mx-auto mb-2 opacity-50" />
66+
<p>No active alerts. All systems normal.</p>
67+
</div>
68+
69+
<table :if={@alerts != []} class="table table-sm">
70+
<thead>
71+
<tr>
72+
<th class="text-xs">State</th>
73+
<th class="text-xs">Rule</th>
74+
<th class="text-xs">Severity</th>
75+
<th class="text-xs">Value</th>
76+
<th class="text-xs">Since</th>
77+
<th class="text-xs">Actions</th>
78+
</tr>
79+
</thead>
80+
<tbody>
81+
<tr :for={alert <- @alerts}>
82+
<td><.alert_state_badge state={alert.state} /></td>
83+
<td>
84+
<.link
85+
navigate={alert_rule_path(@org, @project, alert.alert_rule)}
86+
class="link font-medium"
87+
>
88+
{alert.alert_rule.name}
89+
</.link>
90+
</td>
91+
<td><.severity_badge severity={alert.alert_rule.severity} /></td>
92+
<td class="font-mono text-sm">{format_value(alert.value)}</td>
93+
<td class="text-sm">
94+
{if alert.started_at,
95+
do: Calendar.strftime(alert.started_at, "%Y-%m-%d %H:%M:%S"),
96+
else: "—"}
97+
</td>
98+
<td>
99+
<button
100+
:if={is_nil(alert.acknowledged_by)}
101+
phx-click="acknowledge"
102+
phx-value-id={alert.id}
103+
class="btn btn-outline btn-xs"
104+
>
105+
Acknowledge
106+
</button>
107+
<span :if={alert.acknowledged_by} class="text-xs text-success">Acknowledged</span>
108+
</td>
109+
</tr>
110+
</tbody>
111+
</table>
112+
</div>
113+
"""
114+
end
115+
116+
defp format_value(nil), do: "—"
117+
defp format_value(val) when is_float(val), do: Float.round(val, 2) |> to_string()
118+
defp format_value(val), do: to_string(val)
119+
end

0 commit comments

Comments
 (0)