feat: collapse and dim inactive billing integrations

When a billing provider is active (enabled), the other billing providers
are dimmed and collapsed to a single row showing a lock icon and which
provider is active. This reinforces the mutually exclusive constraint.

Also fixes status emoji accumulating in page title (JS regex needed
unicode flag) and adds missing page_title to 8 LiveViews.
This commit is contained in:
Graham McIntire 2026-03-11 10:00:38 -05:00
parent f2608e8f16
commit 031427a247
No known key found for this signature in database
2 changed files with 232 additions and 196 deletions

View file

@ -176,6 +176,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
|> assign(:providers, @providers)
|> assign(:billing_provider_ids, @billing_provider_ids)
|> assign(:integrations, %{})
|> assign(:active_billing_provider, nil)
|> assign(:configuring, nil)
|> assign(:integration_form, nil)
|> assign(:test_result, nil)
@ -205,7 +206,10 @@ defmodule ToweropsWeb.Org.SettingsLive do
defp maybe_load_integrations(socket, "integrations") do
integrations = load_integrations(socket.assigns.organization.id)
assign(socket, :integrations, integrations)
socket
|> assign(:integrations, integrations)
|> assign(:active_billing_provider, active_billing_provider(integrations))
end
defp maybe_load_integrations(socket, _tab), do: socket
@ -480,6 +484,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
{:noreply,
socket
|> assign(:integrations, integrations)
|> assign(:active_billing_provider, active_billing_provider(integrations))
|> assign(:configuring, nil)
|> assign(:integration_form, nil)
|> assign(:test_result, nil)
@ -513,6 +518,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
{:noreply,
socket
|> assign(:integrations, integrations)
|> assign(:active_billing_provider, active_billing_provider(integrations))
|> put_flash(:info, t("Webhook secret saved"))}
{:error, _changeset} ->
@ -535,6 +541,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
{:noreply,
socket
|> assign(:integrations, integrations)
|> assign(:active_billing_provider, active_billing_provider(integrations))
|> put_flash(:info, t("Integration updated"))}
{:error, _changeset} ->
@ -649,6 +656,15 @@ defmodule ToweropsWeb.Org.SettingsLive do
|> Map.new(fn integration -> {integration.provider, integration} end)
end
defp active_billing_provider(integrations) do
Enum.find_value(@billing_provider_ids, fn id ->
case Map.get(integrations, id) do
%{enabled: true} -> id
_ -> nil
end
end)
end
defp get_current_integration(socket) do
provider = socket.assigns.configuring

View file

@ -865,6 +865,9 @@
<%= if @active_tab == "integrations" do %>
<div class="mt-8 space-y-5">
<%= for {provider, idx} <- Enum.with_index(@providers) do %>
<% billing_locked? =
@active_billing_provider != nil and provider.id in @billing_provider_ids and
provider.id != @active_billing_provider %>
<%!-- Section headers --%>
<%= if idx == 0 do %>
<div class="flex flex-wrap items-center gap-3">
@ -888,7 +891,11 @@
<% end %>
<div class={[
"relative overflow-hidden rounded-xl border bg-white shadow-sm transition-all dark:bg-white/[0.03]",
if(billing_locked?, do: "opacity-50"),
cond do
billing_locked? ->
"border-gray-200 dark:border-white/10"
@integrations[provider.id] && @integrations[provider.id].last_sync_status == "failed" ->
"border-red-200 dark:border-red-500/30"
@ -963,215 +970,228 @@
</div>
<%!-- === Actions (top right) === --%>
<div class="flex items-center gap-4 shrink-0 ml-4">
<%= if integration = @integrations[provider.id] do %>
<div class="flex items-center gap-2">
<span class={[
"text-xs font-medium",
if(integration.enabled,
do: "text-green-700 dark:text-green-400",
else: "text-gray-500 dark:text-gray-400"
)
]}>
{if integration.enabled, do: "Enabled", else: "Disabled"}
</span>
<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 dark:focus:ring-offset-gray-900",
if(integration.enabled,
do: "bg-indigo-600",
else: "bg-gray-200 dark:bg-gray-700"
)
]}
>
<%= if billing_locked? do %>
<div class="shrink-0 ml-4">
<span class="inline-flex items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-500 dark:bg-white/5 dark:text-gray-400">
<.icon name="hero-lock-closed-mini" class="h-3.5 w-3.5" />
{Enum.find_value(@providers, fn p ->
if p.id == @active_billing_provider, do: p.name
end)} is active
</span>
</div>
<% else %>
<div class="flex items-center gap-4 shrink-0 ml-4">
<%= if integration = @integrations[provider.id] do %>
<div class="flex items-center gap-2">
<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")
"text-xs font-medium",
if(integration.enabled,
do: "text-green-700 dark:text-green-400",
else: "text-gray-500 dark:text-gray-400"
)
]}>
{if integration.enabled, do: "Enabled", else: "Disabled"}
</span>
</button>
<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 dark:focus:ring-offset-gray-900",
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")
]}>
</span>
</button>
</div>
<% end %>
<button
type="button"
id={"configure-#{provider.id}"}
phx-click="configure"
phx-value-provider={provider.id}
class={[
"rounded-lg px-4 py-2 text-sm font-semibold shadow-xs transition-colors",
if(@configuring == provider.id,
do:
"bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-300 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10",
else:
"bg-white text-gray-900 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"
)
]}
>
{if @configuring == provider.id, do: "Close", else: "Configure"}
</button>
</div>
<% end %>
</div>
<%!-- === Status Row (when configured, hidden for locked billing providers) === --%>
<%= if !billing_locked? do %>
<%= if integration = @integrations[provider.id] do %>
<%!-- Failed sync banner --%>
<%= if integration.last_sync_status == "failed" do %>
<div class="mt-4 flex items-start gap-3 rounded-lg bg-red-50 px-4 py-3 dark:bg-red-900/20">
<.icon
name="hero-exclamation-triangle"
class="h-5 w-5 shrink-0 text-red-500 dark:text-red-400 mt-0.5"
/>
<div>
<p class="text-sm font-medium text-red-800 dark:text-red-300">
{t("Last sync failed")}
</p>
<%= if integration.last_sync_message && integration.last_sync_message != "" do %>
<p class="mt-0.5 text-sm text-red-700 dark:text-red-400">
{integration.last_sync_message}
</p>
<% end %>
<%= if integration.last_synced_at do %>
<p class="mt-1 text-xs text-red-600/70 dark:text-red-400/60">
<.timestamp
datetime={integration.last_synced_at}
timezone={@timezone}
format="absolute"
/>
</p>
<% end %>
</div>
</div>
<% else %>
<div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
<%!-- Status dot + label --%>
<div class="flex items-center gap-2">
<span class={[
"inline-block h-2 w-2 rounded-full",
if(integration.enabled,
do: "bg-green-500",
else: "bg-gray-400 dark:bg-gray-500"
)
]} />
<span class="text-sm text-gray-600 dark:text-gray-400">
{if integration.enabled, do: "Connected", else: "Paused"}
</span>
</div>
<%!-- Last synced --%>
<%= if integration.last_synced_at do %>
<div class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
<.icon name="hero-clock" class="h-3.5 w-3.5" />
<span>
{t("Synced")}
<.timestamp
datetime={integration.last_synced_at}
timezone={@timezone}
format="absolute"
/>
</span>
</div>
<% else %>
<span class="text-sm italic text-gray-400 dark:text-gray-500">
{t("Never synced")}
</span>
<% end %>
<%!-- Sync status badge (success/partial) --%>
<%= if integration.last_sync_status && integration.last_sync_status not in ["never", "failed"] do %>
<span class={[
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium",
case integration.last_sync_status do
"success" ->
"bg-green-50 text-green-700 ring-1 ring-inset ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20"
"partial" ->
"bg-yellow-50 text-yellow-700 ring-1 ring-inset ring-yellow-600/20 dark:bg-yellow-500/10 dark:text-yellow-400 dark:ring-yellow-500/20"
_ ->
"bg-gray-50 text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:bg-gray-500/10 dark:text-gray-400 dark:ring-gray-500/20"
end
]}>
<%= if integration.last_sync_status == "success" do %>
<.icon name="hero-check-circle-mini" class="h-3.5 w-3.5" />
<% end %>
{String.capitalize(integration.last_sync_status)}
</span>
<% end %>
<%!-- Sync message (success/partial) --%>
<%= if integration.last_sync_message && integration.last_sync_message != "" && integration.last_sync_status != "failed" do %>
<span class="text-sm text-gray-500 dark:text-gray-400">
{integration.last_sync_message}
</span>
<% end %>
<%!-- Next sync --%>
<%= if integration.last_synced_at && integration.enabled do %>
<span class="text-xs text-gray-400 dark:text-gray-500">
Next in ~{next_sync_minutes(
integration.last_synced_at,
integration.sync_interval_minutes
)}m
</span>
<% end %>
<%!-- Sync schedule note --%>
<%= if integration.enabled && provider.id == "pagerduty" do %>
<span class="text-xs text-gray-400 dark:text-gray-500">
· Event-driven (real-time)
</span>
<% end %>
</div>
<% end %>
<button
type="button"
id={"configure-#{provider.id}"}
phx-click="configure"
phx-value-provider={provider.id}
class={[
"rounded-lg px-4 py-2 text-sm font-semibold shadow-xs transition-colors",
if(@configuring == provider.id,
do:
"bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-300 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10",
else:
"bg-white text-gray-900 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"
)
]}
>
{if @configuring == provider.id, do: "Close", else: "Configure"}
</button>
</div>
</div>
<%!-- === Status Row (when configured) === --%>
<%= if integration = @integrations[provider.id] do %>
<%!-- Failed sync banner --%>
<%= if integration.last_sync_status == "failed" do %>
<div class="mt-4 flex items-start gap-3 rounded-lg bg-red-50 px-4 py-3 dark:bg-red-900/20">
<.icon
name="hero-exclamation-triangle"
class="h-5 w-5 shrink-0 text-red-500 dark:text-red-400 mt-0.5"
/>
<div>
<p class="text-sm font-medium text-red-800 dark:text-red-300">
{t("Last sync failed")}
</p>
<%= if integration.last_sync_message && integration.last_sync_message != "" do %>
<p class="mt-0.5 text-sm text-red-700 dark:text-red-400">
{integration.last_sync_message}
</p>
<%!-- === Provider Links Row === --%>
<%= if provider.id == "preseem" || provider.id == "gaiia" do %>
<div class="mt-3 flex flex-wrap gap-3">
<%= if provider.id == "preseem" do %>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices"
}
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
>
<.icon name="hero-cpu-chip" class="h-3.5 w-3.5" /> Manage Devices
</.link>
<.link
navigate={~p"/insights?source=preseem"}
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
>
<.icon name="hero-chart-bar" class="h-3.5 w-3.5" /> Network Insights
</.link>
<% end %>
<%= if integration.last_synced_at do %>
<p class="mt-1 text-xs text-red-600/70 dark:text-red-400/60">
<.timestamp
datetime={integration.last_synced_at}
timezone={@timezone}
format="absolute"
/>
</p>
<%= if provider.id == "gaiia" do %>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"
}
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
>
<.icon name="hero-arrows-right-left" class="h-3.5 w-3.5" /> Entity Mapping
</.link>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation"
}
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
>
<.icon name="hero-scale" class="h-3.5 w-3.5" /> Reconciliation
</.link>
<% end %>
</div>
</div>
<% else %>
<div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
<%!-- Status dot + label --%>
<div class="flex items-center gap-2">
<span class={[
"inline-block h-2 w-2 rounded-full",
if(integration.enabled,
do: "bg-green-500",
else: "bg-gray-400 dark:bg-gray-500"
)
]} />
<span class="text-sm text-gray-600 dark:text-gray-400">
{if integration.enabled, do: "Connected", else: "Paused"}
</span>
</div>
<%!-- Last synced --%>
<%= if integration.last_synced_at do %>
<div class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
<.icon name="hero-clock" class="h-3.5 w-3.5" />
<span>
{t("Synced")}
<.timestamp
datetime={integration.last_synced_at}
timezone={@timezone}
format="absolute"
/>
</span>
</div>
<% else %>
<span class="text-sm italic text-gray-400 dark:text-gray-500">
{t("Never synced")}
</span>
<% end %>
<%!-- Sync status badge (success/partial) --%>
<%= if integration.last_sync_status && integration.last_sync_status not in ["never", "failed"] do %>
<span class={[
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium",
case integration.last_sync_status do
"success" ->
"bg-green-50 text-green-700 ring-1 ring-inset ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20"
"partial" ->
"bg-yellow-50 text-yellow-700 ring-1 ring-inset ring-yellow-600/20 dark:bg-yellow-500/10 dark:text-yellow-400 dark:ring-yellow-500/20"
_ ->
"bg-gray-50 text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:bg-gray-500/10 dark:text-gray-400 dark:ring-gray-500/20"
end
]}>
<%= if integration.last_sync_status == "success" do %>
<.icon name="hero-check-circle-mini" class="h-3.5 w-3.5" />
<% end %>
{String.capitalize(integration.last_sync_status)}
</span>
<% end %>
<%!-- Sync message (success/partial) --%>
<%= if integration.last_sync_message && integration.last_sync_message != "" && integration.last_sync_status != "failed" do %>
<span class="text-sm text-gray-500 dark:text-gray-400">
{integration.last_sync_message}
</span>
<% end %>
<%!-- Next sync --%>
<%= if integration.last_synced_at && integration.enabled do %>
<span class="text-xs text-gray-400 dark:text-gray-500">
Next in ~{next_sync_minutes(
integration.last_synced_at,
integration.sync_interval_minutes
)}m
</span>
<% end %>
<%!-- Sync schedule note --%>
<%= if integration.enabled && provider.id == "pagerduty" do %>
<span class="text-xs text-gray-400 dark:text-gray-500">
· Event-driven (real-time)
</span>
<% end %>
</div>
<% end %>
<%!-- === Provider Links Row === --%>
<%= if provider.id == "preseem" || provider.id == "gaiia" do %>
<div class="mt-3 flex flex-wrap gap-3">
<%= if provider.id == "preseem" do %>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices"
}
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
>
<.icon name="hero-cpu-chip" class="h-3.5 w-3.5" /> Manage Devices
</.link>
<.link
navigate={~p"/insights?source=preseem"}
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
>
<.icon name="hero-chart-bar" class="h-3.5 w-3.5" /> Network Insights
</.link>
<% end %>
<%= if provider.id == "gaiia" do %>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"
}
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
>
<.icon name="hero-arrows-right-left" class="h-3.5 w-3.5" /> Entity Mapping
</.link>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation"
}
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
>
<.icon name="hero-scale" class="h-3.5 w-3.5" /> Reconciliation
</.link>
<% end %>
</div>
<% end %>
<% end %>
<% end %>
</div>
<%!-- === Configure Panel === --%>
<%= if @configuring == provider.id do %>
<%= if !billing_locked? && @configuring == provider.id do %>
<div class="border-t border-gray-200 bg-gray-50/50 px-6 py-6 dark:border-white/10 dark:bg-white/[0.02]">
<%= if provider.id == "netbox" do %>
<.form