feat(query): cross-database timing details, drop OceanBase audit query

This commit is contained in:
efeire authored and GitHub committed 2026-09-24 19:17:27 +08:00
1 parent 4b9a1c4d5c
commit d0497e0ca6
69 files changed
+973 -180

No files matched your search

@@ -1,4 +1,5 @@
<script setup lang="ts">
import QueryTimingDetails from "./QueryTimingDetails.vue";
import { applyDdlStoragePreference } from "@/lib/sql/ddlStorage";
import { applyDdlSearchMarks } from "@/lib/sql/ddlSearchMarks";
import DdlStorageToggle from "@/components/objects/DdlStorageToggle.vue";
@@ -6713,7 +6714,7 @@ let dataGridIsActive = true;
let canvasRuntime: DataGridCanvasRuntime;
const { elapsedMs: resultViewUpdateMs, canvasDrawCompleted: completeResultCanvasDraw } = useResultViewUpdateTiming(
() => props.result,
() => resolvedDatabaseType.value === "oceanbase-oracle" && isResultsContext.value,
() => isResultsContext.value,
() => (useCanvasGridRows.value ? "canvas" : "dom"),
);
@@ -13987,12 +13988,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
</span>
<span v-if="showTruncationWarning" class="shrink-0 text-amber-500 text-xs">(truncated)</span>
<span v-if="!hasData" class="shrink-0">{{ t("grid.rowsAffected", { count: result.affected_rows }) }}</span>
<template v-if="resolvedDatabaseType === 'oceanbase-oracle' && isResultsContext">
<span class="shrink-0" :title="t('grid.serverExecuteTimeHint')">{{ result.server_execute_time_us !== undefined ? t("grid.serverExecuteTime", { us: result.server_execute_time_us }) : t("grid.serverExecuteTimeUnavailable") }}</span>
<span class="shrink-0" :title="t('grid.agentExecuteTimeHint')">{{ t("grid.agentExecuteTime", { ms: result.execution_time_ms }) }}</span>
<span v-if="result.client_request_wait_ms !== undefined" class="shrink-0" :title="t('grid.clientRequestWaitHint')">{{ t("grid.clientRequestWait", { ms: result.client_request_wait_ms }) }}</span>
<span v-if="resultViewUpdateMs !== undefined" class="shrink-0" :title="t('grid.resultViewUpdateHint')">{{ t("grid.resultViewUpdate", { ms: resultViewUpdateMs }) }}</span>
</template>
<QueryTimingDetails v-if="isResultsContext" :result="result" :render-ms="resultViewUpdateMs" />
<span v-else class="shrink-0">{{ result.execution_time_ms }}ms</span>
<template v-if="editable && hasDataGridSaveTarget">
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { QueryResult } from "@/types/database";
const props = defineProps<{ result: QueryResult; renderMs?: number }>();
const { t } = useI18n();
const measured = (value: number | undefined): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
const format = (value: number | undefined) => (measured(value) ? `${Number(value.toFixed(3))}ms` : t("grid.timingUnavailable"));
const wait = computed(() => (measured(props.result.client_request_wait_ms) ? `${Math.round(props.result.client_request_wait_ms)}ms` : "—"));
const hasAgentTiming = computed(() => measured(props.result.query_timings_ms?.agent_total));
const phases = computed(() => {
const m = props.result.query_timings_ms ?? {};
const waitMs = props.result.client_request_wait_ms;
// This is a cross-boundary remainder, not a measured network duration.
const remainder = measured(waitMs) && measured(m.agent_total) && measured(m.core_lock) ? Math.max(0, waitMs - m.agent_total - m.core_lock) : undefined;
const agentOther = measured(m.agent_total)
? Math.max(
0,
m.agent_total -
Object.entries(m)
.filter(([key]) => !["agent_total", "core_lock"].includes(key))
.reduce((sum, [, value]) => sum + (measured(value) ? value : 0), 0),
)
: undefined;
const stages = [
{ key: "prepare", value: props.result.client_prepare_ms },
{ key: "request", value: waitMs },
{ key: "core_lock", value: m.core_lock },
{ key: "pool_acquire", value: m.pool_acquire },
{ key: "session_prepare", value: m.session_prepare },
{ key: "schema", value: m.schema },
{ key: "statement_prepare", value: m.statement_prepare },
{ key: "jdbc_execute", value: m.jdbc_execute },
{ key: "metadata", value: m.metadata },
{ key: "fetch", value: m.fetch },
{ key: "pool_release", value: m.pool_release },
{ key: "agent_other", value: agentOther },
{ key: "remainder", value: remainder },
{ key: "result", value: props.result.client_result_ms },
{ key: "render", value: props.renderMs },
];
return stages.filter((stage) => {
if (["prepare", "result", "render"].includes(stage.key)) return true;
if (stage.key === "request") return !hasAgentTiming.value;
return hasAgentTiming.value && measured(stage.value);
});
});
</script>
<template>
<TooltipProvider>
<Tooltip :delay-duration="200">
<TooltipTrigger as-child>
<button type="button" class="shrink-0 cursor-help rounded-sm tabular-nums focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" :aria-label="t('grid.timingDetails') + ': ' + wait" data-testid="query-timing-trigger">{{ wait }}</button>
</TooltipTrigger>
<TooltipContent side="top" align="start" class="block max-w-sm max-h-[var(--reka-tooltip-content-available-height)] overflow-y-auto">
<div class="space-y-2" data-testid="query-timing-details">
<p class="font-medium">{{ t("grid.clientRequestWait", { ms: measured(result.client_request_wait_ms) ? Number(result.client_request_wait_ms.toFixed(3)) : t("grid.timingUnavailable") }) }}</p>
<p v-if="(result.timing_page_count ?? 1) > 1">{{ t("grid.timingPages", { count: result.timing_page_count }) }}</p>
<table class="w-full border-collapse text-left" :aria-label="t('grid.timingDetails')">
<thead class="border-b border-current/20">
<tr>
<th scope="col" class="pb-1 pr-3 font-medium">{{ t("grid.timingNumber") }}</th>
<th scope="col" class="pb-1 pr-4 font-medium">{{ t("grid.timingStage") }}</th>
<th scope="col" class="pb-1 text-right font-medium">{{ t("grid.timingDuration") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(phase, index) in phases" :key="phase.key">
<td class="py-0.5 pr-3 tabular-nums">{{ index + 1 }}.</td>
<th scope="row" class="py-0.5 pr-4 font-normal">{{ t(`grid.timingPhase_${phase.key}`) }}</th>
<td class="whitespace-nowrap py-0.5 text-right tabular-nums">{{ format(phase.value) }}</td>
</tr>
</tbody>
</table>
<dl v-if="hasAgentTiming" class="border-t border-current/20 pt-2">
<div class="flex justify-between gap-4">
<dt>{{ t("grid.timingBackend") }}</dt>
<dd class="tabular-nums">{{ format(result.execution_time_ms) }}</dd>
</div>
</dl>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { createApp, nextTick } from "vue";
import { createI18n } from "vue-i18n";
import { describe, expect, it } from "vitest";
import QueryTimingDetails from "../QueryTimingDetails.vue";
import zh from "@/i18n/locales/zh-CN";
describe("query timing disclosure", () => {
it("shows only wait, then ordered details on keyboard focus", async () => {
const host = document.createElement("div");
document.body.append(host);
const app = createApp(QueryTimingDetails, {
result: {
columns: [],
rows: [],
affected_rows: 0,
execution_time_ms: 55,
client_request_wait_ms: 7030,
server_execute_time_us: 1019,
query_timings_ms: { pool_acquire: 350, pool_release: 5, session_prepare: 2, schema: 3, statement_prepare: 1, jdbc_execute: 40, metadata: 2, fetch: 7, trace: 1, audit: 6000, agent_total: 6411 },
},
renderMs: 4,
});
app.use(createI18n({ legacy: false, locale: "zh-CN", messages: { "zh-CN": zh } }));
app.mount(host);
await nextTick();
expect(host.textContent).toBe("7030ms");
expect(host.textContent).not.toContain("数据库执行");
host.querySelector<HTMLButtonElement>("button")!.focus();
await new Promise((resolve) => setTimeout(resolve, 100));
await nextTick();
const text = document.body.textContent!;
expect(text).toContain("1.");
expect(text).not.toContain("读取审计记录");
expect(text).not.toContain("读取追踪 ID");
expect(text).not.toContain("数据库计划");
expect(text).toContain("后端报告耗时");
expect(text).not.toContain("驱动报告耗时");
expect(text).toContain("获取连接");
expect(text).toContain("归还连接");
const details = document.querySelector('[data-testid="query-timing-details"]')!;
const stages = [...details.querySelectorAll("tbody tr")].map((row) => row.textContent!);
// No Core lock sample means both the lock and dependent remainder are absent.
expect(stages).toHaveLength(12);
expect([...details.querySelectorAll("thead th")].map((cell) => cell.textContent)).toEqual(["序号", "阶段", "耗时"]);
expect(stages.findIndex((s) => s.includes("获取连接"))).toBeLessThan(stages.findIndex((s) => s.includes("会话")));
expect(stages.findIndex((s) => s.includes("归还连接"))).toBeGreaterThan(stages.findIndex((s) => s.includes("读取结果")));
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
app.unmount();
host.remove();
});
it("shows common request stages without inventing JDBC steps for a native or HTTP result", async () => {
const host = document.createElement("div");
document.body.append(host);
const app = createApp(QueryTimingDetails, { result: { columns: [], rows: [], affected_rows: 0, execution_time_ms: 4, client_request_wait_ms: 12, client_prepare_ms: 2, client_result_ms: 1 }, renderMs: 3 });
app.use(createI18n({ legacy: false, locale: "zh-CN", messages: { "zh-CN": zh } }));
app.mount(host);
await nextTick();
host.querySelector<HTMLButtonElement>("button")!.focus();
await new Promise((resolve) => setTimeout(resolve, 100));
await nextTick();
const details = document.querySelector('[data-testid="query-timing-details"]')!;
expect(details.querySelectorAll("tbody tr")).toHaveLength(4);
expect(details.textContent).toContain("请求等待");
expect(details.textContent).not.toContain("JDBC");
expect(details.textContent).not.toContain("获取连接");
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
app.unmount();
host.remove();
});
it("does not substitute execution time when wait or phases are missing", async () => {
const host = document.createElement("div");
document.body.append(host);
const app = createApp(QueryTimingDetails, { result: { columns: [], rows: [], affected_rows: 0, execution_time_ms: 55 } });
app.use(createI18n({ legacy: false, locale: "zh-CN", messages: { "zh-CN": zh } }));
app.mount(host);
await nextTick();
expect(host.textContent).toBe("—");
app.unmount();
host.remove();
});
});