The three tables on /status (Job Queue, Rust Workers, Table Sizes)
were collapsing to fit the viewport on narrow phones, wrapping
header text ("Siz…") and splitting values like "160 GB" into two
lines ("160." / "GB"). The overflow-x-auto wrappers were already
present but column auto-sizing still squeezed width below content.
Adds `whitespace-nowrap` to each `<table>` so cells never wrap, and
`-mx-2 px-2` on the overflow wrapper so horizontal scroll extends
edge-to-edge of the mobile viewport instead of being inset by the
layout's outer padding. Tables now scroll horizontally as intended
on mobile; desktop layout unchanged.
692 lines
24 KiB
Elixir
692 lines
24 KiB
Elixir
defmodule MicrowavepropWeb.StatusLive do
|
|
@moduledoc "`/status` ops dashboard — enrichment progress, queue depths, DB size."
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
import Ecto.Query
|
|
|
|
alias Microwaveprop.Cache
|
|
alias Microwaveprop.Format
|
|
alias Microwaveprop.Radio.Contact
|
|
alias Microwaveprop.Repo
|
|
alias Microwaveprop.Weather.NarrClient
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
_ =
|
|
if connected?(socket) do
|
|
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "db:contact_status")
|
|
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "db:oban_jobs")
|
|
# Rust prop-grid-rs emits NOTIFY propagation_ready on completion —
|
|
# PropagationNotifyListener fans it out as `propagation:updated` so
|
|
# the grid_tasks panel transitions from "running" → "done" without
|
|
# a manual refresh.
|
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
|
|
end
|
|
|
|
stats = fetch_stats()
|
|
unprocessed = count_unprocessed()
|
|
db_stats = fetch_db_stats()
|
|
grid_tasks = fetch_grid_tasks_stats()
|
|
hrrr_point_tasks = fetch_hrrr_point_tasks_stats()
|
|
|
|
{:ok,
|
|
assign(socket,
|
|
page_title: "Status",
|
|
stats: stats,
|
|
unprocessed: unprocessed,
|
|
db_stats: db_stats,
|
|
grid_tasks: grid_tasks,
|
|
hrrr_point_tasks: hrrr_point_tasks,
|
|
refresh_timer: nil
|
|
)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:contact_status_changed, _data}, socket) do
|
|
{:noreply, schedule_refresh(socket)}
|
|
end
|
|
|
|
def handle_info({:oban_job_changed, _data}, socket) do
|
|
{:noreply, schedule_refresh(socket)}
|
|
end
|
|
|
|
def handle_info({:propagation_updated, _valid_times}, socket) do
|
|
{:noreply, schedule_refresh(socket)}
|
|
end
|
|
|
|
def handle_info(:refresh_stats, socket) do
|
|
{:noreply,
|
|
assign(socket,
|
|
stats: fetch_stats(),
|
|
unprocessed: count_unprocessed(),
|
|
db_stats: fetch_db_stats(),
|
|
grid_tasks: fetch_grid_tasks_stats(),
|
|
hrrr_point_tasks: fetch_hrrr_point_tasks_stats(),
|
|
refresh_timer: nil
|
|
)}
|
|
end
|
|
|
|
defp schedule_refresh(%{assigns: %{refresh_timer: ref}} = socket) when is_reference(ref) do
|
|
# Already scheduled, skip
|
|
socket
|
|
end
|
|
|
|
defp schedule_refresh(socket) do
|
|
# 2s debounce — bulk enrichment runs produce hundreds of status_changed
|
|
# events per second, and each stats refresh fires 8+ count queries.
|
|
ref = Process.send_after(self(), :refresh_stats, 2_000)
|
|
assign(socket, refresh_timer: ref)
|
|
end
|
|
|
|
defp count_unprocessed do
|
|
Cache.fetch_or_store({__MODULE__, :count_unprocessed}, 2_000, fn ->
|
|
count_unprocessed_raw()
|
|
end)
|
|
end
|
|
|
|
defp count_unprocessed_raw do
|
|
# One scan over the contacts table returns every per-type pending
|
|
# count via FILTER clauses. Cheaper than N separate COUNT queries
|
|
# and cheaper than N index scans. Keep the FILTER list in lockstep
|
|
# with the progress-bar list in the template — missing a field
|
|
# here means the progress bar will deny-count its own column.
|
|
%{rows: [[total, terrain, hrrr, weather, iemre, radar, mechanism, all_done]]} =
|
|
Repo.query!("""
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE pos1 IS NOT NULL),
|
|
COUNT(*) FILTER (WHERE pos1 IS NOT NULL
|
|
AND terrain_status::text IN ('pending','queued','processing','failed')),
|
|
COUNT(*) FILTER (WHERE pos1 IS NOT NULL
|
|
AND hrrr_status::text IN ('pending','queued','processing','failed')),
|
|
COUNT(*) FILTER (WHERE pos1 IS NOT NULL
|
|
AND weather_status::text IN ('pending','queued','processing','failed')),
|
|
COUNT(*) FILTER (WHERE pos1 IS NOT NULL
|
|
AND iemre_status::text IN ('pending','queued','processing','failed')),
|
|
COUNT(*) FILTER (WHERE pos1 IS NOT NULL
|
|
AND radar_status::text IN ('pending','queued','processing','failed')),
|
|
COUNT(*) FILTER (WHERE pos1 IS NOT NULL
|
|
AND mechanism_status::text IN ('pending','queued','processing','failed')),
|
|
COUNT(*) FILTER (WHERE pos1 IS NOT NULL
|
|
AND hrrr_status::text IN ('complete','unavailable')
|
|
AND weather_status::text IN ('complete','unavailable')
|
|
AND terrain_status::text IN ('complete','unavailable')
|
|
AND iemre_status::text IN ('complete','unavailable'))
|
|
FROM contacts
|
|
""")
|
|
|
|
narr_candidates = count_narr_candidates()
|
|
narr_done = count_narr_done()
|
|
|
|
%{
|
|
terrain: terrain,
|
|
hrrr: hrrr,
|
|
weather: weather,
|
|
iemre: iemre,
|
|
radar: radar,
|
|
mechanism: mechanism,
|
|
narr_candidates: narr_candidates,
|
|
narr_done: narr_done,
|
|
remaining: max(terrain, max(hrrr, max(weather, iemre))),
|
|
complete: all_done,
|
|
total: total
|
|
}
|
|
end
|
|
|
|
# NARR is the fallback for pre-2014 contacts (HRRR archive starts 2014).
|
|
# Candidate eligibility is defined by qso_timestamp, not hrrr_status —
|
|
# pre-2014 rows bounce between :queued and :unavailable every cron cycle
|
|
# and the UI must count them either way.
|
|
defp count_narr_candidates do
|
|
coverage_end = NarrClient.coverage_end()
|
|
|
|
Repo.one(
|
|
from(c in Contact,
|
|
where: c.qso_timestamp < ^coverage_end and not is_nil(c.pos1),
|
|
select: count()
|
|
)
|
|
)
|
|
end
|
|
|
|
# "Done" means a narr_profiles row within 0.15° of the contact and
|
|
# stamped at the floor-snapped 3-hour analysis mark.
|
|
#
|
|
# NARR analyses live at 00/03/06/09/12/15/18/21 UTC. The fetcher calls
|
|
# `NarrClient.snap_to_analysis_hour/1`, which floors qso_timestamp to the
|
|
# previous 3-hour mark — so a contact at 18:50Z yields a profile valid at
|
|
# 18:00Z. The SQL snap below must stay in sync with that Elixir function.
|
|
defp count_narr_done do
|
|
coverage_end = NarrClient.coverage_end()
|
|
|
|
%{rows: [[done]]} =
|
|
Repo.query!(
|
|
"""
|
|
SELECT count(*)
|
|
FROM contacts c
|
|
WHERE c.qso_timestamp < $1
|
|
AND c.pos1 IS NOT NULL
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM narr_profiles p
|
|
WHERE p.lat BETWEEN ((c.pos1->>'lat')::float - 0.15) AND ((c.pos1->>'lat')::float + 0.15)
|
|
AND p.lon BETWEEN ((c.pos1->>'lon')::float - 0.15) AND ((c.pos1->>'lon')::float + 0.15)
|
|
AND p.valid_time = date_trunc('hour', c.qso_timestamp)
|
|
- make_interval(hours => CAST(EXTRACT(HOUR FROM c.qso_timestamp) AS integer) % 3)
|
|
)
|
|
""",
|
|
[coverage_end]
|
|
)
|
|
|
|
done
|
|
end
|
|
|
|
defp fetch_grid_tasks_stats do
|
|
Cache.fetch_or_store({__MODULE__, :grid_tasks_stats}, 2_000, fn -> fetch_grid_tasks_stats_raw() end)
|
|
end
|
|
|
|
# Snapshot of the Rust prop-grid-rs work queue. Mirrors the Oban panel —
|
|
# "what's running, what's pending, what's retrying" — but pulls from the
|
|
# grid_tasks hand-off table instead of oban_jobs.
|
|
defp fetch_grid_tasks_stats_raw do
|
|
running =
|
|
Repo.all(
|
|
from(t in "grid_tasks",
|
|
where: t.status == "running",
|
|
order_by: [asc: t.run_time, asc: t.forecast_hour],
|
|
select: %{
|
|
run_time: t.run_time,
|
|
forecast_hour: t.forecast_hour,
|
|
attempt: t.attempt,
|
|
claimed_at: t.claimed_at
|
|
}
|
|
)
|
|
)
|
|
|
|
counts_by_status =
|
|
from(t in "grid_tasks",
|
|
where: t.status in ["queued", "failed"],
|
|
group_by: t.status,
|
|
select: {t.status, count(t.id)}
|
|
)
|
|
|> Repo.all()
|
|
|> Map.new()
|
|
|
|
done_1h =
|
|
Repo.one(
|
|
from(t in "grid_tasks",
|
|
where: t.status == "done" and t.completed_at > ago(1, "hour"),
|
|
select: count(t.id)
|
|
)
|
|
)
|
|
|
|
%{
|
|
running: running,
|
|
queued: Map.get(counts_by_status, "queued", 0),
|
|
failed: Map.get(counts_by_status, "failed", 0),
|
|
done_1h: done_1h
|
|
}
|
|
end
|
|
|
|
defp fetch_hrrr_point_tasks_stats do
|
|
Cache.fetch_or_store({__MODULE__, :hrrr_point_tasks_stats}, 2_000, fn ->
|
|
fetch_hrrr_point_tasks_stats_raw()
|
|
end)
|
|
end
|
|
|
|
# Snapshot of the Rust hrrr-point-rs work queue (per-QSO HRRR batches,
|
|
# one row per valid_time). Same shape as grid_tasks_stats so the UI
|
|
# can render both in a single table.
|
|
defp fetch_hrrr_point_tasks_stats_raw do
|
|
running_count =
|
|
Repo.one(
|
|
from(t in "hrrr_fetch_tasks",
|
|
where: t.status == "running",
|
|
select: count(t.id)
|
|
)
|
|
)
|
|
|
|
counts_by_status =
|
|
from(t in "hrrr_fetch_tasks",
|
|
where: t.status in ["queued", "failed"],
|
|
group_by: t.status,
|
|
select: {t.status, count(t.id)}
|
|
)
|
|
|> Repo.all()
|
|
|> Map.new()
|
|
|
|
done_1h =
|
|
Repo.one(
|
|
from(t in "hrrr_fetch_tasks",
|
|
where: t.status == "done" and t.completed_at > ago(1, "hour"),
|
|
select: count(t.id)
|
|
)
|
|
)
|
|
|
|
%{
|
|
running: running_count,
|
|
queued: Map.get(counts_by_status, "queued", 0),
|
|
failed: Map.get(counts_by_status, "failed", 0),
|
|
done_1h: done_1h
|
|
}
|
|
end
|
|
|
|
defp fetch_stats do
|
|
Cache.fetch_or_store({__MODULE__, :stats}, 2_000, fn -> fetch_stats_raw() end)
|
|
end
|
|
|
|
defp fetch_stats_raw do
|
|
jobs =
|
|
Repo.all(
|
|
from(j in "oban_jobs",
|
|
where: j.state in ["available", "executing", "scheduled", "retryable"],
|
|
group_by: [j.worker, j.state],
|
|
select: {j.worker, j.state, count(j.id)}
|
|
)
|
|
)
|
|
|
|
by_worker =
|
|
jobs
|
|
|> Enum.group_by(&elem(&1, 0), fn {_, state, count} -> {state, count} end)
|
|
|> Enum.map(&summarize_worker/1)
|
|
|> Enum.sort_by(& &1.total, :desc)
|
|
|
|
completed_1h =
|
|
Repo.one(
|
|
from(j in "oban_jobs",
|
|
where: j.state == "completed" and j.completed_at > ago(1, "hour"),
|
|
select: count(j.id)
|
|
)
|
|
)
|
|
|
|
%{by_worker: by_worker, completed_1h: completed_1h}
|
|
end
|
|
|
|
defp summarize_worker({worker, states}) do
|
|
short_name = worker |> String.split(".") |> List.last()
|
|
|
|
%{
|
|
worker: short_name,
|
|
total: Enum.reduce(states, 0, fn {_, c}, acc -> acc + c end),
|
|
executing: state_count(states, "executing"),
|
|
available: state_count(states, "available"),
|
|
retryable: state_count(states, "retryable")
|
|
}
|
|
end
|
|
|
|
defp state_count(states, name) do
|
|
Enum.find_value(states, 0, fn {s, c} -> if s == name, do: c end)
|
|
end
|
|
|
|
defp fetch_db_stats do
|
|
# pg_class + pg_stat_user_tables join is slow — 5s cache is fine since
|
|
# table row counts barely change in a 5-second window.
|
|
Cache.fetch_or_store({__MODULE__, :db_stats}, 5_000, fn -> fetch_db_stats_raw() end)
|
|
end
|
|
|
|
defp fetch_db_stats_raw do
|
|
%{rows: rows} =
|
|
Repo.query!("""
|
|
SELECT
|
|
c.relname as table,
|
|
s.n_live_tup as rows,
|
|
s.n_dead_tup as dead,
|
|
pg_total_relation_size(c.oid) as bytes
|
|
FROM pg_class c
|
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
JOIN pg_stat_user_tables s ON s.relid = c.oid
|
|
WHERE n.nspname = 'public'
|
|
AND c.relkind = 'r'
|
|
AND c.relname NOT LIKE 'oban_%'
|
|
AND c.relname NOT LIKE 'schema_%'
|
|
AND NOT EXISTS (SELECT 1 FROM pg_inherits WHERE inhrelid = c.oid)
|
|
UNION ALL
|
|
SELECT
|
|
parent.relname,
|
|
COALESCE(SUM(cs.n_live_tup), 0)::bigint,
|
|
COALESCE(SUM(cs.n_dead_tup), 0)::bigint,
|
|
COALESCE(SUM(pg_total_relation_size(child.oid)), 0)::bigint
|
|
FROM pg_class parent
|
|
JOIN pg_namespace n ON n.oid = parent.relnamespace
|
|
JOIN pg_inherits inh ON inh.inhparent = parent.oid
|
|
JOIN pg_class child ON child.oid = inh.inhrelid
|
|
LEFT JOIN pg_stat_user_tables cs ON cs.relid = child.oid
|
|
WHERE n.nspname = 'public' AND parent.relkind = 'p'
|
|
GROUP BY parent.relname
|
|
ORDER BY bytes DESC
|
|
LIMIT 20
|
|
""")
|
|
|
|
tables =
|
|
Enum.map(rows, fn [table, row_count, dead, bytes] ->
|
|
%{table: table, rows: row_count, dead: dead, size: Format.bytes(bytes)}
|
|
end)
|
|
|
|
%{rows: total_rows} =
|
|
Repo.query!("SELECT pg_database_size(current_database())")
|
|
|
|
[[db_bytes]] = total_rows
|
|
|
|
# Enrichment status breakdown
|
|
%{rows: status_rows} =
|
|
Repo.query!("""
|
|
SELECT
|
|
'hrrr' as type, hrrr_status as status, count(*) as cnt FROM contacts GROUP BY hrrr_status
|
|
UNION ALL
|
|
SELECT
|
|
'weather', weather_status, count(*) FROM contacts GROUP BY weather_status
|
|
UNION ALL
|
|
SELECT
|
|
'terrain', terrain_status, count(*) FROM contacts GROUP BY terrain_status
|
|
UNION ALL
|
|
SELECT
|
|
'iemre', iemre_status, count(*) FROM contacts GROUP BY iemre_status
|
|
UNION ALL
|
|
SELECT
|
|
'radar', radar_status, count(*) FROM contacts GROUP BY radar_status
|
|
ORDER BY 1, 2
|
|
""")
|
|
|
|
statuses =
|
|
Enum.group_by(status_rows, fn [type, _, _] -> type end, fn [_, status, count] ->
|
|
{status, count}
|
|
end)
|
|
|
|
# Per-table row counts from pg_stat estimates (already fetched above, avoids slow count(*))
|
|
table_counts = Map.new(tables, fn %{table: t, rows: r} -> {t, r} end)
|
|
|
|
# NARR doesn't have its own status column — synthesize from HRRR unavailable
|
|
# + narr_profiles row count.
|
|
narr_count = Map.get(table_counts, "narr_profiles", 0)
|
|
|
|
hrrr_unavailable =
|
|
statuses |> Map.get("hrrr", []) |> Enum.find_value(0, fn {s, c} -> if s == "unavailable", do: c end)
|
|
|
|
statuses =
|
|
Map.put(statuses, "narr", [
|
|
{"candidates (HRRR unavail)", hrrr_unavailable},
|
|
{"profiles fetched", narr_count}
|
|
])
|
|
|
|
hrrr_count = Map.get(table_counts, "hrrr_profiles", 0)
|
|
terrain_count = Map.get(table_counts, "terrain_profiles", 0)
|
|
obs_count = Map.get(table_counts, "surface_observations", 0)
|
|
sounding_count = Map.get(table_counts, "soundings", 0)
|
|
iemre_count = Map.get(table_counts, "iemre_observations", 0)
|
|
contact_count = max(Map.get(table_counts, "contacts", 0), 1)
|
|
|
|
%{
|
|
db_size: Format.bytes(db_bytes),
|
|
tables: tables,
|
|
statuses: statuses,
|
|
totals: %{
|
|
hrrr_profiles: hrrr_count,
|
|
narr_profiles: Map.get(table_counts, "narr_profiles", 0),
|
|
terrain_profiles: terrain_count,
|
|
surface_observations: obs_count,
|
|
soundings: sounding_count,
|
|
iemre_observations: iemre_count,
|
|
contacts: contact_count
|
|
},
|
|
averages: %{
|
|
hrrr_per_contact: Float.round(hrrr_count / contact_count, 1),
|
|
obs_per_contact: Float.round(obs_count / contact_count, 1),
|
|
soundings_per_contact: Float.round(sounding_count / contact_count, 1)
|
|
}
|
|
}
|
|
end
|
|
|
|
defp status_class("complete"), do: "text-success"
|
|
defp status_class("pending"), do: "text-warning"
|
|
defp status_class("queued"), do: "text-info"
|
|
defp status_class("processing"), do: "text-info"
|
|
defp status_class("failed"), do: "text-error"
|
|
defp status_class("unavailable"), do: "opacity-40"
|
|
defp status_class(_), do: ""
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
|
<.header>
|
|
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">
|
|
<div class="stat bg-base-200 rounded-box overflow-hidden md:col-span-2">
|
|
<div class="stat-title">Enrichment Progress</div>
|
|
<div class="stat-value text-2xl">
|
|
{Format.number(@unprocessed.complete)} / {Format.number(@unprocessed.total)}
|
|
</div>
|
|
<div class="stat-desc">fully enriched</div>
|
|
<div class="flex flex-col gap-1.5 mt-3">
|
|
<%= for {key, label, complete, denom} <- [
|
|
{"hrrr", "HRRR", @unprocessed.total - @unprocessed.hrrr, @unprocessed.total},
|
|
{"weather", "Weather", @unprocessed.total - @unprocessed.weather, @unprocessed.total},
|
|
{"terrain", "Terrain", @unprocessed.total - @unprocessed.terrain, @unprocessed.total},
|
|
{"iemre", "IEMRE", @unprocessed.total - @unprocessed.iemre, @unprocessed.total},
|
|
{"radar", "Radar", @unprocessed.total - @unprocessed.radar, @unprocessed.total},
|
|
{"mechanism", "Mechanism", @unprocessed.total - @unprocessed.mechanism, @unprocessed.total},
|
|
{"narr", "NARR", @unprocessed.narr_done, @unprocessed.narr_candidates}
|
|
] do %>
|
|
<% pct = if denom > 0, do: round(complete * 100 / denom), else: 0 %>
|
|
<div class="flex items-center gap-2 text-xs" data-progress-key={key}>
|
|
<span class="w-14 shrink-0">{label}</span>
|
|
<progress
|
|
class="progress progress-primary flex-1 min-w-0"
|
|
value={pct}
|
|
max={100}
|
|
/>
|
|
<span class="shrink-0 font-mono opacity-60 whitespace-nowrap">
|
|
{Format.number(complete)} / {Format.number(denom)}
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<div class="stats stats-vertical bg-base-200 rounded-box md:col-span-2">
|
|
<div class="stat">
|
|
<div class="stat-title">Active/Queued Jobs</div>
|
|
<div class="stat-value text-2xl">
|
|
{Format.number(Enum.reduce(@stats.by_worker, 0, fn w, acc -> acc + w.total end))}
|
|
</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-title">Completed (last hour)</div>
|
|
<div class="stat-value text-2xl">{Format.number(@stats.completed_1h)}</div>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
<% else %>
|
|
<div class="overflow-x-auto -mx-2 px-2">
|
|
<table class="table table-sm table-zebra whitespace-nowrap">
|
|
<thead>
|
|
<tr>
|
|
<th>Worker</th>
|
|
<th>Executing</th>
|
|
<th>Pending</th>
|
|
<th>Retryable</th>
|
|
<th>Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<%= for w <- @stats.by_worker do %>
|
|
<tr>
|
|
<td class="font-semibold">{w.worker}</td>
|
|
<td>
|
|
<%= if w.executing > 0 do %>
|
|
<span class="flex items-center gap-1">
|
|
<span class="loading loading-spinner loading-xs"></span> {w.executing}
|
|
</span>
|
|
<% else %>
|
|
0
|
|
<% end %>
|
|
</td>
|
|
<td>{w.available}</td>
|
|
<td>
|
|
<%= if w.retryable > 0 do %>
|
|
<span class="text-warning">{w.retryable}</span>
|
|
<% else %>
|
|
0
|
|
<% end %>
|
|
</td>
|
|
<td>{w.total}</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div class="divider" />
|
|
|
|
<h2 class="text-base font-semibold mb-2">Rust Workers</h2>
|
|
<div id="grid-tasks-panel" class="overflow-x-auto mb-4 -mx-2 px-2">
|
|
<table class="table table-sm whitespace-nowrap">
|
|
<thead>
|
|
<tr>
|
|
<th>Worker</th>
|
|
<th>Running</th>
|
|
<th>Queued</th>
|
|
<th>Failed</th>
|
|
<th>Done (1h)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td class="font-semibold">prop-grid-rs</td>
|
|
<td>
|
|
<%= if @grid_tasks.running == [] do %>
|
|
0
|
|
<% else %>
|
|
<span class="flex items-center gap-2">
|
|
<span class="loading loading-spinner loading-xs"></span>
|
|
<span class="font-mono text-xs">
|
|
<%= for r <- @grid_tasks.running do %>
|
|
<span class="badge badge-sm badge-info mr-1">
|
|
f{String.pad_leading(Integer.to_string(r.forecast_hour), 2, "0")}
|
|
</span>
|
|
<% end %>
|
|
</span>
|
|
<span class="opacity-60 text-xs">running</span>
|
|
</span>
|
|
<% end %>
|
|
</td>
|
|
<td>{@grid_tasks.queued}</td>
|
|
<td>
|
|
<%= if @grid_tasks.failed > 0 do %>
|
|
<span class="text-warning">{@grid_tasks.failed}</span>
|
|
<% else %>
|
|
0
|
|
<% end %>
|
|
</td>
|
|
<td>{Format.number(@grid_tasks.done_1h)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="font-semibold">hrrr-point-rs</td>
|
|
<td>
|
|
<%= if @hrrr_point_tasks.running == 0 do %>
|
|
0
|
|
<% else %>
|
|
<span class="flex items-center gap-2">
|
|
<span class="loading loading-spinner loading-xs"></span>
|
|
<span class="font-mono text-xs">{@hrrr_point_tasks.running}</span>
|
|
<span class="opacity-60 text-xs">batches</span>
|
|
</span>
|
|
<% end %>
|
|
</td>
|
|
<td>{Format.number(@hrrr_point_tasks.queued)}</td>
|
|
<td>
|
|
<%= if @hrrr_point_tasks.failed > 0 do %>
|
|
<span class="text-warning">{@hrrr_point_tasks.failed}</span>
|
|
<% else %>
|
|
0
|
|
<% end %>
|
|
</td>
|
|
<td>{Format.number(@hrrr_point_tasks.done_1h)}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<h2 class="text-base font-semibold mb-2">Enrichment Status by Type</h2>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
<%= for {type, label} <- [{"hrrr", "HRRR"}, {"weather", "Weather"}, {"terrain", "Terrain"}, {"iemre", "IEMRE"}, {"radar", "Radar"}, {"narr", "NARR"}] do %>
|
|
<div class="bg-base-200 rounded-box p-3 text-xs">
|
|
<div class="font-semibold mb-1">{label}</div>
|
|
<%= for {status, count} <- Map.get(@db_stats.statuses, type, []) do %>
|
|
<div class="flex justify-between">
|
|
<span class={status_class(status)}>{status}</span>
|
|
<span class="font-mono">{Format.number(count)}</span>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
|
|
<h2 class="text-base font-semibold mb-2">Database</h2>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
|
<div class="stat bg-base-200 rounded-box overflow-hidden">
|
|
<div class="stat-title">Total DB Size</div>
|
|
<div class="stat-value text-xl font-mono">{@db_stats.db_size}</div>
|
|
</div>
|
|
<div class="stat bg-base-200 rounded-box overflow-hidden">
|
|
<div class="stat-title">HRRR Profiles</div>
|
|
<div class="stat-value text-xl font-mono">
|
|
{Format.number(@db_stats.totals.hrrr_profiles)}
|
|
</div>
|
|
<div class="stat-desc">{@db_stats.averages.hrrr_per_contact} per contact</div>
|
|
</div>
|
|
<div class="stat bg-base-200 rounded-box overflow-hidden">
|
|
<div class="stat-title">Surface Observations</div>
|
|
<div class="stat-value text-xl font-mono">
|
|
{Format.number(@db_stats.totals.surface_observations)}
|
|
</div>
|
|
<div class="stat-desc">{@db_stats.averages.obs_per_contact} per contact</div>
|
|
</div>
|
|
<div class="stat bg-base-200 rounded-box overflow-hidden">
|
|
<div class="stat-title">Soundings</div>
|
|
<div class="stat-value text-xl font-mono">
|
|
{Format.number(@db_stats.totals.soundings)}
|
|
</div>
|
|
<div class="stat-desc">{@db_stats.averages.soundings_per_contact} per contact</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h2 class="text-base font-semibold mb-2">Table Sizes</h2>
|
|
<div class="overflow-x-auto mb-4 -mx-2 px-2">
|
|
<table class="table table-xs table-zebra whitespace-nowrap">
|
|
<thead>
|
|
<tr>
|
|
<th>Table</th>
|
|
<th class="text-right">Rows</th>
|
|
<th class="text-right">Dead</th>
|
|
<th class="text-right">Size</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<%= for t <- @db_stats.tables do %>
|
|
<tr>
|
|
<td class="font-mono text-xs">{t.table}</td>
|
|
<td class="text-right font-mono text-xs">{Format.number(t.rows)}</td>
|
|
<td class={[
|
|
"text-right font-mono text-xs",
|
|
t.dead > 0 && "text-warning"
|
|
]}>
|
|
{Format.number(t.dead)}
|
|
</td>
|
|
<td class="text-right font-mono text-xs">{t.size}</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<p class="text-xs text-base-content/50 mt-4">Live updates via database notifications.</p>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
end
|