mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 16:05:58 +00:00
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:
co-authored by
Claude Opus 4.6
parent
02d0ee9198
commit
01e39d9cd1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user