-
+
{t("Loading chart...")}
diff --git a/lib/towerops_web/live/maintenance_live/form.ex b/lib/towerops_web/live/maintenance_live/form.ex
new file mode 100644
index 00000000..2597882f
--- /dev/null
+++ b/lib/towerops_web/live/maintenance_live/form.ex
@@ -0,0 +1,116 @@
+defmodule ToweropsWeb.MaintenanceLive.Form do
+ use ToweropsWeb, :live_view
+
+ alias Towerops.Maintenance
+ alias Towerops.Maintenance.MaintenanceWindow
+
+ @impl true
+ def mount(_params, _session, socket) do
+ org_id = socket.assigns.current_scope.organization.id
+ sites = Towerops.Sites.list_organization_sites(org_id)
+ devices = Towerops.Devices.list_organization_devices(org_id)
+
+ {:ok,
+ socket
+ |> assign(:sites, sites)
+ |> assign(:devices, devices)
+ |> assign(:timezone, socket.assigns.current_scope.timezone)}
+ end
+
+ @impl true
+ def handle_params(params, _url, socket) do
+ case socket.assigns.live_action do
+ :new ->
+ changeset = MaintenanceWindow.changeset(%MaintenanceWindow{suppress_alerts: true}, %{})
+
+ {:noreply,
+ socket
+ |> assign(:page_title, t("New Maintenance Window"))
+ |> assign(:window, %MaintenanceWindow{suppress_alerts: true, recurring: false})
+ |> assign(:changeset, changeset)
+ |> assign(:scope_type, "org")}
+
+ :edit ->
+ window = Maintenance.get_window!(params["id"])
+
+ scope_type =
+ cond do
+ window.device_id -> "device"
+ window.site_id -> "site"
+ true -> "org"
+ end
+
+ changeset = MaintenanceWindow.changeset(window, %{})
+
+ {:noreply,
+ socket
+ |> assign(:page_title, t("Edit Maintenance Window"))
+ |> assign(:window, window)
+ |> assign(:changeset, changeset)
+ |> assign(:scope_type, scope_type)}
+ end
+ end
+
+ @impl true
+ def handle_event("validate", %{"maintenance_window" => params}, socket) do
+ changeset =
+ socket.assigns.window
+ |> MaintenanceWindow.changeset(params)
+ |> Map.put(:action, :validate)
+
+ {:noreply, assign(socket, :changeset, changeset)}
+ end
+
+ def handle_event("change_scope", %{"scope_type" => scope_type}, socket) do
+ {:noreply, assign(socket, :scope_type, scope_type)}
+ end
+
+ def handle_event("save", %{"maintenance_window" => params}, socket) do
+ scope = socket.assigns.current_scope
+
+ params =
+ params
+ |> Map.put("organization_id", scope.organization.id)
+ |> Map.put("created_by_id", scope.user.id)
+ |> maybe_clear_scope(socket.assigns.scope_type)
+
+ case socket.assigns.live_action do
+ :new -> save_window(socket, :create, params)
+ :edit -> save_window(socket, :update, params)
+ end
+ end
+
+ defp maybe_clear_scope(params, "org"), do: Map.merge(params, %{"site_id" => nil, "device_id" => nil})
+ defp maybe_clear_scope(params, "site"), do: Map.put(params, "device_id", nil)
+ defp maybe_clear_scope(params, _), do: params
+
+ defp format_datetime_local(nil), do: ""
+ defp format_datetime_local(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%dT%H:%M")
+ defp format_datetime_local(_), do: ""
+
+ defp save_window(socket, :create, params) do
+ case Maintenance.create_window(params) do
+ {:ok, window} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, t("Maintenance window created"))
+ |> push_navigate(to: ~p"/maintenance/#{window.id}")}
+
+ {:error, changeset} ->
+ {:noreply, assign(socket, :changeset, changeset)}
+ end
+ end
+
+ defp save_window(socket, :update, params) do
+ case Maintenance.update_window(socket.assigns.window, params) do
+ {:ok, window} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, t("Maintenance window updated"))
+ |> push_navigate(to: ~p"/maintenance/#{window.id}")}
+
+ {:error, changeset} ->
+ {:noreply, assign(socket, :changeset, changeset)}
+ end
+ end
+end
diff --git a/lib/towerops_web/live/maintenance_live/form.html.heex b/lib/towerops_web/live/maintenance_live/form.html.heex
new file mode 100644
index 00000000..20f59b3d
--- /dev/null
+++ b/lib/towerops_web/live/maintenance_live/form.html.heex
@@ -0,0 +1,211 @@
+
+
+ <.link
+ navigate={~p"/maintenance"}
+ class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
+ >
+ <.icon name="hero-arrow-left" class="h-4 w-4" />
+ {t("Back to Maintenance Windows")}
+
+
+
+ {@page_title}
+
+
+ <.form
+ for={@changeset}
+ phx-change="validate"
+ phx-submit="save"
+ id="maintenance-form"
+ >
+
+ <%!-- Name --%>
+
+
+
+ <%= for {msg, _} <- (@changeset.errors[:name] || []) do %>
{msg}
<% end %>
+
+
+ <%!-- Reason --%>
+
+
+
+
+
+ <%!-- Scope --%>
+
+
+
+ <%= for {label, value} <- [{t("Org-wide"), "org"}, {t("Site"), "site"}, {t("Device"), "device"}] do %>
+
+ <% end %>
+
+
+ <%= if @scope_type == "site" do %>
+
+ <% end %>
+
+ <%= if @scope_type == "device" do %>
+
+ <% end %>
+
+
+ <%!-- Time Range --%>
+
+
+
+
+ <%= for {msg, _} <- (@changeset.errors[:starts_at] || []) do %>
{msg}
<% end %>
+
+
+
+
+ <%= for {msg, _} <- (@changeset.errors[:ends_at] || []) do %>
{msg}
<% end %>
+
+
+
+ <%!-- Suppress Alerts --%>
+
+
+
+
+ <%!-- Recurring --%>
+
+
+
+
+ <%!-- Recurrence Rule (shown when recurring) --%>
+ <%= if Ecto.Changeset.get_field(@changeset, :recurring) == true do %>
+
+
+
+
+ <% end %>
+
+ <%!-- Submit --%>
+
+ <.link
+ navigate={~p"/maintenance"}
+ class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 transition-colors"
+ >
+ {t("Cancel")}
+
+
+
+
+
+
+
diff --git a/lib/towerops_web/live/maintenance_live/index.ex b/lib/towerops_web/live/maintenance_live/index.ex
new file mode 100644
index 00000000..8c31a17b
--- /dev/null
+++ b/lib/towerops_web/live/maintenance_live/index.ex
@@ -0,0 +1,66 @@
+defmodule ToweropsWeb.MaintenanceLive.Index do
+ use ToweropsWeb, :live_view
+
+ alias Towerops.Maintenance
+
+ @impl true
+ def mount(_params, _session, socket) do
+ organization = socket.assigns.current_scope.organization
+
+ {:ok,
+ socket
+ |> assign(:page_title, t("Maintenance Windows"))
+ |> assign(:filter, "all")
+ |> assign(:timezone, socket.assigns.current_scope.timezone)
+ |> load_windows(organization.id)}
+ end
+
+ @impl true
+ def handle_params(params, _url, socket) do
+ filter = Map.get(params, "filter", "all")
+
+ {:noreply,
+ socket
+ |> assign(:filter, filter)
+ |> load_windows(socket.assigns.current_scope.organization.id)}
+ end
+
+ defp load_windows(socket, org_id) do
+ filter_atom =
+ case socket.assigns.filter do
+ "active" -> :active
+ "upcoming" -> :upcoming
+ "past" -> :past
+ _ -> nil
+ end
+
+ opts = if filter_atom, do: [filter: filter_atom], else: []
+ windows = Maintenance.list_windows(org_id, opts)
+ assign(socket, :windows, windows)
+ end
+
+ defp status_for(window) do
+ now = DateTime.utc_now()
+
+ cond do
+ DateTime.compare(window.starts_at, now) == :gt -> :upcoming
+ DateTime.compare(window.ends_at, now) == :lt -> :past
+ true -> :active
+ end
+ end
+
+ defp scope_label(window) do
+ cond do
+ window.device -> "Device: #{window.device.name}"
+ window.site -> "Site: #{window.site.name}"
+ true -> "Org-wide"
+ end
+ end
+
+ defp format_datetime(dt, timezone) do
+ case DateTime.shift_zone(dt, timezone) do
+ {:ok, local} -> Calendar.strftime(local, "%b %d, %Y %I:%M %p")
+ _ -> Calendar.strftime(dt, "%b %d, %Y %I:%M %p UTC")
+ end
+ end
+end
diff --git a/lib/towerops_web/live/maintenance_live/index.html.heex b/lib/towerops_web/live/maintenance_live/index.html.heex
new file mode 100644
index 00000000..34331381
--- /dev/null
+++ b/lib/towerops_web/live/maintenance_live/index.html.heex
@@ -0,0 +1,118 @@
+
+
+
{t("Maintenance Windows")}
+ <.link
+ navigate={~p"/maintenance/new"}
+ class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
+ >
+ <.icon name="hero-plus" class="h-4 w-4" />
+ {t("New Window")}
+
+
+
+ <%!-- Filter tabs --%>
+
+ <.link
+ patch={~p"/maintenance?filter=all"}
+ class={[
+ "inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
+ @filter == "all" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
+ @filter != "all" && "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
+ ]}
+ >
+ {t("All")}
+
+ <.link
+ patch={~p"/maintenance?filter=active"}
+ class={[
+ "inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
+ @filter == "active" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
+ @filter != "active" && "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
+ ]}
+ >
+ {t("Active")}
+
+ <.link
+ patch={~p"/maintenance?filter=upcoming"}
+ class={[
+ "inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
+ @filter == "upcoming" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
+ @filter != "upcoming" && "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
+ ]}
+ >
+ {t("Upcoming")}
+
+ <.link
+ patch={~p"/maintenance?filter=past"}
+ class={[
+ "inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
+ @filter == "past" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
+ @filter != "past" && "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
+ ]}
+ >
+ {t("Past")}
+
+
+
+ <%= if Enum.empty?(@windows) do %>
+
+
+ <.icon name="hero-wrench-screwdriver" class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
+
{t("No maintenance windows")}
+
+ {t("Schedule maintenance to suppress alerts during planned work.")}
+
+
+
+ <% else %>
+
+
+
+
+ | {t("Name")} |
+ {t("Scope")} |
+ {t("Time Range")} |
+ {t("Status")} |
+ {t("Created By")} |
+
+
+
+ <%= for window <- @windows do %>
+
+ |
+ {window.name}
+ |
+
+ {scope_label(window)}
+ |
+
+ {format_datetime(window.starts_at, @timezone)} — {format_datetime(window.ends_at, @timezone)}
+ |
+
+ <% status = status_for(window) %>
+
+ {status |> to_string() |> String.capitalize()}
+
+ |
+
+ {if window.created_by, do: window.created_by.email, else: "—"}
+ |
+
+ <% end %>
+
+
+
+ <% end %>
+
diff --git a/lib/towerops_web/live/maintenance_live/show.ex b/lib/towerops_web/live/maintenance_live/show.ex
new file mode 100644
index 00000000..59b869bc
--- /dev/null
+++ b/lib/towerops_web/live/maintenance_live/show.ex
@@ -0,0 +1,56 @@
+defmodule ToweropsWeb.MaintenanceLive.Show do
+ use ToweropsWeb, :live_view
+
+ alias Towerops.Maintenance
+
+ @impl true
+ def mount(%{"id" => id}, _session, socket) do
+ window = Maintenance.get_window!(id)
+ timezone = socket.assigns.current_scope.timezone
+
+ {:ok,
+ socket
+ |> assign(:page_title, window.name)
+ |> assign(:window, window)
+ |> assign(:timezone, timezone)}
+ end
+
+ @impl true
+ def handle_event("delete", _params, socket) do
+ case Maintenance.delete_window(socket.assigns.window) do
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, t("Maintenance window deleted"))
+ |> push_navigate(to: ~p"/maintenance")}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, t("Unable to delete maintenance window"))}
+ end
+ end
+
+ defp status_for(window) do
+ now = DateTime.utc_now()
+
+ cond do
+ DateTime.compare(window.starts_at, now) == :gt -> :upcoming
+ DateTime.compare(window.ends_at, now) == :lt -> :past
+ true -> :active
+ end
+ end
+
+ defp scope_label(window) do
+ cond do
+ window.device -> "Device: #{window.device.name}"
+ window.site -> "Site: #{window.site.name}"
+ true -> "Org-wide"
+ end
+ end
+
+ defp format_datetime(dt, timezone) do
+ case DateTime.shift_zone(dt, timezone) do
+ {:ok, local} -> Calendar.strftime(local, "%b %d, %Y %I:%M %p")
+ _ -> Calendar.strftime(dt, "%b %d, %Y %I:%M %p UTC")
+ end
+ end
+end
diff --git a/lib/towerops_web/live/maintenance_live/show.html.heex b/lib/towerops_web/live/maintenance_live/show.html.heex
new file mode 100644
index 00000000..22491e9e
--- /dev/null
+++ b/lib/towerops_web/live/maintenance_live/show.html.heex
@@ -0,0 +1,122 @@
+
+
+ <.link
+ navigate={~p"/maintenance"}
+ class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
+ >
+ <.icon name="hero-arrow-left" class="h-4 w-4" />
+ {t("Back to Maintenance Windows")}
+
+
+
+ <% status = status_for(@window) %>
+
+ <%!-- Active banner --%>
+ <%= if status == :active do %>
+
+
+ <.icon name="hero-wrench-screwdriver" class="h-6 w-6 text-green-600 dark:text-green-400" />
+
+
{t("Currently Active")}
+
{t("This maintenance window is currently in effect. Alerts are being suppressed.")}
+
+
+
+ <% end %>
+
+
+
+
{@window.name}
+
+ {status |> to_string() |> String.capitalize()}
+
+
+
+ <.link
+ navigate={~p"/maintenance/#{@window.id}/edit"}
+ class="inline-flex items-center gap-2 rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 transition-colors"
+ >
+ <.icon name="hero-pencil" class="h-4 w-4" />
+ {t("Edit")}
+
+
+
+
+
+
+ <%!-- Reason --%>
+ <%= if @window.reason do %>
+
+
{t("Reason")}
+ {@window.reason}
+
+ <% end %>
+
+ <%!-- Scope --%>
+
+
{t("Scope")}
+ {scope_label(@window)}
+
+
+ <%!-- Time Range --%>
+
+
{t("Time Range")}
+
+ {format_datetime(@window.starts_at, @timezone)} — {format_datetime(@window.ends_at, @timezone)}
+
+
+
+ <%!-- Recurring --%>
+
+
{t("Recurring")}
+
+ <%= if @window.recurring do %>
+ {t("Yes")} — {@window.recurrence_rule || t("No rule specified")}
+ <% else %>
+ {t("No")}
+ <% end %>
+
+
+
+ <%!-- Suppress Alerts --%>
+
+
{t("Suppress Alerts")}
+
+ <%= if @window.suppress_alerts do %>
+
+ <.icon name="hero-check-circle" class="h-4 w-4" /> {t("Yes")}
+
+ <% else %>
+
+ <.icon name="hero-x-circle" class="h-4 w-4" /> {t("No")}
+
+ <% end %>
+
+
+
+ <%!-- Created By --%>
+
+
{t("Created By")}
+
+ {if @window.created_by, do: @window.created_by.email, else: "—"}
+
+
+
+
diff --git a/lib/towerops_web/live/onboarding_live.ex b/lib/towerops_web/live/onboarding_live.ex
new file mode 100644
index 00000000..26909c1c
--- /dev/null
+++ b/lib/towerops_web/live/onboarding_live.ex
@@ -0,0 +1,251 @@
+defmodule ToweropsWeb.OnboardingLive do
+ @moduledoc false
+ use ToweropsWeb, :live_view
+
+ alias Towerops.Agents
+ alias Towerops.Integrations
+ alias Towerops.Integrations.Integration
+ alias Towerops.Organizations
+ alias Towerops.Sites
+
+ @steps [:snmp, :site, :billing, :agent]
+
+ @billing_providers [
+ %{id: "gaiia", name: "Gaiia", icon: "hero-user-group"},
+ %{id: "sonar", name: "Sonar", icon: "hero-currency-dollar"},
+ %{id: "splynx", name: "Splynx", icon: "hero-banknotes"},
+ %{id: "visp", name: "VISP", icon: "hero-cloud"}
+ ]
+
+ @impl true
+ def mount(_params, _session, socket) do
+ organization = socket.assigns.current_scope.organization
+ changeset = Organizations.change_organization(organization)
+
+ site_changeset = Sites.change_site(%Towerops.Sites.Site{}, %{})
+
+ {:ok,
+ socket
+ |> assign(:page_title, t("Setup"))
+ |> assign(:organization, organization)
+ |> assign(:step, :snmp)
+ |> assign(:steps, @steps)
+ |> assign(:form, to_form(changeset))
+ |> assign(:site_form, to_form(site_changeset, as: "site"))
+ |> assign(:billing_providers, @billing_providers)
+ |> assign(:selected_provider, nil)
+ |> assign(:integration_form, nil)
+ |> assign(:agent_token, nil)
+ |> assign(:agent_token_value, nil)}
+ end
+
+ @impl true
+ def handle_params(_params, _url, socket) do
+ {:noreply, socket}
+ end
+
+ # SNMP step events
+
+ @impl true
+ def handle_event("validate_snmp", %{"organization" => params}, socket) do
+ changeset =
+ socket.assigns.organization
+ |> Organizations.change_organization(params)
+ |> Map.put(:action, :validate)
+
+ {:noreply, assign(socket, :form, to_form(changeset))}
+ end
+
+ @impl true
+ def handle_event("save_snmp", %{"organization" => params}, socket) do
+ case Organizations.update_organization(socket.assigns.organization, params) do
+ {:ok, organization} ->
+ {:noreply,
+ socket
+ |> assign(:organization, organization)
+ |> put_flash(:info, t("SNMP configuration saved"))
+ |> assign(:step, :site)}
+
+ {:error, changeset} ->
+ {:noreply, assign(socket, :form, to_form(changeset))}
+ end
+ end
+
+ # Site step events
+
+ @impl true
+ def handle_event("validate_site", %{"site" => params}, socket) do
+ changeset =
+ %Towerops.Sites.Site{}
+ |> Sites.change_site(params)
+ |> Map.put(:action, :validate)
+
+ {:noreply, assign(socket, :site_form, to_form(changeset, as: "site"))}
+ end
+
+ @impl true
+ def handle_event("save_site", %{"site" => params}, socket) do
+ org = socket.assigns.organization
+ attrs = Map.put(params, "organization_id", org.id)
+
+ case Sites.create_site(attrs) do
+ {:ok, _site} ->
+ # Enable sites on the org if not already
+ unless org.use_sites do
+ Organizations.update_organization(org, %{use_sites: true})
+ end
+
+ {:noreply,
+ socket
+ |> put_flash(:info, t("Site created"))
+ |> assign(:step, :billing)}
+
+ {:error, changeset} ->
+ {:noreply, assign(socket, :site_form, to_form(changeset, as: "site"))}
+ end
+ end
+
+ # Billing step events
+
+ @impl true
+ def handle_event("select_provider", %{"provider" => provider}, socket) do
+ form =
+ %Integration{}
+ |> Integrations.change_integration(%{provider: provider})
+ |> to_form()
+
+ {:noreply,
+ socket
+ |> assign(:selected_provider, provider)
+ |> assign(:integration_form, form)}
+ end
+
+ @impl true
+ def handle_event("validate_integration", %{"integration" => params}, socket) do
+ changeset =
+ %Integration{}
+ |> Integrations.change_integration(normalize_integration_params(params, socket.assigns.selected_provider))
+ |> Map.put(:action, :validate)
+
+ {:noreply, assign(socket, :integration_form, to_form(changeset))}
+ end
+
+ @impl true
+ def handle_event("save_integration", %{"integration" => params}, socket) do
+ org = socket.assigns.organization
+ provider = socket.assigns.selected_provider
+ attrs = normalize_integration_params(params, provider) |> Map.put(:provider, provider) |> Map.put(:enabled, true)
+
+ case Integrations.create_integration(org.id, attrs) do
+ {:ok, _integration} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, t("Integration saved"))
+ |> assign(:step, :agent)
+ |> load_agent_token()}
+
+ {:error, changeset} ->
+ {:noreply, assign(socket, :integration_form, to_form(changeset))}
+ end
+ end
+
+ # Navigation events
+
+ @impl true
+ def handle_event("skip", _params, socket) do
+ next = next_step(socket.assigns.step)
+
+ socket =
+ if next == :done do
+ complete_onboarding(socket)
+ else
+ socket = assign(socket, :step, next)
+ if next == :agent, do: load_agent_token(socket), else: socket
+ end
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_event("finish", _params, socket) do
+ {:noreply, complete_onboarding(socket)}
+ end
+
+ @impl true
+ def handle_event("go_to_step", %{"step" => step}, socket) do
+ step = String.to_existing_atom(step)
+
+ socket =
+ if step == :agent do
+ socket |> assign(:step, step) |> load_agent_token()
+ else
+ assign(socket, :step, step)
+ end
+
+ {:noreply, socket}
+ end
+
+ # Private helpers
+
+ defp next_step(:snmp), do: :site
+ defp next_step(:site), do: :billing
+ defp next_step(:billing), do: :agent
+ defp next_step(:agent), do: :done
+
+ defp step_index(:snmp), do: 0
+ defp step_index(:site), do: 1
+ defp step_index(:billing), do: 2
+ defp step_index(:agent), do: 3
+
+ defp step_label(:snmp), do: t("SNMP")
+ defp step_label(:site), do: t("Site")
+ defp step_label(:billing), do: t("Billing")
+ defp step_label(:agent), do: t("Agent")
+
+ defp complete_onboarding(socket) do
+ org = socket.assigns.organization
+ Organizations.update_organization(org, %{onboarding_completed: true})
+
+ socket
+ |> put_flash(:info, t("Setup complete"))
+ |> push_navigate(to: ~p"/dashboard")
+ end
+
+ defp load_agent_token(socket) do
+ org = socket.assigns.organization
+ tokens = Agents.list_organization_agent_tokens(org.id)
+
+ case tokens do
+ [token | _] ->
+ assign(socket, agent_token: token, agent_token_value: nil)
+
+ [] ->
+ case Agents.create_agent_token(org.id, "Default Agent") do
+ {:ok, token, token_value} ->
+ assign(socket, agent_token: token, agent_token_value: token_value)
+
+ {:error, _} ->
+ assign(socket, agent_token: nil, agent_token_value: nil)
+ end
+ end
+ end
+
+ defp normalize_integration_params(params, "sonar") do
+ %{credentials: %{
+ "instance_url" => String.trim(Map.get(params, "instance_url", "")),
+ "api_token" => Map.get(params, "api_token", "")
+ }}
+ end
+
+ defp normalize_integration_params(params, "splynx") do
+ %{credentials: %{
+ "instance_url" => String.trim(Map.get(params, "instance_url", "")),
+ "api_key" => Map.get(params, "api_key", ""),
+ "api_secret" => Map.get(params, "api_secret", "")
+ }}
+ end
+
+ defp normalize_integration_params(params, _provider) do
+ %{credentials: %{"api_key" => Map.get(params, "api_key", "")}}
+ end
+end
diff --git a/lib/towerops_web/live/onboarding_live.html.heex b/lib/towerops_web/live/onboarding_live.html.heex
new file mode 100644
index 00000000..d9df6bee
--- /dev/null
+++ b/lib/towerops_web/live/onboarding_live.html.heex
@@ -0,0 +1,309 @@
+
+
+ <%!-- Step indicator --%>
+
+
+ <%!-- Step content --%>
+
+ <%= case @step do %>
+ <% :snmp -> %>
+
+ {t("SNMP Configuration")}
+
+
+ {t("Set organization-wide SNMP defaults. Can be overridden per-site or per-device.")}
+
+
+ <.form for={@form} phx-change="validate_snmp" phx-submit="save_snmp">
+
+ <.input
+ field={@form[:snmp_version]}
+ type="select"
+ label={t("SNMP Version")}
+ options={[{"v1", "1"}, {"v2c", "2c"}, {"v3", "3"}]}
+ />
+
+ <%= if @form[:snmp_version].value in ["1", "2c"] do %>
+ <.input
+ field={@form[:snmp_community]}
+ type="text"
+ label={t("Community String")}
+ placeholder="public"
+ />
+ <% end %>
+
+ <%= if @form[:snmp_version].value == "3" do %>
+ <.input
+ field={@form[:snmpv3_username]}
+ type="text"
+ label={t("Username")}
+ />
+ <.input
+ field={@form[:snmpv3_auth_protocol]}
+ type="select"
+ label={t("Auth Protocol")}
+ prompt="None"
+ options={["MD5", "SHA", "SHA-224", "SHA-256", "SHA-384", "SHA-512"]}
+ />
+ <.input
+ field={@form[:snmpv3_auth_password]}
+ type="password"
+ label={t("Auth Password")}
+ />
+ <.input
+ field={@form[:snmpv3_priv_protocol]}
+ type="select"
+ label={t("Privacy Protocol")}
+ prompt="None"
+ options={["DES", "AES", "AES-192", "AES-256", "AES-256-C"]}
+ />
+ <.input
+ field={@form[:snmpv3_priv_password]}
+ type="password"
+ label={t("Privacy Password")}
+ />
+ <% end %>
+
+
+
+
+
+
+
+
+ <% :site -> %>
+
+ {t("Add Your First Site")}
+
+
+ {t("Sites represent physical locations — tower sites, POPs, data centers, etc.")}
+
+
+ <.form for={@site_form} phx-change="validate_site" phx-submit="save_site">
+
+ <.input field={@site_form[:name]} type="text" label={t("Name")} placeholder="e.g. Main Tower" required />
+ <.input field={@site_form[:address]} type="text" label={t("Address")} placeholder="123 Tower Rd" />
+
+ <.input field={@site_form[:latitude]} type="number" label={t("Latitude")} step="any" placeholder="38.8977" />
+ <.input field={@site_form[:longitude]} type="number" label={t("Longitude")} step="any" placeholder="-77.0365" />
+
+
+
+
+
+
+
+
+
+ <% :billing -> %>
+
+ {t("Connect a Billing Platform")}
+
+
+ {t("Optional. Sync subscriber data for outage impact analysis and inventory reconciliation.")}
+
+
+ <%= if @selected_provider == nil do %>
+
+ <%= for provider <- @billing_providers do %>
+
+ <% end %>
+
+ <% else %>
+
+
+
+
+ <.form for={@integration_form} phx-change="validate_integration" phx-submit="save_integration">
+
+ <%= case @selected_provider do %>
+ <% "sonar" -> %>
+ <.input name="integration[instance_url]" type="text" label={t("Instance URL")} placeholder="https://myisp.sonar.software" value="" />
+ <.input name="integration[api_token]" type="password" label={t("API Token")} value="" />
+ <% "splynx" -> %>
+ <.input name="integration[instance_url]" type="text" label={t("Instance URL")} placeholder="https://myisp.splynx.app" value="" />
+ <.input name="integration[api_key]" type="text" label={t("API Key")} value="" />
+ <.input name="integration[api_secret]" type="password" label={t("API Secret")} value="" />
+ <% _ -> %>
+ <.input name="integration[api_key]" type="password" label={t("API Key")} value="" />
+ <% end %>
+
+
+
+
+
+
+
+ <% end %>
+
+ <%= if @selected_provider == nil do %>
+
+
+
+ <% end %>
+
+ <% :agent -> %>
+
+ {t("Deploy Agent")}
+
+
+ {t("The TowerOps agent polls your network devices via SNMP. Deploy it on a host with network access to your infrastructure.")}
+
+
+ <%= if @agent_token do %>
+
+ <%= if @agent_token_value do %>
+
+
+ <.icon name="hero-exclamation-triangle" class="h-4 w-4 inline -mt-0.5" />
+ {t("Save this token — it won't be shown again.")}
+
+
+ {@agent_token_value}
+
+
+ <% else %>
+
+
+ {t("Agent:")} {@agent_token.name}
+
+
+ <% end %>
+
+
+
{t("Docker")}
+
docker run -d \
+ --name towerops-agent \
+ --restart unless-stopped \
+ -e TOWEROPS_TOKEN=<%= if @agent_token_value, do: @agent_token_value, else: "" %> \
+ -e TOWEROPS_SERVER=<%= ToweropsWeb.Endpoint.url() %> \
+ --network host \
+ ghcr.io/towerops/agent:latest
+
+
+
+
{t("Systemd")}
+
curl -sSL https://install.towerops.net | \
+ TOWEROPS_TOKEN=<%= if @agent_token_value, do: @agent_token_value, else: "" %> \
+ TOWEROPS_SERVER=<%= ToweropsWeb.Endpoint.url() %> \
+ bash
+
+
+ <% else %>
+
+ {t("Failed to create agent token. You can create one later in Settings → Agents.")}
+
+ <% end %>
+
+
+
+
+ <% end %>
+
+
+
diff --git a/lib/towerops_web/live/site_live/show.html.heex b/lib/towerops_web/live/site_live/show.html.heex
index e078e59c..1f90b26f 100644
--- a/lib/towerops_web/live/site_live/show.html.heex
+++ b/lib/towerops_web/live/site_live/show.html.heex
@@ -425,7 +425,7 @@
>
-
+
{t("Loading chart...")}
diff --git a/lib/towerops_web/live/trace_live/index.ex b/lib/towerops_web/live/trace_live/index.ex
index 7890c48d..255670a3 100644
--- a/lib/towerops_web/live/trace_live/index.ex
+++ b/lib/towerops_web/live/trace_live/index.ex
@@ -157,7 +157,7 @@ defmodule ToweropsWeb.TraceLive.Index do
defp device_status_color(:up), do: "text-green-600 dark:text-green-400"
defp device_status_color(:down), do: "text-red-600 dark:text-red-400"
- defp device_status_color(_), do: "text-gray-500"
+ defp device_status_color(_), do: "text-gray-500 dark:text-gray-400"
defp format_speed(nil), do: "—"
defp format_speed(speed) when is_number(speed) and speed >= 1000, do: "#{Float.round(speed / 1000, 1)} Gbps"
diff --git a/lib/towerops_web/live/trace_live/index.html.heex b/lib/towerops_web/live/trace_live/index.html.heex
index 1da7fd3a..5b81d2c4 100644
--- a/lib/towerops_web/live/trace_live/index.html.heex
+++ b/lib/towerops_web/live/trace_live/index.html.heex
@@ -216,7 +216,7 @@
{@trace.access_point.name}
-
+
{@trace.access_point.model}
@@ -362,7 +362,7 @@
>
{ap.name}
- {ap.subscriber_count || 0} subs
+ {ap.subscriber_count || 0} subs
{Map.get(change, :changed_sections, []) |> Enum.join(", ")}
-
+
{format_relative_time(change.changed_at)}
@@ -511,7 +511,7 @@
-
+
| Name |
IP |
Model |
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex
index 554df95a..5f966fa0 100644
--- a/lib/towerops_web/router.ex
+++ b/lib/towerops_web/router.ex
@@ -415,6 +415,12 @@ defmodule ToweropsWeb.Router do
# Activity feed
live "/activity", ActivityFeedLive, :index
+ # Maintenance windows
+ live "/maintenance", MaintenanceLive.Index, :index
+ live "/maintenance/new", MaintenanceLive.Form, :new
+ live "/maintenance/:id", MaintenanceLive.Show, :show
+ live "/maintenance/:id/edit", MaintenanceLive.Form, :edit
+
# Changelog
live "/changelog", ChangelogLive, :index
diff --git a/priv/repo/migrations/20260216160000_add_onboarding_completed_to_organizations.exs b/priv/repo/migrations/20260216160000_add_onboarding_completed_to_organizations.exs
new file mode 100644
index 00000000..cfbb39ba
--- /dev/null
+++ b/priv/repo/migrations/20260216160000_add_onboarding_completed_to_organizations.exs
@@ -0,0 +1,9 @@
+defmodule Towerops.Repo.Migrations.AddOnboardingCompletedToOrganizations do
+ use Ecto.Migration
+
+ def change do
+ alter table(:organizations) do
+ add :onboarding_completed, :boolean, default: false, null: false
+ end
+ end
+end