feat(postgres): add server dashboard metrics
This commit is contained in:
1 parent
3d23a58e36
commit
fc1f02a931
13 files changed
+1064
-126
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user