Fix Gaiia webhook (no signature), sites page with table + insights
This commit is contained in:
parent
3049411337
commit
82dd75d2e9
3 changed files with 201 additions and 121 deletions
|
|
@ -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"})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue