refactor: remove daisyUI, use straight Tailwind v4 with custom color palette

- Remove daisyUI plugins and theme from app.css
- Define 5 custom color palettes: sweet-salmon, desert-sand, wheat, cool-steel, cerulean
- Add @utility card, badge, btn classes to replace daisyUI components
- Replace all daisyUI classes across 16+ template files
- Update tests for new class return values
- Set light mode as default theme
This commit is contained in:
Graham McIntire 2026-06-23 10:44:20 -05:00
parent 785f077c50
commit ebcd54be5b
18 changed files with 410 additions and 276 deletions

View file

@ -6,92 +6,78 @@
@source "../js";
@source "../../lib/towerops_web";
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
The heroicons installation itself is managed by your mix.exs */
/* ── Custom color palette ── */
@theme {
--color-sweet-salmon-50: #ffe7e6;
--color-sweet-salmon-100: #fed0cd;
--color-sweet-salmon-200: #fea19a;
--color-sweet-salmon-300: #fd7268;
--color-sweet-salmon-400: #fd4235;
--color-sweet-salmon-500: #fc1303;
--color-sweet-salmon-600: #ca0f02;
--color-sweet-salmon-700: #970c02;
--color-sweet-salmon-800: #650801;
--color-sweet-salmon-900: #320401;
--color-sweet-salmon-950: #230300;
--color-desert-sand-50: #faf0eb;
--color-desert-sand-100: #f5e2d6;
--color-desert-sand-200: #ebc5ad;
--color-desert-sand-300: #e0a885;
--color-desert-sand-400: #d68b5c;
--color-desert-sand-500: #cc6e33;
--color-desert-sand-600: #a35829;
--color-desert-sand-700: #7a421f;
--color-desert-sand-800: #522c14;
--color-desert-sand-900: #29160a;
--color-desert-sand-950: #1d0f07;
--color-wheat-50: #faf4eb;
--color-wheat-100: #f5e9d6;
--color-wheat-200: #ebd3ad;
--color-wheat-300: #e0bd85;
--color-wheat-400: #d6a75c;
--color-wheat-500: #cc9133;
--color-wheat-600: #a37429;
--color-wheat-700: #7a571f;
--color-wheat-800: #523a14;
--color-wheat-900: #291d0a;
--color-wheat-950: #1d1407;
--color-cool-steel-50: #f0f3f4;
--color-cool-steel-100: #e1e7ea;
--color-cool-steel-200: #c4cfd4;
--color-cool-steel-300: #a6b7bf;
--color-cool-steel-400: #89a0a9;
--color-cool-steel-500: #6b8894;
--color-cool-steel-600: #566d76;
--color-cool-steel-700: #405159;
--color-cool-steel-800: #2b363b;
--color-cool-steel-900: #151b1e;
--color-cool-steel-950: #0f1315;
--color-cerulean-50: #edf4f8;
--color-cerulean-100: #dbe9f0;
--color-cerulean-200: #b6d3e2;
--color-cerulean-300: #92bcd3;
--color-cerulean-400: #6da6c5;
--color-cerulean-500: #4990b6;
--color-cerulean-600: #3a7392;
--color-cerulean-700: #2c566d;
--color-cerulean-800: #1d3a49;
--color-cerulean-900: #0f1d24;
--color-cerulean-950: #0a141a;
/* Design tokens (replaces daisyUI semantic variables) */
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
}
/* Heroicons plugin */
@plugin "../vendor/heroicons";
/* daisyUI Tailwind Plugin. You can update this file by fetching the latest version with:
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js
Make sure to look at the daisyUI changelog: https://daisyui.com/docs/changelog/ */
@plugin "../vendor/daisyui" {
themes: false;
}
/* daisyUI theme plugin. You can update this file by fetching the latest version with:
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired
on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
@plugin "../vendor/daisyui-theme" {
name: "dark";
default: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(30.33% 0.016 252.42);
--color-base-200: oklch(25.26% 0.014 253.1);
--color-base-300: oklch(20.15% 0.012 254.09);
--color-base-content: oklch(97.807% 0.029 256.847);
--color-primary: oklch(58% 0.233 277.117);
--color-primary-content: oklch(96% 0.018 272.314);
--color-secondary: oklch(58% 0.233 277.117);
--color-secondary-content: oklch(96% 0.018 272.314);
--color-accent: oklch(60% 0.25 292.717);
--color-accent-content: oklch(96% 0.016 293.756);
--color-neutral: oklch(37% 0.044 257.287);
--color-neutral-content: oklch(98% 0.003 247.858);
--color-info: oklch(58% 0.158 241.966);
--color-info-content: oklch(97% 0.013 236.62);
--color-success: oklch(60% 0.118 184.704);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--noise: 0;
}
@plugin "../vendor/daisyui-theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0 0);
--color-base-200: oklch(96% 0.001 286.375);
--color-base-300: oklch(92% 0.004 286.32);
--color-base-content: oklch(21% 0.006 285.885);
--color-primary: oklch(70% 0.213 47.604);
--color-primary-content: oklch(98% 0.016 73.684);
--color-secondary: oklch(55% 0.027 264.364);
--color-secondary-content: oklch(98% 0.002 247.839);
--color-accent: oklch(0% 0 0);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(44% 0.017 285.786);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(62% 0.214 259.815);
--color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(70% 0.14 182.503);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--noise: 0;
}
/* Add variants based on LiveView classes */
/* ── Custom variants ── */
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
@ -99,6 +85,56 @@
/* Use the data attribute for dark mode */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
/* ── Reusable utility classes (replaces daisyUI component classes) ── */
/* Card — replaces daisyUI `card card-body bg-base-100 border-base-200` */
@utility card {
border-radius: var(--radius-box);
border: 1px solid var(--color-cool-steel-200);
background: var(--color-cool-steel-50);
padding: 1.5rem;
}
[data-theme="dark"] .card {
border-color: var(--color-cool-steel-700);
background: var(--color-cool-steel-900);
}
/* Badge — replaces daisyUI `badge` */
@utility badge {
display: inline-flex;
align-items: center;
border-radius: 9999px;
padding-inline: 0.625rem;
padding-block: 0.125rem;
font-size: 0.75rem;
font-weight: 500;
line-height: 1.25rem;
}
/* Button base — replaces daisyUI `btn` (for standalone usage outside core_components) */
@utility btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: var(--radius-field);
padding-inline: 1rem;
padding-block: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
cursor: pointer;
border: 1px solid transparent;
background: var(--color-cool-steel-200);
color: var(--color-cool-steel-900);
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
}
[data-theme="dark"] .btn {
background: var(--color-cool-steel-700);
color: var(--color-cool-steel-50);
}
/* ── Base element styles (replaces daisyUI resets) ── */
/* Make LiveView wrapper divs transparent for layout */
[data-phx-session], [data-phx-teleported-src] { display: contents }
@ -107,10 +143,11 @@
/* Sidebar layout */
#app-sidebar {
@apply w-56 transition-all duration-200;
width: 14rem;
transition: width 0.2s ease;
}
html.sidebar-collapsed #app-sidebar {
@apply w-16;
width: 4rem;
}
html.sidebar-collapsed [data-sidebar-text] {
display: none;
@ -144,10 +181,10 @@ html.sidebar-collapsed [data-sidebar-tooltip]:hover::after {
pointer-events: none;
}
#main-wrapper {
@apply transition-all duration-200;
transition: width 0.2s ease;
}
/* This file is for your main application CSS */
/* ── Animations ── */
/* Page transitions */
@keyframes fadeIn {
@ -157,7 +194,7 @@ html.sidebar-collapsed [data-sidebar-tooltip]:hover::after {
.page-fade-in { animation: fadeIn 0.15s ease-out; }
/* Smooth transitions on interactive elements */
.card, .btn, .badge, .alert {
.card, .btn, .badge {
transition: box-shadow 0.15s ease, transform 0.15s ease;
}
@ -179,40 +216,6 @@ html.sidebar-collapsed [data-sidebar-tooltip]:hover::after {
}
.status-pulse { animation: statusPulse 2s ease-in-out infinite; }
/* Scrollbar styling for webkit */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.15); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.25); }
[data-theme="dark"] ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); }
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.25); }
/* Table row hover highlight */
tbody tr:hover td { background-color: rgba(59, 130, 246, 0.04); }
[data-theme="dark"] tbody tr:hover td { background-color: rgba(59, 130, 246, 0.08); }
/* Focus ring styling */
:focus-visible {
outline: 2px solid oklch(58% 0.233 277.117);
outline-offset: 2px;
}
/* Global smooth transitions for interactive elements */
a, button, input, select, textarea {
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
/* Dark mode body transition */
body, .bg-gray-50, .bg-white, .dark\:bg-gray-950, .dark\:bg-gray-900 {
transition: background-color 0.2s ease;
}
/* Password strength indicator (JS-driven via classes) */
.password-strength-bar.strength-weak { background-color: #ef4444; }
.password-strength-bar.strength-fair { background-color: #f59e0b; }
.password-strength-bar.strength-good { background-color: #22c55e; }
.password-strength-bar.strength-strong { background-color: #16a34a; }
/* Skeleton shimmer effect */
@keyframes shimmer {
0% { background-position: -200% 0; }
@ -223,3 +226,34 @@ body, .bg-gray-50, .bg-white, .dark\:bg-gray-950, .dark\:bg-gray-900 {
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
/* ── Global element styles ── */
/* Scrollbar styling for webkit */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.15); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.25); }
[data-theme="dark"] ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); }
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.25); }
/* Table row hover highlight */
tbody tr:hover td { background-color: rgba(73, 144, 182, 0.06); }
[data-theme="dark"] tbody tr:hover td { background-color: rgba(73, 144, 182, 0.12); }
/* Focus ring styling */
:focus-visible {
outline: 2px solid var(--color-cerulean-500);
outline-offset: 2px;
}
/* Global smooth transitions for interactive elements */
a, button, input, select, textarea {
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
/* Password strength indicator (JS-driven via classes) */
.password-strength-bar.strength-weak { background-color: var(--color-sweet-salmon-500); }
.password-strength-bar.strength-fair { background-color: var(--color-wheat-500); }
.password-strength-bar.strength-good { background-color: #22c55e; }
.password-strength-bar.strength-strong { background-color: #16a34a; }

View file

@ -1,6 +1,6 @@
defmodule ToweropsWeb.Components.Breadcrumbs do
@moduledoc """
Reusable breadcrumb navigation component using DaisyUI.
Reusable breadcrumb navigation component.
"""
use Phoenix.Component
@ -25,7 +25,7 @@ defmodule ToweropsWeb.Components.Breadcrumbs do
def breadcrumb(assigns) do
~H"""
<div class="breadcrumbs text-sm mb-4">
<div class="flex items-center gap-2 text-sm mb-4 text-cool-steel-600 dark:text-cool-steel-400">
<ul>
<%= for {item, idx} <- Enum.with_index(@items) do %>
<li>

View file

@ -20,7 +20,7 @@
</.button>
</.form>
<div class="divider" />
<div class="my-4 border-t border-cool-steel-200 dark:border-cool-steel-700" />
<.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password">
<input type="hidden" name="action" value="update_password" />
@ -44,7 +44,7 @@
</.button>
</.form>
<div class="divider" />
<div class="my-4 border-t border-cool-steel-200 dark:border-cool-steel-700" />
<div>
<h3 class="text-lg font-semibold leading-6 text-gray-900 dark:text-white">

View file

@ -32,7 +32,7 @@
value={@search}
placeholder={t("Search by device name, IP, site, or keyword...")}
phx-debounce="300"
class="input input-bordered w-full pl-10 pr-10 bg-white dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:placeholder-gray-500"
class="w-full pl-10 pr-10 rounded-field border border-cool-steel-300 bg-cool-steel-50 px-3 py-2 text-sm text-cool-steel-900 placeholder:text-cool-steel-400 focus:border-cerulean-500 focus:ring-1 focus:ring-cerulean-500 dark:border-cool-steel-600 dark:bg-cool-steel-900 dark:text-cool-steel-100 dark:placeholder:text-cool-steel-500"
/>
<button
:if={@search != ""}
@ -108,7 +108,7 @@
<button
type="button"
phx-click="clear_search"
class="btn btn-sm btn-outline gap-2"
class="btn text-sm !py-1 !px-3 border border-cool-steel-300 bg-transparent dark:border-cool-steel-600 gap-2"
>
<.icon name="hero-x-mark" class="h-4 w-4" /> Clear search
</button>
@ -285,7 +285,7 @@
<button
type="button"
phx-click="load_more"
class="btn btn-outline btn-sm gap-2"
class="btn text-sm !py-1 !px-3 border border-cool-steel-300 bg-transparent dark:border-cool-steel-600 gap-2"
>
<.icon name="hero-arrow-down" class="h-4 w-4" /> Load more events
</button>

View file

@ -19,20 +19,25 @@ defmodule ToweropsWeb.ChangelogLive do
>
<div class="mx-auto max-w-3xl px-4 py-8">
<h1 class="text-3xl font-bold mb-2">What's New</h1>
<p class="text-base-content/60 mb-8">Latest updates and improvements to TowerOps.</p>
<p class="text-cool-steel-900/60 dark:text-cool-steel-100/60 mb-8">
Latest updates and improvements to TowerOps.
</p>
<div class="space-y-8">
<div :for={entry <- @entries} class="relative pl-8 border-l-2 border-primary/20">
<div class="absolute -left-[9px] top-1 w-4 h-4 rounded-full bg-primary border-2 border-base-100" />
<div
:for={entry <- @entries}
class="relative pl-8 border-l-2 border-sweet-salmon-500/20 dark:border-sweet-salmon-600/20"
>
<div class="absolute -left-[9px] top-1 w-4 h-4 rounded-full bg-sweet-salmon-500 dark:bg-sweet-salmon-600 border-2 border-cool-steel-50 dark:border-cool-steel-900" />
<div class="mb-1">
<span class="badge badge-ghost badge-sm font-mono">
<span class="badge text-cool-steel-700 dark:text-cool-steel-300 font-mono">
{Calendar.strftime(entry.date, "%b %d, %Y")}
</span>
</div>
<h2 :if={entry.title} class="text-lg font-semibold mb-2">{entry.title}</h2>
<ul
:if={entry.items != []}
class="list-disc list-inside space-y-1 text-base-content/80 text-sm"
class="list-disc list-inside space-y-1 text-cool-steel-900/80 dark:text-cool-steel-100/80 text-sm"
>
<li :for={item <- entry.items}>{item}</li>
</ul>

View file

@ -89,17 +89,20 @@ defmodule ToweropsWeb.ConfigTimelineLive do
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Config Timeline</h1>
<p class="text-base-content/60">
<.link navigate={~p"/devices/#{@device.id}?tab=overview"} class="link link-hover">
<p class="text-cool-steel-900/60 dark:text-cool-steel-100/60">
<.link
navigate={~p"/devices/#{@device.id}?tab=overview"}
class="text-cerulean-600 hover:text-cerulean-700 hover:underline dark:text-cerulean-400 dark:hover:text-cerulean-300"
>
{@device.name}
</.link>
Config changes vs performance metrics
</p>
</div>
<div class="join">
<div class="inline-flex">
<button
:for={r <- ["24h", "7d", "30d"]}
class={"join-item btn btn-sm #{if @range == r, do: "btn-primary", else: "btn-ghost"}"}
class={"btn text-sm !py-1 !px-3 #{if @range == r, do: "bg-sweet-salmon-500 text-white hover:bg-sweet-salmon-600 dark:bg-sweet-salmon-600 dark:hover:bg-sweet-salmon-700", else: "bg-transparent hover:bg-cool-steel-200 dark:hover:bg-cool-steel-700"}"}
phx-click="select_range"
phx-value-range={r}
>
@ -109,9 +112,9 @@ defmodule ToweropsWeb.ConfigTimelineLive do
</div>
<%!-- Timeline chart area --%>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Performance & Config Changes</h2>
<div class="card shadow-sm">
<div class="p-4 sm:p-6">
<h2 class="text-lg font-semibold">Performance & Config Changes</h2>
<%!-- QoE metrics chart placeholder rendered via hook --%>
<div
@ -122,7 +125,7 @@ defmodule ToweropsWeb.ConfigTimelineLive do
data-checks={Jason.encode!(@check_data)}
class="h-64 w-full"
>
<div class="flex items-center justify-center h-full text-base-content/40">
<div class="flex items-center justify-center h-full text-cool-steel-900/40 dark:text-cool-steel-100/40">
Loading chart...
</div>
</div>
@ -130,15 +133,17 @@ defmodule ToweropsWeb.ConfigTimelineLive do
</div>
<%!-- Change events list --%>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Config Changes ({length(@change_events)})</h2>
<div class="card shadow-sm">
<div class="p-4 sm:p-6">
<h2 class="text-lg font-semibold">Config Changes ({length(@change_events)})</h2>
<%= if Enum.empty?(@change_events) do %>
<p class="text-base-content/50 py-4">No config changes in this period.</p>
<p class="text-cool-steel-900/50 dark:text-cool-steel-100/50 py-4">
No config changes in this period.
</p>
<% else %>
<div class="overflow-x-auto">
<table class="table table-sm">
<table class="min-w-full text-sm">
<thead>
<tr>
<th>When</th>
@ -150,7 +155,7 @@ defmodule ToweropsWeb.ConfigTimelineLive do
<tbody>
<tr
:for={event <- @change_events}
class="hover:bg-base-200/50 cursor-pointer"
class="hover:bg-cool-steel-100/50 dark:hover:bg-cool-steel-800/50 cursor-pointer"
phx-click="select_event"
phx-value-id={event.id}
>
@ -160,13 +165,13 @@ defmodule ToweropsWeb.ConfigTimelineLive do
<td>
<span
:for={section <- event.sections_changed}
class="badge badge-sm badge-ghost mr-1"
class="badge text-cool-steel-700 dark:text-cool-steel-300 mr-1"
>
{section}
</span>
</td>
<td>
<span class={"badge badge-sm #{change_size_class(event.change_size)}"}>
<span class={"badge bg-cool-steel-200 text-cool-steel-700 dark:bg-cool-steel-800 dark:text-cool-steel-300 #{change_size_class(event.change_size)}"}>
{event.change_size} lines
</span>
</td>
@ -175,7 +180,7 @@ defmodule ToweropsWeb.ConfigTimelineLive do
navigate={
~p"/devices/#{@device.id}/backups/compare?before=#{event.backup_before_id}&after=#{event.backup_after_id}"
}
class="btn btn-xs btn-ghost"
class="btn text-xs !py-0.5 !px-2 bg-transparent hover:bg-cool-steel-200 dark:hover:bg-cool-steel-700"
>
View Diff
</.link>
@ -190,35 +195,44 @@ defmodule ToweropsWeb.ConfigTimelineLive do
<%!-- Selected event detail modal --%>
<%= if @selected_event do %>
<div class="card bg-base-100 shadow-sm border border-primary/20">
<div class="card-body">
<div class="card shadow-sm border border-sweet-salmon-500/20 dark:border-sweet-salmon-600/20">
<div class="p-4 sm:p-6">
<div class="flex items-center justify-between">
<h2 class="card-title text-lg">
<h2 class="text-lg font-semibold">
Change Details {Calendar.strftime(@selected_event.changed_at, "%b %d, %H:%M UTC")}
</h2>
<button class="btn btn-sm btn-ghost" phx-click="close_event"></button>
<button
class="btn text-sm !py-1 !px-3 bg-transparent hover:bg-cool-steel-200 dark:hover:bg-cool-steel-700"
phx-click="close_event"
>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-2">
<div>
<span class="text-sm font-medium text-base-content/60">Sections Changed</span>
<span class="text-sm font-medium text-cool-steel-900/60 dark:text-cool-steel-100/60">
Sections Changed
</span>
<div class="mt-1">
<span
:for={s <- @selected_event.sections_changed}
class="badge badge-sm badge-outline mr-1"
class="badge border border-cool-steel-300 bg-transparent dark:border-cool-steel-600 mr-1"
>
{s}
</span>
<span
:if={Enum.empty?(@selected_event.sections_changed)}
class="text-base-content/40"
class="text-cool-steel-900/40 dark:text-cool-steel-100/40"
>
Unknown
</span>
</div>
</div>
<div>
<span class="text-sm font-medium text-base-content/60">Lines Changed</span>
<span class="text-sm font-medium text-cool-steel-900/60 dark:text-cool-steel-100/60">
Lines Changed
</span>
<p class="font-mono">{@selected_event.change_size}</p>
</div>
<div>
@ -226,7 +240,7 @@ defmodule ToweropsWeb.ConfigTimelineLive do
navigate={
~p"/devices/#{@device.id}/backups/compare?before=#{@selected_event.backup_before_id}&after=#{@selected_event.backup_after_id}"
}
class="btn btn-sm btn-primary"
class="btn text-sm !py-1 !px-3 bg-sweet-salmon-500 text-white hover:bg-sweet-salmon-600 dark:bg-sweet-salmon-600 dark:hover:bg-sweet-salmon-700"
>
Full Diff View
</.link>
@ -235,8 +249,10 @@ defmodule ToweropsWeb.ConfigTimelineLive do
<%= if @selected_event.diff_summary do %>
<div class="mt-4">
<span class="text-sm font-medium text-base-content/60">Diff Summary</span>
<pre class="bg-base-200 p-3 rounded-lg mt-1 text-xs overflow-x-auto max-h-48 overflow-y-auto"><code>{@selected_event.diff_summary}</code></pre>
<span class="text-sm font-medium text-cool-steel-900/60 dark:text-cool-steel-100/60">
Diff Summary
</span>
<pre class="bg-cool-steel-100 dark:bg-cool-steel-800 p-3 rounded-lg mt-1 text-xs overflow-x-auto max-h-48 overflow-y-auto"><code>{@selected_event.diff_summary}</code></pre>
</div>
<% end %>
</div>
@ -267,7 +283,11 @@ defmodule ToweropsWeb.ConfigTimelineLive do
end
@doc false
def change_size_class(size) when is_integer(size) and size > 50, do: "badge-error"
def change_size_class(size) when is_integer(size) and size > 20, do: "badge-warning"
def change_size_class(_), do: "badge-info"
def change_size_class(size) when is_integer(size) and size > 50,
do: "bg-sweet-salmon-100 text-sweet-salmon-800 dark:bg-sweet-salmon-900/30 dark:text-sweet-salmon-400"
def change_size_class(size) when is_integer(size) and size > 20,
do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
def change_size_class(_), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
end

View file

@ -231,7 +231,10 @@
</div>
<div class="flex justify-end gap-2">
<.link navigate={~p"/coverage"} class="btn btn-outline">
<.link
navigate={~p"/coverage"}
class="btn border border-cool-steel-300 bg-transparent dark:border-cool-steel-600"
>
{t("Cancel")}
</.link>
<.button type="submit" variant="primary">

View file

@ -21,18 +21,20 @@
<%= cond do %>
<% @sites == [] -> %>
<div class="flex items-center justify-center py-16">
<div class="card bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
<div class="card-body items-center text-center">
<div class="card shadow-md border dark:border-white/10 max-w-md w-full">
<div class="items-center text-center">
<div class="rounded-full bg-blue-100 dark:bg-blue-900/40 p-4 mb-2">
<.icon name="hero-signal" class="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
<h3 class="card-title text-gray-900 dark:text-white">{t("Add a site first")}</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{t("Add a site first")}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
{t(
"Coverage predictions are computed from a site's location. Add a site, then return here to plan its coverage."
)}
</p>
<div class="card-actions mt-2">
<div class="mt-4 flex gap-2">
<.button navigate={~p"/sites/new"} variant="primary">
<.icon name="hero-plus" class="h-4 w-4" /> {t("Add Site")}
</.button>
@ -42,16 +44,18 @@
</div>
<% @coverages == [] -> %>
<div class="flex items-center justify-center py-16">
<div class="card bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
<div class="card-body items-center text-center">
<div class="card shadow-md border dark:border-white/10 max-w-md w-full">
<div class="items-center text-center">
<div class="rounded-full bg-blue-100 dark:bg-blue-900/40 p-4 mb-2">
<.icon name="hero-signal" class="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
<h3 class="card-title text-gray-900 dark:text-white">{t("No coverages yet")}</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{t("No coverages yet")}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
{t("Create your first coverage to predict where a tower's antenna reaches.")}
</p>
<div class="card-actions mt-2">
<div class="mt-4 flex gap-2">
<.button navigate={~p"/coverage/new"} variant="primary">
<.icon name="hero-plus" class="h-4 w-4" /> {t("New Coverage")}
</.button>

View file

@ -10,7 +10,10 @@
{t("All ready coverages overlaid. Click any point to query each tower's predicted RSSI.")}
</:subtitle>
<:actions>
<.link navigate={~p"/coverage/list"} class="btn btn-outline btn-sm gap-1.5">
<.link
navigate={~p"/coverage/list"}
class="btn text-sm !py-1 !px-3 border border-cool-steel-300 bg-transparent dark:border-cool-steel-600 gap-1.5"
>
<.icon name="hero-list-bullet" class="h-4 w-4" /> {t("List view")}
</.link>
<.button navigate={~p"/coverage/new"} variant="primary">
@ -21,18 +24,20 @@
<%= if @coverages == [] do %>
<div class="mt-6 flex items-center justify-center py-16">
<div class="card bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
<div class="card-body items-center text-center">
<div class="card shadow-md border dark:border-white/10 max-w-md w-full">
<div class="items-center text-center">
<div class="rounded-full bg-blue-100 dark:bg-blue-900/40 p-4 mb-2">
<.icon name="hero-signal" class="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
<h3 class="card-title text-gray-900 dark:text-white">{t("No ready coverages")}</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{t("No ready coverages")}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
{t(
"Coverage maps appear here once the background compute finishes. Create or wait for a coverage to finish."
)}
</p>
<div class="card-actions mt-2">
<div class="mt-4 flex gap-2">
<.button navigate={~p"/coverage/new"} variant="primary">
<.icon name="hero-plus" class="h-4 w-4" /> {t("New Coverage")}
</.button>

View file

@ -33,13 +33,16 @@
<button
type="button"
phx-click="toggle_params"
class="btn btn-outline btn-sm gap-1.5"
class="btn text-sm !py-1 !px-3 border border-cool-steel-300 bg-transparent dark:border-cool-steel-600 gap-1.5"
title={t("Toggle parameters panel")}
>
<.icon name="hero-adjustments-horizontal" class="h-4 w-4" />
{if @show_params, do: t("Hide details"), else: t("Details")}
</button>
<.link navigate={~p"/coverage/#{@coverage.id}/edit"} class="btn btn-outline btn-sm gap-1.5">
<.link
navigate={~p"/coverage/#{@coverage.id}/edit"}
class="btn text-sm !py-1 !px-3 border border-cool-steel-300 bg-transparent dark:border-cool-steel-600 gap-1.5"
>
<.icon name="hero-pencil-square" class="h-4 w-4" /> {t("Edit")}
</.link>
<.button phx-click="recompute" variant="primary">

View file

@ -770,7 +770,7 @@
</span>
<span
:for={s <- Enum.take(event.sections_changed, 3)}
class="badge badge-xs badge-ghost"
class="badge text-xs !px-2 !py-0 text-cool-steel-700 dark:text-cool-steel-300"
>
{s}
</span>

View file

@ -178,15 +178,17 @@
<div class="mt-6" id="insights-list">
<%= if @insights == [] do %>
<div id="empty-state" class="flex items-center justify-center py-8">
<div class="card bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
<div class="card-body items-center text-center">
<div class="card shadow-md border border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10 max-w-md w-full">
<div class="items-center text-center">
<div class="rounded-full bg-amber-100 dark:bg-amber-900/40 p-4 mb-2">
<.icon
name="hero-light-bulb-solid"
class="h-12 w-12 text-amber-500 dark:text-amber-400"
/>
</div>
<h3 class="card-title text-gray-900 dark:text-white">{t("No Insights Yet")}</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{t("No Insights Yet")}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
<%= cond do %>
<% @filter_status == "dismissed" -> %>
@ -199,8 +201,8 @@
{t("Connect Preseem or enable SNMP monitoring to get started.")}
<% end %>
</p>
<div class="card-actions mt-2">
<span class="text-sm text-base-content/50">
<div class="mt-4 flex gap-2 mt-2">
<span class="text-sm text-cool-steel-900/50 dark:text-cool-steel-100/50">
{t("Go to your org settings to connect an integration.")}
</span>
</div>

View file

@ -1096,11 +1096,17 @@
<div class="mt-6 flex gap-3">
<%= if @organization.subscription_plan == "free" do %>
<.button phx-click="upgrade_to_paid" class="btn-primary">
<.button
phx-click="upgrade_to_paid"
class="bg-sweet-salmon-500 text-white hover:bg-sweet-salmon-600 dark:bg-sweet-salmon-600 dark:hover:bg-sweet-salmon-700"
>
{t("Upgrade to Paid Plan")}
</.button>
<% else %>
<.button phx-click="manage_billing" class="btn-secondary">
<.button
phx-click="manage_billing"
class="bg-cerulean-500 text-white hover:bg-cerulean-600 dark:bg-cerulean-600 dark:hover:bg-cerulean-700"
>
{t("Manage Billing")}
</.button>
<% end %>

View file

@ -8,7 +8,10 @@
{@page_title}
<:subtitle>{t("Manage your site locations and hierarchy")}</:subtitle>
<:actions>
<.link navigate={~p"/sites-map"} class="btn btn-outline btn-sm gap-1.5 mr-2">
<.link
navigate={~p"/sites-map"}
class="btn text-sm !py-1 !px-3 border border-cool-steel-300 bg-transparent dark:border-cool-steel-600 gap-1.5 mr-2"
>
<.icon name="hero-map" class="h-4 w-4" /> Map View
</.link>
<.button navigate={~p"/sites/new"} variant="primary">
@ -19,16 +22,16 @@
<%= if @sites == [] do %>
<div class="flex items-center justify-center py-16">
<div class="card bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
<div class="card-body items-center text-center">
<div class="card shadow-md border dark:border-white/10 max-w-md w-full">
<div class="items-center text-center">
<div class="rounded-full bg-blue-100 dark:bg-blue-900/40 p-4 mb-2">
<.icon name="hero-map-pin-solid" class="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
<h3 class="card-title text-gray-900 dark:text-white">{t("No Sites Yet")}</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{t("No Sites Yet")}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
{t("Create your first site to organize devices by tower location.")}
</p>
<div class="card-actions mt-2">
<div class="mt-4 flex gap-2">
<.button navigate={~p"/sites/new"} variant="primary">
<.icon name="hero-plus" class="h-4 w-4" /> Add Site
</.button>

View file

@ -42,7 +42,7 @@
<.button
type="button"
phx-click="force_rediscover_all"
class="btn-sm"
class="btn text-sm !py-1 !px-3"
data-confirm={
t("This will trigger SNMP discovery for all devices at this site. Continue?")
}
@ -54,18 +54,22 @@
<%!-- Quick Actions --%>
<div class="mt-4 flex flex-wrap gap-2">
<.button navigate={~p"/devices/new?site_id=#{@site.id}"} variant="primary" class="btn-sm">
<.button
navigate={~p"/devices/new?site_id=#{@site.id}"}
variant="primary"
class="btn text-sm !py-1 !px-3"
>
<.icon name="hero-plus" class="h-4 w-4" /> {t("Add Device")}
</.button>
<%= if @site.latitude && @site.longitude do %>
<.button
navigate={~p"/sites-map?lat=#{@site.latitude}&lng=#{@site.longitude}&zoom=15"}
class="btn-sm"
class="btn text-sm !py-1 !px-3"
>
<.icon name="hero-map" class="h-4 w-4" /> {t("View on Map")}
</.button>
<% end %>
<.button navigate={~p"/sites/#{@site.id}/edit"} class="btn-sm">
<.button navigate={~p"/sites/#{@site.id}/edit"} class="btn text-sm !py-1 !px-3">
<.icon name="hero-pencil" class="h-4 w-4" /> {t("Edit Site")}
</.button>
</div>
@ -286,7 +290,7 @@
<dd class="mt-0.5">
<.link
navigate={~p"/sites/#{@site.parent_site.id}"}
class="link link-primary text-sm"
class="text-sm text-cerulean-600 hover:text-cerulean-700 hover:underline dark:text-cerulean-400 dark:hover:text-cerulean-300"
>
{@site.parent_site.name}
</.link>
@ -307,13 +311,16 @@
<% end %>
</dl>
<%= if @site.child_sites != [] do %>
<div class="mt-4 border-t border-base-300 pt-3">
<div class="mt-4 border-t border-cool-steel-300 dark:border-cool-steel-600 pt-3">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Child Sites")}
</h4>
<ul class="mt-2 space-y-1">
<li :for={child <- @site.child_sites}>
<.link navigate={~p"/sites/#{child.id}"} class="link link-primary text-sm">
<.link
navigate={~p"/sites/#{child.id}"}
class="text-sm text-cerulean-600 hover:text-cerulean-700 hover:underline dark:text-cerulean-400 dark:hover:text-cerulean-300"
>
{child.name}
</.link>
</li>
@ -388,7 +395,10 @@
{time_ago(change.detected_at)}
</p>
</div>
<span class={["badge badge-sm", change_size_color(change.diff_size || 0)]}>
<span class={[
"badge bg-cool-steel-200 text-cool-steel-700 dark:bg-cool-steel-800 dark:text-cool-steel-300",
change_size_color(change.diff_size || 0)
]}>
{change_size_label(change.diff_size || 0)}
</span>
</div>
@ -410,7 +420,7 @@
<.button
navigate={~p"/devices/new?site_id=#{@site.id}"}
variant="primary"
class="btn-sm"
class="btn text-sm !py-1 !px-3"
>
<.icon name="hero-plus" class="h-4 w-4" /> Add
</.button>

View file

@ -31,7 +31,10 @@ defmodule ToweropsWeb.TraceLive.Index do
assigns = assign(assigns, :issues, Enum.reverse(issues))
~H"""
<div :if={@issues != []} class="alert alert-warning shadow-sm">
<div
:if={@issues != []}
class="border-l-4 border-wheat-500 bg-wheat-50 p-4 text-wheat-900 shadow-sm dark:bg-wheat-900/20 dark:text-wheat-300"
>
<.icon name="hero-exclamation-triangle" class="h-5 w-5" />
<div>
<h3 class="font-bold">Issues Detected</h3>
@ -53,15 +56,15 @@ defmodule ToweropsWeb.TraceLive.Index do
:good -> "border-green-200 dark:border-green-800"
:warning -> "border-yellow-200 dark:border-yellow-800"
:bad -> "border-red-200 dark:border-red-800"
_ -> "border-base-200"
_ -> "border-cool-steel-200 dark:border-cool-steel-700"
end
assigns = assign(assigns, :color, color)
~H"""
<div class={"stat bg-base-100 rounded-box border #{@color} p-4"}>
<div class="stat-title text-xs">{@label}</div>
<div class="stat-value text-2xl">{@value}</div>
<div class={"flex flex-col bg-cool-steel-50 dark:bg-cool-steel-900 rounded-xl border #{@color} p-4"}>
<div class="text-xs font-medium text-cool-steel-500 dark:text-cool-steel-400">{@label}</div>
<div class="text-2xl font-bold">{@value}</div>
</div>
"""
end
@ -133,12 +136,14 @@ defmodule ToweropsWeb.TraceLive.Index do
|> push_patch(to: ~p"/trace")}
end
def type_badge_class(:account), do: "badge-primary"
def type_badge_class(:inventory_item), do: "badge-secondary"
def type_badge_class(:site), do: "badge-warning"
def type_badge_class(:device), do: "badge-accent"
def type_badge_class(:access_point), do: "badge-info"
def type_badge_class(_), do: "badge-ghost"
def type_badge_class(:account),
do: "bg-sweet-salmon-100 text-sweet-salmon-800 dark:bg-sweet-salmon-900/30 dark:text-sweet-salmon-400"
def type_badge_class(:inventory_item), do: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
def type_badge_class(:site), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
def type_badge_class(:device), do: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400"
def type_badge_class(:access_point), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
def type_badge_class(_), do: "text-cool-steel-700 dark:text-cool-steel-300"
def type_badge_label(:account), do: "Account"
def type_badge_label(:inventory_item), do: "Inventory"
@ -148,10 +153,13 @@ defmodule ToweropsWeb.TraceLive.Index do
def type_badge_label(_), do: "Unknown"
@doc false
def status_badge_class("active"), do: "badge-success"
def status_badge_class("suspended"), do: "badge-warning"
def status_badge_class("cancelled"), do: "badge-error"
def status_badge_class(_), do: "badge-ghost"
def status_badge_class("active"), do: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
def status_badge_class("suspended"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
def status_badge_class("cancelled"),
do: "bg-sweet-salmon-100 text-sweet-salmon-800 dark:bg-sweet-salmon-900/30 dark:text-sweet-salmon-400"
def status_badge_class(_), do: "text-cool-steel-700 dark:text-cool-steel-300"
@doc false
def device_status_color(:up), do: "text-green-600 dark:text-green-400"

View file

@ -21,8 +21,8 @@
<div class="space-y-6">
<%!-- Search Bar --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<div class="card shadow-sm border border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10 dark:bg-gray-900">
<div class="p-4 sm:p-6">
<div class="flex items-center gap-3">
<.icon name="hero-magnifying-glass" class="h-6 w-6 text-gray-400 flex-shrink-0" />
<form phx-change="search" phx-submit="search" class="flex-1 flex gap-2">
@ -35,7 +35,7 @@
"Search by customer name, IP address, account ID, serial number, site name, or device name..."
)
}
class="input input-bordered w-full dark:bg-gray-800 dark:border-white/10 dark:text-white"
class="w-full rounded-field border border-cool-steel-300 bg-cool-steel-50 px-3 py-2 text-sm text-cool-steel-900 placeholder:text-cool-steel-400 focus:border-cerulean-500 focus:ring-1 focus:ring-cerulean-500 dark:border-cool-steel-600 dark:bg-cool-steel-900 dark:text-cool-steel-100 dark:placeholder:text-cool-steel-500 w-full dark:bg-gray-800 dark:border-white/10 dark:text-white"
phx-debounce="300"
autofocus
/>
@ -43,7 +43,7 @@
<button
:if={@query != "" || @trace}
phx-click="clear"
class="btn btn-ghost btn-sm"
class="btn text-sm !py-1 !px-3 bg-transparent hover:bg-cool-steel-200 dark:hover:bg-cool-steel-700"
>
<.icon name="hero-x-mark" class="h-5 w-5" /> Clear
</button>
@ -54,18 +54,21 @@
<%!-- Search Results --%>
<div
:if={@results != [] && !@trace}
class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900"
class="card shadow-sm border border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10 dark:bg-gray-900"
>
<div class="card-body p-0">
<div class="divide-y divide-base-200 dark:divide-white/10">
<div class="p-0">
<div class="divide-y divide-cool-steel-200 dark:divide-cool-steel-700 dark:divide-white/10">
<button
:for={result <- @results}
phx-click="select_result"
phx-value-type={result.type}
phx-value-id={result.id}
class="w-full flex items-center gap-3 px-4 sm:px-6 py-3 hover:bg-base-200/50 dark:hover:bg-white/5 text-left transition-colors"
class="w-full flex items-center gap-3 px-4 sm:px-6 py-3 hover:bg-cool-steel-100/50 dark:hover:bg-cool-steel-800/50 dark:hover:bg-white/5 text-left transition-colors"
>
<span class={["badge badge-sm font-mono", type_badge_class(result.type)]}>
<span class={[
"badge text-xs !px-2 !py-0 font-mono bg-cool-steel-200 text-cool-steel-700 dark:bg-cool-steel-800 dark:text-cool-steel-300",
type_badge_class(result.type)
]}>
{type_badge_label(result.type)}
</span>
<div class="flex-1 min-w-0">
@ -95,8 +98,8 @@
<%!-- Site Trace View --%>
<div :if={@trace && @trace_type == :site} class="space-y-4">
<%!-- Site Info --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<div class="card shadow-sm border border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10 dark:bg-gray-900">
<div class="p-4 sm:p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<.icon name="hero-building-office-2" class="h-5 w-5 text-gray-400" />
{@trace.site.name}
@ -120,14 +123,14 @@
<%!-- Devices at Site --%>
<div
:if={length(@trace.devices) > 0}
class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900"
class="card shadow-sm border border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10 dark:bg-gray-900"
>
<div class="card-body p-4 sm:p-6">
<div class="p-4 sm:p-6">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
{t("Devices")}
</h3>
<div class="overflow-x-auto">
<table class="table table-sm">
<table class="min-w-full text-sm">
<thead>
<tr class="text-xs text-gray-500 dark:text-gray-400 uppercase">
<th>{t("Name")}</th>
@ -138,7 +141,7 @@
<tbody>
<tr
:for={device <- @trace.devices}
class="hover:bg-base-200/50 dark:hover:bg-white/5"
class="hover:bg-cool-steel-100/50 dark:hover:bg-cool-steel-800/50 dark:hover:bg-white/5"
>
<td>
<.link
@ -167,16 +170,16 @@
<%!-- Recent Alerts --%>
<div
:if={@trace.alerts != []}
class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900"
class="card shadow-sm border border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10 dark:bg-gray-900"
>
<div class="card-body p-4 sm:p-6">
<div class="p-4 sm:p-6">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
{t("Recent Alerts")}
</h3>
<div class="space-y-2">
<div
:for={alert <- @trace.alerts}
class="flex items-start gap-3 p-2 rounded-lg hover:bg-base-200/50 dark:hover:bg-white/5"
class="flex items-start gap-3 p-2 rounded-lg hover:bg-cool-steel-100/50 dark:hover:bg-cool-steel-800/50 dark:hover:bg-white/5"
>
<span class={"mt-1.5 h-2 w-2 rounded-full flex-shrink-0 #{alert_dot(alert)}"}>
</span>
@ -190,7 +193,7 @@
</span>
<span
:if={alert.resolved_at}
class="badge badge-xs badge-success"
class="badge text-xs !px-2 !py-0 bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
>
{t("Resolved")}
</span>
@ -216,8 +219,8 @@
<%!-- Subscriber + Connection --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<%!-- Subscriber Info --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<div class="card shadow-sm border border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10 dark:bg-gray-900">
<div class="p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-user" class="h-4 w-4 inline" /> Subscriber
</h3>
@ -262,7 +265,10 @@
Customer since: {Calendar.strftime(@trace.subscriber.customer_since, "%b %Y")}
</div>
<div class="flex items-center gap-2 mt-1">
<span class={["badge badge-sm", status_badge_class(@trace.subscriber.status)]}>
<span class={[
"badge bg-cool-steel-200 text-cool-steel-700 dark:bg-cool-steel-800 dark:text-cool-steel-300",
status_badge_class(@trace.subscriber.status)
]}>
{@trace.subscriber.status || "unknown"}
</span>
<%!-- Financial data: role-gated --%>
@ -288,8 +294,8 @@
</div>
<%!-- Connection / Device Info --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<div class="card shadow-sm border border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10 dark:bg-gray-900">
<div class="p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-signal" class="h-4 w-4 inline" /> Connection
</h3>
@ -336,7 +342,7 @@
</div>
<div
:if={@trace.access_point}
class="mt-2 pt-2 border-t border-base-200 dark:border-white/10"
class="mt-2 pt-2 border-t border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10"
>
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">
{t("Preseem AP")}
@ -362,8 +368,8 @@
<%!-- QoE Metrics + Peer Impact --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<%!-- QoE Metrics --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<div class="card shadow-sm border border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10 dark:bg-gray-900">
<div class="p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-chart-bar" class="h-4 w-4 inline" /> QoE Metrics
<span class="text-xs font-normal normal-case">(from Preseem)</span>
@ -392,7 +398,7 @@
/>
</div>
<%= if @trace.qoe_metrics.latest_metric do %>
<div class="mt-4 pt-3 border-t border-base-200 dark:border-white/10">
<div class="mt-4 pt-3 border-t border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2">
{t("Latest Metrics")}
</div>
@ -442,8 +448,8 @@
</div>
<%!-- Peer Impact --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<div class="card shadow-sm border border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10 dark:bg-gray-900">
<div class="p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-users" class="h-4 w-4 inline" /> Peer Impact
</h3>
@ -481,7 +487,7 @@
</div>
<div
:if={@trace.peer_impact.peer_aps != []}
class="mt-3 pt-3 border-t border-base-200 dark:border-white/10"
class="mt-3 pt-3 border-t border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10"
>
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2">
{t("Peer APs")}
@ -517,8 +523,8 @@
</div>
<%!-- Active Alerts --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<div class="card shadow-sm border border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10 dark:bg-gray-900">
<div class="p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-bell-alert" class="h-4 w-4 inline" /> Recent Alerts
</h3>
@ -528,7 +534,7 @@
<span class="text-sm font-medium">No alerts — all clear</span>
</div>
<% else %>
<div class="divide-y divide-base-200 dark:divide-white/10">
<div class="divide-y divide-cool-steel-200 dark:divide-cool-steel-700 dark:divide-white/10">
<div
:for={alert <- @trace.alerts}
class="py-2 first:pt-0 last:pb-0 flex items-center gap-3"
@ -549,8 +555,16 @@
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
{format_relative_time(alert.triggered_at)}
</div>
<span :if={alert.resolved_at} class="badge badge-xs badge-success">resolved</span>
<span :if={is_nil(alert.resolved_at)} class="badge badge-xs badge-error">
<span
:if={alert.resolved_at}
class="badge text-xs !px-2 !py-0 bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
>
resolved
</span>
<span
:if={is_nil(alert.resolved_at)}
class="badge text-xs !px-2 !py-0 bg-sweet-salmon-100 text-sweet-salmon-800 dark:bg-sweet-salmon-900/30 dark:text-sweet-salmon-400"
>
active
</span>
</div>
@ -562,8 +576,8 @@
<%!-- Recent Activity: Config Changes + Insights --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<%!-- Config Changes — hidden, feature temporarily disabled --%>
<div class="hidden card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<div class="hidden card shadow-sm border border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10 dark:bg-gray-900">
<div class="p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-document-text" class="h-4 w-4 inline" /> Config Changes (24h)
</h3>
@ -596,8 +610,8 @@
</div>
<%!-- Active Insights --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<div class="card shadow-sm border border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10 dark:bg-gray-900">
<div class="p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-light-bulb" class="h-4 w-4 inline" /> Active Insights
</h3>
@ -635,14 +649,14 @@
<%!-- Inventory Items --%>
<div
:if={@trace.inventory_items != []}
class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900"
class="card shadow-sm border border-cool-steel-200 dark:border-cool-steel-700 dark:border-white/10 dark:bg-gray-900"
>
<div class="card-body p-4 sm:p-6">
<div class="p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-cube" class="h-4 w-4 inline" /> Equipment (from Gaiia)
</h3>
<div class="overflow-x-auto">
<table class="table table-sm">
<table class="min-w-full text-sm">
<thead>
<tr class="text-xs uppercase text-gray-500 dark:text-gray-400">
<th>Name</th>
@ -653,13 +667,19 @@
</tr>
</thead>
<tbody>
<tr :for={item <- @trace.inventory_items} class="hover">
<tr
:for={item <- @trace.inventory_items}
class="hover:bg-cool-steel-100/50 dark:hover:bg-cool-steel-800/50"
>
<td class="font-medium text-gray-900 dark:text-white">{item.name}</td>
<td class="font-mono text-sm">{item.ip_address}</td>
<td>{item.model_name}</td>
<td class="font-mono text-xs">{item.serial_number}</td>
<td>
<span class={["badge badge-xs", status_badge_class(item.status)]}>
<span class={[
"badge text-xs !px-2 !py-0 bg-cool-steel-200 text-cool-steel-700 dark:bg-cool-steel-800 dark:text-cool-steel-300",
status_badge_class(item.status)
]}>
{item.status}
</span>
</td>

View file

@ -4,24 +4,35 @@ defmodule ToweropsWeb.ConfigTimelineLiveHelpersTest do
alias ToweropsWeb.ConfigTimelineLive
describe "change_size_class/1" do
test "> 50 returns error" do
assert "badge-error" == ConfigTimelineLive.change_size_class(51)
assert "badge-error" == ConfigTimelineLive.change_size_class(1_000)
test "> 50 returns error styling" do
assert "bg-sweet-salmon-100 text-sweet-salmon-800 dark:bg-sweet-salmon-900/30 dark:text-sweet-salmon-400" ==
ConfigTimelineLive.change_size_class(51)
assert "bg-sweet-salmon-100 text-sweet-salmon-800 dark:bg-sweet-salmon-900/30 dark:text-sweet-salmon-400" ==
ConfigTimelineLive.change_size_class(1_000)
end
test "21..50 returns warning" do
assert "badge-warning" == ConfigTimelineLive.change_size_class(21)
assert "badge-warning" == ConfigTimelineLive.change_size_class(50)
test "21..50 returns warning styling" do
assert "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400" ==
ConfigTimelineLive.change_size_class(21)
assert "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400" ==
ConfigTimelineLive.change_size_class(50)
end
test "<= 20 returns info" do
assert "badge-info" == ConfigTimelineLive.change_size_class(0)
assert "badge-info" == ConfigTimelineLive.change_size_class(20)
test "<= 20 returns info styling" do
assert "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400" == ConfigTimelineLive.change_size_class(0)
assert "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400" ==
ConfigTimelineLive.change_size_class(20)
end
test "non-integer returns info" do
assert "badge-info" == ConfigTimelineLive.change_size_class(nil)
assert "badge-info" == ConfigTimelineLive.change_size_class("foo")
test "non-integer returns info styling" do
assert "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400" ==
ConfigTimelineLive.change_size_class(nil)
assert "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400" ==
ConfigTimelineLive.change_size_class("foo")
end
end