live_table polish + status page cleanup
- Compat CSS layer (assets/css/live_table_compat.css) maps the shadcn tokens live_table/sutra_ui use (bg-muted, text-foreground, border-border, bg-background, select-trigger, input-group, etc.) onto daisyUI base colors, and provides hand-written component CSS for select/dropdown/input-group/empty so the table has a proper surface + a select popover that actually hides when closed. - Default-sort the Contacts table by most recent inserted_at. - Beacon detail "Plot path" now encodes lat/lon to an 8-char Maidenhead grid when coordinates are known, so the destination handed to the path calculator is more precise than the beacon's stored 4-char grid. - Rename BackfillLive -> StatusLive, move route from /admin/backfill to /status, retitle "Backfill Dashboard" -> "Status", drop the enqueue form (LiveStash + BackfillEnqueueWorker no longer needed on this page). Contact edit admin back-link now points at /status.
This commit is contained in:
parent
f0eab6751f
commit
c020d9c788
7 changed files with 361 additions and 89 deletions
|
|
@ -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";
|
||||
|
|
|
|||
335
assets/css/live_table_compat.css
Normal file
335
assets/css/live_table_compat.css
Normal file
|
|
@ -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); }
|
||||
|
|
@ -172,8 +172,8 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do
|
|||
Review Contact Edits
|
||||
<:subtitle>{@pending_count} pending</:subtitle>
|
||||
<: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
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<.header>
|
||||
Backfill Dashboard
|
||||
<:subtitle>Enqueue and monitor contact enrichment jobs</:subtitle>
|
||||
Status
|
||||
<:subtitle>Enrichment progress, job queues, and database health</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 my-6">
|
||||
|
|
@ -394,48 +355,6 @@ defmodule MicrowavepropWeb.BackfillLive do
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-200 rounded-box p-4 mb-6">
|
||||
<form phx-submit="enqueue" class="flex items-end gap-4 flex-wrap">
|
||||
<div>
|
||||
<label class="label text-sm">Contacts to enqueue</label>
|
||||
<input
|
||||
type="number"
|
||||
name="limit"
|
||||
value={@limit}
|
||||
min="1"
|
||||
max="5000"
|
||||
class="input input-bordered w-32"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-3 items-center">
|
||||
<%= for type <- ~w(hrrr weather terrain iemre era5) do %>
|
||||
<label class="label cursor-pointer gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={type}
|
||||
value="true"
|
||||
checked={MapSet.member?(@types, type)}
|
||||
class="checkbox checkbox-sm"
|
||||
/>
|
||||
<span class="text-xs uppercase">{type}</span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<button class="btn btn-primary" disabled={@enqueuing}>
|
||||
<%= if @enqueuing do %>
|
||||
<span class="loading loading-spinner loading-sm"></span> Enqueuing...
|
||||
<% else %>
|
||||
Enqueue Backfill
|
||||
<% end %>
|
||||
</button>
|
||||
<%= if @last_enqueued do %>
|
||||
<span class="text-sm text-success">
|
||||
Enqueued {@last_enqueued} contacts
|
||||
</span>
|
||||
<% end %>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2 class="text-base font-semibold mb-2">Job Queue Status</h2>
|
||||
<%= if @stats.by_worker == [] do %>
|
||||
<p class="text-sm text-base-content/50 italic">No active jobs.</p>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue