towerops/lib/towerops_web/live/org/integrations_live.html.heex
Graham McIntire de075ea8d9
Store billing subscriber/MRR data on integrations for dashboard
Each billing sync (Gaiia, Splynx, VISP, Sonar) already computes
subscriber counts and MRR but only stored them in sync messages.
Now persists them on the integrations table so the dashboard can
aggregate across all billing providers instead of only querying
Gaiia network sites (which were never populated).

Also fixes SnmpKit Config environment detection to use Mix.env()
as fallback when MIX_ENV env var is not set.
2026-02-15 16:51:33 -06:00

637 lines
30 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<div class="mb-4">
<.link
navigate={~p"/orgs/#{@organization.slug}/settings"}
class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
<.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Settings
</.link>
</div>
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
{t("Integrations")}
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("Connect third-party services to enhance your monitoring capabilities.")}
</p>
</div>
<div class="mt-8 space-y-10">
<div :for={category <- @provider_categories}>
<div class="mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{category.name}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{category.description}
</p>
</div>
<%= if category.exclusive do %>
<%!-- Exclusive category (billing): show as picker + single config panel --%>
<% active_billing =
Enum.find(category.providers, fn p ->
@integrations[p.id] && @integrations[p.id].enabled
end) %>
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-white/10 dark:bg-white/5">
<%= if active_billing do %>
<%!-- Active billing provider --%>
<% integration = @integrations[active_billing.id] %>
<div class="flex items-start justify-between">
<div class="flex items-start gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-50 dark:bg-indigo-900/30">
<.icon
name={active_billing.icon}
class="h-6 w-6 text-indigo-600 dark:text-indigo-400"
/>
</div>
<div>
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{active_billing.name}
</h3>
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/30 dark:text-green-400">
Connected
</span>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{active_billing.description}
</p>
<div class="mt-3 flex flex-wrap items-center gap-4">
<%= if integration.last_synced_at do %>
<span class="text-xs text-gray-500 dark:text-gray-400">
{t("Last synced:")}
<.timestamp
datetime={integration.last_synced_at}
timezone={@timezone}
format="absolute"
/>
</span>
<% end %>
<%= if integration.last_sync_status && integration.last_sync_status != "never" do %>
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
case integration.last_sync_status do
"success" ->
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
"partial" ->
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
"failed" ->
"bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
_ ->
"bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
end
]}>
{String.capitalize(integration.last_sync_status)}
</span>
<% end %>
<%= if integration.last_synced_at do %>
<span class="text-xs text-gray-400 dark:text-gray-500">
· Next sync in ~{next_sync_minutes(
integration.last_synced_at,
integration.sync_interval_minutes
)}m
</span>
<% end %>
<%= if active_billing.id == "gaiia" do %>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"
}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Entity Mapping →
</.link>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation"
}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Reconciliation →
</.link>
<% end %>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
phx-click="toggle_enabled"
phx-value-provider={active_billing.id}
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-indigo-600 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
>
<span class="pointer-events-none inline-block h-5 w-5 translate-x-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" />
</button>
<button
type="button"
id={"configure-#{active_billing.id}"}
phx-click="configure"
phx-value-provider={active_billing.id}
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
>
{t("Configure")}
</button>
</div>
</div>
<%!-- Switch provider link --%>
<div class="mt-4 border-t border-gray-100 pt-3 dark:border-white/5">
<p class="text-xs text-gray-400 dark:text-gray-500">
Using {active_billing.name}. To switch providers, disable this integration first.
</p>
</div>
<% else %>
<%!-- No active billing — show provider picker --%>
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">
{t("Select your billing platform to get started:")}
</p>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<button
:for={provider <- category.providers}
type="button"
id={"configure-#{provider.id}"}
phx-click="configure"
phx-value-provider={provider.id}
class={[
"group relative flex flex-col items-center rounded-lg border-2 p-4 text-center transition-all hover:border-indigo-300 hover:bg-indigo-50/50 dark:hover:border-indigo-700 dark:hover:bg-indigo-900/10",
if(@configuring == provider.id,
do:
"border-indigo-500 bg-indigo-50/50 dark:border-indigo-600 dark:bg-indigo-900/20",
else: "border-gray-200 dark:border-white/10"
)
]}
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-50 group-hover:bg-indigo-100 dark:bg-indigo-900/30">
<.icon
name={provider.icon}
class="h-5 w-5 text-indigo-600 dark:text-indigo-400"
/>
</div>
<span class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
{provider.name}
</span>
<span class="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{provider.description}
</span>
</button>
</div>
<% end %>
<%!-- Config form for whichever billing provider is being configured --%>
<%= if @configuring && find_category_id(@configuring) == "billing" do %>
<% provider = Enum.find(category.providers, &(&1.id == @configuring)) %>
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-white/10">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">
Configure {provider.name}
</h4>
<.form
for={@form}
id={"#{provider.id}-form"}
phx-change="validate"
phx-submit="save"
>
<input type="hidden" name="integration[provider]" value={provider.id} />
<div class="space-y-4">
<%= if provider.id in ["splynx", "sonar"] do %>
<.input
field={@form[:instance_url]}
type="text"
label={t("Instance URL")}
placeholder={"https://your-instance.#{provider.id}.com"}
autocomplete="off"
value={get_credential(@integrations[provider.id], "instance_url")}
/>
<% end %>
<%= if provider.id == "sonar" do %>
<.input
field={@form[:api_token]}
type="password"
label={t("API Token")}
placeholder="Enter your Sonar API token"
autocomplete="off"
value={get_credential(@integrations[provider.id], "api_token")}
/>
<% else %>
<.input
field={@form[:api_key]}
type="password"
label={t("API Key")}
placeholder={"Enter your #{provider.name} API key"}
autocomplete="off"
value={get_credential(@integrations[provider.id], "api_key")}
/>
<% end %>
<%= if provider.id == "splynx" do %>
<.input
field={@form[:api_secret]}
type="password"
label={t("API Secret")}
placeholder="Enter your Splynx API secret"
autocomplete="off"
value={get_credential(@integrations[provider.id], "api_secret")}
/>
<% end %>
<.input
field={@form[:sync_interval_minutes]}
type="number"
label={t("Sync interval (minutes)")}
min="5"
value={
if(@integrations[provider.id],
do: @integrations[provider.id].sync_interval_minutes,
else: if(provider.id == "gaiia", do: 15, else: 10)
)
}
/>
<%= if @test_result do %>
<div class={[
"rounded-md p-4",
case @test_result do
{:ok, _} -> "bg-green-50 dark:bg-green-900/20"
{:error, _} -> "bg-red-50 dark:bg-red-900/20"
end
]}>
<p class={[
"text-sm",
case @test_result do
{:ok, _} -> "text-green-800 dark:text-green-200"
{:error, _} -> "text-red-800 dark:text-red-200"
end
]}>
{elem(@test_result, 1)}
</p>
</div>
<% end %>
<div class="flex items-center justify-between pt-4">
<button
type="button"
id="test-connection"
phx-click="test_connection"
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
>
{t("Test Connection")}
</button>
<div class="flex items-center gap-3">
<button
type="button"
phx-click="close_config"
class="text-sm font-semibold text-gray-900 dark:text-white"
>
{t("Cancel")}
</button>
<button
type="submit"
phx-disable-with={t("Saving...")}
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{t("Save")}
</button>
</div>
</div>
</div>
</.form>
</div>
<%!-- Gaiia webhook config --%>
<%= if @configuring == "gaiia" && @integrations["gaiia"] do %>
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-white/10">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Webhook Configuration")}
</h4>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t(
"Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change."
)}
</p>
<div class="mt-4 space-y-4">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300">
{t("Webhook URL")}
</label>
<div class="mt-1 flex items-center gap-2">
<input
id="gaiia-webhook-url"
type="text"
readonly
value={webhook_url(@organization.id)}
class="block w-full rounded-md border-gray-300 bg-gray-50 py-1.5 text-sm text-gray-900 shadow-xs sm:leading-6 dark:border-white/10 dark:bg-white/5 dark:text-white"
/>
<button
type="button"
phx-hook="CopyToClipboard"
data-target="#gaiia-webhook-url"
id="copy-webhook-url"
class="inline-flex items-center gap-1 rounded-md bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/20"
>
<.icon name="hero-clipboard" class="h-3.5 w-3.5" /> Copy
</button>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300">
{t("Webhook Secret")}
</label>
<div class="mt-1 flex items-center gap-2">
<input
id="gaiia-webhook-secret"
type="text"
readonly
value={get_credential(@integrations["gaiia"], "webhook_secret")}
class="block w-full rounded-md border-gray-300 bg-gray-50 py-1.5 font-mono text-sm text-gray-900 shadow-xs sm:leading-6 dark:border-white/10 dark:bg-white/5 dark:text-white"
/>
<button
type="button"
phx-hook="CopyToClipboard"
data-target="#gaiia-webhook-secret"
id="copy-webhook-secret"
class="inline-flex items-center gap-1 rounded-md bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/20"
>
<.icon name="hero-clipboard" class="h-3.5 w-3.5" /> Copy
</button>
</div>
<div class="mt-2">
<button
type="button"
id="regenerate-webhook-secret"
phx-click="regenerate_webhook_secret"
data-confirm={
t(
"Are you sure? Any existing Gaiia webhook using the current secret will stop working."
)
}
class="text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
{t("Regenerate Secret")}
</button>
</div>
</div>
<div class="rounded-md bg-blue-50 p-3 dark:bg-blue-900/20">
<h5 class="text-xs font-medium text-blue-800 dark:text-blue-300">
{t("Setup Instructions")}
</h5>
<ol class="mt-2 list-inside list-decimal space-y-1 text-xs text-blue-700 dark:text-blue-400">
<li>In your Gaiia admin panel, go to Settings → Webhooks</li>
<li>Click &quot;Add Webhook&quot;</li>
<li>Paste the Webhook URL above</li>
<li>Paste the Webhook Secret above into the &quot;Secret&quot; field</li>
<li>Select events: Account, Billing Subscription, Inventory Item</li>
<li>Save the webhook</li>
</ol>
</div>
</div>
</div>
<% end %>
<% end %>
</div>
<% else %>
<%!-- Non-exclusive categories: one card per provider --%>
<div class="space-y-4">
<div
:for={provider <- category.providers}
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-white/10 dark:bg-white/5"
>
<div class="flex items-start justify-between">
<div class="flex items-start gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-50 dark:bg-indigo-900/30">
<.icon
name={provider.icon}
class="h-6 w-6 text-indigo-600 dark:text-indigo-400"
/>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{provider.name}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{provider.description}
</p>
<%= if integration = @integrations[provider.id] do %>
<div class="mt-3 flex flex-wrap items-center gap-4">
<span class={[
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
if(integration.enabled,
do:
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
else: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
)
]}>
{if integration.enabled, do: "Enabled", else: "Disabled"}
</span>
<%= if integration.last_synced_at do %>
<span class="text-xs text-gray-500 dark:text-gray-400">
{t("Last synced:")}
<.timestamp
datetime={integration.last_synced_at}
timezone={@timezone}
format="absolute"
/>
</span>
<% end %>
<%= if integration.last_sync_status && integration.last_sync_status != "never" do %>
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
case integration.last_sync_status do
"success" ->
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
"partial" ->
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
"failed" ->
"bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
_ ->
"bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
end
]}>
{String.capitalize(integration.last_sync_status)}
</span>
<% end %>
<%= if integration.last_synced_at do %>
<span class="text-xs text-gray-400 dark:text-gray-500">
· Next sync in ~{next_sync_minutes(
integration.last_synced_at,
integration.sync_interval_minutes
)}m
</span>
<% end %>
<%= if provider.id == "preseem" do %>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices"
}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Manage Devices →
</.link>
<.link
navigate={~p"/insights?source=preseem"}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Network Insights →
</.link>
<% end %>
<%= if integration.enabled do %>
<div class="mt-3 w-full rounded-md bg-gray-50 p-3 dark:bg-gray-800/50">
<h4 class="text-xs font-semibold text-gray-700 dark:text-gray-300">
<.icon name="hero-clock" class="h-3.5 w-3.5 inline" /> Sync Schedule
</h4>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<%= if provider.id == "preseem" do %>
Syncs every {integration.sync_interval_minutes} minutes. Baselines and fleet profiles computed nightly.
<% end %>
</p>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
<div class="flex items-center gap-3">
<%= if integration = @integrations[provider.id] do %>
<button
type="button"
phx-click="toggle_enabled"
phx-value-provider={provider.id}
class={[
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2",
if(integration.enabled,
do: "bg-indigo-600",
else: "bg-gray-200 dark:bg-gray-700"
)
]}
>
<span class={[
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
if(integration.enabled, do: "translate-x-5", else: "translate-x-0")
]} />
</button>
<% end %>
<button
type="button"
id={"configure-#{provider.id}"}
phx-click="configure"
phx-value-provider={provider.id}
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
>
{t("Configure")}
</button>
</div>
</div>
<%= if @configuring == provider.id do %>
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-white/10">
<.form
for={@form}
id={"#{provider.id}-form"}
phx-change="validate"
phx-submit="save"
>
<input type="hidden" name="integration[provider]" value={provider.id} />
<div class="space-y-4">
<.input
field={@form[:api_key]}
type="password"
label={t("API Key")}
placeholder={"Enter your #{provider.name} API key"}
autocomplete="off"
value={get_credential(@integrations[provider.id], "api_key")}
/>
<.input
field={@form[:sync_interval_minutes]}
type="number"
label={t("Sync interval (minutes)")}
min="5"
value={
if(@integrations[provider.id],
do: @integrations[provider.id].sync_interval_minutes,
else: 10
)
}
/>
<%= if @test_result do %>
<div class={[
"rounded-md p-4",
case @test_result do
{:ok, _} -> "bg-green-50 dark:bg-green-900/20"
{:error, _} -> "bg-red-50 dark:bg-red-900/20"
end
]}>
<p class={[
"text-sm",
case @test_result do
{:ok, _} -> "text-green-800 dark:text-green-200"
{:error, _} -> "text-red-800 dark:text-red-200"
end
]}>
{elem(@test_result, 1)}
</p>
</div>
<% end %>
<div class="flex items-center justify-between pt-4">
<button
type="button"
id="test-connection"
phx-click="test_connection"
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
>
{t("Test Connection")}
</button>
<div class="flex items-center gap-3">
<button
type="button"
phx-click="close_config"
class="text-sm font-semibold text-gray-900 dark:text-white"
>
{t("Cancel")}
</button>
<button
type="submit"
phx-disable-with={t("Saving...")}
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{t("Save")}
</button>
</div>
</div>
</div>
</.form>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
</Layouts.authenticated>