mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(history): add query history filters
This commit is contained in:
@@ -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)">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "クエリ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "查询",
|
||||
|
||||
@@ -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: "查詢",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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("Ä");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user