- Main wrapper uses flex column with min-h to push footer to bottom - Add 12 tests covering sidebar nav, top bar, mobile menu, user menu, org switcher, collapse toggle, active page highlighting, beta banner, conditional sites link, and footer rendering
1073 lines
42 KiB
Elixir
1073 lines
42 KiB
Elixir
defmodule ToweropsWeb.Layouts do
|
|
@moduledoc """
|
|
This module holds layouts and related functionality
|
|
used by your application.
|
|
"""
|
|
use ToweropsWeb, :html
|
|
|
|
alias ToweropsWeb.Components.ConsentPrompt
|
|
alias ToweropsWeb.Components.CookieConsent
|
|
alias ToweropsWeb.Helpers.StatusHelpers
|
|
alias ToweropsWeb.Live.Components.GlobalSearchComponent
|
|
|
|
require Logger
|
|
|
|
# Embed all files in layouts/* within this module.
|
|
# The default root.html.heex file contains the HTML
|
|
# skeleton of your application, namely HTML headers
|
|
# and other static content.
|
|
embed_templates "layouts/*"
|
|
|
|
@doc """
|
|
Renders your app layout.
|
|
|
|
This function is typically invoked from every template,
|
|
and it often contains your application menu, sidebar,
|
|
or similar.
|
|
|
|
## Examples
|
|
|
|
<Layouts.app flash={@flash}>
|
|
<h1>Content</h1>
|
|
</Layouts.app>
|
|
|
|
"""
|
|
attr :flash, :map, required: true, doc: "the map of flash messages"
|
|
|
|
attr :current_scope, :map,
|
|
default: nil,
|
|
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
|
|
|
|
attr :timezone, :string, default: "UTC", doc: "the user's timezone"
|
|
|
|
attr :requires_cookie_consent, :boolean,
|
|
default: false,
|
|
doc: "whether to show the cookie consent banner"
|
|
|
|
slot :inner_block, required: true
|
|
|
|
def app(assigns) do
|
|
# Get cookie consent from process dictionary (set in plug for non-authenticated pages)
|
|
requires_cookie_consent = Process.get(:requires_cookie_consent, false)
|
|
assigns = Map.put(assigns, :requires_cookie_consent, requires_cookie_consent)
|
|
|
|
~H"""
|
|
<div class="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950">
|
|
<header class="border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900">
|
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
<nav class="flex h-16 items-center justify-between">
|
|
<div class="flex items-center">
|
|
<.link navigate={~p"/"} class="text-xl font-bold text-gray-900 dark:text-white">
|
|
Towerops
|
|
</.link>
|
|
</div>
|
|
<div class="flex items-center gap-x-4">
|
|
<.link
|
|
navigate={~p"/users/log-in"}
|
|
class="text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
|
|
>
|
|
Log in
|
|
</.link>
|
|
<.link
|
|
navigate={~p"/users/register"}
|
|
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
|
>
|
|
Sign up
|
|
</.link>
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="flex-1 px-4 py-12 sm:px-6 lg:px-8">
|
|
<div class="mx-auto max-w-2xl">
|
|
{render_slot(@inner_block)}
|
|
</div>
|
|
</main>
|
|
|
|
<.footer timezone={Map.get(assigns, :timezone, "UTC")} />
|
|
</div>
|
|
|
|
<.flash_group flash={@flash} />
|
|
<CookieConsent.banner requires_consent={@requires_cookie_consent} />
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Renders the authenticated app layout with navigation.
|
|
|
|
Use this for pages that require organization context.
|
|
|
|
## Examples
|
|
|
|
<Layouts.authenticated flash={@flash} current_scope={@current_scope} active_page="dashboard">
|
|
<h1>Content</h1>
|
|
</Layouts.authenticated>
|
|
|
|
"""
|
|
attr :flash, :map, required: true, doc: "the map of flash messages"
|
|
|
|
attr :current_scope, :map,
|
|
default: nil,
|
|
doc: "the current scope (contains user, organization, timezone, and impersonation state)"
|
|
|
|
attr :active_page, :string,
|
|
default: nil,
|
|
doc: "the currently active page (dashboard, sites, device, or alerts)"
|
|
|
|
attr :unresolved_alert_count, :integer,
|
|
default: 0,
|
|
doc: "number of unresolved alerts for the current organization"
|
|
|
|
slot :inner_block, required: true
|
|
|
|
def authenticated(assigns) do
|
|
# Get cookie consent from process dictionary (set in on_mount hook)
|
|
requires_cookie_consent = Process.get(:requires_cookie_consent, false)
|
|
assigns = Map.put(assigns, :requires_cookie_consent, requires_cookie_consent)
|
|
|
|
# Get unresolved alert count from process dictionary (set/updated in on_mount hook)
|
|
unresolved_alert_count = Process.get(:unresolved_alert_count, 0)
|
|
assigns = Map.put(assigns, :unresolved_alert_count, unresolved_alert_count)
|
|
|
|
# Extract organization and timezone from scope for template convenience
|
|
current_organization = assigns[:current_scope] && assigns.current_scope.organization
|
|
timezone = (assigns[:current_scope] && assigns.current_scope.timezone) || "UTC"
|
|
|
|
# Get policies needing consent from assigns (set in CheckPolicyConsent plug)
|
|
policies_needing_consent = Map.get(assigns, :policies_needing_consent, [])
|
|
|
|
# Check if this is the help page for banner display
|
|
always_show_banner = if Map.get(assigns, :active_page) == "help", do: "true", else: "false"
|
|
|
|
assigns =
|
|
assigns
|
|
|> Map.put(:current_organization, current_organization)
|
|
|> Map.put(:timezone, timezone)
|
|
|> Map.put(:policies_needing_consent, policies_needing_consent)
|
|
|> Map.put(:always_show_banner, always_show_banner)
|
|
|
|
~H"""
|
|
<div id="sidebar-wrapper" phx-hook="SidebarCollapse" class="min-h-screen bg-gray-50 dark:bg-gray-950">
|
|
<!-- Impersonation Banner -->
|
|
<%= if @current_scope && @current_scope.impersonating? do %>
|
|
<div class="bg-yellow-400 border-b-2 border-yellow-600 relative z-50">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
|
|
<span class="font-medium text-sm sm:text-base">
|
|
Impersonating: {@current_scope.user.email}
|
|
</span>
|
|
</div>
|
|
<.link
|
|
href={~p"/admin/impersonate"}
|
|
method="delete"
|
|
class="px-3 py-1 bg-white dark:bg-gray-900 text-yellow-900 dark:text-yellow-200 hover:bg-yellow-50 dark:hover:bg-gray-800 rounded font-medium text-sm"
|
|
>
|
|
Exit
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- Slim Top Bar -->
|
|
<header class="sticky top-0 z-40 border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900">
|
|
<div class="flex h-14 items-center justify-between px-4">
|
|
<div class="flex items-center gap-2">
|
|
<!-- Mobile hamburger -->
|
|
<button
|
|
:if={@current_organization || (@current_scope && @current_scope.user)}
|
|
type="button"
|
|
id="mobile-menu-button"
|
|
aria-expanded="false"
|
|
aria-controls="mobile-menu"
|
|
phx-click={
|
|
JS.toggle(to: "#mobile-menu")
|
|
|> JS.toggle_attribute({"aria-expanded", "true", "false"},
|
|
to: "#mobile-menu-button"
|
|
)
|
|
}
|
|
class="inline-flex items-center justify-center rounded-md p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700 focus:outline-none dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 lg:hidden"
|
|
>
|
|
<span class="sr-only">{t("Toggle main menu")}</span>
|
|
<.icon name="hero-bars-3" class="size-6" />
|
|
</button>
|
|
|
|
<.link
|
|
navigate={~p"/dashboard"}
|
|
class="text-lg font-bold text-gray-900 dark:text-white"
|
|
>
|
|
Towerops
|
|
</.link>
|
|
</div>
|
|
|
|
<div class="relative flex items-center gap-1 sm:gap-2">
|
|
<!-- Global Search Trigger -->
|
|
<button
|
|
:if={@current_organization}
|
|
type="button"
|
|
id="global-search-trigger"
|
|
phx-hook="GlobalSearchTrigger"
|
|
class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200 transition-colors"
|
|
title="Search (⌘K)"
|
|
>
|
|
<.icon name="hero-magnifying-glass" class="h-4 w-4" />
|
|
<span class="hidden sm:inline text-xs">{t("Search")}</span>
|
|
<kbd class="hidden sm:inline-flex items-center gap-0.5 rounded border border-gray-300 bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:border-white/20 dark:bg-white/10 dark:text-gray-400">
|
|
⌘K
|
|
</kbd>
|
|
</button>
|
|
|
|
<!-- Notification bell -->
|
|
<.link
|
|
:if={@current_organization}
|
|
navigate={~p"/alerts"}
|
|
class="relative p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-800 transition-colors"
|
|
title={t("Alerts")}
|
|
>
|
|
<.icon name="hero-bell" class="h-5 w-5" />
|
|
<%= if @unresolved_alert_count > 0 do %>
|
|
<span class="absolute top-0.5 right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-0.5 text-[10px] font-bold leading-none text-white ring-1 ring-white dark:ring-gray-950">
|
|
<%= if @unresolved_alert_count > 100 do %>
|
|
|
|
<% else %>
|
|
{@unresolved_alert_count}
|
|
<% end %>
|
|
</span>
|
|
<% end %>
|
|
</.link>
|
|
|
|
<!-- Help icon -->
|
|
<.link
|
|
navigate={~p"/help"}
|
|
class="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-800 transition-colors"
|
|
title={t("Help")}
|
|
>
|
|
<.icon name="hero-question-mark-circle" class="h-5 w-5" />
|
|
</.link>
|
|
|
|
<!-- Theme toggle -->
|
|
<.theme_toggle />
|
|
|
|
<!-- User menu -->
|
|
<button
|
|
type="button"
|
|
id="org-menu-button"
|
|
aria-expanded="false"
|
|
aria-haspopup="true"
|
|
aria-controls="org-menu"
|
|
phx-click={
|
|
JS.toggle(to: "#org-menu")
|
|
|> JS.toggle_attribute({"aria-expanded", "true", "false"}, to: "#org-menu-button")
|
|
}
|
|
class="inline-flex items-center justify-center rounded-full p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-800 transition-colors"
|
|
title={t("Menu")}
|
|
>
|
|
<.icon name="hero-user-circle" class="h-6 w-6" />
|
|
</button>
|
|
|
|
<div
|
|
id="org-menu"
|
|
role="menu"
|
|
class="hidden absolute top-full right-0 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 z-50 dark:bg-gray-800 dark:ring-white/10"
|
|
phx-click-away={
|
|
JS.hide(to: "#org-menu")
|
|
|> JS.set_attribute({"aria-expanded", "false"}, to: "#org-menu-button")
|
|
}
|
|
>
|
|
<div class="py-1">
|
|
<%= if @current_scope && @current_scope.user && @current_scope.user.is_superuser do %>
|
|
<.link
|
|
role="menuitem"
|
|
navigate={~p"/admin"}
|
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
|
phx-click={JS.hide(to: "#org-menu")}
|
|
>
|
|
{t("Admin Panel")}
|
|
</.link>
|
|
<% end %>
|
|
<.link
|
|
role="menuitem"
|
|
navigate={~p"/users/settings"}
|
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
|
phx-click={JS.hide(to: "#org-menu")}
|
|
>
|
|
{t("User Settings")}
|
|
</.link>
|
|
<.link
|
|
role="menuitem"
|
|
navigate={~p"/users/my-data"}
|
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
|
phx-click={JS.hide(to: "#org-menu")}
|
|
>
|
|
{t("My Data")}
|
|
</.link>
|
|
<.link
|
|
role="menuitem"
|
|
navigate={~p"/changelog"}
|
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
|
phx-click={JS.hide(to: "#org-menu")}
|
|
>
|
|
🆕 {t("What's New")}
|
|
</.link>
|
|
<div class="border-t border-gray-100 dark:border-white/10"></div>
|
|
<.link
|
|
role="menuitem"
|
|
href={~p"/users/log-out"}
|
|
method="delete"
|
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
|
>
|
|
{t("Log out")}
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Desktop Sidebar -->
|
|
<aside
|
|
:if={@current_organization || (@current_scope && @current_scope.user)}
|
|
id="app-sidebar"
|
|
class="fixed top-14 left-0 bottom-0 z-30 hidden lg:flex lg:flex-col bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-white/10 overflow-y-auto overflow-x-hidden"
|
|
>
|
|
<!-- Nav sections -->
|
|
<nav class="flex-1 px-2 py-4 space-y-1">
|
|
<!-- MONITOR section -->
|
|
<p data-sidebar-section class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("Monitor")}
|
|
</p>
|
|
<.sidebar_link
|
|
navigate={~p"/dashboard"}
|
|
active={@active_page == "dashboard"}
|
|
icon="hero-home"
|
|
label={t("Dashboard")}
|
|
/>
|
|
<.sidebar_link
|
|
navigate={~p"/devices"}
|
|
active={@active_page == "devices"}
|
|
icon="hero-server"
|
|
label={t("Devices")}
|
|
/>
|
|
<.sidebar_link
|
|
:if={@current_organization && @current_organization.use_sites}
|
|
navigate={~p"/sites"}
|
|
active={@active_page == "sites"}
|
|
icon="hero-building-office"
|
|
label={t("Sites")}
|
|
/>
|
|
<.sidebar_link
|
|
navigate={~p"/network-map"}
|
|
active={@active_page == "network-map"}
|
|
icon="hero-map"
|
|
label={t("Network Map")}
|
|
/>
|
|
|
|
<!-- RESPOND section -->
|
|
<p data-sidebar-section class="px-3 mt-4 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("Respond")}
|
|
</p>
|
|
<.sidebar_link
|
|
navigate={~p"/alerts"}
|
|
active={@active_page == "alerts"}
|
|
icon="hero-bell-alert"
|
|
label={t("Alerts")}
|
|
badge={@unresolved_alert_count}
|
|
/>
|
|
<.sidebar_link
|
|
navigate={~p"/maintenance"}
|
|
active={@active_page == "maintenance"}
|
|
icon="hero-wrench-screwdriver"
|
|
label={t("Maintenance")}
|
|
/>
|
|
<.sidebar_link
|
|
navigate={~p"/schedules"}
|
|
active={@active_page == "schedules"}
|
|
icon="hero-calendar-days"
|
|
label={t("Schedules")}
|
|
/>
|
|
|
|
<!-- ANALYZE section -->
|
|
<p data-sidebar-section class="px-3 mt-4 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("Analyze")}
|
|
</p>
|
|
<.sidebar_link
|
|
navigate={~p"/insights"}
|
|
active={@active_page == "insights"}
|
|
icon="hero-light-bulb"
|
|
label={t("Insights")}
|
|
/>
|
|
<.sidebar_link
|
|
navigate={~p"/trace"}
|
|
active={@active_page == "trace"}
|
|
icon="hero-magnifying-glass"
|
|
label={t("Trace")}
|
|
/>
|
|
<.sidebar_link
|
|
navigate={~p"/activity"}
|
|
active={@active_page == "activity"}
|
|
icon="hero-clock"
|
|
label={t("Activity")}
|
|
/>
|
|
</nav>
|
|
|
|
<!-- Bottom pinned: org switcher + settings + collapse toggle -->
|
|
<div class="border-t border-gray-200 dark:border-white/10 px-2 py-3 space-y-1">
|
|
<!-- Org switcher -->
|
|
<div :if={@current_organization} class="relative">
|
|
<button
|
|
type="button"
|
|
id="sidebar-org-switcher-button"
|
|
aria-expanded="false"
|
|
aria-haspopup="true"
|
|
phx-click={
|
|
JS.toggle(to: "#sidebar-org-switcher-menu")
|
|
|> JS.toggle_attribute({"aria-expanded", "true", "false"},
|
|
to: "#sidebar-org-switcher-button"
|
|
)
|
|
}
|
|
class="flex items-center gap-2 w-full rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white transition-colors"
|
|
data-sidebar-tooltip={@current_organization.name}
|
|
>
|
|
<.icon name="hero-building-office-2" class="h-5 w-5 shrink-0" />
|
|
<span data-sidebar-text class="truncate flex-1 text-left">{@current_organization.name}</span>
|
|
<span data-sidebar-text><.icon name="hero-chevron-up-down" class="h-3 w-3 shrink-0" /></span>
|
|
</button>
|
|
<div
|
|
id="sidebar-org-switcher-menu"
|
|
role="menu"
|
|
class="hidden absolute bottom-full left-0 mb-1 w-48 origin-bottom-left rounded-md bg-white shadow-lg ring-1 ring-black/5 z-50 dark:bg-gray-800 dark:ring-white/10"
|
|
phx-click-away={
|
|
JS.hide(to: "#sidebar-org-switcher-menu")
|
|
|> JS.set_attribute({"aria-expanded", "false"}, to: "#sidebar-org-switcher-button")
|
|
}
|
|
>
|
|
<div class="py-1">
|
|
<.link
|
|
role="menuitem"
|
|
navigate={~p"/orgs"}
|
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
|
phx-click={JS.hide(to: "#sidebar-org-switcher-menu")}
|
|
>
|
|
{t("Switch Organization")}
|
|
</.link>
|
|
<.link
|
|
role="menuitem"
|
|
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
|
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
|
phx-click={JS.hide(to: "#sidebar-org-switcher-menu")}
|
|
>
|
|
{t("Organization Settings")}
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Settings link -->
|
|
<.sidebar_link
|
|
:if={@current_organization}
|
|
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
|
|
active={@active_page == "settings"}
|
|
icon="hero-cog-6-tooth"
|
|
label={t("Settings")}
|
|
/>
|
|
|
|
<!-- Collapse toggle -->
|
|
<button
|
|
type="button"
|
|
data-sidebar-toggle
|
|
phx-click={JS.toggle_class("sidebar-collapsed", to: "#sidebar-wrapper")}
|
|
class="flex items-center justify-center w-full rounded-md px-3 py-2 text-gray-400 hover:bg-gray-50 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300 transition-colors"
|
|
title={t("Toggle sidebar")}
|
|
>
|
|
<.icon name="hero-chevron-double-left" class="h-5 w-5 sidebar-collapse-icon" />
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Mobile slide-out panel -->
|
|
<div
|
|
:if={@current_organization || (@current_scope && @current_scope.user)}
|
|
id="mobile-menu"
|
|
class="hidden fixed inset-0 z-50 lg:hidden"
|
|
>
|
|
<!-- Backdrop -->
|
|
<div
|
|
class="fixed inset-0 bg-black/30 dark:bg-black/50"
|
|
phx-click={JS.hide(to: "#mobile-menu")}
|
|
>
|
|
</div>
|
|
<!-- Panel -->
|
|
<div class="fixed inset-y-0 left-0 w-72 bg-white dark:bg-gray-900 shadow-xl overflow-y-auto">
|
|
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-white/10">
|
|
<span class="text-lg font-bold text-gray-900 dark:text-white">Towerops</span>
|
|
<button
|
|
type="button"
|
|
phx-click={JS.hide(to: "#mobile-menu")}
|
|
class="p-2 rounded-md text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
>
|
|
<.icon name="hero-x-mark" class="size-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Org name -->
|
|
<div
|
|
:if={@current_organization}
|
|
class="px-4 py-3 border-b border-gray-100 dark:border-white/5"
|
|
>
|
|
<p class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
{t("Organization")}
|
|
</p>
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-white mt-0.5">
|
|
{@current_organization.name}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- MONITOR -->
|
|
<div class="px-2 py-3">
|
|
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("Monitor")}
|
|
</p>
|
|
<.mobile_nav_link navigate={~p"/dashboard"} active={@active_page == "dashboard"}>
|
|
<.icon name="hero-home" class="size-5" /> {t("Dashboard")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/devices"} active={@active_page == "devices"}>
|
|
<.icon name="hero-server" class="size-5" /> {t("Devices")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link
|
|
:if={@current_organization && @current_organization.use_sites}
|
|
navigate={~p"/sites"}
|
|
active={@active_page == "sites"}
|
|
>
|
|
<.icon name="hero-building-office" class="size-5" /> {t("Sites")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/network-map"} active={@active_page == "network-map"}>
|
|
<.icon name="hero-map" class="size-5" /> {t("Network Map")}
|
|
</.mobile_nav_link>
|
|
</div>
|
|
|
|
<!-- RESPOND -->
|
|
<div class="px-2 py-3 border-t border-gray-100 dark:border-white/5">
|
|
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("Respond")}
|
|
</p>
|
|
<.mobile_nav_link navigate={~p"/alerts"} active={@active_page == "alerts"}>
|
|
<.icon name="hero-bell-alert" class="size-5" /> {t("Alerts")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/maintenance"} active={@active_page == "maintenance"}>
|
|
<.icon name="hero-wrench-screwdriver" class="size-5" /> {t("Maintenance")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/schedules"} active={@active_page == "schedules"}>
|
|
<.icon name="hero-calendar-days" class="size-5" /> {t("Schedules")}
|
|
</.mobile_nav_link>
|
|
</div>
|
|
|
|
<!-- ANALYZE -->
|
|
<div class="px-2 py-3 border-t border-gray-100 dark:border-white/5">
|
|
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("Analyze")}
|
|
</p>
|
|
<.mobile_nav_link navigate={~p"/insights"} active={@active_page == "insights"}>
|
|
<.icon name="hero-light-bulb" class="size-5" /> {t("Insights")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/trace"} active={@active_page == "trace"}>
|
|
<.icon name="hero-magnifying-glass" class="size-5" /> {t("Trace")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/activity"} active={@active_page == "activity"}>
|
|
<.icon name="hero-clock" class="size-5" /> {t("Activity")}
|
|
</.mobile_nav_link>
|
|
</div>
|
|
|
|
<!-- Account -->
|
|
<div class="px-2 py-3 border-t border-gray-100 dark:border-white/5">
|
|
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("Account")}
|
|
</p>
|
|
<.mobile_nav_link
|
|
:if={@current_organization}
|
|
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
|
|
active={@active_page == "settings"}
|
|
>
|
|
<.icon name="hero-cog-6-tooth" class="size-5" /> {t("Organization Settings")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link
|
|
navigate={~p"/users/settings"}
|
|
active={@active_page == "user_settings"}
|
|
>
|
|
<.icon name="hero-user-circle" class="size-5" /> {t("My Settings")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/help"} active={@active_page == "help"}>
|
|
<.icon name="hero-question-mark-circle" class="size-5" /> {t("Help")}
|
|
</.mobile_nav_link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main content -->
|
|
<div id="main-wrapper" class="flex flex-col min-h-[calc(100vh-3.5rem)]">
|
|
<main id="main-content" tabindex="-1" class="flex-1 py-6 sm:py-10 page-fade-in">
|
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
<!-- Beta Testing Banner -->
|
|
<div
|
|
id="beta-banner"
|
|
phx-hook="BetaBannerDismiss"
|
|
data-always-show={@always_show_banner}
|
|
class="mb-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800"
|
|
>
|
|
<div class="flex items-start gap-3">
|
|
<.icon
|
|
name="hero-heart"
|
|
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
|
|
/>
|
|
<div class="flex-1">
|
|
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
|
|
Thanks for testing Towerops!
|
|
</h3>
|
|
<p class="text-sm text-blue-800 dark:text-blue-300">
|
|
We'd love to hear your feedback, suggestions, or help you with any questions. Please email us at
|
|
<a
|
|
href="mailto:hi@towerops.net"
|
|
class="font-medium underline hover:text-blue-900 dark:hover:text-blue-200"
|
|
>
|
|
hi@towerops.net
|
|
</a>
|
|
for support, feature requests, or just to say hello!
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
data-dismiss
|
|
class="flex-shrink-0 rounded-md p-1.5 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-800/50 focus:outline-none focus:ring-2 focus:ring-blue-600 dark:focus:ring-blue-400"
|
|
aria-label="Dismiss banner"
|
|
>
|
|
<.icon name="hero-x-mark" class="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(function() {
|
|
var banner = document.getElementById('beta-banner');
|
|
if (banner) {
|
|
var isHelpPage = banner.dataset.alwaysShow === 'true';
|
|
|
|
if (isHelpPage) {
|
|
// On help page: keep banner visible, hide dismiss button
|
|
var dismissBtn = banner.querySelector('[data-dismiss]');
|
|
if (dismissBtn) {
|
|
dismissBtn.style.display = 'none';
|
|
}
|
|
} else {
|
|
// On other pages: check if dismissed
|
|
var isDismissed = localStorage.getItem('betaBannerDismissed') === 'true';
|
|
if (isDismissed) {
|
|
banner.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
})();
|
|
</script>
|
|
|
|
{render_slot(@inner_block)}
|
|
</div>
|
|
</main>
|
|
|
|
<.footer timezone={Map.get(assigns, :timezone, "UTC")} />
|
|
</div>
|
|
</div>
|
|
|
|
<.flash_group flash={@flash} />
|
|
<CookieConsent.banner requires_consent={@requires_cookie_consent} />
|
|
<%= if @current_scope && @current_scope.user do %>
|
|
<ConsentPrompt.consent_modal
|
|
user_id={@current_scope.user.id}
|
|
policies={@policies_needing_consent}
|
|
/>
|
|
<% end %>
|
|
<%= if @current_organization do %>
|
|
<.live_component
|
|
module={GlobalSearchComponent}
|
|
id="global-search"
|
|
organization_id={@current_organization.id}
|
|
/>
|
|
<.live_component
|
|
module={ToweropsWeb.Live.Components.StatusTitleComponent}
|
|
id="status-title"
|
|
organization_id={@current_organization.id}
|
|
/>
|
|
<% end %>
|
|
"""
|
|
end
|
|
|
|
attr :navigate, :string, required: true
|
|
attr :active, :boolean, default: false
|
|
attr :icon, :string, required: true
|
|
attr :label, :string, required: true
|
|
attr :badge, :integer, default: 0
|
|
|
|
defp sidebar_link(assigns) do
|
|
~H"""
|
|
<.link
|
|
navigate={@navigate}
|
|
class={[
|
|
"relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
|
if(@active,
|
|
do:
|
|
"bg-blue-50 text-blue-700 border-l-2 border-blue-600 dark:bg-blue-900/20 dark:text-blue-400",
|
|
else:
|
|
"text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
|
)
|
|
]}
|
|
data-sidebar-tooltip={@label}
|
|
>
|
|
<.icon name={@icon} class="h-5 w-5 shrink-0" />
|
|
<span data-sidebar-text class="truncate">{@label}</span>
|
|
<%= if @badge > 0 do %>
|
|
<span
|
|
data-sidebar-text
|
|
class="ml-auto inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
|
>
|
|
{if @badge > 99, do: "99+", else: @badge}
|
|
</span>
|
|
<% end %>
|
|
</.link>
|
|
"""
|
|
end
|
|
|
|
attr :navigate, :string, required: true
|
|
attr :active, :boolean, default: false
|
|
slot :inner_block, required: true
|
|
|
|
defp mobile_nav_link(assigns) do
|
|
~H"""
|
|
<.link
|
|
navigate={@navigate}
|
|
class={[
|
|
"flex items-center gap-3 rounded-md px-3 py-2 text-base font-medium",
|
|
if(@active,
|
|
do: "bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white",
|
|
else:
|
|
"text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white"
|
|
)
|
|
]}
|
|
>
|
|
{render_slot(@inner_block)}
|
|
</.link>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Shows the flash group with standard titles and content.
|
|
|
|
## Examples
|
|
|
|
<.flash_group flash={@flash} />
|
|
"""
|
|
attr :flash, :map, required: true, doc: "the map of flash messages"
|
|
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
|
|
|
|
def flash_group(assigns) do
|
|
~H"""
|
|
<div id={@id} aria-live="polite">
|
|
<.flash kind={:info} flash={@flash} />
|
|
<.flash kind={:error} flash={@flash} />
|
|
|
|
<.flash
|
|
id="client-error"
|
|
kind={:error}
|
|
title={gettext("We can't find the internet")}
|
|
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
|
|
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
|
|
hidden
|
|
>
|
|
{gettext("Attempting to reconnect")}
|
|
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
|
</.flash>
|
|
|
|
<.flash
|
|
id="server-error"
|
|
kind={:error}
|
|
title={gettext("Something went wrong!")}
|
|
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
|
|
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
|
|
hidden
|
|
>
|
|
{gettext("Attempting to reconnect")}
|
|
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
|
</.flash>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Provides dark vs light theme toggle based on themes defined in app.css.
|
|
|
|
See <head> in root.html.heex which applies the theme before page load.
|
|
"""
|
|
def theme_toggle(assigns) do
|
|
~H"""
|
|
<div class="relative" id="theme-toggle" phx-update="ignore">
|
|
<button
|
|
type="button"
|
|
id="theme-menu-button"
|
|
aria-expanded="false"
|
|
aria-haspopup="true"
|
|
aria-controls="theme-menu"
|
|
phx-click={
|
|
JS.toggle(to: "#theme-menu")
|
|
|> JS.toggle_attribute({"aria-expanded", "true", "false"}, to: "#theme-menu-button")
|
|
}
|
|
class="flex items-center justify-center p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-800"
|
|
aria-label="Toggle theme"
|
|
>
|
|
<.icon name="hero-sun" class="size-5 block dark:hidden" />
|
|
<.icon name="hero-moon" class="size-5 hidden dark:block" />
|
|
</button>
|
|
|
|
<div
|
|
id="theme-menu"
|
|
role="menu"
|
|
class="hidden absolute top-full right-0 mt-2 w-36 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 z-50 dark:bg-gray-800 dark:ring-white/10"
|
|
phx-click-away={
|
|
JS.hide(to: "#theme-menu")
|
|
|> JS.set_attribute({"aria-expanded", "false"}, to: "#theme-menu-button")
|
|
}
|
|
>
|
|
<div class="py-1">
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
phx-click={JS.dispatch("phx:set-theme") |> JS.hide(to: "#theme-menu")}
|
|
data-phx-theme="system"
|
|
>
|
|
<.icon name="hero-computer-desktop" class="size-4" /> Auto
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
phx-click={JS.dispatch("phx:set-theme") |> JS.hide(to: "#theme-menu")}
|
|
data-phx-theme="light"
|
|
>
|
|
<.icon name="hero-sun" class="size-4" /> Light
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
phx-click={JS.dispatch("phx:set-theme") |> JS.hide(to: "#theme-menu")}
|
|
data-phx-theme="dark"
|
|
>
|
|
<.icon name="hero-moon" class="size-4" /> Dark
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Renders the admin layout.
|
|
|
|
Use this for admin pages that require superuser access.
|
|
|
|
## Examples
|
|
|
|
<Layouts.admin flash={@flash}>
|
|
<h1>Admin Content</h1>
|
|
</Layouts.admin>
|
|
|
|
"""
|
|
attr :flash, :map, required: true, doc: "the map of flash messages"
|
|
|
|
attr :timezone, :string, default: "UTC", doc: "the user's timezone"
|
|
|
|
attr :requires_cookie_consent, :boolean,
|
|
default: false,
|
|
doc: "whether to show the cookie consent banner"
|
|
|
|
slot :inner_block, required: true
|
|
|
|
def admin(assigns) do
|
|
# Get cookie consent from process dictionary (set in on_mount hook)
|
|
requires_cookie_consent = Process.get(:requires_cookie_consent, false)
|
|
assigns = Map.put(assigns, :requires_cookie_consent, requires_cookie_consent)
|
|
|
|
~H"""
|
|
<div class="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950">
|
|
<nav class="bg-white border-b border-gray-200 dark:border-white/10 dark:bg-gray-900">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div class="flex justify-between h-14 sm:h-16">
|
|
<div class="flex items-center">
|
|
<!-- Mobile menu button -->
|
|
<button
|
|
type="button"
|
|
id="admin-mobile-menu-button"
|
|
phx-click={
|
|
JS.toggle(to: "#admin-mobile-menu")
|
|
|> JS.toggle(to: "#admin-menu-icon-open")
|
|
|> JS.toggle(to: "#admin-menu-icon-close")
|
|
}
|
|
class="inline-flex items-center justify-center rounded-md p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700 focus:outline-none dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 sm:hidden"
|
|
>
|
|
<span class="sr-only">Open admin menu</span>
|
|
<span id="admin-menu-icon-open"><.icon name="hero-bars-3" class="size-6" /></span>
|
|
<span id="admin-menu-icon-close" class="hidden">
|
|
<.icon name="hero-x-mark" class="size-6" />
|
|
</span>
|
|
</button>
|
|
|
|
<.link
|
|
navigate={~p"/admin"}
|
|
class="flex items-center text-lg sm:text-xl font-bold text-gray-900 dark:text-white ml-2 sm:ml-0"
|
|
>
|
|
<span class="hidden sm:inline">Towerops Admin</span>
|
|
<span class="sm:hidden">Admin</span>
|
|
</.link>
|
|
|
|
<!-- Desktop navigation -->
|
|
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
|
<.link
|
|
navigate={~p"/admin/users"}
|
|
class="inline-flex items-center px-1 pt-1 pb-4 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
|
|
>
|
|
Users
|
|
</.link>
|
|
<.link
|
|
navigate={~p"/admin/organizations"}
|
|
class="inline-flex items-center px-1 pt-1 pb-4 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
|
|
>
|
|
Organizations
|
|
</.link>
|
|
<.link
|
|
navigate={~p"/admin/security"}
|
|
class="inline-flex items-center px-1 pt-1 pb-4 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
|
|
>
|
|
IP Allowlist
|
|
</.link>
|
|
<.link
|
|
navigate={~p"/admin/errors"}
|
|
class="inline-flex items-center px-1 pt-1 pb-4 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
|
|
>
|
|
Exceptions
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-1 sm:gap-2">
|
|
<.link
|
|
navigate={~p"/orgs"}
|
|
class="text-xs sm:text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
|
>
|
|
<span class="hidden sm:inline">Back to App</span>
|
|
<span class="sm:hidden">App</span>
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mobile menu -->
|
|
<div class="hidden sm:hidden" id="admin-mobile-menu">
|
|
<div class="space-y-1 px-2 pb-3 pt-2 border-t border-gray-200 dark:border-white/10">
|
|
<.mobile_nav_link navigate={~p"/admin/users"} active={false}>
|
|
<.icon name="hero-users" class="size-5" /> Users
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/admin/organizations"} active={false}>
|
|
<.icon name="hero-building-office-2" class="size-5" /> Organizations
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/admin/security"} active={false}>
|
|
<.icon name="hero-shield-check" class="size-5" /> IP Allowlist
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/admin/errors"} active={false}>
|
|
<.icon name="hero-bug-ant" class="size-5" /> Exceptions
|
|
</.mobile_nav_link>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<main class="flex-1">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
|
{render_slot(@inner_block)}
|
|
</div>
|
|
</main>
|
|
|
|
<.footer timezone={Map.get(assigns, :timezone, "UTC")} />
|
|
</div>
|
|
|
|
<.flash_group flash={@flash} />
|
|
<CookieConsent.banner requires_consent={@requires_cookie_consent} />
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Renders the application footer with copyright notice and deploy timestamp.
|
|
"""
|
|
attr :timezone, :string, default: "UTC"
|
|
|
|
def footer(assigns) do
|
|
alias ToweropsWeb.TimeHelpers
|
|
|
|
build_time = Towerops.Application.build_timestamp()
|
|
timezone = Map.get(assigns, :timezone, "UTC")
|
|
|
|
assigns =
|
|
assigns
|
|
|> assign(:build_time, build_time)
|
|
|> assign(:time_ago, TimeHelpers.format_time_ago(build_time))
|
|
|> assign(:formatted_time, TimeHelpers.format_iso8601(build_time, timezone))
|
|
|
|
~H"""
|
|
<footer class="border-t border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900">
|
|
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
|
<div class="flex flex-col items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
|
<p>Copyright © {Date.utc_today().year} Towerops</p>
|
|
<p class="text-xs">
|
|
<.link
|
|
navigate={~p"/privacy"}
|
|
class="hover:text-gray-900 dark:hover:text-gray-200 underline decoration-dotted underline-offset-2"
|
|
>
|
|
Privacy Policy
|
|
</.link>
|
|
<span class="mx-2">·</span>
|
|
<.link
|
|
navigate={~p"/terms"}
|
|
class="hover:text-gray-900 dark:hover:text-gray-200 underline decoration-dotted underline-offset-2"
|
|
>
|
|
Terms of Service
|
|
</.link>
|
|
<span class="mx-2">·</span>
|
|
<.link
|
|
href="/changelog.txt"
|
|
class="hover:text-gray-900 dark:hover:text-gray-200 underline decoration-dotted underline-offset-2"
|
|
>
|
|
Changelog
|
|
</.link>
|
|
</p>
|
|
<p class="text-xs">
|
|
Last deploy <span title={@formatted_time}>{@time_ago}</span> · {@formatted_time}
|
|
</p>
|
|
<div class="flex items-center gap-1 text-xs opacity-60">
|
|
<span class="leading-none">made in</span>
|
|
<img src="/images/texas.svg" alt="Texas" class="h-5 w-5" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Returns the page title with status emoji prepended based on organization health.
|
|
"""
|
|
def status_title(assigns) do
|
|
page_title = assigns[:page_title] || "TowerOps"
|
|
organization = get_in(assigns, [:current_scope, Access.key(:organization)])
|
|
|
|
if organization do
|
|
StatusHelpers.title_with_status(page_title, organization)
|
|
else
|
|
page_title
|
|
end
|
|
end
|
|
end
|