diff --git a/assets/css/app.css b/assets/css/app.css index fd4084d0..718bcfe8 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -9,6 +9,13 @@ @source "../js"; @source "../../lib/microwaveprop_web"; +/* live_table / sutra_ui compat: maps shadcn tokens live_table uses to + daisyUI base colors and provides component CSS for the SutraUI + primitives wrapped by live_table (select, input-group, dropdown, + empty state). Must come before the daisyUI plugins so daisyUI's + primary/accent/error tokens still win in @theme resolution. */ +@import "./live_table_compat.css"; + /* A Tailwind plugin that makes "hero-#{ICON}" classes available. The heroicons installation itself is managed by your mix.exs */ @plugin "../vendor/heroicons"; diff --git a/assets/css/live_table_compat.css b/assets/css/live_table_compat.css new file mode 100644 index 00000000..690b8014 --- /dev/null +++ b/assets/css/live_table_compat.css @@ -0,0 +1,335 @@ +/* live_table / sutra_ui compat layer. + + live_table's rendered components (and the SutraUI primitives it wraps) + use shadcn-style Tailwind utility classes — `bg-muted`, `text-foreground`, + `border-border`, `bg-background`, `text-muted-foreground`, `text-primary` — + plus component classes like `.select-trigger`, `.select-popover`, + `.input-group`, `.empty-outline`, `.dropdown-menu`. This project uses + daisyUI, which doesn't define any of those tokens or components. + + Importing deps/sutra_ui/priv/static/sutra_ui.css wholesale would clobber + daisyUI's `.btn`, `.badge`, `.input`, `.alert`, and the theme `--color-*` + tokens. Instead, this file provides: + + 1) @theme mappings for the neutral shadcn tokens live_table uses, + aliased to daisyUI base colors so they don't conflict with + daisyUI's primary/secondary/accent/error. + 2) Hand-written component CSS for the sutra_ui primitives live_table + wraps, using daisyUI variables directly (no @apply of shadcn names). */ + +@theme { + --color-background: var(--color-base-100); + --color-foreground: var(--color-base-content); + --color-muted: var(--color-base-200); + --color-muted-foreground: color-mix(in oklch, var(--color-base-content) 60%, transparent); + --color-border: var(--color-base-300); + --color-input: var(--color-base-300); + --color-popover: var(--color-base-100); + --color-popover-foreground: var(--color-base-content); + --color-card: var(--color-base-100); + --color-card-foreground: var(--color-base-content); + --color-ring: var(--color-primary); +} + +/* SutraUI Select — used by live_table for the per-page selector. */ +*:not(select).select { + position: relative; + display: inline-flex; + width: 100%; +} + +.select-trigger { + display: flex; + height: 2.25rem; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + border-radius: 0.375rem; + border: 1px solid var(--color-base-300); + background: var(--color-base-100); + padding: 0.25rem 0.75rem; + font-size: 0.875rem; + line-height: 1.25rem; + color: var(--color-base-content); + cursor: pointer; +} + +.select-trigger:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--color-primary); +} + +.select-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.select-icon { + flex-shrink: 0; + width: 1rem; + height: 1rem; + opacity: 0.5; +} + +.select-popover, +[data-popover] { + position: absolute; + z-index: 50; + margin-top: 0.25rem; + min-width: 100%; + border-radius: 0.375rem; + border: 1px solid var(--color-base-300); + background: var(--color-base-100); + color: var(--color-base-content); + padding: 0.25rem; + box-shadow: 0 4px 6px -1px oklch(0% 0 0 / 0.1), 0 2px 4px -2px oklch(0% 0 0 / 0.1); +} + +.select-popover[aria-hidden="true"], +[data-popover][aria-hidden="true"] { + display: none; +} + +.select-listbox, +[role="listbox"] { + max-height: 300px; + overflow-y: auto; + list-style: none; + padding: 0; + margin: 0; +} + +.select-option, +[role="option"] { + position: relative; + display: flex; + cursor: pointer; + align-items: center; + gap: 0.5rem; + border-radius: 0.25rem; + padding: 0.375rem 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + outline: none; + user-select: none; +} + +.select-option[aria-hidden="true"], +[role="option"][aria-hidden="true"] { + display: none; +} + +.select-option:hover, +.select-option.active, +.select-option:focus-visible, +.select-option[aria-selected="true"], +[role="option"]:hover, +[role="option"].active, +[role="option"]:focus-visible { + background: var(--color-base-200); +} + +.select-separator, +[role="separator"] { + height: 1px; + margin: 0.25rem -0.25rem; + background: var(--color-base-300); +} + +.select-group-label, +[role="listbox"] [role="heading"] { + display: flex; + padding: 0.375rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + color: color-mix(in oklch, var(--color-base-content) 60%, transparent); +} + +/* SutraUI Input Group — used by live_table for the search box. */ +.input-group { + position: relative; + display: flex; + align-items: center; + width: 100%; +} + +.input-group-prefix, +.input-group-suffix { + position: absolute; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + color: color-mix(in oklch, var(--color-base-content) 60%, transparent); + pointer-events: none; +} + +.input-group-prefix { left: 0.75rem; } +.input-group-suffix { right: 0.75rem; } + +.input-group-prefix-icon, +.input-group-suffix-icon { + position: absolute; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + color: color-mix(in oklch, var(--color-base-content) 60%, transparent); + pointer-events: none; +} + +.input-group-prefix-icon svg, +.input-group-suffix-icon svg { width: 1rem; height: 1rem; } + +.input-group-prefix-icon { left: 0.75rem; } +.input-group-suffix-icon { right: 0.75rem; } + +/* SutraUI Dropdown Menu — used by live_table for the Export button. */ +.dropdown-menu { + position: relative; + display: inline-flex; +} + +.dropdown-menu-trigger { + display: flex; + height: 2.25rem; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + border-radius: 0.375rem; + border: 1px solid var(--color-base-300); + background: var(--color-base-100); + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + line-height: 1.25rem; + color: var(--color-base-content); + cursor: pointer; + white-space: nowrap; +} + +.dropdown-menu-trigger:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--color-primary); +} + +.dropdown-menu-chevron { + flex-shrink: 0; + opacity: 0.5; + transition: transform 150ms; +} + +.dropdown-menu:has([aria-expanded="true"]) .dropdown-menu-chevron { + transform: rotate(180deg); +} + +.dropdown-menu [role="menuitem"], +.dropdown-menu [role="menuitemcheckbox"], +.dropdown-menu [role="menuitemradio"] { + position: relative; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + border-radius: 0.25rem; + user-select: none; + cursor: pointer; + outline: none; + width: 100%; +} + +.dropdown-menu [role="menuitem"][aria-hidden="true"], +.dropdown-menu [role="menuitemcheckbox"][aria-hidden="true"], +.dropdown-menu [role="menuitemradio"][aria-hidden="true"] { + display: none; +} + +.dropdown-menu [role="menuitem"]:focus-visible, +.dropdown-menu [role="menuitem"]:hover, +.dropdown-menu [role="menuitem"].active { + background: var(--color-base-200); +} + +/* SutraUI Empty state — used by live_table when no rows match. */ +.empty, +.empty-outline { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 1rem; + text-align: center; +} + +.empty header, +.empty-outline header { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.empty h3, +.empty-outline h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-base-content); +} + +.empty p, +.empty-outline p { + font-size: 0.875rem; + max-width: 24rem; + color: color-mix(in oklch, var(--color-base-content) 60%, transparent); +} + +.empty-icon { + margin-bottom: 1rem; + color: color-mix(in oklch, var(--color-base-content) 60%, transparent); +} + +.empty-icon > svg { width: 3rem; height: 3rem; } + +.empty-outline { + border: 2px dashed var(--color-base-300); + border-radius: 0.5rem; +} + +/* live_table outer container should have a visible surface + border. */ +#live-table table { background: var(--color-base-100); } + +/* Filter bar used when filters are configured. */ +.filter-bar { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.filter-bar-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(var(--filter-cols, 3), minmax(0, 1fr)); +} + +.filter-bar-cols-1 { --filter-cols: 1; } +.filter-bar-cols-2 { --filter-cols: 2; } +.filter-bar-cols-3 { --filter-cols: 3; } + +.filter-bar-actions { + display: flex; + justify-content: flex-end; +} + +.filter-bar-clear { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; + color: color-mix(in oklch, var(--color-base-content) 60%, transparent); + cursor: pointer; +} + +.filter-bar-clear:hover { color: var(--color-base-content); } diff --git a/lib/microwaveprop_web/live/admin/contact_edit_live.ex b/lib/microwaveprop_web/live/admin/contact_edit_live.ex index 6af3b86c..12fb17a4 100644 --- a/lib/microwaveprop_web/live/admin/contact_edit_live.ex +++ b/lib/microwaveprop_web/live/admin/contact_edit_live.ex @@ -172,8 +172,8 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do Review Contact Edits <:subtitle>{@pending_count} pending <:actions> - <.link navigate={~p"/admin/backfill"} class="btn btn-sm btn-ghost"> - <.icon name="hero-arrow-left" class="w-4 h-4" /> Admin + <.link navigate={~p"/status"} class="btn btn-sm btn-ghost"> + <.icon name="hero-arrow-left" class="w-4 h-4" /> Status diff --git a/lib/microwaveprop_web/live/beacon_live/show.ex b/lib/microwaveprop_web/live/beacon_live/show.ex index 59bf0bcd..afde66cc 100644 --- a/lib/microwaveprop_web/live/beacon_live/show.ex +++ b/lib/microwaveprop_web/live/beacon_live/show.ex @@ -6,6 +6,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do alias Microwaveprop.Beacons.Beacon alias Microwaveprop.Beacons.RangeEstimate alias Microwaveprop.Radio.BandResolver + alias Microwaveprop.Radio.Maidenhead @impl true def render(assigns) do @@ -217,7 +218,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do defp path_calc_link(%Beacon{} = beacon) do base = %{ - "destination" => beacon.grid, + "destination" => precise_grid(beacon), "dst_height_ft" => to_string(beacon.height_ft) } @@ -226,6 +227,12 @@ defmodule MicrowavepropWeb.BeaconLive.Show do "/path?" <> URI.encode_query(params) end + defp precise_grid(%Beacon{lat: lat, lon: lon}) when is_number(lat) and is_number(lon) do + Maidenhead.from_latlon(lat, lon, 8) + end + + defp precise_grid(%Beacon{grid: grid}), do: grid + defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) diff --git a/lib/microwaveprop_web/live/contact_live/index.ex b/lib/microwaveprop_web/live/contact_live/index.ex index a9f751ef..a62964ae 100644 --- a/lib/microwaveprop_web/live/contact_live/index.ex +++ b/lib/microwaveprop_web/live/contact_live/index.ex @@ -3,6 +3,10 @@ defmodule MicrowavepropWeb.ContactLive.Index do use MicrowavepropWeb, :live_view use LiveTable.LiveResource, schema: Microwaveprop.Radio.Contact + def table_options do + %{sorting: %{default_sort: [inserted_at: :desc]}} + end + def fields do [ id: %{label: "ID", hidden: true}, diff --git a/lib/microwaveprop_web/live/backfill_live.ex b/lib/microwaveprop_web/live/status_live.ex similarity index 85% rename from lib/microwaveprop_web/live/backfill_live.ex rename to lib/microwaveprop_web/live/status_live.ex index 743c6b35..46ec164f 100644 --- a/lib/microwaveprop_web/live/backfill_live.ex +++ b/lib/microwaveprop_web/live/status_live.ex @@ -1,74 +1,35 @@ -defmodule MicrowavepropWeb.BackfillLive do +defmodule MicrowavepropWeb.StatusLive do @moduledoc false use MicrowavepropWeb, :live_view - use LiveStash import Ecto.Query alias Microwaveprop.Cache alias Microwaveprop.Radio.Contact alias Microwaveprop.Repo - alias Microwaveprop.Workers.BackfillEnqueueWorker @impl true def mount(_params, _session, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "db:contact_status") Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "db:oban_jobs") - Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "backfill:enqueue_complete") end stats = fetch_stats() unprocessed = count_unprocessed() db_stats = fetch_db_stats() - {limit, _} = - case LiveStash.recover_state(socket) do - {:recovered, socket} -> {socket.assigns[:limit] || 500, socket} - _ -> {500, socket} - end - {:ok, assign(socket, - page_title: "Backfill", - limit: limit, - types: MapSet.new(~w(hrrr weather terrain iemre era5)), + page_title: "Status", stats: stats, unprocessed: unprocessed, db_stats: db_stats, - last_enqueued: nil, - enqueuing: false, refresh_timer: nil )} end @impl true - def handle_event("enqueue", %{"limit" => limit_str} = params, socket) do - limit = String.to_integer(limit_str) - - types = - ~w(hrrr weather terrain iemre era5) - |> Enum.filter(&(params[&1] == "true")) - |> then(fn - [] -> ~w(hrrr weather terrain iemre era5) - types -> types - end) - - %{"limit" => limit, "types" => types} - |> BackfillEnqueueWorker.new() - |> Oban.insert() - - {:noreply, - socket - |> assign(enqueuing: true, limit: limit, types: MapSet.new(types)) - |> LiveStash.stash_assigns([:limit])} - end - - @impl true - def handle_info({:enqueue_complete, count}, socket) do - {:noreply, assign(socket, last_enqueued: count, enqueuing: false)} - end - def handle_info({:contact_status_changed, _data}, socket) do {:noreply, schedule_refresh(socket)} end @@ -351,8 +312,8 @@ defmodule MicrowavepropWeb.BackfillLive do ~H""" <.header> - Backfill Dashboard - <:subtitle>Enqueue and monitor contact enrichment jobs + Status + <:subtitle>Enrichment progress, job queues, and database health
@@ -394,48 +355,6 @@ defmodule MicrowavepropWeb.BackfillLive do
-
-
-
- - -
-
- <%= for type <- ~w(hrrr weather terrain iemre era5) do %> - - <% end %> -
- - <%= if @last_enqueued do %> - - Enqueued {@last_enqueued} contacts - - <% end %> -
-
-

Job Queue Status

<%= if @stats.by_worker == [] do %>

No active jobs.

diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index b9bd5f84..d9290d5a 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -54,7 +54,7 @@ defmodule MicrowavepropWeb.Router do live_session :admin, on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}] do live "/beacons/:id/edit", BeaconLive.Form, :edit - live "/admin/backfill", BackfillLive + live "/status", StatusLive live "/admin/contact-edits", Admin.ContactEditLive live "/users", UserManagementLive.Index, :index