mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(sqlserver): add activity trace
This commit is contained in:
@@ -33,6 +33,7 @@ import { useNavigationTargets } from "@/composables/useNavigationTargets";
|
||||
import { useDataGridActions } from "@/composables/useDataGridActions";
|
||||
import { useTauriEvents } from "@/composables/useTauriEvents";
|
||||
import { useCloseActionPrompt, type AppCloseAction, type AppCloseRequestOptions } from "@/composables/useCloseActionPrompt";
|
||||
import { disposeAllSqlServerActivityTraces } from "@/lib/sqlserver/sqlServerActivityTraceRuntime";
|
||||
import { useVisibilityChange } from "@/composables/useVisibilityChange";
|
||||
import { useExternalSqlFileChanges } from "@/composables/useExternalSqlFileChanges";
|
||||
import { useWebDavAutoUpload } from "@/composables/useWebDavAutoUpload";
|
||||
@@ -947,7 +948,7 @@ function cancelPendingAppClose() {
|
||||
pendingSaveShouldCloseTab.value = true;
|
||||
}
|
||||
|
||||
function finishPendingAppClose(action: AppCloseAction) {
|
||||
async function finishPendingAppClose(action: AppCloseAction) {
|
||||
if (pendingCloseActionChoice.value) {
|
||||
pendingCloseActionChoice.value = false;
|
||||
showCloseActionPrompt.value = true;
|
||||
@@ -955,10 +956,9 @@ function finishPendingAppClose(action: AppCloseAction) {
|
||||
}
|
||||
pendingAppCloseAction.value = null;
|
||||
pendingSaveShouldCloseTab.value = true;
|
||||
void queryStore
|
||||
.flushPendingPersist()
|
||||
.catch(() => {})
|
||||
.finally(() => performCloseAction(action));
|
||||
if (action === "quit") await disposeAllSqlServerActivityTraces().catch(() => undefined);
|
||||
await queryStore.flushPendingPersist().catch(() => undefined);
|
||||
await performCloseAction(action);
|
||||
}
|
||||
|
||||
function continuePendingAppCloseAfterSave() {
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Activity, AlertTriangle, Download, Eraser, Loader2, Pause, Play, Search, Square, X } from "@lucide/vue";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { compactLocalTimestamp, sanitizeExportBaseName, saveTextFile } from "@/lib/export/saveTextFile";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { normalizeSqlServerTraceDurationMinutes, normalizeSqlServerTraceMaxEvents, sqlServerTraceEventsToCsv } from "@/lib/sqlserver/sqlServerActivityTrace";
|
||||
import { getSqlServerActivityTraceRuntime } from "@/lib/sqlserver/sqlServerActivityTraceRuntime";
|
||||
|
||||
const props = defineProps<{
|
||||
connection: ConnectionConfig;
|
||||
tabId: string;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { toast } = useToast();
|
||||
const traceRuntime = getSqlServerActivityTraceRuntime(props.tabId, props.connection.id, props.connection.database || "");
|
||||
const { status, selectedDatabase, includeStatements, maxEvents, durationMinutes, events, selectedEvent, sqlFilter, loginFilter, clientFilter, sessionFilter, error, sessionName, elapsedSeconds, autoStopReason, autoStopRevision, capabilityProblem, capabilityVersion, missingCapabilities } = toRefs(
|
||||
traceRuntime.state,
|
||||
);
|
||||
const databases = ref<string[]>([]);
|
||||
const loadingDatabases = ref(false);
|
||||
const tableScroller = ref<HTMLDivElement>();
|
||||
const tableElement = ref<HTMLTableElement>();
|
||||
const horizontalScrollbarTrack = ref<HTMLDivElement>();
|
||||
const horizontalScrollbarThumb = ref<HTMLDivElement>();
|
||||
const hasHorizontalOverflow = ref(false);
|
||||
let tableResizeObserver: ResizeObserver | undefined;
|
||||
let horizontalScrollbarDrag:
|
||||
| {
|
||||
trackRect: DOMRect;
|
||||
thumbOffset: number;
|
||||
}
|
||||
| undefined;
|
||||
const traceActive = computed(() => status.value === "running" || status.value === "paused");
|
||||
const traceSessionOpen = computed(() => !!sessionName.value || status.value === "starting" || traceActive.value || status.value === "stopping");
|
||||
const busy = computed(() => status.value === "starting" || status.value === "stopping");
|
||||
const canStart = computed(() => !!selectedDatabase.value && !traceActive.value && !busy.value);
|
||||
const statusLabel = computed(() => t(`sqlServerTrace.statuses.${status.value}`));
|
||||
const displayedError = computed(() => {
|
||||
if (!capabilityProblem.value) return error.value ? translateBackendError(t, error.value) : "";
|
||||
return t(`sqlServerTrace.capabilityErrors.${capabilityProblem.value}`, {
|
||||
version: capabilityVersion.value,
|
||||
capabilities: missingCapabilities.value.join(", "),
|
||||
});
|
||||
});
|
||||
const filteredEvents = computed(() => {
|
||||
const sql = sqlFilter.value.trim().toLowerCase();
|
||||
const login = loginFilter.value.trim().toLowerCase();
|
||||
const client = clientFilter.value.trim().toLowerCase();
|
||||
const session = sessionFilter.value.trim();
|
||||
return events.value.filter((event) => {
|
||||
if (sql && !event.sqlText.toLowerCase().includes(sql)) return false;
|
||||
if (login && !event.loginName.toLowerCase().includes(login)) return false;
|
||||
if (client && !`${event.clientApp} ${event.hostName}`.toLowerCase().includes(client)) return false;
|
||||
if (session && String(event.sessionId ?? "") !== session) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
function formatMetric(value: number | null, maximumFractionDigits = 2): string {
|
||||
if (value == null) return "-";
|
||||
return value.toLocaleString(undefined, { maximumFractionDigits });
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string): string {
|
||||
if (!value) return "-";
|
||||
const normalized = value.includes("T") ? value : value.replace(" ", "T");
|
||||
const date = new Date(`${normalized}Z`);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString(undefined, { hour12: false });
|
||||
}
|
||||
|
||||
function eventTypeLabel(eventName: string): string {
|
||||
const key = `sqlServerTrace.eventTypes.${eventName}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? eventName : translated;
|
||||
}
|
||||
|
||||
async function loadDatabases() {
|
||||
loadingDatabases.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connection.id);
|
||||
const result = await api.listDatabases(props.connection.id);
|
||||
databases.value = result.map((database) => database.name).filter(Boolean);
|
||||
if (!selectedDatabase.value || !databases.value.includes(selectedDatabase.value)) {
|
||||
selectedDatabase.value = databases.value.find((database) => !["master", "model", "msdb", "tempdb"].includes(database.toLowerCase())) || databases.value[0] || "";
|
||||
}
|
||||
} catch (cause) {
|
||||
error.value = translateBackendError(t, cause);
|
||||
} finally {
|
||||
loadingDatabases.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function syncHorizontalScrollbar() {
|
||||
const scroller = tableScroller.value;
|
||||
const track = horizontalScrollbarTrack.value;
|
||||
const thumb = horizontalScrollbarThumb.value;
|
||||
if (!scroller || !track || !thumb) return;
|
||||
const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
|
||||
hasHorizontalOverflow.value = maxScrollLeft > 1;
|
||||
const widthPercent = scroller.scrollWidth > 0 ? Math.min(100, (scroller.clientWidth / scroller.scrollWidth) * 100) : 100;
|
||||
const travelPercent = Math.max(0, 100 - widthPercent);
|
||||
const leftPercent = maxScrollLeft > 0 ? (scroller.scrollLeft / maxScrollLeft) * travelPercent : 0;
|
||||
thumb.style.width = `${widthPercent}%`;
|
||||
thumb.style.left = `${leftPercent}%`;
|
||||
}
|
||||
|
||||
function scrollTableHorizontally(event: WheelEvent) {
|
||||
const scroller = tableScroller.value;
|
||||
if (!scroller) return;
|
||||
const delta = event.shiftKey ? event.deltaY : Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : 0;
|
||||
if (!delta) return;
|
||||
const previousScrollLeft = scroller.scrollLeft;
|
||||
scroller.scrollLeft += delta;
|
||||
if (scroller.scrollLeft !== previousScrollLeft) event.preventDefault();
|
||||
}
|
||||
|
||||
function setHorizontalScrollFromPointer(clientX: number) {
|
||||
const scroller = tableScroller.value;
|
||||
const thumb = horizontalScrollbarThumb.value;
|
||||
const drag = horizontalScrollbarDrag;
|
||||
if (!scroller || !thumb || !drag) return;
|
||||
const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
|
||||
const maxThumbLeft = Math.max(0, drag.trackRect.width - thumb.offsetWidth);
|
||||
if (maxScrollLeft <= 0 || maxThumbLeft <= 0) return;
|
||||
const thumbLeft = Math.min(maxThumbLeft, Math.max(0, clientX - drag.trackRect.left - drag.thumbOffset));
|
||||
scroller.scrollLeft = (thumbLeft / maxThumbLeft) * maxScrollLeft;
|
||||
}
|
||||
|
||||
function moveHorizontalScrollbar(event: PointerEvent) {
|
||||
if (!horizontalScrollbarDrag) return;
|
||||
setHorizontalScrollFromPointer(event.clientX);
|
||||
}
|
||||
|
||||
function stopHorizontalScrollbarDrag() {
|
||||
horizontalScrollbarDrag = undefined;
|
||||
window.removeEventListener("pointermove", moveHorizontalScrollbar);
|
||||
window.removeEventListener("pointerup", stopHorizontalScrollbarDrag);
|
||||
window.removeEventListener("pointercancel", stopHorizontalScrollbarDrag);
|
||||
}
|
||||
|
||||
function startHorizontalScrollbarDrag(event: PointerEvent) {
|
||||
if (!hasHorizontalOverflow.value || event.button !== 0) return;
|
||||
const track = horizontalScrollbarTrack.value;
|
||||
const thumb = horizontalScrollbarThumb.value;
|
||||
if (!track || !thumb) return;
|
||||
event.preventDefault();
|
||||
const trackRect = track.getBoundingClientRect();
|
||||
const thumbRect = thumb.getBoundingClientRect();
|
||||
horizontalScrollbarDrag = {
|
||||
trackRect,
|
||||
thumbOffset: event.target === thumb ? event.clientX - thumbRect.left : thumbRect.width / 2,
|
||||
};
|
||||
window.addEventListener("pointermove", moveHorizontalScrollbar);
|
||||
window.addEventListener("pointerup", stopHorizontalScrollbarDrag);
|
||||
window.addEventListener("pointercancel", stopHorizontalScrollbarDrag);
|
||||
setHorizontalScrollFromPointer(event.clientX);
|
||||
}
|
||||
|
||||
function observeTableSize() {
|
||||
tableResizeObserver?.disconnect();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
tableResizeObserver = new ResizeObserver(syncHorizontalScrollbar);
|
||||
if (tableScroller.value) tableResizeObserver.observe(tableScroller.value);
|
||||
if (tableElement.value) tableResizeObserver.observe(tableElement.value);
|
||||
syncHorizontalScrollbar();
|
||||
}
|
||||
|
||||
async function startTrace() {
|
||||
if (!canStart.value) return;
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connection.id);
|
||||
await traceRuntime.start();
|
||||
} catch (cause) {
|
||||
error.value = translateBackendError(t, cause);
|
||||
}
|
||||
}
|
||||
|
||||
function pauseTrace() {
|
||||
traceRuntime.pause();
|
||||
}
|
||||
|
||||
async function resumeTrace() {
|
||||
await traceRuntime.resume();
|
||||
}
|
||||
|
||||
async function stopTrace(reason?: "duration" | "events") {
|
||||
await traceRuntime.stop(reason);
|
||||
}
|
||||
|
||||
function clearEvents() {
|
||||
traceRuntime.clearEvents();
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
if (filteredEvents.value.length === 0) return;
|
||||
const base = sanitizeExportBaseName(`${props.connection.name}-${selectedDatabase.value}-activity-trace`) || "sqlserver-activity-trace";
|
||||
await saveTextFile(sqlServerTraceEventsToCsv(filteredEvents.value), `${base}-${compactLocalTimestamp()}.csv`, "CSV", "csv");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadDatabases();
|
||||
void nextTick(observeTableSize);
|
||||
});
|
||||
watch(autoStopRevision, () => {
|
||||
if (autoStopReason.value) toast(t(`sqlServerTrace.autoStopped.${autoStopReason.value}`), 4000);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
stopHorizontalScrollbarDrag();
|
||||
tableResizeObserver?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col bg-background">
|
||||
<div class="flex min-h-11 shrink-0 flex-wrap items-center gap-2 border-b bg-muted/20 px-3 py-1.5">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Activity class="h-4 w-4 text-primary" />
|
||||
<span class="truncate text-sm font-semibold">{{ t("sqlServerTrace.title") }}</span>
|
||||
<Badge variant="outline" class="h-5 max-w-48 rounded-md px-1.5 text-[11px]"
|
||||
><span class="truncate">{{ connection.name }}</span></Badge
|
||||
>
|
||||
<Badge :variant="traceActive ? 'default' : 'secondary'" class="h-5 rounded-md px-1.5 text-[11px]">{{ statusLabel }}</Badge>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-1.5">
|
||||
<span v-if="traceActive || status === 'stopped'" class="text-[11px] tabular-nums text-muted-foreground">{{ t("sqlServerTrace.elapsed", { seconds: elapsedSeconds }) }}</span>
|
||||
<Button v-if="!traceSessionOpen" size="sm" class="h-7 gap-1.5 px-2.5 text-xs" :disabled="!canStart" @click="startTrace">
|
||||
<Loader2 v-if="status === 'starting'" class="h-3.5 w-3.5 animate-spin" />
|
||||
<Play v-else class="h-3.5 w-3.5" />
|
||||
{{ t("sqlServerTrace.start") }}
|
||||
</Button>
|
||||
<Button v-else-if="status === 'running'" variant="outline" size="sm" class="h-7 gap-1.5 px-2.5 text-xs" @click="pauseTrace"> <Pause class="h-3.5 w-3.5" />{{ t("sqlServerTrace.pause") }} </Button>
|
||||
<Button v-else-if="status === 'paused'" variant="outline" size="sm" class="h-7 gap-1.5 px-2.5 text-xs" @click="resumeTrace"> <Play class="h-3.5 w-3.5" />{{ t("sqlServerTrace.resume") }} </Button>
|
||||
<Button v-else variant="outline" size="sm" class="h-7 gap-1.5 px-2.5 text-xs" disabled> <Loader2 class="h-3.5 w-3.5 animate-spin" />{{ statusLabel }} </Button>
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2.5 text-xs" :disabled="!sessionName || busy" @click="stopTrace()">
|
||||
<Loader2 v-if="status === 'stopping'" class="h-3.5 w-3.5 animate-spin" />
|
||||
<Square v-else class="h-3.5 w-3.5" />
|
||||
{{ t("sqlServerTrace.stop") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('sqlServerTrace.clear')" :aria-label="t('sqlServerTrace.clear')" :disabled="events.length === 0" @click="clearEvents">
|
||||
<Eraser class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('sqlServerTrace.export')" :aria-label="t('sqlServerTrace.export')" :disabled="filteredEvents.length === 0" @click="exportCsv">
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid shrink-0 grid-cols-[minmax(150px,1.1fr)_minmax(130px,0.9fr)_minmax(130px,0.9fr)_100px_auto] items-end gap-2 border-b px-3 py-2">
|
||||
<label class="grid min-w-0 gap-1 text-[11px] text-muted-foreground">
|
||||
<span>{{ t("sqlServerTrace.database") }}</span>
|
||||
<Select v-model="selectedDatabase" :disabled="traceActive || busy || loadingDatabases">
|
||||
<SelectTrigger class="h-7 min-w-0 text-xs"><SelectValue :placeholder="loadingDatabases ? t('common.loading') : t('sqlServerTrace.selectDatabase')" /></SelectTrigger>
|
||||
<SelectContent
|
||||
><SelectItem v-for="database in databases" :key="database" :value="database">{{ database }}</SelectItem></SelectContent
|
||||
>
|
||||
</Select>
|
||||
</label>
|
||||
<label class="grid gap-1 text-[11px] text-muted-foreground">
|
||||
<span>{{ t("sqlServerTrace.maxEvents") }}</span>
|
||||
<Input v-model.number="maxEvents" type="number" min="100" max="5000" step="100" class="h-7 text-xs" :disabled="traceActive || busy" @blur="maxEvents = normalizeSqlServerTraceMaxEvents(maxEvents)" />
|
||||
</label>
|
||||
<label class="grid gap-1 text-[11px] text-muted-foreground">
|
||||
<span>{{ t("sqlServerTrace.duration") }}</span>
|
||||
<Input v-model.number="durationMinutes" type="number" min="1" max="60" class="h-7 text-xs" :disabled="traceActive || busy" @blur="durationMinutes = normalizeSqlServerTraceDurationMinutes(durationMinutes)" />
|
||||
</label>
|
||||
<label class="flex h-7 items-center gap-2 text-xs" :title="t('sqlServerTrace.statementsHint')">
|
||||
<Switch v-model="includeStatements" :disabled="traceActive || busy" />
|
||||
<span class="whitespace-nowrap">{{ t("sqlServerTrace.statements") }}</span>
|
||||
</label>
|
||||
<span class="pb-1 text-[11px] text-amber-700 dark:text-amber-300">{{ includeStatements ? t("sqlServerTrace.highCost") : "" }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid shrink-0 grid-cols-[minmax(180px,2fr)_minmax(110px,0.8fr)_minmax(140px,1fr)_minmax(120px,0.75fr)] gap-2 overflow-x-auto border-b bg-muted/10 px-3 py-2">
|
||||
<label class="relative min-w-0"><Search class="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" /><Input v-model="sqlFilter" class="h-7 pl-7 text-xs" :placeholder="t('sqlServerTrace.filterSql')" /></label>
|
||||
<Input v-model="loginFilter" class="h-7 min-w-0 text-xs" :placeholder="t('sqlServerTrace.filterLogin')" />
|
||||
<Input v-model="clientFilter" class="h-7 min-w-0 text-xs" :placeholder="t('sqlServerTrace.filterClient')" />
|
||||
<Input v-model="sessionFilter" inputmode="numeric" class="h-7 min-w-0 text-xs" :placeholder="t('sqlServerTrace.filterSession')" />
|
||||
</div>
|
||||
|
||||
<div v-if="displayedError" class="flex shrink-0 items-start gap-2 border-b border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span data-native-clipboard class="min-w-0 flex-1 select-text whitespace-pre-wrap">{{ displayedError }}</span>
|
||||
<button type="button" class="rounded p-0.5 hover:bg-destructive/10" :aria-label="t('common.close')" @click="((error = ''), (capabilityProblem = null))"><X class="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
|
||||
<div class="relative flex min-h-0 flex-1 flex-col">
|
||||
<div ref="tableScroller" class="sqlserver-trace-table-scroller min-h-0 flex-1" @scroll="syncHorizontalScrollbar" @wheel="scrollTableHorizontally">
|
||||
<table ref="tableElement" class="w-full min-w-[1180px] table-fixed border-collapse text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-muted/95 text-left text-[11px] text-muted-foreground backdrop-blur">
|
||||
<tr>
|
||||
<th class="w-40 border-b px-2 py-1.5 font-medium">{{ t("sqlServerTrace.columns.time") }}</th>
|
||||
<th class="w-32 border-b px-2 py-1.5 font-medium">{{ t("sqlServerTrace.columns.event") }}</th>
|
||||
<th class="border-b px-2 py-1.5 font-medium">{{ t("sqlServerTrace.columns.sql") }}</th>
|
||||
<th class="w-20 border-b px-2 py-1.5 text-right font-medium">{{ t("sqlServerTrace.columns.duration") }}</th>
|
||||
<th class="w-20 border-b px-2 py-1.5 text-right font-medium">{{ t("sqlServerTrace.columns.cpu") }}</th>
|
||||
<th class="w-20 border-b px-2 py-1.5 text-right font-medium">{{ t("sqlServerTrace.columns.reads") }}</th>
|
||||
<th class="w-16 border-b px-2 py-1.5 text-right font-medium">{{ t("sqlServerTrace.columns.writes") }}</th>
|
||||
<th class="w-20 border-b px-2 py-1.5 text-right font-medium">{{ t("sqlServerTrace.columns.rows") }}</th>
|
||||
<th class="w-20 border-b px-2 py-1.5 text-right font-medium">{{ t("sqlServerTrace.columns.session") }}</th>
|
||||
<th class="w-28 border-b px-2 py-1.5 font-medium">{{ t("sqlServerTrace.columns.login") }}</th>
|
||||
<th class="w-36 border-b px-2 py-1.5 font-medium">{{ t("sqlServerTrace.columns.client") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="event in filteredEvents" :key="event.key" class="cursor-pointer border-b border-border/60 hover:bg-muted/35" :class="{ 'bg-primary/5': selectedEvent?.key === event.key }" @click="selectedEvent = event">
|
||||
<td class="truncate px-2 py-1.5 tabular-nums" :title="event.timestamp">{{ formatTimestamp(event.timestamp) }}</td>
|
||||
<td class="truncate px-2 py-1.5" :title="event.eventName">{{ eventTypeLabel(event.eventName) }}</td>
|
||||
<td class="truncate px-2 py-1.5 font-mono" :title="event.sqlText">{{ event.sqlText || "-" }}</td>
|
||||
<td class="px-2 py-1.5 text-right tabular-nums">{{ formatMetric(event.durationMs) }}</td>
|
||||
<td class="px-2 py-1.5 text-right tabular-nums">{{ formatMetric(event.cpuMs) }}</td>
|
||||
<td class="px-2 py-1.5 text-right tabular-nums">{{ formatMetric(event.logicalReads, 0) }}</td>
|
||||
<td class="px-2 py-1.5 text-right tabular-nums">{{ formatMetric(event.writes, 0) }}</td>
|
||||
<td class="px-2 py-1.5 text-right tabular-nums">{{ formatMetric(event.rowCount, 0) }}</td>
|
||||
<td class="px-2 py-1.5 text-right tabular-nums">{{ event.sessionId ?? "-" }}</td>
|
||||
<td class="truncate px-2 py-1.5" :title="event.loginName">{{ event.loginName || "-" }}</td>
|
||||
<td class="truncate px-2 py-1.5" :title="`${event.clientApp} ${event.hostName}`">{{ event.clientApp || event.hostName || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="filteredEvents.length === 0">
|
||||
<td colspan="11" class="h-32 text-center text-muted-foreground">
|
||||
{{ events.length > 0 ? t("sqlServerTrace.noMatches") : t("sqlServerTrace.noEvents") }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div ref="horizontalScrollbarTrack" class="sqlserver-trace-horizontal-scrollbar" :class="{ 'sqlserver-trace-horizontal-scrollbar--visible': hasHorizontalOverflow, 'sqlserver-trace-horizontal-scrollbar--dragging': horizontalScrollbarDrag }" @pointerdown="startHorizontalScrollbarDrag">
|
||||
<div ref="horizontalScrollbarThumb" class="sqlserver-trace-horizontal-scrollbar__thumb" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedEvent" class="grid max-h-40 shrink-0 grid-cols-[minmax(0,2fr)_minmax(220px,1fr)] border-t bg-muted/10 text-xs">
|
||||
<pre data-native-clipboard class="min-h-20 overflow-auto whitespace-pre-wrap break-words border-r p-3 font-mono select-text">{{ selectedEvent.sqlText || "-" }}</pre>
|
||||
<dl class="grid content-start grid-cols-[90px_minmax(0,1fr)] gap-x-2 gap-y-1 overflow-auto p-3">
|
||||
<dt class="text-muted-foreground">{{ t("sqlServerTrace.columns.database") }}</dt>
|
||||
<dd class="truncate" :title="selectedEvent.database">{{ selectedEvent.database || "-" }}</dd>
|
||||
<dt class="text-muted-foreground">{{ t("sqlServerTrace.columns.host") }}</dt>
|
||||
<dd class="truncate" :title="selectedEvent.hostName">{{ selectedEvent.hostName || "-" }}</dd>
|
||||
<dt class="text-muted-foreground">{{ t("sqlServerTrace.columns.client") }}</dt>
|
||||
<dd class="truncate" :title="selectedEvent.clientApp">{{ selectedEvent.clientApp || "-" }}</dd>
|
||||
<dt class="text-muted-foreground">{{ t("sqlServerTrace.columns.result") }}</dt>
|
||||
<dd class="truncate" :title="selectedEvent.result">{{ selectedEvent.result || "-" }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="flex h-7 shrink-0 items-center justify-between border-t px-3 text-[11px] text-muted-foreground">
|
||||
<span>{{ t("sqlServerTrace.eventCount", { visible: filteredEvents.length, total: events.length }) }}</span>
|
||||
<span>{{ t("sqlServerTrace.permissionHint") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sqlserver-trace-table-scroller {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sqlserver-trace-table-scroller::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.sqlserver-trace-horizontal-scrollbar {
|
||||
position: relative;
|
||||
height: 0;
|
||||
flex: 0 0 0;
|
||||
cursor: default;
|
||||
touch-action: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.sqlserver-trace-horizontal-scrollbar--visible {
|
||||
height: 10px;
|
||||
flex-basis: 10px;
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sqlserver-trace-horizontal-scrollbar::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline: 4px;
|
||||
top: 4px;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--muted-foreground) 22%, transparent);
|
||||
}
|
||||
|
||||
.sqlserver-trace-horizontal-scrollbar__thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
height: 4px;
|
||||
min-width: 32px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--muted-foreground) 55%, transparent);
|
||||
transition:
|
||||
height 120ms ease,
|
||||
top 120ms ease,
|
||||
background-color 120ms ease;
|
||||
}
|
||||
|
||||
.sqlserver-trace-horizontal-scrollbar:hover .sqlserver-trace-horizontal-scrollbar__thumb,
|
||||
.sqlserver-trace-horizontal-scrollbar--dragging .sqlserver-trace-horizontal-scrollbar__thumb {
|
||||
top: 2px;
|
||||
height: 6px;
|
||||
background: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -534,7 +534,7 @@ function tabMenuIcon(tab: QueryTab) {
|
||||
if (tab.mode === "objects") return TableProperties;
|
||||
if (tab.mode === "structure") return PencilRuler;
|
||||
if (tab.mode === "dameng-jobs") return CalendarClock;
|
||||
if (tab.mode === "processlist") return Activity;
|
||||
if (tab.mode === "processlist" || tab.mode === "sqlserver-trace") return Activity;
|
||||
if (tab.mode === "mysql-dashboard" || tab.mode === "postgres-dashboard" || tab.mode === "nacos-dashboard") return Gauge;
|
||||
return Code2;
|
||||
}
|
||||
@@ -682,7 +682,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
|
||||
<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" />
|
||||
<Activity v-else-if="tab.mode === 'processlist' || tab.mode === 'sqlserver-trace'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard' || tab.mode === 'nacos-dashboard'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
@@ -880,7 +880,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
|
||||
<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" />
|
||||
<Activity v-else-if="tab.mode === 'processlist' || tab.mode === 'sqlserver-trace'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard' || tab.mode === 'nacos-dashboard'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
|
||||
@@ -100,6 +100,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 ProcessListPanel = defineAsyncComponent(() => import("@/components/admin/ProcessListPanel.vue"));
|
||||
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 DamengJobAdmin = defineAsyncComponent(() => import("@/components/admin/DamengJobAdmin.vue"));
|
||||
@@ -2147,6 +2148,10 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
<ProcessListPanel :key="activeTab.id" :connection="activeConnection" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'sqlserver-trace' && activeConnection">
|
||||
<SqlServerActivityTracePanel :key="activeTab.id" :connection="activeConnection" :tab-id="activeTab.id" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'mysql-dashboard'">
|
||||
<div class="min-h-0 flex-1">
|
||||
<MySqlDashboard :key="activeTab.id" :connection-id="activeTab.connectionId" :client-session-id="activeTab.id" />
|
||||
|
||||
@@ -1293,6 +1293,18 @@ async function openProcessList() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openSqlServerActivityTrace() {
|
||||
const node = activeNode.value;
|
||||
if (!node.connectionId) return;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
connectionStore.activeConnectionId = node.connectionId;
|
||||
queryStore.openSqlServerActivityTrace(node.connectionId);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function openServerDashboard() {
|
||||
const node = activeNode.value;
|
||||
if (!node.connectionId) return;
|
||||
@@ -4184,6 +4196,9 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
|
||||
if (node.connectionId && connectionSupportsProcessList(connectionStore.getConfig(node.connectionId))) {
|
||||
items.push({ label: t("contextMenu.processList"), action: openProcessList, icon: Activity });
|
||||
}
|
||||
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)))) {
|
||||
items.push({ label: t("contextMenu.serverDashboard"), action: openServerDashboard, icon: Gauge });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import docs from "./docs/en";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
@@ -2559,6 +2560,7 @@ export default {
|
||||
userAdmin: "Users & Privileges",
|
||||
openUserAdmin: "Open Users & Privileges",
|
||||
processList: "Process List",
|
||||
sqlServerTrace: "Activity Trace",
|
||||
serverDashboard: "Server Dashboard",
|
||||
damengUsers: "Dameng Users",
|
||||
openDamengUsers: "Open Dameng User Admin",
|
||||
@@ -2967,6 +2969,7 @@ export default {
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
},
|
||||
sqlServerTrace,
|
||||
serverDashboard: {
|
||||
title: "Server Dashboard",
|
||||
autoRefresh: "Auto-refresh",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/es";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
@@ -2780,6 +2781,7 @@ export default withEnglishFallback({
|
||||
manageExtension: "Administrar extensión...",
|
||||
dropExtension: "Eliminar extensión",
|
||||
processList: "Lista de procesos",
|
||||
sqlServerTrace: "Seguimiento de actividad",
|
||||
serverDashboard: "Panel de control del servidor",
|
||||
createProcedure: "Nuevo procedimiento almacenado",
|
||||
createFunction: "Nueva función",
|
||||
@@ -3914,6 +3916,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
},
|
||||
consul,
|
||||
sqlServerTrace,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "Prefijo de ruta, ej. /app/",
|
||||
newKey: "Nuevo Znode",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/it";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
prefixPlaceholder: "Key prefix, e.g. app/ or services/",
|
||||
@@ -2778,6 +2779,7 @@ export default withEnglishFallback({
|
||||
manageExtension: "Gestisci estensione...",
|
||||
dropExtension: "Elimina estensione",
|
||||
processList: "Elenco processi",
|
||||
sqlServerTrace: "Tracciamento attività",
|
||||
serverDashboard: "Dashboard del server",
|
||||
createProcedure: "Nuovo stored procedure",
|
||||
createFunction: "Nuova funzione",
|
||||
@@ -3912,6 +3914,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
},
|
||||
consul,
|
||||
sqlServerTrace,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "Prefisso percorso, es. /app/",
|
||||
newKey: "Nuovo Znode",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/ja";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
@@ -2805,6 +2806,7 @@ export default withEnglishFallback({
|
||||
manageExtension: "拡張機能を管理...",
|
||||
dropExtension: "拡張機能を削除",
|
||||
processList: "プロセス一覧",
|
||||
sqlServerTrace: "アクティビティ追跡",
|
||||
serverDashboard: "サーバーダッシュボード",
|
||||
createProcedure: "新規ストアドプロシージャ",
|
||||
createFunction: "新規関数",
|
||||
@@ -2881,6 +2883,7 @@ export default withEnglishFallback({
|
||||
buckets: "バケット",
|
||||
},
|
||||
consul,
|
||||
sqlServerTrace,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "パスプレフィックス(例: /app/)",
|
||||
newKey: "新しいZnode",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/ko";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
@@ -2415,6 +2416,7 @@ export default withEnglishFallback({
|
||||
userAdmin: "사용자 및 권한",
|
||||
openUserAdmin: "사용자 및 권한 열기",
|
||||
processList: "프로세스 목록",
|
||||
sqlServerTrace: "활동 추적",
|
||||
serverDashboard: "서버 대시보드",
|
||||
damengJobAdmin: "Dameng 에이전트 작업",
|
||||
openDamengJobAdmin: "Dameng 에이전트 작업 열기",
|
||||
@@ -3583,6 +3585,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
},
|
||||
consul,
|
||||
sqlServerTrace,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "경로 접두사, 예: /app/",
|
||||
newKey: "새 Znode",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/pt-BR";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
@@ -2780,6 +2781,7 @@ export default withEnglishFallback({
|
||||
manageExtension: "Gerenciar extensão...",
|
||||
dropExtension: "Remover extensão",
|
||||
processList: "Lista de Processos",
|
||||
sqlServerTrace: "Rastreamento de atividade",
|
||||
serverDashboard: "Painel do Servidor",
|
||||
createProcedure: "Novo procedimento armazenado",
|
||||
createFunction: "Nova função",
|
||||
@@ -3914,6 +3916,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
},
|
||||
consul,
|
||||
sqlServerTrace,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "Prefixo de caminho, ex.: /app/",
|
||||
newKey: "Novo Znode",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
export const sqlServerTraceMessages = {
|
||||
title: "SQL Server Activity Trace",
|
||||
start: "Start",
|
||||
pause: "Pause polling",
|
||||
resume: "Resume",
|
||||
stop: "Stop",
|
||||
clear: "Clear events",
|
||||
export: "Export filtered events as CSV",
|
||||
database: "Database",
|
||||
selectDatabase: "Select a database",
|
||||
maxEvents: "Event limit",
|
||||
duration: "Duration (min)",
|
||||
statements: "Statements",
|
||||
statementsHint: "Capture statements inside stored procedures. This event can add significant overhead.",
|
||||
highCost: "Higher overhead",
|
||||
elapsed: "{seconds}s",
|
||||
eventCount: "Showing {visible} / {total} events",
|
||||
cleanedStaleSessions: "Cleaned {count} expired DBX trace session(s).",
|
||||
permissionHint: "Requires ALTER ANY EVENT SESSION and server-state viewing permissions",
|
||||
filterSql: "Filter procedure or SQL text",
|
||||
filterLogin: "Login",
|
||||
filterClient: "Client or host",
|
||||
filterSession: "Session ID",
|
||||
noEvents: "Start tracing, then execute SQL or a stored procedure in another session.",
|
||||
noMatches: "No events match the current filters.",
|
||||
statuses: {
|
||||
idle: "Ready",
|
||||
starting: "Starting",
|
||||
running: "Running",
|
||||
paused: "Polling paused",
|
||||
stopping: "Stopping",
|
||||
stopped: "Stopped",
|
||||
error: "Error",
|
||||
},
|
||||
eventTypes: {
|
||||
rpc_completed: "RPC completed",
|
||||
sql_batch_completed: "Batch completed",
|
||||
sp_statement_completed: "Procedure statement",
|
||||
},
|
||||
columns: {
|
||||
time: "Time",
|
||||
event: "Event",
|
||||
database: "Database",
|
||||
sql: "Procedure / SQL and parameters",
|
||||
duration: "Duration ms",
|
||||
cpu: "CPU ms",
|
||||
reads: "Reads",
|
||||
writes: "Writes",
|
||||
rows: "Rows",
|
||||
session: "Session",
|
||||
login: "Login",
|
||||
host: "Host",
|
||||
client: "Client",
|
||||
result: "Result",
|
||||
},
|
||||
autoStopped: {
|
||||
duration: "Activity trace stopped after reaching the duration limit.",
|
||||
events: "Activity trace stopped after reaching the event limit.",
|
||||
},
|
||||
capabilityErrors: {
|
||||
"unsupported-version": "SQL Server {version} does not support this Extended Events trace. SQL Server 2008 or later is required.",
|
||||
"unsupported-engine": "This SQL Server edition uses database-scoped Extended Events, which are not supported in the first release.",
|
||||
"alter-permission": "The current login lacks ALTER ANY EVENT SESSION permission.",
|
||||
"view-permission": "The current login lacks the server-state permission required to read trace events.",
|
||||
"missing-capability": "This SQL Server instance is missing required Extended Events capabilities: {capabilities}.",
|
||||
},
|
||||
};
|
||||
@@ -2486,6 +2486,7 @@ export default withEnglishFallback({
|
||||
userAdmin: "用户与权限",
|
||||
openUserAdmin: "打开用户与权限",
|
||||
processList: "进程列表",
|
||||
sqlServerTrace: "活动跟踪",
|
||||
serverDashboard: "服务器仪表盘",
|
||||
damengUsers: "达梦用户",
|
||||
openDamengUsers: "打开达梦用户管理",
|
||||
@@ -2892,6 +2893,73 @@ export default withEnglishFallback({
|
||||
copy: "复制",
|
||||
copied: "已复制",
|
||||
},
|
||||
sqlServerTrace: {
|
||||
title: "SQL Server 活动跟踪",
|
||||
start: "开始",
|
||||
pause: "暂停轮询",
|
||||
resume: "继续",
|
||||
stop: "停止",
|
||||
clear: "清空事件",
|
||||
export: "将筛选后的事件导出为 CSV",
|
||||
database: "数据库",
|
||||
selectDatabase: "选择数据库",
|
||||
maxEvents: "事件上限",
|
||||
duration: "持续时间(分钟)",
|
||||
statements: "过程内语句",
|
||||
statementsHint: "采集存储过程内部逐条语句。此事件可能产生较明显的额外开销。",
|
||||
highCost: "较高开销",
|
||||
elapsed: "已运行 {seconds} 秒",
|
||||
eventCount: "显示 {visible} / {total} 个事件",
|
||||
cleanedStaleSessions: "已清理 {count} 个超时遗留的 DBX 跟踪会话。",
|
||||
permissionHint: "需要 ALTER ANY EVENT SESSION 及查看服务器状态权限",
|
||||
filterSql: "筛选过程名或 SQL 文本",
|
||||
filterLogin: "登录用户",
|
||||
filterClient: "客户端或主机",
|
||||
filterSession: "Session ID",
|
||||
noEvents: "开始跟踪后,请在另一个会话中执行 SQL 或存储过程。",
|
||||
noMatches: "没有符合当前筛选条件的事件。",
|
||||
statuses: {
|
||||
idle: "就绪",
|
||||
starting: "正在启动",
|
||||
running: "跟踪中",
|
||||
paused: "已暂停轮询",
|
||||
stopping: "正在停止",
|
||||
stopped: "已停止",
|
||||
error: "出错",
|
||||
},
|
||||
eventTypes: {
|
||||
rpc_completed: "RPC 完成",
|
||||
sql_batch_completed: "批处理完成",
|
||||
sp_statement_completed: "过程内语句",
|
||||
},
|
||||
columns: {
|
||||
time: "时间",
|
||||
event: "事件",
|
||||
database: "数据库",
|
||||
sql: "过程 / SQL 及参数",
|
||||
duration: "耗时 ms",
|
||||
cpu: "CPU ms",
|
||||
reads: "逻辑读取",
|
||||
writes: "写入",
|
||||
rows: "行数",
|
||||
session: "会话",
|
||||
login: "登录用户",
|
||||
host: "主机",
|
||||
client: "客户端",
|
||||
result: "结果",
|
||||
},
|
||||
autoStopped: {
|
||||
duration: "已达到持续时间上限,活动跟踪已自动停止。",
|
||||
events: "已达到事件数量上限,活动跟踪已自动停止。",
|
||||
},
|
||||
capabilityErrors: {
|
||||
"unsupported-version": "SQL Server {version} 不支持此 Extended Events 跟踪,需要 SQL Server 2008 或更高版本。",
|
||||
"unsupported-engine": "当前 SQL Server 版本使用数据库级 Extended Events,第一版暂不支持。",
|
||||
"alter-permission": "当前登录用户缺少 ALTER ANY EVENT SESSION 权限。",
|
||||
"view-permission": "当前登录用户缺少读取跟踪事件所需的服务器状态查看权限。",
|
||||
"missing-capability": "当前 SQL Server 实例缺少所需的 Extended Events 能力:{capabilities}。",
|
||||
},
|
||||
},
|
||||
serverDashboard: {
|
||||
title: "服务器仪表盘",
|
||||
autoRefresh: "自动刷新",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/zh-TW";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
@@ -2779,6 +2780,7 @@ export default withEnglishFallback({
|
||||
manageExtension: "管理擴展...",
|
||||
dropExtension: "刪除擴展",
|
||||
processList: "處理程序清單",
|
||||
sqlServerTrace: "活動追蹤",
|
||||
serverDashboard: "伺服器儀表板",
|
||||
createProcedure: "新增儲存程序",
|
||||
createFunction: "新增函數",
|
||||
@@ -6389,6 +6391,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
},
|
||||
consul,
|
||||
sqlServerTrace,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "路徑前綴,例如 /app/",
|
||||
newKey: "新增 Znode",
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
import {
|
||||
buildCreateSqlServerTraceSessionSql,
|
||||
buildDropSqlServerTraceSessionSql,
|
||||
buildCleanupSqlServerTraceSessionsSql,
|
||||
buildListSqlServerTraceSessionsSql,
|
||||
buildReadSqlServerTraceEventsSql,
|
||||
buildSqlServerTraceCapabilitiesSql,
|
||||
buildSqlServerTraceSessionName,
|
||||
missingSqlServerTraceCapabilities,
|
||||
normalizeSqlServerTraceDurationMinutes,
|
||||
normalizeSqlServerTraceMaxEvents,
|
||||
mergeSqlServerTraceEventSnapshot,
|
||||
parseSqlServerTraceCapabilities,
|
||||
parseSqlServerTraceEvents,
|
||||
sqlServerTraceCapabilityProblem,
|
||||
sqlServerTraceSessionExpiresAt,
|
||||
sqlServerTraceEventsToCsv,
|
||||
staleSqlServerTraceSessionNames,
|
||||
} from "../sqlServerActivityTrace";
|
||||
|
||||
function result(columns: string[], rows: QueryResult["rows"]): QueryResult {
|
||||
return { columns, rows, affected_rows: 0, execution_time_ms: 0 };
|
||||
}
|
||||
|
||||
describe("SQL Server activity trace", () => {
|
||||
it("builds bounded, database-scoped XE sessions", () => {
|
||||
const sql = buildCreateSqlServerTraceSessionSql({
|
||||
sessionName: "DBX_TRACE_ABC_123",
|
||||
databaseId: 7,
|
||||
maxEvents: 99_999,
|
||||
includeStatements: true,
|
||||
});
|
||||
expect(sql).toContain("ADD EVENT sqlserver.rpc_completed");
|
||||
expect(sql).toContain("ADD EVENT sqlserver.sql_batch_completed");
|
||||
expect(sql).toContain("ADD EVENT sqlserver.sp_statement_completed");
|
||||
expect(sql).toContain("[sqlserver].[database_id]=(7)");
|
||||
expect(sql).toContain("like_i_sql_unicode_string");
|
||||
expect(sql).toContain("max_events_limit=(256)");
|
||||
expect(sql).toContain("STARTUP_STATE=OFF");
|
||||
});
|
||||
|
||||
it("rejects unsafe session names and escapes database literals", () => {
|
||||
expect(() => buildReadSqlServerTraceEventsSql("DBX_TRACE_X]; DROP TABLE x;--")).toThrow(/Invalid/);
|
||||
expect(() => buildDropSqlServerTraceSessionSql("other_session")).toThrow(/Invalid/);
|
||||
expect(buildSqlServerTraceCapabilitiesSql("db'o")).toContain("DB_ID(N'db''o')");
|
||||
});
|
||||
|
||||
it("normalizes resource limits and generates safe names", () => {
|
||||
expect(normalizeSqlServerTraceMaxEvents(1)).toBe(100);
|
||||
expect(normalizeSqlServerTraceMaxEvents(9000)).toBe(5000);
|
||||
expect(normalizeSqlServerTraceDurationMinutes(0)).toBe(1);
|
||||
expect(normalizeSqlServerTraceDurationMinutes(99)).toBe(60);
|
||||
expect(buildSqlServerTraceSessionName(123456789, 0.25)).toMatch(/^DBX_TRACE_[A-Z0-9]+_[A-Z0-9]{8}$/);
|
||||
expect(sqlServerTraceSessionExpiresAt(buildSqlServerTraceSessionName(123456789, 0.25))).toBe(123456789);
|
||||
});
|
||||
|
||||
it("identifies only expired DBX sessions for cleanup", () => {
|
||||
const now = Date.UTC(2026, 7, 12, 12);
|
||||
const oldName = buildSqlServerTraceSessionName(now - 1, 0.1);
|
||||
const activeName = buildSqlServerTraceSessionName(now + 10 * 60_000, 0.2);
|
||||
const stale = staleSqlServerTraceSessionNames(result(["name"], [[oldName], [activeName], ["OTHER_TRACE"]]), now);
|
||||
expect(stale).toEqual([oldName]);
|
||||
expect(buildListSqlServerTraceSessionsSql()).toContain("DBX_TRACE_%");
|
||||
const cleanup = buildCleanupSqlServerTraceSessionsSql(stale);
|
||||
expect(cleanup).toContain(`ALTER EVENT SESSION [${oldName}]`);
|
||||
expect(cleanup).toContain(`DROP EVENT SESSION [${oldName}]`);
|
||||
expect(cleanup).not.toContain(activeName);
|
||||
});
|
||||
|
||||
it("parses capabilities and applies version-specific permission rules", () => {
|
||||
const capabilities = parseSqlServerTraceCapabilities(
|
||||
result(
|
||||
[
|
||||
"product_version",
|
||||
"engine_edition",
|
||||
"database_id",
|
||||
"can_alter_event_session",
|
||||
"can_view_server_state",
|
||||
"can_view_server_performance_state",
|
||||
"has_rpc_completed_event",
|
||||
"has_sql_batch_completed_event",
|
||||
"has_sp_statement_completed_event",
|
||||
"has_ring_buffer_target",
|
||||
"has_database_id_predicate",
|
||||
"has_like_predicate",
|
||||
"available_actions",
|
||||
],
|
||||
[["16.0.4225.2", 3, 5, 1, 1, 1, 1, 1, 1, 1, 1, 1, "client_app_name,client_hostname,database_id,database_name,server_principal_name,session_id,sql_text"]],
|
||||
),
|
||||
);
|
||||
expect(capabilities.majorVersion).toBe(16);
|
||||
expect(sqlServerTraceCapabilityProblem(capabilities)).toBeNull();
|
||||
expect(sqlServerTraceCapabilityProblem({ ...capabilities, canViewServerPerformanceState: false })).toBe("view-permission");
|
||||
expect(sqlServerTraceCapabilityProblem({ ...capabilities, engineEdition: 5 })).toBe("unsupported-engine");
|
||||
expect(sqlServerTraceCapabilityProblem({ ...capabilities, engineEdition: 11 })).toBe("unsupported-engine");
|
||||
expect(sqlServerTraceCapabilityProblem({ ...capabilities, hasRingBufferTarget: false })).toBe("missing-capability");
|
||||
expect(missingSqlServerTraceCapabilities({ ...capabilities, hasRingBufferTarget: false }, false)).toEqual(["package0.ring_buffer"]);
|
||||
expect(sqlServerTraceCapabilityProblem({ ...capabilities, hasSpStatementCompletedEvent: false }, false)).toBeNull();
|
||||
expect(sqlServerTraceCapabilityProblem({ ...capabilities, hasSpStatementCompletedEvent: false }, true)).toBe("missing-capability");
|
||||
});
|
||||
|
||||
it("bounds each ring-buffer parse and reads incrementally from the timestamp cursor", () => {
|
||||
const sql = buildReadSqlServerTraceEventsSql("DBX_TRACE_ABC_123", "2026-08-12T13:23:45.0840000");
|
||||
expect(sql).toContain("SELECT TOP (256) *");
|
||||
expect(sql).toContain("event_time_utc >= CONVERT(datetime2(7), N'2026-08-12T13:23:45.0840000', 126)");
|
||||
expect(sql).toContain("ORDER BY event_time_utc DESC");
|
||||
});
|
||||
|
||||
it("maps XE rows by column name, converts microseconds and removes internal events", () => {
|
||||
const events = parseSqlServerTraceEvents(
|
||||
result(
|
||||
["event_name", "event_time_utc", "database_name", "sql_text", "duration_us", "cpu_time_us", "logical_reads", "writes", "row_count", "session_id", "login_name", "host_name", "client_app", "result_text"],
|
||||
[
|
||||
["sql_batch_completed", "2026-08-12 13:23:45.084", "demo", "SELECT 1", 60_429, "6000", 8, 1, 1, 69, "sa", "host", "dbx", "OK"],
|
||||
["sql_batch_completed", "2026-08-12 13:23:46.000", "demo", "/* DBX_INTERNAL_TRACE */ SELECT 1", 100, 0, 0, 0, 0, 70, "sa", "host", "dbx", "OK"],
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ durationMs: 60.429, cpuMs: 6, sessionId: 69, logicalReads: 8 });
|
||||
expect(events[0].key).toContain("SELECT 1");
|
||||
});
|
||||
|
||||
it("preserves repeated identical events while deduplicating later snapshots", () => {
|
||||
const event = parseSqlServerTraceEvents(
|
||||
result(
|
||||
["event_name", "event_time_utc", "database_name", "sql_text", "duration_us", "cpu_time_us", "logical_reads", "writes", "row_count", "session_id", "login_name", "host_name", "client_app", "result_text"],
|
||||
[["sql_batch_completed", "2026-08-12", "demo", "EXEC repeat_me", 1000, 0, 0, 0, 0, 2, "sa", "host", "app", "OK"]],
|
||||
),
|
||||
)[0];
|
||||
const first = mergeSqlServerTraceEventSnapshot(new Map(), [event, event]);
|
||||
expect(first.additions).toHaveLength(2);
|
||||
expect(new Set(first.additions.map((item) => item.key)).size).toBe(2);
|
||||
const second = mergeSqlServerTraceEventSnapshot(first.counts, [event, event, event]);
|
||||
expect(second.additions).toHaveLength(1);
|
||||
expect(second.counts.get(event.key)).toBe(3);
|
||||
});
|
||||
|
||||
it("exports UTF-8 CSV with formula-safe quoting preserved as data", () => {
|
||||
const event = parseSqlServerTraceEvents(
|
||||
result(
|
||||
["event_name", "event_time_utc", "database_name", "sql_text", "duration_us", "cpu_time_us", "logical_reads", "writes", "row_count", "session_id", "login_name", "host_name", "client_app", "result_text"],
|
||||
[["rpc_completed", "2026-08-12", "demo", "EXEC p N'a,b'", 1000, 0, 0, 0, 1, 2, "sa", "host", "app", "OK"]],
|
||||
),
|
||||
)[0];
|
||||
const csv = sqlServerTraceEventsToCsv([event]);
|
||||
expect(csv.startsWith("\uFEFFtime_utc")).toBe(true);
|
||||
expect(csv).toContain("\"EXEC p N'a,b'\"");
|
||||
expect(csv.endsWith("\r\n")).toBe(true);
|
||||
expect(sqlServerTraceEventsToCsv([{ ...event, sqlText: "=cmd()" }])).toContain("'=cmd()");
|
||||
});
|
||||
});
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeQuery: vi.fn(),
|
||||
cancelQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => mocks);
|
||||
|
||||
function result(columns: string[] = [], rows: QueryResult["rows"] = []): QueryResult {
|
||||
return { columns, rows, affected_rows: 0, execution_time_ms: 0 };
|
||||
}
|
||||
|
||||
function capabilityResult(): QueryResult {
|
||||
return result(
|
||||
[
|
||||
"product_version",
|
||||
"engine_edition",
|
||||
"database_id",
|
||||
"can_alter_event_session",
|
||||
"can_view_server_state",
|
||||
"can_view_server_performance_state",
|
||||
"has_rpc_completed_event",
|
||||
"has_sql_batch_completed_event",
|
||||
"has_sp_statement_completed_event",
|
||||
"has_ring_buffer_target",
|
||||
"has_database_id_predicate",
|
||||
"has_like_predicate",
|
||||
"available_actions",
|
||||
],
|
||||
[["16.0.4225.2", 3, 5, 1, 1, 1, 1, 1, 1, 1, 1, 1, "client_app_name,client_hostname,database_id,database_name,server_principal_name,session_id,sql_text"]],
|
||||
);
|
||||
}
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => data.delete(key)),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function waitFor(predicate: () => boolean): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + 2000;
|
||||
const inspect = () => {
|
||||
if (predicate()) resolve();
|
||||
else if (Date.now() >= deadline) reject(new Error("Timed out waiting for trace runtime state"));
|
||||
else setTimeout(inspect, 0);
|
||||
};
|
||||
inspect();
|
||||
});
|
||||
}
|
||||
|
||||
describe("SQL Server activity trace runtime", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
const { disposeAllSqlServerActivityTraces } = await import("../sqlServerActivityTraceRuntime");
|
||||
await disposeAllSqlServerActivityTraces();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { disposeAllSqlServerActivityTraces } = await import("../sqlServerActivityTraceRuntime");
|
||||
await disposeAllSqlServerActivityTraces();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps trace state in the tab runtime until the tab is explicitly disposed", async () => {
|
||||
mocks.executeQuery.mockImplementation(async (_connectionId: string, _database: string, sql: string) => {
|
||||
if (sql.includes("sys.dm_xe_objects")) return capabilityResult();
|
||||
return result(sql.includes("sys.server_event_sessions") && sql.includes("SELECT name") ? ["name"] : [], []);
|
||||
});
|
||||
mocks.cancelQuery.mockResolvedValue(true);
|
||||
|
||||
const { activeSqlServerActivityTraceRuntimeCount, disposeSqlServerActivityTrace, getSqlServerActivityTraceRuntime } = await import("../sqlServerActivityTraceRuntime");
|
||||
const runtime = getSqlServerActivityTraceRuntime("tab-1", "sqlserver-1", "app");
|
||||
await runtime.start();
|
||||
runtime.state.sqlFilter = "orders";
|
||||
|
||||
const recreatedComponentRuntime = getSqlServerActivityTraceRuntime("tab-1", "sqlserver-1", "app");
|
||||
expect(recreatedComponentRuntime).toBe(runtime);
|
||||
expect(recreatedComponentRuntime.state.status).toBe("running");
|
||||
expect(recreatedComponentRuntime.state.sqlFilter).toBe("orders");
|
||||
expect(activeSqlServerActivityTraceRuntimeCount()).toBe(1);
|
||||
|
||||
await disposeSqlServerActivityTrace("tab-1");
|
||||
expect(activeSqlServerActivityTraceRuntimeCount()).toBe(0);
|
||||
expect(mocks.executeQuery.mock.calls.some((call) => String(call[2]).includes("STATE = STOP"))).toBe(true);
|
||||
expect(mocks.executeQuery.mock.calls.some((call) => String(call[2]).includes("DROP EVENT SESSION"))).toBe(true);
|
||||
});
|
||||
|
||||
it("cancels an in-flight incremental read before dropping the session", async () => {
|
||||
let settleRead: ((value: QueryResult) => void) | undefined;
|
||||
mocks.executeQuery.mockImplementation(async (_connectionId: string, _database: string, sql: string) => {
|
||||
if (sql.includes("sys.dm_xe_objects")) return capabilityResult();
|
||||
if (sql.includes("CROSS APPLY target_data.nodes")) {
|
||||
return new Promise<QueryResult>((resolve) => {
|
||||
settleRead = resolve;
|
||||
});
|
||||
}
|
||||
return result(sql.includes("sys.server_event_sessions") && sql.includes("SELECT name") ? ["name"] : [], []);
|
||||
});
|
||||
mocks.cancelQuery.mockImplementation(async () => {
|
||||
settleRead?.(result());
|
||||
return true;
|
||||
});
|
||||
|
||||
const { disposeSqlServerActivityTrace, getSqlServerActivityTraceRuntime } = await import("../sqlServerActivityTraceRuntime");
|
||||
const runtime = getSqlServerActivityTraceRuntime("tab-2", "sqlserver-1", "app");
|
||||
const starting = runtime.start();
|
||||
await waitFor(() => runtime.state.polling);
|
||||
const readCall = mocks.executeQuery.mock.calls.find((call) => String(call[2]).includes("CROSS APPLY target_data.nodes"));
|
||||
const executionId = readCall?.[4];
|
||||
|
||||
await disposeSqlServerActivityTrace("tab-2");
|
||||
await starting;
|
||||
|
||||
expect(typeof executionId).toBe("string");
|
||||
expect(mocks.cancelQuery).toHaveBeenCalledWith(executionId);
|
||||
const cancelOrder = mocks.cancelQuery.mock.invocationCallOrder[0];
|
||||
const finalDropOrder = mocks.executeQuery.mock.calls.map((call, index) => (String(call[2]).includes("DROP EVENT SESSION") ? mocks.executeQuery.mock.invocationCallOrder[index] : 0)).find((order) => order > cancelOrder);
|
||||
expect(finalDropOrder).toBeDefined();
|
||||
});
|
||||
|
||||
it("waits for an in-flight session creation before disposing the tab", async () => {
|
||||
let finishCreate: (() => void) | undefined;
|
||||
mocks.executeQuery.mockImplementation(async (_connectionId: string, _database: string, sql: string) => {
|
||||
if (sql.includes("sys.dm_xe_objects")) return capabilityResult();
|
||||
if (sql.includes("CREATE EVENT SESSION")) {
|
||||
await new Promise<void>((resolve) => {
|
||||
finishCreate = resolve;
|
||||
});
|
||||
}
|
||||
return result(sql.includes("sys.server_event_sessions") && sql.includes("SELECT name") ? ["name"] : [], []);
|
||||
});
|
||||
mocks.cancelQuery.mockResolvedValue(true);
|
||||
|
||||
const { disposeSqlServerActivityTrace, getSqlServerActivityTraceRuntime } = await import("../sqlServerActivityTraceRuntime");
|
||||
const runtime = getSqlServerActivityTraceRuntime("tab-starting", "sqlserver-1", "app");
|
||||
const starting = runtime.start();
|
||||
await waitFor(() => mocks.executeQuery.mock.calls.some((call) => String(call[2]).includes("CREATE EVENT SESSION")));
|
||||
|
||||
const disposing = disposeSqlServerActivityTrace("tab-starting");
|
||||
await Promise.resolve();
|
||||
expect(mocks.executeQuery.mock.calls.some((call) => String(call[2]).includes("DROP EVENT SESSION") && !String(call[2]).includes("CREATE EVENT SESSION"))).toBe(false);
|
||||
|
||||
finishCreate?.();
|
||||
await Promise.all([starting, disposing]);
|
||||
|
||||
const createOrder = mocks.executeQuery.mock.calls.map((call, index) => (String(call[2]).includes("CREATE EVENT SESSION") ? mocks.executeQuery.mock.invocationCallOrder[index] : 0)).find(Boolean);
|
||||
const dropOrder = mocks.executeQuery.mock.calls.map((call, index) => (String(call[2]).includes("DROP EVENT SESSION") && !String(call[2]).includes("CREATE EVENT SESSION") ? mocks.executeQuery.mock.invocationCallOrder[index] : 0)).find((order) => order > (createOrder ?? 0));
|
||||
expect(dropOrder).toBeDefined();
|
||||
expect(runtime.state.sessionName).toBe("");
|
||||
});
|
||||
|
||||
it("cleans persisted crash leftovers independently on the next connection", async () => {
|
||||
const data = installLocalStorage();
|
||||
const expiresAt = Date.now() - 1;
|
||||
const { buildSqlServerTraceSessionName } = await import("../sqlServerActivityTrace");
|
||||
const sessionName = buildSqlServerTraceSessionName(expiresAt, 0.25);
|
||||
const activeExpiresAt = Date.now() + 60_000;
|
||||
const activeSessionName = buildSqlServerTraceSessionName(activeExpiresAt, 0.5);
|
||||
data.set(
|
||||
"dbx:sqlserver-trace:pending-sessions:v1",
|
||||
JSON.stringify([
|
||||
{ connectionId: "sqlserver-1", database: "app", sessionName, expiresAt },
|
||||
{ connectionId: "sqlserver-1", database: "app", sessionName: activeSessionName, expiresAt: activeExpiresAt },
|
||||
]),
|
||||
);
|
||||
mocks.executeQuery.mockImplementation(async (_connectionId: string, _database: string, sql: string) => {
|
||||
if (sql.includes("SELECT name") && sql.includes("sys.server_event_sessions")) return result(["name"], [[sessionName], [activeSessionName]]);
|
||||
return result();
|
||||
});
|
||||
|
||||
const { cleanupStaleSqlServerTraceSessions } = await import("../sqlServerActivityTraceRuntime");
|
||||
expect(await cleanupStaleSqlServerTraceSessions("sqlserver-1", "app")).toBe(1);
|
||||
expect(mocks.executeQuery.mock.calls.some((call) => String(call[2]).includes(`DROP EVENT SESSION [${sessionName}]`))).toBe(true);
|
||||
expect(mocks.executeQuery.mock.calls.some((call) => String(call[2]).includes(`DROP EVENT SESSION [${activeSessionName}]`))).toBe(false);
|
||||
expect(JSON.parse(data.get("dbx:sqlserver-trace:pending-sessions:v1") || "[]")).toEqual([{ connectionId: "sqlserver-1", database: "app", sessionName: activeSessionName, expiresAt: activeExpiresAt }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,425 @@
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
export const SQLSERVER_TRACE_SESSION_PREFIX = "DBX_TRACE_";
|
||||
export const SQLSERVER_TRACE_INTERNAL_MARKER = "DBX_INTERNAL_TRACE";
|
||||
export const SQLSERVER_TRACE_DEFAULT_MAX_EVENTS = 1000;
|
||||
export const SQLSERVER_TRACE_MIN_MAX_EVENTS = 100;
|
||||
export const SQLSERVER_TRACE_MAX_MAX_EVENTS = 5000;
|
||||
export const SQLSERVER_TRACE_DEFAULT_DURATION_MINUTES = 10;
|
||||
export const SQLSERVER_TRACE_MIN_DURATION_MINUTES = 1;
|
||||
export const SQLSERVER_TRACE_MAX_DURATION_MINUTES = 60;
|
||||
export const SQLSERVER_TRACE_STALE_GRACE_MINUTES = 5;
|
||||
export const SQLSERVER_TRACE_RING_BUFFER_MAX_EVENTS = 256;
|
||||
export const SQLSERVER_TRACE_READ_BATCH_SIZE = 256;
|
||||
|
||||
export interface SqlServerTraceCapabilities {
|
||||
productVersion: string;
|
||||
majorVersion: number;
|
||||
engineEdition: number;
|
||||
databaseId: number;
|
||||
canAlterEventSession: boolean;
|
||||
canViewServerState: boolean;
|
||||
canViewServerPerformanceState: boolean;
|
||||
hasRpcCompletedEvent: boolean;
|
||||
hasSqlBatchCompletedEvent: boolean;
|
||||
hasSpStatementCompletedEvent: boolean;
|
||||
hasRingBufferTarget: boolean;
|
||||
hasDatabaseIdPredicate: boolean;
|
||||
hasLikePredicate: boolean;
|
||||
availableActions: Set<string>;
|
||||
}
|
||||
|
||||
export interface SqlServerTraceEvent {
|
||||
key: string;
|
||||
eventName: string;
|
||||
timestamp: string;
|
||||
database: string;
|
||||
sqlText: string;
|
||||
durationMs: number | null;
|
||||
cpuMs: number | null;
|
||||
logicalReads: number | null;
|
||||
writes: number | null;
|
||||
rowCount: number | null;
|
||||
sessionId: number | null;
|
||||
loginName: string;
|
||||
hostName: string;
|
||||
clientApp: string;
|
||||
result: string;
|
||||
}
|
||||
|
||||
export interface SqlServerTraceSessionOptions {
|
||||
sessionName: string;
|
||||
databaseId: number;
|
||||
maxEvents: number;
|
||||
includeStatements: boolean;
|
||||
}
|
||||
|
||||
type QueryCell = string | number | boolean | null;
|
||||
|
||||
function clampInteger(value: number, min: number, max: number, fallback: number): number {
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.round(value)));
|
||||
}
|
||||
|
||||
export function normalizeSqlServerTraceMaxEvents(value: number): number {
|
||||
return clampInteger(value, SQLSERVER_TRACE_MIN_MAX_EVENTS, SQLSERVER_TRACE_MAX_MAX_EVENTS, SQLSERVER_TRACE_DEFAULT_MAX_EVENTS);
|
||||
}
|
||||
|
||||
export function normalizeSqlServerTraceDurationMinutes(value: number): number {
|
||||
return clampInteger(value, SQLSERVER_TRACE_MIN_DURATION_MINUTES, SQLSERVER_TRACE_MAX_DURATION_MINUTES, SQLSERVER_TRACE_DEFAULT_DURATION_MINUTES);
|
||||
}
|
||||
|
||||
export function buildSqlServerTraceSessionName(expiresAt = Date.now() + (SQLSERVER_TRACE_MAX_DURATION_MINUTES + SQLSERVER_TRACE_STALE_GRACE_MINUTES) * 60_000, random = Math.random()): string {
|
||||
const timePart = Math.max(0, Math.floor(expiresAt)).toString(36).toUpperCase();
|
||||
const randomPart = Math.max(0, Math.min(0.999999999, random)).toString(36).slice(2, 10).toUpperCase().padEnd(8, "0");
|
||||
return `${SQLSERVER_TRACE_SESSION_PREFIX}${timePart}_${randomPart}`;
|
||||
}
|
||||
|
||||
export function isValidSqlServerTraceSessionName(value: string): boolean {
|
||||
return /^DBX_TRACE_[A-Z0-9_]{3,100}$/.test(value);
|
||||
}
|
||||
|
||||
function assertSessionName(sessionName: string): string {
|
||||
if (!isValidSqlServerTraceSessionName(sessionName)) throw new Error("Invalid SQL Server trace session name");
|
||||
return sessionName;
|
||||
}
|
||||
|
||||
function sqlString(value: string): string {
|
||||
return `N'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
function internalSql(sql: string): string {
|
||||
return `/* ${SQLSERVER_TRACE_INTERNAL_MARKER} */\n${sql}`;
|
||||
}
|
||||
|
||||
export function buildSqlServerTraceCapabilitiesSql(database: string): string {
|
||||
return internalSql(`SELECT
|
||||
CAST(SERVERPROPERTY(N'ProductVersion') AS nvarchar(128)) AS product_version,
|
||||
CONVERT(int, SERVERPROPERTY(N'EngineEdition')) AS engine_edition,
|
||||
DB_ID(${sqlString(database)}) AS database_id,
|
||||
CONVERT(int, HAS_PERMS_BY_NAME(NULL, NULL, N'ALTER ANY EVENT SESSION')) AS can_alter_event_session,
|
||||
CONVERT(int, HAS_PERMS_BY_NAME(NULL, NULL, N'VIEW SERVER STATE')) AS can_view_server_state,
|
||||
CONVERT(int, CASE
|
||||
WHEN EXISTS (SELECT 1 FROM sys.fn_builtin_permissions(DEFAULT) WHERE permission_name = N'VIEW SERVER PERFORMANCE STATE')
|
||||
THEN HAS_PERMS_BY_NAME(NULL, NULL, N'VIEW SERVER PERFORMANCE STATE')
|
||||
ELSE 1
|
||||
END) AS can_view_server_performance_state,
|
||||
CONVERT(int, CASE WHEN EXISTS (
|
||||
SELECT 1 FROM sys.dm_xe_objects object
|
||||
JOIN sys.dm_xe_packages package ON package.guid = object.package_guid
|
||||
WHERE package.name = N'sqlserver' AND object.object_type = N'event' AND object.name = N'rpc_completed'
|
||||
) THEN 1 ELSE 0 END) AS has_rpc_completed_event,
|
||||
CONVERT(int, CASE WHEN EXISTS (
|
||||
SELECT 1 FROM sys.dm_xe_objects object
|
||||
JOIN sys.dm_xe_packages package ON package.guid = object.package_guid
|
||||
WHERE package.name = N'sqlserver' AND object.object_type = N'event' AND object.name = N'sql_batch_completed'
|
||||
) THEN 1 ELSE 0 END) AS has_sql_batch_completed_event,
|
||||
CONVERT(int, CASE WHEN EXISTS (
|
||||
SELECT 1 FROM sys.dm_xe_objects object
|
||||
JOIN sys.dm_xe_packages package ON package.guid = object.package_guid
|
||||
WHERE package.name = N'sqlserver' AND object.object_type = N'event' AND object.name = N'sp_statement_completed'
|
||||
) THEN 1 ELSE 0 END) AS has_sp_statement_completed_event,
|
||||
CONVERT(int, CASE WHEN EXISTS (
|
||||
SELECT 1 FROM sys.dm_xe_objects object
|
||||
JOIN sys.dm_xe_packages package ON package.guid = object.package_guid
|
||||
WHERE package.name = N'package0' AND object.object_type = N'target' AND object.name = N'ring_buffer'
|
||||
) THEN 1 ELSE 0 END) AS has_ring_buffer_target,
|
||||
CONVERT(int, CASE WHEN EXISTS (
|
||||
SELECT 1 FROM sys.dm_xe_objects object
|
||||
JOIN sys.dm_xe_packages package ON package.guid = object.package_guid
|
||||
WHERE package.name = N'sqlserver' AND object.object_type = N'pred_source' AND object.name = N'database_id'
|
||||
) THEN 1 ELSE 0 END) AS has_database_id_predicate,
|
||||
CONVERT(int, CASE WHEN EXISTS (
|
||||
SELECT 1 FROM sys.dm_xe_objects object
|
||||
JOIN sys.dm_xe_packages package ON package.guid = object.package_guid
|
||||
WHERE package.name = N'sqlserver' AND object.object_type = N'pred_compare' AND object.name = N'like_i_sql_unicode_string'
|
||||
) THEN 1 ELSE 0 END) AS has_like_predicate,
|
||||
STUFF((
|
||||
SELECT N',' + object.name
|
||||
FROM sys.dm_xe_objects object
|
||||
JOIN sys.dm_xe_packages package ON package.guid = object.package_guid
|
||||
WHERE package.name = N'sqlserver'
|
||||
AND object.object_type = N'action'
|
||||
AND object.name IN (
|
||||
N'client_app_name', N'client_hostname', N'database_id', N'database_name',
|
||||
N'server_principal_name', N'session_id', N'sql_text'
|
||||
)
|
||||
ORDER BY object.name
|
||||
FOR XML PATH(N''), TYPE
|
||||
).value(N'.', N'nvarchar(max)'), 1, 1, N'') AS available_actions;`);
|
||||
}
|
||||
|
||||
function columnIndex(result: QueryResult, name: string): number {
|
||||
return result.columns.findIndex((column) => column.toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
function cell(result: QueryResult, row: QueryCell[], name: string): QueryCell {
|
||||
const index = columnIndex(result, name);
|
||||
return index >= 0 ? row[index] : null;
|
||||
}
|
||||
|
||||
function textValue(value: QueryCell): string {
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function numberValue(value: QueryCell): number | null {
|
||||
if (value == null || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function booleanValue(value: QueryCell): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
return numberValue(value) === 1 || String(value).toLowerCase() === "true";
|
||||
}
|
||||
|
||||
export function parseSqlServerTraceCapabilities(result: QueryResult): SqlServerTraceCapabilities {
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new Error("SQL Server did not return trace capability information");
|
||||
const productVersion = textValue(cell(result, row, "product_version"));
|
||||
const majorVersion = Number.parseInt(productVersion.split(".")[0] || "", 10);
|
||||
const databaseId = numberValue(cell(result, row, "database_id"));
|
||||
const engineEdition = numberValue(cell(result, row, "engine_edition"));
|
||||
if (!Number.isInteger(majorVersion) || !Number.isInteger(databaseId) || !Number.isInteger(engineEdition)) {
|
||||
throw new Error("SQL Server returned incomplete trace capability information");
|
||||
}
|
||||
return {
|
||||
productVersion,
|
||||
majorVersion,
|
||||
engineEdition: engineEdition!,
|
||||
databaseId: databaseId!,
|
||||
canAlterEventSession: booleanValue(cell(result, row, "can_alter_event_session")),
|
||||
canViewServerState: booleanValue(cell(result, row, "can_view_server_state")),
|
||||
canViewServerPerformanceState: booleanValue(cell(result, row, "can_view_server_performance_state")),
|
||||
hasRpcCompletedEvent: booleanValue(cell(result, row, "has_rpc_completed_event")),
|
||||
hasSqlBatchCompletedEvent: booleanValue(cell(result, row, "has_sql_batch_completed_event")),
|
||||
hasSpStatementCompletedEvent: booleanValue(cell(result, row, "has_sp_statement_completed_event")),
|
||||
hasRingBufferTarget: booleanValue(cell(result, row, "has_ring_buffer_target")),
|
||||
hasDatabaseIdPredicate: booleanValue(cell(result, row, "has_database_id_predicate")),
|
||||
hasLikePredicate: booleanValue(cell(result, row, "has_like_predicate")),
|
||||
availableActions: new Set(
|
||||
textValue(cell(result, row, "available_actions"))
|
||||
.split(",")
|
||||
.filter(Boolean),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const REQUIRED_SQLSERVER_TRACE_ACTIONS = ["client_app_name", "client_hostname", "database_id", "database_name", "server_principal_name", "session_id", "sql_text"] as const;
|
||||
|
||||
export function missingSqlServerTraceCapabilities(capabilities: SqlServerTraceCapabilities, includeStatements: boolean): string[] {
|
||||
const missing: string[] = [];
|
||||
if (!capabilities.hasRpcCompletedEvent) missing.push("sqlserver.rpc_completed");
|
||||
if (!capabilities.hasSqlBatchCompletedEvent) missing.push("sqlserver.sql_batch_completed");
|
||||
if (includeStatements && !capabilities.hasSpStatementCompletedEvent) missing.push("sqlserver.sp_statement_completed");
|
||||
if (!capabilities.hasRingBufferTarget) missing.push("package0.ring_buffer");
|
||||
if (!capabilities.hasDatabaseIdPredicate) missing.push("sqlserver.database_id predicate");
|
||||
if (!capabilities.hasLikePredicate) missing.push("sqlserver.like_i_sql_unicode_string predicate");
|
||||
for (const action of REQUIRED_SQLSERVER_TRACE_ACTIONS) {
|
||||
if (!capabilities.availableActions.has(action)) missing.push(`sqlserver.${action} action`);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
export type SqlServerTraceCapabilityProblem = "unsupported-version" | "unsupported-engine" | "alter-permission" | "view-permission" | "missing-capability";
|
||||
|
||||
export function sqlServerTraceCapabilityProblem(capabilities: SqlServerTraceCapabilities, includeStatements = false): SqlServerTraceCapabilityProblem | null {
|
||||
if (capabilities.majorVersion < 10) return "unsupported-version";
|
||||
// Azure SQL Database uses database-scoped XE sessions, which are outside the
|
||||
// first release. Boxed SQL Server, Managed Instance and SQL Edge use SERVER.
|
||||
if (![1, 2, 3, 4, 8, 9].includes(capabilities.engineEdition)) return "unsupported-engine";
|
||||
if (!capabilities.canAlterEventSession) return "alter-permission";
|
||||
if (capabilities.majorVersion >= 16 ? !capabilities.canViewServerPerformanceState : !capabilities.canViewServerState) return "view-permission";
|
||||
if (missingSqlServerTraceCapabilities(capabilities, includeStatements).length > 0) return "missing-capability";
|
||||
return null;
|
||||
}
|
||||
|
||||
function traceEventSql(eventName: "rpc_completed" | "sql_batch_completed" | "sp_statement_completed", databaseId: number): string {
|
||||
return `ADD EVENT sqlserver.${eventName}(
|
||||
ACTION(
|
||||
sqlserver.client_app_name,
|
||||
sqlserver.client_hostname,
|
||||
sqlserver.database_id,
|
||||
sqlserver.database_name,
|
||||
sqlserver.server_principal_name,
|
||||
sqlserver.session_id,
|
||||
sqlserver.sql_text
|
||||
)
|
||||
WHERE (
|
||||
[sqlserver].[database_id]=(${databaseId})
|
||||
AND NOT [sqlserver].[like_i_sql_unicode_string]([sqlserver].[sql_text],N'%${SQLSERVER_TRACE_INTERNAL_MARKER}%')
|
||||
))`;
|
||||
}
|
||||
|
||||
export function buildCreateSqlServerTraceSessionSql(options: SqlServerTraceSessionOptions): string {
|
||||
const sessionName = assertSessionName(options.sessionName);
|
||||
if (!Number.isInteger(options.databaseId) || options.databaseId <= 0) throw new Error("Invalid SQL Server database id");
|
||||
const maxEvents = normalizeSqlServerTraceMaxEvents(options.maxEvents);
|
||||
const events = [traceEventSql("rpc_completed", options.databaseId), traceEventSql("sql_batch_completed", options.databaseId)];
|
||||
if (options.includeStatements) events.push(traceEventSql("sp_statement_completed", options.databaseId));
|
||||
return internalSql(`IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = ${sqlString(sessionName)})
|
||||
DROP EVENT SESSION [${sessionName}] ON SERVER;
|
||||
CREATE EVENT SESSION [${sessionName}] ON SERVER
|
||||
${events.join(",\n")}
|
||||
ADD TARGET package0.ring_buffer(
|
||||
SET max_events_limit=(${Math.min(maxEvents, SQLSERVER_TRACE_RING_BUFFER_MAX_EVENTS)}), max_memory=(4096)
|
||||
)
|
||||
WITH (
|
||||
MAX_MEMORY=4096 KB,
|
||||
EVENT_RETENTION_MODE=ALLOW_SINGLE_EVENT_LOSS,
|
||||
MAX_DISPATCH_LATENCY=1 SECONDS,
|
||||
TRACK_CAUSALITY=OFF,
|
||||
STARTUP_STATE=OFF
|
||||
);
|
||||
ALTER EVENT SESSION [${sessionName}] ON SERVER STATE = START;`);
|
||||
}
|
||||
|
||||
export function buildReadSqlServerTraceEventsSql(sessionName: string, afterTimestamp?: string): string {
|
||||
assertSessionName(sessionName);
|
||||
const cursorPredicate = afterTimestamp ? `WHERE event_time_utc >= CONVERT(datetime2(7), ${sqlString(afterTimestamp.replace(" ", "T"))}, 126)` : "";
|
||||
return internalSql(`;WITH target AS (
|
||||
SELECT CAST(target.target_data AS xml) AS target_data
|
||||
FROM sys.dm_xe_session_targets target
|
||||
JOIN sys.dm_xe_sessions session ON session.address = target.event_session_address
|
||||
WHERE session.name = ${sqlString(sessionName)} AND target.target_name = N'ring_buffer'
|
||||
), event_rows AS (
|
||||
SELECT
|
||||
event_node.value('(@name)[1]', 'nvarchar(128)') AS event_name,
|
||||
event_node.value('(@timestamp)[1]', 'datetime2(7)') AS event_time_utc,
|
||||
event_node.value('(action[@name="database_name"]/value/text())[1]', 'nvarchar(128)') AS database_name,
|
||||
COALESCE(
|
||||
NULLIF(event_node.value('(data[@name="statement"]/value/text())[1]', 'nvarchar(max)'), N''),
|
||||
NULLIF(event_node.value('(data[@name="batch_text"]/value/text())[1]', 'nvarchar(max)'), N''),
|
||||
event_node.value('(action[@name="sql_text"]/value/text())[1]', 'nvarchar(max)')
|
||||
) AS sql_text,
|
||||
event_node.value('(data[@name="duration"]/value/text())[1]', 'bigint') AS duration_us,
|
||||
event_node.value('(data[@name="cpu_time"]/value/text())[1]', 'bigint') AS cpu_time_us,
|
||||
event_node.value('(data[@name="logical_reads"]/value/text())[1]', 'bigint') AS logical_reads,
|
||||
event_node.value('(data[@name="writes"]/value/text())[1]', 'bigint') AS writes,
|
||||
event_node.value('(data[@name="row_count"]/value/text())[1]', 'bigint') AS row_count,
|
||||
event_node.value('(action[@name="session_id"]/value/text())[1]', 'int') AS session_id,
|
||||
event_node.value('(action[@name="server_principal_name"]/value/text())[1]', 'nvarchar(256)') AS login_name,
|
||||
event_node.value('(action[@name="client_hostname"]/value/text())[1]', 'nvarchar(256)') AS host_name,
|
||||
event_node.value('(action[@name="client_app_name"]/value/text())[1]', 'nvarchar(256)') AS client_app,
|
||||
event_node.value('(data[@name="result"]/text/text())[1]', 'nvarchar(256)') AS result_text
|
||||
FROM target
|
||||
CROSS APPLY target_data.nodes('/RingBufferTarget/event') AS events(event_node)
|
||||
), bounded_events AS (
|
||||
SELECT TOP (${SQLSERVER_TRACE_READ_BATCH_SIZE}) *
|
||||
FROM event_rows
|
||||
${cursorPredicate}
|
||||
ORDER BY event_time_utc DESC
|
||||
)
|
||||
SELECT *
|
||||
FROM bounded_events
|
||||
ORDER BY event_time_utc;`);
|
||||
}
|
||||
|
||||
export function buildStopSqlServerTraceSessionSql(sessionName: string): string {
|
||||
assertSessionName(sessionName);
|
||||
return internalSql(`IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = ${sqlString(sessionName)})
|
||||
ALTER EVENT SESSION [${sessionName}] ON SERVER STATE = STOP;`);
|
||||
}
|
||||
|
||||
export function buildDropSqlServerTraceSessionSql(sessionName: string): string {
|
||||
assertSessionName(sessionName);
|
||||
return internalSql(`IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = ${sqlString(sessionName)})
|
||||
DROP EVENT SESSION [${sessionName}] ON SERVER;`);
|
||||
}
|
||||
|
||||
export function buildListSqlServerTraceSessionsSql(): string {
|
||||
return internalSql(`SELECT name
|
||||
FROM sys.server_event_sessions
|
||||
WHERE name LIKE N'${SQLSERVER_TRACE_SESSION_PREFIX}%';`);
|
||||
}
|
||||
|
||||
export function sqlServerTraceSessionExpiresAt(sessionName: string): number | null {
|
||||
if (!isValidSqlServerTraceSessionName(sessionName)) return null;
|
||||
const encoded = sessionName.slice(SQLSERVER_TRACE_SESSION_PREFIX.length).split("_")[0];
|
||||
const timestamp = Number.parseInt(encoded, 36);
|
||||
return Number.isSafeInteger(timestamp) && timestamp >= 0 ? timestamp : null;
|
||||
}
|
||||
|
||||
export function staleSqlServerTraceSessionNames(result: QueryResult, now = Date.now()): string[] {
|
||||
return result.rows
|
||||
.map((row) => textValue(cell(result, row, "name")))
|
||||
.filter((name) => {
|
||||
const expiresAt = sqlServerTraceSessionExpiresAt(name);
|
||||
return expiresAt !== null && expiresAt <= now;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCleanupSqlServerTraceSessionsSql(sessionNames: string[]): string {
|
||||
const names = [...new Set(sessionNames.map(assertSessionName))];
|
||||
if (names.length === 0) return internalSql("SELECT 0 AS cleaned_sessions;");
|
||||
return internalSql(
|
||||
`${names
|
||||
.map(
|
||||
(name) => `IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = ${sqlString(name)})
|
||||
ALTER EVENT SESSION [${name}] ON SERVER STATE = STOP;
|
||||
IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = ${sqlString(name)})
|
||||
DROP EVENT SESSION [${name}] ON SERVER;`,
|
||||
)
|
||||
.join("\n")}\nSELECT ${names.length} AS cleaned_sessions;`,
|
||||
);
|
||||
}
|
||||
|
||||
function microsecondsToMilliseconds(value: QueryCell): number | null {
|
||||
const microseconds = numberValue(value);
|
||||
return microseconds === null ? null : microseconds / 1000;
|
||||
}
|
||||
|
||||
function eventKey(event: Omit<SqlServerTraceEvent, "key">): string {
|
||||
return [event.timestamp, event.eventName, event.sessionId ?? "", event.durationMs ?? "", event.sqlText].join("\u0001");
|
||||
}
|
||||
|
||||
export function parseSqlServerTraceEvents(result: QueryResult): SqlServerTraceEvent[] {
|
||||
return result.rows
|
||||
.map((row) => {
|
||||
const event: Omit<SqlServerTraceEvent, "key"> = {
|
||||
eventName: textValue(cell(result, row, "event_name")),
|
||||
timestamp: textValue(cell(result, row, "event_time_utc")),
|
||||
database: textValue(cell(result, row, "database_name")),
|
||||
sqlText: textValue(cell(result, row, "sql_text")),
|
||||
durationMs: microsecondsToMilliseconds(cell(result, row, "duration_us")),
|
||||
cpuMs: microsecondsToMilliseconds(cell(result, row, "cpu_time_us")),
|
||||
logicalReads: numberValue(cell(result, row, "logical_reads")),
|
||||
writes: numberValue(cell(result, row, "writes")),
|
||||
rowCount: numberValue(cell(result, row, "row_count")),
|
||||
sessionId: numberValue(cell(result, row, "session_id")),
|
||||
loginName: textValue(cell(result, row, "login_name")),
|
||||
hostName: textValue(cell(result, row, "host_name")),
|
||||
clientApp: textValue(cell(result, row, "client_app")),
|
||||
result: textValue(cell(result, row, "result_text")),
|
||||
};
|
||||
return { ...event, key: eventKey(event) };
|
||||
})
|
||||
.filter((event) => event.eventName && !event.sqlText.includes(SQLSERVER_TRACE_INTERNAL_MARKER));
|
||||
}
|
||||
|
||||
export function mergeSqlServerTraceEventSnapshot(existingCounts: ReadonlyMap<string, number>, snapshot: SqlServerTraceEvent[]): { additions: SqlServerTraceEvent[]; counts: Map<string, number> } {
|
||||
const snapshotCounts = new Map<string, number>();
|
||||
const additions: SqlServerTraceEvent[] = [];
|
||||
for (const event of snapshot) {
|
||||
const occurrence = (snapshotCounts.get(event.key) ?? 0) + 1;
|
||||
snapshotCounts.set(event.key, occurrence);
|
||||
if (occurrence > (existingCounts.get(event.key) ?? 0)) {
|
||||
additions.push({ ...event, key: `${event.key}\u0001${occurrence}` });
|
||||
}
|
||||
}
|
||||
const counts = new Map(existingCounts);
|
||||
for (const [key, count] of snapshotCounts) counts.set(key, Math.max(counts.get(key) ?? 0, count));
|
||||
return { additions, counts };
|
||||
}
|
||||
|
||||
function csvCell(value: unknown): string {
|
||||
const raw = value == null ? "" : String(value);
|
||||
const text = /^[\t\r]*[=+\-@]/.test(raw) ? `'${raw}` : raw;
|
||||
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||
}
|
||||
|
||||
export function sqlServerTraceEventsToCsv(events: SqlServerTraceEvent[]): string {
|
||||
const header = ["time_utc", "event", "database", "sql_text", "duration_ms", "cpu_ms", "logical_reads", "writes", "row_count", "session_id", "login", "host", "client_app", "result"];
|
||||
const rows = events.map((event) => [event.timestamp, event.eventName, event.database, event.sqlText, event.durationMs, event.cpuMs, event.logicalReads, event.writes, event.rowCount, event.sessionId, event.loginName, event.hostName, event.clientApp, event.result]);
|
||||
return `\uFEFF${[header, ...rows].map((row) => row.map(csvCell).join(",")).join("\r\n")}\r\n`;
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { reactive } from "vue";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
import {
|
||||
SQLSERVER_TRACE_DEFAULT_DURATION_MINUTES,
|
||||
SQLSERVER_TRACE_DEFAULT_MAX_EVENTS,
|
||||
SQLSERVER_TRACE_MAX_DURATION_MINUTES,
|
||||
SQLSERVER_TRACE_READ_BATCH_SIZE,
|
||||
SQLSERVER_TRACE_STALE_GRACE_MINUTES,
|
||||
buildCleanupSqlServerTraceSessionsSql,
|
||||
buildCreateSqlServerTraceSessionSql,
|
||||
buildDropSqlServerTraceSessionSql,
|
||||
buildListSqlServerTraceSessionsSql,
|
||||
buildReadSqlServerTraceEventsSql,
|
||||
buildSqlServerTraceCapabilitiesSql,
|
||||
buildSqlServerTraceSessionName,
|
||||
buildStopSqlServerTraceSessionSql,
|
||||
isValidSqlServerTraceSessionName,
|
||||
missingSqlServerTraceCapabilities,
|
||||
normalizeSqlServerTraceDurationMinutes,
|
||||
normalizeSqlServerTraceMaxEvents,
|
||||
parseSqlServerTraceCapabilities,
|
||||
parseSqlServerTraceEvents,
|
||||
sqlServerTraceCapabilityProblem,
|
||||
staleSqlServerTraceSessionNames,
|
||||
type SqlServerTraceEvent,
|
||||
type SqlServerTraceCapabilityProblem,
|
||||
} from "@/lib/sqlserver/sqlServerActivityTrace";
|
||||
|
||||
export type SqlServerTraceStatus = "idle" | "starting" | "running" | "paused" | "stopping" | "stopped" | "error";
|
||||
export type SqlServerTraceAutoStopReason = "duration" | "events";
|
||||
|
||||
export interface SqlServerActivityTraceState {
|
||||
status: SqlServerTraceStatus;
|
||||
selectedDatabase: string;
|
||||
includeStatements: boolean;
|
||||
maxEvents: number;
|
||||
durationMinutes: number;
|
||||
events: SqlServerTraceEvent[];
|
||||
selectedEvent: SqlServerTraceEvent | null;
|
||||
sqlFilter: string;
|
||||
loginFilter: string;
|
||||
clientFilter: string;
|
||||
sessionFilter: string;
|
||||
error: string;
|
||||
sessionName: string;
|
||||
startedAt: number | null;
|
||||
elapsedSeconds: number;
|
||||
polling: boolean;
|
||||
autoStopReason: SqlServerTraceAutoStopReason | null;
|
||||
autoStopRevision: number;
|
||||
capabilityProblem: SqlServerTraceCapabilityProblem | null;
|
||||
capabilityVersion: string;
|
||||
missingCapabilities: string[];
|
||||
}
|
||||
|
||||
interface SqlServerTraceCursor {
|
||||
timestamp: string;
|
||||
counts: Map<string, number>;
|
||||
}
|
||||
|
||||
interface PendingSqlServerTraceSession {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
sessionName: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface PollOptions {
|
||||
final?: boolean;
|
||||
allowWhileStopping?: boolean;
|
||||
}
|
||||
|
||||
export interface SqlServerActivityTraceRuntime {
|
||||
readonly tabId: string;
|
||||
readonly connectionId: string;
|
||||
readonly state: SqlServerActivityTraceState;
|
||||
start(): Promise<void>;
|
||||
pause(): void;
|
||||
resume(): Promise<void>;
|
||||
stop(reason?: SqlServerTraceAutoStopReason): Promise<void>;
|
||||
clearEvents(): void;
|
||||
}
|
||||
|
||||
const PENDING_SESSIONS_STORAGE_KEY = "dbx:sqlserver-trace:pending-sessions:v1";
|
||||
const runtimes = new Map<string, InternalSqlServerActivityTraceRuntime>();
|
||||
const activeSessionNames = new Set<string>();
|
||||
const staleCleanupTasks = new Map<string, Promise<number>>();
|
||||
|
||||
interface InternalSqlServerActivityTraceRuntime extends SqlServerActivityTraceRuntime {
|
||||
pollTimer?: ReturnType<typeof setInterval>;
|
||||
elapsedTimer?: ReturnType<typeof setInterval>;
|
||||
pollPromise?: Promise<void>;
|
||||
activeReadExecutionId?: string;
|
||||
generation: number;
|
||||
cursor?: SqlServerTraceCursor;
|
||||
startPromise?: Promise<void>;
|
||||
stopPromise?: Promise<void>;
|
||||
}
|
||||
|
||||
function readPendingSessions(): PendingSqlServerTraceSession[] {
|
||||
try {
|
||||
const value = JSON.parse(safeLocalStorageGet(PENDING_SESSIONS_STORAGE_KEY) || "[]") as unknown;
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(
|
||||
(entry): entry is PendingSqlServerTraceSession =>
|
||||
!!entry &&
|
||||
typeof entry === "object" &&
|
||||
typeof (entry as PendingSqlServerTraceSession).connectionId === "string" &&
|
||||
typeof (entry as PendingSqlServerTraceSession).database === "string" &&
|
||||
typeof (entry as PendingSqlServerTraceSession).sessionName === "string" &&
|
||||
isValidSqlServerTraceSessionName((entry as PendingSqlServerTraceSession).sessionName) &&
|
||||
Number.isFinite((entry as PendingSqlServerTraceSession).expiresAt),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writePendingSessions(sessions: PendingSqlServerTraceSession[]) {
|
||||
safeLocalStorageSet(PENDING_SESSIONS_STORAGE_KEY, JSON.stringify(sessions));
|
||||
}
|
||||
|
||||
function registerPendingSession(runtime: InternalSqlServerActivityTraceRuntime, sessionName: string) {
|
||||
activeSessionNames.add(sessionName);
|
||||
const sessions = readPendingSessions().filter((entry) => entry.sessionName !== sessionName);
|
||||
sessions.push({
|
||||
connectionId: runtime.connectionId,
|
||||
database: runtime.state.selectedDatabase,
|
||||
sessionName,
|
||||
expiresAt: Date.now() + (normalizeSqlServerTraceDurationMinutes(runtime.state.durationMinutes) + SQLSERVER_TRACE_STALE_GRACE_MINUTES) * 60_000,
|
||||
});
|
||||
writePendingSessions(sessions);
|
||||
}
|
||||
|
||||
function unregisterPendingSession(sessionName: string) {
|
||||
activeSessionNames.delete(sessionName);
|
||||
writePendingSessions(readPendingSessions().filter((entry) => entry.sessionName !== sessionName));
|
||||
}
|
||||
|
||||
async function executeControlQuery(connectionId: string, database: string, sql: string, maxRows = 1): Promise<QueryResult> {
|
||||
return api.executeQuery(connectionId, database || "master", sql, undefined, uuid(), { maxRows });
|
||||
}
|
||||
|
||||
export function cleanupStaleSqlServerTraceSessions(connectionId: string, database = "master"): Promise<number> {
|
||||
const existing = staleCleanupTasks.get(connectionId);
|
||||
if (existing) return existing;
|
||||
const task = (async () => {
|
||||
const now = Date.now();
|
||||
const pending = readPendingSessions().filter((entry) => entry.connectionId === connectionId && entry.expiresAt <= now && !activeSessionNames.has(entry.sessionName));
|
||||
const listResult = await executeControlQuery(connectionId, database, buildListSqlServerTraceSessionsSql(), 500);
|
||||
const expired = staleSqlServerTraceSessionNames(listResult, now);
|
||||
const names = [...new Set([...pending.map((entry) => entry.sessionName), ...expired])].filter((name) => !activeSessionNames.has(name));
|
||||
if (names.length === 0) return 0;
|
||||
await executeControlQuery(connectionId, database, buildCleanupSqlServerTraceSessionsSql(names));
|
||||
const cleanedNames = new Set(names);
|
||||
writePendingSessions(readPendingSessions().filter((entry) => !cleanedNames.has(entry.sessionName)));
|
||||
return names.length;
|
||||
})().finally(() => staleCleanupTasks.delete(connectionId));
|
||||
staleCleanupTasks.set(connectionId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
function stopTimers(runtime: InternalSqlServerActivityTraceRuntime) {
|
||||
if (runtime.pollTimer) clearInterval(runtime.pollTimer);
|
||||
if (runtime.elapsedTimer) clearInterval(runtime.elapsedTimer);
|
||||
runtime.pollTimer = undefined;
|
||||
runtime.elapsedTimer = undefined;
|
||||
}
|
||||
|
||||
function startTimers(runtime: InternalSqlServerActivityTraceRuntime) {
|
||||
stopTimers(runtime);
|
||||
runtime.pollTimer = setInterval(() => {
|
||||
if (runtime.state.status === "running") void pollEvents(runtime);
|
||||
}, 1000);
|
||||
runtime.elapsedTimer = setInterval(() => {
|
||||
if (!runtime.state.startedAt || (runtime.state.status !== "running" && runtime.state.status !== "paused")) return;
|
||||
runtime.state.elapsedSeconds = Math.max(0, Math.floor((Date.now() - runtime.state.startedAt) / 1000));
|
||||
if (runtime.state.elapsedSeconds >= normalizeSqlServerTraceDurationMinutes(runtime.state.durationMinutes) * 60) {
|
||||
void stopRuntime(runtime, "duration");
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function mergeIncrementalBatch(runtime: InternalSqlServerActivityTraceRuntime, batch: SqlServerTraceEvent[]): SqlServerTraceEvent[] {
|
||||
const cursor = runtime.cursor;
|
||||
const additions: SqlServerTraceEvent[] = [];
|
||||
const countsByTimestamp = new Map<string, Map<string, number>>();
|
||||
for (const event of batch) {
|
||||
const timestampCounts = countsByTimestamp.get(event.timestamp) ?? new Map<string, number>();
|
||||
const occurrence = (timestampCounts.get(event.key) ?? 0) + 1;
|
||||
timestampCounts.set(event.key, occurrence);
|
||||
countsByTimestamp.set(event.timestamp, timestampCounts);
|
||||
if (!cursor || event.timestamp > cursor.timestamp || (event.timestamp === cursor.timestamp && occurrence > (cursor.counts.get(event.key) ?? 0))) {
|
||||
additions.push({ ...event, key: `${event.key}\u0001${occurrence}` });
|
||||
}
|
||||
}
|
||||
const latestTimestamp = batch[batch.length - 1]?.timestamp;
|
||||
if (latestTimestamp) runtime.cursor = { timestamp: latestTimestamp, counts: countsByTimestamp.get(latestTimestamp) ?? new Map() };
|
||||
return additions;
|
||||
}
|
||||
|
||||
async function cancelActiveRead(runtime: InternalSqlServerActivityTraceRuntime) {
|
||||
const executionId = runtime.activeReadExecutionId;
|
||||
if (!executionId) return;
|
||||
runtime.activeReadExecutionId = undefined;
|
||||
await api.cancelQuery(executionId).catch(() => false);
|
||||
await runtime.pollPromise?.catch(() => undefined);
|
||||
}
|
||||
|
||||
async function cleanupSession(runtime: InternalSqlServerActivityTraceRuntime) {
|
||||
const name = runtime.state.sessionName;
|
||||
if (!name) return;
|
||||
try {
|
||||
await executeControlQuery(runtime.connectionId, runtime.state.selectedDatabase, buildStopSqlServerTraceSessionSql(name));
|
||||
} catch {
|
||||
// The session may already be stopped or the connection may be recovering.
|
||||
}
|
||||
try {
|
||||
await executeControlQuery(runtime.connectionId, runtime.state.selectedDatabase, buildDropSqlServerTraceSessionSql(name));
|
||||
runtime.state.sessionName = "";
|
||||
unregisterPendingSession(name);
|
||||
} catch (error) {
|
||||
runtime.state.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollEvents(runtime: InternalSqlServerActivityTraceRuntime, options: PollOptions = {}) {
|
||||
if (!runtime.state.sessionName || runtime.state.polling) return;
|
||||
if (!options.allowWhileStopping && runtime.state.status !== "running") return;
|
||||
const generation = runtime.generation;
|
||||
const executionId = uuid();
|
||||
runtime.activeReadExecutionId = executionId;
|
||||
runtime.state.polling = true;
|
||||
let autoStopReason: SqlServerTraceAutoStopReason | undefined;
|
||||
const promise = api
|
||||
.executeQuery(runtime.connectionId, runtime.state.selectedDatabase, buildReadSqlServerTraceEventsSql(runtime.state.sessionName, runtime.cursor?.timestamp), undefined, executionId, { maxRows: SQLSERVER_TRACE_READ_BATCH_SIZE })
|
||||
.then((result) => {
|
||||
if (runtime.generation !== generation) return;
|
||||
const additions = mergeIncrementalBatch(runtime, parseSqlServerTraceEvents(result));
|
||||
const remaining = Math.max(0, normalizeSqlServerTraceMaxEvents(runtime.state.maxEvents) - runtime.state.events.length);
|
||||
if (remaining > 0) runtime.state.events.push(...additions.slice(0, remaining));
|
||||
if (!options.final && runtime.state.events.length >= normalizeSqlServerTraceMaxEvents(runtime.state.maxEvents)) {
|
||||
autoStopReason = "events";
|
||||
}
|
||||
})
|
||||
.catch(async (error) => {
|
||||
if (options.final || runtime.generation !== generation || runtime.state.status === "stopping") return;
|
||||
runtime.state.error = error instanceof Error ? error.message : String(error);
|
||||
runtime.state.status = "error";
|
||||
stopTimers(runtime);
|
||||
await cleanupSession(runtime);
|
||||
})
|
||||
.finally(() => {
|
||||
if (runtime.activeReadExecutionId === executionId) runtime.activeReadExecutionId = undefined;
|
||||
runtime.state.polling = false;
|
||||
if (runtime.pollPromise === promise) runtime.pollPromise = undefined;
|
||||
});
|
||||
runtime.pollPromise = promise;
|
||||
await promise;
|
||||
if (autoStopReason) void stopRuntime(runtime, autoStopReason);
|
||||
}
|
||||
|
||||
async function startRuntime(runtime: InternalSqlServerActivityTraceRuntime) {
|
||||
const state = runtime.state;
|
||||
if (!state.selectedDatabase || state.status === "running" || state.status === "paused" || state.status === "starting" || state.status === "stopping") return;
|
||||
state.error = "";
|
||||
state.capabilityProblem = null;
|
||||
state.capabilityVersion = "";
|
||||
state.missingCapabilities = [];
|
||||
state.status = "starting";
|
||||
state.maxEvents = normalizeSqlServerTraceMaxEvents(state.maxEvents);
|
||||
state.durationMinutes = normalizeSqlServerTraceDurationMinutes(state.durationMinutes);
|
||||
state.events = [];
|
||||
state.selectedEvent = null;
|
||||
state.elapsedSeconds = 0;
|
||||
runtime.cursor = undefined;
|
||||
runtime.generation += 1;
|
||||
const generation = runtime.generation;
|
||||
try {
|
||||
await cleanupStaleSqlServerTraceSessions(runtime.connectionId, state.selectedDatabase);
|
||||
const capabilities = parseSqlServerTraceCapabilities(await executeControlQuery(runtime.connectionId, state.selectedDatabase, buildSqlServerTraceCapabilitiesSql(state.selectedDatabase)));
|
||||
const problem = sqlServerTraceCapabilityProblem(capabilities, state.includeStatements);
|
||||
if (problem) {
|
||||
state.capabilityProblem = problem;
|
||||
state.capabilityVersion = capabilities.productVersion;
|
||||
state.missingCapabilities = missingSqlServerTraceCapabilities(capabilities, state.includeStatements);
|
||||
throw new Error(problem);
|
||||
}
|
||||
const name = buildSqlServerTraceSessionName(Date.now() + (state.durationMinutes + SQLSERVER_TRACE_STALE_GRACE_MINUTES) * 60_000);
|
||||
state.sessionName = name;
|
||||
registerPendingSession(runtime, name);
|
||||
await executeControlQuery(
|
||||
runtime.connectionId,
|
||||
state.selectedDatabase,
|
||||
buildCreateSqlServerTraceSessionSql({
|
||||
sessionName: name,
|
||||
databaseId: capabilities.databaseId,
|
||||
maxEvents: state.maxEvents,
|
||||
includeStatements: state.includeStatements,
|
||||
}),
|
||||
);
|
||||
if (runtime.generation !== generation) {
|
||||
await cleanupSession(runtime);
|
||||
return;
|
||||
}
|
||||
state.startedAt = Date.now();
|
||||
state.status = "running";
|
||||
startTimers(runtime);
|
||||
void pollEvents(runtime);
|
||||
} catch (error) {
|
||||
if (!state.capabilityProblem) state.error = error instanceof Error ? error.message : String(error);
|
||||
state.status = "error";
|
||||
stopTimers(runtime);
|
||||
await cleanupSession(runtime);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRuntime(runtime: InternalSqlServerActivityTraceRuntime, reason?: SqlServerTraceAutoStopReason, final = true) {
|
||||
if (runtime.stopPromise) return runtime.stopPromise;
|
||||
const promise = (async () => {
|
||||
if (!runtime.state.sessionName && runtime.state.status !== "starting") return;
|
||||
runtime.state.status = "stopping";
|
||||
stopTimers(runtime);
|
||||
runtime.generation += 1;
|
||||
await runtime.startPromise?.catch(() => undefined);
|
||||
await cancelActiveRead(runtime);
|
||||
if (final && runtime.state.sessionName) await pollEvents(runtime, { final: true, allowWhileStopping: true });
|
||||
await cleanupSession(runtime);
|
||||
runtime.state.status = runtime.state.sessionName ? "error" : "stopped";
|
||||
if (reason && !runtime.state.sessionName) {
|
||||
runtime.state.autoStopReason = reason;
|
||||
runtime.state.autoStopRevision += 1;
|
||||
}
|
||||
})().finally(() => {
|
||||
if (runtime.stopPromise === promise) runtime.stopPromise = undefined;
|
||||
});
|
||||
runtime.stopPromise = promise;
|
||||
await promise;
|
||||
}
|
||||
|
||||
export function getSqlServerActivityTraceRuntime(tabId: string, connectionId: string, database = ""): SqlServerActivityTraceRuntime {
|
||||
const existing = runtimes.get(tabId);
|
||||
if (existing) return existing;
|
||||
const runtime: InternalSqlServerActivityTraceRuntime = {
|
||||
tabId,
|
||||
connectionId,
|
||||
generation: 0,
|
||||
state: reactive<SqlServerActivityTraceState>({
|
||||
status: "idle",
|
||||
selectedDatabase: database,
|
||||
includeStatements: false,
|
||||
maxEvents: SQLSERVER_TRACE_DEFAULT_MAX_EVENTS,
|
||||
durationMinutes: SQLSERVER_TRACE_DEFAULT_DURATION_MINUTES,
|
||||
events: [],
|
||||
selectedEvent: null,
|
||||
sqlFilter: "",
|
||||
loginFilter: "",
|
||||
clientFilter: "",
|
||||
sessionFilter: "",
|
||||
error: "",
|
||||
sessionName: "",
|
||||
startedAt: null,
|
||||
elapsedSeconds: 0,
|
||||
polling: false,
|
||||
autoStopReason: null,
|
||||
autoStopRevision: 0,
|
||||
capabilityProblem: null,
|
||||
capabilityVersion: "",
|
||||
missingCapabilities: [],
|
||||
}),
|
||||
start: () => {
|
||||
if (runtime.startPromise) return runtime.startPromise;
|
||||
const promise = startRuntime(runtime).finally(() => {
|
||||
if (runtime.startPromise === promise) runtime.startPromise = undefined;
|
||||
});
|
||||
runtime.startPromise = promise;
|
||||
return promise;
|
||||
},
|
||||
pause: () => {
|
||||
if (runtime.state.status !== "running") return;
|
||||
runtime.state.status = "paused";
|
||||
if (runtime.pollTimer) clearInterval(runtime.pollTimer);
|
||||
runtime.pollTimer = undefined;
|
||||
},
|
||||
resume: async () => {
|
||||
if (runtime.state.status !== "paused") return;
|
||||
runtime.state.status = "running";
|
||||
startTimers(runtime);
|
||||
await pollEvents(runtime);
|
||||
},
|
||||
stop: (reason) => stopRuntime(runtime, reason),
|
||||
clearEvents: () => {
|
||||
runtime.state.events = [];
|
||||
runtime.state.selectedEvent = null;
|
||||
},
|
||||
};
|
||||
runtimes.set(tabId, runtime);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export async function disposeSqlServerActivityTrace(tabId: string): Promise<void> {
|
||||
const runtime = runtimes.get(tabId);
|
||||
if (!runtime) return;
|
||||
runtime.generation += 1;
|
||||
stopTimers(runtime);
|
||||
await cancelActiveRead(runtime);
|
||||
await runtime.startPromise?.catch(() => undefined);
|
||||
if (runtime.state.sessionName) await stopRuntime(runtime, undefined, false);
|
||||
if (runtimes.get(tabId) === runtime) runtimes.delete(tabId);
|
||||
}
|
||||
|
||||
export async function disposeAllSqlServerActivityTraces(): Promise<void> {
|
||||
await Promise.all([...runtimes.keys()].map(disposeSqlServerActivityTrace));
|
||||
}
|
||||
|
||||
export async function disposeSqlServerActivityTracesForConnection(connectionId: string, database?: string): Promise<void> {
|
||||
const tabIds = [...runtimes.values()].filter((runtime) => runtime.connectionId === connectionId && (!database || runtime.state.selectedDatabase === database)).map((runtime) => runtime.tabId);
|
||||
await Promise.all(tabIds.map(disposeSqlServerActivityTrace));
|
||||
}
|
||||
|
||||
export function activeSqlServerActivityTraceRuntimeCount(): number {
|
||||
return runtimes.size;
|
||||
}
|
||||
|
||||
export function hasSqlServerActivityTraceForConnection(connectionId: string, database?: string): boolean {
|
||||
return [...runtimes.values()].some((runtime) => runtime.connectionId === connectionId && (!database || runtime.state.selectedDatabase === database));
|
||||
}
|
||||
|
||||
export function sqlServerTracePendingSessionLifetimeMs(): number {
|
||||
return (SQLSERVER_TRACE_MAX_DURATION_MINUTES + SQLSERVER_TRACE_STALE_GRACE_MINUTES) * 60_000;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
disposeSqlServerActivityTrace: vi.fn(() => Promise.resolve()),
|
||||
disposeAllSqlServerActivityTraces: vi.fn(() => Promise.resolve()),
|
||||
disposeSqlServerActivityTracesForConnection: vi.fn(() => Promise.resolve()),
|
||||
cleanupStaleSqlServerTraceSessions: vi.fn(() => Promise.resolve(0)),
|
||||
hasSqlServerActivityTraceForConnection: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sqlserver/sqlServerActivityTraceRuntime", () => mocks);
|
||||
|
||||
const connectionStoreSource = readFileSync(new URL("../connectionStore.ts", import.meta.url), "utf8");
|
||||
|
||||
describe("queryStore SQL Server trace lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
});
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("disposes the trace runtime only when its tab is explicitly closed", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.openSqlServerActivityTrace("sqlserver-1");
|
||||
|
||||
store.switchTab(store.createTab("sqlserver-1", "app", "query_1"));
|
||||
expect(mocks.disposeSqlServerActivityTrace).not.toHaveBeenCalled();
|
||||
|
||||
store.closeTab(tabId);
|
||||
expect(mocks.disposeSqlServerActivityTrace).toHaveBeenCalledWith(tabId);
|
||||
});
|
||||
|
||||
it("does not scan trace sessions on ordinary connection success", () => {
|
||||
expect(connectionStoreSource).not.toContain("cleanupStaleSqlServerTraceSessions");
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,7 @@ import { loadTimeoutInheritanceBackup, saveTimeoutInheritanceBackup } from "@/li
|
||||
import { migrateSqlServerLegacyCompatibilityConfig, requiresSqlServerLegacyCompatibilityComponent, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
|
||||
import { gaussdbMTypeDisplayName } from "@/lib/table/postgresDataTypeHelp";
|
||||
import { deleteTabResultSnapshotsForOwner } from "@/lib/tabs/tabResultCache";
|
||||
import { disposeSqlServerActivityTracesForConnection, hasSqlServerActivityTraceForConnection } from "@/lib/sqlserver/sqlServerActivityTraceRuntime";
|
||||
import { connectionUsesVisibleSchemaFilter, filterDatabaseNamesForConnection, filterSchemaNamesForConnection, filterVisibleDatabaseNames, normalizeVisibleDatabaseSelection } from "@/lib/database/visibleDatabases";
|
||||
import {
|
||||
buildObjectGroupPlaceholderNodes,
|
||||
@@ -2931,9 +2932,10 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
|
||||
async function disconnect(connectionId: string) {
|
||||
const stateRevision = bumpConnectionStateRevision(connectionId);
|
||||
const shouldRemoveOneTimeConnection = getConfig(connectionId)?.one_time === true;
|
||||
if (hasSqlServerActivityTraceForConnection(connectionId)) await disposeSqlServerActivityTracesForConnection(connectionId);
|
||||
const disconnectRequest = startDisconnectRequest(connectionId);
|
||||
cancelLocalConnectionAttempt(connectionId);
|
||||
const shouldRemoveOneTimeConnection = getConfig(connectionId)?.one_time === true;
|
||||
|
||||
connectedIds.value.delete(connectionId);
|
||||
clearPrimaryVisibleObjectNames(connectionId);
|
||||
@@ -2978,6 +2980,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
|
||||
async function closeDatabaseConnection(connectionId: string, database: string) {
|
||||
if (hasSqlServerActivityTraceForConnection(connectionId, database)) await disposeSqlServerActivityTracesForConnection(connectionId, database);
|
||||
await api.closeDatabaseConnection(connectionId, database);
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const queryStore = useQueryStore();
|
||||
|
||||
@@ -71,6 +71,7 @@ import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName";
|
||||
import { resolveSavedSqlExecutionTarget, savedSqlExecutionTargetFromTab, type SavedSqlExecutionTarget, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
|
||||
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
|
||||
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
|
||||
import { disposeAllSqlServerActivityTraces, disposeSqlServerActivityTrace } from "@/lib/sqlserver/sqlServerActivityTraceRuntime";
|
||||
import type { SavedSqlFile } from "@/types/database";
|
||||
import i18n from "@/i18n";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
@@ -1586,6 +1587,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
onScopeDispose(() => {
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
persistTimer = null;
|
||||
void disposeAllSqlServerActivityTraces();
|
||||
});
|
||||
|
||||
// Immediately flush any pending debounced persist so the on-disk content
|
||||
@@ -1834,6 +1836,31 @@ export const useQueryStore = defineStore("query", () => {
|
||||
return id;
|
||||
}
|
||||
|
||||
function openSqlServerActivityTrace(connectionId: string) {
|
||||
const existing = tabs.value.find((tab) => tab.mode === "sqlserver-trace" && 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("sqlServerTrace.title")}` : t("sqlServerTrace.title"),
|
||||
connectionId,
|
||||
database: conn?.database || "",
|
||||
sql: "",
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
mode: "sqlserver-trace",
|
||||
};
|
||||
tabs.value.push(tab);
|
||||
activeTabId.value = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
function openMysqlDashboard(connectionId: string) {
|
||||
const existing = tabs.value.find((tab) => tab.mode === "mysql-dashboard" && tab.connectionId === connectionId);
|
||||
if (existing) {
|
||||
@@ -2376,6 +2403,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const idx = tabs.value.findIndex((t) => t.id === id);
|
||||
if (idx < 0) return;
|
||||
persistSavedSqlEditorPosition(tabs.value[idx]);
|
||||
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
|
||||
clearDataGridPendingSnapshotsForTab(id);
|
||||
if (tabs.value[idx].txnSessionId) void rollbackTransaction(id);
|
||||
if (tabs.value[idx].isExecuting) void cancelTabExecution(id);
|
||||
@@ -2657,6 +2685,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tabs.value
|
||||
.filter((tab) => closingIds.has(tab.id))
|
||||
.forEach((tab) => {
|
||||
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
|
||||
clearDataGridPendingSnapshotsForTab(tab.id);
|
||||
if (tab.txnSessionId) void rollbackTransaction(tab.id);
|
||||
if (tab.isExecuting) void cancelTabExecution(tab.id);
|
||||
@@ -5931,6 +5960,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
openMongoBucket,
|
||||
openUserAdmin,
|
||||
openProcessList,
|
||||
openSqlServerActivityTrace,
|
||||
openMysqlDashboard,
|
||||
openPostgresDashboard,
|
||||
openNacosDashboard,
|
||||
|
||||
@@ -1180,6 +1180,7 @@ export interface QueryTab {
|
||||
| "dameng-roles"
|
||||
| "dameng-jobs"
|
||||
| "processlist"
|
||||
| "sqlserver-trace"
|
||||
| "mysql-dashboard"
|
||||
| "postgres-dashboard";
|
||||
/** Ephemeral navigation intent; it is consumed by HBaseBrowser and is not persisted. */
|
||||
|
||||
@@ -72,6 +72,7 @@ const SQLSERVER_RESULT_TYPE_PROBE_SQL: &str = "\
|
||||
END CATCH \
|
||||
END";
|
||||
const SIMPLE_QUERY_MODULE_KEYWORDS: &[&str] = &["FUNCTION", "PROC", "PROCEDURE", "TRIGGER", "VIEW"];
|
||||
const SQLSERVER_INTERNAL_HEALTH_CHECK_SQL: &str = "/* DBX_INTERNAL_TRACE */ SELECT 1";
|
||||
// Match JDBC/tiberius `encrypt=false`: encrypt only login, then drop back to raw TDS.
|
||||
const SQLSERVER_LEGACY_ENCRYPTION_LEVEL: tiberius::EncryptionLevel = tiberius::EncryptionLevel::Off;
|
||||
// Some very old SQL Server setups only accepted DBX <= 0.5.48 because the fallback
|
||||
@@ -1817,7 +1818,7 @@ pub async fn get_completion_context(client: &mut SqlServerClient) -> Result<SqlS
|
||||
|
||||
pub async fn test_connection(client: &mut SqlServerClient) -> Result<(), String> {
|
||||
crate::db::with_connection_timeout("SQL Server", crate::db::connection_timeout(), async {
|
||||
let stream = client.simple_query("SELECT 1").await.map_err(|e| e.to_string())?;
|
||||
let stream = client.simple_query(SQLSERVER_INTERNAL_HEALTH_CHECK_SQL).await.map_err(|e| e.to_string())?;
|
||||
let _ = stream.into_first_result().await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
use dbx_core::db::sqlserver;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn base36(mut value: u128) -> String {
|
||||
const DIGITS: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
let mut encoded = Vec::new();
|
||||
loop {
|
||||
encoded.push(DIGITS[(value % 36) as usize]);
|
||||
value /= 36;
|
||||
if value == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
encoded.reverse();
|
||||
String::from_utf8(encoded).expect("base36 session expiry")
|
||||
}
|
||||
|
||||
async fn run_batch(client: &mut sqlserver::SqlServerClient, sql: &str) -> Result<(), String> {
|
||||
client
|
||||
.simple_query(sql)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.into_results()
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires DBX_TEST_SQLSERVER_HOST and DBX_TEST_SQLSERVER_PASSWORD"]
|
||||
async fn sqlserver_extended_events_probe_captures_rpc_and_batch_events() {
|
||||
let host = std::env::var("DBX_TEST_SQLSERVER_HOST").expect("DBX_TEST_SQLSERVER_HOST");
|
||||
let port = std::env::var("DBX_TEST_SQLSERVER_PORT").ok().and_then(|value| value.parse().ok()).unwrap_or(1433);
|
||||
let user = std::env::var("DBX_TEST_SQLSERVER_USER").unwrap_or_else(|_| "sa".to_string());
|
||||
let password = std::env::var("DBX_TEST_SQLSERVER_PASSWORD").expect("DBX_TEST_SQLSERVER_PASSWORD");
|
||||
let database = std::env::var("DBX_TEST_SQLSERVER_DATABASE").unwrap_or_else(|_| "master".to_string());
|
||||
let expires_at = SystemTime::now().duration_since(UNIX_EPOCH).expect("system time").as_millis() + 5 * 60_000;
|
||||
let session_name = format!("DBX_TRACE_{}_TEST{}", base36(expires_at), std::process::id());
|
||||
let procedure_name = format!("DBX_TRACE_TEST_PROC_{}", std::process::id());
|
||||
|
||||
let mut control = sqlserver::connect_with_port_explicit(
|
||||
&host,
|
||||
port,
|
||||
true,
|
||||
&user,
|
||||
&password,
|
||||
Some(&database),
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.await
|
||||
.expect("connect control session");
|
||||
|
||||
let probe = sqlserver::execute_query(
|
||||
&mut control,
|
||||
&format!(
|
||||
"SELECT \
|
||||
CAST(SERVERPROPERTY(N'ProductVersion') AS nvarchar(128)) AS product_version, \
|
||||
CONVERT(int, SERVERPROPERTY(N'EngineEdition')) AS engine_edition, \
|
||||
DB_ID(N'{}') AS database_id, \
|
||||
CONVERT(int, HAS_PERMS_BY_NAME(NULL, NULL, N'ALTER ANY EVENT SESSION')) AS can_alter_event_session, \
|
||||
CONVERT(int, HAS_PERMS_BY_NAME(NULL, NULL, N'VIEW SERVER STATE')) AS can_view_server_state, \
|
||||
CONVERT(int, CASE WHEN EXISTS (SELECT 1 FROM sys.fn_builtin_permissions(DEFAULT) WHERE permission_name = N'VIEW SERVER PERFORMANCE STATE') \
|
||||
THEN HAS_PERMS_BY_NAME(NULL, NULL, N'VIEW SERVER PERFORMANCE STATE') ELSE 1 END) AS can_view_server_performance_state, \
|
||||
CONVERT(int, CASE WHEN EXISTS (SELECT 1 FROM sys.dm_xe_objects object JOIN sys.dm_xe_packages package ON package.guid = object.package_guid \
|
||||
WHERE package.name = N'sqlserver' AND object.object_type = N'event' AND object.name = N'rpc_completed') THEN 1 ELSE 0 END) AS has_rpc_completed_event, \
|
||||
CONVERT(int, CASE WHEN EXISTS (SELECT 1 FROM sys.dm_xe_objects object JOIN sys.dm_xe_packages package ON package.guid = object.package_guid \
|
||||
WHERE package.name = N'sqlserver' AND object.object_type = N'event' AND object.name = N'sql_batch_completed') THEN 1 ELSE 0 END) AS has_sql_batch_completed_event, \
|
||||
CONVERT(int, CASE WHEN EXISTS (SELECT 1 FROM sys.dm_xe_objects object JOIN sys.dm_xe_packages package ON package.guid = object.package_guid \
|
||||
WHERE package.name = N'sqlserver' AND object.object_type = N'event' AND object.name = N'sp_statement_completed') THEN 1 ELSE 0 END) AS has_sp_statement_completed_event, \
|
||||
CONVERT(int, CASE WHEN EXISTS (SELECT 1 FROM sys.dm_xe_objects object JOIN sys.dm_xe_packages package ON package.guid = object.package_guid \
|
||||
WHERE package.name = N'package0' AND object.object_type = N'target' AND object.name = N'ring_buffer') THEN 1 ELSE 0 END) AS has_ring_buffer_target, \
|
||||
CONVERT(int, CASE WHEN EXISTS (SELECT 1 FROM sys.dm_xe_objects object JOIN sys.dm_xe_packages package ON package.guid = object.package_guid \
|
||||
WHERE package.name = N'sqlserver' AND object.object_type = N'pred_source' AND object.name = N'database_id') THEN 1 ELSE 0 END) AS has_database_id_predicate, \
|
||||
CONVERT(int, CASE WHEN EXISTS (SELECT 1 FROM sys.dm_xe_objects object JOIN sys.dm_xe_packages package ON package.guid = object.package_guid \
|
||||
WHERE package.name = N'sqlserver' AND object.object_type = N'pred_compare' AND object.name = N'like_i_sql_unicode_string') THEN 1 ELSE 0 END) AS has_like_predicate, \
|
||||
STUFF((SELECT N',' + object.name FROM sys.dm_xe_objects object JOIN sys.dm_xe_packages package ON package.guid = object.package_guid \
|
||||
WHERE package.name = N'sqlserver' AND object.object_type = N'action' AND object.name IN \
|
||||
(N'client_app_name', N'client_hostname', N'database_id', N'database_name', N'server_principal_name', N'session_id', N'sql_text') \
|
||||
ORDER BY object.name FOR XML PATH(N''), TYPE).value(N'.', N'nvarchar(max)'), 1, 1, N'') AS available_actions",
|
||||
database.replace('\'', "''")
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("query server capabilities");
|
||||
eprintln!("capabilities={:?}", probe.rows);
|
||||
let capability_row = &probe.rows[0];
|
||||
let database_id = capability_row[2].as_i64().expect("database id");
|
||||
for (column, value) in probe.columns[3..12].iter().zip(&capability_row[3..12]) {
|
||||
assert_eq!(value.as_i64(), Some(1), "missing XE capability in {column}");
|
||||
}
|
||||
let actions = capability_row[12].as_str().expect("available XE actions");
|
||||
for action in [
|
||||
"client_app_name",
|
||||
"client_hostname",
|
||||
"database_id",
|
||||
"database_name",
|
||||
"server_principal_name",
|
||||
"session_id",
|
||||
"sql_text",
|
||||
] {
|
||||
assert!(actions.split(',').any(|available| available == action), "missing XE action {action}");
|
||||
}
|
||||
|
||||
let drop_sql = format!(
|
||||
"IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'{session_name}') \
|
||||
DROP EVENT SESSION [{session_name}] ON SERVER;"
|
||||
);
|
||||
run_batch(&mut control, &drop_sql).await.expect("drop stale test session");
|
||||
run_batch(
|
||||
&mut control,
|
||||
&format!(
|
||||
"IF OBJECT_ID(N'dbo.{procedure_name}', N'P') IS NOT NULL DROP PROCEDURE dbo.[{procedure_name}]; \
|
||||
EXEC(N'CREATE PROCEDURE dbo.[{procedure_name}] @value int AS SELECT @value AS traced_value;');"
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("create trace procedure");
|
||||
|
||||
let create_sql = format!(
|
||||
"CREATE EVENT SESSION [{session_name}] ON SERVER \
|
||||
ADD EVENT sqlserver.rpc_completed( \
|
||||
ACTION(sqlserver.client_app_name, sqlserver.client_hostname, sqlserver.database_id, \
|
||||
sqlserver.database_name, sqlserver.server_principal_name, sqlserver.session_id, sqlserver.sql_text) \
|
||||
WHERE ([sqlserver].[database_id]=({database_id}) AND NOT [sqlserver].[like_i_sql_unicode_string]([sqlserver].[sql_text],N'%DBX_INTERNAL_TRACE%'))), \
|
||||
ADD EVENT sqlserver.sql_batch_completed( \
|
||||
ACTION(sqlserver.client_app_name, sqlserver.client_hostname, sqlserver.database_id, \
|
||||
sqlserver.database_name, sqlserver.server_principal_name, sqlserver.session_id, sqlserver.sql_text) \
|
||||
WHERE ([sqlserver].[database_id]=({database_id}) AND NOT [sqlserver].[like_i_sql_unicode_string]([sqlserver].[sql_text],N'%DBX_INTERNAL_TRACE%'))), \
|
||||
ADD EVENT sqlserver.sp_statement_completed( \
|
||||
ACTION(sqlserver.client_app_name, sqlserver.client_hostname, sqlserver.database_id, \
|
||||
sqlserver.database_name, sqlserver.server_principal_name, sqlserver.session_id, sqlserver.sql_text) \
|
||||
WHERE ([sqlserver].[database_id]=({database_id}) AND NOT [sqlserver].[like_i_sql_unicode_string]([sqlserver].[sql_text],N'%DBX_INTERNAL_TRACE%'))) \
|
||||
ADD TARGET package0.ring_buffer(SET max_events_limit=(256), max_memory=(4096)) \
|
||||
WITH (MAX_MEMORY=4096 KB, EVENT_RETENTION_MODE=ALLOW_SINGLE_EVENT_LOSS, \
|
||||
MAX_DISPATCH_LATENCY=1 SECONDS, TRACK_CAUSALITY=OFF, STARTUP_STATE=OFF); \
|
||||
ALTER EVENT SESSION [{session_name}] ON SERVER STATE = START;"
|
||||
);
|
||||
|
||||
let result = async {
|
||||
run_batch(&mut control, &create_sql).await?;
|
||||
let mut workload = sqlserver::connect_with_port_explicit(
|
||||
&host,
|
||||
port,
|
||||
true,
|
||||
&user,
|
||||
&password,
|
||||
Some(&database),
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.await?;
|
||||
run_batch(&mut workload, "SELECT 1701 AS dbx_trace_probe; WAITFOR DELAY '00:00:00.050';").await?;
|
||||
sqlserver::test_connection(&mut workload).await?;
|
||||
workload
|
||||
.query(&format!("EXEC dbo.[{procedure_name}] @value = @P1"), &[&1701_i32])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.into_results()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
let read_sql = format!(
|
||||
"/* DBX_INTERNAL_TRACE */ ;WITH target AS ( \
|
||||
SELECT CAST(t.target_data AS xml) AS target_data \
|
||||
FROM sys.dm_xe_session_targets t \
|
||||
JOIN sys.dm_xe_sessions s ON s.address = t.event_session_address \
|
||||
WHERE s.name = N'{session_name}' AND t.target_name = N'ring_buffer' \
|
||||
), event_rows AS ( \
|
||||
SELECT event_node.value('(@name)[1]', 'nvarchar(128)') AS event_name, \
|
||||
event_node.value('(@timestamp)[1]', 'datetime2(7)') AS event_time_utc, \
|
||||
event_node.value('(action[@name=\"database_name\"]/value/text())[1]', 'nvarchar(128)') AS database_name, \
|
||||
event_node.value('(action[@name=\"session_id\"]/value/text())[1]', 'int') AS session_id, \
|
||||
COALESCE( \
|
||||
NULLIF(event_node.value('(data[@name=\"statement\"]/value/text())[1]', 'nvarchar(max)'), N''), \
|
||||
NULLIF(event_node.value('(data[@name=\"batch_text\"]/value/text())[1]', 'nvarchar(max)'), N''), \
|
||||
event_node.value('(action[@name=\"sql_text\"]/value/text())[1]', 'nvarchar(max)') \
|
||||
) AS sql_text, \
|
||||
event_node.value('(data[@name=\"duration\"]/value/text())[1]', 'bigint') AS duration, \
|
||||
event_node.value('(data[@name=\"cpu_time\"]/value/text())[1]', 'bigint') AS cpu_time, \
|
||||
event_node.value('(data[@name=\"logical_reads\"]/value/text())[1]', 'bigint') AS logical_reads, \
|
||||
event_node.value('(data[@name=\"writes\"]/value/text())[1]', 'bigint') AS writes, \
|
||||
event_node.value('(data[@name=\"result\"]/text/text())[1]', 'nvarchar(256)') AS result_text \
|
||||
FROM target CROSS APPLY target_data.nodes('/RingBufferTarget/event') AS events(event_node) \
|
||||
), bounded_events AS ( \
|
||||
SELECT TOP (256) * FROM event_rows ORDER BY event_time_utc DESC \
|
||||
) \
|
||||
SELECT * FROM bounded_events ORDER BY event_time_utc"
|
||||
);
|
||||
let events = sqlserver::execute_query(&mut control, &read_sql).await?;
|
||||
eprintln!("columns={:?}", events.columns);
|
||||
eprintln!("events={:?}", events.rows);
|
||||
if !events.rows.iter().any(|row| row.get(4).and_then(|value| value.as_str()).is_some_and(|sql| sql.contains("dbx_trace_probe"))) {
|
||||
return Err("probe SQL was not captured".to_string());
|
||||
}
|
||||
if !events.rows.iter().any(|row| {
|
||||
row.first().and_then(|value| value.as_str()) == Some("rpc_completed")
|
||||
&& row.get(4).and_then(|value| value.as_str()).is_some_and(|sql| sql.contains(&procedure_name))
|
||||
}) {
|
||||
return Err("parameterized procedure RPC was not captured".to_string());
|
||||
}
|
||||
if !events.rows.iter().any(|row| row.first().and_then(|value| value.as_str()) == Some("sp_statement_completed")) {
|
||||
return Err("stored procedure statement was not captured".to_string());
|
||||
}
|
||||
if events.rows.iter().any(|row| {
|
||||
row.get(4)
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|sql| sql.contains("DBX_INTERNAL_TRACE"))
|
||||
}) {
|
||||
return Err("DBX internal health check was captured".to_string());
|
||||
}
|
||||
Ok::<(), String>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
let _ = run_batch(&mut control, &format!("IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'{session_name}') ALTER EVENT SESSION [{session_name}] ON SERVER STATE = STOP; {drop_sql}")).await;
|
||||
let _ = run_batch(
|
||||
&mut control,
|
||||
&format!("IF OBJECT_ID(N'dbo.{procedure_name}', N'P') IS NOT NULL DROP PROCEDURE dbo.[{procedure_name}];"),
|
||||
)
|
||||
.await;
|
||||
result.expect("capture Extended Events workload");
|
||||
}
|
||||
Reference in New Issue
Block a user