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()
|
stats = fetch_stats()
|
||||||
unprocessed = count_unprocessed()
|
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
|
case LiveStash.recover_state(socket) do
|
||||||
{:recovered, socket} ->
|
{: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,
|
{:ok,
|
||||||
|
|
@ -32,7 +32,7 @@ defmodule MicrowavepropWeb.BackfillLive do
|
||||||
limit: 500,
|
limit: 500,
|
||||||
stats: stats,
|
stats: stats,
|
||||||
unprocessed: unprocessed,
|
unprocessed: unprocessed,
|
||||||
total_contacts: total_contacts,
|
db_stats: db_stats,
|
||||||
last_enqueued: nil,
|
last_enqueued: nil,
|
||||||
enqueuing: false
|
enqueuing: false
|
||||||
)}
|
)}
|
||||||
|
|
@ -61,7 +61,8 @@ defmodule MicrowavepropWeb.BackfillLive do
|
||||||
last_enqueued: count,
|
last_enqueued: count,
|
||||||
enqueuing: false,
|
enqueuing: false,
|
||||||
stats: fetch_stats(),
|
stats: fetch_stats(),
|
||||||
unprocessed: count_unprocessed()
|
unprocessed: count_unprocessed(),
|
||||||
|
db_stats: fetch_db_stats()
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -71,7 +72,8 @@ defmodule MicrowavepropWeb.BackfillLive do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
assign(socket,
|
assign(socket,
|
||||||
stats: fetch_stats(),
|
stats: fetch_stats(),
|
||||||
unprocessed: count_unprocessed()
|
unprocessed: count_unprocessed(),
|
||||||
|
db_stats: fetch_db_stats()
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -154,6 +156,126 @@ defmodule MicrowavepropWeb.BackfillLive do
|
||||||
%{by_worker: by_worker, completed_1h: completed_1h}
|
%{by_worker: by_worker, completed_1h: completed_1h}
|
||||||
end
|
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -168,12 +290,12 @@ defmodule MicrowavepropWeb.BackfillLive do
|
||||||
<div class="text-xs opacity-60">Enrichment Progress</div>
|
<div class="text-xs opacity-60">Enrichment Progress</div>
|
||||||
<div class="text-2xl font-bold">{@unprocessed.total} remaining</div>
|
<div class="text-2xl font-bold">{@unprocessed.total} remaining</div>
|
||||||
<div class="text-xs opacity-60 mt-1">
|
<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>
|
</div>
|
||||||
<progress
|
<progress
|
||||||
class="progress progress-primary w-full mt-2"
|
class="progress progress-primary w-full mt-2"
|
||||||
value={@total_contacts - @unprocessed.total}
|
value={@db_stats.totals.contacts - @unprocessed.total}
|
||||||
max={@total_contacts}
|
max={@db_stats.totals.contacts}
|
||||||
/>
|
/>
|
||||||
<div class="flex gap-4 mt-2 text-xs opacity-60">
|
<div class="flex gap-4 mt-2 text-xs opacity-60">
|
||||||
<span>Terrain: {@unprocessed.terrain}</span>
|
<span>Terrain: {@unprocessed.terrain}</span>
|
||||||
|
|
@ -265,6 +387,68 @@ defmodule MicrowavepropWeb.BackfillLive do
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% 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>
|
<p class="text-xs text-base-content/50 mt-4">Auto-refreshes every 2 seconds.</p>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue