towerops/lib/towerops_web/live/dashboard_live.html.heex
Graham McIntire 20f5f48372
feat: command center dashboard with unified insights and contextual enrichment
Transform the dashboard into a single-pane-of-glass command center that
blends operational metrics with business context from Gaiia subscriber data.

- Extend insight schema with multi-source support (snmp, gaiia, system)
- Add Oban workers for automated insight generation (device health, system, gaiia)
- New Dashboard context with health score computation and site summaries
- Rewrite dashboard LiveView with health overview, alert/insight feeds, site grid
- Add source filter pills for insights with URL state management
- Add metrics bar to site detail pages (device count, alerts, subscribers, MRR)
- Add subscriber/MRR context to alert list site links
- Add subscriber badges to device list site headers
- Real-time PubSub updates for alerts on dashboard
2026-02-13 14:52:49 -06:00

441 lines
20 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="dashboard"
>
<.header>
Command Center
<:subtitle>Welcome to {@current_scope.organization.name}</:subtitle>
</.header>
<%= if @device_count == 0 do %>
<div class="mt-8 rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-12 text-center dark:border-white/20 dark:bg-gray-800/50">
<.icon
name="hero-light-bulb"
class="mx-auto h-16 w-16 text-blue-500 dark:text-blue-400"
/>
<h3 class="mt-6 text-xl font-semibold text-gray-900 dark:text-white">
Let's get started!
</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Follow these steps to set up your network monitoring:
</p>
<div class="mt-8 max-w-2xl mx-auto">
<div class="space-y-4 text-left">
<%= if @current_scope.organization.use_sites do %>
<div class="flex gap-4 items-start">
<div class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-600 font-semibold dark:bg-blue-900 dark:text-blue-300">
1
</div>
<div>
<h4 class="font-medium text-gray-900 dark:text-white">Create a Site</h4>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Sites represent your physical locations (offices, data centers, towers, etc.)
</p>
</div>
</div>
<div class="flex gap-4 items-start">
<div class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-600 font-semibold dark:bg-gray-700 dark:text-gray-400">
2
</div>
<div>
<h4 class="font-medium text-gray-900 dark:text-white">Add Devices</h4>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Add network devices (routers, switches, servers) to your sites
</p>
</div>
</div>
<% else %>
<div class="flex gap-4 items-start">
<div class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-600 font-semibold dark:bg-blue-900 dark:text-blue-300">
1
</div>
<div>
<h4 class="font-medium text-gray-900 dark:text-white">Add Devices</h4>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Add network devices (routers, switches, servers) to monitor
</p>
</div>
</div>
<% end %>
<div class="flex gap-4 items-start">
<div class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-600 font-semibold dark:bg-gray-700 dark:text-gray-400">
{if @current_scope.organization.use_sites, do: "3", else: "2"}
</div>
<div>
<h4 class="font-medium text-gray-900 dark:text-white">
Monitor & Receive Alerts
</h4>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Towerops will automatically monitor your devices and alert you of issues
</p>
</div>
</div>
</div>
</div>
<div class="mt-8">
<%= if @current_scope.organization.use_sites do %>
<.button navigate={~p"/sites/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> Create Your First Site
</.button>
<% else %>
<.button navigate={~p"/devices/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> Add Your First Device
</.button>
<% end %>
</div>
</div>
<% else %>
<%!-- Section A: Health Overview --%>
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<%!-- Health Score --%>
<div class={[
"rounded-lg border p-4 shadow-sm",
health_score_bg(@summary.health_score)
]}>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Health Score</h3>
<p class={["mt-1 text-3xl font-bold", health_score_color(@summary.health_score)]}>
{@summary.health_score}
</p>
</div>
<%!-- Active Alerts --%>
<.link
navigate={~p"/alerts"}
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Active Alerts</h3>
<p class={[
"mt-1 text-3xl font-bold",
(@total_alert_count > 0 && "text-red-600 dark:text-red-500") ||
"text-gray-900 dark:text-white"
]}>
{@total_alert_count}
</p>
</.link>
<%!-- Devices --%>
<.link
navigate={~p"/devices"}
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Devices</h3>
<p class="mt-1 text-3xl font-bold text-gray-900 dark:text-white">{@device_count}</p>
<div class="mt-2 flex items-center gap-3 text-xs">
<span class="flex items-center gap-1 text-green-600 dark:text-green-400">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
{@device_up} Up
</span>
<span class="flex items-center gap-1 text-red-600 dark:text-red-400">
<span class="h-2 w-2 rounded-full bg-red-500"></span>
{@device_down} Down
</span>
<span class="flex items-center gap-1 text-gray-500 dark:text-gray-400">
<span class="h-2 w-2 rounded-full bg-gray-400"></span>
{@device_unknown} Unknown
</span>
</div>
</.link>
<%!-- Subscribers (only when Gaiia data exists) --%>
<%= if @has_subscribers do %>
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-800/50">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Subscribers</h3>
<p class="mt-1 text-3xl font-bold text-gray-900 dark:text-white">
{format_number(@summary.subscribers.total)}
</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{format_mrr(@summary.subscribers.total_mrr)}/mo MRR
</p>
</div>
<% end %>
<%!-- Sites (if enabled) --%>
<%= if @current_scope.organization.use_sites do %>
<.link
navigate={~p"/sites"}
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Sites</h3>
<p class="mt-1 text-3xl font-bold text-gray-900 dark:text-white">{@sites_count}</p>
</.link>
<% end %>
<%!-- Insights --%>
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-800/50">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Insights</h3>
<div class="mt-1 flex items-baseline gap-2">
<p class="text-3xl font-bold text-gray-900 dark:text-white">
{@summary.insights.critical + @summary.insights.warning + @summary.insights.info}
</p>
</div>
<div class="mt-2 flex items-center gap-2 text-xs">
<%= if @summary.insights.critical > 0 do %>
<span class="inline-flex items-center rounded-full bg-red-100 px-1.5 py-0.5 text-red-800 dark:bg-red-900/30 dark:text-red-400">
{@summary.insights.critical} critical
</span>
<% end %>
<%= if @summary.insights.warning > 0 do %>
<span class="inline-flex items-center rounded-full bg-yellow-100 px-1.5 py-0.5 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
{@summary.insights.warning} warning
</span>
<% end %>
<%= if @summary.insights.info > 0 do %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-1.5 py-0.5 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
{@summary.insights.info} info
</span>
<% end %>
</div>
</div>
</div>
<%!-- Section B+C: Two-column layout --%>
<div class="mt-8 grid gap-8 lg:grid-cols-5">
<%!-- Left: Alert Feed (3 cols) --%>
<div class="lg:col-span-3">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Active Alerts</h2>
<.link
navigate={~p"/alerts"}
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
>
View All →
</.link>
</div>
<%= if @active_alerts == [] do %>
<div class="rounded-lg border border-gray-200 bg-white p-8 text-center dark:border-white/10 dark:bg-gray-800/50">
<.icon name="hero-check-circle" class="mx-auto h-10 w-10 text-green-500" />
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No active alerts</p>
</div>
<% else %>
<div class="space-y-3">
<div
:for={alert <- @active_alerts}
id={"alert-#{alert.id}"}
class={[
"rounded-lg border p-4 shadow-sm",
"border-l-4 border-red-500 bg-red-50 dark:bg-red-950/30 dark:border-red-700"
]}
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900 dark:text-red-200">
<.icon name="hero-exclamation-triangle" class="h-3.5 w-3.5" /> Device Down
</span>
<%= if alert.acknowledged_at do %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Acknowledged
</span>
<% end %>
</div>
<h3 class="mt-1.5 font-semibold text-gray-900 dark:text-white">
<.link
navigate={~p"/devices/#{alert.device.id}"}
class="hover:text-blue-600 hover:underline dark:hover:text-blue-400"
>
{alert.device.name}
</.link>
</h3>
<p class="mt-0.5 text-sm text-gray-700 dark:text-gray-300">{alert.message}</p>
<%!-- Gaiia impact badges --%>
<%= if alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] && alert.gaiia_impact["total_subscribers"] > 0 do %>
<div class="mt-2 flex items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-amber-400/20">
<.icon name="hero-users" class="h-3 w-3" />
{alert.gaiia_impact["total_subscribers"]} affected
</span>
<%= if alert.gaiia_impact["total_mrr"] && alert.gaiia_impact["total_mrr"] != "0" do %>
<span class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-amber-400/20">
<.icon name="hero-currency-dollar" class="h-3 w-3" />
${alert.gaiia_impact["total_mrr"]}/mo
</span>
<% end %>
</div>
<% end %>
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<.timestamp datetime={alert.triggered_at} />
<span :if={alert.device.site} class="ml-2">
at
<.link
navigate={~p"/sites/#{alert.device.site.id}"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
{alert.device.site.name}
</.link>
</span>
</div>
</div>
<div class="flex-shrink-0">
<%= if is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
<button
type="button"
phx-click="acknowledge_alert"
phx-value-id={alert.id}
class="rounded-md bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:ring-gray-600 dark:hover:bg-gray-700"
>
Acknowledge
</button>
<% end %>
</div>
</div>
</div>
</div>
<%= if @total_alert_count > 20 do %>
<div class="mt-3 text-center text-sm text-gray-500 dark:text-gray-400">
+ {@total_alert_count - 20} more alerts.
<.link
navigate={~p"/alerts"}
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
View All
</.link>
</div>
<% end %>
<% end %>
</div>
<%!-- Right: Insights Feed (2 cols) --%>
<div class="lg:col-span-2">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Insights</h2>
</div>
<%!-- Source filter pills --%>
<div class="mb-4 flex flex-wrap items-center gap-2">
<.link
patch={~p"/dashboard"}
class={[
"rounded-md px-2.5 py-1 text-xs font-medium",
if(is_nil(@insight_source),
do: "bg-gray-200 text-gray-800 dark:bg-white/20 dark:text-white",
else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10"
)
]}
>
All
</.link>
<.link
:for={source <- ~w(preseem snmp gaiia system)}
patch={~p"/dashboard?insight_source=#{source}"}
class={[
"rounded-md px-2.5 py-1 text-xs font-medium",
if(@insight_source == source,
do: "bg-gray-200 text-gray-800 dark:bg-white/20 dark:text-white",
else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10"
)
]}
>
{source}
</.link>
</div>
<%= if @insights == [] do %>
<div class="rounded-lg border border-gray-200 bg-white p-8 text-center dark:border-white/10 dark:bg-gray-800/50">
<.icon
name="hero-light-bulb"
class="mx-auto h-10 w-10 text-gray-400 dark:text-gray-500"
/>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No active insights</p>
</div>
<% else %>
<div class="space-y-3">
<div
:for={insight <- @insights}
id={"insight-#{insight.id}"}
class="rounded-lg border border-gray-200 bg-white p-3 dark:border-white/10 dark:bg-gray-800/50"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5 flex-wrap">
<span class={[
"inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium",
urgency_classes(insight.urgency)
]}>
{insight.urgency}
</span>
<span class={[
"inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium",
source_classes(insight.source)
]}>
{insight.source}
</span>
</div>
<h4 class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
{insight.title}
</h4>
<%= if insight.description do %>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{insight.description}
</p>
<% end %>
</div>
<button
type="button"
id={"dismiss-insight-#{insight.id}"}
phx-click="dismiss_insight"
phx-value-id={insight.id}
class="flex-shrink-0 rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-white/10 dark:hover:text-gray-300"
title="Dismiss"
>
<.icon name="hero-x-mark" class="h-4 w-4" />
</button>
</div>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Section D: Site Health Grid --%>
<%= if @current_scope.organization.use_sites && @site_summaries != [] do %>
<div class="mt-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Site Health</h2>
<.link
navigate={~p"/sites"}
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
>
View All Sites →
</.link>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<.link
:for={site <- @site_summaries}
navigate={~p"/sites/#{site.site_id}"}
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
>
<h3 class="font-semibold text-gray-900 dark:text-white">{site.name}</h3>
<div class="mt-2 flex items-center gap-3 text-sm">
<span class="text-gray-600 dark:text-gray-400">
{site.device_count} devices
</span>
<%= if site.devices_down > 0 do %>
<span class="flex items-center gap-1 text-red-600 dark:text-red-400">
<span class="h-2 w-2 rounded-full bg-red-500"></span>
{site.devices_down} down
</span>
<% end %>
</div>
<%= if site.subscribers do %>
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1">
<.icon name="hero-users" class="h-3.5 w-3.5" />
{site.subscribers} subscribers
</span>
<%= if site.mrr do %>
<span>{format_mrr(site.mrr)}/mo</span>
<% end %>
</div>
<% end %>
</.link>
</div>
</div>
<% end %>
<% end %>
</Layouts.authenticated>