1048 lines
41 KiB
Elixir
1048 lines
41 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
|
|
alias ToweropsWeb.TimeHelpers
|
|
|
|
# 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
|
|
assigns = Map.put_new(assigns, :requires_cookie_consent, false)
|
|
|
|
~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
|
|
assigns = Map.put_new(assigns, :requires_cookie_consent, false)
|
|
|
|
# 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>
|
|
|
|
<!-- 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"/mobile/qr-login"}
|
|
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")}
|
|
>
|
|
<span class="flex items-center gap-2">
|
|
<.icon name="hero-device-phone-mobile" class="h-4 w-4" />
|
|
{t("Link Mobile App")}
|
|
</span>
|
|
</.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>
|
|
|
|
<!-- Sidebar + Main content wrapper -->
|
|
<div class="flex">
|
|
<!-- Desktop Sidebar -->
|
|
<aside
|
|
:if={@current_organization || (@current_scope && @current_scope.user)}
|
|
id="app-sidebar"
|
|
class="sticky top-14 left-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 h-[calc(100vh-3.5rem)]"
|
|
>
|
|
<!-- 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")}
|
|
/>
|
|
<.sidebar_link
|
|
navigate={~p"/coverage"}
|
|
active={@active_page == "coverage"}
|
|
icon="hero-signal"
|
|
label={t("Coverage")}
|
|
/>
|
|
<%!-- <.sidebar_link
|
|
navigate={~p"/weathermap"}
|
|
active={@active_page == "weathermap"}
|
|
icon="hero-signal"
|
|
label={t("Weathermap")}
|
|
/> --%>
|
|
|
|
<!-- 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("On-Call")}
|
|
/>
|
|
|
|
<!-- 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>
|
|
<%!-- RF Links - hidden from nav, code kept for future use
|
|
<.sidebar_link
|
|
navigate={~p"/rf-links"}
|
|
active={@active_page == "rf-links"}
|
|
icon="hero-signal"
|
|
label={t("RF Links")}
|
|
/>
|
|
--%>
|
|
<.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")}
|
|
/>
|
|
<.sidebar_link
|
|
navigate={~p"/reports"}
|
|
active={@active_page == "reports"}
|
|
icon="hero-document-chart-bar"
|
|
label={t("Reports")}
|
|
/>
|
|
</nav>
|
|
|
|
<!-- Bottom pinned: org switcher + settings + collapse toggle -->
|
|
<div class="border-t border-gray-200 dark:border-white/10 px-2 py-3.5 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
|
|
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"}>
|
|
{t("Dashboard")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/devices"} active={@active_page == "devices"}>
|
|
{t("Devices")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link
|
|
:if={@current_organization && @current_organization.use_sites}
|
|
navigate={~p"/sites"}
|
|
active={@active_page == "sites"}
|
|
>
|
|
{t("Sites")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/network-map"} active={@active_page == "network-map"}>
|
|
{t("Network Map")}
|
|
</.mobile_nav_link>
|
|
<%!-- <.mobile_nav_link navigate={~p"/weathermap"} active={@active_page == "weathermap"}>
|
|
<.icon name="hero-signal" class="size-5" /> {t("Weathermap")}
|
|
</.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"}>
|
|
{t("Alerts")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/maintenance"} active={@active_page == "maintenance"}>
|
|
{t("Maintenance")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/schedules"} active={@active_page == "schedules"}>
|
|
{t("On-Call")}
|
|
</.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"}>
|
|
{t("Insights")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/trace"} active={@active_page == "trace"}>
|
|
{t("Trace")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/activity"} active={@active_page == "activity"}>
|
|
{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"}
|
|
>
|
|
{t("Organization Settings")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link
|
|
navigate={~p"/users/settings"}
|
|
active={@active_page == "user_settings"}
|
|
>
|
|
{t("My Settings")}
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/help"} active={@active_page == "help"}>
|
|
{t("Help")}
|
|
</.mobile_nav_link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main content -->
|
|
<div id="main-wrapper" class="flex flex-1 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:pl-4 lg:pr-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>
|
|
<!-- End sidebar + main content wrapper -->
|
|
</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}
|
|
>
|
|
<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("Reconnecting to server")}
|
|
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
|
|
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
|
|
hidden
|
|
>
|
|
{gettext("Hang tight — we'll be back in a moment")}
|
|
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
|
</.flash>
|
|
</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
|
|
assigns = Map.put_new(assigns, :requires_cookie_consent, false)
|
|
|
|
~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}>
|
|
Users
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/admin/organizations"} active={false}>
|
|
Organizations
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/admin/security"} active={false}>
|
|
IP Allowlist
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link navigate={~p"/admin/errors"} active={false}>
|
|
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
|
|
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
|