- Fix CLOAK_KEY placeholder in nix shell (invalid base64) - Fix error HTML test assertion to match actual template - Fix Gaiia sync: read 'subnet' field instead of 'block' for IP blocks - Fix Gaiia sync: extract nested status name from subscription objects - Fix security headers tests for environment detection - Fix mobile auth tests to use raw_token - Fix LiveView test assertions to match actual template content - Fix SNMP config test expectations for test environment - Refactor: resolve all Credo complexity/nesting issues - Extract helper functions in NetBox sync, VISP sync, Sonar sync - Flatten nested conditionals in settings, integrations, alerts - Use with/validate_present pattern for connection testing
1031 lines
42 KiB
Elixir
1031 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.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)"
|
|
|
|
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)
|
|
|
|
# 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 class="flex min-h-screen flex-col 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">
|
|
<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 text-yellow-900 hover:bg-yellow-50 rounded font-medium text-sm"
|
|
>
|
|
Exit
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<nav 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">
|
|
<div class="flex h-14 sm:h-16 justify-between">
|
|
<div class="flex items-center">
|
|
<!-- Mobile menu button -->
|
|
<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(to: "#mobile-menu-icon-open")
|
|
|> JS.toggle(to: "#mobile-menu-icon-close")
|
|
|> 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 md:hidden"
|
|
>
|
|
<span class="sr-only">Toggle main menu</span>
|
|
<span id="mobile-menu-icon-open"><.icon name="hero-bars-3" class="size-6" /></span>
|
|
<span id="mobile-menu-icon-close" class="hidden">
|
|
<.icon name="hero-x-mark" class="size-6" />
|
|
</span>
|
|
</button>
|
|
|
|
<div class="flex flex-shrink-0 items-center ml-2 md:ml-0">
|
|
<.link
|
|
navigate={~p"/orgs"}
|
|
class="text-lg sm:text-xl font-bold text-gray-900 dark:text-white"
|
|
>
|
|
Towerops
|
|
</.link>
|
|
</div>
|
|
|
|
<!-- Org selector (next to logo) -->
|
|
<div :if={@current_organization} class="hidden md:flex items-center ml-3">
|
|
<div class="h-4 w-px bg-gray-300 dark:bg-gray-600 mr-3"></div>
|
|
<button
|
|
type="button"
|
|
id="org-switcher-button"
|
|
aria-expanded="false"
|
|
aria-haspopup="true"
|
|
aria-controls="org-switcher-menu"
|
|
phx-click={
|
|
JS.toggle(to: "#org-switcher-menu")
|
|
|> JS.toggle_attribute({"aria-expanded", "true", "false"},
|
|
to: "#org-switcher-button"
|
|
)
|
|
}
|
|
class="inline-flex items-center gap-1 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors"
|
|
>
|
|
{@current_organization.name}
|
|
<.icon name="hero-chevron-down" class="h-3 w-3" />
|
|
</button>
|
|
<div
|
|
id="org-switcher-menu"
|
|
role="menu"
|
|
class="hidden absolute top-12 left-auto mt-1 w-48 origin-top 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-switcher-menu")
|
|
|> JS.set_attribute({"aria-expanded", "false"}, to: "#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: "#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: "#org-switcher-menu")}
|
|
>
|
|
{t("Organization Settings")}
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Desktop navigation: Primary -->
|
|
<div
|
|
:if={@current_organization || (@current_scope && @current_scope.user)}
|
|
class="hidden md:ml-6 md:flex md:items-center md:space-x-1"
|
|
>
|
|
<.nav_link
|
|
navigate={~p"/dashboard"}
|
|
active={@active_page == "dashboard"}
|
|
>
|
|
{t("Dashboard")}
|
|
</.nav_link>
|
|
<.nav_link
|
|
navigate={~p"/devices"}
|
|
active={@active_page == "devices"}
|
|
>
|
|
{t("Devices")}
|
|
</.nav_link>
|
|
<.nav_link
|
|
:if={@current_organization && @current_organization.use_sites}
|
|
navigate={~p"/sites"}
|
|
active={@active_page == "sites"}
|
|
>
|
|
{t("Sites")}
|
|
</.nav_link>
|
|
<.nav_link
|
|
navigate={~p"/alerts"}
|
|
active={@active_page == "alerts"}
|
|
>
|
|
{t("Alerts")}
|
|
</.nav_link>
|
|
|
|
<!-- Separator -->
|
|
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
|
|
|
<!-- More dropdown -->
|
|
<div class="relative">
|
|
<button
|
|
type="button"
|
|
id="more-nav-button"
|
|
aria-expanded="false"
|
|
aria-haspopup="true"
|
|
phx-click={
|
|
JS.toggle(to: "#more-nav-menu")
|
|
|> JS.toggle_attribute({"aria-expanded", "true", "false"},
|
|
to: "#more-nav-button"
|
|
)
|
|
}
|
|
class={[
|
|
"inline-flex items-center gap-1 h-14 sm:h-16 px-2 text-sm font-medium border-b-2 transition-colors",
|
|
if(@active_page in ["network-map", "insights", "trace", "activity"],
|
|
do:
|
|
"border-gray-900 text-gray-900 font-semibold dark:border-white dark:text-white",
|
|
else:
|
|
"border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
)
|
|
]}
|
|
>
|
|
{t("More")}
|
|
<.icon name="hero-chevron-down" class="h-3 w-3" />
|
|
</button>
|
|
<div
|
|
id="more-nav-menu"
|
|
role="menu"
|
|
class="hidden absolute top-full left-0 mt-0 w-44 origin-top-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: "#more-nav-menu")
|
|
|> JS.set_attribute({"aria-expanded", "false"}, to: "#more-nav-button")
|
|
}
|
|
>
|
|
<div class="py-1">
|
|
<.link
|
|
navigate={~p"/network-map"}
|
|
role="menuitem"
|
|
class={[
|
|
"block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-white/5",
|
|
if(@active_page == "network-map",
|
|
do: "text-gray-900 font-medium dark:text-white",
|
|
else: "text-gray-700 dark:text-gray-300"
|
|
)
|
|
]}
|
|
>
|
|
<.icon name="hero-map" class="h-4 w-4 inline mr-2" />{t("Network Map")}
|
|
</.link>
|
|
<.link
|
|
navigate={~p"/insights"}
|
|
role="menuitem"
|
|
class={[
|
|
"block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-white/5",
|
|
if(@active_page == "insights",
|
|
do: "text-gray-900 font-medium dark:text-white",
|
|
else: "text-gray-700 dark:text-gray-300"
|
|
)
|
|
]}
|
|
>
|
|
<.icon name="hero-light-bulb" class="h-4 w-4 inline mr-2" />{t("Insights")}
|
|
</.link>
|
|
<.link
|
|
navigate={~p"/trace"}
|
|
role="menuitem"
|
|
class={[
|
|
"block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-white/5",
|
|
if(@active_page == "trace",
|
|
do: "text-gray-900 font-medium dark:text-white",
|
|
else: "text-gray-700 dark:text-gray-300"
|
|
)
|
|
]}
|
|
>
|
|
<.icon name="hero-magnifying-glass" class="h-4 w-4 inline mr-2" />{t("Trace")}
|
|
</.link>
|
|
<.link
|
|
navigate={~p"/activity"}
|
|
role="menuitem"
|
|
class={[
|
|
"block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-white/5",
|
|
if(@active_page == "activity",
|
|
do: "text-gray-900 font-medium dark:text-white",
|
|
else: "text-gray-700 dark:text-gray-300"
|
|
)
|
|
]}
|
|
>
|
|
<.icon name="hero-clock" class="h-4 w-4 inline mr-2" />{t("Activity")}
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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="hidden md: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="text-xs">{t("Search")}</span>
|
|
<kbd class="hidden lg: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" />
|
|
</.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 />
|
|
|
|
<!-- 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>
|
|
</div>
|
|
|
|
<!-- Mobile slide-out panel -->
|
|
<div
|
|
:if={@current_organization || (@current_scope && @current_scope.user)}
|
|
id="mobile-menu"
|
|
class="hidden fixed inset-0 z-50 md:hidden"
|
|
>
|
|
<!-- Backdrop -->
|
|
<div
|
|
class="fixed inset-0 bg-black/30 dark:bg-black/50"
|
|
phx-click={
|
|
JS.hide(to: "#mobile-menu")
|
|
|> JS.toggle(to: "#mobile-menu-icon-open")
|
|
|> JS.toggle(to: "#mobile-menu-icon-close")
|
|
}
|
|
>
|
|
</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")
|
|
|> JS.toggle(to: "#mobile-menu-icon-open")
|
|
|> JS.toggle(to: "#mobile-menu-icon-close")
|
|
}
|
|
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>
|
|
|
|
<!-- Primary nav -->
|
|
<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("Navigation")}
|
|
</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"/alerts"} active={@active_page == "alerts"}>
|
|
<.icon name="hero-bell" class="size-5" /> {t("Alerts")}
|
|
</.mobile_nav_link>
|
|
</div>
|
|
|
|
<!-- Secondary nav -->
|
|
<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("Tools")}
|
|
</p>
|
|
<.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>
|
|
<.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>
|
|
|
|
<!-- Organization & 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>
|
|
</nav>
|
|
|
|
<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>
|
|
|
|
<.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}
|
|
/>
|
|
<% end %>
|
|
"""
|
|
end
|
|
|
|
attr :navigate, :string, required: true
|
|
attr :active, :boolean, default: false
|
|
slot :inner_block, required: true
|
|
|
|
defp nav_link(assigns) do
|
|
~H"""
|
|
<.link
|
|
navigate={@navigate}
|
|
class={[
|
|
"inline-flex items-center border-b-2 px-2 h-14 sm:h-16 text-sm transition-colors",
|
|
if(@active,
|
|
do: "border-gray-900 text-gray-900 font-semibold dark:border-white dark:text-white",
|
|
else:
|
|
"border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 font-medium dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-200"
|
|
)
|
|
]}
|
|
>
|
|
{render_slot(@inner_block)}
|
|
</.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>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-1 sm:gap-2">
|
|
<.theme_toggle />
|
|
<.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>
|
|
</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
|
|
end
|