Add detailed database stats to backfill dashboard
- Enrichment status breakdown per type (HRRR/Weather/Terrain/IEMRE) with color-coded status counts (complete/pending/queued/failed) - Database overview: total DB size, HRRR profiles, observations, soundings with per-contact averages - Table sizes: rows and disk size per table, sorted by size - All stats auto-refresh every 2 seconds
This commit is contained in:
parent
59665a5009
commit
2452f1f397
1 changed files with 192 additions and 8 deletions
|
|
@ -19,11 +19,11 @@ defmodule MicrowavepropWeb.BackfillLive do
|
|||
|
||||
stats = fetch_stats()
|
||||
unprocessed = count_unprocessed()
|
||||
total_contacts = Repo.aggregate(from(c in Contact, where: not is_nil(c.pos1)), :count)
|
||||
db_stats = fetch_db_stats()
|
||||
|
||||
case LiveStash.recover_state(socket) do
|
||||
{:recovered, socket} ->
|
||||
{:ok, assign(socket, stats: stats, unprocessed: unprocessed, total_contacts: total_contacts)}
|
||||
{:ok, assign(socket, stats: stats, unprocessed: unprocessed, db_stats: db_stats)}
|
||||
|
||||
_ ->
|
||||
{:ok,
|
||||
|
|
@ -32,7 +32,7 @@ defmodule MicrowavepropWeb.BackfillLive do
|
|||
limit: 500,
|
||||
stats: stats,
|
||||
unprocessed: unprocessed,
|
||||
total_contacts: total_contacts,
|
||||
db_stats: db_stats,
|
||||
last_enqueued: nil,
|
||||
enqueuing: false
|
||||
)}
|
||||
|
|
@ -61,7 +61,8 @@ defmodule MicrowavepropWeb.BackfillLive do
|
|||
last_enqueued: count,
|
||||
enqueuing: false,
|
||||
stats: fetch_stats(),
|
||||
unprocessed: count_unprocessed()
|
||||
unprocessed: count_unprocessed(),
|
||||
db_stats: fetch_db_stats()
|
||||
)}
|
||||
end
|
||||
|
||||
|
|
@ -71,7 +72,8 @@ defmodule MicrowavepropWeb.BackfillLive do
|
|||
{:noreply,
|
||||
assign(socket,
|
||||
stats: fetch_stats(),
|
||||
unprocessed: count_unprocessed()
|
||||
unprocessed: count_unprocessed(),
|
||||
db_stats: fetch_db_stats()
|
||||
)}
|
||||
end
|
||||
|
||||
|
|
@ -154,6 +156,126 @@ defmodule MicrowavepropWeb.BackfillLive do
|
|||
%{by_worker: by_worker, completed_1h: completed_1h}
|
||||
end
|
||||
|
||||
defp fetch_db_stats do
|
||||
%{rows: rows} =
|
||||
Repo.query!("""
|
||||
SELECT
|
||||
relname as table,
|
||||
n_live_tup as rows,
|
||||
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 relname NOT LIKE 'oban_%'
|
||||
AND relname NOT LIKE 'schema_%'
|
||||
AND n_live_tup > 0
|
||||
ORDER BY pg_total_relation_size(c.oid) DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
|
||||
tables =
|
||||
Enum.map(rows, fn [table, row_count, bytes] ->
|
||||
%{table: table, rows: row_count, 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 qsos GROUP BY hrrr_status
|
||||
UNION ALL
|
||||
SELECT
|
||||
'weather', weather_status, count(*) FROM qsos GROUP BY weather_status
|
||||
UNION ALL
|
||||
SELECT
|
||||
'terrain', terrain_status, count(*) FROM qsos GROUP BY terrain_status
|
||||
UNION ALL
|
||||
SELECT
|
||||
'iemre', iemre_status, count(*) FROM qsos GROUP BY iemre_status
|
||||
ORDER BY 1, 2
|
||||
""")
|
||||
|
||||
statuses =
|
||||
Enum.group_by(status_rows, fn [type, _, _] -> type end, fn [_, status, count] ->
|
||||
{status, count}
|
||||
end)
|
||||
|
||||
# Per-contact averages
|
||||
%{rows: avg_rows} =
|
||||
Repo.query!("""
|
||||
SELECT
|
||||
(SELECT count(*) FROM hrrr_profiles) as hrrr_profiles,
|
||||
(SELECT count(*) FROM terrain_profiles) as terrain_profiles,
|
||||
(SELECT count(*) FROM surface_observations) as surface_obs,
|
||||
(SELECT count(*) FROM soundings) as soundings,
|
||||
(SELECT count(*) FROM iemre_observations) as iemre_obs,
|
||||
(SELECT count(*) FROM qsos WHERE pos1 IS NOT NULL) as contacts
|
||||
""")
|
||||
|
||||
[[hrrr_count, terrain_count, obs_count, sounding_count, iemre_count, contact_count]] = avg_rows
|
||||
|
||||
contact_count = max(contact_count, 1)
|
||||
|
||||
%{
|
||||
db_size: format_bytes(db_bytes),
|
||||
tables: tables,
|
||||
statuses: statuses,
|
||||
totals: %{
|
||||
hrrr_profiles: hrrr_count,
|
||||
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 format_number(n) when is_integer(n) do
|
||||
n
|
||||
|> Integer.to_string()
|
||||
|> String.graphemes()
|
||||
|> Enum.reverse()
|
||||
|> Enum.chunk_every(3)
|
||||
|> Enum.map_join(",", &Enum.reverse/1)
|
||||
|> String.reverse()
|
||||
|> then(fn s -> String.replace(s, ~r/^,/, "") end)
|
||||
end
|
||||
|
||||
defp format_number(n) when is_float(n), do: format_number(round(n))
|
||||
defp format_number(n), do: to_string(n)
|
||||
|
||||
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: ""
|
||||
|
||||
defp format_bytes(bytes) when bytes >= 1_073_741_824,
|
||||
do: "#{Float.round(bytes / 1_073_741_824, 1)} GB"
|
||||
|
||||
defp format_bytes(bytes) when bytes >= 1_048_576,
|
||||
do: "#{Float.round(bytes / 1_048_576, 1)} MB"
|
||||
|
||||
defp format_bytes(bytes) when bytes >= 1024,
|
||||
do: "#{Float.round(bytes / 1024, 1)} KB"
|
||||
|
||||
defp format_bytes(bytes), do: "#{bytes} B"
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -168,12 +290,12 @@ defmodule MicrowavepropWeb.BackfillLive do
|
|||
<div class="text-xs opacity-60">Enrichment Progress</div>
|
||||
<div class="text-2xl font-bold">{@unprocessed.total} remaining</div>
|
||||
<div class="text-xs opacity-60 mt-1">
|
||||
{@total_contacts - @unprocessed.total} / {@total_contacts} complete
|
||||
{@db_stats.totals.contacts - @unprocessed.total} / {@db_stats.totals.contacts} complete
|
||||
</div>
|
||||
<progress
|
||||
class="progress progress-primary w-full mt-2"
|
||||
value={@total_contacts - @unprocessed.total}
|
||||
max={@total_contacts}
|
||||
value={@db_stats.totals.contacts - @unprocessed.total}
|
||||
max={@db_stats.totals.contacts}
|
||||
/>
|
||||
<div class="flex gap-4 mt-2 text-xs opacity-60">
|
||||
<span>Terrain: {@unprocessed.terrain}</span>
|
||||
|
|
@ -265,6 +387,68 @@ defmodule MicrowavepropWeb.BackfillLive do
|
|||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<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"}] 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="bg-base-200 rounded-box p-3">
|
||||
<div class="text-xs opacity-60">Total DB Size</div>
|
||||
<div class="text-lg font-bold font-mono">{@db_stats.db_size}</div>
|
||||
</div>
|
||||
<div class="bg-base-200 rounded-box p-3">
|
||||
<div class="text-xs opacity-60">HRRR Profiles</div>
|
||||
<div class="text-lg font-bold font-mono">{format_number(@db_stats.totals.hrrr_profiles)}</div>
|
||||
<div class="text-xs opacity-60">{@db_stats.averages.hrrr_per_contact} per contact</div>
|
||||
</div>
|
||||
<div class="bg-base-200 rounded-box p-3">
|
||||
<div class="text-xs opacity-60">Surface Observations</div>
|
||||
<div class="text-lg font-bold font-mono">{format_number(@db_stats.totals.surface_observations)}</div>
|
||||
<div class="text-xs opacity-60">{@db_stats.averages.obs_per_contact} per contact</div>
|
||||
</div>
|
||||
<div class="bg-base-200 rounded-box p-3">
|
||||
<div class="text-xs opacity-60">Soundings</div>
|
||||
<div class="text-lg font-bold font-mono">{format_number(@db_stats.totals.soundings)}</div>
|
||||
<div class="text-xs opacity-60">{@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">
|
||||
<table class="table table-xs table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Table</th>
|
||||
<th class="text-right">Rows</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.size}</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-base-content/50 mt-4">Auto-refreshes every 2 seconds.</p>
|
||||
</Layouts.app>
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue