feat(xugu): add read-only server monitoring dashboard

This commit is contained in:
Elias
2026-09-06 10:57:08 +08:00
committed by t8y2
parent d6c4fa1aff
commit 3e82d6d650
9 changed files with 1153 additions and 1 deletions
@@ -0,0 +1,541 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import { Activity, AlertTriangle, Database, Gauge, HardDrive, Loader2, RefreshCcw, Server, Users } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useConnectionStore } from "@/stores/connectionStore";
import * as api from "@/lib/backend/api";
import {
XUGU_ACTIVE_SESSION_SQL,
XUGU_CLUSTER_NODES_SQL,
XUGU_LOCK_MODE_SUMMARY_SQL,
XUGU_LOCK_WAITS_SQL,
XUGU_MEMORY_STATUS_SQL,
XUGU_RUN_INFO_SQL,
XUGU_SESSION_SUMMARY_SQL,
XUGU_SESSION_STATUS_SUMMARY_SQL,
XUGU_TABLESPACE_SUMMARY_SQL,
XUGU_TRANSACTION_SUMMARY_SQL,
XUGU_VERSION_SQL,
connectionSupportsXuguServerDashboard,
xuguClusterNodeStateLabel,
xuguClusterNodeTypeLabels,
xuguClusterNodesFromResult,
xuguLockModeSummaryFromResult,
xuguMemoryStatusFromResult,
xuguRunInfoFromResult,
xuguScalarFromResult,
xuguSessionSummaryFromResult,
xuguSessionStatusSummaryFromResult,
xuguTablespaceSummaryFromResult,
xuguTransactionSummaryFromResult,
xuguVersionFromResult,
type XuguClusterNode,
type XuguLockModeSummary,
type XuguMemoryStatus,
type XuguRunInfo,
type XuguSessionSummary,
type XuguSessionStatusSummary,
type XuguTablespaceSummary,
type XuguTransactionSummary,
} from "@/lib/database/xuguServerStatus";
const props = defineProps<{ connectionId: string }>();
const { t } = useI18n();
const connectionStore = useConnectionStore();
const loading = ref(false);
const error = ref("");
const partialErrors = ref<string[]>([]);
const version = ref("");
const nodes = ref<XuguClusterNode[]>([]);
const runInfo = ref<XuguRunInfo[]>([]);
const sessions = ref<XuguSessionSummary[]>([]);
const transactions = ref<XuguTransactionSummary[]>([]);
const memory = ref<XuguMemoryStatus[]>([]);
const sessionStatuses = ref<XuguSessionStatusSummary[]>([]);
const lockModes = ref<XuguLockModeSummary[]>([]);
const tablespaces = ref<XuguTablespaceSummary[]>([]);
const lockWaits = ref("");
const lockOwners = ref("");
const availability = reactive({
version: false,
nodes: false,
runInfo: false,
sessions: false,
activeSessions: false,
transactions: false,
lockWaits: false,
memory: false,
sessionStatuses: false,
lockModes: false,
tablespaces: false,
});
const refreshSeconds = ref(5);
let refreshTimer: ReturnType<typeof setInterval> | null = null;
const connection = computed(() => connectionStore.getConfig(props.connectionId));
const connectionName = computed(() => connection.value?.name ?? "");
const supported = computed(() => connectionSupportsXuguServerDashboard(connection.value));
const runInfoByNode = computed(() => new Map(runInfo.value.map((entry) => [entry.nodeId, entry])));
const sessionsByNode = computed(() => new Map(sessions.value.map((entry) => [entry.nodeId, entry])));
const transactionsByNode = computed(() => new Map(transactions.value.map((entry) => [entry.nodeId, entry])));
const onlineNodes = computed<number | null>(() => (availability.nodes ? nodes.value.filter((node) => xuguClusterNodeStateLabel(node.state) === "running").length : null));
const totalSessions = computed<number | null>(() => (availability.sessions ? sessions.value.reduce((sum, row) => sum + numeric(row.sessions), 0) : null));
const activeSessions = computed<number | null>(() => (availability.activeSessions ? sessions.value.reduce((sum, row) => sum + numeric(row.activeSessions), 0) : null));
const activeTransactions = computed<number | null>(() => {
if (!availability.transactions && !availability.runInfo) return null;
const values = transactions.value.length > 0 ? transactions.value.map((row) => row.activeTransactions) : runInfo.value.map((row) => row.activeTransactions);
return values.reduce((sum, value) => sum + numeric(value), 0);
});
const bufferTotalBytes = computed(() => (availability.memory ? sum(memory.value, "bufferTotalBytes") : null));
const bufferFreeBytes = computed(() => (availability.memory ? sum(memory.value, "bufferFreeBytes") : null));
const bufferDirtyBytes = computed(() => (availability.memory ? sum(memory.value, "bufferDirtyBytes") : null));
const sgaTotalBytes = computed(() => (availability.memory ? sum(memory.value, "sgaTotalBytes") : null));
const sgaFreeBytes = computed(() => (availability.memory ? sum(memory.value, "sgaFreeBytes") : null));
const swapTotalBytes = computed(() => (availability.memory ? sum(memory.value, "swapTotalBytes") : null));
const swapFreeBytes = computed(() => (availability.memory ? sum(memory.value, "swapFreeBytes") : null));
const diskReadBytes = computed(() => (availability.runInfo ? sum(runInfo.value, "diskReadBytes") : null));
const diskWriteBytes = computed(() => (availability.runInfo ? sum(runInfo.value, "diskWriteBytes") : null));
const freeStores = computed(() => (availability.runInfo ? sum(runInfo.value, "freeStores") : null));
const transactionSpan = computed<number | null>(() => (availability.runInfo ? runInfo.value.reduce((total, entry) => total + Math.max(0, numeric(entry.maxTransactionId) - numeric(entry.minTransactionId)), 0) : null));
const walBacklog = computed<number | null>(() => (availability.runInfo ? runInfo.value.reduce((total, entry) => total + Math.max(0, numeric(entry.xlogWritePosition) - numeric(entry.xlogCheckpointPosition)), 0) : null));
const totalDatafiles = computed(() => (availability.tablespaces ? sum(tablespaces.value, "datafiles") : null));
const mediaErrors = computed<number | null>(() => (availability.tablespaces ? tablespaces.value.filter((entry) => ["1", "TRUE", "Y"].includes(entry.mediaError.trim().toUpperCase())).length : null));
const dataOrTempFreeBytes = computed<number | null>(() => (availability.tablespaces ? tablespaces.value.filter((entry) => /DATA|TEMP/i.test(entry.spaceType)).reduce((total, entry) => total + numeric(entry.freeBytes), 0) : null));
function numeric(value: string): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function sum<T extends Record<string, string>>(entries: T[], key: keyof T): number {
return entries.reduce((total, entry) => total + numeric(entry[key]), 0);
}
function percentage(total: number | null, available: number | null): number {
if (total === null || available === null) return 0;
if (total <= 0) return 0;
return Math.max(0, Math.min(100, ((total - available) / total) * 100));
}
function formatBytes(value: number): string {
if (!Number.isFinite(value) || value < 0) return "—";
if (value === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
const scaled = value / 1024 ** index;
return `${scaled >= 100 || index === 0 ? scaled.toFixed(0) : scaled.toFixed(1)} ${units[index]}`;
}
function sessionStatusCount(status: "idle" | "executing"): number | null {
if (!availability.sessionStatuses) return null;
return sessionStatuses.value
.filter((entry) => {
const normalized = Math.abs(numeric(entry.status)) % 1000;
return status === "idle" ? normalized === 112 : normalized === 114;
})
.reduce((total, entry) => total + numeric(entry.sessions), 0);
}
function otherSessionCount(): number | null {
if (totalSessions.value === null || !availability.sessionStatuses) return null;
const idle = sessionStatusCount("idle");
const executing = sessionStatusCount("executing");
if (idle === null || executing === null) return null;
return Math.max(0, totalSessions.value - idle - executing);
}
function lockModeCount(lockMode: string): number | null {
if (!availability.lockModes) return null;
return lockModes.value.filter((entry) => entry.lockMode.toUpperCase() === lockMode).reduce((total, entry) => total + numeric(entry.locks), 0);
}
function nodeStateClass(node: XuguClusterNode): string {
const state = xuguClusterNodeStateLabel(node.state);
if (state === "running") return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
if (state === "error" || state === "offline") return "border-destructive/30 bg-destructive/10 text-destructive";
return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
}
function nodeStateText(node: XuguClusterNode): string {
return t(`xuguServerDashboard.nodeState.${xuguClusterNodeStateLabel(node.state)}`);
}
function nodeTypeText(node: XuguClusterNode): string {
const labels = xuguClusterNodeTypeLabels(node.nodeType);
return labels.length > 0 ? labels.map((role) => t(`xuguServerDashboard.nodeType.${role}`)).join(" / ") : t("xuguServerDashboard.nodeType.unknown");
}
function valueFor<T extends Record<string, string>>(map: Map<string, T>, nodeId: string, key: keyof T): string {
return map.get(nodeId)?.[key] || "—";
}
async function query(sql: string, maxRows: number) {
return api.executeQuery(props.connectionId, "", sql, undefined, undefined, { maxRows });
}
async function load() {
if (!supported.value) return;
loading.value = true;
error.value = "";
partialErrors.value = [];
try {
await connectionStore.ensureConnected(props.connectionId);
const requests = [
["version", XUGU_VERSION_SQL, 1],
["nodes", XUGU_CLUSTER_NODES_SQL, 500],
["runInfo", XUGU_RUN_INFO_SQL, 500],
["sessions", XUGU_SESSION_SUMMARY_SQL, 500],
["activeSessions", XUGU_ACTIVE_SESSION_SQL, 500],
["transactions", XUGU_TRANSACTION_SUMMARY_SQL, 500],
["lockWaits", XUGU_LOCK_WAITS_SQL, 1],
["memory", XUGU_MEMORY_STATUS_SQL, 500],
["sessionStatuses", XUGU_SESSION_STATUS_SUMMARY_SQL, 500],
["lockModes", XUGU_LOCK_MODE_SUMMARY_SQL, 500],
["tablespaces", XUGU_TABLESPACE_SUMMARY_SQL, 500],
] as const;
const results = await Promise.allSettled(requests.map(([, sql, maxRows]) => query(sql, maxRows)));
const failed = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
partialErrors.value = failed.map((result) => result.reason?.message || String(result.reason));
const coreFailed = results.slice(0, 3).every((result) => result.status === "rejected");
if (coreFailed) {
throw failed[0]?.reason ?? new Error(t("xuguServerDashboard.loadFailed"));
}
const [versionResult, nodeResult, runInfoResult, sessionResult, activeSessionResult, transactionResult, lockResult, memoryResult, sessionStatusResult, lockModeResult, tablespaceResult] = results;
availability.version = versionResult.status === "fulfilled";
availability.nodes = nodeResult.status === "fulfilled";
availability.runInfo = runInfoResult.status === "fulfilled";
availability.sessions = sessionResult.status === "fulfilled";
availability.activeSessions = activeSessionResult.status === "fulfilled";
availability.transactions = transactionResult.status === "fulfilled";
availability.lockWaits = lockResult.status === "fulfilled";
availability.memory = memoryResult.status === "fulfilled";
availability.sessionStatuses = sessionStatusResult.status === "fulfilled";
availability.lockModes = lockModeResult.status === "fulfilled";
availability.tablespaces = tablespaceResult.status === "fulfilled";
if (versionResult.status === "fulfilled") version.value = xuguVersionFromResult(versionResult.value);
else version.value = "";
if (nodeResult.status === "fulfilled") nodes.value = xuguClusterNodesFromResult(nodeResult.value);
else nodes.value = [];
if (runInfoResult.status === "fulfilled") runInfo.value = xuguRunInfoFromResult(runInfoResult.value);
else runInfo.value = [];
const activeByNode = activeSessionResult.status === "fulfilled" ? new Map(xuguSessionSummaryFromResult(activeSessionResult.value).map((row) => [row.nodeId, row])) : new Map();
// 活跃会话查询整体失败时不能把缺失显示成硬 0,那会读成"无活跃会话"
const activeSessionsUnavailable = activeSessionResult.status !== "fulfilled";
sessions.value =
sessionResult.status === "fulfilled"
? xuguSessionSummaryFromResult(sessionResult.value).map((row) => {
const active = activeByNode.get(row.nodeId);
return { ...row, activeSessions: active?.activeSessions ?? (activeSessionsUnavailable ? "—" : "0"), oldestStatement: active?.oldestStatement ?? "" };
})
: [];
if (transactionResult.status === "fulfilled") transactions.value = xuguTransactionSummaryFromResult(transactionResult.value);
else transactions.value = [];
if (lockResult.status === "fulfilled") {
lockWaits.value = xuguScalarFromResult(lockResult.value, "LOCK_WAITS");
lockOwners.value = xuguScalarFromResult(lockResult.value, "LOCK_OWNERS");
} else {
lockWaits.value = "";
lockOwners.value = "";
}
if (memoryResult.status === "fulfilled") memory.value = xuguMemoryStatusFromResult(memoryResult.value);
else memory.value = [];
if (sessionStatusResult.status === "fulfilled") sessionStatuses.value = xuguSessionStatusSummaryFromResult(sessionStatusResult.value);
else sessionStatuses.value = [];
if (lockModeResult.status === "fulfilled") lockModes.value = xuguLockModeSummaryFromResult(lockModeResult.value);
else lockModes.value = [];
if (tablespaceResult.status === "fulfilled") tablespaces.value = xuguTablespaceSummaryFromResult(tablespaceResult.value);
else tablespaces.value = [];
} catch (cause: any) {
error.value = cause?.message || String(cause);
} finally {
loading.value = false;
}
}
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
function startAutoRefresh() {
stopAutoRefresh();
if (refreshSeconds.value <= 0) return;
refreshTimer = setInterval(() => {
if (!document.hidden) void load();
}, refreshSeconds.value * 1000);
}
function changeRefresh(value: string) {
refreshSeconds.value = Number(value);
startAutoRefresh();
}
onMounted(async () => {
await load();
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("xuguServerDashboard.title") }}</div>
<Badge variant="outline" class="h-5 max-w-56 truncate rounded-md px-1.5 text-[11px]" :title="connectionName">{{ connectionName }}</Badge>
<div class="ml-auto flex items-center gap-2">
<span class="hidden text-xs text-muted-foreground sm:inline">{{ version || "—" }}</span>
<select :value="String(refreshSeconds)" class="h-7 rounded-md border bg-background px-2 text-xs" @change="changeRefresh(($event.target as HTMLSelectElement).value)">
<option value="0">{{ t("xuguServerDashboard.off") }}</option>
<option value="5">5s</option>
<option value="10">10s</option>
<option value="30">30s</option>
</select>
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" :disabled="loading" @click="load">
<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="!supported" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">{{ t("xuguServerDashboard.unsupported") }}</div>
<div v-else-if="error" class="flex flex-1 items-center justify-center gap-2 px-6 text-center text-sm text-destructive">
<AlertTriangle class="h-4 w-4 shrink-0" />
{{ error }}
</div>
<div v-else class="min-h-0 flex-1 overflow-auto p-4">
<div v-if="partialErrors.length > 0" class="mb-3 flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{{ t("xuguServerDashboard.partialData") }}</span>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div class="rounded-lg border bg-card p-3">
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.version") }}</div>
<div class="mt-2 truncate text-sm font-semibold">{{ version || "—" }}</div>
</div>
<div class="rounded-lg border bg-card p-3">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground"><Server class="h-3.5 w-3.5" />{{ t("xuguServerDashboard.onlineNodes") }}</div>
<div class="mt-2 text-xl font-semibold">
{{ onlineNodes === null ? "—" : onlineNodes }}<span v-if="onlineNodes !== null" class="ml-1 text-xs font-normal text-muted-foreground">/ {{ nodes.length }}</span>
</div>
</div>
<div class="rounded-lg border bg-card p-3">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground"><Users class="h-3.5 w-3.5" />{{ t("xuguServerDashboard.sessions") }}</div>
<div class="mt-2 text-xl font-semibold">
{{ totalSessions === null ? "—" : totalSessions }}<span class="ml-1 text-xs font-normal text-muted-foreground">{{ activeSessions === null ? "—" : t("xuguServerDashboard.activeCount", { count: activeSessions }) }}</span>
</div>
</div>
<div class="rounded-lg border bg-card p-3">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground"><Activity class="h-3.5 w-3.5" />{{ t("xuguServerDashboard.transactions") }}</div>
<div class="mt-2 text-xl font-semibold">{{ activeTransactions === null ? "—" : activeTransactions }}</div>
</div>
<div class="rounded-lg border bg-card p-3">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground"><Database class="h-3.5 w-3.5" />{{ t("xuguServerDashboard.lockWaits") }}</div>
<div class="mt-2 text-xl font-semibold">{{ lockWaits || "—" }}</div>
</div>
</div>
<div class="mt-4 grid gap-4 xl:grid-cols-2">
<section class="overflow-hidden rounded-lg border bg-card">
<div class="flex items-center justify-between border-b px-3 py-2.5">
<div class="flex items-center gap-1.5 text-sm font-semibold"><Database class="h-4 w-4 text-primary" />{{ t("xuguServerDashboard.memoryAndCache") }}</div>
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.memoryHint") }}</div>
</div>
<div class="space-y-4 p-3">
<div>
<div class="mb-1.5 flex items-center justify-between text-xs">
<span>{{ t("xuguServerDashboard.bufferPool") }}</span
><span class="text-muted-foreground">{{ bufferTotalBytes === null || bufferFreeBytes === null ? "—" : `${formatBytes(bufferTotalBytes - bufferFreeBytes)} / ${formatBytes(bufferTotalBytes)}` }}</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-muted"><div class="h-full rounded-full bg-primary" :style="{ width: `${percentage(bufferTotalBytes, bufferFreeBytes)}%` }" /></div>
<div class="mt-1.5 flex justify-between text-[11px] text-muted-foreground">
<span>{{ bufferTotalBytes === null || bufferFreeBytes === null ? "—" : t("xuguServerDashboard.usedPercent", { percent: percentage(bufferTotalBytes, bufferFreeBytes).toFixed(1) }) }}</span
><span>{{ bufferDirtyBytes === null ? "—" : t("xuguServerDashboard.dirtyPages", { value: formatBytes(bufferDirtyBytes) }) }}</span>
</div>
</div>
<div>
<div class="mb-1.5 flex items-center justify-between text-xs">
<span>{{ t("xuguServerDashboard.sga") }}</span
><span class="text-muted-foreground">{{ sgaTotalBytes === null || sgaFreeBytes === null ? "—" : `${formatBytes(sgaTotalBytes - sgaFreeBytes)} / ${formatBytes(sgaTotalBytes)}` }}</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-muted"><div class="h-full rounded-full bg-sky-500" :style="{ width: `${percentage(sgaTotalBytes, sgaFreeBytes)}%` }" /></div>
<div class="mt-1.5 text-[11px] text-muted-foreground">{{ sgaTotalBytes === null || sgaFreeBytes === null ? "—" : t("xuguServerDashboard.usedPercent", { percent: percentage(sgaTotalBytes, sgaFreeBytes).toFixed(1) }) }}</div>
</div>
<div>
<div class="mb-1.5 flex items-center justify-between text-xs">
<span>{{ t("xuguServerDashboard.swap") }}</span
><span class="text-muted-foreground">{{ swapTotalBytes === null || swapFreeBytes === null ? "—" : `${formatBytes(swapTotalBytes - swapFreeBytes)} / ${formatBytes(swapTotalBytes)}` }}</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-muted"><div class="h-full rounded-full bg-amber-500" :style="{ width: `${percentage(swapTotalBytes, swapFreeBytes)}%` }" /></div>
<div class="mt-1.5 text-[11px] text-muted-foreground">{{ swapTotalBytes === null || swapFreeBytes === null ? "—" : t("xuguServerDashboard.usedPercent", { percent: percentage(swapTotalBytes, swapFreeBytes).toFixed(1) }) }}</div>
</div>
</div>
</section>
<section class="overflow-hidden rounded-lg border bg-card">
<div class="flex items-center justify-between border-b px-3 py-2.5">
<div class="flex items-center gap-1.5 text-sm font-semibold"><HardDrive class="h-4 w-4 text-primary" />{{ t("xuguServerDashboard.runtimeAndWrite") }}</div>
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.cumulativeHint") }}</div>
</div>
<div class="grid grid-cols-2 gap-px bg-border sm:grid-cols-4">
<div class="bg-card p-3">
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.diskRead") }}</div>
<div class="mt-1.5 text-lg font-semibold">{{ diskReadBytes === null ? "—" : formatBytes(diskReadBytes) }}</div>
</div>
<div class="bg-card p-3">
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.diskWrite") }}</div>
<div class="mt-1.5 text-lg font-semibold">{{ diskWriteBytes === null ? "—" : formatBytes(diskWriteBytes) }}</div>
</div>
<div class="bg-card p-3">
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.walPending") }}</div>
<div class="mt-1.5 text-lg font-semibold">{{ walBacklog === null ? "—" : formatBytes(walBacklog) }}</div>
</div>
<div class="bg-card p-3">
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.transactionSpan") }}</div>
<div class="mt-1.5 text-lg font-semibold">{{ transactionSpan === null ? "—" : transactionSpan }}</div>
</div>
<div class="col-span-2 bg-card p-3">
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.freeStores") }}</div>
<div class="mt-1.5 text-lg font-semibold">{{ freeStores === null ? "—" : freeStores }}</div>
</div>
<div class="col-span-2 bg-card p-3">
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.datafiles") }}</div>
<div class="mt-1.5 text-lg font-semibold">{{ totalDatafiles === null ? "—" : totalDatafiles }}</div>
</div>
</div>
</section>
</div>
<div class="mt-4 grid gap-4 xl:grid-cols-2">
<section class="overflow-hidden rounded-lg border bg-card">
<div class="flex items-center justify-between border-b px-3 py-2.5">
<div class="flex items-center gap-1.5 text-sm font-semibold"><Users class="h-4 w-4 text-primary" />{{ t("xuguServerDashboard.sessionsAndTransactions") }}</div>
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.activeSessionHint") }}</div>
</div>
<div class="grid grid-cols-3 gap-px bg-border">
<div class="bg-card p-3">
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.idleSessions") }}</div>
<div class="mt-1.5 text-lg font-semibold">{{ sessionStatusCount("idle") ?? "—" }}</div>
</div>
<div class="bg-card p-3">
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.executingSessions") }}</div>
<div class="mt-1.5 text-lg font-semibold">{{ sessionStatusCount("executing") ?? "—" }}</div>
</div>
<div class="bg-card p-3">
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.otherSessions") }}</div>
<div class="mt-1.5 text-lg font-semibold">{{ otherSessionCount() ?? "—" }}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3 p-3 text-xs">
<div>
<div class="text-muted-foreground">{{ t("xuguServerDashboard.oldestStatement") }}</div>
<div class="mt-1 truncate font-medium" :title="sessions.find((entry) => entry.oldestStatement)?.oldestStatement">{{ sessions.find((entry) => entry.oldestStatement)?.oldestStatement || "—" }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("xuguServerDashboard.oldestTransaction") }}</div>
<div class="mt-1 truncate font-medium" :title="transactions.find((entry) => entry.oldestTransaction)?.oldestTransaction">{{ transactions.find((entry) => entry.oldestTransaction)?.oldestTransaction || "—" }}</div>
</div>
</div>
</section>
<section class="overflow-hidden rounded-lg border bg-card">
<div class="flex items-center justify-between border-b px-3 py-2.5">
<div class="flex items-center gap-1.5 text-sm font-semibold"><Activity class="h-4 w-4 text-primary" />{{ t("xuguServerDashboard.locksAndCapacity") }}</div>
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.readOnlyHint") }}</div>
</div>
<div class="grid grid-cols-2 gap-px bg-border sm:grid-cols-4">
<div class="bg-card p-3">
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.lockOwners") }}</div>
<div class="mt-1.5 text-lg font-semibold">{{ lockOwners || "—" }}</div>
</div>
<div class="bg-card p-3">
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.lockWaits") }}</div>
<div class="mt-1.5 text-lg font-semibold">{{ lockWaits || "—" }}</div>
</div>
<div class="bg-card p-3">
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.tablespaces") }}</div>
<div class="mt-1.5 text-lg font-semibold">{{ availability.tablespaces ? tablespaces.length : "—" }}</div>
</div>
<div class="bg-card p-3">
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.mediaErrors") }}</div>
<div class="mt-1.5 text-lg font-semibold" :class="mediaErrors !== null && mediaErrors > 0 ? 'text-destructive' : ''">{{ mediaErrors ?? "—" }}</div>
</div>
</div>
<div class="border-t p-3">
<div class="mb-2 flex items-center justify-between text-xs">
<span class="text-muted-foreground">{{ t("xuguServerDashboard.lockModes") }}</span
><span class="text-muted-foreground">{{ dataOrTempFreeBytes === null ? "—" : t("xuguServerDashboard.dataTempFree", { value: formatBytes(dataOrTempFreeBytes) }) }}</span>
</div>
<div class="grid grid-cols-5 gap-2 text-center text-xs">
<div v-for="mode in ['S', 'X', 'IS', 'IX', 'SIX']" :key="mode" class="rounded-md bg-muted/50 px-2 py-1.5">
<div class="text-muted-foreground">{{ mode }}</div>
<div class="mt-0.5 font-semibold">{{ lockModeCount(mode) ?? "—" }}</div>
</div>
</div>
<div class="mt-2 text-[11px] text-muted-foreground">{{ t("xuguServerDashboard.rowLockLimit") }}</div>
</div>
</section>
</div>
<section class="mt-4 overflow-hidden rounded-lg border bg-card">
<div class="flex items-center justify-between border-b px-3 py-2.5">
<div class="text-sm font-semibold">{{ t("xuguServerDashboard.nodes") }}</div>
<div class="text-xs text-muted-foreground">{{ t("xuguServerDashboard.readOnlyHint") }}</div>
</div>
<div class="overflow-auto">
<table class="w-full min-w-[1180px] text-left text-xs">
<thead class="bg-muted/40 text-muted-foreground">
<tr>
<th class="px-3 py-2 font-medium">{{ t("xuguServerDashboard.node") }}</th>
<th class="px-3 py-2 font-medium">{{ t("xuguServerDashboard.type") }}</th>
<th class="px-3 py-2 font-medium">{{ t("xuguServerDashboard.state") }}</th>
<th class="px-3 py-2 font-medium">{{ t("xuguServerDashboard.cpuLoad") }}</th>
<th class="px-3 py-2 font-medium">{{ t("xuguServerDashboard.sessions") }}</th>
<th class="px-3 py-2 font-medium">{{ t("xuguServerDashboard.transactions") }}</th>
<th class="px-3 py-2 font-medium">{{ t("xuguServerDashboard.diskRead") }}</th>
<th class="px-3 py-2 font-medium">{{ t("xuguServerDashboard.diskWrite") }}</th>
<th class="px-3 py-2 font-medium">{{ t("xuguServerDashboard.walPending") }}</th>
<th class="px-3 py-2 font-medium">{{ t("xuguServerDashboard.freeStores") }}</th>
<th class="px-3 py-2 font-medium">{{ t("xuguServerDashboard.bootTime") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="node in nodes" :key="node.nodeId" class="border-t hover:bg-muted/30">
<td class="px-3 py-2">
<div class="font-medium">#{{ node.nodeId }} · {{ node.host || "—" }}</div>
<div class="mt-0.5 text-[11px] text-muted-foreground">{{ t("xuguServerDashboard.rack", { rack: node.rackNo || "—" }) }} · {{ t("xuguServerDashboard.stores", { count: node.storeCount || "0" }) }}</div>
</td>
<td class="px-3 py-2">{{ nodeTypeText(node) }}</td>
<td class="px-3 py-2">
<Badge variant="outline" class="h-5 rounded-full px-2 text-[10px]" :class="nodeStateClass(node)">{{ nodeStateText(node) }}</Badge>
</td>
<td class="px-3 py-2">{{ node.cpuLoad || "—" }}</td>
<td class="px-3 py-2">
{{ valueFor(sessionsByNode, node.nodeId, "sessions") }}<span class="ml-1 text-muted-foreground">/ {{ valueFor(sessionsByNode, node.nodeId, "activeSessions") }}</span>
</td>
<td class="px-3 py-2">{{ transactionsByNode.has(node.nodeId) ? valueFor(transactionsByNode, node.nodeId, "activeTransactions") : valueFor(runInfoByNode, node.nodeId, "activeTransactions") }}</td>
<td class="px-3 py-2">{{ availability.runInfo ? formatBytes(numeric(valueFor(runInfoByNode, node.nodeId, "diskReadBytes"))) : "—" }}</td>
<td class="px-3 py-2">{{ availability.runInfo ? formatBytes(numeric(valueFor(runInfoByNode, node.nodeId, "diskWriteBytes"))) : "—" }}</td>
<td class="px-3 py-2">{{ availability.runInfo ? formatBytes(Math.max(0, numeric(valueFor(runInfoByNode, node.nodeId, "xlogWritePosition")) - numeric(valueFor(runInfoByNode, node.nodeId, "xlogCheckpointPosition")))) : "—" }}</td>
<td class="px-3 py-2">{{ availability.runInfo ? valueFor(runInfoByNode, node.nodeId, "freeStores") : "—" }}</td>
<td class="px-3 py-2 text-muted-foreground">{{ node.bootTime || "—" }}</td>
</tr>
<tr v-if="!loading && nodes.length === 0">
<td colspan="11" class="px-3 py-10 text-center text-muted-foreground">{{ t("xuguServerDashboard.emptyNodes") }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
</template>
@@ -116,6 +116,7 @@ const ProcessListPanel = defineAsyncComponent(() => import("@/components/admin/P
const SqlServerActivityTracePanel = defineAsyncComponent(() => import("@/components/admin/SqlServerActivityTracePanel.vue"));
const MySqlDashboard = defineAsyncComponent(() => import("@/components/admin/MySqlDashboard.vue"));
const PostgresDashboard = defineAsyncComponent(() => import("@/components/admin/PostgresDashboard.vue"));
const XuguServerDashboard = defineAsyncComponent(() => import("@/components/admin/XuguServerDashboard.vue"));
const DamengJobAdmin = defineAsyncComponent(() => import("@/components/admin/DamengJobAdmin.vue"));
const DamengUserAdmin = defineAsyncComponent(() => import("@/components/admin/DamengUserAdmin.vue"));
const DamengRoleAdmin = defineAsyncComponent(() => import("@/components/admin/DamengRoleAdmin.vue"));
@@ -2559,6 +2560,12 @@ defineExpose({
</div>
</template>
<template v-else-if="activeTab.mode === 'xugu-dashboard'">
<div class="min-h-0 flex-1">
<XuguServerDashboard :key="activeTab.id" :connection-id="activeTab.connectionId" />
</div>
</template>
<template v-else-if="activeTab.mode === 'nacos-dashboard'">
<div class="min-h-0 flex-1">
<NacosDashboard :key="activeTab.id" :connection-id="activeTab.connectionId" />
@@ -174,6 +174,7 @@ import { authorizationPlanSql, authorizationPlanStatus, buildCreateDatabaseAutho
import { connectionSupportsProcessList } from "@/lib/database/processListDrivers";
import { connectionSupportsServerDashboard } from "@/lib/database/mysqlServerStatus";
import { connectionSupportsServerDashboard as connectionSupportsPgServerDashboard } from "@/lib/database/postgresServerStatus";
import { connectionSupportsXuguServerDashboard } from "@/lib/database/xuguServerStatus";
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import { sidebarTreeArrowAction } from "@/lib/sidebar/sidebarTreeArrowNavigation";
import { batchTableEmptyFeedback, runBatchTableEmpty } from "@/lib/sidebar/batchTableEmpty";
@@ -1729,6 +1730,8 @@ async function openServerDashboard() {
connectionStore.activeConnectionId = node.connectionId;
if (currentDatabaseType() === "nacos") {
queryStore.openNacosDashboard(node.connectionId);
} else if (connectionSupportsXuguServerDashboard(connectionStore.getConfig(node.connectionId))) {
queryStore.openXuguDashboard(node.connectionId);
} else if (connectionSupportsPgServerDashboard(connectionStore.getConfig(node.connectionId))) {
queryStore.openPostgresDashboard(node.connectionId);
} else {
@@ -5222,7 +5225,10 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
if (currentDatabaseType() === "sqlserver") {
items.push({ label: t("contextMenu.sqlServerTrace"), action: openSqlServerActivityTrace, icon: Activity });
}
if (node.connectionId && (currentDatabaseType() === "nacos" || connectionSupportsServerDashboard(connectionStore.getConfig(node.connectionId)) || connectionSupportsPgServerDashboard(connectionStore.getConfig(node.connectionId)))) {
if (
node.connectionId &&
(currentDatabaseType() === "nacos" || connectionSupportsXuguServerDashboard(connectionStore.getConfig(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") {
+68
View File
@@ -3619,6 +3619,74 @@ export default {
tuplesOutChart: "Tuples out",
blockIoChart: "Block I/O",
},
xuguServerDashboard: {
title: "Xugu Server Dashboard",
off: "Off",
unsupported: "This page only supports XuguDB connections.",
loadFailed: "Unable to read Xugu runtime status.",
partialData: "Some monitoring views could not be read. The current account may lack system-view privileges; readable data is still shown.",
readOnlyHint: "Read-only monitoring; no database state is modified",
version: "Database version",
onlineNodes: "Online nodes",
sessions: "Sessions",
activeCount: "{count} active",
transactions: "Active transactions",
lockWaits: "Lock waits (table-level)",
lockOwners: "Held locks",
nodes: "Node status",
node: "Node",
type: "Type",
state: "State",
cpuLoad: "CPU load (reference)",
diskRead: "Disk read",
diskWrite: "Disk write",
memoryAndCache: "Cache & memory",
memoryHint: "From system memory views",
bufferPool: "Data buffer pool",
sga: "SGA",
swap: "Swap",
usedPercent: "{percent}% used",
dirtyPages: "Dirty {value}",
runtimeAndWrite: "Runtime & writes",
cumulativeHint: "Cumulative counters; they do not reset on refresh",
walPending: "WAL pending checkpoint",
transactionSpan: "Transaction ID span (risk signal)",
freeStores: "Free stores",
datafiles: "Data files",
sessionsAndTransactions: "Sessions & transactions",
activeSessionHint: "Active sessions include this monitor query",
idleSessions: "Idle sessions",
executingSessions: "Executing sessions",
otherSessions: "Other states",
oldestStatement: "Oldest active statement started",
oldestTransaction: "Oldest active transaction started",
locksAndCapacity: "Locks & capacity",
tablespaces: "Tablespaces",
mediaErrors: "Media errors",
lockModes: "Held lock modes",
dataTempFree: "DATA/TEMP free {value}",
rowLockLimit: "Lock waits cover table-level locks only; investigate row blocking through long transactions.",
bootTime: "Boot time",
rack: "Rack {rack}",
stores: "{count} stores",
emptyNodes: "No node data was returned, or the account cannot access SYS_CLUSTERS.",
nodeState: {
joining: "Joining",
running: "Running",
error: "Error",
offline: "Offline",
unknown: "Unknown",
},
nodeType: {
master: "Master",
standby: "Standby",
storage: "Storage",
query: "Query",
worker: "Worker",
change: "Change",
unknown: "Unknown",
},
},
userAdmin: {
title: "Users & Privileges",
unsupported: "MySQL-compatible and PostgreSQL-compatible connections are supported. SQL Server, Oracle, and other permission models can be added next.",
+68
View File
@@ -3606,6 +3606,74 @@ export default withEnglishFallback({
tuplesOutChart: "元组读取",
blockIoChart: "块 I/O",
},
xuguServerDashboard: {
title: "虚谷服务器仪表盘",
off: "关闭",
unsupported: "当前页面仅支持虚谷数据库连接。",
loadFailed: "无法读取虚谷运行状态。",
partialData: "部分监控视图不可读,当前账号可能缺少系统视图权限;已展示可读取的数据。",
readOnlyHint: "只读监控,不会修改数据库状态",
version: "数据库版本",
onlineNodes: "在线节点",
sessions: "会话",
activeCount: "活跃 {count}",
transactions: "活跃事务",
lockWaits: "锁等待(表级)",
lockOwners: "持有锁",
nodes: "节点运行状态",
node: "节点",
type: "类型",
state: "状态",
cpuLoad: "CPU 负载(参考)",
diskRead: "磁盘读取",
diskWrite: "磁盘写入",
memoryAndCache: "缓存与内存",
memoryHint: "基于系统内存视图",
bufferPool: "数据缓冲池",
sga: "SGA",
swap: "交换区",
usedPercent: "已使用 {percent}%",
dirtyPages: "脏页 {value}",
runtimeAndWrite: "运行与写入",
cumulativeHint: "累计计数,刷新不会重置",
walPending: "待检查点 WAL",
transactionSpan: "事务 ID 跨度(风险信号)",
freeStores: "空闲存储段数",
datafiles: "数据文件数",
sessionsAndTransactions: "会话与事务",
activeSessionHint: "活跃会话含本监控查询",
idleSessions: "空闲会话",
executingSessions: "执行中会话",
otherSessions: "其他状态",
oldestStatement: "最早执行语句开始时间",
oldestTransaction: "最早活跃事务开始时间",
locksAndCapacity: "锁与容量",
tablespaces: "表空间数",
mediaErrors: "介质错误数",
lockModes: "当前持有锁模式",
dataTempFree: "DATA/TEMP 空闲 {value}",
rowLockLimit: "锁等待仅覆盖表级锁;行级阻塞需结合长事务排查。",
bootTime: "启动时间",
rack: "机架 {rack}",
stores: "存储段 {count}",
emptyNodes: "未读取到节点信息,或当前账号无权访问 SYS_CLUSTERS。",
nodeState: {
joining: "加入中",
running: "运行中",
error: "错误",
offline: "离线",
unknown: "未知",
},
nodeType: {
master: "主节点",
standby: "备节点",
storage: "存储节点",
query: "查询节点",
worker: "工作节点",
change: "变更节点",
unknown: "未知",
},
},
userAdmin: {
title: "用户与权限",
unsupported: "当前支持 MySQL 兼容与 PostgreSQL 兼容连接。SQL Server、Oracle 等会按各自权限模型继续扩展。",
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
XUGU_ACTIVE_SESSION_SQL,
XUGU_CLUSTER_NODES_SQL,
XUGU_LOCK_MODE_SUMMARY_SQL,
XUGU_LOCK_WAITS_SQL,
XUGU_MEMORY_STATUS_SQL,
XUGU_RUN_INFO_SQL,
XUGU_SESSION_SUMMARY_SQL,
XUGU_SESSION_STATUS_SUMMARY_SQL,
XUGU_TABLESPACE_SUMMARY_SQL,
XUGU_TRANSACTION_SUMMARY_SQL,
XUGU_VERSION_SQL,
connectionSupportsXuguServerDashboard,
xuguClusterNodeStateLabel,
xuguClusterNodeTypeLabels,
xuguClusterNodesFromResult,
xuguLockModeSummaryFromResult,
xuguMemoryStatusFromResult,
xuguRunInfoFromResult,
xuguSessionSummaryFromResult,
xuguSessionStatusSummaryFromResult,
xuguTablespaceSummaryFromResult,
xuguTransactionSummaryFromResult,
xuguVersionFromResult,
} from "@/lib/database/xuguServerStatus";
describe("Xugu server status helpers", () => {
it("keeps the first-phase catalog queries scoped to Xugu system views", () => {
expect(XUGU_VERSION_SQL).toContain("VERSION()");
expect(XUGU_CLUSTER_NODES_SQL).toContain("FROM SYS_CLUSTERS");
expect(XUGU_RUN_INFO_SQL).toContain("FROM SYS_ALL_RUN_INFO");
expect(XUGU_SESSION_SUMMARY_SQL).toContain("FROM SYS_ALL_SESSIONS");
expect(XUGU_ACTIVE_SESSION_SQL).toContain("FROM SYS_ALL_THD_SESSION");
expect(XUGU_ACTIVE_SESSION_SQL).toContain("JOIN SYS_ALL_SESSIONS");
expect(XUGU_ACTIVE_SESSION_SQL).not.toMatch(/WHERE\s+STATE\s*=\s*1/i);
expect(XUGU_TRANSACTION_SUMMARY_SQL).toContain("FROM SYS_ALL_TRANS");
expect(XUGU_LOCK_WAITS_SQL).toContain("SYS_ALL_LWAITERS");
expect(XUGU_LOCK_MODE_SUMMARY_SQL).toContain("SYS_ALL_LOWNERS");
expect(XUGU_SESSION_STATUS_SUMMARY_SQL).toContain("FROM SYS_ALL_SESSIONS");
expect(XUGU_MEMORY_STATUS_SQL).toContain("SYS_ALL_MEM_STATUS");
expect(XUGU_TABLESPACE_SUMMARY_SQL).toContain("SYS_ALL_TABLESPACES");
});
it("recognizes native and JDBC Xugu connections only", () => {
expect(connectionSupportsXuguServerDashboard({ id: "x", name: "Xugu", db_type: "xugu" } as any)).toBe(true);
expect(connectionSupportsXuguServerDashboard({ id: "pg", name: "Postgres", db_type: "postgres" } as any)).toBe(false);
expect(connectionSupportsXuguServerDashboard(undefined)).toBe(false);
});
it("maps documented node states and roles", () => {
expect(xuguClusterNodeStateLabel("2")).toBe("running");
expect(xuguClusterNodeStateLabel("4")).toBe("offline");
expect(xuguClusterNodeTypeLabels("1")).toEqual(["master"]);
expect(xuguClusterNodeTypeLabels("29")).toEqual(["master", "storage", "query", "worker"]);
expect(xuguClusterNodeTypeLabels("0")).toEqual([]);
expect(xuguClusterNodeStateLabel("999")).toBe("unknown");
});
it("parses status rows by column name instead of column order", () => {
const nodes = xuguClusterNodesFromResult({
columns: ["NODE_IP", "NODE_ID", "NODE_STATE", "NODE_TYPE", "NODE_PORT", "RACK_NO", "CPU_LOAD", "BOOT_TIME", "STORE_NUM", "MAJOR_NUM"],
rows: [["127.0.0.1", 1, 2, 1, 5138, 0, 4, "2026-01-01", 10, 8]],
});
const runInfo = xuguRunInfoFromResult({
columns: ["FREE_STO_N", "NODEID", "ACT_TRANS_NUM", "DISK_R_N", "DISK_R_BYTES", "DISK_W_N", "DISK_W_BYTES", "MAX_TRANS_ID", "MIN_TRANS_ID", "XLOG_WPOS", "XLOG_CKPT"],
rows: [[11, 1, 2, 3, 4, 5, 6, 100, 99, 1000, 900]],
});
const sessions = xuguSessionSummaryFromResult({
columns: ["MEMORY_BYTES", "SESSIONS", "NODE_ID", "ACTIVE_SESSIONS", "OLDEST_STATEMENT"],
rows: [[1024, 4, 1, 2, "2026-01-01 00:00:00"]],
});
const transactions = xuguTransactionSummaryFromResult({
columns: ["ACTIVE_TRANSACTIONS", "NODE_ID", "OLDEST_TRANSACTION"],
rows: [[3, 1, "2026-01-01 00:00:00"]],
});
const memory = xuguMemoryStatusFromResult({
columns: ["SGA_FREE_BYTES", "NODEID", "SGA_TOTAL_BYTES", "BUFFER_FREE_BYTES", "BUFFER_TOTAL_BYTES"],
rows: [[20, 1, 100, 40, 200]],
});
const sessionStatuses = xuguSessionStatusSummaryFromResult({
columns: ["SESSIONS", "STATUS", "NODE_ID"],
rows: [[3, 112, 1]],
});
const lockModes = xuguLockModeSummaryFromResult({
columns: ["LOCKS", "LOCK_MODE", "NODE_ID"],
rows: [[3, "IS", 1]],
});
const tablespaces = xuguTablespaceSummaryFromResult({
columns: ["FREE_BYTES", "TOTAL_BYTES", "MEDIA_ERROR", "DATAFILES", "SPACE_TYPE", "SPACE_NAME", "NODE_ID"],
rows: [[10, 100, false, 1, "DATA_SPACE", "DATA1", 1]],
});
expect(nodes[0]).toMatchObject({ nodeId: "1", host: "127.0.0.1", state: "2", majorCount: "8" });
expect(runInfo[0]).toMatchObject({ nodeId: "1", activeTransactions: "2", diskReadCount: "3", freeStores: "11", xlogWritePosition: "1000" });
expect(sessions[0]).toMatchObject({ nodeId: "1", sessions: "4", activeSessions: "2", memoryBytes: "1024", oldestStatement: "2026-01-01 00:00:00" });
expect(transactions[0]).toMatchObject({ nodeId: "1", activeTransactions: "3", oldestTransaction: "2026-01-01 00:00:00" });
expect(memory[0]).toMatchObject({ nodeId: "1", sgaTotalBytes: "100", sgaFreeBytes: "20", bufferTotalBytes: "200" });
expect(sessionStatuses[0]).toMatchObject({ nodeId: "1", status: "112", sessions: "3" });
expect(lockModes[0]).toMatchObject({ nodeId: "1", lockMode: "IS", locks: "3" });
expect(tablespaces[0]).toMatchObject({ nodeId: "1", spaceName: "DATA1", spaceType: "DATA_SPACE", totalBytes: "100" });
});
it("returns an empty value for missing or null values", () => {
expect(xuguVersionFromResult({ columns: ["VERSION"], rows: [[null]] })).toBe("");
expect(xuguVersionFromResult({ columns: ["OTHER"], rows: [["x"]] })).toBe("");
});
});
@@ -0,0 +1,329 @@
import type { ConnectionConfig, QueryResult } from "@/types/database";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
export interface XuguClusterNode {
nodeId: string;
rackNo: string;
host: string;
port: string;
nodeType: string;
state: string;
cpuLoad: string;
bootTime: string;
storeCount: string;
majorCount: string;
}
export interface XuguRunInfo {
nodeId: string;
activeTransactions: string;
diskReadCount: string;
diskReadBytes: string;
diskWriteCount: string;
diskWriteBytes: string;
maxTransactionId: string;
minTransactionId: string;
xlogWritePosition: string;
xlogCheckpointPosition: string;
freeStores: string;
}
export interface XuguSessionSummary {
nodeId: string;
sessions: string;
activeSessions: string;
memoryBytes: string;
oldestStatement: string;
}
export interface XuguTransactionSummary {
nodeId: string;
activeTransactions: string;
oldestTransaction: string;
}
export interface XuguMemoryStatus {
nodeId: string;
bufferTotalBytes: string;
bufferFreeBytes: string;
bufferDirtyBytes: string;
bufferLruBytes: string;
sgaTotalBytes: string;
sgaFreeBytes: string;
sgaPeakBytes: string;
swapTotalBytes: string;
swapFreeBytes: string;
}
export interface XuguSessionStatusSummary {
nodeId: string;
status: string;
sessions: string;
}
export interface XuguLockModeSummary {
nodeId: string;
lockMode: string;
locks: string;
}
export interface XuguTablespaceSummary {
nodeId: string;
spaceName: string;
spaceType: string;
datafiles: string;
mediaError: string;
totalBytes: string;
freeBytes: string;
}
export const XUGU_VERSION_SQL = "SELECT VERSION() AS VERSION FROM DUAL;";
export const XUGU_CLUSTER_NODES_SQL = `
SELECT
NODE_ID,
RACK_NO,
NODE_IP,
NODE_PORT,
NODE_TYPE,
NODE_STATE,
CPU_LOAD,
BOOT_TIME,
STORE_NUM,
MAJOR_NUM
FROM SYS_CLUSTERS
ORDER BY NODE_ID;`.trim();
export const XUGU_RUN_INFO_SQL = `
SELECT
NODEID,
ACT_TRANS_NUM,
DISK_R_N,
DISK_R_BYTES,
DISK_W_N,
DISK_W_BYTES,
MAX_TRANS_ID,
MIN_TRANS_ID,
XLOG_WPOS,
XLOG_CKPT,
FREE_STO_N
FROM SYS_ALL_RUN_INFO
ORDER BY NODEID;`.trim();
/** Per-node session totals. This is optional because restricted users may not see SYS_ALL_SESSIONS. */
export const XUGU_SESSION_SUMMARY_SQL = `
SELECT NODEID AS NODE_ID,
COUNT(*) AS SESSIONS,
SUM(MEM_SIZE) AS MEMORY_BYTES
FROM SYS_ALL_SESSIONS
GROUP BY NODEID
ORDER BY NODEID;`.trim();
/** The thread-session view is the Xugu-specific definition of an active statement. */
export const XUGU_ACTIVE_SESSION_SQL = `
SELECT T.NODEID AS NODE_ID,
COUNT(*) AS ACTIVE_SESSIONS,
MIN(S.CMD_START_T) AS OLDEST_STATEMENT
FROM SYS_ALL_THD_SESSION T
JOIN SYS_ALL_SESSIONS S
ON T.NODEID = S.NODEID AND T.SESSION_ID = S.SESSION_ID
GROUP BY T.NODEID
ORDER BY T.NODEID;`.trim();
export const XUGU_TRANSACTION_SUMMARY_SQL = `
SELECT NODEID AS NODE_ID,
COUNT(*) AS ACTIVE_TRANSACTIONS,
MIN(START_T) AS OLDEST_TRANSACTION
FROM SYS_ALL_TRANS
GROUP BY NODEID
ORDER BY NODEID;`.trim();
/** Only table-level lock waiters are exposed by SYS_ALL_LWAITERS. */
export const XUGU_LOCK_WAITS_SQL = `
SELECT
(SELECT COUNT(*) FROM SYS_ALL_LWAITERS) AS LOCK_WAITS,
(SELECT COUNT(*) FROM SYS_ALL_LOWNERS) AS LOCK_OWNERS
FROM DUAL;`.trim();
export const XUGU_LOCK_MODE_SUMMARY_SQL = `
SELECT NODEID AS NODE_ID,
LOCK_LEVEL AS LOCK_MODE,
COUNT(*) AS LOCKS
FROM SYS_ALL_LOWNERS
GROUP BY NODEID, LOCK_LEVEL
ORDER BY NODEID, LOCK_LEVEL;`.trim();
export const XUGU_SESSION_STATUS_SUMMARY_SQL = `
SELECT NODEID AS NODE_ID,
STATUS,
COUNT(*) AS SESSIONS
FROM SYS_ALL_SESSIONS
GROUP BY NODEID, STATUS
ORDER BY NODEID, STATUS;`.trim();
export const XUGU_MEMORY_STATUS_SQL = `
SELECT
NODEID,
BUFF_SIZE * TOTAL_BUFF_NUM AS BUFFER_TOTAL_BYTES,
BUFF_SIZE * FREE_BUFF_NUM AS BUFFER_FREE_BYTES,
BUFF_SIZE * DIRTY_BUFF_NUM AS BUFFER_DIRTY_BYTES,
BUFF_SIZE * LRU_BUFF_NUM AS BUFFER_LRU_BYTES,
SGA_BLK_SIZE * TOTAL_SGA_MEM AS SGA_TOTAL_BYTES,
SGA_BLK_SIZE * FREE_SGA_MEM AS SGA_FREE_BYTES,
SGA_BLK_SIZE * PEAK_SGA_MEM AS SGA_PEAK_BYTES,
SWAP_BLK_SIZE * TOTAL_SWAP_MEM AS SWAP_TOTAL_BYTES,
SWAP_BLK_SIZE * FREE_SWAP_MEM AS SWAP_FREE_BYTES
FROM SYS_ALL_MEM_STATUS
ORDER BY NODEID;`.trim();
export const XUGU_TABLESPACE_SUMMARY_SQL = `
SELECT
T.NODEID AS NODE_ID,
T.SPACE_NAME,
T.SPACE_TYPE,
T.DATAFILE_NUM AS DATAFILES,
T.MEDIA_ERROR,
T.TOTAL_CHUNK_NUM * C.CHUNK_SIZE AS TOTAL_BYTES,
T.FREE_CHUNK_NUM * C.CHUNK_SIZE AS FREE_BYTES
FROM SYS_ALL_TABLESPACES T, SYS_CTL_VARS C
ORDER BY T.NODEID, T.SPACE_NAME;`.trim();
export function connectionSupportsXuguServerDashboard(connection: ConnectionConfig | undefined): boolean {
return !!connection && effectiveDatabaseTypeForConnection(connection) === "xugu";
}
export function xuguClusterNodeStateLabel(state: string): "joining" | "running" | "error" | "offline" | "unknown" {
switch (Number(state)) {
case 1:
return "joining";
case 2:
return "running";
case 3:
return "error";
case 4:
return "offline";
default:
return "unknown";
}
}
export type XuguClusterNodeRole = "master" | "standby" | "storage" | "query" | "worker" | "change";
export function xuguClusterNodeTypeLabels(type: string): XuguClusterNodeRole[] {
const value = Number(type);
if (!Number.isFinite(value) || value <= 0) return [];
const roles: Array<[number, XuguClusterNodeRole]> = [
[1, "master"],
[2, "standby"],
[4, "storage"],
[8, "query"],
[16, "worker"],
[32, "change"],
];
return roles.filter(([bit]) => (value & bit) === bit).map(([, role]) => role);
}
export function xuguClusterNodesFromResult(result: QueryResult): XuguClusterNode[] {
return result.rows.map((row) => ({
nodeId: valueAt(result, row, "NODE_ID"),
rackNo: valueAt(result, row, "RACK_NO"),
host: valueAt(result, row, "NODE_IP"),
port: valueAt(result, row, "NODE_PORT"),
nodeType: valueAt(result, row, "NODE_TYPE"),
state: valueAt(result, row, "NODE_STATE"),
cpuLoad: valueAt(result, row, "CPU_LOAD"),
bootTime: valueAt(result, row, "BOOT_TIME"),
storeCount: valueAt(result, row, "STORE_NUM"),
majorCount: valueAt(result, row, "MAJOR_NUM"),
}));
}
export function xuguRunInfoFromResult(result: QueryResult): XuguRunInfo[] {
return result.rows.map((row) => ({
nodeId: valueAt(result, row, "NODEID"),
activeTransactions: valueAt(result, row, "ACT_TRANS_NUM"),
diskReadCount: valueAt(result, row, "DISK_R_N"),
diskReadBytes: valueAt(result, row, "DISK_R_BYTES"),
diskWriteCount: valueAt(result, row, "DISK_W_N"),
diskWriteBytes: valueAt(result, row, "DISK_W_BYTES"),
maxTransactionId: valueAt(result, row, "MAX_TRANS_ID"),
minTransactionId: valueAt(result, row, "MIN_TRANS_ID"),
xlogWritePosition: valueAt(result, row, "XLOG_WPOS"),
xlogCheckpointPosition: valueAt(result, row, "XLOG_CKPT"),
freeStores: valueAt(result, row, "FREE_STO_N"),
}));
}
export function xuguSessionSummaryFromResult(result: QueryResult): XuguSessionSummary[] {
return result.rows.map((row) => ({
nodeId: valueAt(result, row, "NODE_ID"),
sessions: valueAt(result, row, "SESSIONS"),
activeSessions: valueAt(result, row, "ACTIVE_SESSIONS"),
memoryBytes: valueAt(result, row, "MEMORY_BYTES"),
oldestStatement: valueAt(result, row, "OLDEST_STATEMENT"),
}));
}
export function xuguTransactionSummaryFromResult(result: QueryResult): XuguTransactionSummary[] {
return result.rows.map((row) => ({
nodeId: valueAt(result, row, "NODE_ID"),
activeTransactions: valueAt(result, row, "ACTIVE_TRANSACTIONS"),
oldestTransaction: valueAt(result, row, "OLDEST_TRANSACTION"),
}));
}
export function xuguMemoryStatusFromResult(result: QueryResult): XuguMemoryStatus[] {
return result.rows.map((row) => ({
nodeId: valueAt(result, row, "NODEID"),
bufferTotalBytes: valueAt(result, row, "BUFFER_TOTAL_BYTES"),
bufferFreeBytes: valueAt(result, row, "BUFFER_FREE_BYTES"),
bufferDirtyBytes: valueAt(result, row, "BUFFER_DIRTY_BYTES"),
bufferLruBytes: valueAt(result, row, "BUFFER_LRU_BYTES"),
sgaTotalBytes: valueAt(result, row, "SGA_TOTAL_BYTES"),
sgaFreeBytes: valueAt(result, row, "SGA_FREE_BYTES"),
sgaPeakBytes: valueAt(result, row, "SGA_PEAK_BYTES"),
swapTotalBytes: valueAt(result, row, "SWAP_TOTAL_BYTES"),
swapFreeBytes: valueAt(result, row, "SWAP_FREE_BYTES"),
}));
}
export function xuguSessionStatusSummaryFromResult(result: QueryResult): XuguSessionStatusSummary[] {
return result.rows.map((row) => ({
nodeId: valueAt(result, row, "NODE_ID"),
status: valueAt(result, row, "STATUS"),
sessions: valueAt(result, row, "SESSIONS"),
}));
}
export function xuguLockModeSummaryFromResult(result: QueryResult): XuguLockModeSummary[] {
return result.rows.map((row) => ({
nodeId: valueAt(result, row, "NODE_ID"),
lockMode: valueAt(result, row, "LOCK_MODE"),
locks: valueAt(result, row, "LOCKS"),
}));
}
export function xuguTablespaceSummaryFromResult(result: QueryResult): XuguTablespaceSummary[] {
return result.rows.map((row) => ({
nodeId: valueAt(result, row, "NODE_ID"),
spaceName: valueAt(result, row, "SPACE_NAME"),
spaceType: valueAt(result, row, "SPACE_TYPE"),
datafiles: valueAt(result, row, "DATAFILES"),
mediaError: valueAt(result, row, "MEDIA_ERROR"),
totalBytes: valueAt(result, row, "TOTAL_BYTES"),
freeBytes: valueAt(result, row, "FREE_BYTES"),
}));
}
export function xuguScalarFromResult(result: QueryResult, column: string): string {
return result.rows.length > 0 ? valueAt(result, result.rows[0], column) : "";
}
export function xuguVersionFromResult(result: QueryResult): string {
return xuguScalarFromResult(result, "VERSION");
}
function valueAt(result: QueryResult, row: unknown[], column: string): string {
const index = result.columns.findIndex((candidate) => candidate.toUpperCase() === column.toUpperCase());
return index < 0 || row[index] == null ? "" : String(row[index]);
}
+24
View File
@@ -2786,6 +2786,29 @@ export const useQueryStore = defineStore("query", () => {
return registerOpenTab(tab);
}
function openXuguDashboard(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "xugu-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("xuguServerDashboard.title")}` : t("xuguServerDashboard.title"),
connectionId,
database: conn?.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "xugu-dashboard",
};
return registerOpenTab(tab);
}
function openNacosDashboard(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "nacos-dashboard" && tab.connectionId === connectionId);
if (existing) {
@@ -7731,6 +7754,7 @@ export const useQueryStore = defineStore("query", () => {
openSqlServerActivityTrace,
openMysqlDashboard,
openPostgresDashboard,
openXuguDashboard,
openNacosDashboard,
openDamengUsers,
openDamengRoles,
+1
View File
@@ -1263,6 +1263,7 @@ export interface QueryTab {
| "sqlserver-trace"
| "mysql-dashboard"
| "postgres-dashboard"
| "xugu-dashboard"
| "dolt-version-control";
/** Ephemeral navigation intent; it is consumed by HBaseBrowser and is not persisted. */
hbaseCreateTableOnOpen?: boolean;