Add an Appearance section to the Personal tab in user settings with three clearly labelled options (Auto/Light/Dark). A ThemeSelector hook reads localStorage to highlight the active choice and updates it when selection changes. Remove the dropdown toggle from both the main nav and admin nav.
1092 lines
44 KiB
Elixir
1092 lines
44 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 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 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 %>
|
|
|
|
<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 lg: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 lg:ml-0">
|
|
<.link
|
|
navigate={~p"/dashboard"}
|
|
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 lg: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 max-w-xs"
|
|
>
|
|
<span class="truncate">{@current_organization.name}</span>
|
|
<.icon name="hero-chevron-down" class="h-3 w-3 shrink-0" />
|
|
</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 lg:ml-6 lg:flex lg:items-center lg: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>
|
|
<.nav_link
|
|
navigate={~p"/maintenance"}
|
|
active={@active_page == "maintenance"}
|
|
>
|
|
{t("Maintenance")}
|
|
</.nav_link>
|
|
<.nav_link
|
|
navigate={~p"/schedules"}
|
|
active={@active_page == "schedules"}
|
|
>
|
|
{t("Schedules")}
|
|
</.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 lg: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" />
|
|
<%= 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>
|
|
|
|
<!-- 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 lg: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>
|
|
<.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>
|
|
|
|
<!-- 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}
|
|
/>
|
|
<.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
|
|
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>
|
|
<.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
|