Add onboarding flow for new organizations

This commit is contained in:
Graham McIntire 2026-02-16 10:14:32 -06:00
parent 5b786aac08
commit 2735c1a032
23 changed files with 1283 additions and 19 deletions

View file

@ -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">

View file

@ -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>

View file

@ -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">

View file

@ -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 -->

View file

@ -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 -->

View file

@ -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>

View file

@ -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 %>

View file

@ -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">

View file

@ -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

View file

@ -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>

View 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

View 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>

View 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

View 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>

View 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

View 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>

View 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

View 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>

View file

@ -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>

View file

@ -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"

View file

@ -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>

View file

@ -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

View file

@ -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