fix: split DB health endpoint and add slow query controls (#8725)

Split the DB health page into independent panes so fast pg_catalog-based
diagnostics render without waiting for the slower job table scans, and
enrich the slow queries view with server-side sort, reset, and better
setup guidance.

Backend:
- Split /api/db_health into two endpoints: fast panes (database_size,
  connection_pool, table_maintenance, slow_queries, datatables) and
  /jobs (job_retention, large_results with scan_limit).
- Add GET /api/db_health/slow_queries?sort=total|mean|calls for
  server-side sorting of pg_stat_statements queries (sort whitelisted
  via enum, SQL-injection safe).
- Add POST /api/db_health/slow_queries/reset to call
  pg_stat_statements_reset().
- Return stats_reset timestamp from pg_stat_statements_info (PG 14+).
- Bump slow queries to top 50 sorted by total_exec_time (was top 10 by
  mean_exec_time, which misses high-cumulative-load queries).
- Truncate slow queries to 500 chars (was 200).
- Filter table_maintenance to tables with >= 1000 total tuples.

Frontend (DbHealth.svelte):
- Two tabs (Overview / Jobs) with auto-refresh on selection.
- Refresh buttons right-aligned in both tabs; Jobs tab keeps the
  scan_limit selector on the left.
- Job Retention & Large Results always render, with "Click Refresh to
  load" placeholders when no data yet.
- Slow queries table: clickable column headers for server-side sort,
  click a row to toggle the full query text.
- Reset stats button with confirmation dialog, displays "Stats since"
  timestamp for before/after comparison workflow.
- When pg_stat_statements is not installed, show numbered setup
  instructions with copyable SQL snippets.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-04-05 14:00:44 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 02d0ee9198
commit 01e39d9cd1
2 changed files with 675 additions and 364 deletions
+130 -41
View File
@@ -6,7 +6,12 @@
* LICENSE-AGPL for a copy of the license.
*/
use axum::{extract::Query, routing::get, Extension, Json, Router};
use axum::{
extract::Query,
http::StatusCode,
routing::{get, post},
Extension, Json, Router,
};
use serde::{Deserialize, Serialize};
use windmill_common::error::JsonResult;
@@ -15,7 +20,11 @@ use crate::db::{ApiAuthed, DB};
use crate::utils::require_super_admin;
pub fn global_service() -> Router {
Router::new().route("/", get(get_db_health))
Router::new()
.route("/", get(get_db_health))
.route("/jobs", get(get_db_health_jobs))
.route("/slow_queries", get(get_slow_queries))
.route("/slow_queries/reset", post(reset_slow_queries))
}
// --- Response types ---
@@ -31,14 +40,18 @@ pub enum HealthLevel {
#[derive(Serialize)]
pub struct DbHealthResponse {
pub database_size: DatabaseSizeInfo,
pub job_retention: JobRetentionInfo,
pub large_results: LargeResultsInfo,
pub connection_pool: ConnectionPoolInfo,
pub table_maintenance: Vec<TableMaintenanceInfo>,
pub slow_queries: Option<SlowQueriesInfo>,
pub datatables: Vec<DatatableInfo>,
}
#[derive(Serialize)]
pub struct DbHealthJobsResponse {
pub job_retention: JobRetentionInfo,
pub large_results: LargeResultsInfo,
}
#[derive(Serialize)]
pub struct DatabaseSizeInfo {
pub total_size_bytes: i64,
@@ -102,6 +115,8 @@ pub struct TableMaintenanceInfo {
pub struct SlowQueriesInfo {
pub queries: Vec<SlowQueryRow>,
pub message: Option<String>,
/// When stats were last reset (from pg_stat_statements_info.stats_reset, PG 14+)
pub stats_reset: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Serialize)]
@@ -130,37 +145,45 @@ struct DbHealthQuery {
scan_limit: Option<i64>,
}
#[derive(Deserialize, Clone, Copy)]
#[serde(rename_all = "snake_case")]
enum SlowQuerySort {
Total,
Mean,
Calls,
}
impl SlowQuerySort {
fn order_by(&self) -> &'static str {
match self {
SlowQuerySort::Total => "total_exec_time DESC",
SlowQuerySort::Mean => "mean_exec_time DESC",
SlowQuerySort::Calls => "calls DESC",
}
}
}
#[derive(Deserialize)]
struct SlowQueriesQuery {
sort: Option<SlowQuerySort>,
}
async fn get_db_health(
ApiAuthed { email, .. }: ApiAuthed,
Extension(db): Extension<DB>,
Query(query): Query<DbHealthQuery>,
) -> JsonResult<DbHealthResponse> {
require_super_admin(&db, &email).await?;
let scan_limit = query.scan_limit.unwrap_or(10_000).clamp(1_000, 1_000_000);
let (
database_size,
job_retention,
large_results,
connection_pool,
table_maintenance,
slow_queries,
datatables,
) = tokio::try_join!(
let (database_size, connection_pool, table_maintenance, slow_queries, datatables) = tokio::try_join!(
fetch_database_size(&db),
fetch_job_retention(&db),
fetch_large_results(&db, scan_limit),
fetch_connection_pool(&db),
fetch_table_maintenance(&db),
fetch_slow_queries(&db),
fetch_slow_queries(&db, SlowQuerySort::Total),
fetch_datatables(&db),
)?;
Ok(Json(DbHealthResponse {
database_size,
job_retention,
large_results,
connection_pool,
table_maintenance,
slow_queries,
@@ -168,6 +191,49 @@ async fn get_db_health(
}))
}
async fn get_db_health_jobs(
ApiAuthed { email, .. }: ApiAuthed,
Extension(db): Extension<DB>,
Query(query): Query<DbHealthQuery>,
) -> JsonResult<DbHealthJobsResponse> {
require_super_admin(&db, &email).await?;
let scan_limit = query.scan_limit.unwrap_or(10_000).clamp(1_000, 1_000_000);
let (job_retention, large_results) = tokio::try_join!(
fetch_job_retention(&db),
fetch_large_results(&db, scan_limit),
)?;
Ok(Json(DbHealthJobsResponse { job_retention, large_results }))
}
async fn get_slow_queries(
ApiAuthed { email, .. }: ApiAuthed,
Extension(db): Extension<DB>,
Query(query): Query<SlowQueriesQuery>,
) -> JsonResult<Option<SlowQueriesInfo>> {
require_super_admin(&db, &email).await?;
let sort = query.sort.unwrap_or(SlowQuerySort::Total);
Ok(Json(fetch_slow_queries(&db, sort).await?))
}
async fn reset_slow_queries(
ApiAuthed { email, .. }: ApiAuthed,
Extension(db): Extension<DB>,
) -> windmill_common::error::Result<StatusCode> {
require_super_admin(&db, &email).await?;
sqlx::query("SELECT pg_stat_statements_reset()")
.execute(&db)
.await
.map_err(|e| {
windmill_common::error::Error::InternalErr(format!(
"Failed to reset pg_stat_statements (is the extension enabled?): {e}"
))
})?;
Ok(StatusCode::NO_CONTENT)
}
// --- Diagnostic queries ---
async fn fetch_database_size(db: &DB) -> windmill_common::error::Result<DatabaseSizeInfo> {
@@ -407,6 +473,7 @@ async fn fetch_table_maintenance(
LEFT JOIN pg_class p ON p.oid = i.inhparent
) sub
GROUP BY table_name
HAVING SUM(live_tuples) + SUM(dead_tuples) >= 1000
ORDER BY SUM(dead_tuples) DESC"#
)
.fetch_all(db)
@@ -441,7 +508,10 @@ async fn fetch_table_maintenance(
.collect())
}
async fn fetch_slow_queries(db: &DB) -> windmill_common::error::Result<Option<SlowQueriesInfo>> {
async fn fetch_slow_queries(
db: &DB,
sort: SlowQuerySort,
) -> windmill_common::error::Result<Option<SlowQueriesInfo>> {
let ext_exists: bool = sqlx::query_scalar!(
r#"SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements') as "exists!""#
)
@@ -455,35 +525,54 @@ async fn fetch_slow_queries(db: &DB) -> windmill_common::error::Result<Option<Sl
"pg_stat_statements extension is not installed. Enable it for slow query insights."
.to_string(),
),
stats_reset: None,
}));
}
// Use raw query since pg_stat_statements may not exist at compile time
let rows: Vec<SlowQueryRow> = sqlx::query_as::<_, (String, i64, f64, f64)>(
// Use raw query since pg_stat_statements may not exist at compile time.
// ORDER BY column is controlled via an enum (SlowQuerySort) so only safe
// whitelisted column names reach the query — no SQL injection risk.
let query = format!(
r#"SELECT
LEFT(query, 200),
LEFT(query, 500),
calls::bigint,
total_exec_time::float8,
mean_exec_time::float8
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat_statements%'
ORDER BY mean_exec_time DESC
LIMIT 10"#,
)
.fetch_all(db)
.await?
.into_iter()
.map(
|(query, calls, total_exec_time_ms, mean_exec_time_ms)| SlowQueryRow {
query,
calls,
total_exec_time_ms,
mean_exec_time_ms,
},
)
.collect();
ORDER BY {}
LIMIT 50"#,
sort.order_by()
);
let rows: Vec<SlowQueryRow> = sqlx::query_as::<_, (String, i64, f64, f64)>(&query)
.fetch_all(db)
.await?
.into_iter()
.map(
|(query, calls, total_exec_time_ms, mean_exec_time_ms)| SlowQueryRow {
query,
calls,
total_exec_time_ms,
mean_exec_time_ms,
},
)
.collect();
Ok(Some(SlowQueriesInfo { queries: rows, message: None }))
// pg_stat_statements_info exists in PG 14+; tolerate its absence
let stats_reset: Option<chrono::DateTime<chrono::Utc>> =
sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>(
"SELECT stats_reset FROM pg_stat_statements_info",
)
.fetch_one(db)
.await
.ok()
.flatten();
Ok(Some(SlowQueriesInfo {
queries: rows,
message: None,
stats_reset,
}))
}
async fn fetch_datatables(db: &DB) -> windmill_common::error::Result<Vec<DatatableInfo>> {
@@ -1,12 +1,75 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { Button, Tab, Tabs } from '$lib/components/common'
import { sendUserToast } from '$lib/toast'
import { Loader2, RefreshCw, ChevronDown, ChevronRight } from 'lucide-svelte'
import { Loader2, RefreshCw, ChevronDown, ChevronRight, ArrowDown } from 'lucide-svelte'
let loading = $state(false)
let jobsLoading = $state(false)
let data: any = $state(null)
let jobsData: any = $state(null)
let error: string | null = $state(null)
let jobsError: string | null = $state(null)
let scanLimit = $state(10000)
let activeTab = $state('overview')
type SlowQuerySort = 'total' | 'mean' | 'calls'
let slowSort: SlowQuerySort = $state('total')
let slowSortLoading = $state(false)
let expandedQueries: Record<number, boolean> = $state({})
async function resetSlowStats() {
if (
!confirm(
'Reset pg_stat_statements? This clears cumulative stats for ALL queries on this postgres instance.'
)
)
return
try {
const response = await fetch(`/api/db_health/slow_queries/reset`, {
method: 'POST',
credentials: 'include'
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || `HTTP ${response.status}`)
}
// refetch with current sort
const refetch = await fetch(`/api/db_health/slow_queries?sort=${slowSort}`, {
credentials: 'include'
})
if (refetch.ok && data) {
data.slow_queries = await refetch.json()
expandedQueries = {}
}
sendUserToast('pg_stat_statements reset successfully', false)
} catch (e: any) {
sendUserToast('Failed to reset stats: ' + e.message, true)
}
}
async function setSlowSort(sort: SlowQuerySort) {
if (sort === slowSort || slowSortLoading) return
slowSort = sort
slowSortLoading = true
try {
const response = await fetch(`/api/db_health/slow_queries?sort=${sort}`, {
credentials: 'include'
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || `HTTP ${response.status}`)
}
const slowQueries = await response.json()
if (data) {
data.slow_queries = slowQueries
expandedQueries = {}
}
} catch (e: any) {
sendUserToast('Failed to re-sort slow queries: ' + e.message, true)
} finally {
slowSortLoading = false
}
}
const scanLimitOptions = [
{ label: '10,000', value: 10000 },
@@ -29,18 +92,19 @@
expandedSections[key] = !expandedSections[key]
}
async function runDiagnostics() {
async function runFastDiagnostics() {
loading = true
error = null
try {
const response = await fetch(`/api/db_health?scan_limit=${scanLimit}`, {
credentials: 'include'
})
const response = await fetch(`/api/db_health`, { credentials: 'include' })
if (!response.ok) {
const text = await response.text()
throw new Error(text || `HTTP ${response.status}`)
}
data = await response.json()
// main endpoint always returns slow_queries sorted by total
slowSort = 'total'
expandedQueries = {}
} catch (e: any) {
error = e.message
sendUserToast('Failed to run diagnostics: ' + e.message, true)
@@ -49,6 +113,34 @@
}
}
async function runJobsDiagnostics() {
jobsLoading = true
jobsError = null
try {
const response = await fetch(`/api/db_health/jobs?scan_limit=${scanLimit}`, {
credentials: 'include'
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || `HTTP ${response.status}`)
}
jobsData = await response.json()
} catch (e: any) {
jobsError = e.message
sendUserToast('Failed to run job diagnostics: ' + e.message, true)
} finally {
jobsLoading = false
}
}
$effect(() => {
if (activeTab === 'overview') {
runFastDiagnostics()
} else if (activeTab === 'jobs') {
runJobsDiagnostics()
}
})
function statusColor(status: string): string {
switch (status) {
case 'green':
@@ -100,84 +192,414 @@
</script>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<Button
variant="default"
onclick={runDiagnostics}
disabled={loading}
startIcon={{ icon: loading ? Loader2 : RefreshCw }}
>
{loading ? 'Running...' : 'Run Diagnostics'}
</Button>
<label class="text-tertiary flex items-center gap-1 whitespace-nowrap text-xs">
Scan last
<select
class="border-surface-secondary text-secondary rounded border bg-transparent px-1 py-0.5 text-xs"
bind:value={scanLimit}
>
{#each scanLimitOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
jobs
</label>
{#if loading}
<span class="text-tertiary text-xs">This may take a few seconds...</span>
{/if}
</div>
<Tabs bind:selected={activeTab}>
<Tab value="overview" label="Overview" />
<Tab value="jobs" label="Jobs" />
</Tabs>
{#if error}
<div
class="rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700 dark:bg-red-950 dark:text-red-300"
>
{error}
{#if activeTab === 'overview'}
<div class="flex items-center justify-end gap-2">
<Button
variant="subtle"
onclick={runFastDiagnostics}
disabled={loading}
startIcon={{ icon: loading ? Loader2 : RefreshCw }}
size="xs"
>
{loading ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
{#if error}
<div
class="rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700 dark:bg-red-950 dark:text-red-300"
>
{error}
</div>
{/if}
{#if data}
<!-- Database Size -->
<section class="border-surface-secondary rounded-md border">
<button
class="flex w-full items-center justify-between p-3 text-left hover:bg-surface-secondary/50"
onclick={() => toggleSection('database_size')}
>
<h3 class="text-primary text-sm font-semibold">Database Size</h3>
<div class="flex items-center gap-2">
<span class="text-tertiary text-xs">{data.database_size.total_size_pretty}</span>
{#if expandedSections.database_size}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
</div>
</button>
{#if expandedSections.database_size}
<div class="border-surface-secondary border-t p-3">
<p class="text-secondary mb-2 text-xs">
Total database size: <strong>{data.database_size.total_size_pretty}</strong>
</p>
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead>
<tr class="text-tertiary border-surface-secondary border-b">
<th class="pb-1 pr-4">Table</th>
<th class="pb-1 pr-4 text-right">Size</th>
</tr>
</thead>
<tbody>
{#each data.database_size.top_tables as t}
<tr class="border-surface-secondary border-b last:border-0">
<td class="text-primary py-1 pr-4 font-mono">{t.table_name}</td>
<td class="text-secondary py-1 pr-4 text-right">{t.total_size_pretty}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</section>
<!-- Connection Pool -->
<section class="border-surface-secondary rounded-md border">
<button
class="flex w-full items-center justify-between p-3 text-left hover:bg-surface-secondary/50"
onclick={() => toggleSection('connection_pool')}
>
<h3 class="text-primary text-sm font-semibold">Database Connections</h3>
<div class="flex items-center gap-2">
<span
class="rounded px-1.5 py-0.5 text-xs font-medium {statusBadge(
data.connection_pool.status
)}"
>
{data.connection_pool.status}
</span>
{#if expandedSections.connection_pool}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
</div>
</button>
{#if expandedSections.connection_pool}
<div class="border-surface-secondary flex flex-col gap-1 border-t p-3 text-xs">
<p class="text-secondary">
Total connections: <strong>{data.connection_pool.pg_total_connections}</strong> / Max:
<strong>{data.connection_pool.pg_max_connections}</strong>
</p>
<p class="text-secondary">
Active: <strong>{data.connection_pool.pg_active_connections}</strong>
/ Idle: <strong>{data.connection_pool.pg_idle_connections}</strong>
</p>
<p class="{statusColor(data.connection_pool.status)} mt-1 font-medium">
{data.connection_pool.message}
</p>
</div>
{/if}
</section>
<!-- Table Maintenance -->
<section class="border-surface-secondary rounded-md border">
<button
class="flex w-full items-center justify-between p-3 text-left hover:bg-surface-secondary/50"
onclick={() => toggleSection('table_maintenance')}
>
<h3 class="text-primary text-sm font-semibold">Table Maintenance (Vacuum/Bloat)</h3>
<div class="flex items-center gap-2">
{#if expandedSections.table_maintenance}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
</div>
</button>
{#if expandedSections.table_maintenance}
<div class="border-surface-secondary border-t p-3">
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead>
<tr class="text-tertiary border-surface-secondary border-b">
<th class="pb-1 pr-4">Table</th>
<th class="pb-1 pr-4 text-right">Live Tuples</th>
<th class="pb-1 pr-4 text-right">Dead Tuples</th>
<th class="pb-1 pr-4 text-right">Dead %</th>
<th class="pb-1 pr-4 text-right">Last Vacuum</th>
<th class="pb-1 pr-4 text-right">Last Analyze</th>
<th class="pb-1 pr-4">Status</th>
</tr>
</thead>
<tbody>
{#each data.table_maintenance as t}
<tr class="border-surface-secondary border-b last:border-0">
<td class="text-primary py-1 pr-4 font-mono">{t.table_name}</td>
<td class="text-secondary py-1 pr-4 text-right"
>{formatNumber(t.live_tuples)}</td
>
<td class="text-secondary py-1 pr-4 text-right"
>{formatNumber(t.dead_tuples)}</td
>
<td class="text-secondary py-1 pr-4 text-right"
>{(t.dead_ratio * 100).toFixed(1)}%</td
>
<td class="text-secondary py-1 pr-4 text-right"
>{formatDate(t.last_autovacuum)}</td
>
<td class="text-secondary py-1 pr-4 text-right"
>{formatDate(t.last_autoanalyze)}</td
>
<td class="py-1 pr-4">
<span
class="rounded px-1.5 py-0.5 text-xs font-medium {statusBadge(t.status)}"
>
{t.status}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</section>
<!-- Slow Queries -->
<section class="border-surface-secondary rounded-md border">
<button
class="flex w-full items-center justify-between p-3 text-left hover:bg-surface-secondary/50"
onclick={() => toggleSection('slow_queries')}
>
<h3 class="text-primary text-sm font-semibold">Slow Queries</h3>
<div class="flex items-center gap-2">
{#if expandedSections.slow_queries}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
</div>
</button>
{#if expandedSections.slow_queries}
<div class="border-surface-secondary border-t p-3">
{#if data.slow_queries == null}
<p class="text-tertiary text-xs">Slow query data not available.</p>
{:else if data.slow_queries.message}
<div class="flex flex-col gap-2 text-xs">
<p class="text-secondary">{data.slow_queries.message}</p>
<div class="bg-surface-secondary rounded p-2">
<p class="text-secondary mb-1 font-semibold">
Setup (requires superuser + a postgres restart):
</p>
<ol class="text-tertiary ml-4 list-decimal space-y-1">
<li>
On the postgres server, enable the preload library:
<pre
class="text-primary mt-0.5 overflow-x-auto whitespace-pre-wrap break-all font-mono"
>ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements';</pre
>
</li>
<li>
Restart postgres so the library is actually loaded (e.g. <code
class="text-primary font-mono">sudo systemctl restart postgresql</code
>, or use your managed DB console for RDS/Cloud SQL).
</li>
<li>
On this database, create the extension:
<pre
class="text-primary mt-0.5 overflow-x-auto whitespace-pre-wrap break-all font-mono"
>CREATE EXTENSION pg_stat_statements;</pre
>
</li>
<li>Click Refresh on this page.</li>
</ol>
</div>
</div>
{:else}
<div class="flex items-start justify-between gap-6 pb-3">
<div class="text-tertiary flex flex-col gap-0.5 text-xs">
<div class="flex items-center gap-2">
<span
>Top 50 queries from pg_stat_statements, sorted server-side. Click a column to
re-sort. Click a row to show the full query.</span
>
{#if slowSortLoading}
<Loader2 size={12} class="animate-spin shrink-0" />
{/if}
</div>
{#if data.slow_queries.stats_reset}
<span>Stats since: {formatDate(data.slow_queries.stats_reset)}</span>
{/if}
</div>
<div class="shrink-0">
<Button variant="subtle" size="xs" onclick={resetSlowStats}>Reset stats</Button>
</div>
</div>
{#if data.slow_queries.queries.length === 0}
<p class="text-tertiary text-xs">No slow queries found.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead>
<tr class="text-tertiary border-surface-secondary border-b">
<th class="pb-1 pr-4">Query</th>
<th class="pb-1 pr-4 text-right">
<button
class="hover:text-primary inline-flex items-center gap-0.5"
onclick={() => setSlowSort('calls')}
disabled={slowSortLoading}
>
Calls
{#if slowSort === 'calls'}
<ArrowDown size={10} />
{/if}
</button>
</th>
<th class="pb-1 pr-4 text-right">
<button
class="hover:text-primary inline-flex items-center gap-0.5"
onclick={() => setSlowSort('total')}
disabled={slowSortLoading}
>
Total Time
{#if slowSort === 'total'}
<ArrowDown size={10} />
{/if}
</button>
</th>
<th class="pb-1 pr-4 text-right">
<button
class="hover:text-primary inline-flex items-center gap-0.5"
onclick={() => setSlowSort('mean')}
disabled={slowSortLoading}
>
Mean Time
{#if slowSort === 'mean'}
<ArrowDown size={10} />
{/if}
</button>
</th>
</tr>
</thead>
<tbody>
{#each data.slow_queries.queries as q, i}
<tr
class="border-surface-secondary hover:bg-surface-secondary/40 cursor-pointer border-b last:border-0"
onclick={() => (expandedQueries[i] = !expandedQueries[i])}
>
<td class="text-primary py-1 pr-4 font-mono">
{#if expandedQueries[i]}
<pre class="whitespace-pre-wrap break-all">{q.query}</pre>
{:else}
<div class="max-w-md truncate">{q.query}</div>
{/if}
</td>
<td class="text-secondary py-1 pr-4 text-right align-top"
>{formatNumber(q.calls)}</td
>
<td class="text-secondary py-1 pr-4 text-right align-top"
>{formatMs(q.total_exec_time_ms)}</td
>
<td class="text-secondary py-1 pr-4 text-right align-top"
>{formatMs(q.mean_exec_time_ms)}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
</div>
{/if}
</section>
<!-- Datatables -->
<section class="border-surface-secondary rounded-md border">
<button
class="flex w-full items-center justify-between p-3 text-left hover:bg-surface-secondary/50"
onclick={() => toggleSection('datatables')}
>
<h3 class="text-primary text-sm font-semibold">Datatables (Instance Storage)</h3>
<div class="flex items-center gap-2">
{#if expandedSections.datatables}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
</div>
</button>
{#if expandedSections.datatables}
<div class="border-surface-secondary border-t p-3">
{#if data.datatables.length === 0}
<p class="text-tertiary text-xs">No instance-stored datatables found.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead>
<tr class="text-tertiary border-surface-secondary border-b">
<th class="pb-1 pr-4">Workspace</th>
<th class="pb-1 pr-4">Name</th>
<th class="pb-1 pr-4">Table</th>
<th class="pb-1 pr-4 text-right">Size</th>
<th class="pb-1 pr-4 text-right">Est. Rows</th>
</tr>
</thead>
<tbody>
{#each data.datatables as dt}
<tr class="border-surface-secondary border-b last:border-0">
<td class="text-secondary py-1 pr-4">{dt.workspace_id}</td>
<td class="text-primary py-1 pr-4">{dt.name}</td>
<td class="text-secondary py-1 pr-4 font-mono">{dt.table_name}</td>
<td class="text-secondary py-1 pr-4 text-right">{dt.size_pretty}</td>
<td class="text-secondary py-1 pr-4 text-right"
>{formatNumber(Math.round(dt.estimated_rows))}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
</section>
{/if}
{/if}
{#if data}
<!-- Database Size -->
<section class="border-surface-secondary rounded-md border">
<button
class="flex w-full items-center justify-between p-3 text-left hover:bg-surface-secondary/50"
onclick={() => toggleSection('database_size')}
{#if activeTab === 'jobs'}
<div class="flex items-center justify-between gap-2">
<label class="text-tertiary flex items-center gap-1 whitespace-nowrap text-xs">
Scan last
<select
class="border-surface-secondary text-secondary rounded border bg-transparent px-1 py-0.5 text-xs"
bind:value={scanLimit}
>
{#each scanLimitOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
jobs
</label>
<Button
variant="subtle"
onclick={runJobsDiagnostics}
disabled={jobsLoading}
startIcon={{ icon: jobsLoading ? Loader2 : RefreshCw }}
size="xs"
>
<h3 class="text-primary text-sm font-semibold">Database Size</h3>
<div class="flex items-center gap-2">
<span class="text-tertiary text-xs">{data.database_size.total_size_pretty}</span>
{#if expandedSections.database_size}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
</div>
</button>
{#if expandedSections.database_size}
<div class="border-surface-secondary border-t p-3">
<p class="text-secondary mb-2 text-xs">
Total database size: <strong>{data.database_size.total_size_pretty}</strong>
</p>
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead>
<tr class="text-tertiary border-surface-secondary border-b">
<th class="pb-1 pr-4">Table</th>
<th class="pb-1 pr-4 text-right">Size</th>
</tr>
</thead>
<tbody>
{#each data.database_size.top_tables as t}
<tr class="border-surface-secondary border-b last:border-0">
<td class="text-primary py-1 pr-4 font-mono">{t.table_name}</td>
<td class="text-secondary py-1 pr-4 text-right">{t.total_size_pretty}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</section>
{jobsLoading ? 'Scanning...' : 'Refresh'}
</Button>
</div>
{#if jobsError}
<div
class="rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700 dark:bg-red-950 dark:text-red-300"
>
{jobsError}
</div>
{/if}
<!-- Job Retention -->
<section class="border-surface-secondary rounded-md border">
<button
@@ -186,13 +608,17 @@
>
<h3 class="text-primary text-sm font-semibold">Job Retention</h3>
<div class="flex items-center gap-2">
<span
class="rounded px-1.5 py-0.5 text-xs font-medium {statusBadge(
data.job_retention.status
)}"
>
{data.job_retention.status}
</span>
{#if jobsData}
<span
class="rounded px-1.5 py-0.5 text-xs font-medium {statusBadge(
jobsData.job_retention.status
)}"
>
{jobsData.job_retention.status}
</span>
{:else if jobsLoading}
<Loader2 size={14} class="text-tertiary animate-spin" />
{/if}
{#if expandedSections.job_retention}
<ChevronDown size={16} />
{:else}
@@ -201,25 +627,32 @@
</div>
</button>
{#if expandedSections.job_retention}
<div class="border-surface-secondary flex flex-col gap-1 border-t p-3 text-xs">
<p class="text-secondary">
Total completed jobs: <strong
>{formatNumber(data.job_retention.total_completed_jobs)}</strong
>
</p>
<p class="text-secondary">
Oldest job: <strong>{formatDate(data.job_retention.oldest_completed_at)}</strong>
</p>
<p class="text-secondary">
Retention period: <strong>
{data.job_retention.retention_period_secs
? formatNumber(data.job_retention.retention_period_secs) + 's'
: 'Not configured'}
</strong>
</p>
<p class="{statusColor(data.job_retention.status)} mt-1 font-medium">
{data.job_retention.message}
</p>
<div class="border-surface-secondary border-t p-3 text-xs">
{#if jobsData}
<div class="flex flex-col gap-1">
<p class="text-secondary">
Total completed jobs: <strong
>{formatNumber(jobsData.job_retention.total_completed_jobs)}</strong
>
</p>
<p class="text-secondary">
Oldest job: <strong>{formatDate(jobsData.job_retention.oldest_completed_at)}</strong
>
</p>
<p class="text-secondary">
Retention period: <strong>
{jobsData.job_retention.retention_period_secs
? formatNumber(jobsData.job_retention.retention_period_secs) + 's'
: 'Not configured'}
</strong>
</p>
<p class="{statusColor(jobsData.job_retention.status)} mt-1 font-medium">
{jobsData.job_retention.message}
</p>
</div>
{:else}
<p class="text-tertiary">Click Refresh to load.</p>
{/if}
</div>
{/if}
</section>
@@ -234,10 +667,12 @@
>Large Job Results (last {scanLimit.toLocaleString()} jobs)</h3
>
<div class="flex items-center gap-2">
{#if data.large_results.avg_result_size_bytes != null}
{#if jobsData && jobsData.large_results.avg_result_size_bytes != null}
<span class="text-tertiary text-xs"
>avg: {formatBytes(data.large_results.avg_result_size_bytes)}</span
>avg: {formatBytes(jobsData.large_results.avg_result_size_bytes)}</span
>
{:else if jobsLoading}
<Loader2 size={14} class="text-tertiary animate-spin" />
{/if}
{#if expandedSections.large_results}
<ChevronDown size={16} />
@@ -248,7 +683,9 @@
</button>
{#if expandedSections.large_results}
<div class="border-surface-secondary border-t p-3">
{#if data.large_results.top_large_results.length === 0}
{#if !jobsData}
<p class="text-tertiary text-xs">Click Refresh to load.</p>
{:else if jobsData.large_results.top_large_results.length === 0}
<p class="text-tertiary text-xs"
>No job results larger than 1 KB found in the scanned jobs.</p
>
@@ -265,7 +702,7 @@
</tr>
</thead>
<tbody>
{#each data.large_results.top_large_results as r}
{#each jobsData.large_results.top_large_results as r}
<tr class="border-surface-secondary border-b last:border-0">
<td class="text-primary py-1 pr-4 font-mono"
><a
@@ -291,220 +728,5 @@
</div>
{/if}
</section>
<!-- Connection Pool -->
<section class="border-surface-secondary rounded-md border">
<button
class="flex w-full items-center justify-between p-3 text-left hover:bg-surface-secondary/50"
onclick={() => toggleSection('connection_pool')}
>
<h3 class="text-primary text-sm font-semibold">Database Connections</h3>
<div class="flex items-center gap-2">
<span
class="rounded px-1.5 py-0.5 text-xs font-medium {statusBadge(
data.connection_pool.status
)}"
>
{data.connection_pool.status}
</span>
{#if expandedSections.connection_pool}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
</div>
</button>
{#if expandedSections.connection_pool}
<div class="border-surface-secondary flex flex-col gap-1 border-t p-3 text-xs">
<p class="text-secondary">
Total connections: <strong>{data.connection_pool.pg_total_connections}</strong> / Max:
<strong>{data.connection_pool.pg_max_connections}</strong>
</p>
<p class="text-secondary">
Active: <strong>{data.connection_pool.pg_active_connections}</strong>
/ Idle: <strong>{data.connection_pool.pg_idle_connections}</strong>
</p>
<p class="{statusColor(data.connection_pool.status)} mt-1 font-medium">
{data.connection_pool.message}
</p>
</div>
{/if}
</section>
<!-- Table Maintenance -->
<section class="border-surface-secondary rounded-md border">
<button
class="flex w-full items-center justify-between p-3 text-left hover:bg-surface-secondary/50"
onclick={() => toggleSection('table_maintenance')}
>
<h3 class="text-primary text-sm font-semibold">Table Maintenance (Vacuum/Bloat)</h3>
<div class="flex items-center gap-2">
{#if expandedSections.table_maintenance}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
</div>
</button>
{#if expandedSections.table_maintenance}
<div class="border-surface-secondary border-t p-3">
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead>
<tr class="text-tertiary border-surface-secondary border-b">
<th class="pb-1 pr-4">Table</th>
<th class="pb-1 pr-4 text-right">Live Tuples</th>
<th class="pb-1 pr-4 text-right">Dead Tuples</th>
<th class="pb-1 pr-4 text-right">Dead %</th>
<th class="pb-1 pr-4 text-right">Last Vacuum</th>
<th class="pb-1 pr-4 text-right">Last Analyze</th>
<th class="pb-1 pr-4">Status</th>
</tr>
</thead>
<tbody>
{#each data.table_maintenance as t}
<tr class="border-surface-secondary border-b last:border-0">
<td class="text-primary py-1 pr-4 font-mono">{t.table_name}</td>
<td class="text-secondary py-1 pr-4 text-right"
>{formatNumber(t.live_tuples)}</td
>
<td class="text-secondary py-1 pr-4 text-right"
>{formatNumber(t.dead_tuples)}</td
>
<td class="text-secondary py-1 pr-4 text-right"
>{(t.dead_ratio * 100).toFixed(1)}%</td
>
<td class="text-secondary py-1 pr-4 text-right"
>{formatDate(t.last_autovacuum)}</td
>
<td class="text-secondary py-1 pr-4 text-right"
>{formatDate(t.last_autoanalyze)}</td
>
<td class="py-1 pr-4">
<span
class="rounded px-1.5 py-0.5 text-xs font-medium {statusBadge(t.status)}"
>
{t.status}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</section>
<!-- Slow Queries -->
<section class="border-surface-secondary rounded-md border">
<button
class="flex w-full items-center justify-between p-3 text-left hover:bg-surface-secondary/50"
onclick={() => toggleSection('slow_queries')}
>
<h3 class="text-primary text-sm font-semibold">Slow Queries</h3>
<div class="flex items-center gap-2">
{#if expandedSections.slow_queries}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
</div>
</button>
{#if expandedSections.slow_queries}
<div class="border-surface-secondary border-t p-3">
{#if data.slow_queries == null}
<p class="text-tertiary text-xs">Slow query data not available.</p>
{:else if data.slow_queries.message}
<p class="text-tertiary text-xs">{data.slow_queries.message}</p>
{:else if data.slow_queries.queries.length === 0}
<p class="text-tertiary text-xs">No slow queries found.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead>
<tr class="text-tertiary border-surface-secondary border-b">
<th class="pb-1 pr-4">Query</th>
<th class="pb-1 pr-4 text-right">Calls</th>
<th class="pb-1 pr-4 text-right">Total Time</th>
<th class="pb-1 pr-4 text-right">Mean Time</th>
</tr>
</thead>
<tbody>
{#each data.slow_queries.queries as q}
<tr class="border-surface-secondary border-b last:border-0">
<td class="text-primary max-w-md truncate py-1 pr-4 font-mono">{q.query}</td>
<td class="text-secondary py-1 pr-4 text-right">{formatNumber(q.calls)}</td>
<td class="text-secondary py-1 pr-4 text-right"
>{formatMs(q.total_exec_time_ms)}</td
>
<td class="text-secondary py-1 pr-4 text-right"
>{formatMs(q.mean_exec_time_ms)}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
</section>
<!-- Datatables -->
<section class="border-surface-secondary rounded-md border">
<button
class="flex w-full items-center justify-between p-3 text-left hover:bg-surface-secondary/50"
onclick={() => toggleSection('datatables')}
>
<h3 class="text-primary text-sm font-semibold">Datatables (Instance Storage)</h3>
<div class="flex items-center gap-2">
{#if expandedSections.datatables}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
</div>
</button>
{#if expandedSections.datatables}
<div class="border-surface-secondary border-t p-3">
{#if data.datatables.length === 0}
<p class="text-tertiary text-xs">No instance-stored datatables found.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead>
<tr class="text-tertiary border-surface-secondary border-b">
<th class="pb-1 pr-4">Workspace</th>
<th class="pb-1 pr-4">Name</th>
<th class="pb-1 pr-4">Table</th>
<th class="pb-1 pr-4 text-right">Size</th>
<th class="pb-1 pr-4 text-right">Est. Rows</th>
</tr>
</thead>
<tbody>
{#each data.datatables as dt}
<tr class="border-surface-secondary border-b last:border-0">
<td class="text-secondary py-1 pr-4">{dt.workspace_id}</td>
<td class="text-primary py-1 pr-4">{dt.name}</td>
<td class="text-secondary py-1 pr-4 font-mono">{dt.table_name}</td>
<td class="text-secondary py-1 pr-4 text-right">{dt.size_pretty}</td>
<td class="text-secondary py-1 pr-4 text-right"
>{formatNumber(Math.round(dt.estimated_rows))}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
</section>
{:else if !loading}
<p class="text-tertiary text-sm">
Click "Run Diagnostics" to analyze your database health. The queries are read-only and
lightweight.
</p>
{/if}
</div>