From 82dd75d2e9154e6fd21139ec517b61729143caec Mon Sep 17 00:00:00 2001 From: Graham McIntie Date: Sat, 14 Feb 2026 16:07:08 -0600 Subject: [PATCH] Fix Gaiia webhook (no signature), sites page with table + insights --- .../api/v1/gaiia_webhook_controller.ex | 107 +----------- lib/towerops_web/live/site_live/index.ex | 58 ++++++- .../live/site_live/index.html.heex | 157 ++++++++++++++++-- 3 files changed, 201 insertions(+), 121 deletions(-) diff --git a/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex b/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex index 2281557e..8abd6e28 100644 --- a/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex +++ b/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex @@ -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"}) diff --git a/lib/towerops_web/live/site_live/index.ex b/lib/towerops_web/live/site_live/index.ex index 6168ccf8..27fabd17 100644 --- a/lib/towerops_web/live/site_live/index.ex +++ b/lib/towerops_web/live/site_live/index.ex @@ -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 diff --git a/lib/towerops_web/live/site_live/index.html.heex b/lib/towerops_web/live/site_live/index.html.heex index 2ffeef65..66005a3f 100644 --- a/lib/towerops_web/live/site_live/index.html.heex +++ b/lib/towerops_web/live/site_live/index.html.heex @@ -7,6 +7,9 @@ {@page_title} <:subtitle>Manage your site locations and hierarchy <: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 + <.button navigate={~p"/sites/new"} variant="primary"> <.icon name="hero-plus" class="h-5 w-5" /> New Site @@ -33,24 +36,142 @@ <% else %> -
- <.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" - > -

{site.name}

- <%= if site.location do %> -

- <.icon name="hero-map-pin" class="h-4 w-4" /> {site.location} -

- <% end %> - <%= if site.parent_site do %> -

- Parent: {site.parent_site.name} -

- <% end %> - + <%!-- Sites table --%> +
+ + + + + + + + + + + + + + + + + + + + + +
+ Site + + Devices + + Down + + QoE + + Subscribers + + Location +
+ <.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" + > + + + {site.name} + +

+ ↳ {site.parent_site.name} +

+
+ {if @summary_map[site.id], do: @summary_map[site.id].device_count, else: 0} + + <%= if @summary_map[site.id] && @summary_map[site.id].devices_down > 0 do %> + + {@summary_map[site.id].devices_down} + + <% else %> + 0 + <% end %> + + <%= if @summary_map[site.id] && @summary_map[site.id].avg_qoe do %> + + {format_qoe(@summary_map[site.id].avg_qoe)} + + <% else %> + + <% end %> + + <%= if @summary_map[site.id] && @summary_map[site.id].subscribers do %> + {@summary_map[site.id].subscribers} + <% else %> + + <% end %> + + {site.location || "—"} +
+ + <%!-- Insights --%> + <%= if @insights != [] do %> +
+
+

+ Insights +

+ <.link + navigate={~p"/insights"} + class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400" + > + View all → + +
+
+
+
+
+ + {insight.urgency} + + + {insight.source} + +
+

+ {insight.title} +

+

+ {insight.description} +

+
+ +
+
+
+ <% end %> <% end %>