Skip to content

Commit 1db940d

Browse files
feat: add GraphQL subscriptions and GraphiQL dev playground
Wire up real-time GraphQL subscriptions over WebSocket using absinthe_phoenix for rollout progress, node status, and alert state changes. Add GraphiQL playground at /dev/graphiql for development.
1 parent 92ac072 commit 1db940d

14 files changed

Lines changed: 269 additions & 2 deletions

File tree

lib/sentinel_cp/application.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ defmodule SentinelCp.Application do
2121
# Background job processing
2222
{Oban, Application.fetch_env!(:sentinel_cp, Oban)},
2323
# Start to serve requests, typically the last entry
24-
SentinelCpWeb.Endpoint
24+
SentinelCpWeb.Endpoint,
25+
# GraphQL subscriptions (must start after Endpoint)
26+
{Absinthe.Subscription, SentinelCpWeb.Endpoint}
2527
]
2628

2729
# See https://hexdocs.pm/elixir/Supervisor.html

lib/sentinel_cp/nodes.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ defmodule SentinelCp.Nodes do
126126
end
127127
end
128128

129+
Absinthe.Subscription.publish(
130+
SentinelCpWeb.Endpoint,
131+
updated_node,
132+
node_status: updated_node.project_id
133+
)
134+
129135
updated_node
130136
end)
131137
end

lib/sentinel_cp/observability/alert_evaluator.ex

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,10 @@ defmodule SentinelCp.Observability.AlertEvaluator do
191191
alert_state
192192
|> AlertState.changeset(%{state: "firing", firing_at: now, value: value})
193193
|> Repo.update()
194-
|> tap(fn {:ok, _} -> send_alert_notification(rule, value) end)
194+
|> tap(fn {:ok, state} ->
195+
send_alert_notification(rule, value)
196+
publish_alert_state(state, rule)
197+
end)
195198
else
196199
# Still pending, update value
197200
alert_state
@@ -216,6 +219,10 @@ defmodule SentinelCp.Observability.AlertEvaluator do
216219
alert_state
217220
|> AlertState.changeset(%{state: "resolved", resolved_at: now})
218221
|> Repo.update()
222+
|> tap(fn {:ok, updated} ->
223+
rule = Repo.get(AlertRule, updated.alert_rule_id)
224+
if rule, do: publish_alert_state(updated, rule)
225+
end)
219226

220227
_ ->
221228
:ok
@@ -236,9 +243,18 @@ defmodule SentinelCp.Observability.AlertEvaluator do
236243
|> Repo.insert()
237244

238245
send_alert_notification(rule, value)
246+
publish_alert_state(state, rule)
239247
{:ok, state}
240248
end
241249

250+
defp publish_alert_state(alert_state, rule) do
251+
Absinthe.Subscription.publish(
252+
SentinelCpWeb.Endpoint,
253+
alert_state,
254+
alert_state: rule.project_id
255+
)
256+
end
257+
242258
defp send_alert_notification(rule, value) do
243259
Logger.warning("Alert firing: #{rule.name} (#{rule.severity}) — value: #{value}")
244260

lib/sentinel_cp/rollouts.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1427,6 +1427,8 @@ defmodule SentinelCp.Rollouts do
14271427
"rollouts:#{rollout.project_id}",
14281428
{:rollout_updated, rollout.id}
14291429
)
1430+
1431+
Absinthe.Subscription.publish(SentinelCpWeb.Endpoint, rollout, rollout_progress: rollout.id)
14301432
end
14311433

14321434
defp broadcast_and_notify(rollout, old_state, new_state) do

lib/sentinel_cp_web/endpoint.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
defmodule SentinelCpWeb.Endpoint do
22
use Phoenix.Endpoint, otp_app: :sentinel_cp
3+
use Absinthe.Phoenix.Endpoint
34

45
# The session will be stored in the cookie and signed,
56
# this means its contents can be read but not tampered with.
@@ -15,6 +16,10 @@ defmodule SentinelCpWeb.Endpoint do
1516
websocket: [connect_info: [session: @session_options]],
1617
longpoll: [connect_info: [session: @session_options]]
1718

19+
socket "/api/v1/graphql/websocket", SentinelCpWeb.GraphQL.Socket,
20+
websocket: true,
21+
longpoll: false
22+
1823
# Serve at "/" the static files from "priv/static" directory.
1924
#
2025
# When code reloading is disabled (e.g., in production),

lib/sentinel_cp_web/graphql/schema.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ defmodule SentinelCpWeb.GraphQL.Schema do
1010
import_types(SentinelCpWeb.GraphQL.Types.Rollout)
1111
import_types(SentinelCpWeb.GraphQL.Types.Observability)
1212
import_types(SentinelCpWeb.GraphQL.Types.Policy)
13+
import_types(SentinelCpWeb.GraphQL.Types.Subscription)
1314

1415
query do
1516
import_fields(:project_queries)
@@ -26,6 +27,10 @@ defmodule SentinelCpWeb.GraphQL.Schema do
2627
import_fields(:rollout_mutations)
2728
end
2829

30+
subscription do
31+
import_fields(:subscription_fields)
32+
end
33+
2934
def middleware(middleware, _field, %Absinthe.Type.Object{identifier: identifier})
3035
when identifier in [:query, :mutation] do
3136
[SentinelCpWeb.GraphQL.Middleware.AuthScope | middleware]
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
defmodule SentinelCpWeb.GraphQL.Socket do
2+
@moduledoc false
3+
use Phoenix.Socket
4+
use Absinthe.Phoenix.Socket, schema: SentinelCpWeb.GraphQL.Schema
5+
6+
alias SentinelCp.Accounts
7+
8+
@impl true
9+
def connect(%{"token" => token}, socket, _connect_info) do
10+
case Accounts.get_api_key_by_key(token) do
11+
nil ->
12+
:error
13+
14+
api_key ->
15+
Accounts.touch_api_key(api_key)
16+
17+
socket =
18+
Absinthe.Phoenix.Socket.put_options(socket,
19+
context: %{current_api_key: api_key}
20+
)
21+
22+
{:ok, socket}
23+
end
24+
end
25+
26+
def connect(_params, _socket, _connect_info), do: :error
27+
28+
@impl true
29+
def id(socket) do
30+
case socket.assigns[:absinthe] do
31+
%{opts: [context: %{current_api_key: api_key}]} ->
32+
"graphql:#{api_key.id}"
33+
34+
_ ->
35+
nil
36+
end
37+
end
38+
end

lib/sentinel_cp_web/graphql/types/observability.ex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ defmodule SentinelCpWeb.GraphQL.Types.Observability do
2222
field :error_budget_remaining, :float
2323
end
2424

25+
object :alert_state do
26+
field :id, non_null(:id)
27+
field :state, non_null(:string)
28+
field :value, :float
29+
field :started_at, :datetime
30+
field :firing_at, :datetime
31+
field :resolved_at, :datetime
32+
end
33+
2534
object :observability_queries do
2635
field :alert_rules, list_of(:alert_rule) do
2736
arg(:project_id, non_null(:id))
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
defmodule SentinelCpWeb.GraphQL.Types.Subscription do
2+
@moduledoc false
3+
use Absinthe.Schema.Notation
4+
5+
object :subscription_fields do
6+
field :rollout_progress, :rollout do
7+
arg(:rollout_id, non_null(:id))
8+
config(fn args, _ -> {:ok, topic: args.rollout_id} end)
9+
end
10+
11+
field :node_status, :sentinel_node do
12+
arg(:project_id, non_null(:id))
13+
config(fn args, _ -> {:ok, topic: args.project_id} end)
14+
end
15+
16+
field :alert_state, :alert_state do
17+
arg(:project_id, non_null(:id))
18+
config(fn args, _ -> {:ok, topic: args.project_id} end)
19+
end
20+
end
21+
end

lib/sentinel_cp_web/router.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,5 +773,12 @@ defmodule SentinelCpWeb.Router do
773773
live_dashboard "/dashboard", metrics: SentinelCpWeb.Telemetry
774774
forward "/mailbox", Plug.Swoosh.MailboxPreview
775775
end
776+
777+
scope "/dev" do
778+
forward "/graphiql", Absinthe.Plug.GraphiQL,
779+
schema: SentinelCpWeb.GraphQL.Schema,
780+
socket: SentinelCpWeb.GraphQL.Socket,
781+
json_codec: Jason
782+
end
776783
end
777784
end

0 commit comments

Comments
 (0)