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
feat: add real-time GraphQL API with time-series queries and subscriptions
- Added absinthe_phoenix dependency for WebSocket subscription support

View file

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

View file

@ -66,7 +66,7 @@
</div>
</div>
<% else %>
<div class="mt-6">
<div class="mt-6 overflow-x-auto">
<.table
id="agents-table"
rows={@streams.agent_tokens}
@ -183,108 +183,110 @@
"Application-wide agents that can poll devices for any organization. Only visible and manageable by superadmins."
)}
</p>
<.table
id="cloud-pollers-table"
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">
<span class="font-medium">{agent.name}</span>
<%= if agent.id == @global_default_cloud_poller_id do %>
<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">
<.icon name="hero-check-circle" class="h-3 w-3" /> Default
</span>
<% end %>
<%= if agent.allow_remote_debug do %>
<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">
<.icon name="hero-bug-ant" class="h-3 w-3" /> Debug
</span>
<% end %>
</div>
</:col>
<div class="overflow-x-auto">
<.table
id="cloud-pollers-table"
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">
<span class="font-medium">{agent.name}</span>
<%= if agent.id == @global_default_cloud_poller_id do %>
<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">
<.icon name="hero-check-circle" class="h-3 w-3" /> Default
</span>
<% end %>
<%= if agent.allow_remote_debug do %>
<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">
<.icon name="hero-bug-ant" class="h-3 w-3" /> Debug
</span>
<% end %>
</div>
</:col>
<:col :let={{_id, agent}} label={t("Status")}>
<% {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={"h-1.5 w-1.5 rounded-full #{status_dot_class(status)}"} />
{label}
</span>
</:col>
<:col :let={{_id, agent}} label={t("Status")}>
<% {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={"h-1.5 w-1.5 rounded-full #{status_dot_class(status)}"} />
{label}
</span>
</:col>
<:col :let={{_id, agent}} label={t("Device")}>
<% counts = Map.get(@cloud_poller_counts, agent.id, %{direct: 0, total: 0}) %>
<div class="text-sm text-gray-900 dark:text-white">
{counts.total} total
</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 %>
<:col :let={{_id, agent}} label={t("Device")}>
<% counts = Map.get(@cloud_poller_counts, agent.id, %{direct: 0, total: 0}) %>
<div class="text-sm text-gray-900 dark:text-white">
{counts.total} total
</div>
<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>
<% 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>
<:col :let={{_id, agent}} label={t("Version")}>
<%= if agent.metadata["version"] do %>
<div class="text-sm text-gray-600 dark:text-gray-400 font-mono">
{format_agent_version(agent.metadata["version"])}
<: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>
<% end %>
</:col>
<%= if agent.last_seen_at do %>
<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")}>
<div class="text-sm text-gray-600 dark:text-gray-400">
{ToweropsWeb.TimeHelpers.format_date(agent.inserted_at, @timezone)}
</div>
</:col>
<:col :let={{_id, agent}} label={t("Version")}>
<%= if agent.metadata["version"] do %>
<div class="text-sm text-gray-600 dark:text-gray-400 font-mono">
{format_agent_version(agent.metadata["version"])}
</div>
<% end %>
</:col>
<:action :let={{_id, agent}}>
<%= if agent.enabled do %>
<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>
<:col :let={{_id, agent}} label={t("Created")}>
<div class="text-sm text-gray-600 dark:text-gray-400">
{ToweropsWeb.TimeHelpers.format_date(agent.inserted_at, @timezone)}
</div>
<% else %>
<span class="text-xs text-gray-500 dark:text-gray-400">Disabled</span>
<% end %>
</:action>
</.table>
</:col>
<:action :let={{_id, agent}}>
<%= if agent.enabled do %>
<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>
<% 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"
>
<.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
: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"
>
<.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>
<.link
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"
>
<.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
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>
</:actions>
</.header>

View file

@ -363,24 +363,24 @@
<%!-- Alerts table --%>
<%= 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">
<thead class="bg-gray-50/80 dark:bg-gray-800/80">
<tr>
<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")}
</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")}
</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")}
</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")}
</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>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-800/50">
@ -407,7 +407,7 @@
]} />
</td>
<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
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"
@ -416,36 +416,36 @@
</.link>
<span
: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}
</span>
</div>
<span
: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}
</span>
</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}
</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 %>
<span class="font-mono text-xs text-amber-600 dark:text-amber-400">
{alert.gaiia_impact["total_subscribers"]}s
</span>
<% end %>
</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
datetime={alert.triggered_at}
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 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 %>
<button
type="button"
@ -495,26 +495,26 @@
all sites →
</.link>
</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">
<thead class="bg-gray-50/80 dark:bg-gray-800/80">
<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")}
</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")}
</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")}
</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")}
</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")}
</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")}
</th>
</tr>
@ -552,7 +552,7 @@
<span class="text-gray-300 dark:text-gray-600">0</span>
<% end %>
</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 %>
<span class={
cond do
@ -567,14 +567,14 @@
<span class="text-gray-300 dark:text-gray-600">—</span>
<% end %>
</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 %>
{site.subscribers}
<% else %>
<span class="text-gray-300 dark:text-gray-600">—</span>
<% end %>
</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 %>
<span class="text-red-600 dark:text-red-400 font-bold">
{format_number(site.subscribers_affected)}

View file

@ -3,115 +3,125 @@
current_scope={@current_scope}
active_page="devices"
>
<%!-- Header: title + tabs + actions in one row --%>
<div class="flex items-center justify-between mb-4 border-b border-gray-200 dark:border-white/10 pb-3">
<div class="flex items-center gap-4">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@page_title}</h1>
<% percent =
if @device_quota.limit != :unlimited and @device_quota.limit > 0 do
(@device_quota.current / @device_quota.limit * 100) |> trunc()
else
0
end %>
<% badge_class =
cond do
@current_scope.user.is_superuser ->
"bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
<%!-- Header: title + actions, then tabs below on mobile --%>
<div class="mb-4 border-b border-gray-200 dark:border-white/10 pb-3">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<h1 class="text-xl font-bold text-gray-900 dark:text-white truncate">{@page_title}</h1>
<% percent =
if @device_quota.limit != :unlimited and @device_quota.limit > 0 do
(@device_quota.current / @device_quota.limit * 100) |> trunc()
else
0
end %>
<% badge_class =
cond do
@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 ->
"bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400"
@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"
percent >= 90 ->
"bg-orange-50 text-orange-700 dark:bg-orange-900/20 dark:text-orange-400"
percent >= 90 ->
"bg-orange-50 text-orange-700 dark:bg-orange-900/20 dark:text-orange-400"
@device_quota.limit == :unlimited ->
"bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
@device_quota.limit == :unlimited ->
"bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
true ->
"bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400"
end %>
<span class={"rounded px-2 py-0.5 text-xs font-medium #{badge_class}"}>
<%= cond do %>
<% @current_scope.user.is_superuser -> %>
{@device_quota.current} devices
<% @device_quota.limit == :unlimited -> %>
{@device_quota.current} devices
<% true -> %>
{@device_quota.current}/{@device_quota.limit}
true ->
"bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400"
end %>
<span class={"shrink-0 rounded px-2 py-0.5 text-xs font-medium #{badge_class}"}>
<%= cond do %>
<% @current_scope.user.is_superuser -> %>
{@device_quota.current}
<% @device_quota.limit == :unlimited -> %>
{@device_quota.current}
<% true -> %>
{@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 %>
</span>
<%!-- Inline tab switcher --%>
<nav class="flex items-center gap-1 ml-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 class="flex items-center gap-2">
<%= if @has_devices do %>
<%= if @reorder_mode do %>
<.button :if={!@sites_enabled || @has_sites} navigate={~p"/devices/new"} variant="primary">
<.icon name="hero-plus" class="h-4 w-4" />
<span class="hidden sm:inline">{t("New Device")}</span>
<span class="sm:hidden">{t("New")}</span>
</.button>
<%= if @has_devices and not @reorder_mode do %>
<.button
type="button"
phx-click="reset_order"
data-confirm={t("Reset all sites and devices to alphabetical order?")}
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-arrow-path" class="h-4 w-4" /> Reset
</.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" />
<.icon name="hero-magnifying-glass" class="h-4 w-4" />
</.button>
<% end %>
<% end %>
<.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>
<%= case @active_tab do %>
@ -256,7 +266,7 @@
</th>
<th
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")}
</th>
@ -268,25 +278,25 @@
</th>
<th
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")}
</th>
<th
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")}
</th>
<th
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")}
</th>
<th
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")}
</th>
@ -414,7 +424,7 @@
{row.device.name}
</.link>
</td>
<td class="p-0 whitespace-nowrap">
<td class="hidden sm:table-cell p-0 whitespace-nowrap">
<.link
navigate={~p"/devices/#{row.device.id}"}
class={[
@ -452,7 +462,7 @@
</span>
</.link>
</td>
<td class="p-0 whitespace-nowrap">
<td class="hidden md:table-cell p-0 whitespace-nowrap">
<.link
navigate={~p"/devices/#{row.device.id}"}
class={[
@ -471,7 +481,7 @@
</span>
</.link>
</td>
<td class="p-0 whitespace-nowrap">
<td class="hidden md:table-cell p-0 whitespace-nowrap">
<.link
navigate={~p"/devices/#{row.device.id}"}
class={[
@ -483,7 +493,7 @@
<span class={"text-xs font-mono #{color}"}>{text}</span>
</.link>
</td>
<td class="p-0 whitespace-nowrap">
<td class="hidden lg:table-cell p-0 whitespace-nowrap">
<.link
navigate={~p"/devices/#{row.device.id}"}
class={[
@ -507,7 +517,7 @@
</div>
</.link>
</td>
<td class="p-0 whitespace-nowrap">
<td class="hidden lg:table-cell p-0 whitespace-nowrap">
<.link
navigate={~p"/devices/#{row.device.id}"}
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="flex items-start justify-between">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@device.name}</h1>
<div class={[
"flex items-center gap-2 px-3 py-1.5 rounded-lg font-semibold text-sm transition-all",
case @device.status do
:up ->
"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"
: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",
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<h1 class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-white truncate">
{@device.name}
</h1>
<div class={[
"inline-flex shrink-0 items-center gap-2 px-2.5 py-1 rounded-lg font-semibold text-sm transition-all",
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"
:up ->
"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"
: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
: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>
{case @device.status do
:up -> "Online"
:down -> "Offline"
:unknown -> "Unknown"
end}
</span>
<span class="text-gray-300 dark:text-gray-600">·</span>
<%= if @agent_info.type == :cloud do %>
<span class="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400">
<.icon name="hero-cloud" class="h-3 w-3" />
<%= if @agent_info.agent_token_id do %>
{t("Cloud")} ({@agent_info.name})
<% 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 class="flex items-center gap-3 mt-0.5">
<span
class="text-sm text-gray-600 dark:text-gray-400 font-mono 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.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 class="shrink-0 mt-0.5">
<.button navigate={~p"/devices/#{@device.id}/edit"}>
<.icon name="hero-pencil" class="h-4 w-4" />
<span class="hidden sm:inline">{t("Edit")}</span>
</.button>
</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>
<!-- Subscriber Impact Card -->
<%= if @subscriber_impact && @subscriber_impact.subscriber_count > 0 do %>
@ -390,14 +392,18 @@
<dl class="space-y-1">
<%= if @snmp_device do %>
<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>
<dd class="text-sm font-medium text-gray-900 dark:text-white">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
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}
</dd>
</div>
<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
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})}
@ -412,15 +418,17 @@
</div>
<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>
<dd class="text-sm font-medium text-gray-900 dark:text-white">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Hardware</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{@snmp_device.model || "N/A"}
</dd>
</div>
<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>
<dd class="text-sm font-medium text-gray-900 dark:text-white">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
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">
<span>
{cond do
@ -515,65 +523,73 @@
:if={@snmp_device.serial_number}
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>
<dd class="text-sm font-medium text-gray-900 dark:text-white font-mono">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
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}
</dd>
</div>
<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>
<dd class="text-sm font-medium text-gray-900 dark:text-white font-mono">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Object ID</dt>
<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"}
</dd>
</div>
<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>
<dd class="text-sm font-medium text-gray-900 dark:text-white">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Contact</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{@snmp_device.sys_contact || "N/A"}
</dd>
</div>
<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>
<dd class="text-sm font-medium text-gray-900 dark:text-white">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Location</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{@snmp_device.sys_location || "N/A"}
</dd>
</div>
<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>
<dd class="text-sm font-medium text-gray-900 dark:text-white">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Uptime</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{format_uptime(@snmp_device.sys_uptime)}
</dd>
</div>
<% else %>
<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>
<dd class="text-sm font-medium text-gray-900 dark:text-white">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Name</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{@device.name}
</dd>
</div>
<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>
<dd class="text-sm font-medium text-gray-900 dark:text-white font-mono">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
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}
</dd>
</div>
<% end %>
<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>
<dd class="text-sm font-medium text-gray-900 dark:text-white">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
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)}
</dd>
</div>
<div class="flex justify-between py-1.5">
<dt class="text-sm text-gray-600 dark:text-gray-400">Last Discovered</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
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
format_device_age(@device.last_discovery_at)
else
@ -583,8 +599,8 @@
</div>
<div class="flex justify-between py-1.5">
<dt class="text-sm text-gray-600 dark:text-gray-400">Last Polled</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Last Polled</dt>
<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
format_device_age(@device.last_snmp_poll_at)
else
@ -823,7 +839,7 @@
<dl class="space-y-1">
<%= for sensor <- @storage_sensors do %>
<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
navigate={
~p"/devices/#{@device.id}/graph/storage?sensor_id=#{sensor.id}"
@ -833,7 +849,7 @@
{sensor.sensor_descr}
</.link>
</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 %>
{format_sensor_value(
sensor.latest_reading.value,
@ -935,7 +951,7 @@
<dl class="space-y-1">
<%= for sensor <- @temperature_sensors do %>
<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
navigate={
~p"/devices/#{@device.id}/graph/temperature?sensor_id=#{sensor.id}"
@ -945,7 +961,7 @@
{sensor.sensor_descr}
</.link>
</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 %>
{format_sensor_value(
sensor.latest_reading.value,
@ -974,7 +990,7 @@
<dl class="space-y-1">
<%= for sensor <- @voltage_sensors do %>
<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
navigate={
~p"/devices/#{@device.id}/graph/voltage?sensor_id=#{sensor.id}"
@ -984,7 +1000,7 @@
{sensor.sensor_descr}
</.link>
</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 %>
{format_sensor_value(
sensor.latest_reading.value,
@ -1014,7 +1030,7 @@
<%= for {_transceiver_name, sensors} <- @grouped_transceivers do %>
<%= for sensor <- sensors do %>
<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
navigate={
~p"/devices/#{@device.id}/graph/dbm?sensor_id=#{sensor.id}"
@ -1024,7 +1040,7 @@
{sensor.sensor_descr}
</.link>
</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 %>
{format_sensor_value(
sensor.latest_reading.value,
@ -1056,7 +1072,7 @@
<dl class="space-y-1">
<%= for sensor <- @general_counters do %>
<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
navigate={
~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}"
@ -1066,7 +1082,7 @@
{sensor.sensor_descr}
</.link>
</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 %>
{trunc(sensor.latest_reading.value)}
<%= if sensor.sensor_unit && sensor.sensor_unit != "" do %>
@ -1089,7 +1105,7 @@
<dl class="space-y-1">
<%= for sensor <- @firewall_counters do %>
<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
navigate={
~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}"
@ -1099,7 +1115,7 @@
{sensor.sensor_descr}
</.link>
</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 %>
{trunc(sensor.latest_reading.value)}
<%= if sensor.sensor_unit && sensor.sensor_unit != "" do %>
@ -1129,7 +1145,7 @@
<dl class="space-y-1">
<%= for sensor <- @wireless_sensors do %>
<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
navigate={
~p"/devices/#{@device.id}/graph/wireless?sensor_id=#{sensor.id}"
@ -1139,7 +1155,7 @@
{sensor.sensor_descr}
</.link>
</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.sensor_type in ["clients", "ccq", "utilization"] do %>
{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">Name</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">MAC</th>
<th class="px-3 py-2 text-left font-medium">Capacity</th>
@ -1363,7 +1379,7 @@
</span>
</span>
</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)}
</td>
<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}
active_page="schedules"
>
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="flex flex-wrap items-start justify-between gap-3 mb-6">
<div class="flex items-center gap-3 min-w-0">
<.link
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" />
</.link>
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@schedule.name}</h1>
<div class="min-w-0">
<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>
</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 shrink-0">
<.link
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"
>
<.icon name="hero-pencil" class="h-4 w-4" />
{t("Edit")}
<span class="hidden sm:inline">{t("Edit")}</span>
</.link>
<button
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"
>
<.icon name="hero-trash" class="h-4 w-4" />
{t("Delete")}
<span class="hidden sm:inline">{t("Delete")}</span>
</button>
</div>
</div>
@ -110,106 +110,110 @@
<%!-- Layer timelines --%>
<%= 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">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4">
{t("Configuration Layers")}
</h3>
<div class="overflow-x-auto">
<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]">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4">
{t("Configuration Layers")}
</h3>
<%= for lt <- @layer_timelines do %>
<div class="mb-4 last:mb-0">
<%!-- Day headers --%>
<div class="flex mb-1">
<div class="w-28 shrink-0"></div>
<div class="flex-1 flex">
<%= for day <- @timeline_days do %>
<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">
{Calendar.strftime(day, "%-d %a")}
</span>
</div>
<% end %>
<%= for lt <- @layer_timelines do %>
<div class="mb-4 last:mb-0">
<%!-- Day headers --%>
<div class="flex mb-1">
<div class="w-28 shrink-0"></div>
<div class="flex-1 flex">
<%= for day <- @timeline_days do %>
<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">
{Calendar.strftime(day, "%-d %a")}
</span>
</div>
<% 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>
<%!-- 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>
<% end %>
<% end %>
</div>
</div>
<%!-- Final Schedule --%>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4">
{t("Final Schedule")}
</h3>
<%!-- Day headers --%>
<div class="flex mb-1">
<div class="w-28 shrink-0"></div>
<div class="flex-1 flex">
<%= for day <- @timeline_days do %>
<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">
{Calendar.strftime(day, "%-d %a")}
</span>
</div>
<% end %>
<div class="overflow-x-auto">
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4 min-w-[320px]">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4">
{t("Final Schedule")}
</h3>
<%!-- Day headers --%>
<div class="flex mb-1">
<div class="w-28 shrink-0"></div>
<div class="flex-1 flex">
<%= for day <- @timeline_days do %>
<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">
{Calendar.strftime(day, "%-d %a")}
</span>
</div>
<% end %>
</div>
</div>
</div>
<%!-- Timeline bar --%>
<div class="flex items-center">
<div class="w-28 shrink-0"></div>
<div class="flex-1 relative h-8 bg-gray-100 dark:bg-gray-800 rounded overflow-hidden">
<%= for span <- @final_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 %>
<%!-- Timeline bar --%>
<div class="flex items-center">
<div class="w-28 shrink-0"></div>
<div class="flex-1 relative h-8 bg-gray-100 dark:bg-gray-800 rounded overflow-hidden">
<%= for span <- @final_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>

View file

@ -37,7 +37,7 @@
</div>
<% else %>
<%!-- 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">
<thead class="bg-gray-50 dark:bg-gray-800/80">
<tr>
@ -51,21 +51,21 @@
{t("Down")}
</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)")}
>
{t("QoE")}
</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")}
</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")}
</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")}
</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")}
</th>
<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>
<% end %>
</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 %>
<span class={qoe_color(@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>
<% end %>
</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 %>
{@summary_map[site.id].subscribers}
<% else %>
<span class="text-gray-400 dark:text-gray-500">—</span>
<% end %>
</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 || "—"}
</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 %>
<span class="text-xs font-mono text-gray-400 dark:text-gray-500">
{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>
<% end %>
</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>
</td>
<td class="px-4 py-2.5 text-right">
@ -138,7 +138,8 @@
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"
>
<.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>
</td>
</tr>

View file

@ -270,7 +270,7 @@
<div class="mt-8 grid gap-6 lg:grid-cols-2">
<%!-- Left Column --%>
<div class="space-y-6">
<div class="space-y-6 min-w-0">
<%!-- Site Details --%>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="p-4">
@ -397,7 +397,7 @@
</div>
<%!-- Right Column --%>
<div class="space-y-6">
<div class="space-y-6 min-w-0">
<%!-- Device Health Grid --%>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="p-4">
@ -437,10 +437,16 @@
<thead>
<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("IP Address")}</th>
<th class="px-3 py-2 text-right font-medium">{t("Subs")}</th>
<th class="hidden sm:table-cell px-3 py-2 text-left font-medium">
{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 %>
<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 %>
<th class="px-3 py-2 text-right font-medium">{t("Latency")}</th>
</tr>
@ -462,10 +468,10 @@
</span>
</div>
</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}
</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.count > 0, do: di.count, else: "—"}
<% else %>
@ -473,7 +479,7 @@
<% end %>
</td>
<%= 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 Decimal.gt?(di.mrr, 0), do: format_mrr(di.mrr), 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
* New time-series queries for sensor readings, interface traffic, and check results
* GraphQL subscriptions for live device status, alerts, and sensor reading updates