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:
parent
05d79287bc
commit
19abd5508a
11 changed files with 538 additions and 470 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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={[
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (0–100)")}
|
||||
>
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue