Add dashboard setup checklist + onboarding progress

- Setup Progress bar on dashboard when devices exist but setup incomplete
- Tracks: devices added, billing connected, alert routing configured,
  on-call schedule created
- Each step links to the relevant setup page
- Dismissible with X button
- Progress bar shows visual completion percentage
- Optional steps labeled (billing, on-call)
This commit is contained in:
Graham McIntire 2026-03-13 15:37:48 -05:00
parent 9bae7a886a
commit 3a10682d8c
2 changed files with 83 additions and 0 deletions

View file

@ -150,10 +150,35 @@ defmodule ToweropsWeb.DashboardLive do
|> assign(:uptime_percentage, uptime_percentage)
|> assign(:recent_activity, recent_activity)
|> assign(:favicon_status, favicon_status(Map.get(status_counts, :down, 0), length(active_alerts)))
|> assign(:setup_checklist, build_setup_checklist(organization, device_count))
|> load_insights(organization_id)
|> load_recent_config_changes(organization_id)
end
defp build_setup_checklist(organization, device_count) do
integrations = Towerops.Integrations.list_integrations(organization.id)
has_billing = Enum.any?(integrations, fn i -> i.provider in ~w(gaiia sonar splynx visp) and i.enabled end)
has_alert_routing = organization.alert_routing != "builtin" or has_escalation_policy?(organization)
schedules = Towerops.OnCall.list_schedules(organization.id)
steps = [
%{key: :devices, label: "Add devices to monitor", done: device_count > 0, link: ~p"/devices/new"},
%{key: :billing, label: "Connect billing platform", done: has_billing, link: ~p"/orgs/#{organization.slug}/settings?tab=integrations", optional: true},
%{key: :alerting, label: "Configure alert routing", done: has_alert_routing, link: ~p"/orgs/#{organization.slug}/settings?tab=general", optional: false},
%{key: :oncall, label: "Create on-call schedule", done: length(schedules) > 0, link: ~p"/schedules/new", optional: true}
]
completed = Enum.count(steps, & &1.done)
%{steps: steps, completed: completed, total: length(steps), all_done: completed == length(steps)}
end
defp has_escalation_policy?(organization) do
case Towerops.OnCall.list_escalation_policies(organization.id) do
[] -> false
_ -> true
end
end
defp load_recent_config_changes(socket, organization_id) do
alias Towerops.ConfigChanges

View file

@ -108,6 +108,64 @@
</div>
<% end %>
<%!-- Setup Checklist — shown when devices exist but setup isn't complete --%>
<%= if assigns[:setup_checklist] && !@setup_checklist.all_done && @device_count > 0 do %>
<div id="setup-checklist" class="mt-4 rounded-lg border border-blue-200 bg-blue-50/50 dark:border-blue-800/50 dark:bg-blue-900/10 p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<.icon name="hero-rocket-launch" class="h-5 w-5 text-blue-600 dark:text-blue-400" />
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Setup Progress")}
</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">
{@setup_checklist.completed}/{@setup_checklist.total}
</span>
</div>
<button
phx-click={JS.hide(to: "#setup-checklist")}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title={t("Dismiss")}
>
<.icon name="hero-x-mark" class="h-4 w-4" />
</button>
</div>
<%!-- Progress bar --%>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 mb-3">
<div
class="bg-blue-600 dark:bg-blue-400 h-1.5 rounded-full transition-all duration-500"
style={"width: #{@setup_checklist.completed / @setup_checklist.total * 100}%"}
>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<%= for step <- @setup_checklist.steps do %>
<.link
navigate={step.link}
class={[
"flex items-center gap-2.5 rounded-md px-3 py-2 text-sm transition-colors",
if(step.done,
do: "text-gray-400 dark:text-gray-500",
else: "text-gray-700 dark:text-gray-300 hover:bg-blue-100/50 dark:hover:bg-blue-900/20"
)
]}
>
<%= if step.done do %>
<.icon name="hero-check-circle-solid" class="h-5 w-5 text-green-500 shrink-0" />
<% else %>
<div class="h-5 w-5 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
<% end %>
<span class={if step.done, do: "line-through"}>
{step.label}
</span>
<%= if step[:optional] do %>
<span class="text-xs text-gray-400 dark:text-gray-500">optional</span>
<% end %>
</.link>
<% end %>
</div>
</div>
<% end %>
<%= if assigns[:summary] && @device_count > 0 do %>
<%!-- ═══════════════════════════════════════════════
CONDENSED STATUS BAR — single dense line