feat(postgres): add server dashboard metrics
This commit is contained in:
parent
3d23a58e36
commit
fc1f02a931
|
|
@ -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<StatusSample[]>([]);
|
|||
const autoRefreshInterval = ref(5);
|
||||
const statusSearch = ref("");
|
||||
const showStatusTable = ref(true);
|
||||
const scrollerRef = ref<HTMLElement | null>(null);
|
||||
const scrollerContentRef = ref<HTMLElement | null>(null);
|
||||
const scrollbarTrackRef = ref<HTMLElement | null>(null);
|
||||
const {
|
||||
hasOverflow: hasScrollbarOverflow,
|
||||
isScrolling: isScrollbarScrolling,
|
||||
isDragging: isScrollbarDragging,
|
||||
thumbStyle: scrollbarThumbStyle,
|
||||
onScroll: onScrollerScroll,
|
||||
onTrackPointerDown: onScrollbarTrackPointerDown,
|
||||
onThumbPointerDown: onScrollbarThumbPointerDown,
|
||||
} = useVerticalOverlayScrollbar(scrollerRef, scrollerContentRef, scrollbarTrackRef);
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const connectionName = computed(() => connectionStore.getConfig(props.connectionId)?.name ?? "");
|
||||
|
|
@ -193,48 +206,106 @@ onUnmounted(stopAutoRefresh);
|
|||
|
||||
<div v-if="error" class="border-b bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ error }}</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-3 p-3">
|
||||
<div class="grid shrink-0 grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<MetricCard :label="t('serverDashboard.qps')" :value="formatRate(qps)" :icon="Gauge" />
|
||||
<MetricCard :label="t('serverDashboard.connections')" :value="`${formatNumber(threadsConnected)}${maxConnections ? ' / ' + formatNumber(maxConnections) : ''}`" :icon="Users" />
|
||||
<MetricCard :label="t('serverDashboard.running')" :value="formatNumber(threadsRunning)" :icon="Activity" />
|
||||
<MetricCard :label="t('serverDashboard.trafficIn')" :value="formatBytesPerSec(rate('Bytes_received'))" :icon="ArrowDownUp" />
|
||||
<MetricCard :label="t('serverDashboard.trafficOut')" :value="formatBytesPerSec(rate('Bytes_sent'))" :icon="ArrowDownUp" />
|
||||
<MetricCard :label="t('serverDashboard.slowQueries')" :value="formatNumber(slowQueries)" :icon="TriangleAlert" />
|
||||
<MetricCard :label="t('serverDashboard.uptime')" :value="formatUptime(uptimeSeconds)" :icon="Timer" />
|
||||
<MetricCard :label="t('serverDashboard.innodbHit')" :value="innodbHit === null ? '—' : innodbHit.toFixed(2) + '%'" :icon="Database" />
|
||||
</div>
|
||||
|
||||
<div class="grid shrink-0 grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<MetricLineChart :title="t('serverDashboard.qpsChart')" :labels="chartLabels" :series="qpsSeries" :value-formatter="formatRate" />
|
||||
<MetricLineChart :title="t('serverDashboard.sessionsChart')" :labels="chartLabels" :series="sessionsSeries" :value-formatter="formatRate" />
|
||||
<MetricLineChart :title="t('serverDashboard.trafficChart')" :labels="chartLabels" :series="trafficSeries" :value-formatter="formatBytes" />
|
||||
<MetricLineChart :title="t('serverDashboard.commandChart')" :labels="chartLabels" :series="commandSeries" :value-formatter="formatRate" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col rounded-lg border bg-card" :class="showStatusTable ? 'min-h-0 flex-1' : 'shrink-0'">
|
||||
<button type="button" class="flex w-full shrink-0 items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-accent/40" @click="showStatusTable = !showStatusTable">
|
||||
<ChevronRight class="h-3.5 w-3.5 transition-transform" :class="{ 'rotate-90': showStatusTable }" />
|
||||
{{ t("serverDashboard.rawStatus") }}
|
||||
<Badge variant="secondary" class="ml-1 h-4 rounded px-1 text-[10px]">{{ statusRows.length }}</Badge>
|
||||
</button>
|
||||
<div v-if="showStatusTable" class="flex min-h-0 flex-1 flex-col border-t">
|
||||
<div class="flex h-9 shrink-0 items-center gap-1.5 border-b px-3">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input v-model="statusSearch" class="h-full w-full min-w-0 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('serverDashboard.filterStatus')" />
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div ref="scrollerRef" class="mysql-dashboard-scroller h-full min-h-0 overflow-y-auto" @scroll.passive="onScrollerScroll">
|
||||
<div ref="scrollerContentRef" class="flex flex-col gap-3 p-3">
|
||||
<div class="grid shrink-0 grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<MetricCard :label="t('serverDashboard.qps')" :value="formatRate(qps)" :icon="Gauge" />
|
||||
<MetricCard :label="t('serverDashboard.connections')" :value="`${formatNumber(threadsConnected)}${maxConnections ? ' / ' + formatNumber(maxConnections) : ''}`" :icon="Users" />
|
||||
<MetricCard :label="t('serverDashboard.running')" :value="formatNumber(threadsRunning)" :icon="Activity" />
|
||||
<MetricCard :label="t('serverDashboard.trafficIn')" :value="formatBytesPerSec(rate('Bytes_received'))" :icon="ArrowDownUp" />
|
||||
<MetricCard :label="t('serverDashboard.trafficOut')" :value="formatBytesPerSec(rate('Bytes_sent'))" :icon="ArrowDownUp" />
|
||||
<MetricCard :label="t('serverDashboard.slowQueries')" :value="formatNumber(slowQueries)" :icon="TriangleAlert" />
|
||||
<MetricCard :label="t('serverDashboard.uptime')" :value="formatUptime(uptimeSeconds)" :icon="Timer" />
|
||||
<MetricCard :label="t('serverDashboard.innodbHit')" :value="innodbHit === null ? '—' : innodbHit.toFixed(2) + '%'" :icon="Database" />
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<table class="w-full border-collapse text-xs">
|
||||
<tbody>
|
||||
<tr v-for="row in statusRows" :key="row.name" class="border-b last:border-0 hover:bg-accent/40">
|
||||
<td class="whitespace-nowrap px-3 py-1 font-mono text-muted-foreground">{{ row.name }}</td>
|
||||
<td class="px-3 py-1 font-mono tabular-nums">{{ row.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="grid shrink-0 grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<MetricLineChart :title="t('serverDashboard.qpsChart')" :labels="chartLabels" :series="qpsSeries" :value-formatter="formatRate" />
|
||||
<MetricLineChart :title="t('serverDashboard.sessionsChart')" :labels="chartLabels" :series="sessionsSeries" :value-formatter="formatRate" />
|
||||
<MetricLineChart :title="t('serverDashboard.trafficChart')" :labels="chartLabels" :series="trafficSeries" :value-formatter="formatBytes" />
|
||||
<MetricLineChart :title="t('serverDashboard.commandChart')" :labels="chartLabels" :series="commandSeries" :value-formatter="formatRate" />
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-col rounded-lg border bg-card">
|
||||
<button type="button" class="flex w-full shrink-0 items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-accent/40" @click="showStatusTable = !showStatusTable">
|
||||
<ChevronRight class="h-3.5 w-3.5 transition-transform" :class="{ 'rotate-90': showStatusTable }" />
|
||||
{{ t("serverDashboard.rawStatus") }}
|
||||
<Badge variant="secondary" class="ml-1 h-4 rounded px-1 text-[10px]">{{ statusRows.length }}</Badge>
|
||||
</button>
|
||||
<div v-if="showStatusTable" class="flex flex-col border-t">
|
||||
<div class="flex h-9 shrink-0 items-center gap-1.5 border-b px-3">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input v-model="statusSearch" class="h-full w-full min-w-0 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('serverDashboard.filterStatus')" />
|
||||
</div>
|
||||
<div class="max-h-96 overflow-auto">
|
||||
<table class="w-full border-collapse text-xs">
|
||||
<tbody>
|
||||
<tr v-for="row in statusRows" :key="row.name" class="border-b last:border-0 hover:bg-accent/40">
|
||||
<td class="whitespace-nowrap px-3 py-1 font-mono text-muted-foreground">{{ row.name }}</td>
|
||||
<td class="px-3 py-1 font-mono tabular-nums">{{ row.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasScrollbarOverflow" ref="scrollbarTrackRef" class="mysql-dashboard-scrollbar" :class="{ 'mysql-dashboard-scrollbar--scrolling': isScrollbarScrolling, 'mysql-dashboard-scrollbar--dragging': isScrollbarDragging }" @pointerdown="onScrollbarTrackPointerDown">
|
||||
<div class="mysql-dashboard-scrollbar__thumb" :style="scrollbarThumbStyle" @pointerdown.stop="onScrollbarThumbPointerDown" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mysql-dashboard-scroller {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.mysql-dashboard-scroller::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.mysql-dashboard-scrollbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
width: 12px;
|
||||
cursor: default;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.mysql-dashboard-scrollbar--scrolling,
|
||||
.mysql-dashboard-scrollbar:hover,
|
||||
.mysql-dashboard-scrollbar--dragging {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mysql-dashboard-scrollbar__thumb {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
width: 6px;
|
||||
min-height: 24px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklch, var(--foreground) 30%, transparent);
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
width 120ms ease,
|
||||
right 120ms ease;
|
||||
}
|
||||
|
||||
.mysql-dashboard-scrollbar:hover .mysql-dashboard-scrollbar__thumb,
|
||||
.mysql-dashboard-scrollbar--dragging .mysql-dashboard-scrollbar__thumb {
|
||||
right: 1px;
|
||||
width: 8px;
|
||||
background: color-mix(in oklch, var(--foreground) 48%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,320 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Activity, ArrowDownUp, Database, Gauge, Loader2, RefreshCcw, Timer, TriangleAlert, Users } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import MetricCard from "@/components/common/MetricCard.vue";
|
||||
import MetricLineChart from "@/components/chart/MetricLineChart.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import {
|
||||
computePgTps,
|
||||
computeRate,
|
||||
formatBytesPerSec,
|
||||
formatNumber,
|
||||
formatRate,
|
||||
formatUptime,
|
||||
isPgStatusCompatibilityError,
|
||||
MAX_SAMPLES,
|
||||
parsePgStatusRow,
|
||||
pgCacheHitRatio,
|
||||
PG_STATUS_LEGACY_SQL,
|
||||
PG_STATUS_SQL,
|
||||
PG_VARIABLES_SQL,
|
||||
statusNumber,
|
||||
type StatusSample,
|
||||
} from "@/lib/database/postgresServerStatus";
|
||||
import { useVerticalOverlayScrollbar } from "@/composables/useVerticalOverlayScrollbar";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const fetching = ref(false);
|
||||
const error = ref("");
|
||||
const variables = ref<Record<string, string>>({});
|
||||
const samples = ref<StatusSample[]>([]);
|
||||
const autoRefreshInterval = ref(5);
|
||||
const scrollerRef = ref<HTMLElement | null>(null);
|
||||
const scrollerContentRef = ref<HTMLElement | null>(null);
|
||||
const scrollbarTrackRef = ref<HTMLElement | null>(null);
|
||||
const {
|
||||
hasOverflow: hasScrollbarOverflow,
|
||||
isScrolling: isScrollbarScrolling,
|
||||
isDragging: isScrollbarDragging,
|
||||
thumbStyle: scrollbarThumbStyle,
|
||||
onScroll: onScrollerScroll,
|
||||
onTrackPointerDown: onScrollbarTrackPointerDown,
|
||||
onThumbPointerDown: onScrollbarThumbPointerDown,
|
||||
} = useVerticalOverlayScrollbar(scrollerRef, scrollerContentRef, scrollbarTrackRef);
|
||||
// Set once a pre-PG10 server rejects the primary WAL functions, so subsequent
|
||||
// polls go straight to the legacy query instead of erroring every time.
|
||||
const fallbackStatusSql = ref<string | null>(null);
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const connectionName = computed(() => connectionStore.getConfig(props.connectionId)?.name ?? "");
|
||||
const latest = computed(() => samples.value[samples.value.length - 1]);
|
||||
const previous = computed(() => (samples.value.length >= 2 ? samples.value[samples.value.length - 2] : undefined));
|
||||
|
||||
function rate(key: string): number {
|
||||
const prev = previous.value;
|
||||
const curr = latest.value;
|
||||
return prev && curr ? computeRate(prev, curr, key) : 0;
|
||||
}
|
||||
|
||||
const tps = computed(() => {
|
||||
const prev = previous.value;
|
||||
const curr = latest.value;
|
||||
return prev && curr ? computePgTps(prev, curr) : 0;
|
||||
});
|
||||
|
||||
const maxConnections = computed(() => statusNumber(variables.value, "max_connections"));
|
||||
const serverVersion = computed(() => variables.value.version ?? "");
|
||||
const totalConnections = computed(() => (latest.value ? statusNumber(latest.value.status, "connections") : 0));
|
||||
const activeConnections = computed(() => (latest.value ? statusNumber(latest.value.status, "active_connections") : 0));
|
||||
const deadlocks = computed(() => (latest.value ? statusNumber(latest.value.status, "deadlocks") : 0));
|
||||
const tempFiles = computed(() => (latest.value ? statusNumber(latest.value.status, "temp_files") : 0));
|
||||
const uptimeSeconds = computed(() => (latest.value ? statusNumber(latest.value.status, "uptime_seconds") : 0));
|
||||
const cacheHit = computed(() => (latest.value ? pgCacheHitRatio(latest.value.status) : null));
|
||||
|
||||
// Rate series are computed between consecutive samples, so labels/data start at
|
||||
// the second sample.
|
||||
const chartLabels = computed(() => samples.value.slice(1).map((s) => formatClock(s.at)));
|
||||
|
||||
function rateSeries(key: string): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 1; i < samples.value.length; i++) {
|
||||
out.push(computeRate(samples.value[i - 1], samples.value[i], key));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sumSeries(a: number[], b: number[]): number[] {
|
||||
return a.map((value, i) => value + (b[i] ?? 0));
|
||||
}
|
||||
|
||||
const sessionsSeries = computed(() => [
|
||||
{ name: t("serverDashboard.total"), data: samples.value.slice(1).map((s) => statusNumber(s.status, "connections")), color: "#3b82f6" },
|
||||
{ name: t("serverDashboard.active"), data: samples.value.slice(1).map((s) => statusNumber(s.status, "active_connections")), color: "#a3e635" },
|
||||
{ name: t("serverDashboard.idle"), data: samples.value.slice(1).map((s) => statusNumber(s.status, "idle_connections")), color: "#ef4444" },
|
||||
]);
|
||||
|
||||
const transactionsSeries = computed(() => {
|
||||
const commit = rateSeries("xact_commit");
|
||||
const rollback = rateSeries("xact_rollback");
|
||||
return [
|
||||
{ name: t("serverDashboard.total"), data: sumSeries(commit, rollback), color: "#3b82f6" },
|
||||
{ name: t("serverDashboard.commit"), data: commit, color: "#a3e635" },
|
||||
{ name: t("serverDashboard.rollback"), data: rollback, color: "#ef4444" },
|
||||
];
|
||||
});
|
||||
|
||||
const tuplesInSeries = computed(() => [
|
||||
{ name: "INSERT", data: rateSeries("tup_inserted"), color: "#3b82f6" },
|
||||
{ name: "UPDATE", data: rateSeries("tup_updated"), color: "#a3e635" },
|
||||
{ name: "DELETE", data: rateSeries("tup_deleted"), color: "#ef4444" },
|
||||
]);
|
||||
|
||||
const tuplesOutSeries = computed(() => [
|
||||
{ name: t("serverDashboard.fetched"), data: rateSeries("tup_fetched"), color: "#3b82f6" },
|
||||
{ name: t("serverDashboard.returned"), data: rateSeries("tup_returned"), color: "#a3e635" },
|
||||
]);
|
||||
|
||||
const blockIoSeries = computed(() => [
|
||||
{ name: t("serverDashboard.read"), data: rateSeries("blks_read"), color: "#3b82f6" },
|
||||
{ name: t("serverDashboard.hits"), data: rateSeries("blks_hit"), color: "#a3e635" },
|
||||
]);
|
||||
|
||||
function formatClock(at: number): string {
|
||||
const d = new Date(at);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
async function fetchVariables() {
|
||||
try {
|
||||
const result = await api.executeQuery(props.connectionId, "", PG_VARIABLES_SQL, undefined, undefined, { maxRows: 2000 });
|
||||
variables.value = parsePgStatusRow(result);
|
||||
} catch {
|
||||
// Non-fatal: cards that depend on variables (max_connections/version) degrade.
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus(options: { silent?: boolean } = {}) {
|
||||
if (fetching.value) return;
|
||||
fetching.value = true;
|
||||
if (!options.silent) loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connectionId);
|
||||
const sql = fallbackStatusSql.value ?? PG_STATUS_SQL;
|
||||
let result;
|
||||
try {
|
||||
result = await api.executeQuery(props.connectionId, "", sql, undefined, undefined, { maxRows: 2000 });
|
||||
} catch (queryError) {
|
||||
if (fallbackStatusSql.value || !isPgStatusCompatibilityError(queryError)) throw queryError;
|
||||
result = await api.executeQuery(props.connectionId, "", PG_STATUS_LEGACY_SQL, undefined, undefined, { maxRows: 2000 });
|
||||
fallbackStatusSql.value = PG_STATUS_LEGACY_SQL;
|
||||
}
|
||||
const sample: StatusSample = { at: Date.now(), status: parsePgStatusRow(result) };
|
||||
const next = [...samples.value, sample];
|
||||
samples.value = next.length > MAX_SAMPLES ? next.slice(next.length - MAX_SAMPLES) : next;
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
fetching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh();
|
||||
if (autoRefreshInterval.value <= 0) return;
|
||||
refreshTimer = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
void fetchStatus({ silent: true });
|
||||
}, autoRefreshInterval.value * 1000);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onIntervalChange(value: unknown) {
|
||||
autoRefreshInterval.value = Number(value);
|
||||
startAutoRefresh();
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
await fetchStatus();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchStatus();
|
||||
if (!error.value) await fetchVariables();
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
onUnmounted(stopAutoRefresh);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col bg-background">
|
||||
<div class="flex h-11 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
|
||||
<Gauge class="h-4 w-4 text-primary" />
|
||||
<div class="truncate text-sm font-semibold">{{ t("serverDashboard.title") }}</div>
|
||||
<Badge variant="outline" class="h-5 rounded-md px-1.5 text-[11px]">{{ connectionName }}</Badge>
|
||||
<Badge v-if="serverVersion" variant="secondary" class="h-5 rounded-md px-1.5 text-[11px]">{{ serverVersion }}</Badge>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ t("serverDashboard.autoRefresh") }}</span>
|
||||
<Select :model-value="String(autoRefreshInterval)" @update:model-value="onIntervalChange">
|
||||
<SelectTrigger class="h-7 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">{{ t("serverDashboard.off") }}</SelectItem>
|
||||
<SelectItem value="1">1s</SelectItem>
|
||||
<SelectItem value="2">2s</SelectItem>
|
||||
<SelectItem value="5">5s</SelectItem>
|
||||
<SelectItem value="10">10s</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" :disabled="loading" @click="handleRefresh">
|
||||
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
|
||||
<RefreshCcw v-else class="h-3.5 w-3.5" />
|
||||
{{ t("grid.refresh") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="border-b bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ error }}</div>
|
||||
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div ref="scrollerRef" class="pg-dashboard-scroller h-full min-h-0 overflow-y-auto" @scroll.passive="onScrollerScroll">
|
||||
<div ref="scrollerContentRef" class="flex flex-col gap-3 p-3">
|
||||
<div class="grid shrink-0 grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<MetricCard :label="t('serverDashboard.tps')" :value="formatRate(tps)" :icon="Gauge" />
|
||||
<MetricCard :label="t('serverDashboard.connections')" :value="`${formatNumber(totalConnections)}${maxConnections ? ' / ' + formatNumber(maxConnections) : ''}`" :icon="Users" />
|
||||
<MetricCard :label="t('serverDashboard.activeQueries')" :value="formatNumber(activeConnections)" :icon="Activity" />
|
||||
<MetricCard :label="t('serverDashboard.cacheHit')" :value="cacheHit === null ? '—' : cacheHit.toFixed(2) + '%'" :icon="Database" />
|
||||
<MetricCard :label="t('serverDashboard.deadlocks')" :value="formatNumber(deadlocks)" :icon="TriangleAlert" />
|
||||
<MetricCard :label="t('serverDashboard.tempFiles')" :value="formatNumber(tempFiles)" :icon="ArrowDownUp" />
|
||||
<MetricCard :label="t('serverDashboard.uptime')" :value="formatUptime(uptimeSeconds)" :icon="Timer" />
|
||||
<MetricCard :label="t('serverDashboard.walRate')" :value="formatBytesPerSec(rate('wal_bytes'))" :icon="ArrowDownUp" />
|
||||
</div>
|
||||
|
||||
<div class="grid shrink-0 grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<MetricLineChart :title="t('serverDashboard.serverSessionsChart')" :labels="chartLabels" :series="sessionsSeries" :value-formatter="formatNumber" />
|
||||
<MetricLineChart :title="t('serverDashboard.blockIoChart')" :labels="chartLabels" :series="blockIoSeries" :value-formatter="formatRate" />
|
||||
<MetricLineChart :title="t('serverDashboard.tuplesInChart')" :labels="chartLabels" :series="tuplesInSeries" :value-formatter="formatRate" />
|
||||
<MetricLineChart :title="t('serverDashboard.tuplesOutChart')" :labels="chartLabels" :series="tuplesOutSeries" :value-formatter="formatRate" />
|
||||
<MetricLineChart class="xl:col-span-2" :title="t('serverDashboard.transactionsChart')" :labels="chartLabels" :series="transactionsSeries" :value-formatter="formatRate" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasScrollbarOverflow" ref="scrollbarTrackRef" class="pg-dashboard-scrollbar" :class="{ 'pg-dashboard-scrollbar--scrolling': isScrollbarScrolling, 'pg-dashboard-scrollbar--dragging': isScrollbarDragging }" @pointerdown="onScrollbarTrackPointerDown">
|
||||
<div class="pg-dashboard-scrollbar__thumb" :style="scrollbarThumbStyle" @pointerdown.stop="onScrollbarThumbPointerDown" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pg-dashboard-scroller {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.pg-dashboard-scroller::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.pg-dashboard-scrollbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
width: 12px;
|
||||
cursor: default;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.pg-dashboard-scrollbar--scrolling,
|
||||
.pg-dashboard-scrollbar:hover,
|
||||
.pg-dashboard-scrollbar--dragging {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pg-dashboard-scrollbar__thumb {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
width: 6px;
|
||||
min-height: 24px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklch, var(--foreground) 30%, transparent);
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
width 120ms ease,
|
||||
right 120ms ease;
|
||||
}
|
||||
|
||||
.pg-dashboard-scrollbar:hover .pg-dashboard-scrollbar__thumb,
|
||||
.pg-dashboard-scrollbar--dragging .pg-dashboard-scrollbar__thumb {
|
||||
right: 1px;
|
||||
width: 8px;
|
||||
background: color-mix(in oklch, var(--foreground) 48%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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
|
|||
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
|
||||
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
|
||||
<Activity v-else-if="tab.mode === 'processlist'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'mysql-dashboard'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<input
|
||||
|
|
@ -771,7 +771,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
|||
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
|
||||
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
|
||||
<Activity v-else-if="tab.mode === 'processlist'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'mysql-dashboard'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ const TableStructureEditor = defineAsyncComponent(() => 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
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'postgres-dashboard'">
|
||||
<div class="min-h-0 flex-1">
|
||||
<PostgresDashboard :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'dameng-jobs' && activeConnection">
|
||||
<DamengJobAdmin :key="activeTab.id" :connection="activeConnection" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement | null>(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<HTMLElement | null>, contentRef: Ref<HTMLElement | null>, trackRef: Ref<HTMLElement | null>) {
|
||||
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<typeof setTimeout> | 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<CSSProperties>(() => ({
|
||||
top: `${thumbTopPercent.value}%`,
|
||||
height: `${thumbHeightPercent.value}%`,
|
||||
}));
|
||||
|
||||
return { hasOverflow, isScrolling, isDragging, thumbStyle, onScroll, onTrackPointerDown, onThumbPointerDown };
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, string>): 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<DatabaseType>(["mysql"]);
|
||||
|
||||
export type StatusMap = Record<string, string>;
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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<string, string>;
|
||||
|
||||
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`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue