feat(history): add query history filters

This commit is contained in:
auth0
2026-07-22 12:22:01 +08:00
committed by GitHub
parent 1c7576537a
commit d16ba82d5a
22 changed files with 1324 additions and 40 deletions
+1 -1
View File
@@ -2294,7 +2294,7 @@ onUnmounted(() => {
<div v-if="showHistory" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: historyWidth + 'px' }">
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startHistoryResize" />
<QueryHistory @restore="restoreHistorySql" @analyze-ai="analyzeHistoryWithAi" @close="closeRightSidebarPanel('history')" />
<QueryHistory :current-connection-id="activeTab?.connectionId" :current-database="activeTab?.database" @restore="restoreHistorySql" @analyze-ai="analyzeHistoryWithAi" @close="closeRightSidebarPanel('history')" />
</div>
<div v-if="showSqlLibraryPanel" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlLibraryWidth + 'px' }">
@@ -2,7 +2,7 @@
import { ref, computed, nextTick, onBeforeUnmount, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { CalendarClock, Copy, Database, RotateCcw, Search, Sparkles, Trash2, X } from "@lucide/vue";
import { CalendarClock, Check, Copy, Database, ListFilter, LoaderCircle, Minus, RotateCcw, Search, Sparkles, Trash2, X } from "@lucide/vue";
import { RecycleScroller } from "vue-virtual-scroller";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
@@ -13,9 +13,10 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { resolveHistoryActivityKind } from "@/lib/history/historyActivityKind";
import { canRollbackHistoryEntry } from "@/lib/history/historyAiAnalysis";
import { hasHistoryDateRange, historyDateRangeIsValid, historyEntryMatchesDateRange, type HistoryDateRange } from "@/lib/history/historyTimeRange";
import { hasHistoryDateRange, historyDateRangeIsValid, type HistoryDateRange } from "@/lib/history/historyTimeRange";
import { HISTORY_ROW_HEIGHT, HISTORY_SCROLL_BUFFER, shouldVirtualizeHistory } from "@/lib/history/historyVirtualList";
import type { HistoryEntry } from "@/lib/backend/api";
import { historyConnectionHasSelectedDatabase } from "@/lib/history/historySearch";
import type { HistoryConnectionFilter, HistoryDatabaseFilter, HistoryEntry, HistorySearchRequest } from "@/lib/backend/api";
import { copyToClipboard } from "@/lib/common/clipboard";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import * as api from "@/lib/backend/api";
@@ -26,6 +27,11 @@ const { highlight } = useSqlHighlighter();
const store = useHistoryStore();
const connectionStore = useConnectionStore();
const props = defineProps<{
currentConnectionId?: string;
currentDatabase?: string;
}>();
const emit = defineEmits<{
restore: [sql: string, entry: HistoryEntry];
analyzeAi: [entry: HistoryEntry];
@@ -39,6 +45,10 @@ const activeFilter = ref<HistoryFilter>("all");
const dateRange = ref<HistoryDateRange>({ startDate: "", endDate: "" });
const dateRangeDraft = ref<HistoryDateRange>({ startDate: "", endDate: "" });
const dateRangeOpen = ref(false);
const scopeFilterOpen = ref(false);
const scopeSearchText = ref("");
const selectedConnections = ref<HistoryConnectionFilter[]>([]);
const selectedDatabases = ref<HistoryDatabaseFilter[]>([]);
const startDateInputRef = ref<HTMLInputElement | null>(null);
const endDateInputRef = ref<HTMLInputElement | null>(null);
const selectedEntry = ref<HistoryEntry | null>(null);
@@ -49,6 +59,7 @@ const deleteTargetId = ref<string | null>(null);
const filterScrollRef = ref<HTMLElement | null>(null);
const filtersScrollable = ref(false);
let filterScrollResizeObserver: ResizeObserver | null = null;
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const filters: HistoryFilter[] = ["all", "query", "data_change", "schema_change", "failed"];
const hasDateFilter = computed(() => hasHistoryDateRange(dateRange.value));
@@ -60,19 +71,15 @@ const dateRangeSummary = computed(() => {
return `${start} -> ${end}`;
});
const filtered = computed(() => {
const q = searchText.value.toLowerCase();
return store.entries.filter((entry) => {
if (activeFilter.value === "failed" && entry.success) return false;
if (activeFilter.value !== "all" && activeFilter.value !== "failed" && activityKind(entry) !== activeFilter.value) {
return false;
}
if (!historyEntryMatchesDateRange(entry.executed_at, dateRange.value)) return false;
if (!q) return true;
return [entry.sql, entry.connection_name, entry.database, entry.operation, entry.target].filter(Boolean).some((value) => String(value).toLowerCase().includes(q));
});
const hasScopeFilter = computed(() => selectedConnections.value.length > 0 || selectedDatabases.value.length > 0);
const wholeConnectionCount = computed(() => selectedConnections.value.filter((connection) => !isConnectionNarrowed(connection)).length);
const scopeSummary = computed(() => {
const parts: string[] = [];
if (wholeConnectionCount.value > 0) parts.push(t("history.scope.connectionCount", { count: wholeConnectionCount.value }));
if (selectedDatabases.value.length > 0) parts.push(t("history.scope.databaseCount", { count: selectedDatabases.value.length }));
return parts.join(" / ");
});
const emptyMessage = computed(() => (store.entries.length > 0 ? t("history.emptyFilteredRecent") : t("history.empty")));
const emptyMessage = computed(() => (hasScopeFilter.value || hasDateFilter.value || activeFilter.value !== "all" || searchText.value ? t("history.emptyFiltered") : t("history.empty")));
function activityKind(entry: HistoryEntry) {
return resolveHistoryActivityKind(entry);
@@ -106,7 +113,7 @@ function executeDelete() {
}
function confirmClearHistory() {
if (store.entries.length > 0) {
if (store.total > 0 || store.connectionOptions.length > 0) {
showClearConfirm.value = true;
}
}
@@ -145,6 +152,135 @@ function filterLabel(filter: HistoryFilter) {
return t(`history.filters.${filter}`);
}
function connectionKey(connection: HistoryConnectionFilter) {
return connection.connection_id ? `id:${connection.connection_id}` : `legacy:${connection.connection_name}`;
}
function databaseKey(database: HistoryDatabaseFilter) {
return `${connectionKey(database)}\0${database.database}`;
}
function isConnectionSelected(connection: HistoryConnectionFilter) {
const key = connectionKey(connection);
return selectedConnections.value.some((candidate) => connectionKey(candidate) === key);
}
function isConnectionNarrowed(connection: HistoryConnectionFilter) {
return historyConnectionHasSelectedDatabase(connection, selectedDatabases.value);
}
function toggleConnection(connection: HistoryConnectionFilter) {
const key = connectionKey(connection);
if (isConnectionNarrowed(connection)) {
selectedDatabases.value = selectedDatabases.value.filter((database) => connectionKey(database) !== key);
return;
}
if (isConnectionSelected(connection)) {
selectedConnections.value = selectedConnections.value.filter((candidate) => connectionKey(candidate) !== key);
selectedDatabases.value = selectedDatabases.value.filter((database) => connectionKey(database) !== key);
} else {
selectedConnections.value = [...selectedConnections.value, { ...connection }];
}
}
function isDatabaseSelected(database: HistoryDatabaseFilter) {
const key = databaseKey(database);
return selectedDatabases.value.some((candidate) => databaseKey(candidate) === key);
}
function toggleDatabase(database: HistoryDatabaseFilter) {
const key = databaseKey(database);
if (isDatabaseSelected(database)) {
selectedDatabases.value = selectedDatabases.value.filter((candidate) => databaseKey(candidate) !== key);
if (!historyConnectionHasSelectedDatabase(database, selectedDatabases.value)) {
const parentKey = connectionKey(database);
selectedConnections.value = selectedConnections.value.filter((connection) => connectionKey(connection) !== parentKey);
}
return;
}
if (!isConnectionSelected(database)) {
selectedConnections.value = [...selectedConnections.value, { connection_id: database.connection_id, connection_name: database.connection_name }];
}
selectedDatabases.value = [...selectedDatabases.value, { ...database }];
}
const visibleConnectionOptions = computed(() => {
const query = scopeSearchText.value.trim().toLowerCase();
return store.connectionOptions.filter((option) => {
if (!query) return true;
return option.connection_name.toLowerCase().includes(query) || option.databases.some((database) => database.toLowerCase().includes(query));
});
});
const visibleDatabaseOptions = computed<HistoryDatabaseFilter[]>(() => {
const selectedKeys = new Set(selectedConnections.value.map(connectionKey));
const query = scopeSearchText.value.trim().toLowerCase();
return store.connectionOptions
.filter((connection) => selectedKeys.size === 0 || selectedKeys.has(connectionKey(connection)))
.flatMap((connection) =>
connection.databases
.filter((database) => !query || database.toLowerCase().includes(query) || connection.connection_name.toLowerCase().includes(query))
.map((database) => ({
connection_id: connection.connection_id,
connection_name: connection.connection_name,
database,
})),
);
});
const currentConnectionOption = computed(() => store.connectionOptions.find((option) => option.connection_id === props.currentConnectionId));
function selectCurrentScope() {
const connection = currentConnectionOption.value;
if (!connection) return;
selectedConnections.value = [{ connection_id: connection.connection_id, connection_name: connection.connection_name }];
selectedDatabases.value = props.currentDatabase && connection.databases.includes(props.currentDatabase) ? [{ connection_id: connection.connection_id, connection_name: connection.connection_name, database: props.currentDatabase }] : [];
scopeFilterOpen.value = false;
}
function resetScopeFilter() {
selectedConnections.value = [];
selectedDatabases.value = [];
}
// Preserve local-day boundaries before converting date-only input to UTC ISO timestamps.
function localDateBoundary(value: string, endOfDay: boolean): string | undefined {
if (!value) return undefined;
const [year, month, day] = value.split("-").map(Number);
if (!year || !month || !day) return undefined;
return new Date(year, month - 1, day, endOfDay ? 23 : 0, endOfDay ? 59 : 0, endOfDay ? 59 : 0, endOfDay ? 999 : 0).toISOString();
}
function buildHistorySearchRequest(): HistorySearchRequest {
return {
search_text: searchText.value,
connections: selectedConnections.value.map((connection) => ({ ...connection })),
databases: selectedDatabases.value.map((database) => ({ ...database })),
activity_kind: activeFilter.value !== "all" && activeFilter.value !== "failed" ? activeFilter.value : undefined,
success: activeFilter.value === "failed" ? false : undefined,
started_at: localDateBoundary(dateRange.value.startDate, false),
ended_at: localDateBoundary(dateRange.value.endDate, true),
limit: 100,
};
}
async function runHistorySearch() {
try {
await store.search(buildHistorySearchRequest());
} catch {
// The panel already renders the backend error; avoid a duplicate toast.
}
}
// Debounce all filter changes through one timer to avoid redundant history scans.
function scheduleHistorySearch() {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
searchDebounceTimer = null;
void runHistorySearch();
}, 250);
}
function updateFilterScrollability() {
const el = filterScrollRef.value;
if (!el) return;
@@ -282,8 +418,12 @@ watch(
},
);
watch([searchText, activeFilter, () => dateRange.value.startDate, () => dateRange.value.endDate, () => selectedConnections.value.map(connectionKey).join("\0"), () => selectedDatabases.value.map(databaseKey).join("\0")], scheduleHistorySearch);
onMounted(() => {
store.load();
store.setHistoryPanelActive(true);
void store.loadConnectionOptions().catch(() => {});
void runHistorySearch();
void nextTick(updateFilterScrollability);
if (filterScrollRef.value) {
@@ -293,6 +433,9 @@ onMounted(() => {
});
onBeforeUnmount(() => {
store.setHistoryPanelActive(false);
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
searchDebounceTimer = null;
filterScrollResizeObserver?.disconnect();
filterScrollResizeObserver = null;
});
@@ -302,8 +445,9 @@ onBeforeUnmount(() => {
<div class="h-full flex flex-col overflow-hidden border-l">
<div class="h-9 flex items-center gap-1 px-2 border-b shrink-0 bg-muted/20">
<span class="text-xs font-medium">{{ t("history.title") }}</span>
<span v-if="store.total > 0" class="text-[10px] text-muted-foreground">{{ store.total }}</span>
<span class="flex-1" />
<Button v-if="store.entries.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="confirmClearHistory">
<Button v-if="store.total > 0 || store.connectionOptions.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="confirmClearHistory">
<Trash2 class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('close')">
@@ -376,21 +520,83 @@ onBeforeUnmount(() => {
</div>
</PopoverContent>
</Popover>
<Popover :open="scopeFilterOpen" @update:open="scopeFilterOpen = $event">
<PopoverTrigger as-child>
<button
type="button"
class="ml-1 flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors"
:class="hasScopeFilter ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
:title="t('history.scope.title')"
>
<ListFilter class="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent align="end" class="w-72 max-w-[calc(100vw-24px)] p-3" @click.stop @keydown.stop>
<div class="mb-2 flex items-center justify-between gap-2">
<div class="text-xs font-medium">{{ t("history.scope.title") }}</div>
<Button variant="ghost" size="sm" class="h-6 px-2 text-[11px]" :disabled="!hasScopeFilter" @click="resetScopeFilter">
{{ t("history.scope.reset") }}
</Button>
</div>
<div class="relative mb-2">
<Search class="pointer-events-none absolute left-2 top-2 h-3 w-3 text-muted-foreground" />
<input v-model="scopeSearchText" class="h-7 w-full rounded border bg-background pl-7 pr-2 text-xs outline-none focus:border-ring" :placeholder="t('history.scope.search')" />
</div>
<div class="mb-2 flex gap-1">
<Button variant="outline" size="sm" class="h-7 flex-1 px-2 text-[11px]" @click="resetScopeFilter">
{{ t("history.scope.allConnections") }}
</Button>
<Button v-if="currentConnectionOption" variant="outline" size="sm" class="h-7 flex-1 px-2 text-[11px]" @click="selectCurrentScope">
{{ t("history.scope.current") }}
</Button>
</div>
<div class="mb-1 text-[11px] font-medium text-muted-foreground">{{ t("history.scope.connections") }}</div>
<div class="max-h-32 overflow-y-auto rounded border">
<button v-for="connection in visibleConnectionOptions" :key="connectionKey(connection)" type="button" class="flex w-full items-center gap-2 border-b px-2 py-1.5 text-left text-xs last:border-b-0 hover:bg-accent" @click="toggleConnection(connection)">
<span class="flex h-4 w-4 shrink-0 items-center justify-center rounded border" :class="{ 'border-primary bg-primary text-primary-foreground': isConnectionSelected(connection) }">
<Minus v-if="isConnectionNarrowed(connection)" class="h-3 w-3" />
<Check v-else-if="isConnectionSelected(connection)" class="h-3 w-3" />
</span>
<span class="min-w-0 flex-1 truncate">{{ connection.connection_name || t("history.scope.unknownConnection") }}</span>
<span class="shrink-0 text-[10px] text-muted-foreground">{{ connection.databases.length }}</span>
</button>
<div v-if="visibleConnectionOptions.length === 0" class="px-2 py-3 text-center text-[11px] text-muted-foreground">{{ t("history.scope.noOptions") }}</div>
</div>
<div class="mb-1 mt-3 text-[11px] font-medium text-muted-foreground">{{ t("history.scope.databases") }}</div>
<div class="max-h-32 overflow-y-auto rounded border">
<button v-for="database in visibleDatabaseOptions" :key="databaseKey(database)" type="button" class="flex w-full items-center gap-2 border-b px-2 py-1.5 text-left text-xs last:border-b-0 hover:bg-accent" @click="toggleDatabase(database)">
<span class="flex h-4 w-4 shrink-0 items-center justify-center rounded border" :class="{ 'border-primary bg-primary text-primary-foreground': isDatabaseSelected(database) }">
<Check v-if="isDatabaseSelected(database)" class="h-3 w-3" />
</span>
<span class="min-w-0 flex-1 truncate">{{ database.database }}</span>
<span class="max-w-24 shrink-0 truncate text-[10px] text-muted-foreground">{{ database.connection_name }}</span>
</button>
<div v-if="visibleDatabaseOptions.length === 0" class="px-2 py-3 text-center text-[11px] text-muted-foreground">{{ t("history.scope.noOptions") }}</div>
</div>
</PopoverContent>
</Popover>
</div>
<div v-if="hasDateFilter" class="flex items-center gap-1 px-2 pb-2">
<button type="button" class="flex min-w-0 items-center gap-1 rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary hover:bg-primary/15" :title="t('history.dateRange.title')" @click="setDateRangeOpen(true)">
<div v-if="hasDateFilter || hasScopeFilter" class="flex flex-wrap items-center gap-1 px-2 pb-2">
<button v-if="hasDateFilter" type="button" class="flex min-w-0 items-center gap-1 rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary hover:bg-primary/15" :title="t('history.dateRange.title')" @click="setDateRangeOpen(true)">
<CalendarClock class="h-3 w-3 shrink-0" />
<span class="shrink-0">{{ t("history.dateRange.label") }}</span>
<span class="min-w-0 truncate tabular-nums">{{ dateRangeSummary }}</span>
</button>
<button type="button" class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :title="t('history.dateRange.clear')" @click="clearDateRangeFilter">
<button v-if="hasDateFilter" type="button" class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :title="t('history.dateRange.clear')" @click="clearDateRangeFilter">
<X class="h-3 w-3" />
</button>
<button v-if="hasScopeFilter" type="button" class="flex min-w-0 items-center gap-1 rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary hover:bg-primary/15" :title="t('history.scope.title')" @click="scopeFilterOpen = true">
<ListFilter class="h-3 w-3 shrink-0" />
<span class="min-w-0 truncate">{{ scopeSummary }}</span>
</button>
<button v-if="hasScopeFilter" type="button" class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :title="t('history.scope.reset')" @click="resetScopeFilter">
<X class="h-3 w-3" />
</button>
</div>
</div>
<div class="min-h-0 flex-1">
<RecycleScroller v-if="shouldVirtualizeHistory(filtered.length)" class="h-full" :items="filtered" :item-size="HISTORY_ROW_HEIGHT" :buffer="HISTORY_SCROLL_BUFFER" :skip-hover="true" key-field="id">
<div class="flex min-h-0 flex-1 flex-col">
<RecycleScroller v-if="shouldVirtualizeHistory(store.entries.length)" class="min-h-0 flex-1" :items="store.entries" :item-size="HISTORY_ROW_HEIGHT" :buffer="HISTORY_SCROLL_BUFFER" :skip-hover="true" key-field="id">
<template #default="{ item: entry }">
<CustomContextMenu :items="getHistoryMenuItems(entry)" v-slot="{ onContextMenu }">
<div class="h-[72px] cursor-pointer border-b border-border/50 px-3 py-2 text-xs hover:bg-accent/50" @click="selectedEntry = entry" @contextmenu="onContextMenu">
@@ -418,9 +624,20 @@ onBeforeUnmount(() => {
</template>
</RecycleScroller>
<div v-if="filtered.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
<div v-if="store.loading && store.entries.length === 0" class="flex items-center justify-center gap-2 px-3 py-8 text-xs text-muted-foreground">
<LoaderCircle class="h-3.5 w-3.5 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="store.error && store.entries.length === 0" class="px-3 py-8 text-center text-xs text-destructive">
{{ store.error }}
</div>
<div v-else-if="store.entries.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
{{ emptyMessage }}
</div>
<button v-if="store.nextCursor" type="button" class="flex h-8 shrink-0 items-center justify-center gap-1 border-t text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-60" :disabled="store.loadingMore" @click="store.loadMore">
<LoaderCircle v-if="store.loadingMore" class="h-3.5 w-3.5 animate-spin" />
{{ store.loadingMore ? t("common.loading") : t("history.loadMore") }}
</button>
</div>
<Dialog :open="!!selectedEntry" @update:open="(value) => !value && (selectedEntry = null)">
+15
View File
@@ -2719,6 +2719,21 @@ export default {
rollbackFailed: "Rollback failed: {message}",
confirmDelete: "Delete this history entry?",
confirmClear: "Clear all history?",
emptyFiltered: "No history matches the current filters",
loadMore: "Load more",
scope: {
title: "Connection and database",
search: "Search connections or databases...",
reset: "Reset",
allConnections: "All connections",
current: "Current",
connections: "Connections",
databases: "Databases",
connectionCount: "{count} connections",
databaseCount: "{count} databases",
unknownConnection: "Unknown connection",
noOptions: "No matching options",
},
filters: {
all: "All",
query: "Queries",
+15
View File
@@ -2573,6 +2573,21 @@ export default withEnglishFallback({
rollbackFailed: "Error al revertir: {message}",
confirmDelete: "¿Eliminar esta entrada del historial?",
confirmClear: "¿Limpiar todo el historial?",
emptyFiltered: "Ningún elemento del historial coincide con los filtros actuales",
loadMore: "Cargar más",
scope: {
title: "Conexión y base de datos",
search: "Buscar conexiones o bases de datos...",
reset: "Restablecer",
allConnections: "Todas las conexiones",
current: "Actual",
connections: "Conexiones",
databases: "Bases de datos",
connectionCount: "{count} conexiones",
databaseCount: "{count} bases de datos",
unknownConnection: "Conexión desconocida",
noOptions: "No hay opciones coincidentes",
},
filters: {
all: "Todas",
query: "Consultas",
+15
View File
@@ -2571,6 +2571,21 @@ export default withEnglishFallback({
rollbackFailed: "Rollback non riuscito: {message}",
confirmDelete: "Eliminare questa voce della cronologia?",
confirmClear: "Cancellare tutta la cronologia?",
emptyFiltered: "Nessuna voce della cronologia corrisponde ai filtri attuali",
loadMore: "Carica altro",
scope: {
title: "Connessione e database",
search: "Cerca connessioni o database...",
reset: "Reimposta",
allConnections: "Tutte le connessioni",
current: "Attuale",
connections: "Connessioni",
databases: "Database",
connectionCount: "{count} connessioni",
databaseCount: "{count} database",
unknownConnection: "Connessione sconosciuta",
noOptions: "Nessuna opzione corrispondente",
},
filters: {
all: "Tutti",
query: "Query",
+15
View File
@@ -2572,6 +2572,21 @@ export default withEnglishFallback({
rollbackFailed: "ロールバックに失敗しました: {message}",
confirmDelete: "この履歴エントリを削除しますか?",
confirmClear: "すべての履歴をクリアしますか?",
emptyFiltered: "現在のフィルターに一致する履歴はありません",
loadMore: "さらに読み込む",
scope: {
title: "接続とデータベース",
search: "接続またはデータベースを検索...",
reset: "リセット",
allConnections: "すべての接続",
current: "現在",
connections: "接続",
databases: "データベース",
connectionCount: "{count} 件の接続",
databaseCount: "{count} 件のデータベース",
unknownConnection: "不明な接続",
noOptions: "一致する項目はありません",
},
filters: {
all: "すべて",
query: "クエリ",
+15
View File
@@ -2573,6 +2573,21 @@ export default withEnglishFallback({
rollbackFailed: "Falha na reversão: {message}",
confirmDelete: "Excluir esta entrada de histórico?",
confirmClear: "Limpar todo o histórico?",
emptyFiltered: "Nenhum histórico corresponde aos filtros atuais",
loadMore: "Carregar mais",
scope: {
title: "Conexão e banco de dados",
search: "Pesquisar conexões ou bancos de dados...",
reset: "Redefinir",
allConnections: "Todas as conexões",
current: "Atual",
connections: "Conexões",
databases: "Bancos de dados",
connectionCount: "{count} conexões",
databaseCount: "{count} bancos de dados",
unknownConnection: "Conexão desconhecida",
noOptions: "Nenhuma opção correspondente",
},
filters: {
all: "Todos",
query: "Consultas",
+15
View File
@@ -2709,6 +2709,21 @@ export default withEnglishFallback({
rollbackFailed: "回滚失败:{message}",
confirmDelete: "确认删除这条历史记录吗?",
confirmClear: "确认清空所有历史记录吗?",
emptyFiltered: "没有符合当前筛选条件的历史记录",
loadMore: "加载更多",
scope: {
title: "连接和数据库",
search: "搜索连接或数据库...",
reset: "重置",
allConnections: "全部连接",
current: "当前",
connections: "连接",
databases: "数据库",
connectionCount: "{count} 个连接",
databaseCount: "{count} 个数据库",
unknownConnection: "未知连接",
noOptions: "没有匹配项",
},
filters: {
all: "全部",
query: "查询",
+15
View File
@@ -2390,6 +2390,21 @@ export default withEnglishFallback({
rollbackFailed: "回溯失敗:{message}",
confirmDelete: "確認刪除這條歷史記錄嗎?",
confirmClear: "確認清空所有歷史記錄嗎?",
emptyFiltered: "沒有符合目前篩選條件的歷史記錄",
loadMore: "載入更多",
scope: {
title: "連線和資料庫",
search: "搜尋連線或資料庫……",
reset: "重設",
allConnections: "全部連線",
current: "目前",
connections: "連線",
databases: "資料庫",
connectionCount: "{count} 個連線",
databaseCount: "{count} 個資料庫",
unknownConnection: "未知連線",
noOptions: "沒有相符項目",
},
filters: {
all: "全部",
query: "查詢",
+8
View File
@@ -490,6 +490,8 @@ export const vectorListCollections = forward("vectorListCollections");
// History
export const saveHistory = forward("saveHistory");
export const loadHistory = forward("loadHistory");
export const searchHistory = forward("searchHistory");
export const loadHistoryConnectionOptions = forward("loadHistoryConnectionOptions");
export const loadRedisHistory = forward("loadRedisHistory");
export const clearHistory = forward("clearHistory");
export const clearRedisHistory = forward("clearRedisHistory");
@@ -579,6 +581,12 @@ export type {
KvDeleteResponse,
MongoDocumentResult,
HistoryEntry,
HistoryConnectionFilter,
HistoryDatabaseFilter,
HistoryCursor,
HistorySearchRequest,
HistorySearchResult,
HistoryConnectionOption,
SqlFileStatus,
SqlFileRequest,
SqlFilePreview,
+11
View File
@@ -78,6 +78,9 @@ import type {
MongoCollectionStatsResult,
MongoGridFsBucketInfo,
HistoryEntry,
HistorySearchRequest,
HistorySearchResult,
HistoryConnectionOption,
SqlFileRequest,
SqlFilePreview,
SqlFileProgress,
@@ -2251,6 +2254,14 @@ export async function loadHistory(limit: number, offset: number, activityKind?:
return get(`/api/history?${qs({ limit, offset, activity_kind: activityKind })}`);
}
export async function searchHistory(request: HistorySearchRequest): Promise<HistorySearchResult> {
return post("/api/history/search", request);
}
export async function loadHistoryConnectionOptions(): Promise<HistoryConnectionOption[]> {
return get("/api/history/options");
}
export async function loadRedisHistory(limit = 100, offset = 0): Promise<HistoryEntry[]> {
return loadHistory(limit, offset, "redis_command");
}
+44
View File
@@ -2050,6 +2050,42 @@ export interface HistoryEntry {
details_json?: string | null;
}
export interface HistoryConnectionFilter {
connection_id: string;
connection_name: string;
}
export interface HistoryDatabaseFilter extends HistoryConnectionFilter {
database: string;
}
export interface HistoryCursor {
executed_at: string;
id: string;
}
export interface HistorySearchRequest {
search_text: string;
connections: HistoryConnectionFilter[];
databases: HistoryDatabaseFilter[];
activity_kind?: string;
success?: boolean;
started_at?: string;
ended_at?: string;
cursor?: HistoryCursor;
limit: number;
}
export interface HistorySearchResult {
entries: HistoryEntry[];
next_cursor?: HistoryCursor | null;
total: number;
}
export interface HistoryConnectionOption extends HistoryConnectionFilter {
databases: string[];
}
export async function saveHistory(entry: HistoryEntry): Promise<void> {
return invoke("save_history", { entry });
}
@@ -2058,6 +2094,14 @@ export async function loadHistory(limit: number, offset: number, activityKind?:
return invoke("load_history", { limit, offset, activityKind: activityKind ?? null });
}
export async function searchHistory(request: HistorySearchRequest): Promise<HistorySearchResult> {
return invoke("search_history", { request });
}
export async function loadHistoryConnectionOptions(): Promise<HistoryConnectionOption[]> {
return invoke("load_history_connection_options");
}
export async function loadRedisHistory(limit = 100, offset = 0): Promise<HistoryEntry[]> {
return loadHistory(limit, offset, "redis_command");
}
@@ -0,0 +1,11 @@
import type { HistoryConnectionFilter, HistoryDatabaseFilter } from "@/lib/backend/api";
export function historyConnectionHasSelectedDatabase(connection: HistoryConnectionFilter, databases: HistoryDatabaseFilter[]): boolean {
return databases.some((database) => {
if (!database.database) return false;
if (connection.connection_id || database.connection_id) {
return Boolean(connection.connection_id) && connection.connection_id === database.connection_id;
}
return Boolean(connection.connection_name) && connection.connection_name === database.connection_name;
});
}
+205
View File
@@ -0,0 +1,205 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "@/lib/backend/api";
import type { HistoryEntry, HistorySearchRequest, HistorySearchResult } from "@/lib/backend/api";
import { useHistoryStore } from "@/stores/historyStore";
vi.mock("@/lib/backend/api", () => ({
searchHistory: vi.fn(),
saveHistory: vi.fn(),
deleteHistoryEntry: vi.fn(),
clearHistory: vi.fn(),
loadHistoryConnectionOptions: vi.fn(),
}));
const request: HistorySearchRequest = {
search_text: "",
connections: [],
databases: [],
limit: 100,
};
function entry(overrides: Partial<HistoryEntry> = {}): HistoryEntry {
return {
id: "history-1",
connection_id: "conn-a",
connection_name: "Primary",
database: "sales",
sql: "select 1",
executed_at: "2026-07-18T08:00:00.000Z",
execution_time_ms: 10,
success: true,
activity_kind: "query",
...overrides,
};
}
function addInput(stored = entry()): Omit<HistoryEntry, "id" | "executed_at"> {
const { id: _id, executed_at: _executedAt, ...input } = stored;
return input;
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((complete) => {
resolve = complete;
});
return { promise, resolve };
}
describe("historyStore", () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
vi.mocked(api.loadHistoryConnectionOptions).mockResolvedValue([]);
});
it("ignores a delayed search after deleting an entry", async () => {
const stored = entry();
vi.mocked(api.searchHistory).mockResolvedValueOnce({ entries: [stored], total: 1, next_cursor: null });
const store = useHistoryStore();
await store.search(request);
const delayed = deferred<HistorySearchResult>();
vi.mocked(api.searchHistory).mockReturnValueOnce(delayed.promise);
vi.mocked(api.deleteHistoryEntry).mockResolvedValue(undefined);
const searchPromise = store.search(request);
await store.remove(stored.id);
delayed.resolve({ entries: [stored], total: 1, next_cursor: { executed_at: stored.executed_at, id: stored.id } });
await searchPromise;
expect(store.entries).toEqual([]);
expect(store.total).toBe(0);
expect(store.nextCursor).toBeNull();
});
it("ignores a delayed search after clearing history", async () => {
const stored = entry();
vi.mocked(api.searchHistory).mockResolvedValueOnce({ entries: [stored], total: 1, next_cursor: null });
const store = useHistoryStore();
await store.search(request);
const delayed = deferred<HistorySearchResult>();
vi.mocked(api.searchHistory).mockReturnValueOnce(delayed.promise);
vi.mocked(api.clearHistory).mockResolvedValue(undefined);
const searchPromise = store.search(request);
await store.clear();
delayed.resolve({ entries: [stored], total: 1, next_cursor: { executed_at: stored.executed_at, id: stored.id } });
await searchPromise;
expect(store.entries).toEqual([]);
expect(store.total).toBe(0);
expect(store.nextCursor).toBeNull();
});
it("ignores a search started while deletion is pending", async () => {
const stored = entry();
vi.mocked(api.searchHistory).mockResolvedValueOnce({ entries: [stored], total: 1, next_cursor: null });
const store = useHistoryStore();
await store.search(request);
const deletion = deferred<void>();
const delayedSearch = deferred<HistorySearchResult>();
vi.mocked(api.deleteHistoryEntry).mockReturnValueOnce(deletion.promise);
vi.mocked(api.searchHistory).mockReturnValueOnce(delayedSearch.promise);
const removePromise = store.remove(stored.id);
const searchPromise = store.search(request);
deletion.resolve(undefined);
await removePromise;
delayedSearch.resolve({ entries: [stored], total: 1, next_cursor: { executed_at: stored.executed_at, id: stored.id } });
await searchPromise;
expect(store.entries).toEqual([]);
expect(store.total).toBe(0);
expect(store.nextCursor).toBeNull();
expect(store.loading).toBe(false);
});
it("does not restore connection options returned after clear", async () => {
const delayedOptions = deferred<Awaited<ReturnType<typeof api.loadHistoryConnectionOptions>>>();
vi.mocked(api.loadHistoryConnectionOptions).mockReturnValueOnce(delayedOptions.promise);
vi.mocked(api.clearHistory).mockResolvedValue(undefined);
const store = useHistoryStore();
const optionsPromise = store.loadConnectionOptions();
await store.clear();
delayedOptions.resolve([{ connection_id: "conn-a", connection_name: "Primary", databases: ["sales"] }]);
await optionsPromise;
expect(store.connectionOptions).toEqual([]);
});
it("does not let delayed connection options overwrite a newly added option", async () => {
const delayedOptions = deferred<Awaited<ReturnType<typeof api.loadHistoryConnectionOptions>>>();
vi.mocked(api.loadHistoryConnectionOptions).mockReturnValueOnce(delayedOptions.promise);
vi.mocked(api.saveHistory).mockResolvedValue(undefined);
const store = useHistoryStore();
const optionsPromise = store.loadConnectionOptions();
await store.add(addInput());
delayedOptions.resolve([]);
await optionsPromise;
expect(store.connectionOptions).toEqual([{ connection_id: "conn-a", connection_name: "Primary", databases: ["sales"] }]);
});
it("refreshes an active panel even when its initial search is still pending", async () => {
const initialSearch = deferred<HistorySearchResult>();
const persisted = entry({ id: "persisted-new" });
vi.mocked(api.searchHistory)
.mockReturnValueOnce(initialSearch.promise)
.mockResolvedValueOnce({ entries: [persisted], total: 1, next_cursor: null });
vi.mocked(api.saveHistory).mockResolvedValue(undefined);
const store = useHistoryStore();
store.setHistoryPanelActive(true);
const initialPromise = store.search(request);
await store.add(addInput());
initialSearch.resolve({ entries: [], total: 0, next_cursor: null });
await initialPromise;
expect(store.entries).toEqual([persisted]);
expect(store.total).toBe(1);
});
it("does not refresh after the history panel closes", async () => {
vi.mocked(api.saveHistory).mockResolvedValue(undefined);
const store = useHistoryStore();
store.setHistoryPanelActive(true);
store.setHistoryPanelActive(false);
await store.add(addInput());
expect(api.searchHistory).not.toHaveBeenCalled();
});
it("refreshes from storage after insertion instead of exceeding the persisted limit", async () => {
const stored = entry();
vi.mocked(api.searchHistory)
.mockResolvedValueOnce({ entries: [stored], total: 1000, next_cursor: null })
.mockResolvedValueOnce({ entries: [entry({ id: "persisted-new" })], total: 1000, next_cursor: null });
vi.mocked(api.saveHistory).mockResolvedValue(undefined);
const store = useHistoryStore();
store.setHistoryPanelActive(true);
await store.search(request);
await store.add(addInput(stored));
expect(store.total).toBe(1000);
expect(store.entries[0]?.id).toBe("persisted-new");
});
it("uses SQLite results for non-ASCII text searches", async () => {
const filteredRequest = { ...request, search_text: "Ä" };
vi.mocked(api.searchHistory).mockResolvedValue({ entries: [], total: 0, next_cursor: null });
vi.mocked(api.saveHistory).mockResolvedValue(undefined);
const store = useHistoryStore();
store.setHistoryPanelActive(true);
await store.search(filteredRequest);
const stored = entry({ sql: "select 'ä'" });
await store.add(addInput(stored));
expect(store.entries).toEqual([]);
expect(store.total).toBe(0);
expect(vi.mocked(api.searchHistory).mock.calls.at(-1)?.[0].search_text).toBe("Ä");
});
});
+178 -11
View File
@@ -2,21 +2,147 @@ import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import { ref } from "vue";
import * as api from "@/lib/backend/api";
import type { HistoryEntry } from "@/lib/backend/api";
import type { HistoryConnectionOption, HistoryCursor, HistoryEntry, HistorySearchRequest } from "@/lib/backend/api";
const DEFAULT_HISTORY_SEARCH: HistorySearchRequest = {
search_text: "",
connections: [],
databases: [],
limit: 100,
};
function copyRequest(request: HistorySearchRequest): HistorySearchRequest {
return {
...request,
connections: request.connections.map((connection) => ({ ...connection })),
databases: request.databases.map((database) => ({ ...database })),
cursor: request.cursor ? { ...request.cursor } : undefined,
};
}
export const useHistoryStore = defineStore("history", () => {
const entries = ref<HistoryEntry[]>([]);
const connectionOptions = ref<HistoryConnectionOption[]>([]);
const loading = ref(false);
const loadingMore = ref(false);
const total = ref(0);
const nextCursor = ref<HistoryCursor | null>(null);
const error = ref("");
const activeRequest = ref<HistorySearchRequest>(copyRequest(DEFAULT_HISTORY_SEARCH));
// Ignore stale responses when filters change faster than the backend can respond.
let requestSerial = 0;
let mutationGeneration = 0;
let destructiveMutations = 0;
let historyPanelActive = false;
let optionsRequestSerial = 0;
let latestRequestedSearch = copyRequest(DEFAULT_HISTORY_SEARCH);
function invalidateConnectionOptionRequests() {
optionsRequestSerial += 1;
}
function setHistoryPanelActive(active: boolean) {
historyPanelActive = active;
if (active) return;
requestSerial += 1;
invalidateConnectionOptionRequests();
loading.value = false;
loadingMore.value = false;
}
function beginDestructiveMutation() {
requestSerial += 1;
mutationGeneration += 1;
destructiveMutations += 1;
invalidateConnectionOptionRequests();
loading.value = false;
loadingMore.value = false;
}
function endDestructiveMutation() {
destructiveMutations = Math.max(0, destructiveMutations - 1);
// Invalidate searches started while the backend mutation was still pending.
mutationGeneration += 1;
requestSerial += 1;
loading.value = false;
loadingMore.value = false;
}
async function search(request: HistorySearchRequest, append = false) {
const serial = ++requestSerial;
const generation = mutationGeneration;
const baseRequest = copyRequest({ ...request, cursor: undefined });
if (!append) latestRequestedSearch = copyRequest(baseRequest);
const actualRequest = copyRequest({
...baseRequest,
cursor: append ? (nextCursor.value ?? undefined) : undefined,
});
if (append) loadingMore.value = true;
else loading.value = true;
error.value = "";
try {
const result = await api.searchHistory(actualRequest);
if (serial !== requestSerial || generation !== mutationGeneration || destructiveMutations > 0) return;
activeRequest.value = baseRequest;
if (append) {
// A new entry may shift page boundaries while loading; deduplicate before appending.
const knownIds = new Set(entries.value.map((entry) => entry.id));
entries.value.push(...result.entries.filter((entry) => !knownIds.has(entry.id)));
} else {
entries.value = result.entries;
}
total.value = result.total;
nextCursor.value = result.next_cursor ?? null;
} catch (searchError) {
if (serial !== requestSerial || generation !== mutationGeneration || destructiveMutations > 0) return;
error.value = searchError instanceof Error ? searchError.message : String(searchError);
throw searchError;
} finally {
if (serial === requestSerial) {
loading.value = false;
loadingMore.value = false;
}
}
}
async function load() {
loading.value = true;
await search(DEFAULT_HISTORY_SEARCH);
}
async function loadMore() {
if (!nextCursor.value || loading.value || loadingMore.value) return;
try {
entries.value = await api.loadHistory(200, 0);
} finally {
loading.value = false;
await search(activeRequest.value, true);
} catch {
// The error is already exposed through store.error; prevent an unhandled click promise.
}
}
async function loadConnectionOptions() {
const serial = ++optionsRequestSerial;
try {
const options = await api.loadHistoryConnectionOptions();
if (serial !== optionsRequestSerial) return;
connectionOptions.value = options;
} catch (loadError) {
if (serial !== optionsRequestSerial) return;
throw loadError;
}
}
function addConnectionOption(entry: HistoryEntry) {
const option = connectionOptions.value.find((candidate) => (entry.connection_id ? candidate.connection_id === entry.connection_id : !candidate.connection_id && candidate.connection_name === entry.connection_name));
if (option) {
if (entry.database && !option.databases.includes(entry.database)) option.databases.push(entry.database);
return;
}
connectionOptions.value.unshift({
connection_id: entry.connection_id ?? "",
connection_name: entry.connection_name,
databases: entry.database ? [entry.database] : [],
});
}
async function add(entry: Omit<HistoryEntry, "id" | "executed_at">) {
const full: HistoryEntry = {
...entry,
@@ -24,19 +150,60 @@ export const useHistoryStore = defineStore("history", () => {
executed_at: new Date().toISOString(),
};
await api.saveHistory(full);
entries.value.unshift(full);
if (entries.value.length > 200) entries.value.pop();
invalidateConnectionOptionRequests();
addConnectionOption(full);
if (historyPanelActive) {
try {
// Re-query SQLite so eviction, totals, cursors, and text matching stay authoritative.
await search(latestRequestedSearch);
} catch {
// The history panel exposes refresh failures without failing the completed query execution.
}
}
}
async function remove(id: string) {
await api.deleteHistoryEntry(id);
entries.value = entries.value.filter((e) => e.id !== id);
beginDestructiveMutation();
try {
await api.deleteHistoryEntry(id);
} finally {
endDestructiveMutation();
}
const wasVisible = entries.value.some((entry) => entry.id === id);
entries.value = entries.value.filter((entry) => entry.id !== id);
if (wasVisible) total.value = Math.max(0, total.value - 1);
void loadConnectionOptions().catch(() => {});
}
async function clear() {
await api.clearHistory();
beginDestructiveMutation();
try {
await api.clearHistory();
} finally {
endDestructiveMutation();
}
entries.value = [];
connectionOptions.value = [];
total.value = 0;
nextCursor.value = null;
}
return { entries, loading, load, add, remove, clear };
return {
entries,
connectionOptions,
loading,
loadingMore,
total,
nextCursor,
error,
activeRequest,
search,
load,
loadMore,
loadConnectionOptions,
setHistoryPanelActive,
add,
remove,
clear,
};
});
+58
View File
@@ -27,6 +27,64 @@ pub struct HistoryEntry {
pub details_json: Option<String>,
}
/// Matches current entries by connection ID and legacy entries by connection name.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct HistoryConnectionFilter {
#[serde(default)]
pub connection_id: String,
#[serde(default)]
pub connection_name: String,
}
/// Includes connection identity so same-named databases do not match across connections.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct HistoryDatabaseFilter {
#[serde(default)]
pub connection_id: String,
#[serde(default)]
pub connection_name: String,
#[serde(default)]
pub database: String,
}
/// Mirrors the descending (executed_at, id) order to paginate equal timestamps safely.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HistoryCursor {
pub executed_at: String,
pub id: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HistorySearchRequest {
#[serde(default)]
pub search_text: String,
#[serde(default)]
pub connections: Vec<HistoryConnectionFilter>,
#[serde(default)]
pub databases: Vec<HistoryDatabaseFilter>,
pub activity_kind: Option<String>,
pub success: Option<bool>,
pub started_at: Option<String>,
pub ended_at: Option<String>,
pub cursor: Option<HistoryCursor>,
#[serde(default)]
pub limit: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistorySearchResult {
pub entries: Vec<HistoryEntry>,
pub next_cursor: Option<HistoryCursor>,
pub total: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HistoryConnectionOption {
pub connection_id: String,
pub connection_name: String,
pub databases: Vec<String>,
}
pub const MAX_HISTORY: usize = 1000;
fn default_activity_kind() -> String {
+406 -2
View File
@@ -3,7 +3,7 @@ use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use log::warn;
use rusqlite::{params, params_from_iter, Connection, DatabaseName, OpenFlags, OptionalExtension, ToSql};
use rusqlite::{params, params_from_iter, types::Value, Connection, DatabaseName, OpenFlags, OptionalExtension, ToSql};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -14,7 +14,10 @@ use crate::connection_secrets::{
NACOS_AUTH_SECRET_PREFIX,
};
use crate::db::sqlite::{connect_path_create_if_missing, SqliteHandle};
use crate::history::{HistoryEntry, MAX_HISTORY};
use crate::history::{
HistoryConnectionFilter, HistoryConnectionOption, HistoryCursor, HistoryDatabaseFilter, HistoryEntry,
HistorySearchRequest, HistorySearchResult, MAX_HISTORY,
};
use crate::models::connection::{ConnectionConfig, DatabaseConnectionInfo, DatabaseType, TransportLayerConfig};
use crate::saved_sql::{SavedSqlFile, SavedSqlFolder, SavedSqlLibrary};
@@ -668,6 +671,127 @@ fn delete_secret_prefix_in_tx(
// History
fn history_filter_targets_connection(connection: &HistoryConnectionFilter, database: &HistoryDatabaseFilter) -> bool {
if !connection.connection_id.is_empty() || !database.connection_id.is_empty() {
!connection.connection_id.is_empty() && connection.connection_id == database.connection_id
} else {
!connection.connection_name.is_empty() && connection.connection_name == database.connection_name
}
}
fn append_history_scope_clause(
clauses: &mut Vec<String>,
values: &mut Vec<Value>,
connections: &[HistoryConnectionFilter],
databases: &[HistoryDatabaseFilter],
) {
let mut alternatives = Vec::new();
for connection in connections {
// Database selections narrow only their owning connection; other selected connections remain whole scopes.
if databases
.iter()
.any(|database| !database.database.is_empty() && history_filter_targets_connection(connection, database))
{
continue;
}
if !connection.connection_id.is_empty() {
alternatives.push("connection_id = ?".to_string());
values.push(Value::Text(connection.connection_id.clone()));
} else if !connection.connection_name.is_empty() {
// Legacy JSON entries have no connection ID, so name fallback is limited to empty-ID rows.
alternatives.push("(connection_id = '' AND connection_name = ?)".to_string());
values.push(Value::Text(connection.connection_name.clone()));
}
}
for database in databases.iter().filter(|database| !database.database.is_empty()) {
if !database.connection_id.is_empty() {
alternatives.push("(connection_id = ? AND database = ?)".to_string());
values.push(Value::Text(database.connection_id.clone()));
values.push(Value::Text(database.database.clone()));
} else if !database.connection_name.is_empty() {
alternatives.push("(connection_id = '' AND connection_name = ? AND database = ?)".to_string());
values.push(Value::Text(database.connection_name.clone()));
values.push(Value::Text(database.database.clone()));
}
}
if !alternatives.is_empty() {
clauses.push(format!("({})", alternatives.join(" OR ")));
}
}
fn escape_history_like_pattern(value: &str) -> String {
value.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
}
// Only fixed SQL fragments are assembled dynamically; every filter value remains parameter-bound.
fn history_search_predicate(request: &HistorySearchRequest) -> (String, Vec<Value>) {
let mut clauses = Vec::new();
let mut values = Vec::new();
append_history_scope_clause(&mut clauses, &mut values, &request.connections, &request.databases);
if let Some(kind) = request.activity_kind.as_ref().filter(|kind| !kind.is_empty()) {
clauses.push("activity_kind = ?".to_string());
values.push(Value::Text(kind.clone()));
}
if let Some(success) = request.success {
clauses.push("success = ?".to_string());
values.push(Value::Integer(i64::from(success)));
}
if let Some(started_at) = request.started_at.as_ref().filter(|value| !value.is_empty()) {
clauses.push("julianday(executed_at) >= julianday(?)".to_string());
values.push(Value::Text(started_at.clone()));
}
if let Some(ended_at) = request.ended_at.as_ref().filter(|value| !value.is_empty()) {
clauses.push("julianday(executed_at) <= julianday(?)".to_string());
values.push(Value::Text(ended_at.clone()));
}
let search_text = request.search_text.trim();
if !search_text.is_empty() {
let pattern = format!("%{}%", escape_history_like_pattern(search_text));
let fields = ["sql_text", "connection_name", "database", "operation", "target"];
clauses.push(format!(
"({})",
fields
.iter()
.map(|field| format!("{field} LIKE ? ESCAPE '\\' COLLATE NOCASE"))
.collect::<Vec<_>>()
.join(" OR ")
));
values.extend(fields.iter().map(|_| Value::Text(pattern.clone())));
}
let predicate = if clauses.is_empty() { String::new() } else { format!(" WHERE {}", clauses.join(" AND ")) };
(predicate, values)
}
fn map_history_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<HistoryEntry> {
Ok(HistoryEntry {
id: row.get(0)?,
connection_name: row.get(1)?,
database: row.get(2)?,
sql: row.get(3)?,
executed_at: row.get(4)?,
execution_time_ms: row.get::<_, i64>(5)? as u128,
success: row.get(6)?,
error: row.get(7)?,
activity_kind: {
let value: String = row.get(8)?;
if value.is_empty() {
"query".to_string()
} else {
value
}
},
connection_id: row.get(9)?,
operation: row.get(10)?,
target: row.get(11)?,
affected_rows: row.get(12)?,
rollback_sql: row.get(13)?,
details_json: row.get(14)?,
})
}
impl Storage {
pub async fn save_history_entry(&self, entry: &HistoryEntry) -> Result<(), String> {
let entry = entry.clone();
@@ -767,6 +891,96 @@ impl Storage {
.await
}
pub async fn search_history_entries(&self, request: HistorySearchRequest) -> Result<HistorySearchResult, String> {
self.with_conn(move |conn| {
let (predicate, values) = history_search_predicate(&request);
// Count before applying the cursor so the UI keeps the full filtered total.
let count_sql = format!("SELECT COUNT(*) FROM history{predicate}");
let total = conn
.query_row(&count_sql, params_from_iter(values.iter()), |row| row.get::<_, i64>(0))
.map_err(|error| error.to_string())? as usize;
let mut page_predicate = predicate;
let mut page_values = values;
// Keep this predicate aligned with ORDER BY to avoid skipping equal-timestamp rows.
if let Some(cursor) = &request.cursor {
let cursor_clause = "(executed_at < ? OR (executed_at = ? AND id < ?))";
if page_predicate.is_empty() {
page_predicate = format!(" WHERE {cursor_clause}");
} else {
page_predicate.push_str(" AND ");
page_predicate.push_str(cursor_clause);
}
page_values.push(Value::Text(cursor.executed_at.clone()));
page_values.push(Value::Text(cursor.executed_at.clone()));
page_values.push(Value::Text(cursor.id.clone()));
}
let limit = if request.limit == 0 { 100 } else { request.limit.clamp(1, 200) };
let sql = format!(
"SELECT id, connection_name, database, sql_text, executed_at, execution_time_ms, success, \
error, activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json \
FROM history{page_predicate} ORDER BY executed_at DESC, id DESC LIMIT ?"
);
page_values.push(Value::Integer((limit + 1) as i64));
let mut stmt = conn.prepare(&sql).map_err(|error| error.to_string())?;
let rows = stmt
.query_map(params_from_iter(page_values.iter()), map_history_row)
.map_err(|error| error.to_string())?;
let mut entries = rows.collect::<Result<Vec<_>, _>>().map_err(|error| error.to_string())?;
let has_more = entries.len() > limit;
entries.truncate(limit);
let next_cursor = if has_more {
entries
.last()
.map(|entry| HistoryCursor { executed_at: entry.executed_at.clone(), id: entry.id.clone() })
} else {
None
};
Ok(HistorySearchResult { entries, next_cursor, total })
})
.await
}
pub async fn load_history_connection_options(&self) -> Result<Vec<HistoryConnectionOption>, String> {
self.with_conn(|conn| {
let mut stmt = conn
.prepare(
"SELECT connection_id, connection_name, database \
FROM history ORDER BY executed_at DESC, id DESC",
)
.map_err(|error| error.to_string())?;
let rows = stmt
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)))
.map_err(|error| error.to_string())?;
let mut options = Vec::<HistoryConnectionOption>::new();
let mut indexes = HashMap::<String, usize>::new();
for row in rows {
let (connection_id, connection_name, database) = row.map_err(|error| error.to_string())?;
let key = if connection_id.is_empty() {
format!("legacy:{connection_name}")
} else {
format!("id:{connection_id}")
};
let index = if let Some(index) = indexes.get(&key) {
*index
} else {
let index = options.len();
indexes.insert(key, index);
options.push(HistoryConnectionOption { connection_id, connection_name, databases: Vec::new() });
index
};
if !database.is_empty() && !options[index].databases.contains(&database) {
options[index].databases.push(database);
}
}
Ok(options)
})
.await
}
pub async fn clear_history(&self) -> Result<(), String> {
self.with_conn(|conn| conn.execute("DELETE FROM history", []).map(|_| ()).map_err(|e| e.to_string())).await
}
@@ -3044,6 +3258,7 @@ mod tests {
use crate::connection_secrets::{
MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY,
};
use crate::history::{HistoryConnectionFilter, HistoryDatabaseFilter, HistoryEntry, HistorySearchRequest};
use crate::models::connection::{
ConnectionConfig, DatabaseConnectionInfo, DatabaseType, SshTunnelConfig, TransportLayerConfig,
};
@@ -3061,6 +3276,195 @@ mod tests {
std::env::temp_dir().join(format!("dbx-storage-{name}-{}-{stamp}", std::process::id()))
}
fn history_entry(
id: &str,
connection_id: &str,
connection_name: &str,
database: &str,
sql: &str,
executed_at: &str,
success: bool,
) -> HistoryEntry {
HistoryEntry {
id: id.to_string(),
connection_id: connection_id.to_string(),
connection_name: connection_name.to_string(),
database: database.to_string(),
sql: sql.to_string(),
executed_at: executed_at.to_string(),
execution_time_ms: 10,
success,
error: (!success).then(|| "query failed".to_string()),
activity_kind: "query".to_string(),
operation: "SELECT".to_string(),
target: "orders".to_string(),
affected_rows: None,
rollback_sql: None,
details_json: None,
}
}
#[tokio::test]
async fn history_search_filters_connection_database_and_legacy_entries() {
let path = temp_db_path("history-search-scope");
let storage = Storage::open(&path).await.unwrap();
let entries = [
history_entry("1", "conn-a", "Primary", "sales", "select 1", "2026-07-18T01:00:00Z", true),
history_entry("2", "conn-b", "Replica", "sales", "select 2", "2026-07-18T02:00:00Z", true),
history_entry("3", "", "Legacy", "archive", "select 3", "2026-07-18T03:00:00Z", true),
];
for entry in &entries {
storage.save_history_entry(entry).await.unwrap();
}
let result = storage
.search_history_entries(HistorySearchRequest {
connections: vec![HistoryConnectionFilter {
connection_id: "conn-a".to_string(),
connection_name: "Primary".to_string(),
}],
databases: vec![HistoryDatabaseFilter {
connection_id: "conn-a".to_string(),
connection_name: "Primary".to_string(),
database: "sales".to_string(),
}],
limit: 100,
..Default::default()
})
.await
.unwrap();
assert_eq!(result.entries.iter().map(|entry| entry.id.as_str()).collect::<Vec<_>>(), vec!["1"]);
assert_eq!(result.total, 1);
let legacy = storage
.search_history_entries(HistorySearchRequest {
connections: vec![HistoryConnectionFilter {
connection_id: String::new(),
connection_name: "Legacy".to_string(),
}],
limit: 100,
..Default::default()
})
.await
.unwrap();
assert_eq!(legacy.entries[0].id, "3");
let options = storage.load_history_connection_options().await.unwrap();
assert_eq!(options.len(), 3);
assert!(options.iter().any(|option| option.connection_id == "conn-a" && option.databases == ["sales"]));
assert!(options.iter().any(|option| option.connection_id.is_empty() && option.connection_name == "Legacy"));
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn history_search_combines_whole_connections_with_narrowed_database_scopes() {
let path = temp_db_path("history-search-hierarchical-scope");
let storage = Storage::open(&path).await.unwrap();
let entries = [
history_entry("a-sales", "conn-a", "Primary", "sales", "select 1", "2026-07-18T01:00:00Z", true),
history_entry("a-archive", "conn-a", "Primary", "archive", "select 2", "2026-07-18T02:00:00Z", true),
history_entry("b-sales", "conn-b", "Replica", "sales", "select 3", "2026-07-18T03:00:00Z", true),
history_entry("b-archive", "conn-b", "Replica", "archive", "select 4", "2026-07-18T04:00:00Z", true),
];
for entry in &entries {
storage.save_history_entry(entry).await.unwrap();
}
let result = storage
.search_history_entries(HistorySearchRequest {
connections: vec![
HistoryConnectionFilter {
connection_id: "conn-a".to_string(),
connection_name: "Primary".to_string(),
},
HistoryConnectionFilter {
connection_id: "conn-b".to_string(),
connection_name: "Replica".to_string(),
},
],
databases: vec![HistoryDatabaseFilter {
connection_id: "conn-b".to_string(),
connection_name: "Replica".to_string(),
database: "sales".to_string(),
}],
limit: 100,
..Default::default()
})
.await
.unwrap();
assert_eq!(
result.entries.iter().map(|entry| entry.id.as_str()).collect::<Vec<_>>(),
vec!["b-sales", "a-archive", "a-sales"]
);
assert_eq!(result.total, 3);
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn history_search_combines_text_status_and_time_filters() {
let path = temp_db_path("history-search-fields");
let storage = Storage::open(&path).await.unwrap();
let entries = [
history_entry("1", "conn", "Main", "app", "select 100% from orders", "2026-07-17T23:59:59Z", false),
history_entry("2", "conn", "Main", "app", "select 1000 from orders", "2026-07-18T12:00:00Z", false),
history_entry("3", "conn", "Main", "app", "select 100% from orders", "2026-07-18T12:00:00Z", true),
];
for entry in &entries {
storage.save_history_entry(entry).await.unwrap();
}
let result = storage
.search_history_entries(HistorySearchRequest {
search_text: "100%".to_string(),
success: Some(false),
started_at: Some("2026-07-18T00:00:00Z".to_string()),
ended_at: Some("2026-07-18T23:59:59Z".to_string()),
limit: 100,
..Default::default()
})
.await
.unwrap();
assert!(result.entries.is_empty());
let successful = storage
.search_history_entries(HistorySearchRequest {
search_text: "100%".to_string(),
success: Some(true),
started_at: Some("2026-07-18T00:00:00Z".to_string()),
ended_at: Some("2026-07-18T23:59:59Z".to_string()),
limit: 100,
..Default::default()
})
.await
.unwrap();
assert_eq!(successful.entries[0].id, "3");
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn history_search_cursor_is_stable_for_equal_timestamps() {
let path = temp_db_path("history-search-cursor");
let storage = Storage::open(&path).await.unwrap();
for id in ["a", "b", "c"] {
storage
.save_history_entry(&history_entry(id, "conn", "Main", "app", "select 1", "2026-07-18T12:00:00Z", true))
.await
.unwrap();
}
let first =
storage.search_history_entries(HistorySearchRequest { limit: 2, ..Default::default() }).await.unwrap();
assert_eq!(first.entries.iter().map(|entry| entry.id.as_str()).collect::<Vec<_>>(), vec!["c", "b"]);
let second = storage
.search_history_entries(HistorySearchRequest { cursor: first.next_cursor, limit: 2, ..Default::default() })
.await
.unwrap();
assert_eq!(second.entries.iter().map(|entry| entry.id.as_str()).collect::<Vec<_>>(), vec!["a"]);
assert!(second.next_cursor.is_none());
let _ = std::fs::remove_file(path);
}
fn ssh_profile(id: &str, password: &str) -> TransportLayerConfig {
TransportLayerConfig::Ssh(SshTunnelConfig {
id: id.to_string(),
+2
View File
@@ -486,6 +486,8 @@ async fn main() {
// History
.route("/history", get(routes::history::load_history).delete(routes::history::clear_history))
.route("/history/save", post(routes::history::save_history))
.route("/history/search", post(routes::history::search_history))
.route("/history/options", get(routes::history::load_history_connection_options))
.route("/history/{id}", delete(routes::history::delete_history_entry))
// Saved SQL
.route(
+14 -1
View File
@@ -2,7 +2,7 @@ use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::Json;
use dbx_core::history::HistoryEntry;
use dbx_core::history::{HistoryConnectionOption, HistoryEntry, HistorySearchRequest, HistorySearchResult};
use serde::Deserialize;
use crate::error::AppError;
@@ -39,6 +39,19 @@ pub async fn load_history(
Ok(Json(entries))
}
pub async fn search_history(
State(state): State<Arc<WebState>>,
Json(request): Json<HistorySearchRequest>,
) -> Result<Json<HistorySearchResult>, AppError> {
state.app.storage.search_history_entries(request).await.map(Json).map_err(AppError)
}
pub async fn load_history_connection_options(
State(state): State<Arc<WebState>>,
) -> Result<Json<Vec<HistoryConnectionOption>>, AppError> {
state.app.storage.load_history_connection_options().await.map(Json).map_err(AppError)
}
pub async fn clear_history(State(state): State<Arc<WebState>>) -> Result<Json<()>, AppError> {
state.app.storage.clear_history().await.map_err(AppError)?;
Ok(Json(()))
+22
View File
@@ -0,0 +1,22 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { historyConnectionHasSelectedDatabase } from "../../apps/desktop/src/lib/history/historySearch.ts";
import type { HistoryConnectionFilter, HistoryDatabaseFilter } from "../../apps/desktop/src/lib/backend/tauri.ts";
test("database selections narrow only their owning connection", () => {
const primary: HistoryConnectionFilter = { connection_id: "conn-a", connection_name: "Primary" };
const replica: HistoryConnectionFilter = { connection_id: "conn-b", connection_name: "Replica" };
const databases: HistoryDatabaseFilter[] = [{ connection_id: "conn-b", connection_name: "Replica", database: "sales" }];
assert.equal(historyConnectionHasSelectedDatabase(primary, databases), false);
assert.equal(historyConnectionHasSelectedDatabase(replica, databases), true);
});
test("legacy database selections use names only when both connection IDs are absent", () => {
const legacy: HistoryConnectionFilter = { connection_id: "", connection_name: "Legacy" };
const current: HistoryConnectionFilter = { connection_id: "current", connection_name: "Legacy" };
const databases: HistoryDatabaseFilter[] = [{ connection_id: "", connection_name: "Legacy", database: "archive" }];
assert.equal(historyConnectionHasSelectedDatabase(legacy, databases), true);
assert.equal(historyConnectionHasSelectedDatabase(current, databases), false);
});
+16 -1
View File
@@ -2,7 +2,7 @@ use std::sync::Arc;
use tauri::State;
use super::connection::AppState;
pub use dbx_core::history::HistoryEntry;
pub use dbx_core::history::{HistoryConnectionOption, HistoryEntry, HistorySearchRequest, HistorySearchResult};
#[tauri::command]
pub async fn save_history(state: State<'_, Arc<AppState>>, entry: HistoryEntry) -> Result<(), String> {
@@ -19,6 +19,21 @@ pub async fn load_history(
state.storage.load_history_entries(limit, offset, activity_kind).await
}
#[tauri::command]
pub async fn search_history(
state: State<'_, Arc<AppState>>,
request: HistorySearchRequest,
) -> Result<HistorySearchResult, String> {
state.storage.search_history_entries(request).await
}
#[tauri::command]
pub async fn load_history_connection_options(
state: State<'_, Arc<AppState>>,
) -> Result<Vec<HistoryConnectionOption>, String> {
state.storage.load_history_connection_options().await
}
#[tauri::command]
pub async fn clear_history(state: State<'_, Arc<AppState>>) -> Result<(), String> {
state.storage.clear_history().await
+2
View File
@@ -1467,6 +1467,8 @@ pub fn run() {
commands::mq_cmd::mq_send_message,
commands::history::save_history,
commands::history::load_history,
commands::history::search_history,
commands::history::load_history_connection_options,
commands::history::clear_history,
commands::history::delete_history_entry,
commands::mcp::check_mcp_server_status,