@indiekitai/pg-dash 0.3.9 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/README.zh-CN.md +9 -4
- package/dist/mcp.js +490 -0
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# pg-dash
|
|
4
4
|
|
|
5
|
-
**The AI-native PostgreSQL health checker.** One command to audit your database,
|
|
5
|
+
**The AI-native PostgreSQL health checker.** One command to audit your database, 23 MCP tools for AI-assisted optimization, CI integration for automated checks.
|
|
6
6
|
|
|
7
7
|
Not another monitoring dashboard — pg-dash is built to fit into your **AI coding workflow**:
|
|
8
8
|
|
|
@@ -47,7 +47,7 @@ The Dashboard is there when you need it. But the real power is in the CLI, MCP,
|
|
|
47
47
|
| pganalyze | $149+/mo | SaaS signup | ❌ | ❌ |
|
|
48
48
|
| Grafana+Prometheus | Free | 3 services | ❌ | ❌ |
|
|
49
49
|
| pgAdmin | Free | Complex UI | ❌ | ❌ |
|
|
50
|
-
| **pg-dash** | **Free** | **One command** | **
|
|
50
|
+
| **pg-dash** | **Free** | **One command** | **23 MCP tools** | **`--ci --diff`** |
|
|
51
51
|
|
|
52
52
|
## Features
|
|
53
53
|
|
|
@@ -118,7 +118,7 @@ The Dashboard is there when you need it. But the real power is in the CLI, MCP,
|
|
|
118
118
|
- `pg_dash_compare_env` MCP tool: ask your AI "what's different between local and staging?"
|
|
119
119
|
|
|
120
120
|
### 🤖 MCP Server
|
|
121
|
-
-
|
|
121
|
+
- 23 tools for AI agent integration
|
|
122
122
|
- `pg-dash-mcp postgres://...` — works with Claude, Cursor, etc.
|
|
123
123
|
|
|
124
124
|
### 🖥️ CLI
|
|
@@ -202,7 +202,7 @@ pg-dash-mcp postgres://user:pass@host/db
|
|
|
202
202
|
PG_DASH_CONNECTION_STRING=postgres://... pg-dash-mcp
|
|
203
203
|
```
|
|
204
204
|
|
|
205
|
-
### Available Tools (
|
|
205
|
+
### Available Tools (23)
|
|
206
206
|
|
|
207
207
|
| Tool | Description |
|
|
208
208
|
|------|-------------|
|
|
@@ -224,6 +224,11 @@ PG_DASH_CONNECTION_STRING=postgres://... pg-dash-mcp
|
|
|
224
224
|
| `pg_dash_analyze_query` | Deep EXPLAIN analysis with automatic index suggestions |
|
|
225
225
|
| `pg_dash_query_regressions` | Detect queries that degraded >50% vs historical baseline |
|
|
226
226
|
| `pg_dash_compare_env` | Compare schema and health between two database environments |
|
|
227
|
+
| `pg_dash_unused_indexes` | Find unused indexes that waste space and slow down writes |
|
|
228
|
+
| `pg_dash_bloat` | Detect table bloat (dead tuples) that slow down queries |
|
|
229
|
+
| `pg_dash_autovacuum` | Check autovacuum health — which tables are stale or never vacuumed |
|
|
230
|
+
| `pg_dash_locks` | Show active lock waits and long-running blocking queries |
|
|
231
|
+
| `pg_dash_config_check` | Audit PostgreSQL configuration and get tuning recommendations |
|
|
227
232
|
|
|
228
233
|
## MCP Setup
|
|
229
234
|
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# pg-dash
|
|
4
4
|
|
|
5
|
-
**AI 原生的 PostgreSQL 健康检查工具。** 一条命令审计数据库,
|
|
5
|
+
**AI 原生的 PostgreSQL 健康检查工具。** 一条命令审计数据库,23 个 MCP 工具让 AI 帮你优化,CI 集成自动检查。
|
|
6
6
|
|
|
7
7
|
不是又一个监控面板 —— pg-dash 是为 **AI 编程工作流** 设计的:
|
|
8
8
|
|
|
@@ -47,7 +47,7 @@ Dashboard 需要时可以用。但真正的核心能力在 CLI、MCP 和 CI。
|
|
|
47
47
|
| pganalyze | $149+/月 | SaaS 注册 | ❌ | ❌ |
|
|
48
48
|
| Grafana+Prometheus | 免费 | 配置 3 个服务 | ❌ | ❌ |
|
|
49
49
|
| pgAdmin | 免费 | 界面复杂 | ❌ | ❌ |
|
|
50
|
-
| **pg-dash** | **免费** | **一条命令** | **
|
|
50
|
+
| **pg-dash** | **免费** | **一条命令** | **23 个 MCP 工具** | **`--ci --diff`** |
|
|
51
51
|
|
|
52
52
|
## 功能
|
|
53
53
|
|
|
@@ -118,7 +118,7 @@ Dashboard 需要时可以用。但真正的核心能力在 CLI、MCP 和 CI。
|
|
|
118
118
|
- `pg_dash_compare_env` MCP 工具:直接问 AI "本地和预发有什么差异?"
|
|
119
119
|
|
|
120
120
|
### 🤖 MCP Server
|
|
121
|
-
-
|
|
121
|
+
- 23 个工具,支持 AI Agent 集成
|
|
122
122
|
- `pg-dash-mcp postgres://...` —— 可配合 Claude、Cursor 等使用
|
|
123
123
|
|
|
124
124
|
### 🖥️ CLI
|
|
@@ -202,7 +202,7 @@ pg-dash-mcp postgres://user:pass@host/db
|
|
|
202
202
|
PG_DASH_CONNECTION_STRING=postgres://... pg-dash-mcp
|
|
203
203
|
```
|
|
204
204
|
|
|
205
|
-
### 可用工具(
|
|
205
|
+
### 可用工具(23 个)
|
|
206
206
|
|
|
207
207
|
| 工具 | 描述 |
|
|
208
208
|
|------|------|
|
|
@@ -224,6 +224,11 @@ PG_DASH_CONNECTION_STRING=postgres://... pg-dash-mcp
|
|
|
224
224
|
| `pg_dash_analyze_query` | 深度 EXPLAIN 分析,自动生成索引建议 |
|
|
225
225
|
| `pg_dash_query_regressions` | 检测比历史基线慢超过 50% 的查询 |
|
|
226
226
|
| `pg_dash_compare_env` | 对比两个数据库环境的 Schema 和健康状态 |
|
|
227
|
+
| `pg_dash_unused_indexes` | 发现从未被使用的索引(浪费空间、拖慢写入) |
|
|
228
|
+
| `pg_dash_bloat` | 检测表膨胀(dead tuples 过多) |
|
|
229
|
+
| `pg_dash_autovacuum` | Autovacuum 健康状态——哪些表长期未 vacuum |
|
|
230
|
+
| `pg_dash_locks` | 显示活跃锁等待链和长时间阻塞的查询 |
|
|
231
|
+
| `pg_dash_config_check` | 审计 PostgreSQL 配置,给出调优建议 |
|
|
227
232
|
|
|
228
233
|
## MCP 配置
|
|
229
234
|
|
package/dist/mcp.js
CHANGED
|
@@ -1902,6 +1902,456 @@ async function analyzeMigration(sql, pool2) {
|
|
|
1902
1902
|
};
|
|
1903
1903
|
}
|
|
1904
1904
|
|
|
1905
|
+
// src/server/unused-indexes.ts
|
|
1906
|
+
function formatBytes(bytes) {
|
|
1907
|
+
if (bytes < 1024) return "< 1 KB";
|
|
1908
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
1909
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1910
|
+
if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
|
1911
|
+
return `${(bytes / 1024 ** 4).toFixed(1)} TB`;
|
|
1912
|
+
}
|
|
1913
|
+
async function getUnusedIndexes(pool2) {
|
|
1914
|
+
const [indexResult, bgwriterResult] = await Promise.all([
|
|
1915
|
+
pool2.query(`
|
|
1916
|
+
SELECT
|
|
1917
|
+
s.schemaname,
|
|
1918
|
+
s.relname AS table_name,
|
|
1919
|
+
s.indexrelname AS index_name,
|
|
1920
|
+
pg_relation_size(s.indexrelid) AS index_size_bytes,
|
|
1921
|
+
s.idx_scan,
|
|
1922
|
+
i.indexdef
|
|
1923
|
+
FROM pg_stat_user_indexes s
|
|
1924
|
+
JOIN pg_indexes i ON s.schemaname = i.schemaname
|
|
1925
|
+
AND s.relname = i.tablename
|
|
1926
|
+
AND s.indexrelname = i.indexname
|
|
1927
|
+
WHERE s.schemaname = 'public'
|
|
1928
|
+
AND s.idx_scan = 0
|
|
1929
|
+
AND i.indexdef NOT LIKE '%UNIQUE%'
|
|
1930
|
+
AND s.indexrelname NOT LIKE '%_pkey'
|
|
1931
|
+
ORDER BY pg_relation_size(s.indexrelid) DESC
|
|
1932
|
+
`),
|
|
1933
|
+
pool2.query(`SELECT stats_reset FROM pg_stat_bgwriter`)
|
|
1934
|
+
]);
|
|
1935
|
+
const statsReset = bgwriterResult.rows[0]?.stats_reset ? new Date(bgwriterResult.rows[0].stats_reset).toISOString() : null;
|
|
1936
|
+
const indexes = indexResult.rows.map((row) => {
|
|
1937
|
+
const sizeBytes = parseInt(row.index_size_bytes, 10) || 0;
|
|
1938
|
+
const index = row.index_name;
|
|
1939
|
+
const table = row.table_name;
|
|
1940
|
+
return {
|
|
1941
|
+
schema: row.schemaname,
|
|
1942
|
+
table,
|
|
1943
|
+
index,
|
|
1944
|
+
indexSize: formatBytes(sizeBytes),
|
|
1945
|
+
indexSizeBytes: sizeBytes,
|
|
1946
|
+
scans: parseInt(row.idx_scan, 10) || 0,
|
|
1947
|
+
lastUsed: statsReset,
|
|
1948
|
+
suggestion: `Index ${index} on ${table} has never been used (0 scans). Consider dropping it: DROP INDEX CONCURRENTLY "${index.replace(/"/g, '""')}"`
|
|
1949
|
+
};
|
|
1950
|
+
});
|
|
1951
|
+
const totalWastedBytes = indexes.reduce((sum, idx) => sum + idx.indexSizeBytes, 0);
|
|
1952
|
+
return {
|
|
1953
|
+
indexes,
|
|
1954
|
+
totalWastedBytes,
|
|
1955
|
+
totalWasted: formatBytes(totalWastedBytes),
|
|
1956
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
// src/server/bloat.ts
|
|
1961
|
+
function getSuggestion(table, bloatPercent) {
|
|
1962
|
+
if (bloatPercent >= 50) {
|
|
1963
|
+
return `HIGH bloat on ${table} (${bloatPercent}% dead rows). Run: VACUUM ANALYZE ${table}`;
|
|
1964
|
+
} else if (bloatPercent >= 20) {
|
|
1965
|
+
return `Moderate bloat on ${table} (${bloatPercent}% dead rows). Consider VACUUM ANALYZE ${table}`;
|
|
1966
|
+
} else {
|
|
1967
|
+
return `Minor bloat on ${table} (${bloatPercent}% dead rows). Autovacuum should handle this.`;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
async function getBloatReport(pool2) {
|
|
1971
|
+
const result = await pool2.query(`
|
|
1972
|
+
SELECT
|
|
1973
|
+
schemaname,
|
|
1974
|
+
relname AS table_name,
|
|
1975
|
+
n_live_tup,
|
|
1976
|
+
n_dead_tup,
|
|
1977
|
+
last_autovacuum,
|
|
1978
|
+
last_vacuum
|
|
1979
|
+
FROM pg_stat_user_tables
|
|
1980
|
+
WHERE schemaname = 'public'
|
|
1981
|
+
AND (n_live_tup + n_dead_tup) > 0
|
|
1982
|
+
ORDER BY (n_dead_tup::float / (n_live_tup + n_dead_tup)) DESC
|
|
1983
|
+
`);
|
|
1984
|
+
const tables = [];
|
|
1985
|
+
for (const row of result.rows) {
|
|
1986
|
+
const live = parseInt(row.n_live_tup, 10) || 0;
|
|
1987
|
+
const dead = parseInt(row.n_dead_tup, 10) || 0;
|
|
1988
|
+
const total = live + dead;
|
|
1989
|
+
if (total === 0) continue;
|
|
1990
|
+
const bloatPercent = Math.round(dead / total * 1e3) / 10;
|
|
1991
|
+
if (bloatPercent < 10) continue;
|
|
1992
|
+
const table = row.table_name;
|
|
1993
|
+
tables.push({
|
|
1994
|
+
schema: row.schemaname,
|
|
1995
|
+
table,
|
|
1996
|
+
liveRows: live,
|
|
1997
|
+
deadRows: dead,
|
|
1998
|
+
bloatPercent,
|
|
1999
|
+
lastAutoVacuum: row.last_autovacuum ? new Date(row.last_autovacuum).toISOString() : null,
|
|
2000
|
+
lastVacuum: row.last_vacuum ? new Date(row.last_vacuum).toISOString() : null,
|
|
2001
|
+
suggestion: getSuggestion(table, bloatPercent)
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
tables.sort((a, b) => b.bloatPercent - a.bloatPercent);
|
|
2005
|
+
return {
|
|
2006
|
+
tables,
|
|
2007
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// src/server/autovacuum.ts
|
|
2012
|
+
function classifyStatus(lastAutoVacuum, deadTuples, vacuumCount) {
|
|
2013
|
+
if (lastAutoVacuum === null) return "never";
|
|
2014
|
+
const daysSince = (Date.now() - lastAutoVacuum.getTime()) / (1e3 * 60 * 60 * 24);
|
|
2015
|
+
if (daysSince > 7 && deadTuples > 1e4) return "overdue";
|
|
2016
|
+
if (daysSince > 3) return "stale";
|
|
2017
|
+
return "ok";
|
|
2018
|
+
}
|
|
2019
|
+
function getSuggestion2(status, table) {
|
|
2020
|
+
switch (status) {
|
|
2021
|
+
case "never":
|
|
2022
|
+
return `Table ${table} has never been autovacuumed. Check if autovacuum is enabled and the table has enough churn.`;
|
|
2023
|
+
case "overdue":
|
|
2024
|
+
return `Table ${table} is overdue for vacuum and has many dead tuples. Run: VACUUM ANALYZE ${table}`;
|
|
2025
|
+
case "stale":
|
|
2026
|
+
return `Table ${table} hasn't been vacuumed in over 3 days. Monitor for bloat.`;
|
|
2027
|
+
case "ok":
|
|
2028
|
+
return null;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
async function getAutovacuumReport(pool2) {
|
|
2032
|
+
const [tableResult, settingsResult] = await Promise.all([
|
|
2033
|
+
pool2.query(`
|
|
2034
|
+
SELECT
|
|
2035
|
+
schemaname, relname,
|
|
2036
|
+
last_autovacuum, last_autoanalyze,
|
|
2037
|
+
n_dead_tup, n_live_tup,
|
|
2038
|
+
autovacuum_count, autoanalyze_count
|
|
2039
|
+
FROM pg_stat_user_tables
|
|
2040
|
+
WHERE schemaname = 'public'
|
|
2041
|
+
ORDER BY n_dead_tup DESC
|
|
2042
|
+
`),
|
|
2043
|
+
pool2.query(`
|
|
2044
|
+
SELECT name, setting
|
|
2045
|
+
FROM pg_settings
|
|
2046
|
+
WHERE name IN ('autovacuum', 'autovacuum_vacuum_cost_delay', 'autovacuum_max_workers', 'autovacuum_naptime')
|
|
2047
|
+
`)
|
|
2048
|
+
]);
|
|
2049
|
+
const tables = tableResult.rows.map((row) => {
|
|
2050
|
+
const lastAutoVacuumDate = row.last_autovacuum ? new Date(row.last_autovacuum) : null;
|
|
2051
|
+
const deadTuples = parseInt(row.n_dead_tup, 10) || 0;
|
|
2052
|
+
const liveTuples = parseInt(row.n_live_tup, 10) || 0;
|
|
2053
|
+
const vacuumCount = parseInt(row.autovacuum_count, 10) || 0;
|
|
2054
|
+
const analyzeCount = parseInt(row.autoanalyze_count, 10) || 0;
|
|
2055
|
+
const status = classifyStatus(lastAutoVacuumDate, deadTuples, vacuumCount);
|
|
2056
|
+
const table = row.relname;
|
|
2057
|
+
return {
|
|
2058
|
+
schema: row.schemaname,
|
|
2059
|
+
table,
|
|
2060
|
+
lastAutoVacuum: lastAutoVacuumDate ? lastAutoVacuumDate.toISOString() : null,
|
|
2061
|
+
lastAutoAnalyze: row.last_autoanalyze ? new Date(row.last_autoanalyze).toISOString() : null,
|
|
2062
|
+
deadTuples,
|
|
2063
|
+
liveTuples,
|
|
2064
|
+
vacuumCount,
|
|
2065
|
+
analyzeCount,
|
|
2066
|
+
status,
|
|
2067
|
+
suggestion: getSuggestion2(status, table)
|
|
2068
|
+
};
|
|
2069
|
+
});
|
|
2070
|
+
const settingsMap = /* @__PURE__ */ new Map();
|
|
2071
|
+
for (const row of settingsResult.rows) {
|
|
2072
|
+
settingsMap.set(row.name, row.setting);
|
|
2073
|
+
}
|
|
2074
|
+
return {
|
|
2075
|
+
tables,
|
|
2076
|
+
settings: {
|
|
2077
|
+
autovacuumEnabled: settingsMap.get("autovacuum") !== "off",
|
|
2078
|
+
vacuumCostDelay: `${settingsMap.get("autovacuum_vacuum_cost_delay") ?? "2"}ms`,
|
|
2079
|
+
autovacuumMaxWorkers: parseInt(settingsMap.get("autovacuum_max_workers") ?? "3", 10),
|
|
2080
|
+
autovacuumNaptime: `${settingsMap.get("autovacuum_naptime") ?? "60"}s`
|
|
2081
|
+
},
|
|
2082
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// src/server/locks.ts
|
|
2087
|
+
function formatDurationSecs(secs) {
|
|
2088
|
+
const h = Math.floor(secs / 3600);
|
|
2089
|
+
const m = Math.floor(secs % 3600 / 60);
|
|
2090
|
+
const s = secs % 60;
|
|
2091
|
+
return [
|
|
2092
|
+
String(h).padStart(2, "0"),
|
|
2093
|
+
String(m).padStart(2, "0"),
|
|
2094
|
+
String(s).padStart(2, "0")
|
|
2095
|
+
].join(":");
|
|
2096
|
+
}
|
|
2097
|
+
async function getLockReport(pool2) {
|
|
2098
|
+
const [locksResult, longResult] = await Promise.all([
|
|
2099
|
+
pool2.query(`
|
|
2100
|
+
SELECT
|
|
2101
|
+
blocked.pid AS blocked_pid,
|
|
2102
|
+
blocked.query AS blocked_query,
|
|
2103
|
+
EXTRACT(EPOCH FROM (NOW() - blocked.query_start))::int AS blocked_secs,
|
|
2104
|
+
blocking.pid AS blocking_pid,
|
|
2105
|
+
blocking.query AS blocking_query,
|
|
2106
|
+
EXTRACT(EPOCH FROM (NOW() - blocking.query_start))::int AS blocking_secs,
|
|
2107
|
+
blocked_locks.relation::regclass::text AS table_name,
|
|
2108
|
+
blocked_locks.locktype
|
|
2109
|
+
FROM pg_catalog.pg_locks blocked_locks
|
|
2110
|
+
JOIN pg_catalog.pg_stat_activity blocked ON blocked.pid = blocked_locks.pid
|
|
2111
|
+
JOIN pg_catalog.pg_locks blocking_locks
|
|
2112
|
+
ON blocking_locks.locktype = blocked_locks.locktype
|
|
2113
|
+
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
|
|
2114
|
+
AND blocking_locks.pid != blocked_locks.pid
|
|
2115
|
+
AND blocking_locks.granted = true
|
|
2116
|
+
JOIN pg_catalog.pg_stat_activity blocking ON blocking.pid = blocking_locks.pid
|
|
2117
|
+
WHERE NOT blocked_locks.granted
|
|
2118
|
+
`),
|
|
2119
|
+
pool2.query(`
|
|
2120
|
+
SELECT
|
|
2121
|
+
pid,
|
|
2122
|
+
EXTRACT(EPOCH FROM (NOW() - query_start))::int AS duration_secs,
|
|
2123
|
+
query,
|
|
2124
|
+
state,
|
|
2125
|
+
wait_event_type
|
|
2126
|
+
FROM pg_stat_activity
|
|
2127
|
+
WHERE state != 'idle'
|
|
2128
|
+
AND query_start IS NOT NULL
|
|
2129
|
+
AND EXTRACT(EPOCH FROM (NOW() - query_start)) > 5
|
|
2130
|
+
AND query NOT LIKE '%pg_stat_activity%'
|
|
2131
|
+
ORDER BY duration_secs DESC
|
|
2132
|
+
LIMIT 20
|
|
2133
|
+
`)
|
|
2134
|
+
]);
|
|
2135
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2136
|
+
const waitingLocks = [];
|
|
2137
|
+
for (const row of locksResult.rows) {
|
|
2138
|
+
const key = `${row.blocked_pid}:${row.blocking_pid}`;
|
|
2139
|
+
if (!seen.has(key)) {
|
|
2140
|
+
seen.add(key);
|
|
2141
|
+
waitingLocks.push({
|
|
2142
|
+
blockedPid: parseInt(row.blocked_pid, 10),
|
|
2143
|
+
blockedQuery: row.blocked_query,
|
|
2144
|
+
blockedDuration: formatDurationSecs(parseInt(row.blocked_secs, 10) || 0),
|
|
2145
|
+
blockingPid: parseInt(row.blocking_pid, 10),
|
|
2146
|
+
blockingQuery: row.blocking_query,
|
|
2147
|
+
blockingDuration: formatDurationSecs(parseInt(row.blocking_secs, 10) || 0),
|
|
2148
|
+
table: row.table_name ?? null,
|
|
2149
|
+
lockType: row.locktype
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
const longRunningQueries = longResult.rows.map((row) => ({
|
|
2154
|
+
pid: parseInt(row.pid, 10),
|
|
2155
|
+
duration: formatDurationSecs(parseInt(row.duration_secs, 10) || 0),
|
|
2156
|
+
query: row.query,
|
|
2157
|
+
state: row.state,
|
|
2158
|
+
waitEventType: row.wait_event_type ?? null
|
|
2159
|
+
}));
|
|
2160
|
+
return {
|
|
2161
|
+
waitingLocks,
|
|
2162
|
+
longRunningQueries,
|
|
2163
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
// src/server/config-checker.ts
|
|
2168
|
+
function settingToBytes(value, unit) {
|
|
2169
|
+
const v = parseFloat(value);
|
|
2170
|
+
if (!unit) return v;
|
|
2171
|
+
switch (unit.toLowerCase()) {
|
|
2172
|
+
case "b":
|
|
2173
|
+
return v;
|
|
2174
|
+
case "kb":
|
|
2175
|
+
return v * 1024;
|
|
2176
|
+
case "8kb":
|
|
2177
|
+
return v * 8 * 1024;
|
|
2178
|
+
// shared_buffers, effective_cache_size
|
|
2179
|
+
case "mb":
|
|
2180
|
+
return v * 1024 * 1024;
|
|
2181
|
+
case "gb":
|
|
2182
|
+
return v * 1024 * 1024 * 1024;
|
|
2183
|
+
default:
|
|
2184
|
+
return v;
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
function settingToMb(value, unit) {
|
|
2188
|
+
return settingToBytes(value, unit) / (1024 * 1024);
|
|
2189
|
+
}
|
|
2190
|
+
function formatMemSetting(rawValue, unit) {
|
|
2191
|
+
if (!rawValue) return "unknown";
|
|
2192
|
+
const bytes = settingToBytes(rawValue, unit ?? "");
|
|
2193
|
+
if (bytes <= 0 || isNaN(bytes)) return rawValue;
|
|
2194
|
+
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)}GB`;
|
|
2195
|
+
if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)}MB`;
|
|
2196
|
+
if (bytes >= 1024) return `${Math.round(bytes / 1024)}KB`;
|
|
2197
|
+
return `${bytes}B`;
|
|
2198
|
+
}
|
|
2199
|
+
async function getConfigReport(pool2) {
|
|
2200
|
+
const result = await pool2.query(`
|
|
2201
|
+
SELECT name, setting, unit
|
|
2202
|
+
FROM pg_settings
|
|
2203
|
+
WHERE name IN (
|
|
2204
|
+
'max_connections', 'shared_buffers', 'work_mem',
|
|
2205
|
+
'effective_cache_size', 'maintenance_work_mem', 'wal_buffers',
|
|
2206
|
+
'checkpoint_completion_target', 'random_page_cost',
|
|
2207
|
+
'autovacuum_vacuum_scale_factor', 'autovacuum_analyze_scale_factor',
|
|
2208
|
+
'log_min_duration_statement', 'idle_in_transaction_session_timeout',
|
|
2209
|
+
'effective_io_concurrency'
|
|
2210
|
+
)
|
|
2211
|
+
`);
|
|
2212
|
+
const settings = {};
|
|
2213
|
+
for (const row of result.rows) {
|
|
2214
|
+
settings[row.name] = { setting: row.setting, unit: row.unit ?? void 0 };
|
|
2215
|
+
}
|
|
2216
|
+
const recommendations = [];
|
|
2217
|
+
const get = (name) => settings[name]?.setting ?? null;
|
|
2218
|
+
const getUnit = (name) => settings[name]?.unit;
|
|
2219
|
+
const sharedBuffersSetting = get("shared_buffers");
|
|
2220
|
+
if (sharedBuffersSetting !== null) {
|
|
2221
|
+
const mb = settingToMb(sharedBuffersSetting, getUnit("shared_buffers"));
|
|
2222
|
+
if (mb < 128) {
|
|
2223
|
+
recommendations.push({
|
|
2224
|
+
setting: "shared_buffers",
|
|
2225
|
+
currentValue: `${Math.round(mb)}MB`,
|
|
2226
|
+
recommendedValue: "256MB",
|
|
2227
|
+
reason: "shared_buffers should be at least 25% of RAM; typical starting point is 256MB\u20131GB",
|
|
2228
|
+
severity: "warning",
|
|
2229
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS"
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
const workMemSetting = get("work_mem");
|
|
2234
|
+
if (workMemSetting !== null) {
|
|
2235
|
+
const mb = settingToMb(workMemSetting, getUnit("work_mem"));
|
|
2236
|
+
if (mb <= 4) {
|
|
2237
|
+
recommendations.push({
|
|
2238
|
+
setting: "work_mem",
|
|
2239
|
+
currentValue: "4MB",
|
|
2240
|
+
recommendedValue: "16MB",
|
|
2241
|
+
reason: "work_mem of 4MB is conservative; consider 16MB\u201364MB for analytical queries (but multiply by max_connections for total)",
|
|
2242
|
+
severity: "info",
|
|
2243
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM"
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
const cctSetting = get("checkpoint_completion_target");
|
|
2248
|
+
if (cctSetting !== null) {
|
|
2249
|
+
const v = parseFloat(cctSetting);
|
|
2250
|
+
if (v < 0.9) {
|
|
2251
|
+
recommendations.push({
|
|
2252
|
+
setting: "checkpoint_completion_target",
|
|
2253
|
+
currentValue: cctSetting,
|
|
2254
|
+
recommendedValue: "0.9",
|
|
2255
|
+
reason: "Set to 0.9 to spread checkpoint I/O over 90% of checkpoint interval",
|
|
2256
|
+
severity: "warning",
|
|
2257
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-CHECKPOINT-COMPLETION-TARGET"
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
const rpcSetting = get("random_page_cost");
|
|
2262
|
+
if (rpcSetting !== null) {
|
|
2263
|
+
const v = parseFloat(rpcSetting);
|
|
2264
|
+
if (v > 2) {
|
|
2265
|
+
recommendations.push({
|
|
2266
|
+
setting: "random_page_cost",
|
|
2267
|
+
currentValue: rpcSetting,
|
|
2268
|
+
recommendedValue: "1.1",
|
|
2269
|
+
reason: "If using SSDs, set random_page_cost=1.1 (default 4.0 is tuned for spinning disks)",
|
|
2270
|
+
severity: "info",
|
|
2271
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-RANDOM-PAGE-COST"
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
const avsfSetting = get("autovacuum_vacuum_scale_factor");
|
|
2276
|
+
if (avsfSetting !== null) {
|
|
2277
|
+
const v = parseFloat(avsfSetting);
|
|
2278
|
+
if (v >= 0.2) {
|
|
2279
|
+
recommendations.push({
|
|
2280
|
+
setting: "autovacuum_vacuum_scale_factor",
|
|
2281
|
+
currentValue: avsfSetting,
|
|
2282
|
+
recommendedValue: "0.05",
|
|
2283
|
+
reason: "Consider lowering to 0.05\u20130.1 for large tables to vacuum more frequently",
|
|
2284
|
+
severity: "info",
|
|
2285
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-autovacuum.html#GUC-AUTOVACUUM-VACUUM-SCALE-FACTOR"
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
const lmdsSetting = get("log_min_duration_statement");
|
|
2290
|
+
if (lmdsSetting !== null && parseInt(lmdsSetting, 10) === -1) {
|
|
2291
|
+
recommendations.push({
|
|
2292
|
+
setting: "log_min_duration_statement",
|
|
2293
|
+
currentValue: "-1",
|
|
2294
|
+
recommendedValue: "1000",
|
|
2295
|
+
reason: "Consider setting to 1000 (log queries > 1s) for performance monitoring",
|
|
2296
|
+
severity: "info",
|
|
2297
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-MIN-DURATION-STATEMENT"
|
|
2298
|
+
});
|
|
2299
|
+
}
|
|
2300
|
+
const iitsSetting = get("idle_in_transaction_session_timeout");
|
|
2301
|
+
if (iitsSetting !== null && parseInt(iitsSetting, 10) === 0) {
|
|
2302
|
+
recommendations.push({
|
|
2303
|
+
setting: "idle_in_transaction_session_timeout",
|
|
2304
|
+
currentValue: "0",
|
|
2305
|
+
recommendedValue: "60000",
|
|
2306
|
+
reason: "Set idle_in_transaction_session_timeout=60000 (60s) to prevent stuck transactions from holding locks",
|
|
2307
|
+
severity: "warning",
|
|
2308
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT"
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
const eicSetting = get("effective_io_concurrency");
|
|
2312
|
+
if (eicSetting !== null && parseInt(eicSetting, 10) === 1) {
|
|
2313
|
+
recommendations.push({
|
|
2314
|
+
setting: "effective_io_concurrency",
|
|
2315
|
+
currentValue: "1",
|
|
2316
|
+
recommendedValue: "200",
|
|
2317
|
+
reason: "If using SSDs, set effective_io_concurrency=200 for better parallel I/O",
|
|
2318
|
+
severity: "info",
|
|
2319
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-EFFECTIVE-IO-CONCURRENCY"
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
const mwmSetting = get("maintenance_work_mem");
|
|
2323
|
+
if (mwmSetting !== null) {
|
|
2324
|
+
const mb = settingToMb(mwmSetting, getUnit("maintenance_work_mem"));
|
|
2325
|
+
if (mb <= 64) {
|
|
2326
|
+
recommendations.push({
|
|
2327
|
+
setting: "maintenance_work_mem",
|
|
2328
|
+
currentValue: "64MB",
|
|
2329
|
+
recommendedValue: "256MB",
|
|
2330
|
+
reason: "Consider 256MB for faster VACUUM and index builds",
|
|
2331
|
+
severity: "info",
|
|
2332
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM"
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
const maxConnSetting = get("max_connections");
|
|
2337
|
+
const serverInfo = {
|
|
2338
|
+
maxConnections: maxConnSetting !== null ? parseInt(maxConnSetting, 10) : 0,
|
|
2339
|
+
sharedBuffers: formatMemSetting(sharedBuffersSetting, getUnit("shared_buffers")),
|
|
2340
|
+
workMem: formatMemSetting(workMemSetting, getUnit("work_mem")),
|
|
2341
|
+
effectiveCacheSize: formatMemSetting(get("effective_cache_size"), getUnit("effective_cache_size")),
|
|
2342
|
+
maintenanceWorkMem: formatMemSetting(mwmSetting, getUnit("maintenance_work_mem")),
|
|
2343
|
+
walBuffers: get("wal_buffers") ?? "",
|
|
2344
|
+
checkpointCompletionTarget: cctSetting ?? "",
|
|
2345
|
+
randomPageCost: rpcSetting ?? "",
|
|
2346
|
+
autovacuumVacuumScaleFactor: avsfSetting ?? ""
|
|
2347
|
+
};
|
|
2348
|
+
return {
|
|
2349
|
+
recommendations,
|
|
2350
|
+
serverInfo,
|
|
2351
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
|
|
1905
2355
|
// src/mcp.ts
|
|
1906
2356
|
import Database2 from "better-sqlite3";
|
|
1907
2357
|
import path3 from "path";
|
|
@@ -2253,6 +2703,46 @@ server.tool(
|
|
|
2253
2703
|
}
|
|
2254
2704
|
}
|
|
2255
2705
|
);
|
|
2706
|
+
server.tool("pg_dash_unused_indexes", "Find unused indexes that waste space and slow down writes", {}, async () => {
|
|
2707
|
+
try {
|
|
2708
|
+
const report = await getUnusedIndexes(pool);
|
|
2709
|
+
return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
|
|
2710
|
+
} catch (err) {
|
|
2711
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
2712
|
+
}
|
|
2713
|
+
});
|
|
2714
|
+
server.tool("pg_dash_bloat", "Detect table bloat (dead tuples) that slow down queries", {}, async () => {
|
|
2715
|
+
try {
|
|
2716
|
+
const report = await getBloatReport(pool);
|
|
2717
|
+
return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
|
|
2718
|
+
} catch (err) {
|
|
2719
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
2720
|
+
}
|
|
2721
|
+
});
|
|
2722
|
+
server.tool("pg_dash_autovacuum", "Check autovacuum health \u2014 which tables are stale or never vacuumed", {}, async () => {
|
|
2723
|
+
try {
|
|
2724
|
+
const report = await getAutovacuumReport(pool);
|
|
2725
|
+
return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
|
|
2726
|
+
} catch (err) {
|
|
2727
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
2728
|
+
}
|
|
2729
|
+
});
|
|
2730
|
+
server.tool("pg_dash_locks", "Show active lock waits and long-running queries blocking the database", {}, async () => {
|
|
2731
|
+
try {
|
|
2732
|
+
const report = await getLockReport(pool);
|
|
2733
|
+
return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
|
|
2734
|
+
} catch (err) {
|
|
2735
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
2736
|
+
}
|
|
2737
|
+
});
|
|
2738
|
+
server.tool("pg_dash_config_check", "Audit PostgreSQL configuration settings and get tuning recommendations", {}, async () => {
|
|
2739
|
+
try {
|
|
2740
|
+
const report = await getConfigReport(pool);
|
|
2741
|
+
return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
|
|
2742
|
+
} catch (err) {
|
|
2743
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
2744
|
+
}
|
|
2745
|
+
});
|
|
2256
2746
|
var transport = new StdioServerTransport();
|
|
2257
2747
|
await server.connect(transport);
|
|
2258
2748
|
//# sourceMappingURL=mcp.js.map
|