feat(mysql): add server metrics dashboard

This commit is contained in:
Bagus Wahyu Aprianto 2026-07-13 22:55:52 +07:00 committed by GitHub
parent c02b3967df
commit c9c2fddfe2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 812 additions and 2 deletions

View File

@ -0,0 +1,240 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Activity, ArrowDownUp, ChevronRight, Database, Gauge, Loader2, RefreshCcw, Search, 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 { computeQps, computeRate, formatBytes, formatBytesPerSec, formatNumber, formatUptime, GLOBAL_STATUS_SQL, GLOBAL_VARIABLES_SQL, innodbBufferHitRatio, MAX_SAMPLES, parseStatusResult, statusEntries, statusNumber, type StatusSample } from "@/lib/database/mysqlServerStatus";
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 statusSearch = ref("");
const showStatusTable = ref(true);
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 qps = computed(() => {
const prev = previous.value;
const curr = latest.value;
return prev && curr ? computeQps(prev, curr) : 0;
});
const maxConnections = computed(() => statusNumber(variables.value, "max_connections"));
const serverVersion = computed(() => variables.value.version ?? "");
const threadsConnected = computed(() => (latest.value ? statusNumber(latest.value.status, "Threads_connected") : 0));
const threadsRunning = computed(() => (latest.value ? statusNumber(latest.value.status, "Threads_running") : 0));
const slowQueries = computed(() => (latest.value ? statusNumber(latest.value.status, "Slow_queries") : 0));
const uptimeSeconds = computed(() => (latest.value ? statusNumber(latest.value.status, "Uptime") : 0));
const innodbHit = computed(() => (latest.value ? innodbBufferHitRatio(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;
}
const qpsSeries = computed(() => {
const out: number[] = [];
for (let i = 1; i < samples.value.length; i++) out.push(computeQps(samples.value[i - 1], samples.value[i]));
return [{ name: "QPS", data: out, color: "#3b82f6" }];
});
const trafficSeries = computed(() => [
{ name: t("serverDashboard.in"), data: rateSeries("Bytes_received"), color: "#3b82f6" },
{ name: t("serverDashboard.out"), data: rateSeries("Bytes_sent"), color: "#8b5cf6" },
]);
const commandSeries = computed(() => [
{ name: "SELECT", data: rateSeries("Com_select"), color: "#3b82f6" },
{ name: "INSERT", data: rateSeries("Com_insert"), color: "#22c55e" },
{ name: "UPDATE", data: rateSeries("Com_update"), color: "#f59e0b" },
{ name: "DELETE", data: rateSeries("Com_delete"), color: "#ef4444" },
]);
// New sessions per second the `Connections` status var is the cumulative count
// of connection attempts, so its rate is the sessions-established-per-second.
const sessionsSeries = computed(() => [{ name: t("serverDashboard.sessions"), data: rateSeries("Connections"), color: "#14b8a6" }]);
const statusRows = computed(() => {
if (!latest.value) return [];
const query = statusSearch.value.trim().toLowerCase();
const rows = statusEntries(latest.value.status);
if (!query) return rows;
return rows.filter((row) => row.name.toLowerCase().includes(query) || row.value.toLowerCase().includes(query));
});
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, "", GLOBAL_VARIABLES_SQL, undefined, undefined, { maxRows: 2000 });
variables.value = parseStatusResult(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 result = await api.executeQuery(props.connectionId, "", GLOBAL_STATUS_SQL, undefined, undefined, { maxRows: 2000 });
const sample: StatusSample = { at: Date.now(), status: parseStatusResult(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="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="formatNumber(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="formatNumber" />
<MetricLineChart :title="t('serverDashboard.sessionsChart')" :labels="chartLabels" :series="sessionsSeries" :value-formatter="formatNumber" />
<MetricLineChart :title="t('serverDashboard.trafficChart')" :labels="chartLabels" :series="trafficSeries" :value-formatter="formatBytes" />
<MetricLineChart :title="t('serverDashboard.commandChart')" :labels="chartLabels" :series="commandSeries" :value-formatter="formatNumber" />
</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>
<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>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from "vue";
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { LineChart } from "echarts/charts";
import { GridComponent, TooltipComponent, LegendComponent } from "echarts/components";
import VChart from "vue-echarts";
import { useTheme } from "@/composables/useTheme";
// Reuses the piecewise ECharts registration pattern from QueryChart.vue, but a
// dedicated time-series line chart for live metrics (no bar/pie, no result prop).
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent]);
interface Series {
name: string;
data: number[];
color?: string;
}
const props = defineProps<{
title: string;
labels: string[];
series: Series[];
height?: number;
valueFormatter?: (value: number) => string;
}>();
const { isDark } = useTheme();
const axisColor = computed(() => (isDark.value ? "#3f3f46" : "#e4e4e7"));
const textColor = computed(() => (isDark.value ? "#a1a1aa" : "#71717a"));
const chartOption = computed(() => {
const format = props.valueFormatter ?? ((v: number) => String(Math.round(v)));
return {
animation: false,
grid: { left: 8, right: 12, top: 28, bottom: 8, containLabel: true },
legend: props.series.length > 1 ? { top: 0, textStyle: { color: textColor.value, fontSize: 11 }, itemHeight: 8, itemWidth: 12 } : undefined,
tooltip: {
trigger: "axis",
valueFormatter: (value: number) => format(value),
},
xAxis: {
type: "category",
data: props.labels,
boundaryGap: false,
axisLine: { lineStyle: { color: axisColor.value } },
axisLabel: { color: textColor.value, fontSize: 10 },
},
yAxis: {
type: "value",
axisLabel: { color: textColor.value, fontSize: 10, formatter: (value: number) => format(value) },
splitLine: { lineStyle: { color: axisColor.value } },
},
series: props.series.map((s) => ({
name: s.name,
type: "line",
data: s.data,
smooth: true,
showSymbol: false,
lineStyle: s.color ? { color: s.color, width: 2 } : { width: 2 },
itemStyle: s.color ? { color: s.color } : undefined,
areaStyle: props.series.length === 1 ? { opacity: 0.12 } : undefined,
})),
};
});
</script>
<template>
<div class="flex flex-col rounded-lg border bg-card p-3">
<div class="mb-1 text-xs font-medium text-muted-foreground">{{ title }}</div>
<VChart :option="chartOption" autoresize :style="{ height: `${height ?? 160}px` }" />
</div>
</template>

View File

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { Component } from "vue";
defineProps<{
label: string;
value: string;
sub?: string;
icon?: Component;
}>();
</script>
<template>
<div class="flex flex-col gap-1 rounded-lg border bg-card p-3 text-card-foreground">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
<component :is="icon" v-if="icon" class="h-3.5 w-3.5" />
<span class="truncate">{{ label }}</span>
</div>
<div class="text-xl font-semibold tabular-nums">{{ value }}</div>
<div v-if="sub" class="truncate text-[11px] text-muted-foreground">{{ sub }}</div>
</div>
</template>

View File

@ -2,7 +2,7 @@
import { computed, ref, watch, nextTick, onUnmounted } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity } from "@lucide/vue";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge } from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -451,6 +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;
return Code2;
}
@ -586,6 +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" />
<Code2 v-else class="h-3.5 w-3.5" />
</span>
<input
@ -769,6 +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" />
<Code2 v-else class="h-3.5 w-3.5" />
</span>
<input

View File

@ -55,6 +55,7 @@ const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/Ob
const TableStructureEditor = defineAsyncComponent(() => import("@/components/structure/TableStructureEditor.vue"));
const DatabaseUserAdmin = defineAsyncComponent(() => import("@/components/admin/DatabaseUserAdmin.vue"));
const MySqlProcessList = defineAsyncComponent(() => import("@/components/admin/MySqlProcessList.vue"));
const MySqlDashboard = defineAsyncComponent(() => import("@/components/admin/MySqlDashboard.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"));
@ -1589,6 +1590,12 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
<MySqlProcessList :key="activeTab.id" :connection="activeConnection" />
</template>
<template v-else-if="activeTab.mode === 'mysql-dashboard'">
<div class="min-h-0 flex-1">
<MySqlDashboard :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>

View File

@ -51,6 +51,7 @@ import {
Check,
UsersRound,
Activity,
Gauge,
CalendarClock,
Lock,
HardDriveDownload,
@ -146,6 +147,7 @@ import { selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes, treeSelectio
import { connectionPasteTargetGroupId, selectedConnectionClipboardTargets, selectedConnectionDeleteTargets, selectedConnectionDuplicateTargets, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection";
import { supportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin";
import { supportsProcessList } from "@/lib/database/mysqlProcessList";
import { connectionSupportsServerDashboard } from "@/lib/database/mysqlServerStatus";
import { canCloseSidebarDatabaseConnection, isSidebarDatabaseOpened } from "@/lib/sidebar/sidebarDatabaseOpenState";
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import { batchTableEmptyFeedback, runBatchTableEmpty } from "@/lib/sidebar/batchTableEmpty";
@ -1167,6 +1169,18 @@ async function openProcessList() {
}
}
async function openMysqlDashboard() {
const node = props.node;
if (!node.connectionId) return;
try {
await connectionStore.ensureConnected(node.connectionId);
connectionStore.activeConnectionId = node.connectionId;
queryStore.openMysqlDashboard(node.connectionId);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
}
}
async function openDamengJobAdmin() {
const node = props.node;
if (!node.connectionId) return;
@ -4714,6 +4728,9 @@ function treeItemMenuItems(): ContextMenuItem[] {
if (supportsProcessList(currentDatabaseType())) {
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 (currentDatabaseType() === "dameng") {
items.push({ label: t("contextMenu.damengJobAdmin"), action: openDamengJobAdmin, icon: CalendarClock });
}

View File

@ -1403,6 +1403,7 @@ export default {
userAdmin: "Users & Privileges",
openUserAdmin: "Open Users & Privileges",
processList: "Process List",
serverDashboard: "Server Dashboard",
damengJobAdmin: "Dameng Agent Jobs",
openDamengJobAdmin: "Open Dameng Agent Jobs",
duplicateConnection: "Duplicate Connection",
@ -1723,6 +1724,28 @@ export default {
killSuccess: "Session {id} killed",
killFailed: "Failed to kill session: {message}",
},
serverDashboard: {
title: "Server Dashboard",
autoRefresh: "Auto-refresh",
off: "Off",
qps: "Queries / sec",
connections: "Connections",
running: "Threads running",
trafficIn: "Traffic in",
trafficOut: "Traffic out",
slowQueries: "Slow queries",
uptime: "Uptime",
innodbHit: "InnoDB hit rate",
in: "In",
out: "Out",
sessions: "Sessions",
qpsChart: "Queries per second",
sessionsChart: "Sessions per second",
trafficChart: "Network traffic /s",
commandChart: "Commands per second",
rawStatus: "Status variables",
filterStatus: "Filter status variables",
},
userAdmin: {
title: "Users & Privileges",
unsupported: "MySQL-compatible and PostgreSQL-compatible connections are supported. SQL Server, Oracle, and other permission models can be added next.",

View File

@ -1578,6 +1578,7 @@ export default withEnglishFallback({
manageExtension: "Administrar extensión...",
dropExtension: "Eliminar extensión",
processList: "Lista de procesos",
serverDashboard: "Panel de control del servidor",
},
visibleDatabases: {
title: "Bases de datos visibles",
@ -3866,4 +3867,26 @@ export default withEnglishFallback({
killSuccess: "Sesión {id} terminada",
killFailed: "Error al terminar la sesión: {message}",
},
serverDashboard: {
title: "Panel de control del servidor",
autoRefresh: "Actualización automática",
off: "Desactivado",
qps: "Consultas por segundo",
connections: "Conexiones",
running: "Hilos en ejecución",
trafficIn: "Tráfico entrante",
trafficOut: "Tráfico saliente",
slowQueries: "Consultas lentas",
uptime: "Tiempo de actividad",
innodbHit: "Tasa de aciertos de InnoDB",
in: "Entrante",
out: "Saliente",
sessions: "Sesiones",
qpsChart: "Consultas por segundo",
sessionsChart: "Nuevas sesiones por segundo",
trafficChart: "Tráfico de red por segundo",
commandChart: "Comandos por segundo",
rawStatus: "Variables de estado",
filterStatus: "Filtrar variables de estado",
},
});

View File

@ -1576,6 +1576,7 @@ export default withEnglishFallback({
manageExtension: "Gestisci estensione...",
dropExtension: "Elimina estensione",
processList: "Elenco processi",
serverDashboard: "Dashboard del server",
},
visibleDatabases: {
title: "Database Visibili",
@ -3864,4 +3865,26 @@ export default withEnglishFallback({
killSuccess: "Sessione {id} terminata",
killFailed: "Terminazione sessione non riuscita: {message}",
},
serverDashboard: {
title: "Dashboard del server",
autoRefresh: "Aggiornamento automatico",
off: "Spento",
qps: "Query al secondo",
connections: "Connessioni",
running: "Thread in esecuzione",
trafficIn: "Traffico in ingresso",
trafficOut: "Traffico in uscita",
slowQueries: "Query lente",
uptime: "Uptime",
innodbHit: "Percentuale di hit InnoDB",
in: "In",
out: "Out",
sessions: "Sessioni",
qpsChart: "Query al secondo",
sessionsChart: "Nuove sessioni al secondo",
trafficChart: "Traffico di rete al secondo",
commandChart: "Comandi al secondo",
rawStatus: "Variabili di stato",
filterStatus: "Filtra variabili di stato",
},
});

View File

@ -1577,6 +1577,7 @@ export default withEnglishFallback({
manageExtension: "拡張機能を管理...",
dropExtension: "拡張機能を削除",
processList: "プロセス一覧",
serverDashboard: "サーバーダッシュボード",
},
visibleDatabases: {
title: "表示するデータベース",
@ -3865,4 +3866,26 @@ export default withEnglishFallback({
killSuccess: "セッション {id} を強制終了しました。",
killFailed: "セッションの強制終了に失敗しました:{message}",
},
serverDashboard: {
title: "サーバーダッシュボード",
autoRefresh: "自動更新",
off: "オフ",
qps: "毎秒クエリ数",
connections: "接続数",
running: "実行スレッド数",
trafficIn: "受信トラフィック",
trafficOut: "送信トラフィック",
slowQueries: "スロークエリ",
uptime: "稼働時間",
innodbHit: "InnoDBヒット率",
in: "受信",
out: "送信",
sessions: "セッション",
qpsChart: "毎秒クエリ数",
sessionsChart: "毎秒新規セッション数",
trafficChart: "毎秒ネットワークトラフィック",
commandChart: "毎秒コマンド数",
rawStatus: "ステータス変数",
filterStatus: "ステータス変数の絞り込み",
},
});

View File

@ -1578,6 +1578,7 @@ export default withEnglishFallback({
manageExtension: "Gerenciar extensão...",
dropExtension: "Remover extensão",
processList: "Lista de Processos",
serverDashboard: "Painel do Servidor",
},
visibleDatabases: {
title: "Bancos de dados visíveis",
@ -3866,4 +3867,26 @@ export default withEnglishFallback({
killSuccess: "Sessão {id} encerrada.",
killFailed: "Falha ao encerrar a sessão: {message}",
},
serverDashboard: {
title: "Painel do Servidor",
autoRefresh: "Atualização automática",
off: "Desligado",
qps: "Consultas por segundo",
connections: "Conexões",
running: "Threads em execução",
trafficIn: "Tráfego de entrada",
trafficOut: "Tráfego de saída",
slowQueries: "Consultas lentas",
uptime: "Tempo de atividade",
innodbHit: "Taxa de acertos do InnoDB",
in: "Entrada",
out: "Saída",
sessions: "Sessões",
qpsChart: "Consultas por segundo",
sessionsChart: "Novas sessões por segundo",
trafficChart: "Tráfego de rede por segundo",
commandChart: "Comandos por segundo",
rawStatus: "Variáveis de estado",
filterStatus: "Filtrar variáveis de estado",
},
});

View File

@ -1406,6 +1406,7 @@ export default withEnglishFallback({
userAdmin: "用户与权限",
openUserAdmin: "打开用户与权限",
processList: "进程列表",
serverDashboard: "服务器仪表盘",
damengJobAdmin: "达梦代理作业",
openDamengJobAdmin: "打开达梦代理作业",
newQuery: "新建查询",
@ -1722,6 +1723,28 @@ export default withEnglishFallback({
killSuccess: "已终止会话 {id}",
killFailed: "终止会话失败:{message}",
},
serverDashboard: {
title: "服务器仪表盘",
autoRefresh: "自动刷新",
off: "关闭",
qps: "每秒查询数",
connections: "连接数",
running: "运行线程",
trafficIn: "入流量",
trafficOut: "出流量",
slowQueries: "慢查询",
uptime: "运行时长",
innodbHit: "InnoDB 命中率",
in: "入",
out: "出",
sessions: "会话",
qpsChart: "每秒查询数",
sessionsChart: "每秒新建会话",
trafficChart: "每秒网络流量",
commandChart: "每秒命令数",
rawStatus: "状态变量",
filterStatus: "筛选状态变量",
},
userAdmin: {
title: "用户与权限",
unsupported: "当前支持 MySQL 兼容与 PostgreSQL 兼容连接。SQL Server、Oracle 等会按各自权限模型继续扩展。",

View File

@ -1577,6 +1577,7 @@ export default withEnglishFallback({
manageExtension: "管理擴展...",
dropExtension: "刪除擴展",
processList: "處理程序清單",
serverDashboard: "伺服器儀表板",
},
visibleDatabases: {
title: "顯示資料庫",
@ -3865,4 +3866,26 @@ export default withEnglishFallback({
killSuccess: "已終止會話 {id}",
killFailed: "終止會話失敗:{message}",
},
serverDashboard: {
title: "伺服器儀表板",
autoRefresh: "自動重新整理",
off: "關閉",
qps: "每秒查詢數",
connections: "連線數",
running: "執行中的執行緒",
trafficIn: "傳入流量",
trafficOut: "傳出流量",
slowQueries: "慢查詢",
uptime: "運行時間",
innodbHit: "InnoDB 命中率",
in: "入",
out: "出",
sessions: "工作階段",
qpsChart: "每秒查詢數",
sessionsChart: "每秒新工作階段",
trafficChart: "每秒網路流量",
commandChart: "每秒命令數",
rawStatus: "狀態變數",
filterStatus: "篩選狀態變數",
},
});

View File

@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import type { QueryResult } from "@/types/database";
import { computeQps, computeRate, connectionSupportsServerDashboard, formatBytes, formatBytesPerSec, formatUptime, innodbBufferHitRatio, parseStatusResult, statusNumber, supportsServerDashboard, type StatusSample } from "@/lib/database/mysqlServerStatus";
function statusResult(rows: [string, string][]): QueryResult {
return { columns: ["Variable_name", "Value"], rows, affected_rows: 0, execution_time_ms: 0 };
}
function sample(at: number, status: Record<string, string>): StatusSample {
return { at, status };
}
describe("parseStatusResult", () => {
it("parses two-column status into a map", () => {
const map = parseStatusResult(
statusResult([
["Threads_connected", "12"],
["Uptime", "3600"],
]),
);
expect(map).toEqual({ Threads_connected: "12", Uptime: "3600" });
});
it("returns empty map for malformed input", () => {
expect(parseStatusResult(null)).toEqual({});
expect(parseStatusResult({ columns: [], rows: [], affected_rows: 0, execution_time_ms: 0 })).toEqual({});
});
});
describe("statusNumber", () => {
it("reads numeric values and defaults to 0", () => {
expect(statusNumber({ Questions: "500" }, "Questions")).toBe(500);
expect(statusNumber({}, "Missing")).toBe(0);
expect(statusNumber({ X: "abc" }, "X")).toBe(0);
});
});
describe("computeRate", () => {
it("computes per-second delta", () => {
const prev = sample(1000, { Bytes_sent: "1000" });
const curr = sample(3000, { Bytes_sent: "5000" });
expect(computeRate(prev, curr, "Bytes_sent")).toBe(2000); // 4000 bytes / 2s
});
it("returns 0 on counter reset (decrease)", () => {
const prev = sample(1000, { Queries: "9000" });
const curr = sample(2000, { Queries: "10" });
expect(computeRate(prev, curr, "Queries")).toBe(0);
});
it("returns 0 for non-positive time delta", () => {
const prev = sample(2000, { Queries: "10" });
const curr = sample(2000, { Queries: "20" });
expect(computeRate(prev, curr, "Queries")).toBe(0);
});
});
describe("computeQps", () => {
it("prefers Queries and falls back to Questions", () => {
const withQueries = computeQps(sample(0, { Queries: "0", Questions: "0" }), sample(1000, { Queries: "100", Questions: "40" }));
expect(withQueries).toBe(100);
const withoutQueries = computeQps(sample(0, { Questions: "0" }), sample(1000, { Questions: "40" }));
expect(withoutQueries).toBe(40);
});
});
describe("innodbBufferHitRatio", () => {
it("computes hit ratio as a percentage", () => {
expect(innodbBufferHitRatio({ Innodb_buffer_pool_read_requests: "1000", Innodb_buffer_pool_reads: "3" })).toBeCloseTo(99.7, 1);
});
it("returns null when counters are absent", () => {
expect(innodbBufferHitRatio({})).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("supportsServerDashboard", () => {
it("is true for MySQL (incl. MariaDB/TiDB via mysql dbType) only", () => {
expect(supportsServerDashboard("mysql")).toBe(true);
// OLAP forks lack MySQL status counters — dashboard would be empty.
expect(supportsServerDashboard("doris")).toBe(false);
expect(supportsServerDashboard("starrocks")).toBe(false);
expect(supportsServerDashboard("goldendb")).toBe(false);
expect(supportsServerDashboard("postgres")).toBe(false);
expect(supportsServerDashboard(undefined)).toBe(false);
});
it("rejects JDBC profiles that only use the MySQL-compatible dialect", () => {
expect(connectionSupportsServerDashboard({ id: "mysql", name: "MySQL", db_type: "mysql" } as any)).toBe(true);
expect(connectionSupportsServerDashboard({ id: "jdbc-mysql", name: "JDBC MySQL", db_type: "jdbc", connection_string: "jdbc:mysql://localhost/db" } as any)).toBe(true);
expect(connectionSupportsServerDashboard({ id: "kyuubi", name: "Kyuubi", db_type: "jdbc", driver_profile: "kyuubi", connection_string: "jdbc:hive2://localhost/default" } as any)).toBe(false);
expect(connectionSupportsServerDashboard({ id: "hive", name: "Hive", db_type: "jdbc", jdbc_driver_class: "org.apache.hive.jdbc.HiveDriver" } as any)).toBe(false);
});
});

View File

@ -0,0 +1,151 @@
import type { ConnectionConfig, DatabaseType, QueryResult } from "@/types/database";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
/**
* MySQL 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.
*
* 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.
*/
export const GLOBAL_STATUS_SQL = "SHOW GLOBAL STATUS";
export const GLOBAL_VARIABLES_SQL = "SHOW GLOBAL VARIABLES";
/** Max samples retained for the live charts (~ a few minutes at 5s cadence). */
export const MAX_SAMPLES = 60;
/**
* Engines with meaningful MySQL server-status counters (`SHOW GLOBAL STATUS`:
* QPS, Com_*, Bytes_*, InnoDB). MariaDB, TiDB, and OceanBase ride the `mysql`
* dbType via a driver profile, so they are covered by the `"mysql"` entry.
*
* Deliberately excludes the OLAP MySQL-protocol forks (Doris, StarRocks) they
* have no InnoDB and do not expose these status counters, so the dashboard would
* be empty there. GoldenDB is excluded pending confirmation its proxy passes the
* counters through.
*/
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 = {};
if (!result || !Array.isArray(result.columns) || !Array.isArray(result.rows)) return map;
const nameIdx = result.columns.findIndex((c) => c.toLowerCase() === "variable_name");
const valueIdx = result.columns.findIndex((c) => c.toLowerCase() === "value");
const nameCol = nameIdx >= 0 ? nameIdx : 0;
const valueCol = valueIdx >= 0 ? valueIdx : 1;
for (const row of result.rows) {
const name = row[nameCol];
if (name === null || name === undefined) continue;
map[String(name)] = row[valueCol] === null || row[valueCol] === undefined ? "" : String(row[valueCol]);
}
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;
return computeRate(prev, curr, hasQueries ? "Queries" : "Questions");
}
/**
* InnoDB buffer pool hit ratio (0-100) from cumulative reads vs read requests.
* Returns null when the counters are unavailable (non-InnoDB / forks).
*/
export function innodbBufferHitRatio(status: StatusMap): number | null {
const requests = statusNumber(status, "Innodb_buffer_pool_read_requests");
if (requests <= 0) return null;
const reads = statusNumber(status, "Innodb_buffer_pool_reads");
const ratio = (1 - reads / requests) * 100;
if (!Number.isFinite(ratio)) return 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 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);
}
/** Prevent JDBC profiles that only borrow MySQL SQL syntax from exposing MySQL server administration queries. */
export function connectionSupportsServerDashboard(connection: ConnectionConfig | undefined): boolean {
if (!connection || !supportsServerDashboard(effectiveDatabaseTypeForConnection(connection))) return false;
if (connection.db_type !== "jdbc") return true;
const profile = [connection.driver_profile, connection.connection_string, connection.jdbc_driver_class, ...(connection.jdbc_driver_paths ?? [])].filter(Boolean).join("\n");
return !/(?:kyuubi|hive2|org\.apache\.hive\.jdbc\.HiveDriver|hive-jdbc)/i.test(profile);
}

View File

@ -1025,6 +1025,31 @@ export const useQueryStore = defineStore("query", () => {
return id;
}
function openMysqlDashboard(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "mysql-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: "mysql-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) {
@ -3860,6 +3885,7 @@ export const useQueryStore = defineStore("query", () => {
openMongoBucket,
openUserAdmin,
openProcessList,
openMysqlDashboard,
openDamengJobAdmin,
openMqAdmin,
openNacosAdmin,

View File

@ -779,7 +779,7 @@ export interface QueryTab {
explainExecutionId?: string;
/** Per-run connection session for sequential MySQL explain formats. */
explainClientSessionId?: string;
mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "objects" | "structure" | "users" | "dameng-jobs" | "processlist";
mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "objects" | "structure" | "users" | "dameng-jobs" | "processlist" | "mysql-dashboard";
mqTenant?: string;
mqInitialTab?: "topics";
nacosNamespace?: string;