Add onboarding flow for new organizations
This commit is contained in:
parent
5b786aac08
commit
2735c1a032
23 changed files with 1283 additions and 19 deletions
|
|
@ -25,7 +25,7 @@ defmodule ToweropsWeb.Components.ConsentPrompt do
|
|||
>
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity"></div>
|
||||
|
||||
<!-- Modal panel -->
|
||||
<div class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ defmodule ToweropsWeb.Layouts do
|
|||
<.link
|
||||
href={~p"/admin/impersonate"}
|
||||
method="delete"
|
||||
class="px-3 py-1 bg-white text-yellow-900 hover:bg-yellow-50 rounded font-medium text-sm"
|
||||
class="px-3 py-1 bg-white dark:bg-gray-900 text-yellow-900 dark:text-yellow-200 hover:bg-yellow-50 dark:hover:bg-gray-800 rounded font-medium text-sm"
|
||||
>
|
||||
Exit
|
||||
</.link>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ defmodule ToweropsWeb.MarketingLayouts do
|
|||
assigns = Map.put(assigns, :requires_cookie_consent, requires_cookie_consent)
|
||||
|
||||
~H"""
|
||||
<div class="bg-white">
|
||||
<div class="bg-white dark:bg-gray-900">
|
||||
<header class="py-10">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<nav class="relative z-50 flex justify-between">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<title>{t("404 - Page Not Found")}</title>
|
||||
<link phx-track-static rel="stylesheet" href="/assets/css/app.css" />
|
||||
</head>
|
||||
<body class="bg-white">
|
||||
<body class="bg-white dark:bg-gray-900">
|
||||
<div class="min-h-screen flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-2xl w-full text-center">
|
||||
<!-- Logo -->
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<title>{t("500 - Server Error")}</title>
|
||||
<link phx-track-static rel="stylesheet" href="/assets/css/app.css" />
|
||||
</head>
|
||||
<body class="bg-white">
|
||||
<body class="bg-white dark:bg-gray-900">
|
||||
<div class="min-h-screen flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-2xl w-full text-center">
|
||||
<!-- Logo -->
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@
|
|||
Your dashboard lights up with subscriber impact, revenue at risk, device health,
|
||||
and site status. Help desk gets subscriber trace. NOC gets alert triage. Everyone wins.
|
||||
</p>
|
||||
<div class="mt-6 rounded-lg border-2 border-blue-200 bg-white p-4">
|
||||
<div class="mt-6 rounded-lg border-2 border-blue-200 dark:border-blue-800 bg-white dark:bg-white/5 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<.icon name="hero-chart-bar" class="h-5 w-5 text-blue-600" />
|
||||
<span class="text-sm font-medium text-slate-900">Impact Dashboard</span>
|
||||
|
|
@ -317,7 +317,7 @@
|
|||
</p>
|
||||
</div>
|
||||
<div class="mt-16 mx-auto max-w-xl">
|
||||
<div class="rounded-2xl bg-white p-8 shadow-lg ring-2 ring-blue-600 text-center">
|
||||
<div class="rounded-2xl bg-white dark:bg-gray-900 p-8 shadow-lg dark:shadow-gray-900/50 ring-2 ring-blue-600 text-center">
|
||||
<div class="mt-2">
|
||||
<span class="text-5xl font-bold text-slate-900">$3</span>
|
||||
<span class="text-lg text-slate-500">/device/month</span>
|
||||
|
|
@ -436,7 +436,7 @@
|
|||
</p>
|
||||
<.link
|
||||
navigate={~p"/users/register"}
|
||||
class="group inline-flex items-center justify-center rounded-full py-3 px-6 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 mt-10 bg-white text-slate-900 hover:bg-blue-50 active:bg-blue-200 active:text-slate-600 focus-visible:outline-white"
|
||||
class="group inline-flex items-center justify-center rounded-full py-3 px-6 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 mt-10 bg-white dark:bg-gray-100 text-slate-900 hover:bg-blue-50 dark:hover:bg-white active:bg-blue-200 active:text-slate-600 focus-visible:outline-white"
|
||||
>
|
||||
Start monitoring free →
|
||||
</.link>
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@
|
|||
)}
|
||||
</span>
|
||||
<%= if alert.acknowledged_by do %>
|
||||
<span class="text-gray-500">
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
by {alert.acknowledged_by.email}
|
||||
</span>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="relative z-50">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity"></div>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
|
|
|
|||
|
|
@ -645,7 +645,7 @@
|
|||
"critical" -> "text-red-600 dark:text-red-400"
|
||||
"warning" -> "text-yellow-600 dark:text-yellow-400"
|
||||
"info" -> "text-blue-600 dark:text-blue-400"
|
||||
_ -> "text-gray-500"
|
||||
_ -> "text-gray-500 dark:text-gray-400"
|
||||
end
|
||||
]}>
|
||||
{case insight.urgency do
|
||||
|
|
|
|||
|
|
@ -576,7 +576,7 @@
|
|||
>
|
||||
<div class="absolute inset-0 flex items-center justify-center" data-loading>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 border-t-blue-500">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 dark:border-gray-700 border-t-blue-500">
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{t("Loading chart...")}</span>
|
||||
</div>
|
||||
|
|
@ -612,7 +612,7 @@
|
|||
>
|
||||
<div class="absolute inset-0 flex items-center justify-center" data-loading>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 border-t-blue-500">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 dark:border-gray-700 border-t-blue-500">
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{t("Loading chart...")}</span>
|
||||
</div>
|
||||
|
|
|
|||
116
lib/towerops_web/live/maintenance_live/form.ex
Normal file
116
lib/towerops_web/live/maintenance_live/form.ex
Normal file
|
|
@ -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
|
||||
211
lib/towerops_web/live/maintenance_live/form.html.heex
Normal file
211
lib/towerops_web/live/maintenance_live/form.html.heex
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="maintenance"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<.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")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white mb-6">{@page_title}</h1>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-6">
|
||||
<.form
|
||||
for={@changeset}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
id="maintenance-form"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<%!-- Name --%>
|
||||
<div>
|
||||
<label for="maintenance_window_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("Name")} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="maintenance_window[name]"
|
||||
id="maintenance_window_name"
|
||||
value={Ecto.Changeset.get_field(@changeset, :name) || ""}
|
||||
required
|
||||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||||
placeholder={t("e.g., Tower firmware upgrade")}
|
||||
/>
|
||||
<%= for {msg, _} <- (@changeset.errors[:name] || []) do %><p class="mt-1 text-sm text-red-600 dark:text-red-400">{msg}</p><% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Reason --%>
|
||||
<div>
|
||||
<label for="maintenance_window_reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("Reason")}
|
||||
</label>
|
||||
<textarea
|
||||
name="maintenance_window[reason]"
|
||||
id="maintenance_window_reason"
|
||||
rows="3"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||||
placeholder={t("Optional description of the maintenance work")}
|
||||
>{Ecto.Changeset.get_field(@changeset, :reason) || ""}</textarea>
|
||||
</div>
|
||||
|
||||
<%!-- Scope --%>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{t("Scope")}</label>
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<%= for {label, value} <- [{t("Org-wide"), "org"}, {t("Site"), "site"}, {t("Device"), "device"}] do %>
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="scope_type"
|
||||
value={value}
|
||||
checked={@scope_type == value}
|
||||
phx-click="change_scope"
|
||||
phx-value-scope_type={value}
|
||||
class="text-blue-600 focus:ring-blue-500 dark:bg-gray-800 dark:border-white/10"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{label}</span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @scope_type == "site" do %>
|
||||
<select
|
||||
name="maintenance_window[site_id]"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white"
|
||||
>
|
||||
<option value="">{t("Select a site...")}</option>
|
||||
<%= for site <- @sites do %>
|
||||
<option value={site.id} selected={Ecto.Changeset.get_field(@changeset, :site_id) == site.id}>
|
||||
{site.name}
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<% end %>
|
||||
|
||||
<%= if @scope_type == "device" do %>
|
||||
<select
|
||||
name="maintenance_window[device_id]"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white"
|
||||
>
|
||||
<option value="">{t("Select a device...")}</option>
|
||||
<%= for device <- @devices do %>
|
||||
<option value={device.id} selected={Ecto.Changeset.get_field(@changeset, :device_id) == device.id}>
|
||||
{device.name}
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Time Range --%>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="maintenance_window_starts_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("Starts At")} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="maintenance_window[starts_at]"
|
||||
id="maintenance_window_starts_at"
|
||||
value={format_datetime_local(Ecto.Changeset.get_field(@changeset, :starts_at))}
|
||||
required
|
||||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
<%= for {msg, _} <- (@changeset.errors[:starts_at] || []) do %><p class="mt-1 text-sm text-red-600 dark:text-red-400">{msg}</p><% end %>
|
||||
</div>
|
||||
<div>
|
||||
<label for="maintenance_window_ends_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("Ends At")} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="maintenance_window[ends_at]"
|
||||
id="maintenance_window_ends_at"
|
||||
value={format_datetime_local(Ecto.Changeset.get_field(@changeset, :ends_at))}
|
||||
required
|
||||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
<%= for {msg, _} <- (@changeset.errors[:ends_at] || []) do %><p class="mt-1 text-sm text-red-600 dark:text-red-400">{msg}</p><% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Suppress Alerts --%>
|
||||
<div>
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="hidden"
|
||||
name="maintenance_window[suppress_alerts]"
|
||||
value="false"
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="maintenance_window[suppress_alerts]"
|
||||
value="true"
|
||||
checked={Ecto.Changeset.get_field(@changeset, :suppress_alerts) == true}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:bg-gray-800 dark:border-white/10"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{t("Suppress alerts during this window")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<%!-- Recurring --%>
|
||||
<div>
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="hidden"
|
||||
name="maintenance_window[recurring]"
|
||||
value="false"
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="maintenance_window[recurring]"
|
||||
value="true"
|
||||
checked={Ecto.Changeset.get_field(@changeset, :recurring) == true}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:bg-gray-800 dark:border-white/10"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{t("Recurring")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<%!-- Recurrence Rule (shown when recurring) --%>
|
||||
<%= if Ecto.Changeset.get_field(@changeset, :recurring) == true do %>
|
||||
<div>
|
||||
<label for="maintenance_window_recurrence_rule" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("Recurrence Rule")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="maintenance_window[recurrence_rule]"
|
||||
id="maintenance_window_recurrence_rule"
|
||||
value={Ecto.Changeset.get_field(@changeset, :recurrence_rule) || ""}
|
||||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||||
placeholder={t("e.g., FREQ=WEEKLY;BYDAY=SU")}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Submit --%>
|
||||
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
<.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")}
|
||||
</.link>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
{t("Save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
66
lib/towerops_web/live/maintenance_live/index.ex
Normal file
66
lib/towerops_web/live/maintenance_live/index.ex
Normal file
|
|
@ -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
|
||||
118
lib/towerops_web/live/maintenance_live/index.html.heex
Normal file
118
lib/towerops_web/live/maintenance_live/index.html.heex
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="maintenance"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("Maintenance Windows")}</h1>
|
||||
<.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")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%!-- Filter tabs --%>
|
||||
<div class="flex items-center gap-2 mb-4 flex-wrap">
|
||||
<.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>
|
||||
<.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>
|
||||
<.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>
|
||||
<.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")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%= if Enum.empty?(@windows) do %>
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="text-center">
|
||||
<.icon name="hero-wrench-screwdriver" class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">{t("No maintenance windows")}</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("Schedule maintenance to suppress alerts during planned work.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="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/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t("Name")}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t("Scope")}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t("Time Range")}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t("Status")}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t("Created By")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
|
||||
<%= for window <- @windows do %>
|
||||
<tr
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
|
||||
phx-click={JS.navigate(~p"/maintenance/#{window.id}")}
|
||||
>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{window.name}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{scope_label(window)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
{format_datetime(window.starts_at, @timezone)} — {format_datetime(window.ends_at, @timezone)}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<% status = status_for(window) %>
|
||||
<span class={[
|
||||
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
status == :active && "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300",
|
||||
status == :upcoming && "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
status == :past && "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
]}>
|
||||
{status |> to_string() |> String.capitalize()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{if window.created_by, do: window.created_by.email, else: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</Layouts.authenticated>
|
||||
56
lib/towerops_web/live/maintenance_live/show.ex
Normal file
56
lib/towerops_web/live/maintenance_live/show.ex
Normal file
|
|
@ -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
|
||||
122
lib/towerops_web/live/maintenance_live/show.html.heex
Normal file
122
lib/towerops_web/live/maintenance_live/show.html.heex
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="maintenance"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<.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")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<% status = status_for(@window) %>
|
||||
|
||||
<%!-- Active banner --%>
|
||||
<%= if status == :active do %>
|
||||
<div class="mb-6 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<.icon name="hero-wrench-screwdriver" class="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-green-800 dark:text-green-300">{t("Currently Active")}</h3>
|
||||
<p class="text-sm text-green-700 dark:text-green-400">{t("This maintenance window is currently in effect. Alerts are being suppressed.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@window.name}</h1>
|
||||
<span class={[
|
||||
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium mt-2",
|
||||
status == :active && "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300",
|
||||
status == :upcoming && "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
status == :past && "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
]}>
|
||||
{status |> to_string() |> String.capitalize()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<.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")}
|
||||
</.link>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="delete"
|
||||
data-confirm={t("Are you sure you want to delete this maintenance window?")}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-red-100 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50 transition-colors"
|
||||
>
|
||||
<.icon name="hero-trash" class="h-4 w-4" />
|
||||
{t("Delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-white/10">
|
||||
<%!-- Reason --%>
|
||||
<%= if @window.reason do %>
|
||||
<div class="px-6 py-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Reason")}</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap">{@window.reason}</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Scope --%>
|
||||
<div class="px-6 py-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Scope")}</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">{scope_label(@window)}</dd>
|
||||
</div>
|
||||
|
||||
<%!-- Time Range --%>
|
||||
<div class="px-6 py-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Time Range")}</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
{format_datetime(@window.starts_at, @timezone)} — {format_datetime(@window.ends_at, @timezone)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<%!-- Recurring --%>
|
||||
<div class="px-6 py-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Recurring")}</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
<%= if @window.recurring do %>
|
||||
{t("Yes")} — {@window.recurrence_rule || t("No rule specified")}
|
||||
<% else %>
|
||||
{t("No")}
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<%!-- Suppress Alerts --%>
|
||||
<div class="px-6 py-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Suppress Alerts")}</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
<%= if @window.suppress_alerts do %>
|
||||
<span class="inline-flex items-center gap-1 text-green-700 dark:text-green-400">
|
||||
<.icon name="hero-check-circle" class="h-4 w-4" /> {t("Yes")}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||
<.icon name="hero-x-circle" class="h-4 w-4" /> {t("No")}
|
||||
</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<%!-- Created By --%>
|
||||
<div class="px-6 py-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Created By")}</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
{if @window.created_by, do: @window.created_by.email, else: "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
251
lib/towerops_web/live/onboarding_live.ex
Normal file
251
lib/towerops_web/live/onboarding_live.ex
Normal file
|
|
@ -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
|
||||
309
lib/towerops_web/live/onboarding_live.html.heex
Normal file
309
lib/towerops_web/live/onboarding_live.html.heex
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
>
|
||||
<div class="max-w-2xl mx-auto py-8">
|
||||
<%!-- Step indicator --%>
|
||||
<nav class="mb-8">
|
||||
<ol class="flex items-center gap-2">
|
||||
<%= for step <- @steps do %>
|
||||
<li class="flex items-center gap-2">
|
||||
<button
|
||||
phx-click="go_to_step"
|
||||
phx-value-step={step}
|
||||
class={[
|
||||
"flex items-center gap-1.5 text-sm font-medium px-2 py-1 rounded transition",
|
||||
if(step == @step,
|
||||
do: "text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-900/30",
|
||||
else: if(step_index(step) < step_index(@step),
|
||||
do: "text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white",
|
||||
else: "text-gray-400 dark:text-gray-500"
|
||||
)
|
||||
)
|
||||
]}
|
||||
>
|
||||
<span class={[
|
||||
"flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold",
|
||||
if(step == @step,
|
||||
do: "bg-indigo-600 text-white dark:bg-indigo-500",
|
||||
else: if(step_index(step) < step_index(@step),
|
||||
do: "bg-gray-400 dark:bg-gray-500 text-white",
|
||||
else: "bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400"
|
||||
)
|
||||
)
|
||||
]}>
|
||||
<%= if step_index(step) < step_index(@step) do %>
|
||||
<.icon name="hero-check-mini" class="h-3 w-3" />
|
||||
<% else %>
|
||||
{step_index(step) + 1}
|
||||
<% end %>
|
||||
</span>
|
||||
{step_label(step)}
|
||||
</button>
|
||||
<%= if step != List.last(@steps) do %>
|
||||
<.icon name="hero-chevron-right-mini" class="h-4 w-4 text-gray-300 dark:text-gray-600" />
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<%!-- Step content --%>
|
||||
<div class="bg-white dark:bg-white/5 rounded-lg border border-gray-200 dark:border-white/10 p-6">
|
||||
<%= case @step do %>
|
||||
<% :snmp -> %>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t("SNMP Configuration")}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
{t("Set organization-wide SNMP defaults. Can be overridden per-site or per-device.")}
|
||||
</p>
|
||||
|
||||
<.form for={@form} phx-change="validate_snmp" phx-submit="save_snmp">
|
||||
<div class="space-y-4">
|
||||
<.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 %>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="skip"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
{t("Skip")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||
>
|
||||
{t("Save & Continue")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<% :site -> %>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t("Add Your First Site")}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
{t("Sites represent physical locations — tower sites, POPs, data centers, etc.")}
|
||||
</p>
|
||||
|
||||
<.form for={@site_form} phx-change="validate_site" phx-submit="save_site">
|
||||
<div class="space-y-4">
|
||||
<.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" />
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<.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" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="skip"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
{t("Skip")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||
>
|
||||
{t("Create Site & Continue")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<% :billing -> %>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t("Connect a Billing Platform")}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
{t("Optional. Sync subscriber data for outage impact analysis and inventory reconciliation.")}
|
||||
</p>
|
||||
|
||||
<%= if @selected_provider == nil do %>
|
||||
<div class="grid grid-cols-2 gap-3 mb-6">
|
||||
<%= for provider <- @billing_providers do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="select_provider"
|
||||
phx-value-provider={provider.id}
|
||||
class="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-white/10 hover:border-indigo-400 dark:hover:border-indigo-500 hover:bg-gray-50 dark:hover:bg-white/5 transition text-left"
|
||||
>
|
||||
<.icon name={provider.icon} class="h-6 w-6 text-gray-400 dark:text-gray-500" />
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">{provider.name}</span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="mb-4">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="select_provider"
|
||||
phx-value-provider=""
|
||||
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
← {t("Back to providers")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<.form for={@integration_form} phx-change="validate_integration" phx-submit="save_integration">
|
||||
<div class="space-y-4">
|
||||
<%= 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 %>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="skip"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
{t("Skip")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||
>
|
||||
{t("Save & Continue")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
<% end %>
|
||||
|
||||
<%= if @selected_provider == nil do %>
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="skip"
|
||||
class="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
{t("Skip — I'll set this up later")}
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% :agent -> %>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t("Deploy Agent")}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
{t("The TowerOps agent polls your network devices via SNMP. Deploy it on a host with network access to your infrastructure.")}
|
||||
</p>
|
||||
|
||||
<%= if @agent_token do %>
|
||||
<div class="space-y-4">
|
||||
<%= if @agent_token_value do %>
|
||||
<div class="rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3">
|
||||
<p class="text-sm text-amber-800 dark:text-amber-200">
|
||||
<.icon name="hero-exclamation-triangle" class="h-4 w-4 inline -mt-0.5" />
|
||||
{t("Save this token — it won't be shown again.")}
|
||||
</p>
|
||||
<code class="mt-2 block text-xs font-mono bg-amber-100 dark:bg-amber-900/40 rounded p-2 break-all select-all text-amber-900 dark:text-amber-100">
|
||||
{@agent_token_value}
|
||||
</code>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="rounded-md bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 p-3">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("Agent:")} <span class="font-medium text-gray-900 dark:text-white">{@agent_token.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">{t("Docker")}</h3>
|
||||
<pre class="text-xs font-mono bg-gray-900 text-green-400 rounded-lg p-4 overflow-x-auto select-all"><code>docker run -d \
|
||||
--name towerops-agent \
|
||||
--restart unless-stopped \
|
||||
-e TOWEROPS_TOKEN=<%= if @agent_token_value, do: @agent_token_value, else: "<your-token>" %> \
|
||||
-e TOWEROPS_SERVER=<%= ToweropsWeb.Endpoint.url() %> \
|
||||
--network host \
|
||||
ghcr.io/towerops/agent:latest</code></pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">{t("Systemd")}</h3>
|
||||
<pre class="text-xs font-mono bg-gray-900 text-green-400 rounded-lg p-4 overflow-x-auto select-all"><code>curl -sSL https://install.towerops.net | \
|
||||
TOWEROPS_TOKEN=<%= if @agent_token_value, do: @agent_token_value, else: "<your-token>" %> \
|
||||
TOWEROPS_SERVER=<%= ToweropsWeb.Endpoint.url() %> \
|
||||
bash</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("Failed to create agent token. You can create one later in Settings → Agents.")}
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center justify-end pt-6 border-t border-gray-200 dark:border-white/10 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="finish"
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||
>
|
||||
{t("Done — Go to Dashboard")}
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
|
|
@ -425,7 +425,7 @@
|
|||
>
|
||||
<div class="absolute inset-0 flex items-center justify-center" data-loading>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 border-t-blue-500">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 dark:border-gray-700 border-t-blue-500">
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{t("Loading chart...")}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@
|
|||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{@trace.access_point.name}
|
||||
</div>
|
||||
<div :if={@trace.access_point.model} class="text-xs text-gray-500">
|
||||
<div :if={@trace.access_point.model} class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{@trace.access_point.model}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -362,7 +362,7 @@
|
|||
>
|
||||
<span class="text-gray-700 dark:text-gray-300 truncate">{ap.name}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-500">{ap.subscriber_count || 0} subs</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{ap.subscriber_count || 0} subs</span>
|
||||
<span
|
||||
:if={ap.qoe_score}
|
||||
class={["font-medium", score_color(ap.qoe_score)]}
|
||||
|
|
@ -446,7 +446,7 @@
|
|||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{Map.get(change, :changed_sections, []) |> Enum.join(", ")}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{format_relative_time(change.changed_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -511,7 +511,7 @@
|
|||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase text-gray-500">
|
||||
<tr class="text-xs uppercase text-gray-500 dark:text-gray-400">
|
||||
<th>Name</th>
|
||||
<th>IP</th>
|
||||
<th>Model</th>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue