diff --git a/apps/desktop/src/components/admin/MySqlDashboard.vue b/apps/desktop/src/components/admin/MySqlDashboard.vue index c07eef3a62..8db6b465a6 100644 --- a/apps/desktop/src/components/admin/MySqlDashboard.vue +++ b/apps/desktop/src/components/admin/MySqlDashboard.vue @@ -10,6 +10,7 @@ import MetricCard from "@/components/common/MetricCard.vue"; import MetricLineChart from "@/components/chart/MetricLineChart.vue"; import * as api from "@/lib/backend/api"; import { computeQps, computeRate, formatBytes, formatBytesPerSec, formatNumber, formatRate, formatUptime, GLOBAL_STATUS_SQL, GLOBAL_VARIABLES_SQL, innodbBufferHitRatio, MAX_SAMPLES, parseStatusResult, statusEntries, statusNumber, type StatusSample } from "@/lib/database/mysqlServerStatus"; +import { useVerticalOverlayScrollbar } from "@/composables/useVerticalOverlayScrollbar"; const props = defineProps<{ connectionId: string; @@ -26,6 +27,18 @@ const samples = ref([]); const autoRefreshInterval = ref(5); const statusSearch = ref(""); const showStatusTable = ref(true); +const scrollerRef = ref(null); +const scrollerContentRef = ref(null); +const scrollbarTrackRef = ref(null); +const { + hasOverflow: hasScrollbarOverflow, + isScrolling: isScrollbarScrolling, + isDragging: isScrollbarDragging, + thumbStyle: scrollbarThumbStyle, + onScroll: onScrollerScroll, + onTrackPointerDown: onScrollbarTrackPointerDown, + onThumbPointerDown: onScrollbarThumbPointerDown, +} = useVerticalOverlayScrollbar(scrollerRef, scrollerContentRef, scrollbarTrackRef); let refreshTimer: ReturnType | null = null; const connectionName = computed(() => connectionStore.getConfig(props.connectionId)?.name ?? ""); @@ -193,48 +206,106 @@ onUnmounted(stopAutoRefresh);
{{ error }}
-
-
- - - - - - - - -
- -
- - - - -
- -
- -
-
- - +
+
+
+
+ + + + + + + +
-
- - - - - - - -
{{ row.name }}{{ row.value }}
+ +
+ + + + +
+ +
+ +
+
+ + +
+
+ + + + + + + +
{{ row.name }}{{ row.value }}
+
+
+ +
+
+
+ + diff --git a/apps/desktop/src/components/admin/PostgresDashboard.vue b/apps/desktop/src/components/admin/PostgresDashboard.vue new file mode 100644 index 0000000000..f1c8a56201 --- /dev/null +++ b/apps/desktop/src/components/admin/PostgresDashboard.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue index a68e5ccef6..4eab8c9ce6 100644 --- a/apps/desktop/src/components/layout/AppTabBar.vue +++ b/apps/desktop/src/components/layout/AppTabBar.vue @@ -451,7 +451,7 @@ function tabMenuIcon(tab: QueryTab) { if (tab.mode === "structure") return PencilRuler; if (tab.mode === "dameng-jobs") return CalendarClock; if (tab.mode === "processlist") return Activity; - if (tab.mode === "mysql-dashboard") return Gauge; + if (tab.mode === "mysql-dashboard" || tab.mode === "postgres-dashboard") return Gauge; return Code2; } @@ -587,7 +587,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul - + - + import("@/components/str const DatabaseUserAdmin = defineAsyncComponent(() => import("@/components/admin/DatabaseUserAdmin.vue")); const ProcessListPanel = defineAsyncComponent(() => import("@/components/admin/ProcessListPanel.vue")); const MySqlDashboard = defineAsyncComponent(() => import("@/components/admin/MySqlDashboard.vue")); +const PostgresDashboard = defineAsyncComponent(() => import("@/components/admin/PostgresDashboard.vue")); const DamengJobAdmin = defineAsyncComponent(() => import("@/components/admin/DamengJobAdmin.vue")); const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue")); const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue")); @@ -1629,6 +1630,12 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
+ + diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index b46d60ea53..eb12d0e2d3 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -150,6 +150,7 @@ import { connectionPasteTargetGroupId, selectedConnectionClipboardTargets, selec import { supportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin"; import { connectionSupportsProcessList } from "@/lib/database/processListDrivers"; import { connectionSupportsServerDashboard } from "@/lib/database/mysqlServerStatus"; +import { connectionSupportsServerDashboard as connectionSupportsPgServerDashboard } from "@/lib/database/postgresServerStatus"; import { canCloseSidebarDatabaseConnection, isSidebarDatabaseOpened } from "@/lib/sidebar/sidebarDatabaseOpenState"; import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; import { batchTableEmptyFeedback, runBatchTableEmpty } from "@/lib/sidebar/batchTableEmpty"; @@ -1333,13 +1334,17 @@ async function openProcessList() { } } -async function openMysqlDashboard() { +async function openServerDashboard() { const node = props.node; if (!node.connectionId) return; try { await connectionStore.ensureConnected(node.connectionId); connectionStore.activeConnectionId = node.connectionId; - queryStore.openMysqlDashboard(node.connectionId); + if (currentDatabaseType() === "postgres") { + queryStore.openPostgresDashboard(node.connectionId); + } else { + queryStore.openMysqlDashboard(node.connectionId); + } } catch (e: any) { toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); } @@ -5129,8 +5134,8 @@ function treeItemMenuItems(): ContextMenuItem[] { if (node.connectionId && connectionSupportsProcessList(connectionStore.getConfig(node.connectionId))) { items.push({ label: t("contextMenu.processList"), action: openProcessList, icon: Activity }); } - if (node.connectionId && connectionSupportsServerDashboard(connectionStore.getConfig(node.connectionId))) { - items.push({ label: t("contextMenu.serverDashboard"), action: openMysqlDashboard, icon: Gauge }); + if (node.connectionId && (connectionSupportsServerDashboard(connectionStore.getConfig(node.connectionId)) || connectionSupportsPgServerDashboard(connectionStore.getConfig(node.connectionId)))) { + items.push({ label: t("contextMenu.serverDashboard"), action: openServerDashboard, icon: Gauge }); } if (currentDatabaseType() === "dameng") { items.push({ label: t("contextMenu.damengJobAdmin"), action: openDamengJobAdmin, icon: CalendarClock }); diff --git a/apps/desktop/src/composables/useVerticalOverlayScrollbar.ts b/apps/desktop/src/composables/useVerticalOverlayScrollbar.ts new file mode 100644 index 0000000000..c03c4dcc63 --- /dev/null +++ b/apps/desktop/src/composables/useVerticalOverlayScrollbar.ts @@ -0,0 +1,151 @@ +import { computed, onBeforeUnmount, ref, watch, type CSSProperties, type Ref } from "vue"; + +/** + * Thin floating overlay scrollbar (track + draggable thumb) for a plain + * `overflow-y-auto` element, matching the sidebar tree's custom scrollbar + * look/feel. The native scrollbar should be hidden on the scroller via CSS + * (`scrollbar-width: none` / `::-webkit-scrollbar { display: none }`). + * + * `scrollerRef`, `contentRef`, and `trackRef` are declared by the caller + * (plain `ref(null)`) and bound via `ref="..."` in its + * template — mirrors the pattern already used for the sidebar tree's own + * scrollbar. `contentRef` must be the direct child that wraps everything + * inside the scroller: because `scrollerRef` itself is `overflow-y-auto`, its + * own box height is capped by the surrounding flex layout and does NOT change + * when its content grows (e.g. async-loaded rows/charts) — only `contentRef`, + * an in-flow element, reports that growth via ResizeObserver. + */ +export function useVerticalOverlayScrollbar(scrollerRef: Ref, contentRef: Ref, trackRef: Ref) { + const hasOverflow = ref(false); + const isScrolling = ref(false); + const isDragging = ref(false); + const thumbTopPercent = ref(0); + const thumbHeightPercent = ref(100); + + let scrollerResizeObserver: ResizeObserver | null = null; + let contentResizeObserver: ResizeObserver | null = null; + let scrollHideTimer: ReturnType | null = null; + let dragOffsetPx = 0; + + function updateMetrics() { + const el = scrollerRef.value; + if (!el) { + hasOverflow.value = false; + return; + } + const maxScrollTop = Math.max(0, el.scrollHeight - el.clientHeight); + hasOverflow.value = maxScrollTop > 1; + const rawThumbHeight = el.scrollHeight > 0 ? (el.clientHeight / el.scrollHeight) * 100 : 100; + const thumbHeight = Math.min(100, Math.max(8, rawThumbHeight)); + const thumbTravel = Math.max(0, 100 - thumbHeight); + thumbHeightPercent.value = thumbHeight; + thumbTopPercent.value = maxScrollTop > 0 ? (el.scrollTop / maxScrollTop) * thumbTravel : 0; + } + + function onScroll() { + updateMetrics(); + isScrolling.value = true; + if (scrollHideTimer) clearTimeout(scrollHideTimer); + scrollHideTimer = setTimeout(() => { + isScrolling.value = false; + }, 600); + } + + function setScrollFromPointer(clientY: number, offsetPx: number) { + const el = scrollerRef.value; + const track = trackRef.value; + if (!el || !track) return; + const maxScrollTop = Math.max(0, el.scrollHeight - el.clientHeight); + if (maxScrollTop <= 0) return; + const trackRect = track.getBoundingClientRect(); + const thumbHeightPx = trackRect.height * (thumbHeightPercent.value / 100); + const maxThumbTopPx = Math.max(1, trackRect.height - thumbHeightPx); + const thumbTopPx = Math.min(maxThumbTopPx, Math.max(0, clientY - trackRect.top - offsetPx)); + el.scrollTop = (thumbTopPx / maxThumbTopPx) * maxScrollTop; + updateMetrics(); + } + + function onPointerMove(event: PointerEvent) { + if (!isDragging.value) return; + event.preventDefault(); + setScrollFromPointer(event.clientY, dragOffsetPx); + } + + function stopDrag() { + isDragging.value = false; + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", stopDrag); + window.removeEventListener("pointercancel", stopDrag); + document.body.style.userSelect = ""; + } + + function startDrag(offsetPx: number, clientY: number) { + dragOffsetPx = offsetPx; + isDragging.value = true; + document.body.style.userSelect = "none"; + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", stopDrag); + window.addEventListener("pointercancel", stopDrag); + setScrollFromPointer(clientY, offsetPx); + } + + function onTrackPointerDown(event: PointerEvent) { + if (!hasOverflow.value) return; + const trackRect = trackRef.value?.getBoundingClientRect(); + if (!trackRect) return; + const thumbHeightPx = trackRect.height * (thumbHeightPercent.value / 100); + event.preventDefault(); + startDrag(thumbHeightPx / 2, event.clientY); + } + + function onThumbPointerDown(event: PointerEvent) { + const track = trackRef.value; + if (!track) return; + const rect = track.getBoundingClientRect(); + const thumbTopPx = rect.height * (thumbTopPercent.value / 100); + event.preventDefault(); + startDrag(event.clientY - rect.top - thumbTopPx, event.clientY); + } + + watch( + scrollerRef, + (el) => { + scrollerResizeObserver?.disconnect(); + scrollerResizeObserver = null; + if (el && typeof ResizeObserver !== "undefined") { + scrollerResizeObserver = new ResizeObserver(updateMetrics); + scrollerResizeObserver.observe(el); + } + updateMetrics(); + }, + { flush: "post", immediate: true }, + ); + + watch( + contentRef, + (el) => { + contentResizeObserver?.disconnect(); + contentResizeObserver = null; + if (el && typeof ResizeObserver !== "undefined") { + contentResizeObserver = new ResizeObserver(updateMetrics); + contentResizeObserver.observe(el); + } + updateMetrics(); + }, + { flush: "post", immediate: true }, + ); + + onBeforeUnmount(() => { + scrollerResizeObserver?.disconnect(); + contentResizeObserver?.disconnect(); + stopDrag(); + if (scrollHideTimer) clearTimeout(scrollHideTimer); + }); + + const thumbStyle = computed(() => ({ + top: `${thumbTopPercent.value}%`, + height: `${thumbHeightPercent.value}%`, + })); + + return { hasOverflow, isScrolling, isDragging, thumbStyle, onScroll, onTrackPointerDown, onThumbPointerDown }; +} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 9299f604a5..ff044d57d5 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1791,6 +1791,26 @@ export default { commandChart: "Commands per second", rawStatus: "Status variables", filterStatus: "Filter status variables", + tps: "Transactions / sec", + activeQueries: "Active queries", + cacheHit: "Cache hit rate", + deadlocks: "Deadlocks", + tempFiles: "Temp files", + walRate: "WAL rate", + commit: "Commit", + rollback: "Rollback", + total: "Total", + active: "Active", + idle: "Idle", + fetched: "Fetched", + returned: "Returned", + read: "Read", + hits: "Hits", + serverSessionsChart: "Server sessions", + transactionsChart: "Transactions per second", + tuplesInChart: "Tuples in", + tuplesOutChart: "Tuples out", + blockIoChart: "Block I/O", }, userAdmin: { title: "Users & Privileges", diff --git a/apps/desktop/src/lib/__tests__/database/postgresServerStatus.spec.ts b/apps/desktop/src/lib/__tests__/database/postgresServerStatus.spec.ts new file mode 100644 index 0000000000..0c1abe1855 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/database/postgresServerStatus.spec.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vitest"; +import type { QueryResult } from "@/types/database"; +import { + computePgTps, + computeRate, + connectionSupportsServerDashboard, + formatBytes, + formatBytesPerSec, + formatUptime, + isPgStatusCompatibilityError, + parsePgStatusRow, + pgCacheHitRatio, + PG_STATUS_LEGACY_SQL, + PG_STATUS_SQL, + statusNumber, + supportsServerDashboard, + type StatusSample, +} from "@/lib/database/postgresServerStatus"; + +function statusResult(columns: string[], row: (string | number)[]): QueryResult { + return { columns, rows: [row], affected_rows: 0, execution_time_ms: 0 }; +} + +function sample(at: number, status: Record): StatusSample { + return { at, status }; +} + +describe("parsePgStatusRow", () => { + it("parses the single aggregate row into a map", () => { + const map = parsePgStatusRow(statusResult(["xact_commit", "xact_rollback"], ["1200", "3"])); + expect(map).toEqual({ xact_commit: "1200", xact_rollback: "3" }); + }); + + it("returns empty map for malformed input", () => { + expect(parsePgStatusRow(null)).toEqual({}); + expect(parsePgStatusRow({ columns: [], rows: [], affected_rows: 0, execution_time_ms: 0 })).toEqual({}); + }); +}); + +describe("statusNumber", () => { + it("reads numeric values and defaults to 0", () => { + expect(statusNumber({ connections: "12" }, "connections")).toBe(12); + expect(statusNumber({}, "missing")).toBe(0); + expect(statusNumber({ x: "abc" }, "x")).toBe(0); + }); +}); + +describe("computeRate", () => { + it("computes per-second delta", () => { + const prev = sample(1000, { tup_inserted: "1000" }); + const curr = sample(3000, { tup_inserted: "5000" }); + expect(computeRate(prev, curr, "tup_inserted")).toBe(2000); // 4000 rows / 2s + }); + + it("returns 0 on counter reset (decrease)", () => { + const prev = sample(1000, { xact_commit: "9000" }); + const curr = sample(2000, { xact_commit: "10" }); + expect(computeRate(prev, curr, "xact_commit")).toBe(0); + }); + + it("returns 0 for non-positive time delta", () => { + const prev = sample(2000, { xact_commit: "10" }); + const curr = sample(2000, { xact_commit: "20" }); + expect(computeRate(prev, curr, "xact_commit")).toBe(0); + }); +}); + +describe("computePgTps", () => { + it("sums commit and rollback rates", () => { + const prev = sample(0, { xact_commit: "0", xact_rollback: "0" }); + const curr = sample(1000, { xact_commit: "90", xact_rollback: "10" }); + expect(computePgTps(prev, curr)).toBe(100); + }); +}); + +describe("pgCacheHitRatio", () => { + it("computes hit ratio as a percentage", () => { + expect(pgCacheHitRatio({ blks_hit: "997", blks_read: "3" })).toBeCloseTo(99.7, 1); + }); + + it("returns null when no data has accumulated", () => { + expect(pgCacheHitRatio({})).toBeNull(); + expect(pgCacheHitRatio({ blks_hit: "0", blks_read: "0" })).toBeNull(); + }); +}); + +describe("formatters", () => { + it("formats bytes and bytes/sec", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(1024)).toBe("1.0 KB"); + expect(formatBytes(1024 * 1024 * 2)).toBe("2.0 MB"); + expect(formatBytesPerSec(1024)).toBe("1.0 KB/s"); + }); + + it("formats uptime compactly", () => { + expect(formatUptime(0)).toBe("0s"); + expect(formatUptime(45)).toBe("45s"); + expect(formatUptime(3661)).toBe("1h 1m"); + expect(formatUptime(90061)).toBe("1d 1h 1m"); + }); +}); + +describe("PG_STATUS_SQL", () => { + it("does not use FILTER (PG9.4+ only) — this dashboard targets PG9.2+", () => { + expect(PG_STATUS_SQL).not.toContain("FILTER"); + expect(PG_STATUS_SQL).toContain("sum(CASE WHEN state IS NOT NULL THEN 1 ELSE 0 END)"); + expect(PG_STATUS_SQL).not.toContain("count(*) AS connections"); + expect(PG_STATUS_SQL).toContain("sum(CASE WHEN state = 'active' THEN 1 ELSE 0 END)"); + expect(PG_STATUS_SQL).toContain("sum(CASE WHEN state = 'idle' THEN 1 ELSE 0 END)"); + }); + + it("is recovery-aware for the WAL metric, never calling pg_current_wal_lsn() unconditionally", () => { + expect(PG_STATUS_SQL).toContain("CASE WHEN pg_is_in_recovery()"); + expect(PG_STATUS_SQL).toContain("pg_last_wal_replay_lsn()"); + expect(PG_STATUS_SQL).toContain("pg_current_wal_lsn()"); + }); +}); + +describe("PG_STATUS_LEGACY_SQL", () => { + it("swaps only the PG10+ WAL functions for their pre-10 equivalents", () => { + expect(PG_STATUS_LEGACY_SQL).not.toContain("pg_current_wal_lsn"); + expect(PG_STATUS_LEGACY_SQL).not.toContain("pg_last_wal_replay_lsn"); + expect(PG_STATUS_LEGACY_SQL).not.toContain("pg_wal_lsn_diff"); + expect(PG_STATUS_LEGACY_SQL).toContain("pg_current_xlog_location"); + expect(PG_STATUS_LEGACY_SQL).toContain("pg_last_xlog_replay_location"); + expect(PG_STATUS_LEGACY_SQL).toContain("pg_xlog_location_diff"); + // Everything else stays byte-for-byte identical to the primary query. + const revertedToPrimary = PG_STATUS_LEGACY_SQL.replace(/\bpg_current_xlog_location\b/g, "pg_current_wal_lsn") + .replace(/\bpg_last_xlog_replay_location\b/g, "pg_last_wal_replay_lsn") + .replace(/\bpg_xlog_location_diff\b/g, "pg_wal_lsn_diff"); + expect(revertedToPrimary).toBe(PG_STATUS_SQL); + }); +}); + +describe("isPgStatusCompatibilityError", () => { + it("detects the WAL-function-not-found message on servers without a code field", () => { + expect(isPgStatusCompatibilityError(new Error("function pg_current_wal_lsn() does not exist"))).toBe(true); + expect(isPgStatusCompatibilityError(new Error("function pg_last_wal_replay_lsn() does not exist"))).toBe(true); + expect(isPgStatusCompatibilityError(new Error("function pg_wal_lsn_diff(pg_lsn, unknown) does not exist"))).toBe(true); + }); + + it("detects the WAL-function-not-found SQLSTATE combined with a matching message", () => { + expect(isPgStatusCompatibilityError(Object.assign(new Error("function pg_current_wal_lsn() does not exist"), { code: "42883" }))).toBe(true); + }); + + it("does not misclassify unrelated errors", () => { + expect(isPgStatusCompatibilityError(new Error("connection refused"))).toBe(false); + expect(isPgStatusCompatibilityError({ code: "42703" })).toBe(false); + }); + + it("does not treat every SQLSTATE 42883 as the WAL compatibility issue — only the two specific functions", () => { + // Bare code with no message can't confirm which function is missing. + expect(isPgStatusCompatibilityError({ code: "42883" })).toBe(false); + // A different undefined function under the same SQLSTATE must not trigger the WAL fallback. + expect(isPgStatusCompatibilityError(Object.assign(new Error("function pg_postmaster_start_time() does not exist"), { code: "42883" }))).toBe(false); + expect(isPgStatusCompatibilityError(new Error("function some_other_fn() does not exist"))).toBe(false); + }); +}); + +describe("supportsServerDashboard", () => { + it("is true for postgres only", () => { + expect(supportsServerDashboard("postgres")).toBe(true); + expect(supportsServerDashboard("mysql")).toBe(false); + expect(supportsServerDashboard("opengauss")).toBe(false); + expect(supportsServerDashboard("kingbase")).toBe(false); + expect(supportsServerDashboard(undefined)).toBe(false); + }); + + it("gates on the connection's effective db type", () => { + expect(connectionSupportsServerDashboard({ id: "pg", name: "Postgres", db_type: "postgres" } as any)).toBe(true); + expect(connectionSupportsServerDashboard({ id: "jdbc-pg", name: "JDBC Postgres", db_type: "jdbc", connection_string: "jdbc:postgresql://localhost/db" } as any)).toBe(true); + expect(connectionSupportsServerDashboard({ id: "mysql", name: "MySQL", db_type: "mysql" } as any)).toBe(false); + expect(connectionSupportsServerDashboard(undefined)).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/database/mysqlServerStatus.ts b/apps/desktop/src/lib/database/mysqlServerStatus.ts index 5aefedc58c..1442cc1285 100644 --- a/apps/desktop/src/lib/database/mysqlServerStatus.ts +++ b/apps/desktop/src/lib/database/mysqlServerStatus.ts @@ -1,5 +1,6 @@ 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"; /** * MySQL server-monitoring helpers. Pure and framework-free so the rate math and @@ -9,7 +10,11 @@ import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; * Data comes from `SHOW GLOBAL STATUS` (cumulative counters, two columns * Variable_name/Value) and a one-shot `SHOW GLOBAL VARIABLES` (config such as * max_connections/version), both run through the generic query bridge. + * + * Sample/rate math and formatting are engine-agnostic and live in + * `./serverMetrics`; re-exported here so existing callers keep one import path. */ +export { computeRate, formatBytes, formatBytesPerSec, formatNumber, formatRate, formatUptime, statusEntries, statusNumber, type StatusEntry, type StatusMap, type StatusSample }; export const GLOBAL_STATUS_SQL = "SHOW GLOBAL STATUS"; export const GLOBAL_VARIABLES_SQL = "SHOW GLOBAL VARIABLES"; @@ -29,20 +34,6 @@ export const MAX_SAMPLES = 60; */ const SERVER_DASHBOARD_DB_TYPES = new Set(["mysql"]); -export type StatusMap = Record; - -export interface StatusSample { - /** Capture time in epoch milliseconds (captured by the caller). */ - at: number; - status: StatusMap; -} - -/** A key/value row for the raw status table. */ -export interface StatusEntry { - name: string; - value: string; -} - /** Parse a two-column `SHOW GLOBAL STATUS` / `SHOW GLOBAL VARIABLES` result. */ export function parseStatusResult(result: QueryResult | null | undefined): StatusMap { const map: StatusMap = {}; @@ -59,27 +50,6 @@ export function parseStatusResult(result: QueryResult | null | undefined): Statu return map; } -/** Read a status value as a number, defaulting to 0 when absent/non-numeric. */ -export function statusNumber(status: StatusMap, key: string): number { - const raw = status[key]; - if (raw === undefined) return 0; - const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : 0; -} - -/** - * Per-second rate of a cumulative counter between two samples. Guards against a - * counter reset (server restart / FLUSH STATUS) by treating a decrease as no - * measurable rate, and against a zero/negative time delta. - */ -export function computeRate(prev: StatusSample, curr: StatusSample, key: string): number { - const dtSeconds = (curr.at - prev.at) / 1000; - if (dtSeconds <= 0) return 0; - const delta = statusNumber(curr.status, key) - statusNumber(prev.status, key); - if (delta < 0) return 0; - return delta / dtSeconds; -} - /** QPS between two samples, preferring `Queries` and falling back to `Questions`. */ export function computeQps(prev: StatusSample, curr: StatusSample): number { const hasQueries = curr.status.Queries !== undefined; @@ -99,51 +69,6 @@ export function innodbBufferHitRatio(status: StatusMap): number | null { return Math.max(0, Math.min(100, ratio)); } -/** Flatten a status map into sorted key/value rows for the raw table. */ -export function statusEntries(status: StatusMap): StatusEntry[] { - return Object.keys(status) - .sort((a, b) => a.localeCompare(b)) - .map((name) => ({ name, value: status[name] })); -} - -export function formatNumber(value: number): string { - return Math.round(value).toLocaleString("en-US"); -} - -const RATE_NUMBER_FORMATTER = new Intl.NumberFormat("en-US", { maximumFractionDigits: 3 }); - -/** Cumulative-counter rates can be below 1/s, so preserve their fractional value. */ -export function formatRate(value: number): string { - return Number.isFinite(value) ? RATE_NUMBER_FORMATTER.format(value) : "0"; -} - -const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"]; - -export function formatBytes(value: number): string { - if (!Number.isFinite(value) || value <= 0) return "0 B"; - const exponent = Math.min(Math.floor(Math.log(value) / Math.log(1024)), BYTE_UNITS.length - 1); - const scaled = value / 1024 ** exponent; - return `${scaled.toFixed(exponent === 0 ? 0 : 1)} ${BYTE_UNITS[exponent]}`; -} - -export function formatBytesPerSec(value: number): string { - return `${formatBytes(value)}/s`; -} - -/** Format an uptime in seconds as a compact `Nd Nh Nm` / `Nh Nm` / `Nm Ns` string. */ -export function formatUptime(seconds: number): string { - if (!Number.isFinite(seconds) || seconds <= 0) return "0s"; - const total = Math.floor(seconds); - const days = Math.floor(total / 86400); - const hours = Math.floor((total % 86400) / 3600); - const minutes = Math.floor((total % 3600) / 60); - const secs = total % 60; - if (days > 0) return `${days}d ${hours}h ${minutes}m`; - if (hours > 0) return `${hours}h ${minutes}m`; - if (minutes > 0) return `${minutes}m ${secs}s`; - return `${secs}s`; -} - /** Whether the given database type exposes the server dashboard (MySQL family). */ export function supportsServerDashboard(dbType: DatabaseType | undefined): boolean { return !!dbType && SERVER_DASHBOARD_DB_TYPES.has(dbType); diff --git a/apps/desktop/src/lib/database/postgresServerStatus.ts b/apps/desktop/src/lib/database/postgresServerStatus.ts new file mode 100644 index 0000000000..bf55f42c01 --- /dev/null +++ b/apps/desktop/src/lib/database/postgresServerStatus.ts @@ -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(["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)); +} diff --git a/apps/desktop/src/lib/database/serverMetrics.ts b/apps/desktop/src/lib/database/serverMetrics.ts new file mode 100644 index 0000000000..4b1f2fbf23 --- /dev/null +++ b/apps/desktop/src/lib/database/serverMetrics.ts @@ -0,0 +1,85 @@ +/** + * Engine-agnostic server-dashboard helpers shared by the MySQL and PostgreSQL + * status modules: sample/rate math and human-readable formatting. Pure and + * framework-free so they can be unit-tested in isolation. + */ + +export type StatusMap = Record; + +export interface StatusSample { + /** Capture time in epoch milliseconds (captured by the caller). */ + at: number; + status: StatusMap; +} + +/** A key/value row for the raw status table. */ +export interface StatusEntry { + name: string; + value: string; +} + +/** Read a status value as a number, defaulting to 0 when absent/non-numeric. */ +export function statusNumber(status: StatusMap, key: string): number { + const raw = status[key]; + if (raw === undefined) return 0; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : 0; +} + +/** + * Per-second rate of a cumulative counter between two samples. Guards against a + * counter reset (server restart / stats reset) by treating a decrease as no + * measurable rate, and against a zero/negative time delta. + */ +export function computeRate(prev: StatusSample, curr: StatusSample, key: string): number { + const dtSeconds = (curr.at - prev.at) / 1000; + if (dtSeconds <= 0) return 0; + const delta = statusNumber(curr.status, key) - statusNumber(prev.status, key); + if (delta < 0) return 0; + return delta / dtSeconds; +} + +/** Flatten a status map into sorted key/value rows for the raw table. */ +export function statusEntries(status: StatusMap): StatusEntry[] { + return Object.keys(status) + .sort((a, b) => a.localeCompare(b)) + .map((name) => ({ name, value: status[name] })); +} + +export function formatNumber(value: number): string { + return Math.round(value).toLocaleString("en-US"); +} + +const RATE_NUMBER_FORMATTER = new Intl.NumberFormat("en-US", { maximumFractionDigits: 3 }); + +/** Cumulative-counter rates can be below 1/s, so preserve their fractional value. */ +export function formatRate(value: number): string { + return Number.isFinite(value) ? RATE_NUMBER_FORMATTER.format(value) : "0"; +} + +const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"]; + +export function formatBytes(value: number): string { + if (!Number.isFinite(value) || value <= 0) return "0 B"; + const exponent = Math.min(Math.floor(Math.log(value) / Math.log(1024)), BYTE_UNITS.length - 1); + const scaled = value / 1024 ** exponent; + return `${scaled.toFixed(exponent === 0 ? 0 : 1)} ${BYTE_UNITS[exponent]}`; +} + +export function formatBytesPerSec(value: number): string { + return `${formatBytes(value)}/s`; +} + +/** Format an uptime in seconds as a compact `Nd Nh Nm` / `Nh Nm` / `Nm Ns` string. */ +export function formatUptime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return "0s"; + const total = Math.floor(seconds); + const days = Math.floor(total / 86400); + const hours = Math.floor((total % 86400) / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${secs}s`; + return `${secs}s`; +} diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index b03ef3a2b2..9750225564 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1172,6 +1172,31 @@ export const useQueryStore = defineStore("query", () => { return id; } + function openPostgresDashboard(connectionId: string) { + const existing = tabs.value.find((tab) => tab.mode === "postgres-dashboard" && tab.connectionId === connectionId); + if (existing) { + switchTab(existing.id); + return existing.id; + } + + const conn = useConnectionStore().getConfig(connectionId); + const id = uuid(); + const tab: QueryTab = { + id, + title: conn?.name ? `${conn.name} - ${t("serverDashboard.title")}` : t("serverDashboard.title"), + connectionId, + database: conn?.database || "", + sql: "", + isExecuting: false, + isCancelling: false, + isExplaining: false, + mode: "postgres-dashboard", + }; + tabs.value.push(tab); + activeTabId.value = id; + return id; + } + function openDamengJobAdmin(connectionId: string) { const existing = tabs.value.find((tab) => tab.mode === "dameng-jobs" && tab.connectionId === connectionId); if (existing) { @@ -4281,6 +4306,7 @@ export const useQueryStore = defineStore("query", () => { openUserAdmin, openProcessList, openMysqlDashboard, + openPostgresDashboard, openDamengJobAdmin, openMqAdmin, openNacosAdmin, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index f80904fc58..c3088274ec 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -815,7 +815,7 @@ export interface QueryTab { explainExecutionId?: string; /** Per-run connection session for explain flows that require session state. */ explainClientSessionId?: string; - mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "objects" | "structure" | "users" | "dameng-jobs" | "processlist" | "mysql-dashboard"; + mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "objects" | "structure" | "users" | "dameng-jobs" | "processlist" | "mysql-dashboard" | "postgres-dashboard"; mqTenant?: string; mqInitialTab?: "topics"; nacosNamespace?: string;