feat(postgres): add server dashboard metrics

This commit is contained in:
Bagus Wahyu Aprianto authored and GitHub committed 2026-07-16 17:01:10 +08:00
1 parent 3d23a58e36
commit fc1f02a931
13 files changed
+1064 -126

No files matched your search

@@ -0,0 +1,153 @@
import type { ConnectionConfig, DatabaseType, QueryResult } from "@/types/database";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { computeRate, formatBytes, formatBytesPerSec, formatNumber, formatRate, formatUptime, statusEntries, statusNumber, type StatusEntry, type StatusMap, type StatusSample } from "@/lib/database/serverMetrics";
/**
* PostgreSQL server-monitoring helpers. Pure and framework-free so the rate math
* and formatting can be unit-tested; the dashboard component owns the polling
* loop and ring buffer, and feeds samples through these functions.
*
* The MySQL family lives in `./mysqlServerStatus`; the two engines' status
* shapes differ (cumulative name/value pairs vs. a single aggregate row), so
* the SQL/mapping/gates stay separate, but the sample/rate math and formatting
* are identical and shared from `./serverMetrics` — re-exported here so
* existing callers keep one import path.
*
* Data comes from one aggregate query over `pg_stat_database` / `pg_stat_activity`
* / WAL position (`PG_STATUS_SQL`) and a one-shot settings query
* (`PG_VARIABLES_SQL`), both run through the generic query bridge.
*/
export { computeRate, formatBytes, formatBytesPerSec, formatNumber, formatRate, formatUptime, statusEntries, statusNumber, type StatusEntry, type StatusMap, type StatusSample };
/**
* Single round-trip aggregate, mirroring `SHOW GLOBAL STATUS` being one call.
* Deliberately excludes `pg_stat_bgwriter`/checkpoint counters: those columns
* moved to `pg_stat_checkpointer` in PG17, and a version-fragile query would
* break the dashboard on newer servers for a nice-to-have metric.
*
* Scans `pg_stat_database` and `pg_stat_activity` exactly once each (one CTE
* per view) rather than once per column — this runs every ~1-10s while the
* dashboard is open, so re-scanning either view per column would be needless
* repeated load on the server. Uses `SUM(CASE WHEN ...)` rather than the
* `FILTER` clause for the conditional counts: `FILTER` is SQL:2003/PG9.4+,
* and this dashboard is meant to work back to PG 9.2.
*
* The `pg_stat_activity` CTE excludes `pg_backend_pid()` — this query's own
* backend is always `active` while it runs, so without the exclusion an
* otherwise idle server would always show at least one active connection.
* Rows whose `state` is NULL are excluded from the total because PostgreSQL
* masks activity columns for sessions the current role cannot inspect.
*
* The WAL metric is recovery-aware: `pg_current_wal_lsn()` errors on a hot
* standby ("recovery is in progress"), which would fail this entire combined
* query on a read replica. `pg_is_in_recovery()` picks `pg_last_wal_replay_lsn()`
* instead in that case — Postgres only evaluates the taken `CASE` branch, so
* `pg_current_wal_lsn()` is never actually called while in recovery.
*/
export const PG_STATUS_SQL = `WITH db_stats AS (
SELECT
coalesce(sum(xact_commit),0) AS xact_commit,
coalesce(sum(xact_rollback),0) AS xact_rollback,
coalesce(sum(blks_hit),0) AS blks_hit,
coalesce(sum(blks_read),0) AS blks_read,
coalesce(sum(tup_returned),0) AS tup_returned,
coalesce(sum(tup_fetched),0) AS tup_fetched,
coalesce(sum(tup_inserted),0) AS tup_inserted,
coalesce(sum(tup_updated),0) AS tup_updated,
coalesce(sum(tup_deleted),0) AS tup_deleted,
coalesce(sum(deadlocks),0) AS deadlocks,
coalesce(sum(temp_files),0) AS temp_files
FROM pg_stat_database
), activity_stats AS (
SELECT
coalesce(sum(CASE WHEN state IS NOT NULL THEN 1 ELSE 0 END),0) AS connections,
coalesce(sum(CASE WHEN state = 'active' THEN 1 ELSE 0 END),0) AS active_connections,
coalesce(sum(CASE WHEN state = 'idle' THEN 1 ELSE 0 END),0) AS idle_connections
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
)
SELECT
db_stats.*,
activity_stats.*,
coalesce(CASE WHEN pg_is_in_recovery()
THEN pg_wal_lsn_diff(pg_last_wal_replay_lsn(), '0/0')
ELSE pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0')
END, 0) AS wal_bytes,
floor(extract(epoch FROM (now() - pg_postmaster_start_time())))::bigint AS uptime_seconds
FROM db_stats, activity_stats`;
/**
* Pre-10 fallback: PostgreSQL 10 renamed the WAL location functions
* (`pg_current_xlog_location()` → `pg_current_wal_lsn()`,
* `pg_last_xlog_replay_location()` → `pg_last_wal_replay_lsn()`,
* `pg_xlog_location_diff()` → `pg_wal_lsn_diff()`). Every other column here
* (pg_stat_database's transaction, block, tuple, deadlock and temp-file
* counters, pg_stat_activity, pg_is_in_recovery(), pg_postmaster_start_time())
* has been present since 9.2, so this is the only piece that needs a
* version-gated fallback.
*/
export const PG_STATUS_LEGACY_SQL = PG_STATUS_SQL.replace(/\bpg_current_wal_lsn\b/g, "pg_current_xlog_location")
.replace(/\bpg_last_wal_replay_lsn\b/g, "pg_last_xlog_replay_location")
.replace(/\bpg_wal_lsn_diff\b/g, "pg_xlog_location_diff");
export const PG_VARIABLES_SQL = "SELECT current_setting('max_connections') AS max_connections, current_setting('server_version') AS version";
/** Max samples retained for the live charts (~ a few minutes at 5s cadence). */
export const MAX_SAMPLES = 60;
/** Engines exposing `pg_stat_database`/`pg_stat_activity` in the shape this dashboard expects. */
const SERVER_DASHBOARD_DB_TYPES = new Set<DatabaseType>(["postgres"]);
/** Detect the undefined-function failure produced by pre-10 servers lacking `pg_current_wal_lsn`/`pg_last_wal_replay_lsn`/`pg_wal_lsn_diff`. */
export function isPgStatusCompatibilityError(error: unknown): boolean {
const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code ?? "") : "";
// SQLSTATE 42883 (undefined_function) is not specific to the WAL functions —
// any missing function in PG_STATUS_SQL would raise it. Only treat this as
// the pre-PG10 WAL rename by also requiring the message to name one of the
// three renamed functions; otherwise it's a different, unrelated failure and
// retrying with the legacy WAL query wouldn't fix it.
if (code !== "" && code !== "42883") return false;
const message = error instanceof Error ? error.message : String(error);
return /(?:pg_current_wal_lsn|pg_last_wal_replay_lsn|pg_wal_lsn_diff)/i.test(message) && /does not exist/i.test(message);
}
/** Parse the single-row `PG_STATUS_SQL` / `PG_VARIABLES_SQL` result into a name/value map. */
export function parsePgStatusRow(result: QueryResult | null | undefined): StatusMap {
const map: StatusMap = {};
if (!result || !Array.isArray(result.columns) || !Array.isArray(result.rows) || result.rows.length === 0) return map;
const row = result.rows[0];
result.columns.forEach((column, idx) => {
const value = row[idx];
map[column] = value === null || value === undefined ? "" : String(value);
});
return map;
}
/** Transactions/sec between two samples: rate of committed + rolled-back transactions. */
export function computePgTps(prev: StatusSample, curr: StatusSample): number {
return computeRate(prev, curr, "xact_commit") + computeRate(prev, curr, "xact_rollback");
}
/**
* Shared-buffer cache hit ratio (0-100) from cumulative block hits vs reads.
* Returns null when no data has been accumulated yet.
*/
export function pgCacheHitRatio(status: StatusMap): number | null {
const hits = statusNumber(status, "blks_hit");
const reads = statusNumber(status, "blks_read");
const total = hits + reads;
if (total <= 0) return null;
const ratio = (hits / total) * 100;
if (!Number.isFinite(ratio)) return null;
return Math.max(0, Math.min(100, ratio));
}
/** Whether the given database type exposes the Postgres server dashboard. */
export function supportsServerDashboard(dbType: DatabaseType | undefined): boolean {
return !!dbType && SERVER_DASHBOARD_DB_TYPES.has(dbType);
}
/** Connection-aware gate (mirrors the MySQL server-dashboard gate). */
export function connectionSupportsServerDashboard(connection: ConnectionConfig | undefined): boolean {
return !!connection && supportsServerDashboard(effectiveDatabaseTypeForConnection(connection));
}