feat(grid): show query elapsed time in adaptive units

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-24 19:33:19 +08:00
1 parent a6f841293b
commit 900f99797f
21 files changed
+195 -69

No files matched your search

@@ -17,6 +17,7 @@ import { hasHistoryDateRange, historyDateRangeIsValid, type HistoryDateRange } f
import { HISTORY_ROW_HEIGHT, HISTORY_SCROLL_BUFFER, shouldVirtualizeHistory } from "@/lib/history/historyVirtualList";
import { historyConnectionHasSelectedDatabase } from "@/lib/history/historySearch";
import { historyEntrySource } from "@/lib/history/historyEntrySource";
import { formatQueryDuration } from "@/lib/format/duration";
import type { HistoryConnectionFilter, HistoryDatabaseFilter, HistoryEntry, HistorySearchRequest } from "@/lib/backend/api";
import { copyToClipboard } from "@/lib/common/clipboard";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
@@ -356,7 +357,7 @@ function detailsRows(entry: HistoryEntry) {
[t("history.detail.database"), entry.database || "-"],
[t("history.detail.target"), entry.target || "-"],
[t("history.detail.time"), formatFullTime(entry.executed_at)],
[t("history.detail.duration"), `${entry.execution_time_ms}ms`],
[t("history.detail.duration"), formatQueryDuration(entry.execution_time_ms)],
[t("history.detail.affectedRows"), entry.affected_rows ?? "-"],
[t("history.detail.rollback"), canRollbackHistoryEntry(entry) ? t("history.rollbackAvailable") : t("history.rollbackUnavailable")],
[t("history.detail.status"), entry.success ? t("history.success") : t("history.failed")],
@@ -707,7 +708,7 @@ onBeforeUnmount(() => {
</span>
</span>
<span class="ml-auto shrink-0" :class="entry.success ? 'text-green-500' : 'text-red-500'">
{{ entry.success ? `${entry.execution_time_ms}ms` : t("history.failed") }}
{{ entry.success ? formatQueryDuration(entry.execution_time_ms) : t("history.failed") }}
</span>
</div>
</div>
@@ -337,6 +337,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { databaseSortSupportedForDatabase, simpleDataGridOrderByMatchesSort, simpleDataGridOrderByReferencesMissingColumn, type DataGridSortDirection, type DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import { resolveGridFocusRestoreTarget, shouldRestoreDataGridFocusAfterEditCommit } from "@/lib/dataGrid/dataGridFocusRestore";
import { buildOrderedGridRows, type GridInsertRowPosition, type GridNewRowPlacement } from "@/lib/dataGrid/gridNewRowPlacement";
import { formatQueryDuration } from "@/lib/format/duration";
import {
DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH,
DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER,
@@ -13989,7 +13990,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
<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>
<QueryTimingDetails v-if="isResultsContext" :result="result" :render-ms="resultViewUpdateMs" />
<span v-else class="shrink-0">{{ result.execution_time_ms }}ms</span>
<span v-else class="shrink-0">{{ formatQueryDuration(result.execution_time_ms) }}</span>
<template v-if="editable && hasDataGridSaveTarget">
<span v-if="hasPendingChanges" class="shrink-0 text-foreground">
@@ -2,13 +2,14 @@
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { formatQueryDuration } from "@/lib/format/duration";
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 wait = computed(() => (measured(props.result.client_request_wait_ms) ? formatQueryDuration(props.result.client_request_wait_ms) : "—"));
const hasAgentTiming = computed(() => measured(props.result.query_timings_ms?.agent_total));
const phases = computed(() => {
const m = props.result.query_timings_ms ?? {};
@@ -57,7 +58,7 @@ const phases = computed(() => {
</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 class="font-medium">{{ t("grid.clientRequestWait", { duration: measured(result.client_request_wait_ms) ? formatQueryDuration(result.client_request_wait_ms) : 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">
@@ -24,7 +24,7 @@ describe("query timing disclosure", () => {
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).toBe("7.0 s");
expect(host.textContent).not.toContain("数据库执行");
host.querySelector<HTMLButtonElement>("button")!.focus();
await new Promise((resolve) => setTimeout(resolve, 100));
@@ -16,6 +16,7 @@ import * as api from "@/lib/backend/api";
import type { CellValue } from "@/lib/dataGrid/cellValue";
import { encodeHBaseTextInput, hbaseCellInput } from "@/lib/hbase/hbaseValues";
import { loadHBaseRowLimit, normalizeHBaseRowLimit, saveHBaseRowLimit } from "@/lib/hbase/hbaseBrowserPreferences";
import { formatQueryDuration } from "@/lib/format/duration";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useQueryStore } from "@/stores/queryStore";
@@ -438,7 +439,7 @@ function errorMessage(value: unknown): string {
<div v-if="hasTable" class="flex h-7 shrink-0 items-center gap-2 border-b px-3 text-[11px] text-muted-foreground">
<span>{{ t("hbase.loadedRows", { count: rows.length }) }}</span>
<span>{{ elapsedMs }} ms</span>
<span>{{ formatQueryDuration(elapsedMs) }}</span>
<span v-if="truncated" class="text-amber-600 dark:text-amber-400">{{ t("hbase.resultTruncated") }}</span>
</div>
@@ -18,6 +18,7 @@ import JsonTree from "@/components/common/JsonTree.vue";
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
import * as api from "@/lib/backend/api";
import { compactLocalTimestamp, sanitizeExportBaseName, saveTextFile } from "@/lib/export/saveTextFile";
import { formatQueryDuration } from "@/lib/format/duration";
import { parseDocumentStoreJsonDocument, serializeDocumentStoreId, stringifyDocumentStoreValue } from "@/lib/app/documentJsonValues";
import { parseJsonPreservingLargeNumbers, safeJsonFormat, stringifyJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
import { useToast } from "@/composables/useToast";
@@ -120,7 +121,7 @@ const page = computed(() => Math.max(0, Math.floor(offset.value / Math.max(1, li
const totalPages = computed(() => Math.max(1, Math.ceil(totalHits.value / Math.max(1, limit.value))));
const canGoNext = computed(() => offset.value + limit.value < totalHits.value);
const resultSummary = computed(() => t("meilisearch.resultSummary", { count: totalHits.value, time: processingTimeMs.value }));
const resultSummary = computed(() => t("meilisearch.resultSummary", { count: totalHits.value, duration: formatQueryDuration(processingTimeMs.value) }));
function escapeHtml(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -2,6 +2,7 @@ import { reactive, computed } from "vue";
import * as api from "@/lib/backend/api";
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
import { uuid } from "@/lib/common/utils";
import { formatQueryDuration } from "@/lib/format/duration";
export type BackgroundTaskKind = "table-export" | "database-export" | "sql-file" | "data-transfer" | "multi-db-execution" | "schema-diff" | "data-compare";
export type BackgroundTaskStatus = "Running" | "Writing" | "Cancelling" | "Done" | "Error" | "Cancelled";
@@ -346,20 +347,10 @@ function finishExportTask(task: ExportTask) {
task.finishedAt ??= Date.now();
}
export function formatDataTransferDuration(elapsedMs: number): string {
const safeElapsedMs = Math.max(0, Number.isFinite(elapsedMs) ? Math.round(elapsedMs) : 0);
if (safeElapsedMs < 1000) return `${safeElapsedMs} ms`;
if (safeElapsedMs < 60_000) return `${(Math.floor(safeElapsedMs / 100) / 10).toFixed(1)} s`;
const totalSeconds = Math.floor(safeElapsedMs / 1000);
const seconds = totalSeconds % 60;
const totalMinutes = Math.floor(totalSeconds / 60);
if (totalMinutes < 60) return `${totalMinutes}m ${seconds}s`;
const hours = Math.floor(totalMinutes / 60);
return `${hours}h ${totalMinutes % 60}m ${seconds}s`;
}
// Implementation lives in @/lib/format/duration (shared with the DataGrid
// footer / history elapsed hints); the data-transfer name is kept for the
// export/multi-db call sites.
export { formatQueryDuration as formatDataTransferDuration };
function targetTableName(table: string, nameCase: api.TransferTableNameCase): string {
if (nameCase === "lower") return table.toLowerCase();
@@ -0,0 +1,50 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Issue #10117: the grid duration hints and the meilisearch result summary
// switched from fixed-unit placeholders ("{ms} ms", "{us} µs", "{time} 毫秒")
// to the pre-formatted "{duration}" placeholder produced by
// formatQueryDuration/formatDurationUs. Assertions run against locale SOURCE
// text: imported modules are deep-merged with English via withEnglishFallback,
// so a dropped key would be invisible post-merge.
function localeSource(name: string): string {
return readFileSync(new URL(`../locales/${name}.ts`, import.meta.url), "utf8");
}
function declaredValue(source: string, key: string): string | undefined {
return source.match(new RegExp(String.raw`^\s*${key}:\s*"((?:[^"\\]|\\.)*)",\s*$`, "m"))?.[1];
}
const GRID_DURATION_KEYS = ["serverExecuteTime", "agentExecuteTime", "clientRequestWait", "resultViewUpdate"] as const;
// Locales that carry the grid duration cluster; az/tr intentionally do not and
// fall back to en.
const GRID_DURATION_LOCALES = ["en", "zh-CN", "zh-TW", "ja", "ko", "es", "it", "pt-BR", "ru"] as const;
const RESULT_SUMMARY_LOCALES = [...GRID_DURATION_LOCALES, "az", "tr"];
describe("grid duration hint placeholder parity", () => {
it.each(GRID_DURATION_LOCALES.flatMap((name) => GRID_DURATION_KEYS.map((key) => ({ name, key }))))("$name: grid.$key uses {duration}", ({ name, key }) => {
const value = declaredValue(localeSource(name), key);
expect(value, `${name}: grid.${key} is missing`).toBeDefined();
expect(value, `${name}: grid.${key}`).toContain("{duration}");
expect(value, `${name}: grid.${key}`).not.toMatch(/\{(ms|us|time)\}/);
});
it("az/tr keep falling back to en for the grid duration cluster", () => {
for (const name of ["az", "tr"] as const) {
for (const key of GRID_DURATION_KEYS) {
expect(declaredValue(localeSource(name), key), `${name}: grid.${key} should stay undeclared`).toBeUndefined();
}
}
});
});
describe("meilisearch resultSummary placeholder parity", () => {
it.each(RESULT_SUMMARY_LOCALES)("%s: meilisearch.resultSummary uses {duration}", (name) => {
const value = declaredValue(localeSource(name), "resultSummary");
expect(value, `${name}: meilisearch.resultSummary is missing`).toBeDefined();
expect(value, `${name}: meilisearch.resultSummary`).toContain("{count}");
expect(value, `${name}: meilisearch.resultSummary`).toContain("{duration}");
expect(value, `${name}: meilisearch.resultSummary`).not.toMatch(/\{(ms|us|time)\}/);
});
});
+1 -1
View File
@@ -5668,7 +5668,7 @@ export default withEnglishFallback({
sortDesc: "Azalan",
limit: "Hədd",
offset: "Sürüşmə",
resultSummary: "Təxminən {count} nəticə · {time} ms",
resultSummary: "Təxminən {count} nəticə · {duration}",
viewJson: "JSON",
viewTable: "Cədvəl",
viewGrid: "Tor",
+5 -5
View File
@@ -2088,8 +2088,8 @@ export default {
calculateTotalRowsInline: "(count total rows)",
calculateTotalRowsFailed: "Count failed: {message}",
rowsAffected: "{count} rows affected",
agentExecuteTime: "Execution: {ms} ms",
serverExecuteTime: "Database execution: {us} µs",
agentExecuteTime: "Execution: {duration}",
serverExecuteTime: "Database execution: {duration}",
serverExecuteTimeUnavailable: "Database execution: unavailable",
serverExecuteTimeHint:
"OceanBase SQL Audit EXECUTE_TIME measures server plan execution. A JDBC cursor shows its completed statement sample; independent page queries show the sum only when every page is sampled. This overlaps with execution and wait timings. Unavailable while a cursor remains open or when audit data cannot be read or matched.",
@@ -2116,9 +2116,9 @@ export default {
timingPhase_result: "Process results (outside wait)",
timingBackend: "Backend-reported time (overlaps stages)",
timingPhase_render: "Update grid (outside wait)",
clientRequestWait: "Wait: {ms} ms",
clientRequestWait: "Wait: {duration}",
clientRequestWaitHint: "Desktop time from query request dispatch until the full result payload arrives. Includes backend processing, fetching, and transport; excludes request preparation and grid rendering. Summed across loaded pages.",
resultViewUpdate: "Grid update: {ms} ms",
resultViewUpdate: "Grid update: {duration}",
resultViewUpdateHint: "Time from the grid observing this result until its DOM update completes or a Canvas draw is submitted. Excludes GPU compositing and screen presentation.",
querySuccess: "Query executed successfully",
noRows: "No data",
@@ -6251,7 +6251,7 @@ export default {
sortDesc: "Descending",
limit: "Limit",
offset: "Offset",
resultSummary: "About {count} results · {time} ms",
resultSummary: "About {count} results · {duration}",
viewJson: "JSON",
viewTable: "Table",
viewGrid: "Grid",
+5 -5
View File
@@ -1986,8 +1986,8 @@ export default withEnglishFallback({
noNumericData: "No hay datos numéricos disponibles para graficar",
},
grid: {
agentExecuteTime: "Ejecución: {ms} ms",
serverExecuteTime: "Ejecución en BD: {us} µs",
agentExecuteTime: "Ejecución: {duration}",
serverExecuteTime: "Ejecución en BD: {duration}",
serverExecuteTimeUnavailable: "Ejecución en BD: no disponible",
serverExecuteTimeHint:
"EXECUTE_TIME de OceanBase SQL Audit mide la ejecución del plan en el servidor. Los cursores JDBC muestran la muestra al finalizar la sentencia original; la paginación con consultas independientes solo muestra la suma si todas las páginas tienen muestras. Se solapa con los tiempos de ejecución y espera. No está disponible si el cursor sigue abierto o si los datos de auditoría no se pueden leer o asociar.",
@@ -2014,10 +2014,10 @@ export default withEnglishFallback({
timingPhase_result: "Procesar resultados (fuera de la espera)",
timingBackend: "Tiempo informado por el backend (se solapa con las etapas)",
timingPhase_render: "Actualizar tabla (fuera de la espera)",
clientRequestWait: "Espera: {ms} ms",
clientRequestWait: "Espera: {duration}",
clientRequestWaitHint:
"Tiempo desde que la aplicación de escritorio envía la consulta hasta que recibe el resultado completo. Incluye la lectura de filas, la consulta de auditoría del servidor cuando está disponible y la transferencia; excluye la preparación del SQL y el renderizado de la tabla. Se suma entre las páginas cargadas.",
resultViewUpdate: "Actualización de tabla: {ms} ms",
resultViewUpdate: "Actualización de tabla: {duration}",
resultViewUpdateHint: "Tiempo desde que la tabla recibe este resultado hasta que termina la actualización del DOM o se envía el dibujo de Canvas. Excluye la composición de GPU y la presentación en pantalla.",
rows: "{count} filas",
totalRows: "Total {count} filas",
@@ -5793,7 +5793,7 @@ export default withEnglishFallback({
sortDesc: "Descendente",
limit: "Límite",
offset: "Desplazamiento",
resultSummary: "Aprox. {count} resultados · {time} ms",
resultSummary: "Aprox. {count} resultados · {duration}",
viewJson: "JSON",
viewTable: "Tabla",
viewGrid: "Cuadrícula",
+5 -5
View File
@@ -1920,8 +1920,8 @@ export default withEnglishFallback({
noNumericData: "Nessun dato numerico disponibile per il grafico",
},
grid: {
agentExecuteTime: "Esecuzione: {ms} ms",
serverExecuteTime: "Esecuzione DB: {us} µs",
agentExecuteTime: "Esecuzione: {duration}",
serverExecuteTime: "Esecuzione DB: {duration}",
serverExecuteTimeUnavailable: "Esecuzione DB: non disponibile",
serverExecuteTimeHint:
"EXECUTE_TIME di OceanBase SQL Audit misura l'esecuzione del piano sul server. I cursori JDBC mostrano il campione al termine dell'istruzione originale; la paginazione con query indipendenti mostra la somma solo se tutte le pagine hanno un campione. Si sovrappone ai tempi di esecuzione e attesa. Non è disponibile se il cursore è ancora aperto o se i dati di audit non possono essere letti o associati.",
@@ -1948,10 +1948,10 @@ export default withEnglishFallback({
timingPhase_result: "Elaborazione risultati (fuori dal tempo di attesa)",
timingBackend: "Tempo riportato dal backend (sovrapposto alle fasi)",
timingPhase_render: "Aggiornamento tabella (fuori dal tempo di attesa)",
clientRequestWait: "Attesa: {ms} ms",
clientRequestWait: "Attesa: {duration}",
clientRequestWaitHint:
"Tempo dall'invio della query dall'applicazione desktop alla ricezione del risultato completo. Include la lettura delle righe, la query di audit del server quando disponibile e il trasferimento; esclude la preparazione SQL e il rendering della tabella. Viene sommato tra le pagine caricate.",
resultViewUpdate: "Aggiornamento tabella: {ms} ms",
resultViewUpdate: "Aggiornamento tabella: {duration}",
resultViewUpdateHint: "Tempo dalla ricezione del risultato da parte della tabella al completamento dell'aggiornamento DOM o all'invio del disegno Canvas. Esclude la composizione GPU e la visualizzazione sullo schermo.",
rows: "{count} righe",
totalRows: "Totale {count} righe",
@@ -5715,7 +5715,7 @@ export default withEnglishFallback({
sortDesc: "Decrescente",
limit: "Limite",
offset: "Scostamento",
resultSummary: "Circa {count} risultati · {time} ms",
resultSummary: "Circa {count} risultati · {duration}",
viewJson: "JSON",
viewTable: "Tabella",
viewGrid: "Griglia",
+5 -5
View File
@@ -1942,8 +1942,8 @@ export default withEnglishFallback({
noNumericData: "グラフ化できる数値データがありません",
},
grid: {
agentExecuteTime: "実行時間:{ms} ms",
serverExecuteTime: "DB 実行:{us} µs",
agentExecuteTime: "実行時間:{duration}",
serverExecuteTime: "DB 実行:{duration}",
serverExecuteTimeUnavailable: "DB 実行:取得不可",
serverExecuteTimeHint:
"OceanBase SQL Audit の EXECUTE_TIME はサーバーでの実行計画の処理時間です。JDBC カーソルでは元の文の終了後に取得したサンプルを表示します。ページごとに独立した SQL を実行する場合は、すべてのページのサンプルがあるときだけ合計を表示します。実行時間や待機時間と範囲が重複します。カーソルが終了していない場合や、監査データの取得・関連付けができない場合は取得不可となります。",
@@ -1970,9 +1970,9 @@ export default withEnglishFallback({
timingPhase_result: "結果の処理(待機時間に含まない)",
timingBackend: "バックエンド報告時間(各段階と重複)",
timingPhase_render: "表の更新(待機時間に含まない)",
clientRequestWait: "待機時間:{ms} ms",
clientRequestWait: "待機時間:{duration}",
clientRequestWaitHint: "デスクトップ側がクエリ要求を送信してから結果全体を受信するまでの時間です。行の取得、利用可能な場合のサーバー監査クエリ、転送を含み、SQL の準備とテーブル描画は含みません。複数ページを読み込んだ場合は各ページの待機時間を合計します。",
resultViewUpdate: "テーブル更新:{ms} ms",
resultViewUpdate: "テーブル更新:{duration}",
resultViewUpdateHint: "テーブルが今回の結果を受け取ってから DOM 更新が完了するか Canvas の描画が送信されるまでの時間です。GPU 合成と画面への実際の表示は含みません。",
rows: "{count}行",
totalRows: "{count}件表示",
@@ -5743,7 +5743,7 @@ export default withEnglishFallback({
sortDesc: "降順",
limit: "件数",
offset: "オフセット",
resultSummary: "約 {count} 件 · {time} ms",
resultSummary: "約 {count} 件 · {duration}",
viewJson: "JSON",
viewTable: "テーブル",
viewGrid: "グリッド",
+5 -5
View File
@@ -1871,8 +1871,8 @@ export default withEnglishFallback({
noNumericData: "차트에 사용할 숫자 데이터가 없습니다",
},
grid: {
agentExecuteTime: "실행 시간: {ms} ms",
serverExecuteTime: "DB 실행: {us} µs",
agentExecuteTime: "실행 시간: {duration}",
serverExecuteTime: "DB 실행: {duration}",
serverExecuteTimeUnavailable: "DB 실행: 사용 불가",
serverExecuteTimeHint:
"OceanBase SQL Audit의 EXECUTE_TIME은 서버에서 실행 계획을 수행한 시간입니다. JDBC 커서는 원래 구문이 끝난 후의 샘플을 표시하며, 페이지별 독립 SQL은 모든 페이지에 샘플이 있을 때만 합계를 표시합니다. 실행 시간 및 대기 시간과 측정 범위가 겹칩니다. 커서가 끝나지 않았거나 감사 데이터를 읽거나 연결할 수 없으면 사용할 수 없습니다.",
@@ -1899,9 +1899,9 @@ export default withEnglishFallback({
timingPhase_result: "결과 처리 (대기 시간 제외)",
timingBackend: "백엔드 보고 시간 (단계별 시간과 중복)",
timingPhase_render: "표 업데이트 (대기 시간 제외)",
clientRequestWait: "대기 시간: {ms} ms",
clientRequestWait: "대기 시간: {duration}",
clientRequestWaitHint: "데스크톱에서 쿼리 요청을 보낸 시점부터 전체 결과를 받을 때까지의 시간입니다. 행 가져오기, 사용 가능한 경우의 서버 감사 조회 및 전송을 포함하며 SQL 준비와 테이블 렌더링은 제외합니다. 여러 페이지를 불러오면 각 페이지의 대기 시간을 합산합니다.",
resultViewUpdate: "테이블 갱신: {ms} ms",
resultViewUpdate: "테이블 갱신: {duration}",
resultViewUpdateHint: "테이블이 이번 결과를 받은 시점부터 DOM 갱신이 완료되거나 Canvas 그리기가 제출될 때까지의 시간입니다. GPU 합성과 실제 화면 표시는 포함하지 않습니다.",
rows: "{count}행",
totalRows: "전체 {count}행",
@@ -5362,7 +5362,7 @@ export default withEnglishFallback({
sortDesc: "내림차순",
limit: "제한",
offset: "오프셋",
resultSummary: "약 {count}개 결과 · {time}ms",
resultSummary: "약 {count}개 결과 · {duration}",
viewJson: "JSON",
viewTable: "표",
viewGrid: "그리드",
+5 -5
View File
@@ -1922,8 +1922,8 @@ export default withEnglishFallback({
noNumericData: "Nenhum dado numérico disponível para gerar o gráfico",
},
grid: {
agentExecuteTime: "Execução: {ms} ms",
serverExecuteTime: "Execução no BD: {us} µs",
agentExecuteTime: "Execução: {duration}",
serverExecuteTime: "Execução no BD: {duration}",
serverExecuteTimeUnavailable: "Execução no BD: indisponível",
serverExecuteTimeHint:
"EXECUTE_TIME do OceanBase SQL Audit mede a execução do plano no servidor. Os cursores JDBC mostram a amostra após o término da instrução original; a paginação com consultas independentes só mostra a soma quando todas as páginas têm amostras. Há sobreposição com os tempos de execução e espera. Indisponível enquanto o cursor não terminar ou quando os dados de auditoria não puderem ser lidos ou associados.",
@@ -1950,10 +1950,10 @@ export default withEnglishFallback({
timingPhase_result: "Processar resultados (fora da espera)",
timingBackend: "Tempo informado pelo backend (sobrepõe as etapas)",
timingPhase_render: "Atualizar tabela (fora da espera)",
clientRequestWait: "Espera: {ms} ms",
clientRequestWait: "Espera: {duration}",
clientRequestWaitHint:
"Tempo entre o envio da consulta pelo aplicativo desktop e o recebimento do resultado completo. Inclui a leitura de linhas, a consulta de auditoria do servidor quando disponível e a transferência; exclui a preparação do SQL e a renderização da tabela. Soma os tempos de espera das páginas carregadas.",
resultViewUpdate: "Atualização da tabela: {ms} ms",
resultViewUpdate: "Atualização da tabela: {duration}",
resultViewUpdateHint: "Tempo desde que a tabela recebe este resultado até a conclusão da atualização do DOM ou o envio do desenho no Canvas. Exclui a composição da GPU e a exibição na tela.",
rows: "{count} linhas",
totalRows: "Total de {count} linhas",
@@ -5717,7 +5717,7 @@ export default withEnglishFallback({
sortDesc: "Decrescente",
limit: "Limite",
offset: "Deslocamento",
resultSummary: "Cerca de {count} resultados · {time} ms",
resultSummary: "Cerca de {count} resultados · {duration}",
viewJson: "JSON",
viewTable: "Tabela",
viewGrid: "Grade",
+5 -5
View File
@@ -1970,8 +1970,8 @@ export default withEnglishFallback({
calculateTotalRowsInline: "(подсчитать общее число строк)",
calculateTotalRowsFailed: "Не удалось подсчитать: {message}",
rowsAffected: "Затронуто строк: {count}",
agentExecuteTime: "Выполнение: {ms} мс",
serverExecuteTime: "Выполнение в БД: {us} мкс",
agentExecuteTime: "Выполнение: {duration}",
serverExecuteTime: "Выполнение в БД: {duration}",
serverExecuteTimeUnavailable: "Выполнение в БД: недоступно",
serverExecuteTimeHint:
"OceanBase SQL Audit EXECUTE_TIME измеряет выполнение плана на сервере. Курсор JDBC показывает образец завершённого оператора; независимые запросы страниц показывают сумму только тогда, когда каждая страница попала в выборку. Эти данные пересекаются со временем выполнения и ожидания. Недоступно, пока курсор остаётся открытым или пока данные аудита нельзя прочитать или сопоставить.",
@@ -1998,9 +1998,9 @@ export default withEnglishFallback({
timingPhase_result: "Обработка результатов (вне ожидания)",
timingBackend: "Время от backend (перекрывается с этапами)",
timingPhase_render: "Обновление таблицы (вне ожидания)",
clientRequestWait: "Ожидание: {ms} мс",
clientRequestWait: "Ожидание: {duration}",
clientRequestWaitHint: "Время настольного приложения от отправки запроса до получения полного ответа. Включает выборку, необязательный поиск в аудите сервера и передачу данных; не включает подготовку SQL и отрисовку таблицы. Суммируется по загруженным страницам.",
resultViewUpdate: "Обновление таблицы: {ms} мс",
resultViewUpdate: "Обновление таблицы: {duration}",
resultViewUpdateHint: "Время от обнаружения результата таблицей до завершения обновления DOM или отправки отрисовки Canvas. Не включает композитинг GPU и вывод на экран.",
querySuccess: "Запрос выполнен успешно",
noRows: "Нет данных",
@@ -6789,7 +6789,7 @@ export default withEnglishFallback({
sortDesc: "По убыванию",
limit: "Лимит",
offset: "Смещение",
resultSummary: "Около {count} результатов · {time} мс",
resultSummary: "Около {count} результатов · {duration}",
viewJson: "JSON",
viewTable: "Таблица",
viewGrid: "Сетка",
+1 -1
View File
@@ -5562,7 +5562,7 @@ export default withEnglishFallback({
sortDesc: "Azalan",
limit: "Sınır",
offset: "Konum",
resultSummary: "Yaklaşık {count} sonuç · {time} ms",
resultSummary: "Yaklaşık {count} sonuç · {duration}",
viewJson: "JSON",
viewTable: "Tablo",
viewGrid: "Izgara",
+5 -5
View File
@@ -2001,8 +2001,8 @@ export default withEnglishFallback({
calculateTotalRowsInline: "(统计总行数)",
calculateTotalRowsFailed: "统计失败:{message}",
rowsAffected: "影响 {count} 行",
agentExecuteTime: "执行耗时:{ms} 毫秒",
serverExecuteTime: "数据库执行:{us} 微秒",
agentExecuteTime: "执行耗时:{duration}",
serverExecuteTime: "数据库执行:{duration}",
serverExecuteTimeUnavailable: "数据库执行:不可用",
serverExecuteTimeHint: "OceanBase SQL Audit EXECUTE_TIME 表示服务端执行计划的耗时。JDBC 游标显示原语句结束后的样本;独立分页 SQL 仅在各页均有样本时显示总和。与执行耗时和等待耗时范围重叠。游标未结束或审计数据无法读取、关联时不可用。",
agentExecuteTimeHint: "从设置 schema 到 JDBC execute 返回的 Agent 耗时;不含取行,并非数据库服务端纯执行时间。",
@@ -2028,9 +2028,9 @@ export default withEnglishFallback({
timingPhase_result: "结果整理(不计入等待)",
timingBackend: "后端报告耗时(与步骤重叠)",
timingPhase_render: "表格更新(不计入等待)",
clientRequestWait: "等待耗时:{ms} 毫秒",
clientRequestWait: "等待耗时:{duration}",
clientRequestWaitHint: "桌面端发出查询请求至收到完整结果的耗时,包含后端处理、取行和传输,不含请求准备与表格渲染;加载多页时累加各页等待时间。",
resultViewUpdate: "表格更新:{ms} 毫秒",
resultViewUpdate: "表格更新:{duration}",
resultViewUpdateHint: "从表格接收到本次结果,到 DOM 更新完成或 Canvas 绘制提交;不含 GPU 合成及屏幕实际呈现。",
querySuccess: "查询执行成功",
noRows: "暂无数据",
@@ -6224,7 +6224,7 @@ export default withEnglishFallback({
sortDesc: "降序",
limit: "条数",
offset: "偏移",
resultSummary: "约 {count} 条结果 · {time} 毫秒",
resultSummary: "约 {count} 条结果 · {duration}",
viewJson: "JSON",
viewTable: "表格",
viewGrid: "网格",
+5 -5
View File
@@ -1946,8 +1946,8 @@ export default withEnglishFallback({
calculateTotalRowsInline: "(統計總筆數)",
calculateTotalRowsFailed: "統計失敗:{message}",
rowsAffected: "影響 {count} 筆",
agentExecuteTime: "執行耗時:{ms} 毫秒",
serverExecuteTime: "資料庫執行:{us} 微秒",
agentExecuteTime: "執行耗時:{duration}",
serverExecuteTime: "資料庫執行:{duration}",
serverExecuteTimeUnavailable: "資料庫執行:無法取得",
serverExecuteTimeHint: "OceanBase SQL Audit EXECUTE_TIME 表示伺服端執行計畫的耗時。JDBC 游標顯示原語句結束後的樣本;獨立分頁 SQL 僅在各頁均有樣本時顯示總和。與執行耗時和等待耗時範圍重疊。游標尚未結束或稽核資料無法讀取、關聯時無法取得。",
agentExecuteTimeHint: "從設定 schema 到 JDBC execute 返回的 Agent 耗時;不含讀取資料列,並非資料庫伺服端純執行時間。",
@@ -1973,9 +1973,9 @@ export default withEnglishFallback({
timingPhase_result: "結果整理(不計入等待)",
timingBackend: "後端回報耗時(與步驟重疊)",
timingPhase_render: "表格更新(不計入等待)",
clientRequestWait: "等待耗時:{ms} 毫秒",
clientRequestWait: "等待耗時:{duration}",
clientRequestWaitHint: "桌面端送出查詢請求至收到完整結果的耗時,包含後端處理、讀取資料列與傳輸,不含請求準備與表格呈現;載入多頁時累加各頁等待時間。",
resultViewUpdate: "表格更新:{ms} 毫秒",
resultViewUpdate: "表格更新:{duration}",
resultViewUpdateHint: "從表格接收到本次結果,到 DOM 更新完成或 Canvas 繪製提交;不含 GPU 合成及螢幕實際呈現。",
querySuccess: "查詢執行成功",
noRows: "暫無資料",
@@ -5032,7 +5032,7 @@ export default withEnglishFallback({
sortDesc: "降序",
limit: "筆數",
offset: "位移",
resultSummary: "約 {count} 筆結果 · {time} 毫秒",
resultSummary: "約 {count} 筆結果 · {duration}",
viewJson: "JSON",
viewTable: "表格",
viewGrid: "網格",
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { formatDurationUs, formatQueryDuration } from "../duration";
describe("formatQueryDuration", () => {
it("clamps non-finite and negative inputs", () => {
expect(formatQueryDuration(Number.NaN)).toBe("0 ms");
expect(formatQueryDuration(-1)).toBe("0 ms");
});
it("keeps sub-second durations in integer milliseconds", () => {
expect(formatQueryDuration(0)).toBe("0 ms");
expect(formatQueryDuration(999)).toBe("999 ms");
});
it("shows one decimal second between 1s and 60s (floor-truncated)", () => {
expect(formatQueryDuration(1_000)).toBe("1.0 s");
expect(formatQueryDuration(1_234)).toBe("1.2 s");
expect(formatQueryDuration(59_999)).toBe("59.9 s");
});
it("shows minutes + seconds between 1min and 1h", () => {
expect(formatQueryDuration(60_000)).toBe("1m 0s");
expect(formatQueryDuration(65_000)).toBe("1m 5s");
expect(formatQueryDuration(3_599_999)).toBe("59m 59s");
});
it("shows hours + minutes + seconds from 1h on", () => {
expect(formatQueryDuration(3_600_000)).toBe("1h 0m 0s");
expect(formatQueryDuration(3_661_000)).toBe("1h 1m 1s");
});
});
describe("formatDurationUs", () => {
it("keeps sub-millisecond values in microseconds", () => {
expect(formatDurationUs(0)).toBe("0 µs");
expect(formatDurationUs(999)).toBe("999 µs");
});
it("converts to the ms tiers from 1ms on", () => {
expect(formatDurationUs(1_000)).toBe("1 ms");
expect(formatDurationUs(1_999)).toBe("2 ms");
expect(formatDurationUs(1_500_000)).toBe("1.5 s");
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* Adaptive, glance-friendly duration formatting for query/UI elapsed hints
* (issue #10117: raw millisecond readouts like "5230ms" scale poorly).
*
* Tiers (identical to the former `formatDataTransferDuration` in
* useExportTracker, now shared):
* < 1s -> "N ms" (integer milliseconds)
* < 60s -> "X.X s" (one decimal, floor-truncated)
* < 1h -> "Nm Ss"
* >= 1h -> "Hh MMm Ss"
*/
export function formatQueryDuration(elapsedMs: number): string {
const safeElapsedMs = Math.max(0, Number.isFinite(elapsedMs) ? Math.round(elapsedMs) : 0);
if (safeElapsedMs < 1000) return `${safeElapsedMs} ms`;
if (safeElapsedMs < 60_000) return `${(Math.floor(safeElapsedMs / 100) / 10).toFixed(1)} s`;
const totalSeconds = Math.floor(safeElapsedMs / 1000);
const seconds = totalSeconds % 60;
const totalMinutes = Math.floor(totalSeconds / 60);
if (totalMinutes < 60) return `${totalMinutes}m ${seconds}s`;
const hours = Math.floor(totalMinutes / 60);
return `${hours}h ${totalMinutes % 60}m ${seconds}s`;
}
/**
* Server-side execution times reported in microseconds (OceanBase SQL Audit
* EXECUTE_TIME): keep µs below one millisecond, then reuse the ms tiers.
*/
export function formatDurationUs(micros: number): string {
const safeMicros = Math.max(0, Number.isFinite(micros) ? Math.round(micros) : 0);
if (safeMicros < 1000) return `${safeMicros} µs`;
return formatQueryDuration(safeMicros / 1000);
}