Fix Gaiia webhook (no signature), sites page with table + insights

This commit is contained in:
Graham McIntire 2026-02-14 16:07:08 -06:00
parent 3049411337
commit 82dd75d2e9
3 changed files with 201 additions and 121 deletions

View file

@ -2,9 +2,8 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
@moduledoc """
Webhook endpoint for receiving real-time updates from Gaiia.
Each organization has its own webhook secret stored in the integration
credentials. The signature is verified using HMAC-SHA256 against the
raw request body.
Gaiia sends unsigned webhooks via axios. We verify the organization exists
and has an active Gaiia integration, then process the event.
Route: POST /api/v1/webhooks/gaiia/:organization_id
"""
@ -16,15 +15,9 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
require Logger
def create(conn, %{"organization_id" => organization_id}) do
# Log all headers for debugging webhook signature issues
Logger.info("Gaiia webhook headers: #{inspect(conn.req_headers)}")
with {:ok, integration} <- get_gaiia_integration(organization_id),
{:ok, secret} <- get_webhook_secret(integration),
{:ok, raw_body} <- get_raw_body(conn),
:ok <- verify_signature(conn, raw_body, secret),
with {:ok, _integration} <- get_gaiia_integration(organization_id),
{:ok, event} <- get_event(conn.body_params) do
# Gaiia sends object data in "payload" field; fall back to "data" for compat
# Gaiia sends object data in "payload" field
data = Map.get(conn.body_params, "payload", Map.get(conn.body_params, "data", %{}))
entity_id = Map.get(conn.body_params, "objectId", Map.get(data, "id"))
payload = %{"entity_id" => entity_id, "data" => data}
@ -37,82 +30,8 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
defp get_gaiia_integration(organization_id) do
case Integrations.get_integration(organization_id, "gaiia") do
{:ok, integration} ->
{:ok, integration}
{:error, :not_found} ->
{:error, :not_found}
end
end
defp get_webhook_secret(integration) do
case get_in(integration.credentials, ["webhook_secret"]) do
nil -> {:error, :no_secret}
secret -> {:ok, secret}
end
end
defp get_raw_body(conn) do
case conn.private[:raw_body] do
nil -> {:error, :no_body}
body -> {:ok, body}
end
end
defp verify_signature(conn, raw_body, secret) do
case get_req_header(conn, "x-gaiia-webhook-signature") do
[header] ->
with {:ok, timestamp, signature} <- parse_signature_header(header),
:ok <- check_timestamp(timestamp) do
check_signature(timestamp, raw_body, secret, signature)
end
_ ->
{:error, :missing_signature}
end
end
defp parse_signature_header(header) do
parts =
header
|> String.split(",")
|> Enum.map(&String.split(&1, "=", parts: 2))
|> Map.new(fn [k, v] -> {k, v} end)
case {Map.get(parts, "t"), Map.get(parts, "v1")} do
{t, v1} when is_binary(t) and is_binary(v1) -> {:ok, t, v1}
_ -> {:error, :missing_signature}
end
rescue
_ -> {:error, :missing_signature}
end
@max_age_seconds 300
defp check_timestamp(timestamp) do
case Integer.parse(timestamp) do
{ts, ""} ->
now = System.system_time(:second)
if abs(now - ts) <= @max_age_seconds do
:ok
else
{:error, :invalid_signature}
end
_ ->
{:error, :invalid_signature}
end
end
defp check_signature(timestamp, raw_body, secret, expected) do
signed_payload = timestamp <> "." <> raw_body
computed = :hmac |> :crypto.mac(:sha256, secret, signed_payload) |> Base.encode16(case: :lower)
if Plug.Crypto.secure_compare(computed, expected) do
:ok
else
{:error, :invalid_signature}
{:ok, integration} -> {:ok, integration}
{:error, :not_found} -> {:error, :not_found}
end
end
@ -126,20 +45,6 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
{:error, :not_found} ->
conn |> put_status(:not_found) |> json(%{error: "Gaiia integration not found"})
{:error, :no_secret} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "Webhook secret not configured"})
{:error, :missing_signature} ->
conn |> put_status(:unauthorized) |> json(%{error: "Missing signature header"})
{:error, :invalid_signature} ->
conn |> put_status(:unauthorized) |> json(%{error: "Invalid webhook signature"})
{:error, :no_body} ->
conn |> put_status(:bad_request) |> json(%{error: "Missing request body"})
{:error, :missing_event} ->
conn |> put_status(:bad_request) |> json(%{error: "Missing event field"})

View file

@ -2,19 +2,27 @@ defmodule ToweropsWeb.SiteLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Dashboard
alias Towerops.Preseem.Insights
alias Towerops.Sites
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
sites = Sites.list_organization_sites(organization.id)
site_tree = Sites.build_site_tree(organization.id)
site_summaries = Dashboard.get_site_impact_summaries(organization.id)
insights = Insights.list_insights(organization.id, status: "active", limit: 10)
# Index site summaries by site_id for easy lookup
summary_map = Map.new(site_summaries, &{&1.site_id, &1})
{:ok,
socket
|> assign(:page_title, "Sites")
|> assign(:sites, sites)
|> assign(:site_tree, site_tree)}
|> assign(:summary_map, summary_map)
|> assign(:insights, insights)
|> assign(:timezone, socket.assigns.current_scope.timezone)}
end
@impl true
@ -22,7 +30,53 @@ defmodule ToweropsWeb.SiteLive.Index do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
@impl true
def handle_event("dismiss_insight", %{"id" => insight_id}, socket) do
case Towerops.Preseem.dismiss_insight(insight_id) do
{:ok, _} ->
org_id = socket.assigns.current_scope.organization.id
insights = Insights.list_insights(org_id, status: "active", limit: 10)
{:noreply,
socket
|> assign(:insights, insights)
|> put_flash(:info, "Insight dismissed")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to dismiss insight")}
end
end
defp apply_action(socket, :index, _params) do
assign(socket, :page_title, "Sites")
end
defp format_qoe(nil), do: ""
defp format_qoe(score), do: :erlang.float_to_binary(score / 1, decimals: 1)
defp qoe_color(nil), do: "text-gray-400 dark:text-gray-500"
defp qoe_color(score) when score < 2.0, do: "text-red-600 dark:text-red-400"
defp qoe_color(score) when score < 4.0, do: "text-yellow-600 dark:text-yellow-400"
defp qoe_color(_), do: "text-green-600 dark:text-green-400"
defp health_dot(nil), do: "bg-gray-400"
defp health_dot(:red), do: "bg-red-500"
defp health_dot(:yellow), do: "bg-yellow-500"
defp health_dot(_), do: "bg-green-500"
defp urgency_classes("critical"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
defp urgency_classes("warning"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
defp urgency_classes("info"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
defp urgency_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
defp source_classes("preseem"), do: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
defp source_classes("snmp"), do: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400"
defp source_classes("gaiia"), do: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
defp source_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
end

View file

@ -7,6 +7,9 @@
{@page_title}
<:subtitle>Manage your site locations and hierarchy</:subtitle>
<:actions>
<.link navigate={~p"/sites-map"} class="btn btn-outline btn-sm gap-1.5 mr-2">
<.icon name="hero-map" class="h-4 w-4" /> Map View
</.link>
<.button navigate={~p"/sites/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> New Site
</.button>
@ -33,24 +36,142 @@
</div>
</div>
<% else %>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<.link
:for={site <- @sites}
navigate={~p"/sites/#{site.id}"}
class="relative overflow-hidden rounded-lg border border-gray-200 bg-white p-6 shadow-sm hover:shadow-md transition-shadow dark:border-white/10 dark:bg-gray-800/50 cursor-pointer"
>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">{site.name}</h3>
<%= if site.location do %>
<p class="mt-2 flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400">
<.icon name="hero-map-pin" class="h-4 w-4" /> {site.location}
</p>
<% end %>
<%= if site.parent_site do %>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
Parent: {site.parent_site.name}
</p>
<% end %>
</.link>
<%!-- Sites table --%>
<div class="mt-6 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/80">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Site
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Devices
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Down
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
QoE
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Subscribers
</th>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Location
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white dark:divide-white/5 dark:bg-gray-800/50">
<tr :for={site <- @sites} class="hover:bg-gray-50 dark:hover:bg-white/5">
<td class="px-4 py-2.5">
<.link
navigate={~p"/sites/#{site.id}"}
class="flex items-center gap-2 text-sm font-medium text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
>
<span class={[
"h-2 w-2 rounded-full flex-shrink-0",
health_dot(@summary_map[site.id] && @summary_map[site.id].site_health)
]}>
</span>
{site.name}
</.link>
<p :if={site.parent_site} class="text-xs text-gray-500 dark:text-gray-400 ml-4">
↳ {site.parent_site.name}
</p>
</td>
<td class="px-4 py-2.5 text-right text-sm text-gray-600 dark:text-gray-400">
{if @summary_map[site.id], do: @summary_map[site.id].device_count, else: 0}
</td>
<td class="px-4 py-2.5 text-right text-sm">
<%= if @summary_map[site.id] && @summary_map[site.id].devices_down > 0 do %>
<span class="text-red-600 dark:text-red-400">
{@summary_map[site.id].devices_down}
</span>
<% else %>
<span class="text-gray-400 dark:text-gray-500">0</span>
<% end %>
</td>
<td class="px-4 py-2.5 text-right text-sm">
<%= if @summary_map[site.id] && @summary_map[site.id].avg_qoe do %>
<span class={qoe_color(@summary_map[site.id].avg_qoe)}>
{format_qoe(@summary_map[site.id].avg_qoe)}
</span>
<% else %>
<span class="text-gray-400 dark:text-gray-500">—</span>
<% end %>
</td>
<td class="px-4 py-2.5 text-right text-sm text-gray-600 dark:text-gray-400">
<%= if @summary_map[site.id] && @summary_map[site.id].subscribers do %>
{@summary_map[site.id].subscribers}
<% else %>
<span class="text-gray-400 dark:text-gray-500">—</span>
<% end %>
</td>
<td class="px-4 py-2.5 text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate">
{site.location || "—"}
</td>
</tr>
</tbody>
</table>
</div>
<%!-- Insights --%>
<%= if @insights != [] do %>
<div class="mt-8">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Insights
</h2>
<.link
navigate={~p"/insights"}
class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
View all →
</.link>
</div>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10 divide-y divide-gray-100 dark:divide-white/5">
<div
:for={insight <- @insights}
id={"insight-#{insight.id}"}
class="flex items-start justify-between gap-3 px-4 py-3 bg-white dark:bg-gray-800/50 hover:bg-gray-50 dark:hover:bg-white/5"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class={[
"inline-flex rounded-full px-1.5 py-0.5 text-[10px] font-medium",
urgency_classes(insight.urgency)
]}>
{insight.urgency}
</span>
<span class={[
"inline-flex rounded-full px-1.5 py-0.5 text-[10px] font-medium",
source_classes(insight.source)
]}>
{insight.source}
</span>
</div>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{insight.title}
</p>
<p
:if={insight.description}
class="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-1"
>
{insight.description}
</p>
</div>
<button
type="button"
phx-click="dismiss_insight"
phx-value-id={insight.id}
class="flex-shrink-0 rounded p-1 text-gray-300 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-600 dark:hover:bg-white/10 dark:hover:text-gray-400"
title="Dismiss"
>
<.icon name="hero-x-mark" class="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
<% end %>
<% end %>
</Layouts.authenticated>