- Escape HEEx template braces in GraphQL/API docs with raw(~S[...]) - Fix test assertions for updated marketing copy and UI text - Extract helper functions in GraphQL resolvers to reduce nesting depth - Create shared ErrorHelpers module for API controllers - Fix ETS race condition in brute force whitelist cache for async tests - Fix property test generators to use ASCII instead of printable unicode - Add alert_severity helper to site_live/show - Update accounts fixtures for explicit user confirmation
351 lines
14 KiB
Text
351 lines
14 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="sites"
|
|
>
|
|
<.breadcrumb items={[
|
|
%{label: "Dashboard", navigate: ~p"/dashboard"},
|
|
%{label: "Sites", navigate: ~p"/sites"},
|
|
%{label: @site.name}
|
|
]} />
|
|
<.header>
|
|
{@page_title}
|
|
<:subtitle>{@site.location}</:subtitle>
|
|
<:actions>
|
|
<%= if @device != [] do %>
|
|
<.button
|
|
type="button"
|
|
phx-click="force_rediscover_all"
|
|
data-confirm="This will trigger SNMP discovery for all devices at this site. Continue?"
|
|
>
|
|
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> Force Rediscover All
|
|
</.button>
|
|
<% end %>
|
|
<.button navigate={~p"/sites/#{@site.id}/edit"}>
|
|
<.icon name="hero-pencil" class="h-4 w-4" /> Edit
|
|
</.button>
|
|
</:actions>
|
|
</.header>
|
|
|
|
<%!-- Summary Stat Cards --%>
|
|
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
|
<%!-- Subscribers --%>
|
|
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
|
<div class="card-body p-4">
|
|
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
<.icon name="hero-users" class="h-4 w-4" /> Subscribers
|
|
</div>
|
|
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
|
{format_number(@site_summary.subscribers || 0)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<%!-- MRR --%>
|
|
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
|
<div class="card-body p-4">
|
|
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
<.icon name="hero-currency-dollar" class="h-4 w-4" /> Monthly Revenue
|
|
</div>
|
|
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
|
{format_mrr(@site_summary.mrr)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<%!-- Device Count --%>
|
|
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
|
<div class="card-body p-4">
|
|
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
<.icon name="hero-server" class="h-4 w-4" /> Devices
|
|
</div>
|
|
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
|
{format_number(@site_summary.device_count)}
|
|
<%= if @site_summary.devices_down > 0 do %>
|
|
<span class="text-sm font-medium text-red-600 dark:text-red-400">
|
|
({@site_summary.devices_down} down)
|
|
</span>
|
|
<% end %>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<%!-- Active Alerts --%>
|
|
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
|
<div class="card-body p-4">
|
|
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
<.icon name="hero-bell-alert" class="h-4 w-4" /> Active Alerts
|
|
</div>
|
|
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
|
{length(@active_alerts)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- QoE Summary Card --%>
|
|
<%= if @qoe_summary do %>
|
|
<div class={["mt-6 rounded-lg border p-4", qoe_bg(@qoe_summary[:score])]}>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
|
Preseem QoE Score
|
|
</h3>
|
|
<p class={["mt-1 text-3xl font-bold", qoe_color(@qoe_summary[:score])]}>
|
|
{if @qoe_summary[:score], do: Float.round(@qoe_summary[:score] / 1, 1), else: "—"}
|
|
</p>
|
|
</div>
|
|
<%= if @qoe_summary[:capacity_score] do %>
|
|
<div class="text-right">
|
|
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Capacity</p>
|
|
<div class="mt-1 w-32">
|
|
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
|
<div
|
|
class={["h-2 rounded-full", capacity_bar_color(@qoe_summary[:capacity_score])]}
|
|
style={"width: #{min(@qoe_summary[:capacity_score], 100)}%"}
|
|
/>
|
|
</div>
|
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
|
{@qoe_summary[:capacity_score]}%
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
|
<%!-- Left Column --%>
|
|
<div class="space-y-6">
|
|
<%!-- Site Details --%>
|
|
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
|
<div class="card-body p-4">
|
|
<h3 class="card-title text-base">Site Details</h3>
|
|
<dl class="mt-2 space-y-3">
|
|
<%= if @site.parent_site do %>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Parent Site</dt>
|
|
<dd class="mt-0.5">
|
|
<.link
|
|
navigate={~p"/sites/#{@site.parent_site.id}"}
|
|
class="link link-primary text-sm"
|
|
>
|
|
{@site.parent_site.name}
|
|
</.link>
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
<%= if @site.location do %>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Location</dt>
|
|
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">{@site.location}</dd>
|
|
</div>
|
|
<% end %>
|
|
<%= if @site.description do %>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Description</dt>
|
|
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">{@site.description}</dd>
|
|
</div>
|
|
<% end %>
|
|
</dl>
|
|
<%= if @site.child_sites != [] do %>
|
|
<div class="mt-4 border-t border-base-300 pt-3">
|
|
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">Child Sites</h4>
|
|
<ul class="mt-2 space-y-1">
|
|
<li :for={child <- @site.child_sites}>
|
|
<.link navigate={~p"/sites/#{child.id}"} class="link link-primary text-sm">
|
|
{child.name}
|
|
</.link>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Active Alerts Panel --%>
|
|
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
|
<div class="card-body p-4">
|
|
<h3 class="card-title text-base">
|
|
<.icon name="hero-bell-alert" class="h-5 w-5" /> Active Alerts
|
|
<%= if @active_alerts != [] do %>
|
|
<span class="badge badge-error badge-sm">{length(@active_alerts)}</span>
|
|
<% end %>
|
|
</h3>
|
|
<%= if @active_alerts == [] do %>
|
|
<div class="mt-2 text-center py-6 text-gray-500 dark:text-gray-400">
|
|
<.icon name="hero-check-circle" class="mx-auto h-8 w-8 text-green-500" />
|
|
<p class="mt-2 text-sm">No active alerts</p>
|
|
</div>
|
|
<% else %>
|
|
<div class="mt-2 space-y-2">
|
|
<div
|
|
:for={alert <- @active_alerts}
|
|
class="flex items-start gap-3 rounded-lg border border-base-300 p-3"
|
|
>
|
|
<.icon
|
|
name={insight_urgency_icon(alert_severity(alert.alert_type))}
|
|
class={[
|
|
"h-5 w-5 mt-0.5 shrink-0",
|
|
insight_urgency_class(alert_severity(alert.alert_type))
|
|
]}
|
|
/>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{alert.message}</p>
|
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
|
{time_ago(alert.triggered_at)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Recent Config Changes Panel --%>
|
|
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
|
<div class="card-body p-4">
|
|
<h3 class="card-title text-base">
|
|
<.icon name="hero-document-text" class="h-5 w-5" /> Recent Config Changes
|
|
</h3>
|
|
<%= if @config_changes == [] do %>
|
|
<div class="mt-2 text-center py-6 text-gray-500 dark:text-gray-400">
|
|
<p class="text-sm">No config changes in the last 30 days</p>
|
|
</div>
|
|
<% else %>
|
|
<div class="mt-2 space-y-2">
|
|
<div
|
|
:for={change <- @config_changes}
|
|
class="flex items-center justify-between rounded-lg border border-base-300 p-3"
|
|
>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
|
{change.device_name || "Unknown device"}
|
|
</p>
|
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
|
{time_ago(change.detected_at)}
|
|
</p>
|
|
</div>
|
|
<span class={["badge badge-sm", change_size_color(change.diff_size || 0)]}>
|
|
{change_size_label(change.diff_size || 0)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Right Column --%>
|
|
<div class="space-y-6">
|
|
<%!-- Device Health Grid --%>
|
|
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
|
<div class="card-body p-4">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="card-title text-base">
|
|
<.icon name="hero-server" class="h-5 w-5" /> Device Health
|
|
</h3>
|
|
<.button
|
|
navigate={~p"/devices/new?site_id=#{@site.id}"}
|
|
variant="primary"
|
|
class="btn-sm"
|
|
>
|
|
<.icon name="hero-plus" class="h-4 w-4" /> Add
|
|
</.button>
|
|
</div>
|
|
<%= if @device == [] do %>
|
|
<div class="mt-2 rounded-lg border-2 border-dashed border-base-300 p-8 text-center">
|
|
<.icon
|
|
name="hero-server"
|
|
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"
|
|
/>
|
|
<h4 class="mt-4 text-base font-semibold text-gray-900 dark:text-white">
|
|
Add your first device
|
|
</h4>
|
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
Start monitoring by adding network devices to this site.
|
|
</p>
|
|
<div class="mt-4">
|
|
<.button navigate={~p"/devices/new?site_id=#{@site.id}"} variant="primary">
|
|
<.icon name="hero-plus" class="h-5 w-5" /> Add Device
|
|
</.button>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<div class="mt-2 grid gap-2 sm:grid-cols-2">
|
|
<.link
|
|
:for={eq <- @device}
|
|
navigate={~p"/devices/#{eq.id}"}
|
|
class="flex items-center gap-3 rounded-lg border border-base-300 p-3 hover:bg-base-200 transition-colors cursor-pointer"
|
|
>
|
|
<span class={["h-3 w-3 shrink-0 rounded-full", status_dot_class(eq.status)]} />
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
|
{eq.name}
|
|
</p>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">{eq.ip_address}</p>
|
|
</div>
|
|
<span class="text-xs font-mono text-gray-500 dark:text-gray-400">
|
|
{format_response_time(@response_times[eq.id])}
|
|
</span>
|
|
</.link>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Insights Panel --%>
|
|
<%= if @insights != [] do %>
|
|
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
|
<div class="card-body p-4">
|
|
<h3 class="card-title text-base">
|
|
<.icon name="hero-light-bulb" class="h-5 w-5" /> Preseem Insights
|
|
</h3>
|
|
<div class="mt-2 space-y-2">
|
|
<div
|
|
:for={insight <- @insights}
|
|
class="flex items-start gap-3 rounded-lg border border-base-300 p-3"
|
|
>
|
|
<.icon
|
|
name={insight_urgency_icon(insight.urgency)}
|
|
class={["h-5 w-5 mt-0.5 shrink-0", insight_urgency_class(insight.urgency)]}
|
|
/>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{insight.title}</p>
|
|
<%= 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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Latency Chart --%>
|
|
<%= if @latency_chart_data do %>
|
|
<div class="mt-6">
|
|
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
|
<div class="card-body p-4">
|
|
<h3 class="card-title text-base">
|
|
<.icon name="hero-chart-bar" class="h-5 w-5" /> Site Latency — Last 24 Hours
|
|
</h3>
|
|
<div class="mt-2">
|
|
<div
|
|
id="site-latency-chart"
|
|
phx-hook="SensorChart"
|
|
data-chart={@latency_chart_data}
|
|
data-unit="ms"
|
|
data-auto-scale="true"
|
|
style="height: 400px;"
|
|
>
|
|
<canvas></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</Layouts.authenticated>
|