ux: improve mobile responsiveness across all pages

- Hide less-critical table columns on small screens (IP, subs, MRR, etc.)
- Add overflow-x-auto wrappers on tables to prevent horizontal scroll
- Fix <.header> component to flex-wrap action buttons on mobile
- Make page action button labels icon-only on small screens
- Add min-w-0 on grid children to prevent viewport overflow
- Right-align device detail dt/dd values consistently
- Fix schedule timeline cards with overflow-x-auto wrappers
This commit is contained in:
Graham McIntire 2026-03-12 16:49:59 -05:00
parent 05d79287bc
commit 19abd5508a
No known key found for this signature in database
11 changed files with 538 additions and 470 deletions

View file

@ -1,3 +1,22 @@
2026-03-12
fix: comprehensive mobile responsive audit and alignment improvements
- Devices index: restructured header for 390px (tabs on second row, icon-only buttons)
- Devices index: hide IP/Type/Last Seen/Response/Subs columns at sm/md/lg breakpoints
- Sites index: overflow-x-auto wrapper, hide QoE/Subs/Location/Coordinates on mobile
- Dashboard: overflow-x-auto on alerts + site health tables, hide secondary columns mobile
- Agents index: overflow-x-auto wrappers around both agent tables
- Device show: restructured sticky header for mobile (name+status / IP+agent row)
- Site show: min-w-0 on grid columns, hide IP/Subs columns in device health table
- Agent show: icon-only action buttons on mobile, fix header component flex-wrap
- Schedule show: flex-wrap header, icon-only buttons, overflow-x-auto on timelines
- core_components.ex header component: flex-wrap + flex gap-2 justify-end for actions
- Device show: all dl/dt/dd value pairs now flex-1 text-right ml-4 for consistent column
- Device show ports table: Speed column now text-right to match In/Out
Files: device_live/index.html.heex, site_live/index.html.heex, dashboard_live.html.heex,
agent_live/index.html.heex, device_live/show.html.heex, site_live/show.html.heex,
agent_live/show.html.heex, schedule_live/show.html.heex,
components/core_components.ex
2026-03-12 2026-03-12
feat: add real-time GraphQL API with time-series queries and subscriptions feat: add real-time GraphQL API with time-series queries and subscriptions
- Added absinthe_phoenix dependency for WebSocket subscription support - Added absinthe_phoenix dependency for WebSocket subscription support

View file

@ -476,8 +476,8 @@ defmodule ToweropsWeb.CoreComponents do
def header(assigns) do def header(assigns) do
~H""" ~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "mb-8"]}> <header class={[@actions != [] && "flex flex-wrap items-start justify-between gap-3", "mb-8"]}>
<div> <div class="min-w-0">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white"> <h1 class="text-3xl font-bold text-gray-900 dark:text-white">
{render_slot(@inner_block)} {render_slot(@inner_block)}
</h1> </h1>
@ -485,7 +485,7 @@ defmodule ToweropsWeb.CoreComponents do
{render_slot(@subtitle)} {render_slot(@subtitle)}
</p> </p>
</div> </div>
<div class="flex-none">{render_slot(@actions)}</div> <div class="flex flex-wrap gap-2 justify-end shrink-0">{render_slot(@actions)}</div>
</header> </header>
""" """
end end

View file

@ -66,7 +66,7 @@
</div> </div>
</div> </div>
<% else %> <% else %>
<div class="mt-6"> <div class="mt-6 overflow-x-auto">
<.table <.table
id="agents-table" id="agents-table"
rows={@streams.agent_tokens} rows={@streams.agent_tokens}
@ -183,108 +183,110 @@
"Application-wide agents that can poll devices for any organization. Only visible and manageable by superadmins." "Application-wide agents that can poll devices for any organization. Only visible and manageable by superadmins."
)} )}
</p> </p>
<.table <div class="overflow-x-auto">
id="cloud-pollers-table" <.table
rows={@streams.cloud_pollers} id="cloud-pollers-table"
row_link={fn {_id, agent} -> ~p"/agents/#{agent.id}" end} rows={@streams.cloud_pollers}
> row_link={fn {_id, agent} -> ~p"/agents/#{agent.id}" end}
<:col :let={{_id, agent}} label={t("Name")}> >
<div class="flex items-center gap-2"> <:col :let={{_id, agent}} label={t("Name")}>
<span class="font-medium">{agent.name}</span> <div class="flex items-center gap-2">
<%= if agent.id == @global_default_cloud_poller_id do %> <span class="font-medium">{agent.name}</span>
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 dark:bg-green-900/30 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300 ring-1 ring-inset ring-green-600/20 dark:ring-green-400/30"> <%= if agent.id == @global_default_cloud_poller_id do %>
<.icon name="hero-check-circle" class="h-3 w-3" /> Default <span class="inline-flex items-center gap-1 rounded-full bg-green-50 dark:bg-green-900/30 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300 ring-1 ring-inset ring-green-600/20 dark:ring-green-400/30">
</span> <.icon name="hero-check-circle" class="h-3 w-3" /> Default
<% end %> </span>
<%= if agent.allow_remote_debug do %> <% end %>
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 dark:bg-amber-900/30 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-600/20 dark:ring-amber-400/30"> <%= if agent.allow_remote_debug do %>
<.icon name="hero-bug-ant" class="h-3 w-3" /> Debug <span class="inline-flex items-center gap-1 rounded-full bg-amber-50 dark:bg-amber-900/30 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-600/20 dark:ring-amber-400/30">
</span> <.icon name="hero-bug-ant" class="h-3 w-3" /> Debug
<% end %> </span>
</div> <% end %>
</:col> </div>
</:col>
<:col :let={{_id, agent}} label={t("Status")}> <:col :let={{_id, agent}} label={t("Status")}>
<% {status, label} = agent_status(agent) %> <% {status, label} = agent_status(agent) %>
<span class={"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium #{status_badge_class(status)}"}> <span class={"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium #{status_badge_class(status)}"}>
<span class={"h-1.5 w-1.5 rounded-full #{status_dot_class(status)}"} /> <span class={"h-1.5 w-1.5 rounded-full #{status_dot_class(status)}"} />
{label} {label}
</span> </span>
</:col> </:col>
<:col :let={{_id, agent}} label={t("Device")}> <:col :let={{_id, agent}} label={t("Device")}>
<% counts = Map.get(@cloud_poller_counts, agent.id, %{direct: 0, total: 0}) %> <% counts = Map.get(@cloud_poller_counts, agent.id, %{direct: 0, total: 0}) %>
<div class="text-sm text-gray-900 dark:text-white"> <div class="text-sm text-gray-900 dark:text-white">
{counts.total} total {counts.total} total
</div> </div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{counts.direct} direct
<%= if counts.total > counts.direct do %>
· {counts.total - counts.direct} inherited
<% end %>
</div>
</:col>
<:col :let={{_id, agent}} label={t("Last Seen")}>
<div class="text-sm text-gray-900 dark:text-white">
<.timestamp datetime={agent.last_seen_at} timezone={@timezone} now={@now} />
</div>
<%= if agent.last_seen_at do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> <div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
<.timestamp datetime={agent.last_seen_at} timezone={@timezone} format="absolute" /> {counts.direct} direct
<%= if counts.total > counts.direct do %>
· {counts.total - counts.direct} inherited
<% end %>
</div> </div>
<% end %> </:col>
<%= if agent.last_ip do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono">
{agent.last_ip}
</div>
<% end %>
</:col>
<:col :let={{_id, agent}} label={t("Version")}> <:col :let={{_id, agent}} label={t("Last Seen")}>
<%= if agent.metadata["version"] do %> <div class="text-sm text-gray-900 dark:text-white">
<div class="text-sm text-gray-600 dark:text-gray-400 font-mono"> <.timestamp datetime={agent.last_seen_at} timezone={@timezone} now={@now} />
{format_agent_version(agent.metadata["version"])}
</div> </div>
<% end %> <%= if agent.last_seen_at do %>
</:col> <div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
<.timestamp datetime={agent.last_seen_at} timezone={@timezone} format="absolute" />
</div>
<% end %>
<%= if agent.last_ip do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono">
{agent.last_ip}
</div>
<% end %>
</:col>
<:col :let={{_id, agent}} label={t("Created")}> <:col :let={{_id, agent}} label={t("Version")}>
<div class="text-sm text-gray-600 dark:text-gray-400"> <%= if agent.metadata["version"] do %>
{ToweropsWeb.TimeHelpers.format_date(agent.inserted_at, @timezone)} <div class="text-sm text-gray-600 dark:text-gray-400 font-mono">
</div> {format_agent_version(agent.metadata["version"])}
</:col> </div>
<% end %>
</:col>
<:action :let={{_id, agent}}> <:col :let={{_id, agent}} label={t("Created")}>
<%= if agent.enabled do %> <div class="text-sm text-gray-600 dark:text-gray-400">
<div class="flex items-center gap-3"> {ToweropsWeb.TimeHelpers.format_date(agent.inserted_at, @timezone)}
<.button
type="button"
phx-click="show_setup"
phx-value-id={agent.id}
variant="secondary"
>
{t("View Setup")}
</.button>
<.button
type="button"
phx-click="delete_agent"
phx-value-id={agent.id}
data-confirm={
t(
"Are you sure you want to delete this cloud poller? This will affect all organizations using it."
)
}
variant="danger"
>
{t("Delete")}
</.button>
</div> </div>
<% else %> </:col>
<span class="text-xs text-gray-500 dark:text-gray-400">Disabled</span>
<% end %> <:action :let={{_id, agent}}>
</:action> <%= if agent.enabled do %>
</.table> <div class="flex items-center gap-3">
<.button
type="button"
phx-click="show_setup"
phx-value-id={agent.id}
variant="secondary"
>
{t("View Setup")}
</.button>
<.button
type="button"
phx-click="delete_agent"
phx-value-id={agent.id}
data-confirm={
t(
"Are you sure you want to delete this cloud poller? This will affect all organizations using it."
)
}
variant="danger"
>
{t("Delete")}
</.button>
</div>
<% else %>
<span class="text-xs text-gray-500 dark:text-gray-400">Disabled</span>
<% end %>
</:action>
</.table>
</div>
</div> </div>
<% end %> <% end %>

View file

@ -17,7 +17,8 @@
} }
class="inline-flex items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-green-600 text-white hover:bg-green-700 focus:ring-green-500 dark:bg-green-500 dark:hover:bg-green-600" class="inline-flex items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-green-600 text-white hover:bg-green-700 focus:ring-green-500 dark:bg-green-500 dark:hover:bg-green-600"
> >
<.icon name="hero-arrow-up-tray" class="h-5 w-5" /> Update <.icon name="hero-arrow-up-tray" class="h-5 w-5" />
<span class="hidden sm:inline">Update</span>
</button> </button>
<button <button
:if={@current_scope.user.is_superuser} :if={@current_scope.user.is_superuser}
@ -29,19 +30,22 @@
} }
class="inline-flex items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-amber-600 text-white hover:bg-amber-700 focus:ring-amber-500 dark:bg-amber-500 dark:hover:bg-amber-600" class="inline-flex items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-amber-600 text-white hover:bg-amber-700 focus:ring-amber-500 dark:bg-amber-500 dark:hover:bg-amber-600"
> >
<.icon name="hero-arrow-path" class="h-5 w-5" /> Restart <.icon name="hero-arrow-path" class="h-5 w-5" />
<span class="hidden sm:inline">Restart</span>
</button> </button>
<.link <.link
navigate={~p"/agents/#{@agent_token.id}/edit"} navigate={~p"/agents/#{@agent_token.id}/edit"}
class="inline-flex items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600" class="inline-flex items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600"
> >
<.icon name="hero-pencil" class="h-5 w-5" /> Edit Agent <.icon name="hero-pencil" class="h-5 w-5" />
<span class="hidden sm:inline">Edit Agent</span>
</.link> </.link>
<.link <.link
navigate={~p"/agents"} navigate={~p"/agents"}
class="text-sm font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white" class="inline-flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
> >
<.icon name="hero-arrow-left" class="h-5 w-5" /> Back to Agents <.icon name="hero-arrow-left" class="h-5 w-5" />
<span class="hidden sm:inline">Back to Agents</span>
</.link> </.link>
</:actions> </:actions>
</.header> </.header>

View file

@ -363,24 +363,24 @@
<%!-- Alerts table --%> <%!-- Alerts table --%>
<%= if @active_alerts != [] do %> <%= if @active_alerts != [] do %>
<div class="overflow-hidden rounded-md border border-gray-200 dark:border-white/10"> <div class="overflow-x-auto rounded-md border border-gray-200 dark:border-white/10">
<table class="min-w-full"> <table class="min-w-full">
<thead class="bg-gray-50/80 dark:bg-gray-800/80"> <thead class="bg-gray-50/80 dark:bg-gray-800/80">
<tr> <tr>
<th class="w-1 p-0"></th> <th class="w-1 p-0"></th>
<th class="px-3 py-1.5 text-left text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500 dark:text-gray-400"> <th class="px-3 py-1.5 text-left text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t("Device")} {t("Device")}
</th> </th>
<th class="px-3 py-1.5 text-left text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500 dark:text-gray-400"> <th class="px-3 py-1.5 text-left text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t("Alert")} {t("Alert")}
</th> </th>
<th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500 dark:text-gray-400"> <th class="hidden sm:table-cell px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t("Impact")} {t("Impact")}
</th> </th>
<th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500 dark:text-gray-400"> <th class="hidden sm:table-cell px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t("When")} {t("When")}
</th> </th>
<th class="px-3 py-1.5 w-12"></th> <th class="hidden sm:table-cell px-3 py-1.5 w-12"></th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-800/50"> <tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-800/50">
@ -407,7 +407,7 @@
]} /> ]} />
</td> </td>
<td class="px-3 py-1.5"> <td class="px-3 py-1.5">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5 flex-wrap">
<.link <.link
navigate={~p"/devices/#{alert.device.id}"} navigate={~p"/devices/#{alert.device.id}"}
class="text-sm font-medium text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400" class="text-sm font-medium text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
@ -416,36 +416,36 @@
</.link> </.link>
<span <span
:if={alert.device.ip_address} :if={alert.device.ip_address}
class="font-mono text-[11px] text-gray-400 dark:text-gray-500 dark:text-gray-400" class="hidden sm:inline font-mono text-[11px] text-gray-400 dark:text-gray-500"
> >
{alert.device.ip_address} {alert.device.ip_address}
</span> </span>
</div> </div>
<span <span
:if={alert.device.site} :if={alert.device.site}
class="text-[10px] text-gray-400 dark:text-gray-500 dark:text-gray-400" class="text-[10px] text-gray-400 dark:text-gray-500"
> >
{alert.device.site.name} {alert.device.site.name}
</span> </span>
</td> </td>
<td class="px-3 py-1.5 text-xs text-gray-600 dark:text-gray-400 max-w-xs truncate"> <td class="px-3 py-1.5 text-xs text-gray-600 dark:text-gray-400 max-w-[120px] sm:max-w-xs truncate">
{alert.message} {alert.message}
</td> </td>
<td class="px-3 py-1.5 text-right whitespace-nowrap"> <td class="hidden sm:table-cell px-3 py-1.5 text-right whitespace-nowrap">
<%= if alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] && alert.gaiia_impact["total_subscribers"] > 0 do %> <%= if alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] && alert.gaiia_impact["total_subscribers"] > 0 do %>
<span class="font-mono text-xs text-amber-600 dark:text-amber-400"> <span class="font-mono text-xs text-amber-600 dark:text-amber-400">
{alert.gaiia_impact["total_subscribers"]}s {alert.gaiia_impact["total_subscribers"]}s
</span> </span>
<% end %> <% end %>
</td> </td>
<td class="px-3 py-1.5 text-right whitespace-nowrap"> <td class="hidden sm:table-cell px-3 py-1.5 text-right whitespace-nowrap">
<.timestamp <.timestamp
datetime={alert.triggered_at} datetime={alert.triggered_at}
timezone={@timezone} timezone={@timezone}
class="font-mono text-[11px] text-gray-400 dark:text-gray-500 dark:text-gray-400" class="font-mono text-[11px] text-gray-400 dark:text-gray-500"
/> />
</td> </td>
<td class="px-3 py-1.5 text-right"> <td class="hidden sm:table-cell px-3 py-1.5 text-right">
<%= if is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %> <%= if is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
<button <button
type="button" type="button"
@ -495,26 +495,26 @@
all sites → all sites →
</.link> </.link>
</div> </div>
<div class="overflow-hidden rounded-md border border-gray-200 dark:border-white/10"> <div class="overflow-x-auto rounded-md border border-gray-200 dark:border-white/10">
<table class="min-w-full"> <table class="min-w-full">
<thead class="bg-gray-50/80 dark:bg-gray-800/80"> <thead class="bg-gray-50/80 dark:bg-gray-800/80">
<tr> <tr>
<th class="px-3 py-1.5 text-left text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500 dark:text-gray-400"> <th class="px-3 py-1.5 text-left text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t("Site")} {t("Site")}
</th> </th>
<th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500 dark:text-gray-400"> <th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t("Devices")} {t("Devices")}
</th> </th>
<th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500 dark:text-gray-400"> <th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t("Down")} {t("Down")}
</th> </th>
<th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500 dark:text-gray-400"> <th class="hidden sm:table-cell px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t("QoE")} {t("QoE")}
</th> </th>
<th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500 dark:text-gray-400"> <th class="hidden sm:table-cell px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t("Subs")} {t("Subs")}
</th> </th>
<th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500 dark:text-gray-400"> <th class="hidden sm:table-cell px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t("Affected")} {t("Affected")}
</th> </th>
</tr> </tr>
@ -552,7 +552,7 @@
<span class="text-gray-300 dark:text-gray-600">0</span> <span class="text-gray-300 dark:text-gray-600">0</span>
<% end %> <% end %>
</td> </td>
<td class="px-3 py-1.5 text-right font-mono text-xs"> <td class="hidden sm:table-cell px-3 py-1.5 text-right font-mono text-xs">
<%= if site.avg_qoe do %> <%= if site.avg_qoe do %>
<span class={ <span class={
cond do cond do
@ -567,14 +567,14 @@
<span class="text-gray-300 dark:text-gray-600">—</span> <span class="text-gray-300 dark:text-gray-600">—</span>
<% end %> <% end %>
</td> </td>
<td class="px-3 py-1.5 text-right font-mono text-xs text-gray-600 dark:text-gray-400"> <td class="hidden sm:table-cell px-3 py-1.5 text-right font-mono text-xs text-gray-600 dark:text-gray-400">
<%= if site.subscribers do %> <%= if site.subscribers do %>
{site.subscribers} {site.subscribers}
<% else %> <% else %>
<span class="text-gray-300 dark:text-gray-600">—</span> <span class="text-gray-300 dark:text-gray-600">—</span>
<% end %> <% end %>
</td> </td>
<td class="px-3 py-1.5 text-right font-mono text-xs"> <td class="hidden sm:table-cell px-3 py-1.5 text-right font-mono text-xs">
<%= if site.subscribers_affected > 0 do %> <%= if site.subscribers_affected > 0 do %>
<span class="text-red-600 dark:text-red-400 font-bold"> <span class="text-red-600 dark:text-red-400 font-bold">
{format_number(site.subscribers_affected)} {format_number(site.subscribers_affected)}

View file

@ -3,115 +3,125 @@
current_scope={@current_scope} current_scope={@current_scope}
active_page="devices" active_page="devices"
> >
<%!-- Header: title + tabs + actions in one row --%> <%!-- Header: title + actions, then tabs below on mobile --%>
<div class="flex items-center justify-between mb-4 border-b border-gray-200 dark:border-white/10 pb-3"> <div class="mb-4 border-b border-gray-200 dark:border-white/10 pb-3">
<div class="flex items-center gap-4"> <div class="flex items-center justify-between gap-2">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@page_title}</h1> <div class="flex items-center gap-2 min-w-0">
<% percent = <h1 class="text-xl font-bold text-gray-900 dark:text-white truncate">{@page_title}</h1>
if @device_quota.limit != :unlimited and @device_quota.limit > 0 do <% percent =
(@device_quota.current / @device_quota.limit * 100) |> trunc() if @device_quota.limit != :unlimited and @device_quota.limit > 0 do
else (@device_quota.current / @device_quota.limit * 100) |> trunc()
0 else
end %> 0
<% badge_class = end %>
cond do <% badge_class =
@current_scope.user.is_superuser -> cond do
"bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400" @current_scope.user.is_superuser ->
"bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
@device_quota.limit != :unlimited and @device_quota.current >= @device_quota.limit -> @device_quota.limit != :unlimited and @device_quota.current >= @device_quota.limit ->
"bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400" "bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400"
percent >= 90 -> percent >= 90 ->
"bg-orange-50 text-orange-700 dark:bg-orange-900/20 dark:text-orange-400" "bg-orange-50 text-orange-700 dark:bg-orange-900/20 dark:text-orange-400"
@device_quota.limit == :unlimited -> @device_quota.limit == :unlimited ->
"bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400" "bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
true -> true ->
"bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400" "bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400"
end %> end %>
<span class={"rounded px-2 py-0.5 text-xs font-medium #{badge_class}"}> <span class={"shrink-0 rounded px-2 py-0.5 text-xs font-medium #{badge_class}"}>
<%= cond do %> <%= cond do %>
<% @current_scope.user.is_superuser -> %> <% @current_scope.user.is_superuser -> %>
{@device_quota.current} devices {@device_quota.current}
<% @device_quota.limit == :unlimited -> %> <% @device_quota.limit == :unlimited -> %>
{@device_quota.current} devices {@device_quota.current}
<% true -> %> <% true -> %>
{@device_quota.current}/{@device_quota.limit} {@device_quota.current}/{@device_quota.limit}
<% end %>
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<%= if @has_devices do %>
<%= if @reorder_mode do %>
<.button
type="button"
phx-click="reset_order"
data-confirm={t("Reset all sites and devices to alphabetical order?")}
>
<.icon name="hero-arrow-path" class="h-4 w-4" />
<span class="hidden sm:inline">{t("Reset")}</span>
</.button>
<.button type="button" phx-click="toggle_reorder_mode" variant="primary">
<.icon name="hero-check" class="h-4 w-4" />
<span class="hidden sm:inline">{t("Done")}</span>
</.button>
<% else %>
<.button
type="button"
phx-click="toggle_reorder_mode"
title="Reorder devices and sites"
>
<.icon name="hero-bars-arrow-up" class="h-4 w-4" />
</.button>
<% end %>
<% end %> <% end %>
</span> <.button :if={!@sites_enabled || @has_sites} navigate={~p"/devices/new"} variant="primary">
<%!-- Inline tab switcher --%> <.icon name="hero-plus" class="h-4 w-4" />
<nav class="flex items-center gap-1 ml-2"> <span class="hidden sm:inline">{t("New Device")}</span>
<.link <span class="sm:hidden">{t("New")}</span>
patch={~p"/devices?tab=existing"} </.button>
class={[ <%= if @has_devices and not @reorder_mode do %>
"px-2.5 py-1 rounded-md text-sm font-medium transition-colors",
if @active_tab == "existing" do
"bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white"
else
"text-gray-500 hover:text-gray-700 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:bg-gray-800/50"
end
]}
>
{t("Existing")}
</.link>
<.link
patch={~p"/devices?tab=discovered"}
class={[
"inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-sm font-medium transition-colors",
if @active_tab == "discovered" do
"bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white"
else
"text-gray-500 hover:text-gray-700 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:bg-gray-800/50"
end
]}
>
{t("Discovered")}
<span
:if={@pagination && @pagination.total_count > 0}
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
{@pagination.total_count}
</span>
</.link>
</nav>
</div>
<div class="flex items-center gap-2">
<%= if @has_devices do %>
<%= if @reorder_mode do %>
<.button <.button
type="button" type="button"
phx-click="reset_order" phx-click="force_rediscover_all"
data-confirm={t("Reset all sites and devices to alphabetical order?")} title="Rediscover all devices"
data-confirm={
t("This will trigger SNMP discovery for all SNMP-enabled devices. Continue?")
}
> >
<.icon name="hero-arrow-path" class="h-4 w-4" /> Reset <.icon name="hero-magnifying-glass" class="h-4 w-4" />
</.button>
<.button type="button" phx-click="toggle_reorder_mode" variant="primary">
<.icon name="hero-check" class="h-4 w-4" /> Done
</.button>
<% else %>
<.button type="button" phx-click="toggle_reorder_mode" title="Reorder devices and sites">
<.icon name="hero-bars-arrow-up" class="h-4 w-4" />
</.button> </.button>
<% end %> <% end %>
<% end %> </div>
<.button :if={!@sites_enabled || @has_sites} navigate={~p"/devices/new"} variant="primary">
<.icon name="hero-plus" class="h-4 w-4" /> {t("New Device")}
</.button>
<%= if @has_devices and not @reorder_mode do %>
<.button
type="button"
phx-click="force_rediscover_all"
title="Rediscover all devices"
data-confirm={
t("This will trigger SNMP discovery for all SNMP-enabled devices. Continue?")
}
>
<.icon name="hero-magnifying-glass" class="h-4 w-4" />
</.button>
<% end %>
</div> </div>
<%!-- Tabs on second line --%>
<nav class="flex items-center gap-1 mt-2">
<.link
patch={~p"/devices?tab=existing"}
class={[
"px-2.5 py-1 rounded-md text-sm font-medium transition-colors",
if @active_tab == "existing" do
"bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white"
else
"text-gray-500 hover:text-gray-700 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:bg-gray-800/50"
end
]}
>
{t("Existing")}
</.link>
<.link
patch={~p"/devices?tab=discovered"}
class={[
"inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-sm font-medium transition-colors",
if @active_tab == "discovered" do
"bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white"
else
"text-gray-500 hover:text-gray-700 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:bg-gray-800/50"
end
]}
>
{t("Discovered")}
<span
:if={@pagination && @pagination.total_count > 0}
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
{@pagination.total_count}
</span>
</.link>
</nav>
</div> </div>
<%= case @active_tab do %> <%= case @active_tab do %>
@ -256,7 +266,7 @@
</th> </th>
<th <th
scope="col" scope="col"
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400" class="hidden sm:table-cell px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
> >
{t("IP Address")} {t("IP Address")}
</th> </th>
@ -268,25 +278,25 @@
</th> </th>
<th <th
scope="col" scope="col"
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400" class="hidden md:table-cell px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
> >
{t("Type")} {t("Type")}
</th> </th>
<th <th
scope="col" scope="col"
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400" class="hidden md:table-cell px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
> >
{t("Last Seen")} {t("Last Seen")}
</th> </th>
<th <th
scope="col" scope="col"
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400" class="hidden lg:table-cell px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
> >
{t("Response")} {t("Response")}
</th> </th>
<th <th
scope="col" scope="col"
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400" class="hidden lg:table-cell px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
> >
{t("Subs")} {t("Subs")}
</th> </th>
@ -414,7 +424,7 @@
{row.device.name} {row.device.name}
</.link> </.link>
</td> </td>
<td class="p-0 whitespace-nowrap"> <td class="hidden sm:table-cell p-0 whitespace-nowrap">
<.link <.link
navigate={~p"/devices/#{row.device.id}"} navigate={~p"/devices/#{row.device.id}"}
class={[ class={[
@ -452,7 +462,7 @@
</span> </span>
</.link> </.link>
</td> </td>
<td class="p-0 whitespace-nowrap"> <td class="hidden md:table-cell p-0 whitespace-nowrap">
<.link <.link
navigate={~p"/devices/#{row.device.id}"} navigate={~p"/devices/#{row.device.id}"}
class={[ class={[
@ -471,7 +481,7 @@
</span> </span>
</.link> </.link>
</td> </td>
<td class="p-0 whitespace-nowrap"> <td class="hidden md:table-cell p-0 whitespace-nowrap">
<.link <.link
navigate={~p"/devices/#{row.device.id}"} navigate={~p"/devices/#{row.device.id}"}
class={[ class={[
@ -483,7 +493,7 @@
<span class={"text-xs font-mono #{color}"}>{text}</span> <span class={"text-xs font-mono #{color}"}>{text}</span>
</.link> </.link>
</td> </td>
<td class="p-0 whitespace-nowrap"> <td class="hidden lg:table-cell p-0 whitespace-nowrap">
<.link <.link
navigate={~p"/devices/#{row.device.id}"} navigate={~p"/devices/#{row.device.id}"}
class={[ class={[
@ -507,7 +517,7 @@
</div> </div>
</.link> </.link>
</td> </td>
<td class="p-0 whitespace-nowrap"> <td class="hidden lg:table-cell p-0 whitespace-nowrap">
<.link <.link
navigate={~p"/devices/#{row.device.id}"} navigate={~p"/devices/#{row.device.id}"}
class={[ class={[

View file

@ -17,86 +17,88 @@
} /> } />
<div class="sticky top-0 z-30 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 py-3 bg-gray-50/95 dark:bg-gray-950/95 backdrop-blur-sm border-b border-gray-200/50 dark:border-white/5 mb-4"> <div class="sticky top-0 z-30 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 py-3 bg-gray-50/95 dark:bg-gray-950/95 backdrop-blur-sm border-b border-gray-200/50 dark:border-white/5 mb-4">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-3"> <div class="min-w-0 flex-1">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@device.name}</h1> <div class="flex items-center gap-2 flex-wrap">
<div class={[ <h1 class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-white truncate">
"flex items-center gap-2 px-3 py-1.5 rounded-lg font-semibold text-sm transition-all", {@device.name}
case @device.status do </h1>
:up -> <div class={[
"bg-green-100 text-green-800 dark:bg-green-950/50 dark:text-green-400 ring-1 ring-green-600/20 dark:ring-green-500/30" "inline-flex shrink-0 items-center gap-2 px-2.5 py-1 rounded-lg font-semibold text-sm transition-all",
:down ->
"bg-red-100 text-red-800 dark:bg-red-950/50 dark:text-red-400 ring-1 ring-red-600/20 dark:ring-red-500/30"
:unknown ->
"bg-gray-100 text-gray-800 dark:bg-gray-800/50 dark:text-gray-400 ring-1 ring-gray-600/20 dark:ring-gray-500/30"
end
]}>
<span class={[
"h-2 w-2 rounded-full animate-pulse",
case @device.status do case @device.status do
:up -> "bg-green-600 dark:bg-green-500" :up ->
:down -> "bg-red-600 dark:bg-red-500" "bg-green-100 text-green-800 dark:bg-green-950/50 dark:text-green-400 ring-1 ring-green-600/20 dark:ring-green-500/30"
:unknown -> "bg-gray-500 dark:bg-gray-400"
:down ->
"bg-red-100 text-red-800 dark:bg-red-950/50 dark:text-red-400 ring-1 ring-red-600/20 dark:ring-red-500/30"
:unknown ->
"bg-gray-100 text-gray-800 dark:bg-gray-800/50 dark:text-gray-400 ring-1 ring-gray-600/20 dark:ring-gray-500/30"
end end
]}> ]}>
<span class={[
"h-2 w-2 rounded-full animate-pulse",
case @device.status do
:up -> "bg-green-600 dark:bg-green-500"
:down -> "bg-red-600 dark:bg-red-500"
:unknown -> "bg-gray-500 dark:bg-gray-400"
end
]}>
</span>
<span>
{case @device.status do
:up -> "Online"
:down -> "Offline"
:unknown -> "Unknown"
end}
</span>
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5 mt-1 text-xs text-gray-500 dark:text-gray-400">
<span
class="font-mono text-gray-600 dark:text-gray-400 group/ip cursor-pointer"
phx-click={JS.dispatch("phx:copy", detail: %{text: @device.ip_address})}
title={t("Click to copy")}
>
{@device.ip_address}
<.icon
name="hero-clipboard-document"
class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/ip:opacity-100 transition-opacity text-gray-400"
/>
</span> </span>
<span> <span class="text-gray-300 dark:text-gray-600">·</span>
{case @device.status do <%= if @agent_info.type == :cloud do %>
:up -> "Online" <span class="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400">
:down -> "Offline" <.icon name="hero-cloud" class="h-3 w-3" />
:unknown -> "Unknown" <%= if @agent_info.agent_token_id do %>
end} {t("Cloud")} ({@agent_info.name})
</span> <% else %>
{t("Cloud")}
<% end %>
</span>
<% else %>
<span class="inline-flex items-center gap-1 text-green-600 dark:text-green-400">
<.icon name="hero-server" class="h-3 w-3" />
{@agent_info.name}
</span>
<%= if @agent_info.is_offline do %>
<span
class="inline-flex items-center gap-1 px-1.5 py-0.5 font-medium text-amber-700 bg-amber-100 dark:text-amber-400 dark:bg-amber-900/30 rounded"
title="Agent has not checked in for over 5 minutes."
>
<.icon name="hero-exclamation-triangle" class="h-3 w-3" /> Offline
</span>
<% end %>
<% end %>
</div> </div>
</div> </div>
<div class="flex items-center gap-3 mt-0.5"> <div class="shrink-0 mt-0.5">
<span <.button navigate={~p"/devices/#{@device.id}/edit"}>
class="text-sm text-gray-600 dark:text-gray-400 font-mono group/ip cursor-pointer" <.icon name="hero-pencil" class="h-4 w-4" />
phx-click={JS.dispatch("phx:copy", detail: %{text: @device.ip_address})} <span class="hidden sm:inline">{t("Edit")}</span>
title={t("Click to copy")} </.button>
>
{@device.ip_address}
<.icon
name="hero-clipboard-document"
class="h-3.5 w-3.5 inline ml-1 opacity-0 group-hover/ip:opacity-100 transition-opacity text-gray-400"
/>
</span>
<span class="text-gray-300 dark:text-gray-600">•</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{t("Polled by:")}
</span>
<%= if @agent_info.type == :cloud do %>
<span class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
<.icon name="hero-cloud" class="h-3.5 w-3.5" />
<%= if @agent_info.agent_token_id do %>
Cloud ({@agent_info.name})
<% else %>
{t("Cloud")}
<% end %>
</span>
<% else %>
<span class="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<.icon name="hero-server" class="h-3.5 w-3.5" />
{@agent_info.name}
</span>
<%= if @agent_info.is_offline do %>
<span
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-amber-700 bg-amber-100 dark:text-amber-400 dark:bg-amber-900/30 rounded-md"
title="Agent has not checked in for over 5 minutes. This device will not be polled until the agent comes back online."
>
<.icon name="hero-exclamation-triangle" class="h-3 w-3" /> Offline
</span>
<% end %>
<% end %>
</div> </div>
</div> </div>
<div class="flex gap-2">
<.button navigate={~p"/devices/#{@device.id}/edit"}>
<.icon name="hero-pencil" class="h-4 w-4" /> Edit
</.button>
</div>
</div> </div>
<!-- Subscriber Impact Card --> <!-- Subscriber Impact Card -->
<%= if @subscriber_impact && @subscriber_impact.subscriber_count > 0 do %> <%= if @subscriber_impact && @subscriber_impact.subscriber_count > 0 do %>
@ -390,14 +392,18 @@
<dl class="space-y-1"> <dl class="space-y-1">
<%= if @snmp_device do %> <%= if @snmp_device do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">System Name</dt> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<dd class="text-sm font-medium text-gray-900 dark:text-white"> System Name
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{@snmp_device.sys_name || @device.name} {@snmp_device.sys_name || @device.name}
</dd> </dd>
</div> </div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">{t("Resolved IP")}</dt> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
{t("Resolved IP")}
</dt>
<dd <dd
class="text-sm font-medium text-gray-900 dark:text-white font-mono group/rip cursor-pointer" class="text-sm font-medium text-gray-900 dark:text-white font-mono group/rip cursor-pointer"
phx-click={JS.dispatch("phx:copy", detail: %{text: @device.ip_address})} phx-click={JS.dispatch("phx:copy", detail: %{text: @device.ip_address})}
@ -412,15 +418,17 @@
</div> </div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">Hardware</dt> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Hardware</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white"> <dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{@snmp_device.model || "N/A"} {@snmp_device.model || "N/A"}
</dd> </dd>
</div> </div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">Operating System</dt> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<dd class="text-sm font-medium text-gray-900 dark:text-white"> Operating System
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span> <span>
{cond do {cond do
@ -515,65 +523,73 @@
:if={@snmp_device.serial_number} :if={@snmp_device.serial_number}
class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5" class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"
> >
<dt class="text-sm text-gray-600 dark:text-gray-400">Serial Number</dt> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<dd class="text-sm font-medium text-gray-900 dark:text-white font-mono"> Serial Number
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white font-mono">
{@snmp_device.serial_number} {@snmp_device.serial_number}
</dd> </dd>
</div> </div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">Object ID</dt> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Object ID</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white font-mono"> <dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white font-mono">
{@snmp_device.sys_object_id || "N/A"} {@snmp_device.sys_object_id || "N/A"}
</dd> </dd>
</div> </div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">Contact</dt> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Contact</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white"> <dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{@snmp_device.sys_contact || "N/A"} {@snmp_device.sys_contact || "N/A"}
</dd> </dd>
</div> </div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">Location</dt> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Location</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white"> <dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{@snmp_device.sys_location || "N/A"} {@snmp_device.sys_location || "N/A"}
</dd> </dd>
</div> </div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">Uptime</dt> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Uptime</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white"> <dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{format_uptime(@snmp_device.sys_uptime)} {format_uptime(@snmp_device.sys_uptime)}
</dd> </dd>
</div> </div>
<% else %> <% else %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">Name</dt> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Name</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white"> <dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{@device.name} {@device.name}
</dd> </dd>
</div> </div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">IP Address</dt> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<dd class="text-sm font-medium text-gray-900 dark:text-white font-mono"> IP Address
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white font-mono">
{@device.ip_address} {@device.ip_address}
</dd> </dd>
</div> </div>
<% end %> <% end %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">Device Added</dt> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<dd class="text-sm font-medium text-gray-900 dark:text-white"> Device Added
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{format_device_age(@device.inserted_at)} {format_device_age(@device.inserted_at)}
</dd> </dd>
</div> </div>
<div class="flex justify-between py-1.5"> <div class="flex justify-between py-1.5">
<dt class="text-sm text-gray-600 dark:text-gray-400">Last Discovered</dt> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<dd class="text-sm font-medium text-gray-900 dark:text-white"> Last Discovered
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{if @device.last_discovery_at do {if @device.last_discovery_at do
format_device_age(@device.last_discovery_at) format_device_age(@device.last_discovery_at)
else else
@ -583,8 +599,8 @@
</div> </div>
<div class="flex justify-between py-1.5"> <div class="flex justify-between py-1.5">
<dt class="text-sm text-gray-600 dark:text-gray-400">Last Polled</dt> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Last Polled</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white"> <dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{if @device.last_snmp_poll_at do {if @device.last_snmp_poll_at do
format_device_age(@device.last_snmp_poll_at) format_device_age(@device.last_snmp_poll_at)
else else
@ -823,7 +839,7 @@
<dl class="space-y-1"> <dl class="space-y-1">
<%= for sensor <- @storage_sensors do %> <%= for sensor <- @storage_sensors do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400"> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<.link <.link
navigate={ navigate={
~p"/devices/#{@device.id}/graph/storage?sensor_id=#{sensor.id}" ~p"/devices/#{@device.id}/graph/storage?sensor_id=#{sensor.id}"
@ -833,7 +849,7 @@
{sensor.sensor_descr} {sensor.sensor_descr}
</.link> </.link>
</dt> </dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white"> <dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
<%= if sensor.latest_reading do %> <%= if sensor.latest_reading do %>
{format_sensor_value( {format_sensor_value(
sensor.latest_reading.value, sensor.latest_reading.value,
@ -935,7 +951,7 @@
<dl class="space-y-1"> <dl class="space-y-1">
<%= for sensor <- @temperature_sensors do %> <%= for sensor <- @temperature_sensors do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400"> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<.link <.link
navigate={ navigate={
~p"/devices/#{@device.id}/graph/temperature?sensor_id=#{sensor.id}" ~p"/devices/#{@device.id}/graph/temperature?sensor_id=#{sensor.id}"
@ -945,7 +961,7 @@
{sensor.sensor_descr} {sensor.sensor_descr}
</.link> </.link>
</dt> </dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white"> <dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
<%= if sensor.latest_reading do %> <%= if sensor.latest_reading do %>
{format_sensor_value( {format_sensor_value(
sensor.latest_reading.value, sensor.latest_reading.value,
@ -974,7 +990,7 @@
<dl class="space-y-1"> <dl class="space-y-1">
<%= for sensor <- @voltage_sensors do %> <%= for sensor <- @voltage_sensors do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400"> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<.link <.link
navigate={ navigate={
~p"/devices/#{@device.id}/graph/voltage?sensor_id=#{sensor.id}" ~p"/devices/#{@device.id}/graph/voltage?sensor_id=#{sensor.id}"
@ -984,7 +1000,7 @@
{sensor.sensor_descr} {sensor.sensor_descr}
</.link> </.link>
</dt> </dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white"> <dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
<%= if sensor.latest_reading do %> <%= if sensor.latest_reading do %>
{format_sensor_value( {format_sensor_value(
sensor.latest_reading.value, sensor.latest_reading.value,
@ -1014,7 +1030,7 @@
<%= for {_transceiver_name, sensors} <- @grouped_transceivers do %> <%= for {_transceiver_name, sensors} <- @grouped_transceivers do %>
<%= for sensor <- sensors do %> <%= for sensor <- sensors do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400"> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<.link <.link
navigate={ navigate={
~p"/devices/#{@device.id}/graph/dbm?sensor_id=#{sensor.id}" ~p"/devices/#{@device.id}/graph/dbm?sensor_id=#{sensor.id}"
@ -1024,7 +1040,7 @@
{sensor.sensor_descr} {sensor.sensor_descr}
</.link> </.link>
</dt> </dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white font-mono"> <dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white font-mono">
<%= if sensor.latest_reading do %> <%= if sensor.latest_reading do %>
{format_sensor_value( {format_sensor_value(
sensor.latest_reading.value, sensor.latest_reading.value,
@ -1056,7 +1072,7 @@
<dl class="space-y-1"> <dl class="space-y-1">
<%= for sensor <- @general_counters do %> <%= for sensor <- @general_counters do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400"> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<.link <.link
navigate={ navigate={
~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}" ~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}"
@ -1066,7 +1082,7 @@
{sensor.sensor_descr} {sensor.sensor_descr}
</.link> </.link>
</dt> </dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white"> <dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
<%= if sensor.latest_reading && sensor.latest_reading.value do %> <%= if sensor.latest_reading && sensor.latest_reading.value do %>
{trunc(sensor.latest_reading.value)} {trunc(sensor.latest_reading.value)}
<%= if sensor.sensor_unit && sensor.sensor_unit != "" do %> <%= if sensor.sensor_unit && sensor.sensor_unit != "" do %>
@ -1089,7 +1105,7 @@
<dl class="space-y-1"> <dl class="space-y-1">
<%= for sensor <- @firewall_counters do %> <%= for sensor <- @firewall_counters do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400"> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<.link <.link
navigate={ navigate={
~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}" ~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}"
@ -1099,7 +1115,7 @@
{sensor.sensor_descr} {sensor.sensor_descr}
</.link> </.link>
</dt> </dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white"> <dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
<%= if sensor.latest_reading && sensor.latest_reading.value do %> <%= if sensor.latest_reading && sensor.latest_reading.value do %>
{trunc(sensor.latest_reading.value)} {trunc(sensor.latest_reading.value)}
<%= if sensor.sensor_unit && sensor.sensor_unit != "" do %> <%= if sensor.sensor_unit && sensor.sensor_unit != "" do %>
@ -1129,7 +1145,7 @@
<dl class="space-y-1"> <dl class="space-y-1">
<%= for sensor <- @wireless_sensors do %> <%= for sensor <- @wireless_sensors do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400"> <dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<.link <.link
navigate={ navigate={
~p"/devices/#{@device.id}/graph/wireless?sensor_id=#{sensor.id}" ~p"/devices/#{@device.id}/graph/wireless?sensor_id=#{sensor.id}"
@ -1139,7 +1155,7 @@
{sensor.sensor_descr} {sensor.sensor_descr}
</.link> </.link>
</dt> </dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white"> <dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
<%= if sensor.latest_reading && sensor.latest_reading.value do %> <%= if sensor.latest_reading && sensor.latest_reading.value do %>
<%= if sensor.sensor_type in ["clients", "ccq", "utilization"] do %> <%= if sensor.sensor_type in ["clients", "ccq", "utilization"] do %>
{trunc(sensor.latest_reading.value)} {trunc(sensor.latest_reading.value)}
@ -1310,7 +1326,7 @@
<th class="px-3 py-2 text-left font-medium">#</th> <th class="px-3 py-2 text-left font-medium">#</th>
<th class="px-3 py-2 text-left font-medium">Name</th> <th class="px-3 py-2 text-left font-medium">Name</th>
<th class="px-3 py-2 text-left font-medium">Status</th> <th class="px-3 py-2 text-left font-medium">Status</th>
<th class="px-3 py-2 text-left font-medium">Speed</th> <th class="px-3 py-2 text-right font-medium">Speed</th>
<th class="px-3 py-2 text-left font-medium">IP Address</th> <th class="px-3 py-2 text-left font-medium">IP Address</th>
<th class="px-3 py-2 text-left font-medium">MAC</th> <th class="px-3 py-2 text-left font-medium">MAC</th>
<th class="px-3 py-2 text-left font-medium">Capacity</th> <th class="px-3 py-2 text-left font-medium">Capacity</th>
@ -1363,7 +1379,7 @@
</span> </span>
</span> </span>
</td> </td>
<td class="px-3 py-2 text-gray-900 dark:text-white text-xs font-mono"> <td class="px-3 py-2 text-right text-gray-900 dark:text-white text-xs font-mono">
{format_speed(interface.if_speed)} {format_speed(interface.if_speed)}
</td> </td>
<td class="px-3 py-2 font-mono text-xs text-gray-600 dark:text-gray-400"> <td class="px-3 py-2 font-mono text-xs text-gray-600 dark:text-gray-400">

View file

@ -3,26 +3,26 @@
current_scope={@current_scope} current_scope={@current_scope}
active_page="schedules" active_page="schedules"
> >
<div class="flex items-center justify-between mb-6"> <div class="flex flex-wrap items-start justify-between gap-3 mb-6">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 min-w-0">
<.link <.link
navigate={~p"/schedules"} navigate={~p"/schedules"}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" class="shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
> >
<.icon name="hero-arrow-left" class="h-5 w-5" /> <.icon name="hero-arrow-left" class="h-5 w-5" />
</.link> </.link>
<div> <div class="min-w-0">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@schedule.name}</h1> <h1 class="text-xl font-bold text-gray-900 dark:text-white truncate">{@schedule.name}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">{@schedule.timezone}</p> <p class="text-sm text-gray-500 dark:text-gray-400">{@schedule.timezone}</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 shrink-0">
<.link <.link
navigate={~p"/schedules/#{@schedule.id}/edit"} navigate={~p"/schedules/#{@schedule.id}/edit"}
class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
> >
<.icon name="hero-pencil" class="h-4 w-4" /> <.icon name="hero-pencil" class="h-4 w-4" />
{t("Edit")} <span class="hidden sm:inline">{t("Edit")}</span>
</.link> </.link>
<button <button
phx-click="delete" phx-click="delete"
@ -30,7 +30,7 @@
class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-gray-300 dark:border-gray-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors" class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-gray-300 dark:border-gray-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
> >
<.icon name="hero-trash" class="h-4 w-4" /> <.icon name="hero-trash" class="h-4 w-4" />
{t("Delete")} <span class="hidden sm:inline">{t("Delete")}</span>
</button> </button>
</div> </div>
</div> </div>
@ -110,106 +110,110 @@
<%!-- Layer timelines --%> <%!-- Layer timelines --%>
<%= if @layer_timelines != [] do %> <%= if @layer_timelines != [] do %>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4 mb-4"> <div class="overflow-x-auto">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4"> <div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4 mb-4 min-w-[320px]">
{t("Configuration Layers")} <h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4">
</h3> {t("Configuration Layers")}
</h3>
<%= for lt <- @layer_timelines do %> <%= for lt <- @layer_timelines do %>
<div class="mb-4 last:mb-0"> <div class="mb-4 last:mb-0">
<%!-- Day headers --%> <%!-- Day headers --%>
<div class="flex mb-1"> <div class="flex mb-1">
<div class="w-28 shrink-0"></div> <div class="w-28 shrink-0"></div>
<div class="flex-1 flex"> <div class="flex-1 flex">
<%= for day <- @timeline_days do %> <%= for day <- @timeline_days do %>
<div class="flex-1 text-center border-l first:border-l-0 border-gray-200 dark:border-gray-700"> <div class="flex-1 text-center border-l first:border-l-0 border-gray-200 dark:border-gray-700">
<span class="text-[10px] text-gray-400 dark:text-gray-500"> <span class="text-[10px] text-gray-400 dark:text-gray-500">
{Calendar.strftime(day, "%-d %a")} {Calendar.strftime(day, "%-d %a")}
</span> </span>
</div> </div>
<% end %> <% end %>
</div>
</div>
<%!-- Timeline bar --%>
<div class="flex items-center">
<div class="w-28 shrink-0 pr-2 text-right">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">
{lt.layer.name}
</span>
</div>
<div class="flex-1 relative h-8 bg-gray-100 dark:bg-gray-800 rounded overflow-hidden">
<%= for span <- lt.spans do %>
<div
class={[
"absolute inset-y-0 flex items-center px-1 overflow-hidden",
@user_colors[span.user.id] || "bg-gray-400"
]}
style={"left: #{span.start_offset / span.total_hours * 100}%; width: #{span.duration / span.total_hours * 100}%;"}
>
<span class="text-[10px] font-medium text-white truncate">
{display_name(span.user)}
</span>
</div>
<% end %>
<%!-- Today marker --%>
<% today_offset = Date.diff(Date.utc_today(), @timeline_start) %>
<%= if today_offset >= 0 and today_offset < @timeline_days do %>
<div
class="absolute inset-y-0 w-px bg-red-500 z-10"
style={"left: #{(today_offset * 24 + DateTime.utc_now().hour) / (length(@timeline_days) * 24) * 100}%;"}
>
</div>
<% end %>
</div>
</div> </div>
</div> </div>
<%!-- Timeline bar --%> <% end %>
<div class="flex items-center"> </div>
<div class="w-28 shrink-0 pr-2 text-right">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">
{lt.layer.name}
</span>
</div>
<div class="flex-1 relative h-8 bg-gray-100 dark:bg-gray-800 rounded overflow-hidden">
<%= for span <- lt.spans do %>
<div
class={[
"absolute inset-y-0 flex items-center px-1 overflow-hidden",
@user_colors[span.user.id] || "bg-gray-400"
]}
style={"left: #{span.start_offset / span.total_hours * 100}%; width: #{span.duration / span.total_hours * 100}%;"}
>
<span class="text-[10px] font-medium text-white truncate">
{display_name(span.user)}
</span>
</div>
<% end %>
<%!-- Today marker --%>
<% today_offset = Date.diff(Date.utc_today(), @timeline_start) %>
<%= if today_offset >= 0 and today_offset < @timeline_days do %>
<div
class="absolute inset-y-0 w-px bg-red-500 z-10"
style={"left: #{(today_offset * 24 + DateTime.utc_now().hour) / (length(@timeline_days) * 24) * 100}%;"}
>
</div>
<% end %>
</div>
</div>
</div>
<% end %>
</div> </div>
<%!-- Final Schedule --%> <%!-- Final Schedule --%>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4"> <div class="overflow-x-auto">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4"> <div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4 min-w-[320px]">
{t("Final Schedule")} <h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4">
</h3> {t("Final Schedule")}
<%!-- Day headers --%> </h3>
<div class="flex mb-1"> <%!-- Day headers --%>
<div class="w-28 shrink-0"></div> <div class="flex mb-1">
<div class="flex-1 flex"> <div class="w-28 shrink-0"></div>
<%= for day <- @timeline_days do %> <div class="flex-1 flex">
<div class="flex-1 text-center border-l first:border-l-0 border-gray-200 dark:border-gray-700"> <%= for day <- @timeline_days do %>
<span class="text-[10px] text-gray-400 dark:text-gray-500"> <div class="flex-1 text-center border-l first:border-l-0 border-gray-200 dark:border-gray-700">
{Calendar.strftime(day, "%-d %a")} <span class="text-[10px] text-gray-400 dark:text-gray-500">
</span> {Calendar.strftime(day, "%-d %a")}
</div> </span>
<% end %> </div>
<% end %>
</div>
</div> </div>
</div> <%!-- Timeline bar --%>
<%!-- Timeline bar --%> <div class="flex items-center">
<div class="flex items-center"> <div class="w-28 shrink-0"></div>
<div class="w-28 shrink-0"></div> <div class="flex-1 relative h-8 bg-gray-100 dark:bg-gray-800 rounded overflow-hidden">
<div class="flex-1 relative h-8 bg-gray-100 dark:bg-gray-800 rounded overflow-hidden"> <%= for span <- @final_spans do %>
<%= for span <- @final_spans do %> <div
<div class={[
class={[ "absolute inset-y-0 flex items-center px-1 overflow-hidden",
"absolute inset-y-0 flex items-center px-1 overflow-hidden", @user_colors[span.user.id] || "bg-gray-400"
@user_colors[span.user.id] || "bg-gray-400" ]}
]} style={"left: #{span.start_offset / span.total_hours * 100}%; width: #{span.duration / span.total_hours * 100}%;"}
style={"left: #{span.start_offset / span.total_hours * 100}%; width: #{span.duration / span.total_hours * 100}%;"} >
> <span class="text-[10px] font-medium text-white truncate">
<span class="text-[10px] font-medium text-white truncate"> {display_name(span.user)}
{display_name(span.user)} </span>
</span> </div>
</div> <% end %>
<% end %> <%!-- Today marker --%>
<%!-- Today marker --%> <% today_offset = Date.diff(Date.utc_today(), @timeline_start) %>
<% today_offset = Date.diff(Date.utc_today(), @timeline_start) %> <%= if today_offset >= 0 and today_offset < @timeline_days do %>
<%= if today_offset >= 0 and today_offset < @timeline_days do %> <div
<div class="absolute inset-y-0 w-px bg-red-500 z-10"
class="absolute inset-y-0 w-px bg-red-500 z-10" style={"left: #{(today_offset * 24 + DateTime.utc_now().hour) / (length(@timeline_days) * 24) * 100}%;"}
style={"left: #{(today_offset * 24 + DateTime.utc_now().hour) / (length(@timeline_days) * 24) * 100}%;"} >
> </div>
</div> <% end %>
<% end %> </div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -37,7 +37,7 @@
</div> </div>
<% else %> <% else %>
<%!-- Sites table --%> <%!-- Sites table --%>
<div class="mt-6 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10"> <div class="mt-6 overflow-x-auto rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-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/80"> <thead class="bg-gray-50 dark:bg-gray-800/80">
<tr> <tr>
@ -51,21 +51,21 @@
{t("Down")} {t("Down")}
</th> </th>
<th <th
class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400" class="hidden sm:table-cell px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"
title={t("Quality of Experience — Preseem network quality score (0100)")} title={t("Quality of Experience — Preseem network quality score (0100)")}
> >
{t("QoE")} {t("QoE")}
</th> </th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"> <th class="hidden sm:table-cell px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Subscribers")} {t("Subscribers")}
</th> </th>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"> <th class="hidden md:table-cell px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Location")} {t("Location")}
</th> </th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"> <th class="hidden lg:table-cell px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Coordinates")} {t("Coordinates")}
</th> </th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"> <th class="hidden lg:table-cell px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Last Check")} {t("Last Check")}
</th> </th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"> <th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
@ -102,7 +102,7 @@
<span class="text-gray-400 dark:text-gray-500">0</span> <span class="text-gray-400 dark:text-gray-500">0</span>
<% end %> <% end %>
</td> </td>
<td class="px-4 py-2.5 text-right text-sm"> <td class="hidden sm:table-cell px-4 py-2.5 text-right text-sm">
<%= if @summary_map[site.id] && @summary_map[site.id].avg_qoe do %> <%= if @summary_map[site.id] && @summary_map[site.id].avg_qoe do %>
<span class={qoe_color(@summary_map[site.id].avg_qoe)}> <span class={qoe_color(@summary_map[site.id].avg_qoe)}>
{format_qoe(@summary_map[site.id].avg_qoe)} {format_qoe(@summary_map[site.id].avg_qoe)}
@ -111,17 +111,17 @@
<span class="text-gray-400 dark:text-gray-500">—</span> <span class="text-gray-400 dark:text-gray-500">—</span>
<% end %> <% end %>
</td> </td>
<td class="px-4 py-2.5 text-right text-sm text-gray-600 dark:text-gray-400"> <td class="hidden sm:table-cell px-4 py-2.5 text-right text-sm text-gray-600 dark:text-gray-400">
<%= if @summary_map[site.id] && @summary_map[site.id].subscribers do %> <%= if @summary_map[site.id] && @summary_map[site.id].subscribers do %>
{@summary_map[site.id].subscribers} {@summary_map[site.id].subscribers}
<% else %> <% else %>
<span class="text-gray-400 dark:text-gray-500">—</span> <span class="text-gray-400 dark:text-gray-500">—</span>
<% end %> <% end %>
</td> </td>
<td class="px-4 py-2.5 text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate"> <td class="hidden md:table-cell px-4 py-2.5 text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate">
{site.location || "—"} {site.location || "—"}
</td> </td>
<td class="px-4 py-2.5 text-right"> <td class="hidden lg:table-cell px-4 py-2.5 text-right">
<%= if site.latitude && site.longitude do %> <%= if site.latitude && site.longitude do %>
<span class="text-xs font-mono text-gray-400 dark:text-gray-500"> <span class="text-xs font-mono text-gray-400 dark:text-gray-500">
{Float.round(site.latitude, 4)}, {Float.round(site.longitude, 4)} {Float.round(site.latitude, 4)}, {Float.round(site.longitude, 4)}
@ -130,7 +130,7 @@
<span class="text-gray-400 dark:text-gray-500 text-sm">—</span> <span class="text-gray-400 dark:text-gray-500 text-sm">—</span>
<% end %> <% end %>
</td> </td>
<td class="px-4 py-2.5 text-right text-xs text-gray-500 dark:text-gray-400"> <td class="hidden lg:table-cell px-4 py-2.5 text-right text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">—</span> <span class="text-gray-400 dark:text-gray-500">—</span>
</td> </td>
<td class="px-4 py-2.5 text-right"> <td class="px-4 py-2.5 text-right">
@ -138,7 +138,8 @@
navigate={~p"/devices/new?site_id=#{site.id}"} navigate={~p"/devices/new?site_id=#{site.id}"}
class="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" class="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
> >
<.icon name="hero-plus" class="h-3.5 w-3.5" /> {t("Add Device")} <.icon name="hero-plus" class="h-3.5 w-3.5" />
<span class="hidden sm:inline">{t("Add Device")}</span>
</.link> </.link>
</td> </td>
</tr> </tr>

View file

@ -270,7 +270,7 @@
<div class="mt-8 grid gap-6 lg:grid-cols-2"> <div class="mt-8 grid gap-6 lg:grid-cols-2">
<%!-- Left Column --%> <%!-- Left Column --%>
<div class="space-y-6"> <div class="space-y-6 min-w-0">
<%!-- Site Details --%> <%!-- Site Details --%>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50"> <div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="p-4"> <div class="p-4">
@ -397,7 +397,7 @@
</div> </div>
<%!-- Right Column --%> <%!-- Right Column --%>
<div class="space-y-6"> <div class="space-y-6 min-w-0">
<%!-- Device Health Grid --%> <%!-- Device Health Grid --%>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50"> <div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="p-4"> <div class="p-4">
@ -437,10 +437,16 @@
<thead> <thead>
<tr class="text-xs text-gray-500 dark:text-gray-400"> <tr class="text-xs text-gray-500 dark:text-gray-400">
<th class="px-3 py-2 text-left font-medium">{t("Device")}</th> <th class="px-3 py-2 text-left font-medium">{t("Device")}</th>
<th class="px-3 py-2 text-left font-medium">{t("IP Address")}</th> <th class="hidden sm:table-cell px-3 py-2 text-left font-medium">
<th class="px-3 py-2 text-right font-medium">{t("Subs")}</th> {t("IP Address")}
</th>
<th class="hidden sm:table-cell px-3 py-2 text-right font-medium">
{t("Subs")}
</th>
<%= if @can_view_financials do %> <%= if @can_view_financials do %>
<th class="px-3 py-2 text-right font-medium">{t("MRR")}</th> <th class="hidden md:table-cell px-3 py-2 text-right font-medium">
{t("MRR")}
</th>
<% end %> <% end %>
<th class="px-3 py-2 text-right font-medium">{t("Latency")}</th> <th class="px-3 py-2 text-right font-medium">{t("Latency")}</th>
</tr> </tr>
@ -462,10 +468,10 @@
</span> </span>
</div> </div>
</td> </td>
<td class="px-3 py-2 font-mono text-xs text-gray-500 dark:text-gray-400"> <td class="hidden sm:table-cell px-3 py-2 font-mono text-xs text-gray-500 dark:text-gray-400">
{eq.ip_address} {eq.ip_address}
</td> </td>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-700 dark:text-gray-300"> <td class="hidden sm:table-cell px-3 py-2 text-right font-mono text-xs text-gray-700 dark:text-gray-300">
<%= if di = @device_impact[eq.id] do %> <%= if di = @device_impact[eq.id] do %>
{if di.count > 0, do: di.count, else: "—"} {if di.count > 0, do: di.count, else: "—"}
<% else %> <% else %>
@ -473,7 +479,7 @@
<% end %> <% end %>
</td> </td>
<%= if @can_view_financials do %> <%= if @can_view_financials do %>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-700 dark:text-gray-300"> <td class="hidden md:table-cell px-3 py-2 text-right font-mono text-xs text-gray-700 dark:text-gray-300">
<%= if di = @device_impact[eq.id] do %> <%= if di = @device_impact[eq.id] do %>
{if Decimal.gt?(di.mrr, 0), do: format_mrr(di.mrr), else: "—"} {if Decimal.gt?(di.mrr, 0), do: format_mrr(di.mrr), else: "—"}
<% else %> <% else %>

View file

@ -1,3 +1,9 @@
2026-03-12 — Mobile Responsive Improvements
* Improved layout across all pages for mobile devices
* Tables now hide less critical columns on small screens to prevent horizontal scrolling
* Action buttons in page headers compact to icon-only on small screens
* Device detail page values now consistently right-aligned for easier reading
2026-03-12 — Real-Time GraphQL API 2026-03-12 — Real-Time GraphQL API
* New time-series queries for sensor readings, interface traffic, and check results * New time-series queries for sensor readings, interface traffic, and check results
* GraphQL subscriptions for live device status, alerts, and sensor reading updates * GraphQL subscriptions for live device status, alerts, and sensor reading updates