Merge branch 'main' of https://github.com/Abeautifulsnow/dbx into feat/ai-agent-loop-phase1

This commit is contained in:
runstone 2026-06-12 12:00:47 +08:00
commit 453169ae45
25 changed files with 767 additions and 141 deletions

View File

@ -6,11 +6,13 @@ import { CanvasRenderer } from "echarts/renderers";
import { LineChart, BarChart, PieChart } from "echarts/charts";
import { GridComponent, TooltipComponent, LegendComponent } from "echarts/components";
import VChart from "vue-echarts";
import { BarChart3 } from "@lucide/vue";
import { BarChart3, ChevronDown } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { QueryResult } from "@/types/database";
import { useTheme } from "@/composables/useTheme";
import { axisColumnLabel, chartableColumnIndexes, toChartNumber } from "@/lib/chartData";
use([CanvasRenderer, LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent]);
@ -23,41 +25,58 @@ const { isDark } = useTheme();
type ChartType = "line" | "bar" | "pie";
const chartType = ref<ChartType>("bar");
const xColumn = ref("");
const yColumns = ref<string[]>([]);
const xColumnIndex = ref(0);
const yColumnIndexes = ref<number[]>([]);
const numericColumns = computed(() => props.result.columns.filter((_, idx) => props.result.rows.some((row) => typeof row[idx] === "number")));
const numericColumnIndexes = computed(() => chartableColumnIndexes(props.result));
const allColumns = computed(() => props.result.columns);
const allColumnOptions = computed(() => props.result.columns.map((_, index) => ({ index, label: axisColumnLabel(props.result.columns, index) })));
const numericColumnOptions = computed(() => numericColumnIndexes.value.map((index) => ({ index, label: axisColumnLabel(props.result.columns, index) })));
const xColumnValue = computed({
get: () => String(xColumnIndex.value),
set: (value: string) => {
const index = Number(value);
if (Number.isInteger(index) && index >= 0 && index < props.result.columns.length) {
xColumnIndex.value = index;
}
},
});
const yColumnLabel = computed(() => {
if (yColumnIndexes.value.length === 0) return "0";
const [first, ...rest] = yColumnIndexes.value;
const label = axisColumnLabel(props.result.columns, first);
return rest.length > 0 ? `${label} +${rest.length}` : label;
});
watch(
() => props.result,
() => {
const cols = props.result.columns;
const numCols = numericColumns.value;
xColumn.value = cols.find((c) => !numCols.includes(c)) || cols[0] || "";
yColumns.value = numCols.length > 0 ? [numCols[0]] : [];
const numCols = numericColumnIndexes.value;
xColumnIndex.value = cols.findIndex((_, index) => !numCols.includes(index));
if (xColumnIndex.value < 0) xColumnIndex.value = cols.length > 0 ? 0 : -1;
yColumnIndexes.value = numCols.length > 0 ? [numCols[0]] : [];
},
{ immediate: true },
);
function toggleYColumn(col: string) {
const idx = yColumns.value.indexOf(col);
function toggleYColumn(index: number) {
const idx = yColumnIndexes.value.indexOf(index);
if (idx >= 0) {
yColumns.value = yColumns.value.filter((c) => c !== col);
yColumnIndexes.value = yColumnIndexes.value.filter((selected) => selected !== index);
} else {
yColumns.value = [...yColumns.value, col];
yColumnIndexes.value = [...yColumnIndexes.value, index];
}
}
const chartOption = computed(() => {
const xIdx = props.result.columns.indexOf(xColumn.value);
if (xIdx < 0 || yColumns.value.length === 0) return null;
const xIdx = xColumnIndex.value;
if (xIdx < 0 || yColumnIndexes.value.length === 0) return null;
const xData = props.result.rows.map((row) => String(row[xIdx] ?? ""));
if (chartType.value === "pie") {
const yIdx = props.result.columns.indexOf(yColumns.value[0]);
const yIdx = yColumnIndexes.value[0];
if (yIdx < 0) return null;
return {
tooltip: { trigger: "item" },
@ -68,14 +87,14 @@ const chartOption = computed(() => {
radius: ["30%", "60%"],
data: xData.map((name, i) => ({
name,
value: Number(props.result.rows[i][yIdx]) || 0,
value: toChartNumber(props.result.rows[i][yIdx]) ?? 0,
})),
},
],
};
}
const yIndices = yColumns.value.map((c) => props.result.columns.indexOf(c)).filter((i) => i >= 0);
const yIndices = yColumnIndexes.value.filter((index) => index >= 0 && index < props.result.columns.length);
return {
tooltip: { trigger: "axis" },
@ -94,15 +113,15 @@ const chartOption = computed(() => {
axisLabel: { color: isDark.value ? "#aaa" : "#666" },
},
series: yIndices.map((yIdx) => ({
name: props.result.columns[yIdx],
name: axisColumnLabel(props.result.columns, yIdx),
type: chartType.value,
data: props.result.rows.map((row) => Number(row[yIdx]) || 0),
data: props.result.rows.map((row) => toChartNumber(row[yIdx]) ?? 0),
smooth: chartType.value === "line",
})),
};
});
const hasData = computed(() => props.result.rows.length > 0 && numericColumns.value.length > 0);
const hasData = computed(() => props.result.rows.length > 0 && numericColumnIndexes.value.length > 0);
</script>
<template>
@ -124,23 +143,31 @@ const hasData = computed(() => props.result.rows.length > 0 && numericColumns.va
<span class="h-4 w-px bg-border" />
<div class="flex items-center gap-1.5">
<span class="text-muted-foreground">X</span>
<Select :model-value="xColumn" @update:model-value="(v: any) => (xColumn = v)">
<Select v-model="xColumnValue">
<SelectTrigger class="h-6 w-auto max-w-40 border-0 bg-transparent px-1 text-xs shadow-none focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="col in allColumns" :key="col" :value="col">{{ col }}</SelectItem>
<SelectItem v-for="col in allColumnOptions" :key="col.index" :value="String(col.index)">{{ col.label }}</SelectItem>
</SelectContent>
</Select>
</div>
<span class="h-4 w-px bg-border" />
<div class="flex items-center gap-1.5">
<span class="text-muted-foreground">Y</span>
<div class="flex gap-0.5">
<Button v-for="col in numericColumns" :key="col" size="sm" :variant="yColumns.includes(col) ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs" @click="toggleYColumn(col)">
{{ col }}
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 max-w-48 gap-1 px-2 text-xs">
<span class="truncate">{{ yColumnLabel }}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-56" align="start" @close-auto-focus.prevent>
<DropdownMenuCheckboxItem v-for="col in numericColumnOptions" :key="col.index" :checked="yColumnIndexes.includes(col.index)" class="text-xs" @select.prevent @click="toggleYColumn(col.index)">
<span class="truncate">{{ col.label }}</span>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div class="flex-1 min-h-0 p-2">

View File

@ -365,6 +365,8 @@ const customSaveHandler = computed<CustomSaveHandler>(() => ({
async function load() {
loading.value = true;
error.value = "";
const previousSelectedIdx = selectedIdx.value;
const previousSelectedId = previousSelectedIdx === null ? null : documentIdentity(documents.value[previousSelectedIdx]);
try {
const filter = currentDocumentFilter();
const sort = sortInput.value.trim() || undefined;
@ -382,6 +384,7 @@ async function load() {
lastGridColumns.value = [...keySet];
}
total.value = result.total;
syncSelectedDocumentAfterLoad(previousSelectedIdx, previousSelectedId);
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e);
} finally {
@ -414,6 +417,33 @@ function asRecord(value: unknown): JsonRecord {
return {};
}
function documentIdentity(doc: JsonRecord | undefined): string | null {
const id = doc?._id;
if (id === null || id === undefined) return null;
return typeof id === "object" ? JSON.stringify(id) : String(id);
}
function syncSelectedDocumentAfterLoad(previousSelectedIdx: number | null, previousSelectedId: string | null) {
if (isNew.value || previousSelectedIdx === null) return;
if (!documents.value.length) {
selectedIdx.value = null;
if (!isEditing.value) editJson.value = "";
return;
}
const nextIdx = previousSelectedId ? documents.value.findIndex((doc) => documentIdentity(doc) === previousSelectedId) : previousSelectedIdx < documents.value.length ? previousSelectedIdx : -1;
if (nextIdx < 0) {
selectedIdx.value = null;
if (!isEditing.value) editJson.value = "";
return;
}
selectedIdx.value = nextIdx;
if (!isEditing.value) {
editJson.value = JSON.stringify(documents.value[nextIdx], null, 2);
}
}
function selectDoc(idx: number) {
selectedIdx.value = idx;
editJson.value = JSON.stringify(documents.value[idx], null, 2);

View File

@ -3414,6 +3414,16 @@ const canvasDevicePixelRatio = ref(typeof window === "undefined" ? 1 : window.de
const canvasBackingPixelRatio = computed(() => Math.min(4, Math.max(1, canvasDevicePixelRatio.value * settingsStore.editorSettings.uiScale)));
const useCanvasGridRows = computed(() => dataGridRenderMode.value === "canvas");
const canvasContentHeight = computed(() => Math.max(1, displayRowCount.value * CANVAS_DATA_GRID_ROW_HEIGHT));
// Clamp the sticky canvas/overlay to the content width. A viewport-wide sticky surface inflates the
// scroller's scrollWidth up to clientWidth, so with few columns it sits right on the overflow threshold
// and the custom horizontal scrollbar flickers while the pane shrinks (canvas width lags clientWidth).
const canvasSurfaceWidth = computed(() => {
const total = totalWidth.value;
const vw = canvasViewportWidth.value;
if (total <= 0) return Math.max(0, vw);
if (vw <= 0) return total;
return Math.min(vw, total);
});
const canvasRenderStyleKey = computed(() => `${settingsStore.editorSettings.theme}:${settingsStore.editorSettings.uiScale}:${canvasBackingPixelRatio.value}:${isDark.value}`);
const CANVAS_MOUSE_WHEEL_SCROLL_MULTIPLIER = 1.5;
const CANVAS_TRACKPAD_DELTA_THRESHOLD = 40;
@ -3774,10 +3784,9 @@ function canvasEffectiveViewportHeight(): number {
}
const canvasOverlayStyle = computed(() => {
const vw = canvasEffectiveViewportWidth();
const vh = canvasEffectiveViewportHeight();
return {
width: `${vw}px`,
width: `${canvasSurfaceWidth.value}px`,
height: `${vh}px`,
marginTop: `-${vh}px`,
};
@ -3833,7 +3842,7 @@ function drawCanvasGrid() {
drawCanvasDataGrid({
canvas,
scroller,
width: Math.max(1, canvasViewportWidth.value || scroller.clientWidth),
width: Math.max(1, canvasSurfaceWidth.value || scroller.clientWidth),
height: Math.max(1, canvasViewportHeight.value || scroller.clientHeight),
pixelRatio: canvasBackingPixelRatio.value,
isDark: isDark.value,
@ -6716,7 +6725,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<canvas
ref="canvasRef"
class="canvas-grid-surface sticky left-0 top-0 z-0 block text-xs font-sans font-normal"
:style="{ width: `${canvasViewportWidth}px`, height: `${canvasViewportHeight}px` }"
:style="{ width: `${canvasSurfaceWidth}px`, height: `${canvasViewportHeight}px` }"
@mousemove="onCanvasMouseMove"
@mouseleave="onCanvasMouseLeave"
@mousedown="onCanvasMouseDown"

View File

@ -46,6 +46,7 @@ import { isTableDataEditable } from "@/lib/tableEditing";
import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
import { formatShortcut } from "@/lib/shortcutRegistry";
import { effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { chartableColumnIndexes } from "@/lib/chartData";
import { useTabScroll } from "@/composables/useTabScroll";
import type { QueryTab, ConnectionConfig } from "@/types/database";
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
@ -203,7 +204,7 @@ const resultTabsScrollbarThumbStyle = computed<CSSProperties>(() => ({
const hasNumericData = computed(() => {
const r = props.activeTab.result;
if (!r || r.rows.length === 0) return false;
return r.columns.some((_, idx) => r.rows.some((row) => typeof row[idx] === "number"));
return chartableColumnIndexes(r).length > 0;
});
const activeQueryError = computed(() => {
@ -424,8 +425,8 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
<div class="flex flex-col flex-1 min-h-0">
<!-- Query mode: editor + results -->
<template v-if="activeTab.mode === 'query'">
<Splitpanes horizontal class="flex-1">
<Pane :size="resultsPaneOpen ? 40 : 100" :min-size="resultsPaneOpen ? 15 : 100">
<Splitpanes horizontal class="query-output-splitpanes flex-1 min-h-0 overflow-hidden">
<Pane class="min-h-0" :size="resultsPaneOpen ? 40 : 100" :min-size="resultsPaneOpen ? 15 : 100">
<div class="h-full flex flex-col relative">
<QueryEditor
ref="queryEditorRef"
@ -460,7 +461,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
</Button>
</div>
</Pane>
<Pane v-if="resultsPaneOpen" :size="60" :min-size="20">
<Pane v-if="resultsPaneOpen" class="min-h-0" :size="60" :min-size="20">
<div class="h-full flex flex-col">
<div v-if="hasQueryOutput" class="flex h-10 shrink-0 items-center gap-1 border-b bg-muted/20 px-2">
<div class="flex shrink-0 items-center gap-1">
@ -860,6 +861,15 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
</template>
<style scoped>
.query-output-splitpanes {
isolation: isolate;
}
.query-output-splitpanes :deep(> .splitpanes__splitter) {
z-index: 1;
flex: 0 0 3px;
}
.result-tab-scroll::-webkit-scrollbar {
display: none;
}

View File

@ -35,7 +35,7 @@ const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const editorFontFamilyStyle = useEditorFontFamilyStyle();
type RedisSearchMode = "key" | "value";
type RedisSearchMode = "key" | "value" | "all";
type RedisCreateKeyType = "string" | "hash" | "list" | "set" | "zset" | "stream" | "json";
interface CreateKeyEntry {
@ -100,10 +100,14 @@ let redisBrowserIsActive = true;
let redisDbFlushedListenerRegistered = false;
const valueQuery = computed(() => searchPattern.value.trim());
const isValueSearchMode = computed(() => searchMode.value === "value" || searchMode.value === "all");
const effectivePattern = computed(() => (searchMode.value === "key" ? redisKeySearchPattern(searchPattern.value, fuzzyKeySearch.value) : "*"));
const isSearchMode = computed(() => (searchMode.value === "key" ? effectivePattern.value !== "*" : valueQuery.value !== ""));
const searchPlaceholder = computed(() => (searchMode.value === "key" ? (fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern")) : t("redis.valueSearchPlaceholder")));
const loadingEmptyText = computed(() => (searchMode.value === "value" && valueQuery.value ? t("redis.searchingValues") : t("redis.loadingKeys")));
const searchPlaceholder = computed(() => {
if (searchMode.value === "key") return fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern");
return searchMode.value === "all" ? t("redis.allSearchPlaceholder") : t("redis.valueSearchPlaceholder");
});
const loadingEmptyText = computed(() => (isValueSearchMode.value && valueQuery.value ? t(searchMode.value === "all" ? "redis.searchingAll" : "redis.searchingValues") : t("redis.loadingKeys")));
const redisKeySeparator = computed(() => connectionStore.getConfig(props.connectionId)?.redis_key_separator ?? ":");
watch(redisKeySeparator, () => {
if (flatKeys.value.length > 0) rebuildTree(false);
@ -192,7 +196,7 @@ function mergeTree(newKeys: RedisKeyInfo[]) {
async function fetchScanPage(): Promise<RedisScanResult> {
const pageSize = settingsStore.editorSettings.redisScanPageSize;
return searchMode.value === "value" ? await api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize) : await api.redisScanKeys(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize);
return isValueSearchMode.value ? await api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize, searchMode.value === "all") : await api.redisScanKeys(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize);
}
function appendScanResult(result: RedisScanResult) {
@ -223,7 +227,7 @@ async function scanNextPage(requestId = searchRequestId): Promise<boolean> {
}
async function streamValueSearch(requestId: number) {
while (requestId === searchRequestId && searchMode.value === "value" && valueQuery.value && hasMore.value) {
while (requestId === searchRequestId && isValueSearchMode.value && valueQuery.value && hasMore.value) {
const applied = await scanNextPage(requestId);
if (!applied) return;
}
@ -255,13 +259,13 @@ async function loadKeys() {
expandedGroupIds.value = new Set();
scanCursor.value = 0;
try {
if (searchMode.value === "value" && !valueQuery.value) {
if (isValueSearchMode.value && !valueQuery.value) {
hasMore.value = false;
return;
}
const applied = await scanNextPage(requestId);
if (applied) {
if (searchMode.value === "value") {
if (isValueSearchMode.value) {
await streamValueSearch(requestId);
} else {
await fillInitialKeyBatch(requestId);
@ -849,6 +853,9 @@ defineExpose({ focusSearch });
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'value' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('value')">
{{ t("redis.searchByValue") }}
</button>
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'all' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('all')">
{{ t("redis.searchByAll") }}
</button>
</div>
<Input v-model="searchPattern" data-redis-search-input class="h-6 text-xs border-0 shadow-none focus-visible:ring-0" :placeholder="searchPlaceholder" @input="onSearchInput" @keydown="onSearchKeydown" />
<Button v-if="searchMode === 'key'" variant="ghost" size="sm" class="h-6 shrink-0 px-2 text-xs" :class="fuzzyKeySearch ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'" :title="t('redis.fuzzyMatchTitle')" :aria-pressed="fuzzyKeySearch" @click="toggleFuzzyKeySearch">

View File

@ -9,7 +9,7 @@ import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
import * as api from "@/lib/api";
import type { QueryTab } from "@/types/database";
import { useToast } from "@/composables/useToast";
import { effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { connectionObjectTreeQuerySchema, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>) {
const { t } = useI18n();
@ -46,6 +46,24 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
});
}
async function refreshDataTabTableMeta(tab: QueryTab): Promise<void> {
if (tab.mode !== "data" || !tab.connectionId || !tab.database) return;
const tableMeta = tableMetaForDataTab(tab);
if (!tableMeta?.tableName) return;
await connectionStore.ensureConnected(tab.connectionId);
const config = connectionStore.getConfig(tab.connectionId);
const querySchema = connectionObjectTreeQuerySchema(config, tab.database, tableMeta.schema);
const columns = await api.getColumns(tab.connectionId, tab.database, querySchema, tableMeta.tableName);
const primaryKeys = editablePrimaryKeys(effectiveDatabaseTypeForConnection(config), columns);
queryStore.setTableMeta(tab.id, {
schema: tableMeta.schema,
tableName: tableMeta.tableName,
columns,
primaryKeys,
});
}
async function onExecuteSql(sql: string) {
const tab = activeTab.value;
if (!tab) return;
@ -60,6 +78,11 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
tab.whereInput = whereInput ?? "";
const pageLimit = limit ?? settingsStore.editorSettings.pageSize;
const pageOffset = offset ?? 0;
try {
await refreshDataTabTableMeta(tab);
} catch (e: any) {
toast(e?.message || String(e), 5000);
}
const nextSql = await buildTableSql(tab, { whereInput, orderBy, limit: pageLimit, offset: pageOffset });
queryStore.updateSql(tab.id, nextSql);
await queryStore.executeTabSql(tab.id, nextSql, {

View File

@ -1391,11 +1391,14 @@
fuzzyMatch: "Fuzzy",
fuzzyMatchTitle: "Fuzzy match: search keys by plain text contains",
valueSearchPlaceholder: "value contains...",
allSearchPlaceholder: "key or value contains...",
searchByKey: "Key",
searchByValue: "Value",
searchByAll: "All",
keys: "{count} keys",
loadingKeys: "Loading keys...",
searchingValues: "Searching values...",
searchingAll: "Searching keys and values...",
loadMoreKeys: "Load more keys",
fetchAllKeys: "Fetch all",
stopFetchAll: "Stop",

View File

@ -1171,11 +1171,14 @@
fuzzyMatch: "Difusa",
fuzzyMatchTitle: "Coincidencia difusa: busca claves que contengan texto plano",
valueSearchPlaceholder: "el valor contiene...",
allSearchPlaceholder: "la clave o el valor contiene...",
searchByKey: "Clave",
searchByValue: "Valor",
searchByAll: "Todo",
keys: "{count} claves",
loadingKeys: "Cargando claves...",
searchingValues: "Buscando por valor...",
searchingAll: "Buscando claves y valores...",
loadMoreKeys: "Cargar más claves",
fetchAllKeys: "Cargar todas",
stopFetchAll: "Detener",

View File

@ -1281,11 +1281,14 @@
fuzzyMatch: "Fuzzy",
fuzzyMatchTitle: "Corrispondenza fuzzy: cerca le chiavi per testo semplice contenuto",
valueSearchPlaceholder: "il valore contiene...",
allSearchPlaceholder: "la chiave o il valore contiene...",
searchByKey: "Chiave",
searchByValue: "Valore",
searchByAll: "Tutto",
keys: "{count} chiavi",
loadingKeys: "Caricamento chiavi...",
searchingValues: "Ricerca nei valori...",
searchingAll: "Ricerca in chiavi e valori...",
loadMoreKeys: "Carica altre chiavi",
fetchAllKeys: "Recupera tutto",
stopFetchAll: "Interrompi",

View File

@ -1281,11 +1281,14 @@
fuzzyMatch: "Aproximado",
fuzzyMatchTitle: "Correspondência aproximada: pesquisa chaves por texto contido",
valueSearchPlaceholder: "o valor contém...",
allSearchPlaceholder: "a chave ou o valor contém...",
searchByKey: "Chave",
searchByValue: "Valor",
searchByAll: "Tudo",
keys: "{count} chaves",
loadingKeys: "Carregando chaves...",
searchingValues: "Pesquisando valores...",
searchingAll: "Pesquisando chaves e valores...",
loadMoreKeys: "Carregar mais chaves",
fetchAllKeys: "Buscar todas",
stopFetchAll: "Parar",

View File

@ -1390,11 +1390,14 @@
fuzzyMatch: "模糊",
fuzzyMatchTitle: "模糊匹配:自动按包含关系搜索 key",
valueSearchPlaceholder: "按值内容搜索...",
allSearchPlaceholder: "按 key 或值搜索...",
searchByKey: "键",
searchByValue: "值",
searchByAll: "全部",
keys: "{count} 个 key",
loadingKeys: "正在加载 key...",
searchingValues: "正在按值搜索...",
searchingAll: "正在搜索 key 和值...",
loadMoreKeys: "加载更多",
fetchAllKeys: "获取全部",
stopFetchAll: "停止",

View File

@ -1260,11 +1260,14 @@
fuzzyMatch: "模糊",
fuzzyMatchTitle: "模糊匹配:自動按包含關係搜尋 key",
valueSearchPlaceholder: "值包含……",
allSearchPlaceholder: "鍵或值包含……",
searchByKey: "鍵",
searchByValue: "值",
searchByAll: "全部",
keys: "{count} 個 key",
loadingKeys: "正在載入 key……",
searchingValues: "正在按值搜尋……",
searchingAll: "正在搜尋鍵和值……",
loadMoreKeys: "載入更多",
fetchAllKeys: "取得全部",
stopFetchAll: "停止",

View File

@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { axisColumnLabel, chartableColumnIndexes, toChartNumber } from "@/lib/chartData";
import type { QueryResult } from "@/types/database";
function result(columns: string[], rows: QueryResult["rows"]): QueryResult {
return {
columns,
rows,
affected_rows: rows.length,
execution_time_ms: 1,
};
}
describe("chartData", () => {
it("accepts finite numbers and numeric strings", () => {
expect(toChartNumber(42)).toBe(42);
expect(toChartNumber("42.5")).toBe(42.5);
expect(toChartNumber(" 1e3 ")).toBe(1000);
});
it("rejects non-finite and non-numeric values", () => {
expect(toChartNumber("")).toBeNull();
expect(toChartNumber("abc")).toBeNull();
expect(toChartNumber(null)).toBeNull();
expect(toChartNumber(true)).toBeNull();
});
it("finds numeric columns returned as strings", () => {
expect(
chartableColumnIndexes(
result(
["name", "decimal_total", "status"],
[
["a", "12.34", "ok"],
["b", "56.78", "ok"],
],
),
),
).toEqual([1]);
});
it("disambiguates duplicate axis labels by index", () => {
expect(axisColumnLabel(["amount", "amount"], 0)).toBe("amount #1");
expect(axisColumnLabel(["amount", "amount"], 1)).toBe("amount #2");
});
});

View File

@ -0,0 +1,26 @@
import type { QueryResult } from "@/types/database";
export function toChartNumber(value: QueryResult["rows"][number][number]): number | null {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (trimmed === "") return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
export function isChartableValue(value: QueryResult["rows"][number][number]): boolean {
return toChartNumber(value) !== null;
}
export function chartableColumnIndexes(result: QueryResult): number[] {
return result.columns.map((_, index) => index).filter((index) => result.rows.some((row) => isChartableValue(row[index])));
}
export function axisColumnLabel(columns: string[], index: number): string {
const name = columns[index] ?? `#${index + 1}`;
if (columns.filter((column) => column === name).length <= 1) return name;
return `${name} #${index + 1}`;
}

View File

@ -1,4 +1,4 @@
import type { ConnectionConfig } from "@/types/database";
import type { ConnectionConfig, DatabaseType } from "@/types/database";
export const CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS = 2_000;
export const MONGO_LEGACY_FALLBACK_TIMEOUT_BUFFER_MS = 30_000;

View File

@ -1200,8 +1200,8 @@ export async function redisScanKeys(connectionId: string, db: number, cursor: nu
return post("/api/redis/scan-keys", { connectionId, db, cursor, pattern, count });
}
export async function redisScanValues(connectionId: string, db: number, cursor: number, pattern: string, query: string, count: number): Promise<RedisScanResult> {
return post("/api/redis/scan-values", { connectionId, db, cursor, pattern, query, count });
export async function redisScanValues(connectionId: string, db: number, cursor: number, pattern: string, query: string, count: number, includeKeyMatches = false): Promise<RedisScanResult> {
return post("/api/redis/scan-values", { connectionId, db, cursor, pattern, query, includeKeyMatches, count });
}
export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise<RedisValue> {

View File

@ -1032,8 +1032,8 @@ export async function redisScanKeys(connectionId: string, db: number, cursor: nu
return invoke("redis_scan_keys", { connectionId, db, cursor, pattern, count });
}
export async function redisScanValues(connectionId: string, db: number, cursor: number, pattern: string, query: string, count: number): Promise<RedisScanResult> {
return invoke("redis_scan_values", { connectionId, db, cursor, pattern, query, count });
export async function redisScanValues(connectionId: string, db: number, cursor: number, pattern: string, query: string, count: number, includeKeyMatches = false): Promise<RedisScanResult> {
return invoke("redis_scan_values", { connectionId, db, cursor, pattern, query, includeKeyMatches, count });
}
export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise<RedisValue> {

View File

@ -420,6 +420,7 @@ function normalizeCustomColumnFormatters(value: unknown): Record<string, CustomC
function normalizeSqlSnippets(value: unknown, existing?: SqlSnippet[]): SqlSnippet[] {
if (!Array.isArray(value)) return existing ?? DEFAULT_SQL_SNIPPETS;
if (value.length === 0) return [];
const valid: SqlSnippet[] = [];
const seenPrefixes = new Set<string>();
for (const item of value) {

View File

@ -15,8 +15,10 @@ pub fn agent_connect_params(config: &ConnectionConfig, host: &str, port: u16, da
};
let connection_string = if config.db_type == DatabaseType::MongoDb {
config.connection_url_with_host(host, port)
} else if matches!(config.db_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle) {
} else if config.db_type == DatabaseType::Oracle {
oracle_jdbc_connection_string(config, host, port, database)
} else if config.db_type == DatabaseType::OceanbaseOracle {
oceanbase_oracle_jdbc_connection_string(config, host, port, database)
} else if matches!(config.db_type, DatabaseType::Kingbase | DatabaseType::Highgo | DatabaseType::Vastbase) {
postgres_like_agent_jdbc_connection_string(config, host, port, database)
} else if config.db_type == DatabaseType::SapHana {
@ -205,6 +207,64 @@ pub fn oracle_error_with_driver_hint(config: &ConnectionConfig, err: &str) -> St
)
}
pub fn oracle_alternate_connect_configs(config: &ConnectionConfig, err: &str) -> Vec<ConnectionConfig> {
if config.db_type != DatabaseType::Oracle {
return Vec::new();
}
if config.driver_profile.as_deref() == Some("oracle-10g") {
return Vec::new();
}
if config.connection_string.as_deref().is_some_and(|value| !value.trim().is_empty()) {
return Vec::new();
}
if !oracle_listener_error_can_retry(err) {
return Vec::new();
}
let database = config.effective_database().unwrap_or("").trim();
if database.is_empty() {
return Vec::new();
}
let host = config.host.trim();
let port = config.port;
let current_url = oracle_jdbc_connection_string(config, host, port, database);
let service_url = oracle_service_jdbc_url(host, port, database);
let sid_url = oracle_sid_jdbc_url(host, port, database);
let legacy_service_url = oracle_legacy_service_jdbc_url(host, port, database);
let descriptor_service_url = oracle_descriptor_jdbc_url(host, port, database, "SERVICE_NAME");
let descriptor_sid_url = oracle_descriptor_jdbc_url(host, port, database, "SID");
let normalized = err.to_lowercase();
let candidates = if normalized.contains("ora-12505") {
vec![service_url, descriptor_service_url, legacy_service_url]
} else if normalized.contains("ora-12514") {
vec![sid_url, descriptor_sid_url, legacy_service_url]
} else {
match config.oracle_connection_type.as_deref() {
Some("sid") => vec![service_url, legacy_service_url, descriptor_sid_url, descriptor_service_url],
_ => vec![sid_url, legacy_service_url, descriptor_service_url, descriptor_sid_url],
}
};
let mut urls = Vec::new();
for url in candidates {
if url == current_url || urls.iter().any(|seen| seen == &url) {
continue;
}
urls.push(url);
}
urls.into_iter()
.map(|url| {
let mut retry = config.clone();
retry.oracle_connection_type = None;
retry.connection_string = Some(url);
retry
})
.collect()
}
fn oracle_jdbc_connection_string(config: &ConnectionConfig, host: &str, port: u16, database: &str) -> String {
if let Some(connection_string) = config.connection_string.as_deref().filter(|value| !value.trim().is_empty()) {
let connection_string = connection_string.trim();
@ -220,12 +280,54 @@ fn oracle_jdbc_connection_string(config: &ConnectionConfig, host: &str, port: u1
}
if config.oracle_connection_type.as_deref() == Some("sid") {
format!("jdbc:oracle:thin:@{host}:{port}:{database}")
oracle_sid_jdbc_url(host, port, database)
} else {
format!("jdbc:oracle:thin:@//{host}:{port}/{database}")
oracle_service_jdbc_url(host, port, database)
}
}
fn oracle_listener_error_can_retry(err: &str) -> bool {
let normalized = err.to_lowercase();
normalized.contains("ora-12505")
|| normalized.contains("ora-12514")
|| normalized.contains("ora-12541")
|| normalized.contains("no listener")
|| err.contains("没有监听程序")
}
fn oracle_service_jdbc_url(host: &str, port: u16, database: &str) -> String {
format!("jdbc:oracle:thin:@//{host}:{port}/{database}")
}
fn oracle_sid_jdbc_url(host: &str, port: u16, database: &str) -> String {
format!("jdbc:oracle:thin:@{host}:{port}:{database}")
}
fn oracle_legacy_service_jdbc_url(host: &str, port: u16, database: &str) -> String {
format!("jdbc:oracle:thin:@{host}:{port}/{database}")
}
fn oracle_descriptor_jdbc_url(host: &str, port: u16, database: &str, key: &str) -> String {
format!("jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST={host})(PORT={port}))(CONNECT_DATA=({key}={database})))")
}
fn oceanbase_oracle_jdbc_connection_string(config: &ConnectionConfig, host: &str, port: u16, database: &str) -> String {
if let Some(connection_string) = config.connection_string.as_deref().filter(|value| !value.trim().is_empty()) {
let connection_string = connection_string.trim();
if host == config.host && port == config.port {
return connection_string.to_string();
}
return crate::models::connection::rewrite_jdbc_url_host(connection_string, host, port);
}
let database = database.trim();
if database.is_empty() {
return String::new();
}
append_agent_url_params(format!("jdbc:oceanbase://{host}:{port}/{database}"), config.url_params.as_deref())
}
fn postgres_like_agent_jdbc_connection_string(
config: &ConnectionConfig,
host: &str,
@ -262,24 +364,35 @@ pub fn oracle_auth_fallback_profiles(config: &ConnectionConfig, err: &str) -> Ve
}
pub fn oracle_alternate_connect_config(config: &ConnectionConfig, err: &str) -> Option<ConnectionConfig> {
if config.db_type != DatabaseType::Oracle {
return None;
}
if config.driver_profile.as_deref() == Some("oracle-10g") {
return None;
}
if config.connection_string.as_deref().is_some_and(|value| !value.trim().is_empty()) {
return None;
}
let normalized = err.to_lowercase();
if !normalized.contains("ora-12505") && !normalized.contains("ora-12514") {
return None;
}
oracle_alternate_connect_configs(config, err).into_iter().next()
}
let mut retry = config.clone();
retry.oracle_connection_type =
Some(if config.oracle_connection_type.as_deref() == Some("sid") { "service_name" } else { "sid" }.to_string());
Some(retry)
pub fn oracle_alternate_connect_config_labels(configs: &[ConnectionConfig]) -> Vec<String> {
configs
.iter()
.map(|config| {
config
.connection_string
.as_deref()
.map(oracle_connection_string_label)
.unwrap_or_else(|| config.oracle_connection_type.as_deref().unwrap_or("service_name").to_string())
})
.collect()
}
fn oracle_connection_string_label(connection_string: &str) -> String {
let upper = connection_string.to_ascii_uppercase();
if upper.contains("(SERVICE_NAME=") {
"descriptor service name".to_string()
} else if upper.contains("(SID=") {
"descriptor SID".to_string()
} else if connection_string.starts_with("jdbc:oracle:thin:@//") {
"service name".to_string()
} else if connection_string.contains(':') && !connection_string.contains('/') {
"SID".to_string()
} else {
"legacy service name".to_string()
}
}
fn sap_hana_jdbc_connection_string(config: &ConnectionConfig, host: &str, port: u16, database: &str) -> String {
@ -575,7 +688,7 @@ mod tests {
}
#[test]
fn oceanbase_oracle_uses_oracle_jdbc_connection_string_for_agent_protocol() {
fn oceanbase_oracle_uses_oceanbase_jdbc_connection_string_for_agent_protocol() {
let mut cfg = config(DatabaseType::OceanbaseOracle, Some("sys"));
cfg.host = "oceanbase.example.com".to_string();
cfg.port = 2881;
@ -584,7 +697,19 @@ mod tests {
assert_eq!(params["database"], "sys");
assert_eq!(params["sysdba"], false);
assert_eq!(params["connection_string"], "jdbc:oracle:thin:@//oceanbase.example.com:2881/sys");
assert_eq!(params["connection_string"], "jdbc:oceanbase://oceanbase.example.com:2881/sys");
}
#[test]
fn oceanbase_oracle_jdbc_url_appends_params_and_rewrites_forwarded_host() {
let mut cfg = config(DatabaseType::OceanbaseOracle, Some("sys"));
cfg.host = "oceanbase.example.com".to_string();
cfg.port = 2881;
cfg.url_params = Some("useSSL=false".to_string());
let params = agent_connect_params(&cfg, "127.0.0.1", 12881, "sys");
assert_eq!(params["connection_string"], "jdbc:oceanbase://127.0.0.1:12881/sys?useSSL=false");
}
#[test]
@ -631,9 +756,9 @@ mod tests {
let retry = oracle_alternate_connect_config(&cfg, "ORA-12514: listener does not know service").unwrap();
assert_eq!(retry.oracle_connection_type.as_deref(), Some("sid"));
assert_eq!(retry.connection_string.as_deref(), Some("jdbc:oracle:thin:@127.0.0.1:3306:ORCL"));
assert!(oracle_alternate_connect_config(&retry, "ORA-01017: invalid username/password").is_none());
assert!(oracle_alternate_connect_config(&cfg, "ORA-12541: TNS:no listener").is_none());
assert!(oracle_alternate_connect_config(&cfg, "ORA-12541: TNS:no listener").is_some());
}
#[test]
@ -666,6 +791,28 @@ mod tests {
assert!(oracle_alternate_connect_config(&cfg, "ORA-12514: listener does not know service").is_none());
}
#[test]
fn oracle_no_listener_errors_try_common_jdbc_url_variants() {
let mut cfg = config(DatabaseType::Oracle, Some("ORCL"));
cfg.host = "oracle.example.com".to_string();
cfg.port = 1521;
cfg.driver_profile = Some("oracle".to_string());
cfg.oracle_connection_type = Some("service_name".to_string());
let retries = oracle_alternate_connect_configs(&cfg, "ORA-12541: TNS:no listener");
let urls: Vec<_> = retries.iter().filter_map(|retry| retry.connection_string.as_deref()).collect();
assert_eq!(
urls,
vec![
"jdbc:oracle:thin:@oracle.example.com:1521:ORCL",
"jdbc:oracle:thin:@oracle.example.com:1521/ORCL",
"jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=oracle.example.com)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=ORCL)))",
"jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=oracle.example.com)(PORT=1521))(CONNECT_DATA=(SID=ORCL)))",
]
);
}
#[test]
fn sap_hana_url_includes_selected_database_and_params() {
let mut cfg = config(DatabaseType::SapHana, Some("TENANT1"));

View File

@ -8,8 +8,8 @@ use mysql_async::Row as MysqlRow;
use crate::agent_connection::{
agent_connect_params, h2_file_path_from_jdbc_url, is_h2_file_connection, mongo_legacy_error_with_auth_hint,
oracle_alternate_connect_config, oracle_auth_fallback_profiles, oracle_error_with_driver_hint,
should_retry_oracle_with_10g_driver,
oracle_alternate_connect_config_labels, oracle_alternate_connect_configs, oracle_auth_fallback_profiles,
oracle_error_with_driver_hint, should_retry_oracle_with_10g_driver,
};
use crate::agent_manager::{JavaRuntimeMode, DEFAULT_JRE_KEY};
use crate::database_capabilities;
@ -412,7 +412,7 @@ impl AppState {
))
} else {
db::redis_driver::RedisConnection::Direct(tokio::sync::Mutex::new(
db::redis_driver::connect(&url, connect_timeout).await?,
db::redis_driver::connect_standalone(&db_config, &host, port, connect_timeout).await?,
))
};
PoolKind::Redis(con)
@ -553,27 +553,48 @@ impl AppState {
let connect_result =
client.call_method::<serde_json::Value>(AgentMethod::Connect, connect_params.clone()).await;
if let Err(err) = connect_result {
if let Some(alternate_config) = oracle_alternate_connect_config(&db_config, &err) {
let alternate_configs = oracle_alternate_connect_configs(&db_config, &err);
if !alternate_configs.is_empty() {
log::warn!(
"Oracle connect failed with {:?} descriptor: {}. Retrying with {:?} descriptor.",
"Oracle connect failed with {:?} descriptor: {}. Retrying with Oracle JDBC URL variants: {:?}.",
db_config.oracle_connection_type,
err,
alternate_config.oracle_connection_type
oracle_alternate_connect_config_labels(&alternate_configs)
);
client
.call_method::<serde_json::Value>(
AgentMethod::Connect,
agent_connect_params(
&alternate_config,
&host,
port,
alternate_config.effective_database().unwrap_or(""),
),
)
.await
.map_err(|alternate_err| {
format!("{err}\n\nFallback with alternate Oracle descriptor failed: {alternate_err}")
})?;
let mut fallback_errors = Vec::new();
let mut connected = false;
for alternate_config in alternate_configs {
let label = oracle_alternate_connect_config_labels(std::slice::from_ref(&alternate_config))
.into_iter()
.next()
.unwrap_or_else(|| "alternate".to_string());
match client
.call_method::<serde_json::Value>(
AgentMethod::Connect,
agent_connect_params(
&alternate_config,
&host,
port,
alternate_config.effective_database().unwrap_or(""),
),
)
.await
{
Ok(_) => {
connected = true;
break;
}
Err(alternate_err) => {
fallback_errors.push(format!("{label}: {alternate_err}"));
}
}
}
if !connected {
return Err(format!(
"{err}\n\nFallback with alternate Oracle JDBC URLs failed: {}",
fallback_errors.join("\n")
));
}
} else if should_retry_oracle_with_10g_driver(&db_config, &err) {
log::warn!(
"Oracle connect failed with profile {:?}: {}. Retrying with legacy Oracle profiles.",
@ -1480,16 +1501,18 @@ mod tests {
)
.expect("listener errors should allow alternate descriptor retry");
assert_eq!(retry.driver_profile.as_deref(), Some("oracle"));
assert_eq!(retry.oracle_connection_type.as_deref(), Some("sid"));
assert_eq!(retry.connection_string.as_deref(), Some("jdbc:oracle:thin:@127.0.0.1:3306:ORCL"));
let mut sid_config = config.clone();
sid_config.oracle_connection_type = Some("sid".to_string());
let service_retry = oracle_alternate_connect_config(
&retry,
&sid_config,
"Agent RPC error (-1): ORA-12505: listener does not currently know of SID given",
)
.expect("SID listener errors should allow service-name retry");
assert_eq!(service_retry.oracle_connection_type.as_deref(), Some("service_name"));
assert_eq!(service_retry.connection_string.as_deref(), Some("jdbc:oracle:thin:@//127.0.0.1:3306/ORCL"));
assert!(oracle_alternate_connect_config(&config, "ORA-12541: TNS:no listener").is_none());
assert!(oracle_alternate_connect_config(&config, "ORA-12541: TNS:no listener").is_some());
}
#[test]

View File

@ -82,6 +82,12 @@ pub struct RedisClusterPool {
pub password: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RedisAuthCandidate {
username: String,
password: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RedisNodeEndpoint {
pub host: String,
@ -90,15 +96,56 @@ pub struct RedisNodeEndpoint {
pub async fn connect(url: &str, timeout: std::time::Duration) -> Result<redis::aio::MultiplexedConnection, String> {
let client = redis::Client::open(url).map_err(|e| format!("Redis connection failed: {e}"))?;
connect_client_with_timeout(client, timeout, "Redis").await
}
pub async fn connect_standalone(
config: &ConnectionConfig,
host: &str,
port: u16,
timeout: std::time::Duration,
) -> Result<redis::aio::MultiplexedConnection, String> {
let mut last_error = None;
for auth in redis_auth_candidates(&config.username, &config.password) {
let client = redis::Client::open(connection_info(
host,
port,
config.ssl,
config.redis_tls_insecure(),
&auth.username,
&auth.password,
redis_database_index(config),
))
.map_err(|e| format!("Redis connection failed: {e}"))?;
match connect_client_with_timeout(client, timeout, "Redis").await {
Ok(con) => return Ok(con),
Err(err) if last_error.is_none() || is_redis_auth_error(&err) => {
let should_retry = is_redis_auth_error(&err);
last_error = Some(err);
if !should_retry {
break;
}
}
Err(err) => return Err(err),
}
}
Err(last_error.unwrap_or_else(|| "Redis connection failed".to_string()))
}
async fn connect_client_with_timeout(
client: redis::Client,
timeout: std::time::Duration,
label: &str,
) -> Result<redis::aio::MultiplexedConnection, String> {
let mut con = tokio::time::timeout(timeout, client.get_multiplexed_async_connection())
.await
.map_err(|_| format!("Redis connection timed out ({}s)", timeout.as_secs()))?
.map_err(|e| format!("Redis connection failed: {e}"))?;
.map_err(|_| format!("{label} connection timed out ({}s)", timeout.as_secs()))?
.map_err(|e| format!("{label} connection failed: {e}"))?;
tokio::time::timeout(timeout, redis::cmd("PING").query_async::<String>(&mut con))
.await
.map_err(|_| format!("Redis ping timed out ({}s)", timeout.as_secs()))?
.map_err(|e| format!("Redis authentication failed or command rejected: {e}"))?;
.map_err(|_| format!("{label} ping timed out ({}s)", timeout.as_secs()))?
.map_err(|e| format!("{label} authentication failed or command rejected: {e}"))?;
Ok(con)
}
@ -128,39 +175,66 @@ pub async fn connect_sentinel(config: &ConnectionConfig) -> Result<redis::aio::M
pub async fn connect_cluster(config: &ConnectionConfig) -> Result<RedisClusterPool, String> {
let seed_nodes = redis_cluster_seed_nodes(config)?;
let cluster_nodes: Vec<ConnectionInfo> = seed_nodes
.iter()
.map(|endpoint| {
connection_info(
&endpoint.host,
endpoint.port,
config.ssl,
config.redis_tls_insecure(),
&config.username,
&config.password,
0,
)
})
.collect();
let client = ClusterClient::new(cluster_nodes).map_err(|e| format!("Redis cluster connection failed: {e}"))?;
let mut con = tokio::time::timeout(super::connection_timeout(), client.get_async_connection())
.await
.map_err(|_| format!("Redis cluster connection timed out ({}s)", super::CONNECTION_TIMEOUT_SECS))?
.map_err(|e| format!("Redis cluster connection failed: {e}"))?;
let mut last_error = None;
for auth in redis_auth_candidates(&config.username, &config.password) {
let cluster_nodes: Vec<ConnectionInfo> = seed_nodes
.iter()
.map(|endpoint| {
connection_info(
&endpoint.host,
endpoint.port,
config.ssl,
config.redis_tls_insecure(),
&auth.username,
&auth.password,
0,
)
})
.collect();
let client = ClusterClient::new(cluster_nodes).map_err(|e| format!("Redis cluster connection failed: {e}"))?;
let mut con = match tokio::time::timeout(super::connection_timeout(), client.get_async_connection())
.await
.map_err(|_| format!("Redis cluster connection timed out ({}s)", super::CONNECTION_TIMEOUT_SECS))?
.map_err(|e| format!("Redis cluster connection failed: {e}"))
{
Ok(con) => con,
Err(err) if last_error.is_none() || is_redis_auth_error(&err) => {
let should_retry = is_redis_auth_error(&err);
last_error = Some(err);
if should_retry {
continue;
}
break;
}
Err(err) => return Err(err),
};
tokio::time::timeout(super::connection_timeout(), redis::cmd("PING").query_async::<String>(&mut con))
.await
.map_err(|_| format!("Redis cluster ping timed out ({}s)", super::CONNECTION_TIMEOUT_SECS))?
.map_err(|e| format!("Redis cluster authentication failed or command rejected: {e}"))?;
Ok(RedisClusterPool {
connection: Mutex::new(con),
seed_nodes,
tls: config.ssl,
tls_insecure: config.redis_tls_insecure(),
username: config.username.clone(),
password: config.password.clone(),
})
match tokio::time::timeout(super::connection_timeout(), redis::cmd("PING").query_async::<String>(&mut con))
.await
.map_err(|_| format!("Redis cluster ping timed out ({}s)", super::CONNECTION_TIMEOUT_SECS))?
.map_err(|e| format!("Redis cluster authentication failed or command rejected: {e}"))
{
Ok(_) => {
return Ok(RedisClusterPool {
connection: Mutex::new(con),
seed_nodes,
tls: config.ssl,
tls_insecure: config.redis_tls_insecure(),
username: auth.username,
password: auth.password,
});
}
Err(err) if last_error.is_none() || is_redis_auth_error(&err) => {
let should_retry = is_redis_auth_error(&err);
last_error = Some(err);
if !should_retry {
break;
}
}
Err(err) => return Err(err),
}
}
Err(last_error.unwrap_or_else(|| "Redis cluster connection failed".to_string()))
}
fn redis_sentinel_nodes(config: &ConnectionConfig) -> Result<Vec<ConnectionInfo>, String> {
@ -273,6 +347,25 @@ fn redis_connection_info(username: &str, password: &str, db: i64) -> RedisConnec
}
}
fn redis_auth_candidates(username: &str, password: &str) -> Vec<RedisAuthCandidate> {
let username = username.trim();
let password = password.trim();
let mut candidates = vec![RedisAuthCandidate { username: username.to_string(), password: password.to_string() }];
if !username.is_empty() && !password.is_empty() {
candidates.push(RedisAuthCandidate { username: String::new(), password: format!("{username}@{password}") });
}
candidates
}
fn redis_database_index(config: &ConnectionConfig) -> i64 {
config.effective_database().and_then(|database| database.parse::<i64>().ok()).unwrap_or(0)
}
fn is_redis_auth_error(error: &str) -> bool {
let error = error.to_ascii_lowercase();
error.contains("auth") || error.contains("wrongpass") || error.contains("invalid username-password")
}
fn non_empty_string(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
@ -489,6 +582,7 @@ pub async fn scan_cluster_values_page(
cursor: u64,
pattern: &str,
query: &str,
include_key_matches: bool,
count: usize,
) -> Result<RedisScanResult, String> {
let master_nodes = cluster_master_nodes(pool).await?;
@ -507,7 +601,7 @@ pub async fn scan_cluster_values_page(
let mut con =
connect_direct_node(endpoint, pool.tls, pool.tls_insecure, &pool.username, &pool.password).await?;
let current_cursor = if index == node_index { node_cursor } else { 0 };
let result = scan_values_page(&mut con, current_cursor, pattern, query, count).await?;
let result = scan_values_page(&mut con, current_cursor, pattern, query, include_key_matches, count).await?;
if !result.keys.is_empty() {
let next_cursor = if result.cursor != 0 {
encode_cluster_cursor(index, result.cursor)?
@ -869,6 +963,7 @@ pub async fn scan_values_page<C>(
cursor: u64,
pattern: &str,
query: &str,
include_key_matches: bool,
count: usize,
) -> Result<RedisScanResult, String>
where
@ -892,7 +987,47 @@ where
let (next_cursor, keys) = parse_scan_keys(raw)?;
let mut result = Vec::new();
for key in keys {
let keys: Vec<_> = keys
.into_iter()
.map(|key| {
let key_display = redis_key_bytes_to_display(&key);
let key_raw = redis_key_bytes_to_raw(&key);
let key_matches = include_key_matches && redis_key_matches_query(&key_display, &key_raw, query);
(key, key_display, key_raw, key_matches)
})
.collect();
let mut key_match_types = Vec::new();
if include_key_matches {
let mut pipe = redis::pipe();
let mut key_match_count = 0usize;
for (key, _, _, key_matches) in &keys {
if *key_matches {
pipe.cmd("TYPE").arg(key);
key_match_count += 1;
}
}
if key_match_count > 0 {
key_match_types = pipe.query_async(con).await.unwrap_or_default();
}
}
let mut key_match_type_index = 0usize;
for (key, key_display, key_raw, key_matches) in keys {
if key_matches {
let key_type = key_match_types.get(key_match_type_index).cloned().unwrap_or_else(|| "unknown".to_string());
key_match_type_index += 1;
result.push(RedisKeyInfo {
key_display,
key_raw,
value_preview: redis_key_value_preview(&key_type),
key_type,
ttl: -2,
size: 0,
});
continue;
}
let Ok(value) = get_value(con, &key).await else {
continue;
};
@ -983,6 +1118,15 @@ fn redis_value_matches_query(value: &serde_json::Value, query: &str) -> bool {
redis_search_value_text(value).to_lowercase().contains(&query.to_lowercase())
}
fn redis_key_matches_query(key_display: &str, key_raw: &str, query: &str) -> bool {
let query = query.trim();
if query.is_empty() {
return false;
}
let query = query.to_lowercase();
key_display.to_lowercase().contains(&query) || key_raw.to_lowercase().contains(&query)
}
fn redis_search_value_text(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(text) => text.clone(),
@ -1530,11 +1674,13 @@ mod tests {
use super::{
classify_command, connection_info, decode_cluster_cursor, encode_cluster_cursor, is_redis_json_type,
parse_cluster_slots, parse_command_argv, parse_database_count, parse_redis_endpoint, parse_scan_keys,
parse_stream_entries, redis_command_raw_to_json, redis_json_raw_to_json, redis_json_value_preview,
redis_key_bytes_to_display, redis_key_bytes_to_raw, redis_key_raw_to_bytes, redis_key_value_preview,
redis_raw_to_json, redis_value_contains_binary, redis_value_matches_query, RedisCommandSafety,
parse_stream_entries, redis_auth_candidates, redis_command_raw_to_json, redis_database_index,
redis_json_raw_to_json, redis_json_value_preview, redis_key_bytes_to_display, redis_key_bytes_to_raw,
redis_key_matches_query, redis_key_raw_to_bytes, redis_key_value_preview, redis_raw_to_json,
redis_value_contains_binary, redis_value_matches_query, RedisAuthCandidate, RedisCommandSafety,
RedisNodeEndpoint, RedisRawValue,
};
use crate::models::connection::ConnectionConfig;
use redis::ConnectionAddr;
fn bulk(value: &str) -> RedisRawValue {
@ -1673,6 +1819,14 @@ mod tests {
assert!(!redis_value_matches_query(&serde_json::json!("Hello Redis"), "mysql"));
}
#[test]
fn matches_redis_keys_case_insensitively() {
assert!(redis_key_matches_query("User:42:Profile", "User:42:Profile", "profile"));
assert!(redis_key_matches_query("binary key", "ff75736572", "FF75"));
assert!(!redis_key_matches_query("User:42:Profile", "User:42:Profile", ""));
assert!(!redis_key_matches_query("User:42:Profile", "User:42:Profile", "order"));
}
#[test]
fn matches_hash_field_name_in_value_search() {
let hash_value = serde_json::json!([
@ -1754,6 +1908,76 @@ mod tests {
assert!(matches!(info.addr, ConnectionAddr::TcpTls { insecure: true, .. }));
}
#[test]
fn redis_connection_info_preserves_acl_username_and_password() {
let info = connection_info("cache.example.com", 6379, false, false, "app-user", "secret", 0);
assert_eq!(info.redis.username.as_deref(), Some("app-user"));
assert_eq!(info.redis.password.as_deref(), Some("secret"));
}
#[test]
fn redis_auth_candidates_try_username_at_password_fallback() {
let candidates = redis_auth_candidates("app-user", "secret");
assert_eq!(
candidates,
vec![
RedisAuthCandidate { username: "app-user".to_string(), password: "secret".to_string() },
RedisAuthCandidate { username: String::new(), password: "app-user@secret".to_string() },
]
);
}
#[test]
fn redis_database_index_uses_numeric_database_only() {
let mut config = ConnectionConfig {
id: "redis".to_string(),
name: "Redis".to_string(),
db_type: crate::models::connection::DatabaseType::Redis,
driver_profile: None,
driver_label: None,
url_params: None,
host: "cache.example.com".to_string(),
port: 6379,
username: String::new(),
password: String::new(),
database: Some("4".to_string()),
visible_databases: None,
attached_databases: Vec::new(),
color: None,
transport_layers: Vec::new(),
connect_timeout_secs: crate::models::connection::default_connect_timeout_secs(),
query_timeout_secs: crate::models::connection::default_query_timeout_secs(),
idle_timeout_secs: crate::models::connection::default_idle_timeout_secs(),
ssl: false,
ca_cert_path: String::new(),
client_cert_path: String::new(),
client_key_path: String::new(),
sysdba: false,
oracle_connection_type: None,
connection_string: None,
redis_connection_mode: None,
redis_sentinel_master: String::new(),
redis_sentinel_nodes: String::new(),
redis_sentinel_username: String::new(),
redis_sentinel_password: String::new(),
redis_sentinel_tls: false,
redis_cluster_nodes: String::new(),
redis_key_separator: crate::models::connection::default_redis_key_separator(),
etcd_endpoints: String::new(),
external_config: None,
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
};
assert_eq!(redis_database_index(&config), 4);
config.database = Some("not-a-number".to_string());
assert_eq!(redis_database_index(&config), 0);
}
#[test]
fn encodes_and_decodes_cluster_scan_cursor() {
let encoded = encode_cluster_cursor(12, 3456).unwrap();

View File

@ -357,7 +357,7 @@ fn has_top_level_select_into(sql: &str) -> bool {
}
fn add_sql_server_top(sql: &str, limit: usize) -> String {
if has_top_level_select_top(sql) {
if has_top_level_select_top(sql) || has_top_level_offset_fetch_next(sql) {
return sql.to_string();
}
if sql.len() >= 6 && sql[..6].eq_ignore_ascii_case("SELECT") {
@ -415,6 +415,13 @@ fn has_top_level_fetch_first(sql: &str) -> bool {
tokens.windows(2).any(|w| w[0].text == "FETCH" && w[1].text == "FIRST")
}
fn has_top_level_offset_fetch_next(sql: &str) -> bool {
let tokens = top_level_sql_tokens(sql);
let has_offset = tokens.iter().any(|token| token.text == "OFFSET");
let has_fetch_next = tokens.windows(2).any(|w| w[0].text == "FETCH" && w[1].text == "NEXT");
has_offset && has_fetch_next
}
fn add_fetch_first_limit(statement: &str, limit: usize, offset: usize) -> String {
if has_top_level_fetch_first(statement) {
return format!("{statement};");
@ -787,6 +794,19 @@ mod tests {
assert_eq!(result, err("unsupported"));
}
#[test]
fn keeps_sqlserver_offset_fetch_next_when_offset_is_zero() {
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
original_sql: "SELECT * FROM TABLE_NAME ORDER BY id OFFSET 1 ROWS FETCH NEXT 10 ROWS ONLY".to_string(),
database_type: Some(DatabaseType::SqlServer),
limit: 100,
offset: 0,
});
assert!(result.ok);
assert_eq!(result.sql.unwrap(), "SELECT * FROM TABLE_NAME ORDER BY id OFFSET 1 ROWS FETCH NEXT 10 ROWS ONLY");
}
#[test]
fn oracle_pagination_skips_sql_clause() {
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {

View File

@ -54,6 +54,7 @@ pub async fn redis_scan_values_core(
cursor: u64,
pattern: &str,
query: &str,
include_key_matches: bool,
count: usize,
) -> Result<RedisScanResult, String> {
let connections = state.connections.read().await;
@ -63,11 +64,12 @@ pub async fn redis_scan_values_core(
RedisConnection::Direct(con) => {
let mut con = con.lock().await;
redis_driver::select_db(&mut *con, db).await?;
redis_driver::scan_values_page(&mut *con, cursor, pattern, query, count).await
redis_driver::scan_values_page(&mut *con, cursor, pattern, query, include_key_matches, count).await
}
RedisConnection::Cluster(cluster) => {
redis_driver::ensure_cluster_db(db)?;
redis_driver::scan_cluster_values_page(cluster, cursor, pattern, query, count).await
redis_driver::scan_cluster_values_page(cluster, cursor, pattern, query, include_key_matches, count)
.await
}
},
_ => Err("Not a Redis connection".to_string()),

View File

@ -46,6 +46,7 @@ pub struct RedisValueScanRequest {
pub cursor: u64,
pub pattern: String,
pub query: String,
pub include_key_matches: Option<bool>,
pub count: usize,
}
@ -192,6 +193,7 @@ pub async fn scan_values(
req.cursor,
&req.pattern,
&req.query,
req.include_key_matches.unwrap_or(false),
req.count,
)
.await

View File

@ -34,9 +34,20 @@ pub async fn redis_scan_values(
cursor: u64,
pattern: String,
query: String,
include_key_matches: Option<bool>,
count: usize,
) -> Result<RedisScanResult, String> {
dbx_core::redis_ops::redis_scan_values_core(&state, &connection_id, db, cursor, &pattern, &query, count).await
dbx_core::redis_ops::redis_scan_values_core(
&state,
&connection_id,
db,
cursor,
&pattern,
&query,
include_key_matches.unwrap_or(false),
count,
)
.await
}
#[tauri::command]