mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(history): add unified MCP execution history
This commit is contained in:
@@ -402,6 +402,7 @@ const agentDriverUpdateCount = ref(0);
|
||||
const showHistory = ref(false);
|
||||
const showAiPanel = ref(safeLocalStorageGet("dbx-ai-panel-open") === "true");
|
||||
const isAiPanelMaximized = ref(false);
|
||||
const isHistoryPanelMaximized = ref(false);
|
||||
const isZenMode = ref(false);
|
||||
const showSqlLibraryPanel = ref(safeLocalStorageGet("dbx-sql-library-open") === "true");
|
||||
const showSqlFilePanel = ref(safeLocalStorageGet("dbx-sql-file-panel-open") === "true");
|
||||
@@ -1689,6 +1690,7 @@ function applyRightSidebarPanelState(next: RightSidebarPanelState) {
|
||||
}
|
||||
|
||||
function setRightSidebarPanelOpen(panelId: RightSidebarPanelId, open: boolean) {
|
||||
if ((panelId === "history" && !open) || (panelId !== "history" && open)) isHistoryPanelMaximized.value = false;
|
||||
if (panelId === "ai" && !open) {
|
||||
isAiPanelMaximized.value = false;
|
||||
} else if (open && panelId !== "ai" && isAiPanelMaximized.value) {
|
||||
@@ -4192,7 +4194,11 @@ onUnmounted(() => {
|
||||
@mousedown="rememberSidebarSearchSurface"
|
||||
/>
|
||||
|
||||
<div v-show="!isAiPanelMaximized || isZenMode" :class="isDetachedWindowContext ? 'flex-1 min-w-0 overflow-hidden bg-background' : isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'">
|
||||
<div
|
||||
data-editor-content
|
||||
v-show="(!isAiPanelMaximized && !isHistoryPanelMaximized) || isZenMode"
|
||||
:class="isDetachedWindowContext ? 'flex-1 min-w-0 overflow-hidden bg-background' : isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'"
|
||||
>
|
||||
<div class="h-full flex min-h-0 min-w-0 flex-col">
|
||||
<AppTabBar
|
||||
v-if="!isDetachedWindowContext"
|
||||
@@ -4464,7 +4470,7 @@ onUnmounted(() => {
|
||||
|
||||
<div
|
||||
v-if="!isDetachedWindowContext && showAiPanel"
|
||||
v-show="!isZenMode"
|
||||
v-show="!isHistoryPanelMaximized && !isZenMode"
|
||||
:class="[isClassicLayout ? 'h-full relative z-30 isolate bg-background' : 'h-full relative z-30 isolate rounded-md border border-border/80 bg-background', isAiPanelMaximized ? 'min-w-0 flex-1' : 'min-w-[240px] max-w-full']"
|
||||
:style="isAiPanelMaximized ? {} : { width: aiPanelWidth + 'px' }"
|
||||
>
|
||||
@@ -4493,20 +4499,28 @@ onUnmounted(() => {
|
||||
<div
|
||||
v-if="!isDetachedWindowContext && showHistory"
|
||||
v-show="!isAiPanelMaximized && !isZenMode"
|
||||
: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' }"
|
||||
:class="[isClassicLayout ? 'h-full relative z-30 isolate bg-background' : 'h-full relative z-30 isolate rounded-md border border-border/80 bg-background', isHistoryPanelMaximized ? 'min-w-0 flex-1' : 'shrink-0 max-w-full']"
|
||||
:style="isHistoryPanelMaximized ? {} : { width: historyWidth + 'px' }"
|
||||
>
|
||||
<div class="panel-resize-handle panel-resize-handle--left" @pointerdown="startHistoryResize" />
|
||||
<div v-if="!isHistoryPanelMaximized" class="panel-resize-handle panel-resize-handle--left" @pointerdown="startHistoryResize" />
|
||||
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]" @mousedown="rememberAuxiliarySearchSurface('history')">
|
||||
<div data-history-panel class="h-full min-h-0">
|
||||
<QueryHistory :current-connection-id="activeTab?.connectionId" :current-database="activeTab?.database" @restore="restoreHistorySql" @analyze-ai="analyzeHistoryWithAi" @close="closeRightSidebarPanel('history')" />
|
||||
<QueryHistory
|
||||
:maximized="isHistoryPanelMaximized"
|
||||
@toggle-maximize="isHistoryPanelMaximized = !isHistoryPanelMaximized"
|
||||
:current-connection-id="activeTab?.connectionId"
|
||||
:current-database="activeTab?.database"
|
||||
@restore="restoreHistorySql"
|
||||
@analyze-ai="analyzeHistoryWithAi"
|
||||
@close="closeRightSidebarPanel('history')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isDetachedWindowContext && showSqlLibraryPanel"
|
||||
v-show="!isAiPanelMaximized && !isZenMode"
|
||||
v-show="!isAiPanelMaximized && !isHistoryPanelMaximized && !isZenMode"
|
||||
: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' }"
|
||||
>
|
||||
@@ -4520,7 +4534,7 @@ onUnmounted(() => {
|
||||
|
||||
<div
|
||||
v-if="!isDetachedWindowContext && showSqlFilePanel"
|
||||
v-show="!isAiPanelMaximized && !isZenMode"
|
||||
v-show="!isAiPanelMaximized && !isHistoryPanelMaximized && !isZenMode"
|
||||
: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: sqlFilePanelWidth + 'px' }"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { cleanupMcpHistoryRetention, loadMcpHistoryRetentionLimit, saveMcpHistoryRetentionLimit } from "@/lib/backend/api";
|
||||
import { HISTORY_RETENTION_LIMITS, useHistoryRetentionSetting } from "@/composables/useHistoryRetentionSetting";
|
||||
import { ref, watch, shallowRef, computed, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
@@ -305,7 +306,10 @@ const { t, locale } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const settingsStore = useSettingsStore();
|
||||
const historyRetention = useHistoryRetentionSetting();
|
||||
const mcpHistoryRetention = useHistoryRetentionSetting(loadMcpHistoryRetentionLimit, saveMcpHistoryRetentionLimit);
|
||||
const { draft: editHistoryRetentionLimit, loaded: historyRetentionLoaded, loading: historyRetentionLoading, saving: historyRetentionSaving, loadError: historyRetentionLoadError } = historyRetention;
|
||||
const { draft: editMcpHistoryRetentionLimit, loaded: mcpHistoryRetentionLoaded, loading: mcpHistoryRetentionLoading, saving: mcpHistoryRetentionSaving, loadError: mcpHistoryRetentionLoadError } = mcpHistoryRetention;
|
||||
const mcpHistoryCleanupLoading = ref(false);
|
||||
const connectionStore = useConnectionStore();
|
||||
const hasSqlServerConnection = computed(() => connectionStore.connections.some((connection) => effectiveDatabaseTypeForConnection(connection) === "sqlserver"));
|
||||
const savedSqlStore = useSavedSqlStore();
|
||||
@@ -1908,6 +1912,7 @@ watch(
|
||||
if (open) {
|
||||
syncEditorSettingsDraftFromStore();
|
||||
void historyRetention.load();
|
||||
void mcpHistoryRetention.load();
|
||||
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
|
||||
editQuitOnClose.value = settingsStore.desktopSettings.quit_on_close;
|
||||
editIconTheme.value = settingsStore.desktopSettings.icon_theme;
|
||||
@@ -1917,6 +1922,7 @@ watch(
|
||||
editSidebarTablePageSize.value = settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE;
|
||||
} else {
|
||||
historyRetention.discard();
|
||||
mcpHistoryRetention.discard();
|
||||
clearThemePaletteOptionPreview();
|
||||
clearUiFontOptionPreview();
|
||||
restoreLocaleOptionPreview();
|
||||
@@ -2116,11 +2122,25 @@ const queryResultRowLimitDraftTouched = computed(
|
||||
normalizeTableOpenPageSizeDraft(editPageSize.value) !== normalizeTableOpenPageSizeDraft(editEditorSettingsBase.value.pageSize),
|
||||
);
|
||||
const hasBlockingQueryResultRowLimit = computed(() => queryResultRowLimitViolated.value && queryResultRowLimitDraftTouched.value);
|
||||
const hasApplyBlocker = computed(() => historyRetention.invalid.value || historyRetentionSaving.value || hasBlockingShortcutConflicts.value || hasBlockingFormatterConfig.value || hasBlockingQueryResultRowLimit.value);
|
||||
const hasApplyBlocker = computed(() => historyRetention.invalid.value || mcpHistoryRetention.invalid.value || historyRetentionSaving.value || mcpHistoryRetentionSaving.value || hasBlockingShortcutConflicts.value || hasBlockingFormatterConfig.value || hasBlockingQueryResultRowLimit.value);
|
||||
|
||||
async function cleanupMcpHistory() {
|
||||
if (mcpHistoryCleanupLoading.value) return;
|
||||
mcpHistoryCleanupLoading.value = true;
|
||||
try {
|
||||
const deleted = await cleanupMcpHistoryRetention();
|
||||
toast(t("settings.mcpHistoryCleanupCompleted", { count: deleted }));
|
||||
} catch (error) {
|
||||
toast(t("settings.mcpHistoryCleanupFailed", { error: error instanceof Error ? error.message : String(error) }), 5000);
|
||||
} finally {
|
||||
mcpHistoryCleanupLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasChanges(): boolean {
|
||||
return (
|
||||
historyRetention.changed.value ||
|
||||
mcpHistoryRetention.changed.value ||
|
||||
hasImportedSettingsPendingApply.value ||
|
||||
hasEditorDraftChanges.value ||
|
||||
editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon ||
|
||||
@@ -2137,6 +2157,7 @@ function hasChanges(): boolean {
|
||||
async function persistSettings() {
|
||||
if (hasApplyBlocker.value) return;
|
||||
await historyRetention.save();
|
||||
await mcpHistoryRetention.save();
|
||||
const editorSettingsPatch = editorSettingsPatchFromDraft(currentEditorSettingsDraft(), editEditorSettingsBase.value);
|
||||
const sidebarObjectDisplayChanged = editorSettingsPatch.sidebarObjectDisplay !== undefined && editorSettingsPatch.sidebarObjectDisplay !== settingsStore.editorSettings.sidebarObjectDisplay;
|
||||
const sidebarTablePageSizeChanged = editSidebarTablePageSize.value !== (settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE);
|
||||
@@ -2316,6 +2337,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
|
||||
editToolbarItems.value = { ...DEFAULT_EDITOR_SETTINGS.toolbarItems };
|
||||
} else if (tab === "data") {
|
||||
historyRetention.reset();
|
||||
mcpHistoryRetention.reset();
|
||||
editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader;
|
||||
editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader;
|
||||
editShowColumnHeaderTooltips.value = DEFAULT_EDITOR_SETTINGS.showColumnHeaderTooltips;
|
||||
@@ -2377,6 +2399,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
|
||||
|
||||
function resetAllDefaults() {
|
||||
historyRetention.reset();
|
||||
mcpHistoryRetention.reset();
|
||||
// Same contract as resetDefaultsForTab: a full reset also exits any
|
||||
// in-progress shortcut capture (#9066).
|
||||
editingShortcutId.value = null;
|
||||
@@ -7681,11 +7704,13 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Data Tab -->
|
||||
<section v-else-if="activeSettingsTab === 'data'" data-settings-search-id="data" :class="['flex flex-col gap-5 py-2', settingsSearchTargetClass('data')]">
|
||||
<div data-settings-search-id="history-retention" :class="['space-y-2', settingsSearchTargetClass('history-retention')]">
|
||||
<Label for="history-retention-limit">{{ t("settings.historyRetentionLimit") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.historyRetentionDescription") }}</p>
|
||||
<div data-settings-search-id="history-retention" :class="['flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('history-retention')]">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<Label for="history-retention-limit">{{ t("settings.historyRetentionLimit") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.historyRetentionDescription") }}</p>
|
||||
</div>
|
||||
<Select :model-value="String(editHistoryRetentionLimit)" :disabled="!historyRetentionLoaded || historyRetentionSaving" @update:model-value="(value) => (editHistoryRetentionLimit = Number(value))">
|
||||
<SelectTrigger id="history-retention-limit" class="w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger id="history-retention-limit" class="w-40 shrink-0"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="limit in HISTORY_RETENTION_LIMITS" :key="limit" :value="String(limit)">{{ limit === 0 ? t("settings.historyRetentionUnlimited") : String(limit) }}</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -7696,6 +7721,28 @@ onUnmounted(() => {
|
||||
<Button type="button" variant="outline" size="sm" @click="historyRetention.load">{{ t("common.retry") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-settings-search-id="mcp-history-retention" :class="['flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('mcp-history-retention')]">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<Label for="mcp-history-retention-limit">{{ t("settings.mcpHistoryRetentionLimit") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.mcpHistoryRetentionDescription") }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Select :model-value="String(editMcpHistoryRetentionLimit)" :disabled="!mcpHistoryRetentionLoaded || mcpHistoryRetentionSaving" @update:model-value="(value) => (editMcpHistoryRetentionLimit = Number(value))">
|
||||
<SelectTrigger id="mcp-history-retention-limit" class="w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectContent
|
||||
><SelectItem v-for="limit in HISTORY_RETENTION_LIMITS" :key="limit" :value="String(limit)">{{ limit === 0 ? t("settings.historyRetentionUnlimited") : String(limit) }}</SelectItem></SelectContent
|
||||
>
|
||||
</Select>
|
||||
<p v-if="mcpHistoryRetentionLoading" class="text-xs text-muted-foreground">{{ t("common.loading") }}</p>
|
||||
<div v-if="mcpHistoryRetentionLoadError" class="flex items-center gap-2 text-xs text-destructive" role="alert">
|
||||
<span>{{ t("settings.historyRetentionLoadFailed", { error: mcpHistoryRetentionLoadError }) }}</span>
|
||||
<Button type="button" variant="outline" size="sm" @click="mcpHistoryRetention.load">{{ t("common.retry") }}</Button>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" :disabled="mcpHistoryCleanupLoading || !mcpHistoryRetentionLoaded" @click="cleanupMcpHistory">
|
||||
{{ mcpHistoryCleanupLoading ? t("common.loading") : t("settings.mcpHistoryCleanup") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="data-grid-toolbar-layout" data-settings-search-id="data-grid-toolbar-layout" :class="['settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('data-grid-toolbar-layout')]">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<Label for="data-grid-toolbar-layout-select">{{ t("settings.dataGridToolbarLayout") }}</Label>
|
||||
|
||||
@@ -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, Check, Copy, Database, ListFilter, LoaderCircle, Minus, RotateCcw, Search, Sparkles, Trash2, X } from "@lucide/vue";
|
||||
import { CalendarClock, Check, Copy, Database, ListFilter, LoaderCircle, Maximize2, Minimize2, Minus, RefreshCw, 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";
|
||||
@@ -34,18 +34,24 @@ const connectionStore = useConnectionStore();
|
||||
const props = defineProps<{
|
||||
currentConnectionId?: string;
|
||||
currentDatabase?: string;
|
||||
maximized?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
restore: [sql: string, entry: HistoryEntry];
|
||||
analyzeAi: [entry: HistoryEntry];
|
||||
close: [];
|
||||
toggleMaximize: [];
|
||||
}>();
|
||||
|
||||
type HistoryFilter = "all" | "query" | "data_change" | "schema_change" | "failed";
|
||||
type HistoryFilter = "all" | "query" | "data_change" | "schema_change" | "failed" | "mcp_success";
|
||||
type HistorySource = "sql" | "mcp";
|
||||
|
||||
const searchText = ref("");
|
||||
const mcpToolName = ref("");
|
||||
const mcpToolOptions = ref<string[]>([]);
|
||||
const activeFilter = ref<HistoryFilter>("all");
|
||||
const activeSource = ref<HistorySource>("sql");
|
||||
const dateRange = ref<HistoryDateRange>({ startDate: "", endDate: "" });
|
||||
const dateRangeDraft = ref<HistoryDateRange>({ startDate: "", endDate: "" });
|
||||
const dateRangeOpen = ref(false);
|
||||
@@ -71,7 +77,9 @@ 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 filters = computed<HistoryFilter[]>(() => (activeSource.value === "mcp" ? ["all", "failed", "mcp_success"] : ["all", "query", "data_change", "schema_change", "failed"]));
|
||||
const mcpQuickTools = computed(() => mcpToolOptions.value);
|
||||
const canClearHistory = computed(() => store.total > 0);
|
||||
const hasDateFilter = computed(() => hasHistoryDateRange(dateRange.value));
|
||||
const dateRangeDraftValid = computed(() => historyDateRangeIsValid(dateRangeDraft.value));
|
||||
const dateRangeSummary = computed(() => {
|
||||
@@ -95,6 +103,10 @@ function activityKind(entry: HistoryEntry) {
|
||||
return resolveHistoryActivityKind(entry);
|
||||
}
|
||||
|
||||
function isMcpEntry(entry: HistoryEntry) {
|
||||
return entry.source === "mcp" || historyEntrySource(entry) === "MCP";
|
||||
}
|
||||
|
||||
function restore(entry: HistoryEntry) {
|
||||
emit("restore", entry.sql, entry);
|
||||
selectedEntry.value = null;
|
||||
@@ -123,13 +135,13 @@ function executeDelete() {
|
||||
}
|
||||
|
||||
function confirmClearHistory() {
|
||||
if (store.total > 0 || store.connectionOptions.length > 0) {
|
||||
if (canClearHistory.value) {
|
||||
showClearConfirm.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function executeClear() {
|
||||
store.clear();
|
||||
void store.clear(activeSource.value);
|
||||
showClearConfirm.value = false;
|
||||
}
|
||||
|
||||
@@ -149,11 +161,31 @@ function truncateSql(sql: string): string {
|
||||
return line.length > 120 ? line.slice(0, 120) + "..." : line;
|
||||
}
|
||||
|
||||
function formatHistoryJson(value?: string | null): string {
|
||||
if (!value) return "-";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function isLegacyResponse(value?: string | null): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(value || "null");
|
||||
return !!parsed && typeof parsed.content_blocks === "number" && !("content" in parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function entryTitle(entry: HistoryEntry) {
|
||||
if (isMcpEntry(entry)) return entry.mcp_tool_name || entry.operation || "MCP call";
|
||||
return entry.target || entry.operation || truncateSql(entry.sql);
|
||||
}
|
||||
|
||||
function entrySubtitle(entry: HistoryEntry) {
|
||||
if (isMcpEntry(entry)) return truncateSql(entry.mcp_request_json || entry.target || entry.operation || "-");
|
||||
if (activityKind(entry) === "query") return truncateSql(entry.sql);
|
||||
return truncateSql(entry.sql || entry.target || entry.operation || "");
|
||||
}
|
||||
@@ -266,11 +298,13 @@ function buildHistorySearchRequest(): HistorySearchRequest {
|
||||
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,
|
||||
activity_kind: activeFilter.value !== "all" && activeFilter.value !== "failed" && activeFilter.value !== "mcp_success" ? activeFilter.value : undefined,
|
||||
success: activeFilter.value === "failed" ? false : activeFilter.value === "mcp_success" ? true : undefined,
|
||||
started_at: localDateBoundary(dateRange.value.startDate, false),
|
||||
ended_at: localDateBoundary(dateRange.value.endDate, true),
|
||||
limit: 100,
|
||||
source: activeSource.value,
|
||||
mcp_tool_name: activeSource.value === "mcp" ? mcpToolName.value.trim() || undefined : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -282,6 +316,10 @@ async function runHistorySearch() {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshHistory() {
|
||||
void runHistorySearch();
|
||||
}
|
||||
|
||||
// Debounce all filter changes through one timer to avoid redundant history scans.
|
||||
function scheduleHistorySearch() {
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||
@@ -362,6 +400,7 @@ function detailsRows(entry: HistoryEntry) {
|
||||
[t("history.detail.rollback"), canRollbackHistoryEntry(entry) ? t("history.rollbackAvailable") : t("history.rollbackUnavailable")],
|
||||
[t("history.detail.status"), entry.success ? t("history.success") : t("history.failed")],
|
||||
];
|
||||
if (entry.source === "mcp" || historyEntrySource(entry) === "MCP") rows.splice(1, 0, [t("history.detail.mcpTool"), entry.mcp_tool_name || entry.operation || "-"]);
|
||||
if (entry.error) rows.push([t("history.detail.error"), entry.error]);
|
||||
return rows;
|
||||
}
|
||||
@@ -477,6 +516,7 @@ async function closeHistory() {
|
||||
}
|
||||
|
||||
function getHistoryMenuItems(entry: HistoryEntry): ContextMenuItem[] {
|
||||
const isMcp = isMcpEntry(entry);
|
||||
return [
|
||||
{
|
||||
label: t("history.viewDetails"),
|
||||
@@ -484,22 +524,35 @@ function getHistoryMenuItems(entry: HistoryEntry): ContextMenuItem[] {
|
||||
selectedEntry.value = entry;
|
||||
},
|
||||
},
|
||||
{ label: t("history.restore"), action: () => restore(entry) },
|
||||
{ label: t("history.analyzeWithAi"), action: () => emit("analyzeAi", entry), icon: Sparkles },
|
||||
{ label: t("history.copy"), action: () => copyText(entry.sql) },
|
||||
...(canRollbackHistoryEntry(entry) ? [{ label: t("history.rollback"), action: () => rollback(entry) }] : []),
|
||||
...(isMcp
|
||||
? []
|
||||
: [
|
||||
{ label: t("history.restore"), action: () => restore(entry) },
|
||||
{ label: t("history.analyzeWithAi"), action: () => emit("analyzeAi", entry), icon: Sparkles },
|
||||
{ label: t("history.copy"), action: () => copyText(entry.sql) },
|
||||
...(canRollbackHistoryEntry(entry) ? [{ label: t("history.rollback"), action: () => rollback(entry) }] : []),
|
||||
]),
|
||||
{ label: t("history.delete"), action: () => confirmDeleteEntry(entry.id), variant: "destructive" as const },
|
||||
];
|
||||
}
|
||||
|
||||
watch(activeSource, () => {
|
||||
void nextTick(updateFilterScrollability);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => filters.map((filter) => filterLabel(filter)).join("\0"),
|
||||
() => {
|
||||
void nextTick(updateFilterScrollability);
|
||||
() => store.entries,
|
||||
(entries) => {
|
||||
const known = new Set(mcpToolOptions.value);
|
||||
for (const entry of entries) {
|
||||
if (isMcpEntry(entry) && entry.mcp_tool_name) known.add(entry.mcp_tool_name);
|
||||
}
|
||||
mcpToolOptions.value = [...known].sort().slice(0, 50);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch([searchText, activeFilter, () => dateRange.value.startDate, () => dateRange.value.endDate, () => selectedConnections.value.map(connectionKey).join("\0"), () => selectedDatabases.value.map(databaseKey).join("\0")], scheduleHistorySearch);
|
||||
watch([searchText, mcpToolName, activeSource, activeFilter, () => dateRange.value.startDate, () => dateRange.value.endDate, () => selectedConnections.value.map(connectionKey).join("\0"), () => selectedDatabases.value.map(databaseKey).join("\0")], scheduleHistorySearch);
|
||||
|
||||
onMounted(() => {
|
||||
store.setHistoryPanelActive(true);
|
||||
@@ -530,9 +583,16 @@ onBeforeUnmount(() => {
|
||||
<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.total > 0 || store.connectionOptions.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="confirmClearHistory">
|
||||
<Button v-if="canClearHistory" 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" :disabled="store.loading" :title="t('history.refresh')" :aria-label="t('history.refresh')" @click="refreshHistory">
|
||||
<RefreshCw class="h-3 w-3" :class="{ 'animate-spin': store.loading }" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :title="t(maximized ? 'diff.restore' : 'diff.maximize')" :aria-label="t(maximized ? 'diff.restore' : 'diff.maximize')" :aria-pressed="!!maximized" @click="emit('toggleMaximize')">
|
||||
<Minimize2 v-if="maximized" class="h-3 w-3" />
|
||||
<Maximize2 v-else class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :aria-label="t('common.close')" :disabled="resolvingTransaction || (isRollingBack && !!pendingRollback)" @click="closeHistory">
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -545,14 +605,42 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="border-b shrink-0">
|
||||
<div class="flex gap-1 px-2 pt-2">
|
||||
<button
|
||||
v-for="source in ['sql', 'mcp'] as HistorySource[]"
|
||||
:key="source"
|
||||
type="button"
|
||||
class="h-7 rounded border px-3 text-xs"
|
||||
:class="activeSource === source ? 'border-primary bg-primary text-primary-foreground' : 'bg-background'"
|
||||
@click="
|
||||
activeSource = source;
|
||||
activeFilter = 'all';
|
||||
"
|
||||
>
|
||||
{{ t(`history.sources.${source}`) }}
|
||||
</button>
|
||||
</div>
|
||||
<div ref="filterScrollRef" class="history-filter-scroll flex gap-1 px-2 pt-2" :class="{ 'history-filter-scroll--scrollable': filtersScrollable }">
|
||||
<button v-for="filter in filters" :key="filter" type="button" class="h-6 shrink-0 rounded border px-2 text-xs" :class="activeFilter === filter ? 'border-primary bg-primary text-primary-foreground' : 'bg-background'" @click="activeFilter = filter">
|
||||
{{ filterLabel(filter) }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex items-center px-2 py-1">
|
||||
<Search class="absolute left-3 w-3 h-3 text-muted-foreground pointer-events-none" />
|
||||
<input data-history-search v-model="searchText" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 text-xs bg-transparent border rounded pl-5 pr-1 outline-none placeholder:text-muted-foreground" :placeholder="t('history.search')" />
|
||||
<select v-if="activeSource === 'mcp'" data-mcp-tool-search v-model="mcpToolName" class="mr-1 h-5 max-w-52 rounded border bg-background px-1 text-xs outline-none" :title="t('history.mcp.toolSearch')">
|
||||
<option value="">{{ t("history.mcp.allTools") }}</option>
|
||||
<option v-for="tool in mcpQuickTools" :key="tool" :value="tool">{{ tool }}</option>
|
||||
</select>
|
||||
<Search v-if="activeSource !== 'mcp'" class="absolute left-3 w-3 h-3 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
data-history-search
|
||||
v-model="searchText"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="flex-1 h-5 text-xs bg-transparent border rounded pr-1 outline-none placeholder:text-muted-foreground"
|
||||
:class="activeSource === 'mcp' ? 'pl-1' : 'pl-5'"
|
||||
:placeholder="t('history.search')"
|
||||
/>
|
||||
<Popover :open="dateRangeOpen" @update:open="setDateRangeOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
@@ -688,28 +776,30 @@ onBeforeUnmount(() => {
|
||||
<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, isOpen }">
|
||||
<div class="h-[72px] cursor-pointer select-none border-b border-border/50 px-3 py-2 text-xs" :class="isOpen || selectedEntry?.id === entry.id ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40'" @click="selectedEntry = entry" @contextmenu="onContextMenu">
|
||||
<div class="mb-0.5 flex items-center gap-1">
|
||||
<span class="inline-flex h-5 w-9 shrink-0 items-center justify-center rounded border px-1 text-[10px] leading-none text-muted-foreground">
|
||||
{{ kindShortLabel(entry) }}
|
||||
</span>
|
||||
<span class="truncate font-medium">{{ entryTitle(entry) }}</span>
|
||||
<span v-if="historyEntrySource(entry)" class="inline-flex h-5 shrink-0 items-center rounded border border-primary/30 bg-primary/5 px-1 text-[10px] font-medium text-primary">
|
||||
{{ historyEntrySource(entry) }}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 text-muted-foreground">{{ formatTime(entry.executed_at) }}</span>
|
||||
</div>
|
||||
<div class="truncate font-mono text-muted-foreground">{{ entrySubtitle(entry) }}</div>
|
||||
<div class="mt-0.5 flex items-center gap-2">
|
||||
<span class="inline-flex min-w-0 items-center gap-1 text-muted-foreground">
|
||||
<Database class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate">
|
||||
{{ entry.connection_name }}<template v-if="entry.database"> / {{ entry.database }}</template>
|
||||
<div class="h-[80px] cursor-pointer select-none border-b border-border/50 px-2 py-1.5 text-xs" :class="isOpen || selectedEntry?.id === entry.id ? 'bg-accent text-accent-foreground' : ''" @click="selectedEntry = entry" @contextmenu="onContextMenu">
|
||||
<div class="h-full rounded-md border bg-card px-2 py-1.5 shadow-sm transition-colors hover:border-primary/30 hover:bg-accent/40">
|
||||
<div class="mb-0.5 flex items-center gap-1">
|
||||
<span class="inline-flex h-5 w-9 shrink-0 items-center justify-center overflow-hidden rounded border px-1 text-[10px] leading-none text-muted-foreground">
|
||||
{{ isMcpEntry(entry) ? "MCP" : kindShortLabel(entry) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="ml-auto shrink-0" :class="entry.success ? 'text-green-500' : 'text-red-500'">
|
||||
{{ entry.success ? formatQueryDuration(entry.execution_time_ms) : t("history.failed") }}
|
||||
</span>
|
||||
<span class="truncate font-medium">{{ entryTitle(entry) }}</span>
|
||||
<span v-if="historyEntrySource(entry) && !isMcpEntry(entry)" class="inline-flex h-5 shrink-0 items-center rounded border border-primary/30 bg-primary/5 px-1 text-[10px] font-medium text-primary">
|
||||
{{ historyEntrySource(entry) }}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 text-muted-foreground">{{ formatTime(entry.executed_at) }}</span>
|
||||
</div>
|
||||
<div class="truncate font-mono text-muted-foreground">{{ entrySubtitle(entry) }}</div>
|
||||
<div class="mt-0.5 flex items-center gap-2">
|
||||
<span class="inline-flex min-w-0 items-center gap-1 text-muted-foreground">
|
||||
<Database class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate">
|
||||
{{ entry.connection_name || entry.target || t("history.scope.unknownConnection") }}<template v-if="entry.database"> / {{ entry.database }}</template>
|
||||
</span>
|
||||
</span>
|
||||
<span class="ml-auto shrink-0" :class="entry.success ? 'text-green-500' : 'text-red-500'">
|
||||
{{ entry.success ? formatQueryDuration(entry.execution_time_ms) : t("history.failed") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CustomContextMenu>
|
||||
@@ -744,7 +834,24 @@ onBeforeUnmount(() => {
|
||||
<div class="min-w-0 break-words">{{ value }}</div>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="selectedEntry.source === 'mcp' || historyEntrySource(selectedEntry) === 'MCP'" class="space-y-2">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<div class="text-sm font-medium">{{ t("history.mcp.request") }}</div>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('history.copy')" :aria-label="t('history.copy')" @click="copyText(selectedEntry.mcp_request_json || selectedEntry.sql || selectedEntry.details_json || '-')">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre class="max-h-64 overflow-auto rounded border bg-muted/30 p-3 text-xs">{{ formatHistoryJson(selectedEntry.mcp_request_json || selectedEntry.sql || selectedEntry.details_json) }}</pre>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<div class="text-sm font-medium">{{ t("history.mcp.response") }}</div>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('history.copy')" :aria-label="t('history.copy')" @click="copyText(selectedEntry.mcp_response_json || '-')">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="isLegacyResponse(selectedEntry.mcp_response_json)" class="text-xs text-muted-foreground">{{ t("history.mcp.legacyResponse") }}</p>
|
||||
<pre class="max-h-[min(50vh,32rem)] overflow-auto rounded border bg-muted/30 p-3 text-xs">{{ formatHistoryJson(selectedEntry.mcp_response_json) }}</pre>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<div class="text-sm font-medium">SQL</div>
|
||||
<Button variant="ghost" size="sm" class="h-7" @click="copyText(selectedEntry.sql)">
|
||||
@@ -766,11 +873,11 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="selectedEntry && emit('analyzeAi', selectedEntry)">
|
||||
<Button v-if="selectedEntry && selectedEntry.source !== 'mcp' && historyEntrySource(selectedEntry) !== 'MCP'" variant="outline" @click="selectedEntry && emit('analyzeAi', selectedEntry)">
|
||||
<Sparkles class="h-4 w-4" />
|
||||
{{ t("history.analyzeWithAi") }}
|
||||
</Button>
|
||||
<Button variant="outline" @click="selectedEntry && restore(selectedEntry)">{{ t("history.restore") }}</Button>
|
||||
<Button v-if="selectedEntry && selectedEntry.source !== 'mcp' && historyEntrySource(selectedEntry) !== 'MCP'" variant="outline" @click="selectedEntry && restore(selectedEntry)">{{ t("history.restore") }}</Button>
|
||||
<label v-if="selectedEntry && canRollbackHistoryEntry(selectedEntry) && canUseManualRollback" class="flex items-center gap-2 text-xs" :title="t('history.manualRollbackHint')">
|
||||
<input v-model="manualRollback" type="checkbox" :disabled="rollbackLocked" />
|
||||
{{ t("toolbar.manualTransaction") }}
|
||||
@@ -801,7 +908,7 @@ onBeforeUnmount(() => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("history.clear") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground">{{ t("history.confirmClear") }}</p>
|
||||
<p class="text-sm text-muted-foreground">{{ t("history.confirmClearSource", { source: t(`history.sources.${activeSource}`) }) }}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showClearConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button variant="destructive" @click="executeClear">{{ t("dangerDialog.confirm") }}</Button>
|
||||
|
||||
+6
@@ -111,6 +111,12 @@ vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||
hoisted.saves.push(limit);
|
||||
hoisted.limit = limit;
|
||||
},
|
||||
loadMcpHistoryRetentionLimit: async () => hoisted.limit,
|
||||
saveMcpHistoryRetentionLimit: async (limit: number) => {
|
||||
hoisted.saves.push(limit);
|
||||
hoisted.limit = limit;
|
||||
},
|
||||
cleanupMcpHistoryRetention: async () => 0,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+21
-1
@@ -5,6 +5,7 @@ import QueryHistory from "../QueryHistory.vue";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
add: vi.fn(),
|
||||
search: vi.fn(),
|
||||
beginManualTransaction: vi.fn(),
|
||||
executeInManualTransaction: vi.fn(),
|
||||
commitManualTransaction: vi.fn(),
|
||||
@@ -40,7 +41,7 @@ vi.mock("@/stores/historyStore", () => ({
|
||||
nextCursor: null,
|
||||
setHistoryPanelActive: vi.fn(),
|
||||
loadConnectionOptions: vi.fn().mockResolvedValue(undefined),
|
||||
search: vi.fn().mockResolvedValue(undefined),
|
||||
search: mocks.search,
|
||||
add: mocks.add,
|
||||
}),
|
||||
}));
|
||||
@@ -229,3 +230,22 @@ describe("QueryHistory manual rollback SQL", () => {
|
||||
expect(mocks.beginManualTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("QueryHistory source filters", () => {
|
||||
it("searches MCP independently and resets success when switching back to SQL", async () => {
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(QueryHistory);
|
||||
app.mount(root);
|
||||
await nextTick();
|
||||
button("history.sources.mcp").click();
|
||||
await vi.waitFor(() => expect(mocks.search).toHaveBeenLastCalledWith(expect.objectContaining({ source: "mcp", activity_kind: undefined, success: undefined })));
|
||||
expect(document.body.textContent).not.toContain("history.filters.schema_change");
|
||||
button("history.filters.mcp_success").click();
|
||||
await vi.waitFor(() => expect(mocks.search).toHaveBeenLastCalledWith(expect.objectContaining({ source: "mcp", success: true, activity_kind: undefined })));
|
||||
button("history.sources.sql").click();
|
||||
await vi.waitFor(() => expect(mocks.search).toHaveBeenLastCalledWith(expect.objectContaining({ source: "sql", success: undefined, activity_kind: undefined })));
|
||||
expect(document.body.textContent).toContain("history.filters.schema_change");
|
||||
expect(document.body.textContent).not.toContain("history.sources.all");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useHistoryRetentionSetting } from "../useHistoryRetentionSetting";
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
loadHistoryRetentionLimit: vi.fn(),
|
||||
saveHistoryRetentionLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("injected MCP history retention endpoints", () => {
|
||||
it("loads, saves unlimited, and resets through the independent endpoints", async () => {
|
||||
const loadMcp = vi.fn().mockResolvedValue(5000);
|
||||
const saveMcp = vi.fn().mockResolvedValue(undefined);
|
||||
const setting = useHistoryRetentionSetting(loadMcp, saveMcp);
|
||||
await setting.load();
|
||||
expect(loadMcp).toHaveBeenCalledOnce();
|
||||
expect(setting.draft.value).toBe(5000);
|
||||
expect(setting.changed.value).toBe(false);
|
||||
setting.draft.value = 0;
|
||||
await setting.save();
|
||||
expect(saveMcp).toHaveBeenLastCalledWith(0);
|
||||
expect(setting.changed.value).toBe(false);
|
||||
setting.reset();
|
||||
expect(setting.draft.value).toBe(1000);
|
||||
expect(setting.changed.value).toBe(true);
|
||||
await setting.save();
|
||||
expect(saveMcp).toHaveBeenLastCalledWith(1000);
|
||||
});
|
||||
|
||||
it("does not overwrite the server setting after a failed load", async () => {
|
||||
const saveMcp = vi.fn();
|
||||
const setting = useHistoryRetentionSetting(vi.fn().mockRejectedValue(new Error("offline")), saveMcp);
|
||||
await setting.load();
|
||||
expect(setting.loadError.value).toBe("offline");
|
||||
setting.draft.value = 200;
|
||||
await setting.save();
|
||||
expect(saveMcp).not.toHaveBeenCalled();
|
||||
expect(setting.loaded.value).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps failed saves dirty so they can be retried", async () => {
|
||||
const saveMcp = vi.fn().mockRejectedValueOnce(new Error("write failed")).mockResolvedValue(undefined);
|
||||
const setting = useHistoryRetentionSetting(vi.fn().mockResolvedValue(1000), saveMcp);
|
||||
await setting.load();
|
||||
setting.draft.value = 200;
|
||||
await expect(setting.save()).rejects.toThrow("write failed");
|
||||
expect(setting.saving.value).toBe(false);
|
||||
expect(setting.changed.value).toBe(true);
|
||||
await setting.save();
|
||||
expect(setting.changed.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,41 @@ describe("usePanelResize", () => {
|
||||
expect(localStorage.getItem("dbx-ai-panel-width")).toBe("1060");
|
||||
});
|
||||
|
||||
it.each([0, 360])("lets history use the editor width with a %ipx AI sibling", (aiWidth) => {
|
||||
const editor = document.createElement("div");
|
||||
editor.setAttribute("data-editor-content", "");
|
||||
const ai = document.createElement("div");
|
||||
const panel = document.createElement("div");
|
||||
const handle = document.createElement("div");
|
||||
panel.append(handle);
|
||||
document.body.append(editor, ai, panel);
|
||||
vi.spyOn(editor, "getBoundingClientRect").mockReturnValue(rect(300, 900));
|
||||
vi.spyOn(ai, "getBoundingClientRect").mockReturnValue(rect(1200, aiWidth));
|
||||
vi.spyOn(panel, "getBoundingClientRect").mockReturnValue(rect(1200 + aiWidth, 288));
|
||||
|
||||
const { historyWidth, startHistoryResize } = usePanelResize();
|
||||
handle.addEventListener("pointerdown", startHistoryResize);
|
||||
handle.dispatchEvent(pointerEvent("pointerdown", 1200 + aiWidth));
|
||||
document.dispatchEvent(pointerEvent("pointermove", -1000));
|
||||
expect(panel.style.width).toBe("1188px");
|
||||
document.dispatchEvent(pointerEvent("pointerup", -1000));
|
||||
expect(historyWidth.value).toBe(1188);
|
||||
expect(localStorage.getItem("dbx-history-width")).toBeNull();
|
||||
|
||||
vi.spyOn(panel, "getBoundingClientRect").mockReturnValue(rect(300 + aiWidth, 1188));
|
||||
vi.spyOn(editor, "getBoundingClientRect").mockReturnValue(rect(300, 0));
|
||||
handle.dispatchEvent(pointerEvent("pointerdown", 300 + aiWidth));
|
||||
document.dispatchEvent(pointerEvent("pointermove", 500 + aiWidth));
|
||||
document.dispatchEvent(pointerEvent("pointerup", 500 + aiWidth));
|
||||
expect(historyWidth.value).toBe(988);
|
||||
});
|
||||
|
||||
it("starts at 60% on every application launch even when a legacy width is stored", () => {
|
||||
localStorage.setItem("dbx-history-width", String(window.innerWidth));
|
||||
const { historyWidth } = usePanelResize();
|
||||
expect(historyWidth.value).toBe(Math.round(window.innerWidth * 0.6));
|
||||
});
|
||||
|
||||
it("resizes from the flex-shrunk width after a wide panel is restored in a narrow window", () => {
|
||||
localStorage.setItem("dbx-ai-panel-width", "1060");
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export function isHistoryRetentionLimit(value: unknown): value is number {
|
||||
}
|
||||
|
||||
/** A server-owned setting: browser localStorage cannot configure MCP retention. */
|
||||
export function useHistoryRetentionSetting() {
|
||||
export function useHistoryRetentionSetting(loadLimit: () => Promise<number> = loadHistoryRetentionLimit, saveLimit: (limit: number) => Promise<void> = saveHistoryRetentionLimit) {
|
||||
const draft = ref(DEFAULT_HISTORY_RETENTION_LIMIT);
|
||||
const persisted = ref(DEFAULT_HISTORY_RETENTION_LIMIT);
|
||||
const loaded = ref(false);
|
||||
@@ -26,7 +26,7 @@ export function useHistoryRetentionSetting() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
const value = await loadHistoryRetentionLimit();
|
||||
const value = await loadLimit();
|
||||
if (!isHistoryRetentionLimit(value)) throw new Error("Invalid query history retention limit");
|
||||
if (request !== generation) return;
|
||||
draft.value = persisted.value = value;
|
||||
@@ -44,7 +44,7 @@ export function useHistoryRetentionSetting() {
|
||||
const value = draft.value;
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveHistoryRetentionLimit(value);
|
||||
await saveLimit(value);
|
||||
persisted.value = value;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
|
||||
@@ -14,6 +14,11 @@ function restoredPanelWidth(storageKey: string, fallback: number): number {
|
||||
return Math.max(PANEL_MIN_WIDTH, Number(safeLocalStorageGet(storageKey)) || fallback);
|
||||
}
|
||||
|
||||
function restoredHistoryPanelWidth(): number {
|
||||
const fallback = Math.round(window.innerWidth * 0.6);
|
||||
return Math.max(PANEL_MIN_WIDTH, fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the layout-owning panel root over the handle's immediate parent.
|
||||
* Vertical tab handles live inside a nested `.app-tab-bar`, while width is
|
||||
@@ -41,10 +46,21 @@ function availableAiPanelMaxWidth(handle: HTMLElement | null) {
|
||||
return Math.max(PANEL_MIN_WIDTH, panelRect.width + panelRect.left - contentRect.left);
|
||||
}
|
||||
|
||||
// History may have another auxiliary panel (including a hidden AI panel)
|
||||
// before it. Only borrow space from the editor, never from that sibling.
|
||||
function availableHistoryPanelMaxWidth(handle: HTMLElement | null) {
|
||||
const panel = resolveResizePanel(handle);
|
||||
const editor = panel?.parentElement?.querySelector<HTMLElement>(":scope > [data-editor-content]");
|
||||
if (!panel || !editor) return availableAiPanelMaxWidth(handle);
|
||||
return Math.max(PANEL_MIN_WIDTH, panel.getBoundingClientRect().width + editor.getBoundingClientRect().width);
|
||||
}
|
||||
|
||||
export function usePanelResize() {
|
||||
const sidebarWidth = ref(restoredPanelWidth("dbx-sidebar-width", 260));
|
||||
const aiPanelWidth = ref(restoredPanelWidth("dbx-ai-panel-width", 360));
|
||||
const historyWidth = ref(restoredPanelWidth("dbx-history-width", 288));
|
||||
// History opens at a comfortable content-area width; users can still resize
|
||||
// it or switch to the explicit full-width mode from its toolbar.
|
||||
const historyWidth = ref(restoredHistoryPanelWidth());
|
||||
const sqlLibraryWidth = ref(restoredPanelWidth("dbx-sql-library-width", 288));
|
||||
const sqlFilePanelWidth = ref(restoredPanelWidth("dbx-sql-file-panel-width", 288));
|
||||
const tabBarWidth = ref(restoredPanelWidth("dbx-tab-bar-width", 240));
|
||||
@@ -108,7 +124,9 @@ export function usePanelResize() {
|
||||
endPanelResize();
|
||||
resizeOverlay.remove();
|
||||
widthRef.value = currentWidth;
|
||||
safeLocalStorageSet(storageKey, String(widthRef.value));
|
||||
// History deliberately starts at 60% on every application launch;
|
||||
// resizing remains available for the current session only.
|
||||
if (storageKey !== "dbx-history-width") safeLocalStorageSet(storageKey, String(widthRef.value));
|
||||
};
|
||||
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
@@ -120,7 +138,7 @@ export function usePanelResize() {
|
||||
|
||||
const startSidebarResize = startPanelResize(sidebarWidth, "dbx-sidebar-width", "right");
|
||||
const startAiPanelResize = startPanelResize(aiPanelWidth, "dbx-ai-panel-width", "left", availableAiPanelMaxWidth);
|
||||
const startHistoryResize = startPanelResize(historyWidth, "dbx-history-width", "left");
|
||||
const startHistoryResize = startPanelResize(historyWidth, "dbx-history-width", "left", availableHistoryPanelMaxWidth);
|
||||
const startSqlLibraryResize = startPanelResize(sqlLibraryWidth, "dbx-sql-library-width", "left");
|
||||
const startSqlFilePanelResize = startPanelResize(sqlFilePanelWidth, "dbx-sql-file-panel-width", "left");
|
||||
const startLeftTabBarResize = startPanelResize(tabBarWidth, "dbx-tab-bar-width", "right");
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Non-SQL MCP history entries persist activity_kind "mcp" and the details
|
||||
// dialog resolves the label through `history.kinds.${kind}`. Assert every
|
||||
// locale that carries the history kinds cluster declares the mcp entry:
|
||||
// imported modules are deep-merged with English via withEnglishFallback, so a
|
||||
// dropped key would be invisible post-merge.
|
||||
|
||||
function localeSource(name: string): string {
|
||||
return readFileSync(new URL(`../locales/${name}.ts`, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
function historyKindsCluster(source: string): string | undefined {
|
||||
return (source.match(/kinds: \{[^}]*\}/g) ?? []).find((cluster) => cluster.includes("redis_command:"));
|
||||
}
|
||||
|
||||
const LOCALES = ["en", "zh-CN", "zh-TW", "ja", "ko", "es", "it", "pt-BR", "ru", "az", "tr"] as const;
|
||||
|
||||
describe("history kinds mcp label parity", () => {
|
||||
it.each(LOCALES)("%s: history.kinds declares mcp", (name) => {
|
||||
const cluster = historyKindsCluster(localeSource(name));
|
||||
expect(cluster, `${name}: history kinds cluster not found`).toBeDefined();
|
||||
expect(cluster, `${name}: history.kinds.mcp is missing`).toMatch(/(^|[^A-Za-z_])mcp:\s*"/);
|
||||
});
|
||||
});
|
||||
@@ -5842,6 +5842,15 @@ export default withEnglishFallback({
|
||||
},
|
||||
history: {
|
||||
title: "Tarixçə",
|
||||
sources: { all: "Hamısı", sql: "SQL sorğuları", mcp: "MCP çağırışları" },
|
||||
refresh: "Yenilə",
|
||||
mcp: {
|
||||
legacyResponse: "Bu köhnə qeyd yalnız xülasə saxlayır. İlkin cavabı bərpa etmək mümkün deyil. Yenilənmiş MCP xidməti ilə yeni çağırışlar cavab məzmununu saxlayır.",
|
||||
request: "Sorğu parametrləri",
|
||||
response: "Cavab məzmunu",
|
||||
toolSearch: "MCP alət adına görə süzgəcdən keçir...",
|
||||
allTools: "Bütün alətlər",
|
||||
},
|
||||
search: "Tarixçədə axtar...",
|
||||
conversationSearch: "Söhbətləri axtar...",
|
||||
empty: "Hələ tarixçə yoxdur",
|
||||
@@ -5865,6 +5874,7 @@ export default withEnglishFallback({
|
||||
rollbackSuccess: "Geri qaytarma icra edildi",
|
||||
rollbackFailed: "Geri qaytarma alınmadı: {message}",
|
||||
confirmDelete: "Bu tarixçə qeydi silinsin?",
|
||||
confirmClearSource: "{source} tarixçəsinin hamısını təmizləyəsiniz?",
|
||||
confirmClear: "Bütün tarixçə təmizlənsin?",
|
||||
emptyFiltered: "Cari süzgəclərə uyğun tarixçə yoxdur",
|
||||
loadMore: "Daha çox yüklə",
|
||||
@@ -5886,6 +5896,7 @@ export default withEnglishFallback({
|
||||
query: "Sorğular",
|
||||
data_change: "Məlumat dəyişiklikləri",
|
||||
schema_change: "Sxem dəyişiklikləri",
|
||||
mcp_success: "Uğurlu",
|
||||
failed: "Uğursuz oldu",
|
||||
},
|
||||
dateRange: {
|
||||
@@ -5906,6 +5917,7 @@ export default withEnglishFallback({
|
||||
import: "İdxal",
|
||||
transfer: "Köçürmə",
|
||||
redis_command: "Redis əmri",
|
||||
mcp: "MCP çağırışı",
|
||||
},
|
||||
kindShort: {
|
||||
query: "SQL",
|
||||
@@ -5916,6 +5928,7 @@ export default withEnglishFallback({
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
mcpTool: "MCP aləti",
|
||||
kind: "Növ",
|
||||
operation: "Əməliyyat",
|
||||
connection: "Əlaqə",
|
||||
@@ -7001,6 +7014,11 @@ export default withEnglishFallback({
|
||||
},
|
||||
settings: {
|
||||
historyRetentionLimit: "Sorğu tarixçəsinin saxlanma həddi",
|
||||
mcpHistoryRetentionLimit: "MCP tarixçəsinin saxlanma həddi",
|
||||
mcpHistoryRetentionDescription: "MCP çağırış tarixçəsinin sayını SQL tarixçəsindən müstəqil idarə edir.",
|
||||
mcpHistoryCleanup: "MCP tarixçəsini indi təmizlə",
|
||||
mcpHistoryCleanupCompleted: "{count} MCP tarixçə qeydi silindi.",
|
||||
mcpHistoryCleanupFailed: "MCP tarixçəsi təmizlənə bilmədi: {error}",
|
||||
historyRetentionDescription: "Hədd azaldıldıqda köhnə qeydlər növbəti sorğu tarixçəsi yazıldıqda silinir. Həddi artırmaq və ya Limitsiz seçmək silinmiş qeydləri bərpa etmir. Bütün bağlantılara və icra mənbələrinə tətbiq olunur.",
|
||||
historyRetentionUnlimited: "Limitsiz",
|
||||
historyRetentionLoadFailed: "Tarixçənin saxlanma ayarı yüklənmədi: {error}",
|
||||
|
||||
@@ -6434,6 +6434,15 @@ export default {
|
||||
transactionEnded: "The transaction has ended or was rolled back. No rollback success was recorded.",
|
||||
rollbackBeforeClose: "Discard the uncommitted rollback changes and close history?",
|
||||
title: "History",
|
||||
sources: { all: "All", sql: "SQL Queries", mcp: "MCP Calls" },
|
||||
refresh: "Refresh",
|
||||
mcp: {
|
||||
legacyResponse: "This older record contains only a summary. The original response cannot be recovered. Calls made with the updated MCP service will record response content.",
|
||||
request: "Request parameters",
|
||||
response: "Response content",
|
||||
toolSearch: "Filter by MCP tool name...",
|
||||
allTools: "All tools",
|
||||
},
|
||||
search: "Search history...",
|
||||
conversationSearch: "Search conversations...",
|
||||
empty: "No history yet",
|
||||
@@ -6458,6 +6467,7 @@ export default {
|
||||
rollbackFailed: "Rollback failed: {message}",
|
||||
confirmDelete: "Delete this history entry?",
|
||||
confirmClear: "Clear all history?",
|
||||
confirmClearSource: "Clear all {source} history?",
|
||||
emptyFiltered: "No history matches the current filters",
|
||||
loadMore: "Load more",
|
||||
scope: {
|
||||
@@ -6479,6 +6489,7 @@ export default {
|
||||
data_change: "Data changes",
|
||||
schema_change: "Schema changes",
|
||||
failed: "Failed",
|
||||
mcp_success: "Succeeded",
|
||||
},
|
||||
dateRange: {
|
||||
title: "Time range",
|
||||
@@ -6498,6 +6509,7 @@ export default {
|
||||
import: "Import",
|
||||
transfer: "Transfer",
|
||||
redis_command: "Redis Command",
|
||||
mcp: "MCP call",
|
||||
},
|
||||
kindShort: {
|
||||
query: "SQL",
|
||||
@@ -6508,6 +6520,7 @@ export default {
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
mcpTool: "MCP tool",
|
||||
kind: "Type",
|
||||
operation: "Operation",
|
||||
connection: "Connection",
|
||||
@@ -7649,6 +7662,11 @@ export default {
|
||||
},
|
||||
settings: {
|
||||
historyRetentionLimit: "Query history retention",
|
||||
mcpHistoryRetentionLimit: "MCP history retention",
|
||||
mcpHistoryRetentionDescription: "Controls MCP call history independently from SQL history.",
|
||||
mcpHistoryCleanup: "Clean up MCP history now",
|
||||
mcpHistoryCleanupCompleted: "Removed {count} MCP history records.",
|
||||
mcpHistoryCleanupFailed: "Could not clean up MCP history: {error}",
|
||||
historyRetentionDescription: "Lowering the limit removes older records the next time query history is recorded. Increasing the limit or choosing Unlimited does not restore deleted records. The limit applies to all connections and execution sources.",
|
||||
historyRetentionUnlimited: "Unlimited",
|
||||
historyRetentionLoadFailed: "Could not load history retention: {error}",
|
||||
|
||||
@@ -5960,6 +5960,15 @@ export default withEnglishFallback({
|
||||
transactionEnded: "La transacción terminó o se revirtió. No se registró una reversión correcta.",
|
||||
rollbackBeforeClose: "¿Descartar los cambios de reversión sin confirmar y cerrar el historial?",
|
||||
title: "Historial",
|
||||
sources: { all: "Todo", sql: "Consultas SQL", mcp: "Llamadas MCP" },
|
||||
refresh: "Actualizar",
|
||||
mcp: {
|
||||
legacyResponse: "Este registro antiguo solo contiene un resumen. No se puede recuperar la respuesta original. Las nuevas llamadas MCP guardan el contenido de la respuesta.",
|
||||
request: "Parámetros de solicitud",
|
||||
response: "Contenido de respuesta",
|
||||
toolSearch: "Filtrar por nombre de herramienta MCP...",
|
||||
allTools: "Todas las herramientas",
|
||||
},
|
||||
search: "Buscar en historial...",
|
||||
conversationSearch: "Buscar conversaciones...",
|
||||
empty: "Sin historial aún",
|
||||
@@ -5983,6 +5992,7 @@ export default withEnglishFallback({
|
||||
rollbackSuccess: "Reversión ejecutada",
|
||||
rollbackFailed: "Error al revertir: {message}",
|
||||
confirmDelete: "¿Eliminar esta entrada del historial?",
|
||||
confirmClearSource: "¿Borrar todo el historial de {source}?",
|
||||
confirmClear: "¿Limpiar todo el historial?",
|
||||
emptyFiltered: "Ningún elemento del historial coincide con los filtros actuales",
|
||||
loadMore: "Cargar más",
|
||||
@@ -6004,6 +6014,7 @@ export default withEnglishFallback({
|
||||
query: "Consultas",
|
||||
data_change: "Cambios de datos",
|
||||
schema_change: "Cambios de esquema",
|
||||
mcp_success: "Correcto",
|
||||
failed: "Fallidas",
|
||||
},
|
||||
dateRange: {
|
||||
@@ -6024,6 +6035,7 @@ export default withEnglishFallback({
|
||||
import: "Importación",
|
||||
transfer: "Transferencia",
|
||||
redis_command: "Comando Redis",
|
||||
mcp: "Llamada MCP",
|
||||
},
|
||||
kindShort: {
|
||||
query: "SQL",
|
||||
@@ -6034,6 +6046,7 @@ export default withEnglishFallback({
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
mcpTool: "Herramienta MCP",
|
||||
kind: "Tipo",
|
||||
operation: "Operación",
|
||||
connection: "Conexión",
|
||||
@@ -7034,6 +7047,11 @@ export default withEnglishFallback({
|
||||
},
|
||||
settings: {
|
||||
historyRetentionLimit: "Límite del historial de consultas",
|
||||
mcpHistoryRetentionLimit: "Retención del historial MCP",
|
||||
mcpHistoryRetentionDescription: "Controla por separado la cantidad de historial de llamadas MCP del historial SQL.",
|
||||
mcpHistoryCleanup: "Limpiar el historial MCP ahora",
|
||||
mcpHistoryCleanupCompleted: "Se eliminaron {count} registros del historial MCP.",
|
||||
mcpHistoryCleanupFailed: "No se pudo limpiar el historial MCP: {error}",
|
||||
historyRetentionDescription: "Al reducir el límite, los registros antiguos se eliminarán la próxima vez que se guarde una consulta. Aumentarlo o elegir Sin límite no recupera registros eliminados. Se aplica a todas las conexiones y fuentes de ejecución.",
|
||||
historyRetentionUnlimited: "Sin límite",
|
||||
historyRetentionLoadFailed: "No se pudo cargar el límite del historial: {error}",
|
||||
|
||||
@@ -5883,6 +5883,15 @@ export default withEnglishFallback({
|
||||
transactionEnded: "La transazione è terminata o è stata annullata. Nessun ripristino riuscito è stato registrato.",
|
||||
rollbackBeforeClose: "Scartare le modifiche di ripristino non confermate e chiudere la cronologia?",
|
||||
title: "Cronologia",
|
||||
sources: { all: "Tutto", sql: "Query SQL", mcp: "Chiamate MCP" },
|
||||
refresh: "Aggiorna",
|
||||
mcp: {
|
||||
legacyResponse: "Questo record precedente contiene solo un riepilogo. La risposta originale non può essere recuperata. Le nuove chiamate MCP salvano il contenuto della risposta.",
|
||||
request: "Parametri della richiesta",
|
||||
response: "Contenuto della risposta",
|
||||
toolSearch: "Filtra per nome strumento MCP...",
|
||||
allTools: "Tutti gli strumenti",
|
||||
},
|
||||
search: "Cerca nella cronologia...",
|
||||
conversationSearch: "Cerca conversazioni...",
|
||||
empty: "Ancora nessuna cronologia",
|
||||
@@ -5906,6 +5915,7 @@ export default withEnglishFallback({
|
||||
rollbackSuccess: "Rollback eseguito",
|
||||
rollbackFailed: "Rollback non riuscito: {message}",
|
||||
confirmDelete: "Eliminare questa voce della cronologia?",
|
||||
confirmClearSource: "Cancellare tutta la cronologia {source}?",
|
||||
confirmClear: "Cancellare tutta la cronologia?",
|
||||
emptyFiltered: "Nessuna voce della cronologia corrisponde ai filtri attuali",
|
||||
loadMore: "Carica altro",
|
||||
@@ -5927,6 +5937,7 @@ export default withEnglishFallback({
|
||||
query: "Query",
|
||||
data_change: "Modifiche dati",
|
||||
schema_change: "Modifiche schema",
|
||||
mcp_success: "Riuscito",
|
||||
failed: "Non riuscite",
|
||||
},
|
||||
dateRange: {
|
||||
@@ -5947,6 +5958,7 @@ export default withEnglishFallback({
|
||||
import: "Importazione",
|
||||
transfer: "Trasferimento",
|
||||
redis_command: "Comando Redis",
|
||||
mcp: "Chiamata MCP",
|
||||
},
|
||||
kindShort: {
|
||||
query: "SQL",
|
||||
@@ -5957,6 +5969,7 @@ export default withEnglishFallback({
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
mcpTool: "Strumento MCP",
|
||||
kind: "Tipo",
|
||||
operation: "Operazione",
|
||||
connection: "Connessione",
|
||||
@@ -6959,6 +6972,11 @@ export default withEnglishFallback({
|
||||
},
|
||||
settings: {
|
||||
historyRetentionLimit: "Limite della cronologia query",
|
||||
mcpHistoryRetentionLimit: "Conservazione cronologia MCP",
|
||||
mcpHistoryRetentionDescription: "Controlla separatamente la quantità di cronologia delle chiamate MCP da quella SQL.",
|
||||
mcpHistoryCleanup: "Pulisci ora la cronologia MCP",
|
||||
mcpHistoryCleanupCompleted: "Rimossi {count} record dalla cronologia MCP.",
|
||||
mcpHistoryCleanupFailed: "Impossibile pulire la cronologia MCP: {error}",
|
||||
historyRetentionDescription: "Riducendo il limite, i record più vecchi verranno eliminati al prossimo salvataggio nella cronologia. Aumentare il limite o scegliere Illimitato non ripristina i record eliminati. Si applica a tutte le connessioni e origini di esecuzione.",
|
||||
historyRetentionUnlimited: "Illimitato",
|
||||
historyRetentionLoadFailed: "Impossibile caricare il limite della cronologia: {error}",
|
||||
|
||||
@@ -5910,6 +5910,9 @@ export default withEnglishFallback({
|
||||
transactionEnded: "トランザクションは終了またはロールバックされました。ロールバック成功は記録されていません。",
|
||||
rollbackBeforeClose: "未コミットのロールバック変更を破棄して履歴を閉じますか?",
|
||||
title: "履歴",
|
||||
sources: { all: "すべて", sql: "SQL クエリ", mcp: "MCP 呼び出し" },
|
||||
refresh: "更新",
|
||||
mcp: { legacyResponse: "この旧記録には概要のみが含まれます。元の応答は復元できません。更新後の MCP サービスでは応答内容が保存されます。", request: "リクエストパラメーター", response: "応答内容", toolSearch: "MCP ツール名で絞り込み...", allTools: "すべてのツール" },
|
||||
search: "履歴を検索...",
|
||||
conversationSearch: "会話を検索...",
|
||||
empty: "まだ履歴がありません",
|
||||
@@ -5933,6 +5936,7 @@ export default withEnglishFallback({
|
||||
rollbackSuccess: "ロールバックを実行しました",
|
||||
rollbackFailed: "ロールバックに失敗しました: {message}",
|
||||
confirmDelete: "この履歴エントリを削除しますか?",
|
||||
confirmClearSource: "{source} の履歴をすべて消去しますか?",
|
||||
confirmClear: "すべての履歴をクリアしますか?",
|
||||
emptyFiltered: "現在のフィルターに一致する履歴はありません",
|
||||
loadMore: "さらに読み込む",
|
||||
@@ -5954,6 +5958,7 @@ export default withEnglishFallback({
|
||||
query: "クエリ",
|
||||
data_change: "データ変更",
|
||||
schema_change: "スキーマ変更",
|
||||
mcp_success: "成功",
|
||||
failed: "失敗",
|
||||
},
|
||||
dateRange: {
|
||||
@@ -5974,6 +5979,7 @@ export default withEnglishFallback({
|
||||
import: "インポート",
|
||||
transfer: "転送",
|
||||
redis_command: "Redisコマンド",
|
||||
mcp: "MCP 呼び出し",
|
||||
},
|
||||
kindShort: {
|
||||
query: "SQL",
|
||||
@@ -5984,6 +5990,7 @@ export default withEnglishFallback({
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
mcpTool: "MCP ツール",
|
||||
kind: "タイプ",
|
||||
operation: "操作",
|
||||
connection: "接続",
|
||||
@@ -6995,6 +7002,11 @@ export default withEnglishFallback({
|
||||
},
|
||||
settings: {
|
||||
historyRetentionLimit: "クエリ履歴の保持件数",
|
||||
mcpHistoryRetentionLimit: "MCP 履歴の保持数",
|
||||
mcpHistoryRetentionDescription: "SQL 履歴とは別に MCP 呼び出し履歴の保持数を管理します。",
|
||||
mcpHistoryCleanup: "MCP 履歴を今すぐ消去",
|
||||
mcpHistoryCleanupCompleted: "MCP 履歴を {count} 件削除しました。",
|
||||
mcpHistoryCleanupFailed: "MCP 履歴を消去できませんでした: {error}",
|
||||
historyRetentionDescription: "上限を減らすと、次回のクエリ履歴の記録時に古い履歴が削除されます。上限の増加や「無制限」の選択で削除済みの履歴は復元されません。すべての接続と実行元に適用されます。",
|
||||
historyRetentionUnlimited: "無制限",
|
||||
historyRetentionLoadFailed: "履歴の保持設定を読み込めませんでした: {error}",
|
||||
|
||||
@@ -5541,6 +5541,9 @@ export default withEnglishFallback({
|
||||
transactionEnded: "트랜잭션이 종료되었거나 롤백되었습니다. 롤백 성공은 기록되지 않았습니다.",
|
||||
rollbackBeforeClose: "커밋되지 않은 롤백 변경 사항을 취소하고 기록을 닫을까요?",
|
||||
title: "기록",
|
||||
sources: { all: "전체", sql: "SQL 쿼리", mcp: "MCP 호출" },
|
||||
refresh: "새로 고침",
|
||||
mcp: { legacyResponse: "이 이전 기록에는 요약만 포함되어 원래 응답을 복구할 수 없습니다. 업데이트된 MCP 서비스의 새 호출은 응답 내용을 저장합니다.", request: "요청 매개변수", response: "응답 내용", toolSearch: "MCP 도구 이름으로 필터링...", allTools: "모든 도구" },
|
||||
search: "기록 검색...",
|
||||
conversationSearch: "대화 검색...",
|
||||
empty: "아직 기록이 없습니다",
|
||||
@@ -5564,6 +5567,7 @@ export default withEnglishFallback({
|
||||
rollbackSuccess: "롤백을 실행했습니다",
|
||||
rollbackFailed: "롤백 실패: {message}",
|
||||
confirmDelete: "이 기록 항목을 삭제하시겠습니까?",
|
||||
confirmClearSource: "{source} 기록을 모두 지우시겠습니까?",
|
||||
confirmClear: "모든 기록을 비우시겠습니까?",
|
||||
emptyFiltered: "현재 필터와 일치하는 기록이 없습니다",
|
||||
loadMore: "더 불러오기",
|
||||
@@ -5585,6 +5589,7 @@ export default withEnglishFallback({
|
||||
query: "쿼리",
|
||||
data_change: "데이터 변경",
|
||||
schema_change: "스키마 변경",
|
||||
mcp_success: "성공",
|
||||
failed: "실패",
|
||||
},
|
||||
dateRange: {
|
||||
@@ -5605,6 +5610,7 @@ export default withEnglishFallback({
|
||||
import: "가져오기",
|
||||
transfer: "이전",
|
||||
redis_command: "Redis 명령",
|
||||
mcp: "MCP 호출",
|
||||
},
|
||||
kindShort: {
|
||||
query: "SQL",
|
||||
@@ -5615,6 +5621,7 @@ export default withEnglishFallback({
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
mcpTool: "MCP 도구",
|
||||
kind: "유형",
|
||||
operation: "작업",
|
||||
connection: "연결",
|
||||
@@ -6730,6 +6737,11 @@ export default withEnglishFallback({
|
||||
},
|
||||
settings: {
|
||||
historyRetentionLimit: "쿼리 기록 보관 개수",
|
||||
mcpHistoryRetentionLimit: "MCP 기록 보존 수",
|
||||
mcpHistoryRetentionDescription: "SQL 기록과 별도로 MCP 호출 기록 보존 수를 관리합니다.",
|
||||
mcpHistoryCleanup: "MCP 기록 지금 정리",
|
||||
mcpHistoryCleanupCompleted: "MCP 기록 {count}개를 삭제했습니다.",
|
||||
mcpHistoryCleanupFailed: "MCP 기록을 정리할 수 없습니다: {error}",
|
||||
historyRetentionDescription: "한도를 낮추면 다음 쿼리 기록을 저장할 때 오래된 기록이 삭제됩니다. 한도를 높이거나 무제한을 선택해도 삭제된 기록은 복원되지 않습니다. 모든 연결과 실행 경로에 적용됩니다.",
|
||||
historyRetentionUnlimited: "무제한",
|
||||
historyRetentionLoadFailed: "기록 보관 설정을 불러오지 못했습니다: {error}",
|
||||
|
||||
@@ -5884,6 +5884,15 @@ export default withEnglishFallback({
|
||||
transactionEnded: "A transação terminou ou foi revertida. Nenhuma reversão bem-sucedida foi registrada.",
|
||||
rollbackBeforeClose: "Descartar as alterações de reversão não confirmadas e fechar o histórico?",
|
||||
title: "Histórico",
|
||||
sources: { all: "Tudo", sql: "Consultas SQL", mcp: "Chamadas MCP" },
|
||||
refresh: "Atualizar",
|
||||
mcp: {
|
||||
legacyResponse: "Este registro antigo contém apenas um resumo. A resposta original não pode ser recuperada. Novas chamadas MCP armazenam o conteúdo da resposta.",
|
||||
request: "Parâmetros da solicitação",
|
||||
response: "Conteúdo da resposta",
|
||||
toolSearch: "Filtrar pelo nome da ferramenta MCP...",
|
||||
allTools: "Todas as ferramentas",
|
||||
},
|
||||
search: "Pesquisar histórico...",
|
||||
conversationSearch: "Pesquisar conversas...",
|
||||
empty: "Nenhum histórico ainda",
|
||||
@@ -5907,6 +5916,7 @@ export default withEnglishFallback({
|
||||
rollbackSuccess: "Reversão executada",
|
||||
rollbackFailed: "Falha na reversão: {message}",
|
||||
confirmDelete: "Excluir esta entrada de histórico?",
|
||||
confirmClearSource: "Limpar todo o histórico de {source}?",
|
||||
confirmClear: "Limpar todo o histórico?",
|
||||
emptyFiltered: "Nenhum histórico corresponde aos filtros atuais",
|
||||
loadMore: "Carregar mais",
|
||||
@@ -5928,6 +5938,7 @@ export default withEnglishFallback({
|
||||
query: "Consultas",
|
||||
data_change: "Alterações de dados",
|
||||
schema_change: "Alterações de schema",
|
||||
mcp_success: "Sucesso",
|
||||
failed: "Falhou",
|
||||
},
|
||||
dateRange: {
|
||||
@@ -5948,6 +5959,7 @@ export default withEnglishFallback({
|
||||
import: "Importação",
|
||||
transfer: "Transferência",
|
||||
redis_command: "Comando Redis",
|
||||
mcp: "Chamada MCP",
|
||||
},
|
||||
kindShort: {
|
||||
query: "SQL",
|
||||
@@ -5958,6 +5970,7 @@ export default withEnglishFallback({
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
mcpTool: "Ferramenta MCP",
|
||||
kind: "Tipo",
|
||||
operation: "Operação",
|
||||
connection: "Conexão",
|
||||
@@ -6960,6 +6973,11 @@ export default withEnglishFallback({
|
||||
},
|
||||
settings: {
|
||||
historyRetentionLimit: "Limite do histórico de consultas",
|
||||
mcpHistoryRetentionLimit: "Retenção do histórico MCP",
|
||||
mcpHistoryRetentionDescription: "Controla separadamente a quantidade do histórico de chamadas MCP do histórico SQL.",
|
||||
mcpHistoryCleanup: "Limpar histórico MCP agora",
|
||||
mcpHistoryCleanupCompleted: "{count} registros do histórico MCP removidos.",
|
||||
mcpHistoryCleanupFailed: "Não foi possível limpar o histórico MCP: {error}",
|
||||
historyRetentionDescription: "Ao reduzir o limite, os registros antigos serão removidos na próxima gravação do histórico. Aumentar o limite ou escolher Sem limite não restaura registros excluídos. Aplica-se a todas as conexões e origens de execução.",
|
||||
historyRetentionUnlimited: "Sem limite",
|
||||
historyRetentionLoadFailed: "Não foi possível carregar o limite do histórico: {error}",
|
||||
|
||||
@@ -6968,6 +6968,9 @@ export default withEnglishFallback({
|
||||
transactionEnded: "Транзакция завершена или была откатана. Успешный откат не зафиксирован.",
|
||||
rollbackBeforeClose: "Отменить незафиксированные изменения отката и закрыть историю?",
|
||||
title: "История",
|
||||
sources: { all: "Все", sql: "SQL-запросы", mcp: "Вызовы MCP" },
|
||||
refresh: "Обновить",
|
||||
mcp: { legacyResponse: "Эта старая запись содержит только сводку. Исходный ответ восстановить нельзя. Новые вызовы MCP сохраняют содержимое ответа.", request: "Параметры запроса", response: "Содержимое ответа", toolSearch: "Фильтр по имени инструмента MCP...", allTools: "Все инструменты" },
|
||||
search: "Поиск по истории...",
|
||||
conversationSearch: "Поиск по диалогам...",
|
||||
empty: "История пока пуста",
|
||||
@@ -6991,6 +6994,7 @@ export default withEnglishFallback({
|
||||
rollbackSuccess: "Откат выполнен",
|
||||
rollbackFailed: "Откат не выполнен: {message}",
|
||||
confirmDelete: "Удалить эту запись истории?",
|
||||
confirmClearSource: "Очистить всю историю {source}?",
|
||||
confirmClear: "Очистить всю историю?",
|
||||
emptyFiltered: "Нет записей истории, соответствующих текущим фильтрам",
|
||||
loadMore: "Загрузить ещё",
|
||||
@@ -7012,6 +7016,7 @@ export default withEnglishFallback({
|
||||
query: "Запросы",
|
||||
data_change: "Изменения данных",
|
||||
schema_change: "Изменения схемы",
|
||||
mcp_success: "Успешно",
|
||||
failed: "С ошибкой",
|
||||
},
|
||||
dateRange: {
|
||||
@@ -7032,6 +7037,7 @@ export default withEnglishFallback({
|
||||
import: "Импорт",
|
||||
transfer: "Перенос",
|
||||
redis_command: "Команда Redis",
|
||||
mcp: "Вызов MCP",
|
||||
},
|
||||
kindShort: {
|
||||
query: "SQL",
|
||||
@@ -7042,6 +7048,7 @@ export default withEnglishFallback({
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
mcpTool: "Инструмент MCP",
|
||||
kind: "Тип",
|
||||
operation: "Операция",
|
||||
connection: "Подключение",
|
||||
@@ -8183,6 +8190,11 @@ export default withEnglishFallback({
|
||||
},
|
||||
settings: {
|
||||
historyRetentionLimit: "Хранение истории запросов",
|
||||
mcpHistoryRetentionLimit: "Хранение истории MCP",
|
||||
mcpHistoryRetentionDescription: "Отдельно управляет количеством истории вызовов MCP, не затрагивая историю SQL.",
|
||||
mcpHistoryCleanup: "Очистить историю MCP сейчас",
|
||||
mcpHistoryCleanupCompleted: "Удалено записей истории MCP: {count}.",
|
||||
mcpHistoryCleanupFailed: "Не удалось очистить историю MCP: {error}",
|
||||
historyRetentionDescription: "Снижение лимита удаляет старые записи при следующей записи истории запросов. Увеличение лимита или выбор «Без ограничений» не восстанавливает удалённые записи. Лимит применяется ко всем подключениям и источникам выполнения.",
|
||||
historyRetentionUnlimited: "Без ограничения",
|
||||
historyRetentionLoadFailed: "Не удалось загрузить настройку хранения истории: {error}",
|
||||
|
||||
@@ -5736,6 +5736,15 @@ export default withEnglishFallback({
|
||||
},
|
||||
history: {
|
||||
title: "Geçmiş",
|
||||
sources: { all: "Tümü", sql: "SQL sorguları", mcp: "MCP çağrıları" },
|
||||
refresh: "Yenile",
|
||||
mcp: {
|
||||
legacyResponse: "Bu eski kayıt yalnızca özet içerir. Orijinal yanıt kurtarılamaz. Güncellenen MCP hizmetiyle yapılan yeni çağrılar yanıt içeriğini kaydeder.",
|
||||
request: "İstek parametreleri",
|
||||
response: "Yanıt içeriği",
|
||||
toolSearch: "MCP araç adına göre filtrele...",
|
||||
allTools: "Tüm araçlar",
|
||||
},
|
||||
search: "Geçmişte ara...",
|
||||
conversationSearch: "Konuşmalarda ara...",
|
||||
empty: "Henüz geçmiş yok",
|
||||
@@ -5759,6 +5768,7 @@ export default withEnglishFallback({
|
||||
rollbackSuccess: "Geri alma çalıştırıldı",
|
||||
rollbackFailed: "Geri alma başarısız: {message}",
|
||||
confirmDelete: "Bu geçmiş girdisi silinsin mi?",
|
||||
confirmClearSource: "Tüm {source} geçmişi temizlensin mi?",
|
||||
confirmClear: "Tüm geçmiş temizlensin mi?",
|
||||
emptyFiltered: "Geçerli filtrelerle eşleşen geçmiş yok",
|
||||
loadMore: "Daha fazla yükle",
|
||||
@@ -5780,6 +5790,7 @@ export default withEnglishFallback({
|
||||
query: "Sorgular",
|
||||
data_change: "Veri değişiklikleri",
|
||||
schema_change: "Şema değişiklikleri",
|
||||
mcp_success: "Başarılı",
|
||||
failed: "Başarısız",
|
||||
},
|
||||
dateRange: {
|
||||
@@ -5800,6 +5811,7 @@ export default withEnglishFallback({
|
||||
import: "İçe Aktar",
|
||||
transfer: "Aktarım",
|
||||
redis_command: "Redis Komutu",
|
||||
mcp: "MCP çağrısı",
|
||||
},
|
||||
kindShort: {
|
||||
query: "SQL",
|
||||
@@ -5810,6 +5822,7 @@ export default withEnglishFallback({
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
mcpTool: "MCP aracı",
|
||||
kind: "Tür",
|
||||
operation: "İşlem",
|
||||
connection: "Bağlantı",
|
||||
@@ -6883,6 +6896,11 @@ export default withEnglishFallback({
|
||||
},
|
||||
settings: {
|
||||
historyRetentionLimit: "Sorgu geçmişi saklama sınırı",
|
||||
mcpHistoryRetentionLimit: "MCP geçmişi saklama sınırı",
|
||||
mcpHistoryRetentionDescription: "MCP çağrı geçmişi miktarını SQL geçmişinden bağımsız olarak denetler.",
|
||||
mcpHistoryCleanup: "MCP geçmişini şimdi temizle",
|
||||
mcpHistoryCleanupCompleted: "{count} MCP geçmiş kaydı kaldırıldı.",
|
||||
mcpHistoryCleanupFailed: "MCP geçmişi temizlenemedi: {error}",
|
||||
historyRetentionDescription: "Sınır düşürüldüğünde eski kayıtlar bir sonraki sorgu geçmişi kaydında silinir. Sınırı artırmak veya Sınırsız seçmek silinen kayıtları geri getirmez. Tüm bağlantılar ve yürütme kaynakları için geçerlidir.",
|
||||
historyRetentionUnlimited: "Sınırsız",
|
||||
historyRetentionLoadFailed: "Geçmiş saklama ayarı yüklenemedi: {error}",
|
||||
|
||||
@@ -6407,6 +6407,9 @@ export default withEnglishFallback({
|
||||
transactionEnded: "事务已结束或已回滚,未记录回滚成功。",
|
||||
rollbackBeforeClose: "撤销尚未提交的回滚修改并关闭历史记录?",
|
||||
title: "历史",
|
||||
sources: { all: "全部", sql: "查询", mcp: "MCP 调用" },
|
||||
refresh: "刷新",
|
||||
mcp: { legacyResponse: "此旧记录仅保存了摘要,无法恢复原始响应。使用更新后的 MCP 服务再次调用后,可查看新记录的响应内容。", request: "请求参数", response: "响应内容", toolSearch: "按 MCP 工具名称筛选…", allTools: "全部工具" },
|
||||
search: "搜索历史...",
|
||||
conversationSearch: "搜索历史会话...",
|
||||
empty: "暂无历史记录",
|
||||
@@ -6431,6 +6434,7 @@ export default withEnglishFallback({
|
||||
rollbackFailed: "回滚失败:{message}",
|
||||
confirmDelete: "确认删除这条历史记录吗?",
|
||||
confirmClear: "确认清空所有历史记录吗?",
|
||||
confirmClearSource: "确认清空所有 {source} 历史记录吗?",
|
||||
emptyFiltered: "没有符合当前筛选条件的历史记录",
|
||||
loadMore: "加载更多",
|
||||
scope: {
|
||||
@@ -6452,6 +6456,7 @@ export default withEnglishFallback({
|
||||
data_change: "数据变更",
|
||||
schema_change: "结构变更",
|
||||
failed: "失败",
|
||||
mcp_success: "成功",
|
||||
},
|
||||
dateRange: {
|
||||
title: "时间范围",
|
||||
@@ -6471,6 +6476,7 @@ export default withEnglishFallback({
|
||||
import: "导入",
|
||||
transfer: "传输",
|
||||
redis_command: "Redis 命令",
|
||||
mcp: "MCP 调用",
|
||||
},
|
||||
kindShort: {
|
||||
query: "查询",
|
||||
@@ -6481,6 +6487,7 @@ export default withEnglishFallback({
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
mcpTool: "MCP 工具",
|
||||
kind: "类型",
|
||||
operation: "操作",
|
||||
connection: "连接",
|
||||
@@ -7621,6 +7628,11 @@ export default withEnglishFallback({
|
||||
},
|
||||
settings: {
|
||||
historyRetentionLimit: "查询历史保留条数",
|
||||
mcpHistoryRetentionLimit: "MCP 历史保留条数",
|
||||
mcpHistoryRetentionDescription: "独立控制 MCP 调用历史的保留数量,不影响 SQL 查询历史。",
|
||||
mcpHistoryCleanup: "立即清理 MCP 历史",
|
||||
mcpHistoryCleanupCompleted: "已清理 {count} 条 MCP 历史记录。",
|
||||
mcpHistoryCleanupFailed: "无法清理 MCP 历史:{error}",
|
||||
historyRetentionDescription: "调小上限后,下次记录查询历史时将删除超过上限的旧记录。提高上限或选择“不限”不会恢复已删除记录。此上限适用于所有连接及执行来源。",
|
||||
historyRetentionUnlimited: "不限",
|
||||
historyRetentionLoadFailed: "无法加载历史保留设置:{error}",
|
||||
|
||||
@@ -5199,6 +5199,9 @@ export default withEnglishFallback({
|
||||
transactionEnded: "交易已結束或已回滾,未記錄回滾成功。",
|
||||
rollbackBeforeClose: "撤銷尚未提交的回滾修改並關閉歷史記錄?",
|
||||
title: "歷史",
|
||||
sources: { all: "全部", sql: "SQL 查詢", mcp: "MCP 呼叫" },
|
||||
refresh: "重新整理",
|
||||
mcp: { legacyResponse: "此舊記錄僅保存摘要,無法復原原始回應。使用新版 MCP 服務再次呼叫後,即可檢視回應內容。", request: "請求參數", response: "回應內容", toolSearch: "按 MCP 工具名稱篩選……", allTools: "全部工具" },
|
||||
search: "搜尋歷史……",
|
||||
conversationSearch: "搜尋歷史會話……",
|
||||
empty: "暫無歷史記錄",
|
||||
@@ -5222,6 +5225,7 @@ export default withEnglishFallback({
|
||||
rollbackSuccess: "回溯已執行",
|
||||
rollbackFailed: "回溯失敗:{message}",
|
||||
confirmDelete: "確認刪除這條歷史記錄嗎?",
|
||||
confirmClearSource: "確定要清空所有 {source} 歷史記錄嗎?",
|
||||
confirmClear: "確認清空所有歷史記錄嗎?",
|
||||
emptyFiltered: "沒有符合目前篩選條件的歷史記錄",
|
||||
loadMore: "載入更多",
|
||||
@@ -5243,6 +5247,7 @@ export default withEnglishFallback({
|
||||
query: "查詢",
|
||||
data_change: "資料變更",
|
||||
schema_change: "結構變更",
|
||||
mcp_success: "成功",
|
||||
failed: "失敗",
|
||||
},
|
||||
dateRange: {
|
||||
@@ -5263,6 +5268,7 @@ export default withEnglishFallback({
|
||||
import: "匯入",
|
||||
transfer: "傳輸",
|
||||
redis_command: "Redis 命令",
|
||||
mcp: "MCP 呼叫",
|
||||
},
|
||||
kindShort: {
|
||||
query: "查詢",
|
||||
@@ -5273,6 +5279,7 @@ export default withEnglishFallback({
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
mcpTool: "MCP 工具",
|
||||
kind: "類型",
|
||||
operation: "操作",
|
||||
connection: "連線",
|
||||
@@ -6282,6 +6289,11 @@ export default withEnglishFallback({
|
||||
},
|
||||
settings: {
|
||||
historyRetentionLimit: "查詢歷史保留筆數",
|
||||
mcpHistoryRetentionLimit: "MCP 歷史保留筆數",
|
||||
mcpHistoryRetentionDescription: "獨立控制 MCP 呼叫歷史的保留數量,不影響 SQL 查詢歷史。",
|
||||
mcpHistoryCleanup: "立即清理 MCP 歷史",
|
||||
mcpHistoryCleanupCompleted: "已清理 {count} 筆 MCP 歷史記錄。",
|
||||
mcpHistoryCleanupFailed: "無法清理 MCP 歷史:{error}",
|
||||
historyRetentionDescription: "調小上限後,下次記錄查詢歷史時將刪除超過上限的舊記錄。提高上限或選擇「不限」不會還原已刪除的記錄。此上限適用於所有連線及執行來源。",
|
||||
historyRetentionUnlimited: "不限",
|
||||
historyRetentionLoadFailed: "無法載入歷史保留設定:{error}",
|
||||
|
||||
@@ -380,6 +380,8 @@ export const saveSqlFileUploadMaxMb = forward("saveSqlFileUploadMaxMb");
|
||||
export const saveMaxAgentTurns = forward("saveMaxAgentTurns");
|
||||
export const loadHistoryRetentionLimit = forward("loadHistoryRetentionLimit");
|
||||
export const saveHistoryRetentionLimit = forward("saveHistoryRetentionLimit");
|
||||
export const loadMcpHistoryRetentionLimit = forward("loadMcpHistoryRetentionLimit");
|
||||
export const saveMcpHistoryRetentionLimit = forward("saveMcpHistoryRetentionLimit");
|
||||
export const loadMaxRetries = forward("loadMaxRetries");
|
||||
export const saveMaxRetries = forward("saveMaxRetries");
|
||||
export const completeAppClose = forward("completeAppClose");
|
||||
@@ -916,6 +918,8 @@ export const searchHistory = forward("searchHistory");
|
||||
export const loadHistoryConnectionOptions = forward("loadHistoryConnectionOptions");
|
||||
export const loadRedisHistory = forward("loadRedisHistory");
|
||||
export const clearHistory = forward("clearHistory");
|
||||
export const clearHistoryBySource = forward("clearHistoryBySource");
|
||||
export const cleanupMcpHistoryRetention = forward("cleanupMcpHistoryRetention");
|
||||
export const clearRedisHistory = forward("clearRedisHistory");
|
||||
export const deleteHistoryEntry = forward("deleteHistoryEntry");
|
||||
|
||||
|
||||
@@ -2277,6 +2277,19 @@ export async function saveHistoryRetentionLimit(limit: number): Promise<void> {
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
}
|
||||
|
||||
export async function loadMcpHistoryRetentionLimit(): Promise<number> {
|
||||
return get("/api/app-settings/mcp-history-retention-limit");
|
||||
}
|
||||
|
||||
export async function saveMcpHistoryRetentionLimit(limit: number): Promise<void> {
|
||||
const res = await fetch(apiUrl("/api/app-settings/mcp-history-retention-limit"), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ limit }),
|
||||
});
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
}
|
||||
|
||||
export async function loadMaxRetries(): Promise<number> {
|
||||
return get("/api/app-settings/max-retries");
|
||||
}
|
||||
@@ -5197,6 +5210,14 @@ export async function clearHistory(): Promise<void> {
|
||||
return del("/api/history");
|
||||
}
|
||||
|
||||
export async function clearHistoryBySource(source: string): Promise<void> {
|
||||
return del(`/api/history?source=${encodeURIComponent(source)}`);
|
||||
}
|
||||
|
||||
export async function cleanupMcpHistoryRetention(): Promise<number> {
|
||||
return post("/api/app-settings/mcp-history-retention-cleanup", {});
|
||||
}
|
||||
|
||||
export async function clearRedisHistory(): Promise<void> {
|
||||
const entries = await loadRedisHistory(1000, 0);
|
||||
await Promise.all(entries.map((e) => deleteHistoryEntry(e.id)));
|
||||
|
||||
@@ -882,6 +882,14 @@ export async function saveHistoryRetentionLimit(limit: number): Promise<void> {
|
||||
return invoke("save_history_retention_limit", { limit });
|
||||
}
|
||||
|
||||
export async function loadMcpHistoryRetentionLimit(): Promise<number> {
|
||||
return invoke("load_mcp_history_retention_limit");
|
||||
}
|
||||
|
||||
export async function saveMcpHistoryRetentionLimit(limit: number): Promise<void> {
|
||||
return invoke("save_mcp_history_retention_limit", { limit });
|
||||
}
|
||||
|
||||
export async function loadMaxRetries(): Promise<number> {
|
||||
return invoke("load_max_retries");
|
||||
}
|
||||
@@ -5086,6 +5094,11 @@ export interface HistoryEntry {
|
||||
affected_rows?: number | null;
|
||||
rollback_sql?: string | null;
|
||||
details_json?: string | null;
|
||||
source?: "sql" | "mcp" | "other";
|
||||
mcp_tool_name?: string | null;
|
||||
mcp_request_json?: string | null;
|
||||
mcp_response_json?: string | null;
|
||||
mcp_session_id?: string | null;
|
||||
}
|
||||
|
||||
export interface HistoryConnectionFilter {
|
||||
@@ -5112,6 +5125,8 @@ export interface HistorySearchRequest {
|
||||
ended_at?: string;
|
||||
cursor?: HistoryCursor;
|
||||
limit: number;
|
||||
source?: "sql" | "mcp" | "other";
|
||||
mcp_tool_name?: string;
|
||||
}
|
||||
|
||||
export interface HistorySearchResult {
|
||||
@@ -5152,6 +5167,14 @@ export async function clearHistory(): Promise<void> {
|
||||
return invoke("clear_history");
|
||||
}
|
||||
|
||||
export async function clearHistoryBySource(source: string): Promise<void> {
|
||||
return invoke("clear_history_by_source", { source });
|
||||
}
|
||||
|
||||
export async function cleanupMcpHistoryRetention(): Promise<number> {
|
||||
return invoke("cleanup_mcp_history_retention");
|
||||
}
|
||||
|
||||
export async function clearRedisHistory(): Promise<void> {
|
||||
const entries = await loadRedisHistory(1000, 0);
|
||||
await Promise.all(entries.map((e) => deleteHistoryEntry(e.id)));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type HistoryActivityKind = "query" | "data_change" | "schema_change" | "import" | "transfer" | "redis_command";
|
||||
export type HistoryActivityKind = "query" | "data_change" | "schema_change" | "import" | "transfer" | "redis_command" | "mcp";
|
||||
|
||||
export type HistoryActivitySource = {
|
||||
activity_kind?: HistoryActivityKind;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const HISTORY_ROW_HEIGHT = 72;
|
||||
export const HISTORY_ROW_HEIGHT = 80;
|
||||
export const HISTORY_SCROLL_BUFFER = 600;
|
||||
|
||||
export function shouldVirtualizeHistory(count: number): boolean {
|
||||
|
||||
@@ -207,6 +207,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
|
||||
{ id: "navigation-sidebar-font-size", category: "navigation", titleKey: "settings.sidebarFontSize", descriptionKey: "settings.sidebarFontSizeDescription", targetId: "navigation" },
|
||||
{ id: "navigation-hidden-tables", category: "navigation", titleKey: "settings.sidebarHiddenTablePrefixes", descriptionKey: "settings.sidebarHiddenTablePrefixesDescription", targetId: "navigation" },
|
||||
{ id: "history-retention", category: "data", titleKey: "settings.historyRetentionLimit", descriptionKey: "settings.historyRetentionDescription", targetId: "history-retention" },
|
||||
{ id: "mcp-history-retention", category: "data", titleKey: "settings.mcpHistoryRetentionLimit", descriptionKey: "settings.mcpHistoryRetentionDescription", targetId: "mcp-history-retention" },
|
||||
{ id: "navigation-table-page-size", category: "navigation", titleKey: "settings.sidebarTablePageSize", descriptionKey: "settings.sidebarTablePageSizeDescription", targetId: "navigation" },
|
||||
{ id: "navigation-disconnect-tabs", category: "navigation", titleKey: "settings.disconnectTabHandlingMode", descriptionKey: "settings.disconnectTabHandlingModeDescription", targetId: "navigation" },
|
||||
{ id: "navigation-delete-connection-tabs", category: "navigation", titleKey: "settings.deleteConnectionTabHandlingMode", descriptionKey: "settings.deleteConnectionTabHandlingModeDescription", targetId: "navigation" },
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
saveHistory: vi.fn(),
|
||||
deleteHistoryEntry: vi.fn(),
|
||||
clearHistory: vi.fn(),
|
||||
clearHistoryBySource: vi.fn(),
|
||||
loadHistoryConnectionOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -92,6 +93,15 @@ describe("historyStore", () => {
|
||||
expect(store.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("clears only the requested history source", async () => {
|
||||
vi.mocked(api.clearHistoryBySource).mockResolvedValue(undefined);
|
||||
const store = useHistoryStore();
|
||||
await store.clear("mcp");
|
||||
expect(api.clearHistoryBySource).toHaveBeenCalledWith("mcp");
|
||||
expect(api.clearHistory).not.toHaveBeenCalled();
|
||||
expect(api.loadHistoryConnectionOptions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
@@ -175,10 +175,11 @@ export const useHistoryStore = defineStore("history", () => {
|
||||
void loadConnectionOptions().catch(() => {});
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
async function clear(source?: string) {
|
||||
beginDestructiveMutation();
|
||||
try {
|
||||
await api.clearHistory();
|
||||
if (source) await api.clearHistoryBySource(source);
|
||||
else await api.clearHistory();
|
||||
} finally {
|
||||
endDestructiveMutation();
|
||||
}
|
||||
@@ -186,6 +187,7 @@ export const useHistoryStore = defineStore("history", () => {
|
||||
connectionOptions.value = [];
|
||||
total.value = 0;
|
||||
nextCursor.value = null;
|
||||
if (source) void loadConnectionOptions().catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -25,6 +25,16 @@ pub struct HistoryEntry {
|
||||
pub rollback_sql: Option<String>,
|
||||
#[serde(default)]
|
||||
pub details_json: Option<String>,
|
||||
#[serde(default = "default_history_source")]
|
||||
pub source: String,
|
||||
#[serde(default)]
|
||||
pub mcp_tool_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mcp_request_json: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mcp_response_json: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mcp_session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Matches current entries by connection ID and legacy entries by connection name.
|
||||
@@ -69,6 +79,10 @@ pub struct HistorySearchRequest {
|
||||
pub cursor: Option<HistoryCursor>,
|
||||
#[serde(default)]
|
||||
pub limit: usize,
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mcp_tool_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -97,6 +111,9 @@ pub fn validate_history_retention_limit(limit: u32) -> Result<(), String> {
|
||||
fn default_activity_kind() -> String {
|
||||
"query".to_string()
|
||||
}
|
||||
fn default_history_source() -> String {
|
||||
"sql".to_string()
|
||||
}
|
||||
|
||||
pub fn read_all(path: &Path) -> Result<Vec<HistoryEntry>, String> {
|
||||
if !path.exists() {
|
||||
|
||||
@@ -58,6 +58,7 @@ const MCP_HTTP_SERVER_SETTINGS_KEY: &str = "mcp_http_server_settings";
|
||||
const WEB_MCP_SETTINGS_KEY: &str = "web_mcp_settings";
|
||||
const MAX_RETRIES_KEY: &str = "max_retries";
|
||||
const HISTORY_RETENTION_LIMIT_KEY: &str = "history_retention_limit";
|
||||
const MCP_HISTORY_RETENTION_LIMIT_KEY: &str = "mcp_history_retention_limit";
|
||||
const SQL_FILE_UPLOAD_MAX_MB_KEY: &str = "sql_file_upload_max_mb";
|
||||
/// Plugin ids whose MCP tools the built-in AI agent may call.
|
||||
const AI_PLUGIN_TOOL_PLUGINS_KEY: &str = "ai_plugin_tool_plugins";
|
||||
@@ -955,6 +956,11 @@ const SCHEMA_STATEMENTS: &[&str] = &[
|
||||
affected_rows INTEGER,
|
||||
rollback_sql TEXT,
|
||||
details_json TEXT
|
||||
,source TEXT NOT NULL DEFAULT 'sql'
|
||||
,mcp_tool_name TEXT
|
||||
,mcp_request_json TEXT
|
||||
,mcp_response_json TEXT
|
||||
,mcp_session_id TEXT
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS ai_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
@@ -2743,6 +2749,11 @@ fn ensure_history_columns_sync(conn: &Connection) -> Result<(), String> {
|
||||
("affected_rows", "INTEGER"),
|
||||
("rollback_sql", "TEXT"),
|
||||
("details_json", "TEXT"),
|
||||
("source", "TEXT NOT NULL DEFAULT 'sql'"),
|
||||
("mcp_tool_name", "TEXT"),
|
||||
("mcp_request_json", "TEXT"),
|
||||
("mcp_response_json", "TEXT"),
|
||||
("mcp_session_id", "TEXT"),
|
||||
];
|
||||
|
||||
let mut stmt = conn.prepare("SELECT name FROM pragma_table_info('history')").map_err(|e| e.to_string())?;
|
||||
@@ -2758,6 +2769,19 @@ fn ensure_history_columns_sync(conn: &Connection) -> Result<(), String> {
|
||||
}
|
||||
conn.execute(&format!("ALTER TABLE history ADD COLUMN {name} {definition}"), []).map_err(|e| e.to_string())?;
|
||||
}
|
||||
// Older MCP SQL entries carried their source only in details_json. Guard
|
||||
// malformed legacy JSON so opening the database cannot fail during upgrade.
|
||||
conn.execute(
|
||||
"UPDATE history SET source = 'mcp' WHERE source = 'sql' AND \
|
||||
CASE WHEN json_valid(details_json) THEN json_extract(details_json, '$.source') = 'mcp' ELSE 0 END",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
conn.execute_batch(
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_source_time ON history(source, executed_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_mcp_tool_time ON history(mcp_tool_name, executed_at DESC);",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3147,6 +3171,15 @@ fn history_search_predicate(request: &HistorySearchRequest) -> (String, Vec<Valu
|
||||
let mut values = Vec::new();
|
||||
append_history_scope_clause(&mut clauses, &mut values, &request.connections, &request.databases);
|
||||
|
||||
if let Some(source) = request.source.as_ref().filter(|value| !value.is_empty()) {
|
||||
clauses.push("source = ?".to_string());
|
||||
values.push(Value::Text(source.clone()));
|
||||
}
|
||||
if let Some(tool) = request.mcp_tool_name.as_ref().filter(|value| !value.is_empty()) {
|
||||
clauses.push("mcp_tool_name = ?".to_string());
|
||||
values.push(Value::Text(tool.clone()));
|
||||
}
|
||||
|
||||
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()));
|
||||
@@ -3167,7 +3200,7 @@ fn history_search_predicate(request: &HistorySearchRequest) -> (String, Vec<Valu
|
||||
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"];
|
||||
let fields = ["sql_text", "connection_name", "database", "operation", "target", "mcp_tool_name"];
|
||||
clauses.push(format!(
|
||||
"({})",
|
||||
fields
|
||||
@@ -3207,6 +3240,11 @@ fn map_history_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<HistoryEntry> {
|
||||
affected_rows: row.get(12)?,
|
||||
rollback_sql: row.get(13)?,
|
||||
details_json: row.get(14)?,
|
||||
source: row.get::<_, String>(15).unwrap_or_else(|_| "sql".to_string()),
|
||||
mcp_tool_name: row.get(16).ok(),
|
||||
mcp_request_json: row.get(17).ok(),
|
||||
mcp_response_json: row.get(18).ok(),
|
||||
mcp_session_id: row.get(19).ok(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3244,6 +3282,16 @@ fn app_settings_map_from_conn(conn: &Connection) -> Result<serde_json::Map<Strin
|
||||
}
|
||||
}
|
||||
|
||||
fn load_mcp_history_retention_limit_from_conn(conn: &Connection) -> Result<u32, String> {
|
||||
let settings = app_settings_map_from_conn(conn)?;
|
||||
Ok(settings
|
||||
.get(MCP_HISTORY_RETENTION_LIMIT_KEY)
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.filter(|value| crate::history::validate_history_retention_limit(*value).is_ok())
|
||||
.unwrap_or(1000))
|
||||
}
|
||||
|
||||
fn write_app_settings_map(
|
||||
conn: &Connection,
|
||||
settings: &serde_json::Map<String, serde_json::Value>,
|
||||
@@ -3288,12 +3336,17 @@ impl Storage {
|
||||
self.with_conn(move |conn| {
|
||||
let tx =
|
||||
conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate).map_err(|e| e.to_string())?;
|
||||
let limit = load_history_retention_limit_from_conn(&tx)?;
|
||||
let source = if entry.source.is_empty() { "sql" } else { entry.source.as_str() };
|
||||
let limit = if source == "mcp" {
|
||||
load_mcp_history_retention_limit_from_conn(&tx)?
|
||||
} else {
|
||||
load_history_retention_limit_from_conn(&tx)?
|
||||
};
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO history \
|
||||
(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) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json, source, mcp_tool_name, mcp_request_json, mcp_response_json, mcp_session_id) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
params![
|
||||
entry.id,
|
||||
entry.connection_name,
|
||||
@@ -3309,16 +3362,17 @@ impl Storage {
|
||||
entry.target,
|
||||
entry.affected_rows,
|
||||
entry.rollback_sql,
|
||||
entry.details_json
|
||||
entry.details_json,
|
||||
source, entry.mcp_tool_name, entry.mcp_request_json, entry.mcp_response_json, entry.mcp_session_id
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if limit != 0 {
|
||||
tx.execute(
|
||||
"DELETE FROM history WHERE id NOT IN \
|
||||
(SELECT id FROM history ORDER BY executed_at DESC, id DESC LIMIT ?1)",
|
||||
[i64::from(limit)],
|
||||
"DELETE FROM history WHERE source = ?2 AND id NOT IN \
|
||||
(SELECT id FROM history WHERE source = ?2 ORDER BY executed_at DESC, id DESC LIMIT ?1)",
|
||||
rusqlite::params![i64::from(limit), source],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
@@ -3354,6 +3408,11 @@ impl Storage {
|
||||
affected_rows: row.get(12)?,
|
||||
rollback_sql: row.get(13)?,
|
||||
details_json: row.get(14)?,
|
||||
source: row.get::<_, String>(15).unwrap_or_else(|_| "sql".to_string()),
|
||||
mcp_tool_name: row.get(16).ok(),
|
||||
mcp_request_json: row.get(17).ok(),
|
||||
mcp_response_json: row.get(18).ok(),
|
||||
mcp_session_id: row.get(19).ok(),
|
||||
})
|
||||
};
|
||||
|
||||
@@ -3361,7 +3420,7 @@ impl Storage {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"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 \
|
||||
error, activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json, source, mcp_tool_name, mcp_request_json, mcp_response_json, mcp_session_id \
|
||||
FROM history WHERE activity_kind = ?1 ORDER BY executed_at DESC LIMIT ?2 OFFSET ?3",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -3373,7 +3432,7 @@ impl Storage {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"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 \
|
||||
error, activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json, source, mcp_tool_name, mcp_request_json, mcp_response_json, mcp_session_id \
|
||||
FROM history ORDER BY executed_at DESC LIMIT ?1 OFFSET ?2",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -3414,7 +3473,7 @@ impl Storage {
|
||||
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 \
|
||||
error, activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json, source, mcp_tool_name, mcp_request_json, mcp_response_json, mcp_session_id \
|
||||
FROM history{page_predicate} ORDER BY executed_at DESC, id DESC LIMIT ?"
|
||||
);
|
||||
page_values.push(Value::Integer((limit + 1) as i64));
|
||||
@@ -3480,6 +3539,34 @@ impl Storage {
|
||||
self.with_conn(|conn| conn.execute("DELETE FROM history", []).map(|_| ()).map_err(|e| e.to_string())).await
|
||||
}
|
||||
|
||||
pub async fn clear_history_by_source(&self, source: &str) -> Result<(), String> {
|
||||
let source = source.trim().to_string();
|
||||
if source.is_empty() {
|
||||
return Err("History source is required".to_string());
|
||||
}
|
||||
self.with_conn(move |conn| {
|
||||
conn.execute("DELETE FROM history WHERE source = ?1", [source]).map(|_| ()).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cleanup_mcp_history_retention(&self) -> Result<u64, String> {
|
||||
self.with_conn(|conn| {
|
||||
let limit = load_mcp_history_retention_limit_from_conn(conn)?;
|
||||
if limit == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
conn.execute(
|
||||
"DELETE FROM history WHERE source = 'mcp' AND id NOT IN \
|
||||
(SELECT id FROM history WHERE source = 'mcp' ORDER BY executed_at DESC, id DESC LIMIT ?1)",
|
||||
[i64::from(limit)],
|
||||
)
|
||||
.map(|count| count as u64)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_history_entry(&self, id: &str) -> Result<(), String> {
|
||||
let id = id.to_string();
|
||||
self.with_conn(move |conn| {
|
||||
@@ -4114,6 +4201,7 @@ impl Storage {
|
||||
HISTORY_RETENTION_LIMIT_KEY,
|
||||
AI_PLUGIN_TOOL_PLUGINS_KEY,
|
||||
PLUGIN_DATA_GRANTS_KEY,
|
||||
MCP_HISTORY_RETENTION_LIMIT_KEY,
|
||||
];
|
||||
for key in dedicated_keys {
|
||||
settings.remove(key);
|
||||
@@ -4921,6 +5009,33 @@ impl Storage {
|
||||
self.with_conn(|conn| load_history_retention_limit_from_conn(conn)).await
|
||||
}
|
||||
|
||||
pub async fn load_mcp_history_retention_limit(&self) -> Result<u32, String> {
|
||||
self.with_conn(|conn| load_mcp_history_retention_limit_from_conn(conn)).await
|
||||
}
|
||||
|
||||
pub async fn save_mcp_history_retention_limit(&self, limit: u32) -> Result<(), String> {
|
||||
crate::history::validate_history_retention_limit(limit)?;
|
||||
self.with_conn(move |conn| {
|
||||
let current: Option<String> = conn
|
||||
.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0))
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut settings = match current {
|
||||
Some(json) => serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&json)
|
||||
.map_err(|e| format!("invalid app settings JSON: {e}"))?,
|
||||
None => serde_json::Map::new(),
|
||||
};
|
||||
settings.insert(MCP_HISTORY_RETENTION_LIMIT_KEY.to_string(), serde_json::Value::from(limit));
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)",
|
||||
[serde_json::Value::Object(settings).to_string()],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save_history_retention_limit(&self, limit: u32) -> Result<(), String> {
|
||||
crate::history::validate_history_retention_limit(limit)?;
|
||||
self.with_conn(move |conn| {
|
||||
@@ -8987,6 +9102,11 @@ mod tests {
|
||||
affected_rows: None,
|
||||
rollback_sql: None,
|
||||
details_json: None,
|
||||
source: "sql".to_string(),
|
||||
mcp_tool_name: None,
|
||||
mcp_request_json: None,
|
||||
mcp_response_json: None,
|
||||
mcp_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9411,6 +9531,121 @@ mod tests {
|
||||
}).await.unwrap();
|
||||
}
|
||||
|
||||
async fn seed_mixed_history(storage: &Storage) {
|
||||
seed_history_backlog(storage, 1001).await;
|
||||
storage.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO history (id, connection_name, database, sql_text, executed_at, execution_time_ms, success, source)
|
||||
SELECT 'mcp-' || id, connection_name, database, sql_text, executed_at, execution_time_ms, success, 'mcp' FROM history WHERE source = 'sql'",
|
||||
[],
|
||||
).map(|_| ()).map_err(|e| e.to_string())
|
||||
}).await.unwrap();
|
||||
}
|
||||
|
||||
async fn source_count(storage: &Storage, source: &str) -> usize {
|
||||
storage
|
||||
.search_history_entries(HistorySearchRequest { source: Some(source.to_string()), ..Default::default() })
|
||||
.await
|
||||
.unwrap()
|
||||
.total as usize
|
||||
}
|
||||
|
||||
async fn write_source_history(storage: &Storage, source: &str) {
|
||||
let mut entry =
|
||||
history_entry(&format!("new-{source}"), "conn", "Main", "app", "select 2", "2026-07-19T00:00:00Z", true);
|
||||
entry.source = source.to_string();
|
||||
storage.save_history_entry(&entry).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_retention_sources_are_independent_and_unlimited() {
|
||||
for (sql_limit, mcp_limit, sql_expected, mcp_expected) in [
|
||||
(200, 1000, 200, 1000),
|
||||
(1000, 200, 1000, 200),
|
||||
(0, 200, 1002, 200),
|
||||
(200, 0, 200, 1002),
|
||||
(0, 0, 1002, 1002),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let storage = Storage::open(&dir.path().join("dbx.db")).await.unwrap();
|
||||
seed_mixed_history(&storage).await;
|
||||
storage.save_history_retention_limit(sql_limit).await.unwrap();
|
||||
storage.save_mcp_history_retention_limit(mcp_limit).await.unwrap();
|
||||
assert_eq!(source_count(&storage, "sql").await, 1001);
|
||||
assert_eq!(source_count(&storage, "mcp").await, 1001);
|
||||
write_source_history(&storage, "mcp").await;
|
||||
assert_eq!(source_count(&storage, "mcp").await, mcp_expected);
|
||||
assert_eq!(source_count(&storage, "sql").await, 1001);
|
||||
write_source_history(&storage, "sql").await;
|
||||
assert_eq!(source_count(&storage, "sql").await, sql_expected);
|
||||
assert_eq!(source_count(&storage, "mcp").await, mcp_expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_retention_mcp_defaults_and_preserves_saved_setting() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("dbx.db");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
assert_eq!(storage.load_mcp_history_retention_limit().await.unwrap(), 1000);
|
||||
seed_mixed_history(&storage).await;
|
||||
write_source_history(&storage, "mcp").await;
|
||||
assert_eq!(source_count(&storage, "mcp").await, 1000);
|
||||
assert_eq!(source_count(&storage, "sql").await, 1001);
|
||||
let stale = storage.load_app_settings_json().await.unwrap();
|
||||
storage.save_mcp_history_retention_limit(200).await.unwrap();
|
||||
storage.save_app_settings_json(&stale).await.unwrap();
|
||||
assert_eq!(storage.load_mcp_history_retention_limit().await.unwrap(), 200);
|
||||
assert_eq!(source_count(&storage, "mcp").await, 1000);
|
||||
write_source_history(&storage, "sql").await;
|
||||
assert_eq!(source_count(&storage, "mcp").await, 1000);
|
||||
write_source_history(&storage, "mcp").await;
|
||||
assert_eq!(source_count(&storage, "mcp").await, 200);
|
||||
assert!(storage.save_mcp_history_retention_limit(1).await.is_err());
|
||||
drop(storage);
|
||||
let reopened = Storage::open(&path).await.unwrap();
|
||||
assert_eq!(reopened.load_mcp_history_retention_limit().await.unwrap(), 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_retention_legacy_migration_recovers_sources() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("dbx.db");
|
||||
{
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE history (id TEXT PRIMARY KEY, connection_name TEXT NOT NULL,
|
||||
database TEXT NOT NULL, sql_text TEXT NOT NULL, executed_at TEXT NOT NULL,
|
||||
execution_time_ms INTEGER NOT NULL, success INTEGER NOT NULL, error TEXT, details_json TEXT);
|
||||
INSERT INTO history VALUES ('sql', 'Main', 'app', 'select 1', '2026-07-18', 1, 1, NULL, NULL);
|
||||
INSERT INTO history VALUES ('mcp', 'Main', 'app', 'select 2', '2026-07-18', 1, 1, NULL, '{\"source\":\"mcp\"}');
|
||||
INSERT INTO history VALUES ('invalid', 'Main', 'app', 'select 3', '2026-07-18', 1, 1, NULL, 'invalid json');"
|
||||
).unwrap();
|
||||
}
|
||||
for _ in 0..2 {
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
assert_eq!(source_count(&storage, "sql").await, 2);
|
||||
assert_eq!(source_count(&storage, "mcp").await, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_source_clear_and_mcp_cleanup_are_isolated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let storage = Storage::open(&dir.path().join("dbx.db")).await.unwrap();
|
||||
seed_mixed_history(&storage).await;
|
||||
storage.save_mcp_history_retention_limit(200).await.unwrap();
|
||||
|
||||
assert_eq!(storage.cleanup_mcp_history_retention().await.unwrap(), 801);
|
||||
assert_eq!(source_count(&storage, "mcp").await, 200);
|
||||
assert_eq!(source_count(&storage, "sql").await, 1001);
|
||||
|
||||
storage.clear_history_by_source("mcp").await.unwrap();
|
||||
assert_eq!(source_count(&storage, "mcp").await, 0);
|
||||
assert_eq!(source_count(&storage, "sql").await, 1001);
|
||||
assert!(storage.clear_history_by_source(" ").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_retention_uses_persisted_limit_on_the_next_write() {
|
||||
for (limit, expected) in [(200, 200), (5000, 1002), (10000, 1002), (0, 1002)] {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
net::Ipv6Addr,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
@@ -253,8 +254,14 @@ fn normalize_hosts(hosts: impl IntoIterator<Item = String>) -> Result<Vec<HostRu
|
||||
}
|
||||
|
||||
fn parse_host_rule(value: &str) -> Result<HostRule, String> {
|
||||
let authority =
|
||||
axum::http::uri::Authority::try_from(value.trim()).map_err(|_| format!("invalid allowed host: {value}"))?;
|
||||
let value = value.trim();
|
||||
// `Authority` requires IPv6 literals to use URI brackets (`[::1]`), while
|
||||
// the loopback defaults and settings UI naturally expose the bare form
|
||||
// (`::1`). Normalize that form before parsing so loopback HTTP services do
|
||||
// not fail immediately during startup.
|
||||
let normalized = if value.parse::<Ipv6Addr>().is_ok() { format!("[{value}]") } else { value.to_string() };
|
||||
let authority = axum::http::uri::Authority::try_from(normalized.as_str())
|
||||
.map_err(|_| format!("invalid allowed host: {value}"))?;
|
||||
if value.contains('@') {
|
||||
return Err(format!("invalid allowed host: {value}"));
|
||||
}
|
||||
@@ -320,4 +327,14 @@ mod tests {
|
||||
assert!(parse_host_rule("https://dbx.example.test").is_err());
|
||||
assert!(parse_host_rule("user@dbx.example.test:4224").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_ipv6_loopback_host_is_normalized_for_uri_authority_parsing() {
|
||||
let auth = HttpAuth::new_with_hosts(Some("token".to_string()), ["::1".to_string()], Vec::<String>::new(), true)
|
||||
.unwrap();
|
||||
let uri: Uri = "/mcp".parse().unwrap();
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(header::HOST, HeaderValue::from_static("[::1]"));
|
||||
assert!(auth.host_is_allowed(&uri, &headers));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +507,157 @@ impl PendingSalesforceWrites {
|
||||
}
|
||||
}
|
||||
|
||||
// One context per dispatched request; legacy SQL helpers enrich it instead of
|
||||
// writing duplicate rows. Direct internal calls retain their existing behavior.
|
||||
tokio::task_local! {
|
||||
static CALL_HISTORY: std::sync::Mutex<HistoryEntry>;
|
||||
}
|
||||
|
||||
struct CallHistoryGuard {
|
||||
backend: Arc<dyn DbxBackend>,
|
||||
fallback: Option<HistoryEntry>,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
impl Drop for CallHistoryGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut entry) = self.fallback.take() {
|
||||
entry.execution_time_ms = self.started.elapsed().as_millis();
|
||||
entry.error = Some("MCP request cancelled or interrupted".into());
|
||||
let backend = self.backend.clone();
|
||||
// rmcp may drop the handler future on cancellation. Persist outside
|
||||
// that future so the interruption still has an audit record.
|
||||
if let Ok(runtime) = tokio::runtime::Handle::try_current() {
|
||||
runtime.spawn(async move {
|
||||
if let Err(error) = backend.save_history_entry(&entry).await {
|
||||
log::warn!("failed to save interrupted MCP history: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn history_request(value: &serde_json::Value, depth: usize) -> serde_json::Value {
|
||||
use serde_json::Value;
|
||||
if depth > 8 {
|
||||
return json!("[depth limit]");
|
||||
}
|
||||
match value {
|
||||
Value::Object(fields) => Value::Object(
|
||||
fields
|
||||
.iter()
|
||||
.take(64)
|
||||
.map(|(key, value)| {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
let secret = [
|
||||
"password",
|
||||
"passwd",
|
||||
"secret",
|
||||
"token",
|
||||
"credential",
|
||||
"authorization",
|
||||
"private_key",
|
||||
"apikey",
|
||||
"api_key",
|
||||
"connection_string",
|
||||
"url",
|
||||
"dsn",
|
||||
]
|
||||
.iter()
|
||||
.any(|part| lower.contains(part));
|
||||
(
|
||||
key.chars().take(128).collect(),
|
||||
if secret { json!("[redacted]") } else { history_request(value, depth + 1) },
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(values) => {
|
||||
Value::Array(values.iter().take(16).map(|value| history_request(value, depth + 1)).collect())
|
||||
}
|
||||
Value::String(value) => json!(value.chars().take(1024).collect::<String>()),
|
||||
value => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_history_request(value: &serde_json::Value) -> String {
|
||||
let sanitized = history_request(value, 0).to_string();
|
||||
if sanitized.len() > 16 * 1024 {
|
||||
json!({"truncated": true, "summary": "Request exceeds history size limit"}).to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
// Response payloads need their own sanitizer: request summaries intentionally
|
||||
// shorten strings and arrays, which would silently discard query results.
|
||||
fn history_response(value: &serde_json::Value, depth: usize) -> serde_json::Value {
|
||||
use serde_json::Value;
|
||||
if depth > 32 {
|
||||
return json!({"truncated": true, "reason": "depth limit"});
|
||||
}
|
||||
match value {
|
||||
Value::Object(fields) => Value::Object(
|
||||
fields
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
let secret = [
|
||||
"password",
|
||||
"passwd",
|
||||
"secret",
|
||||
"token",
|
||||
"credential",
|
||||
"authorization",
|
||||
"private_key",
|
||||
"apikey",
|
||||
"api_key",
|
||||
"connection_string",
|
||||
"url",
|
||||
"dsn",
|
||||
]
|
||||
.iter()
|
||||
.any(|part| lower.contains(part));
|
||||
let binary = key == "data"
|
||||
&& fields
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|kind| kind == "image" || kind == "audio")
|
||||
|| key == "blob";
|
||||
(
|
||||
key.clone(),
|
||||
if secret || binary { json!("[redacted]") } else { history_response(value, depth + 1) },
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(values) => Value::Array(values.iter().map(|value| history_response(value, depth + 1)).collect()),
|
||||
Value::String(text) => {
|
||||
// MCP text blocks often contain JSON encoded inside a string.
|
||||
// Sanitize that JSON too, while preserving the text-block shape.
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(text) {
|
||||
if parsed.is_object() || parsed.is_array() {
|
||||
return json!(history_response(&parsed, depth + 1).to_string());
|
||||
}
|
||||
}
|
||||
value.clone()
|
||||
}
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_history_response<T: serde::Serialize>(value: &T) -> String {
|
||||
let value = serde_json::to_value(value).unwrap_or_else(|_| json!({"serialization_error": true}));
|
||||
let sanitized = history_response(&value, 0).to_string();
|
||||
if sanitized.len() > 64 * 1024 {
|
||||
// A short preview leaves room for JSON escaping and metadata.
|
||||
json!({"truncated": true, "summary": "Response exceeds 64 KiB history size limit", "original_bytes": sanitized.len(), "preview": sanitized.chars().take(4096).collect::<String>()}).to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DbxMcpServer {
|
||||
backend: Arc<dyn DbxBackend>,
|
||||
@@ -693,6 +844,7 @@ impl DbxMcpServer {
|
||||
}
|
||||
async fn save_mcp_sql_history(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
connection: &ConnectionConfig,
|
||||
database: &str,
|
||||
sql: &str,
|
||||
@@ -717,7 +869,27 @@ impl DbxMcpServer {
|
||||
affected_rows,
|
||||
rollback_sql: None,
|
||||
details_json: Some(r#"{"source":"mcp"}"#.to_string()),
|
||||
source: "mcp".to_string(),
|
||||
mcp_tool_name: Some(tool_name.to_string()),
|
||||
mcp_request_json: None,
|
||||
mcp_response_json: None,
|
||||
mcp_session_id: None,
|
||||
};
|
||||
if CALL_HISTORY
|
||||
.try_with(|current| {
|
||||
let mut current = current.lock().unwrap();
|
||||
current.connection_id = entry.connection_id.clone();
|
||||
current.connection_name = entry.connection_name.clone();
|
||||
current.database = entry.database.clone();
|
||||
current.sql = entry.sql.clone();
|
||||
current.activity_kind = entry.activity_kind.clone();
|
||||
current.operation = entry.operation.clone();
|
||||
current.affected_rows = entry.affected_rows;
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Err(error) = self.backend.save_history_entry(&entry).await {
|
||||
log::warn!("failed to save MCP SQL history for connection {}: {error}", connection.id);
|
||||
}
|
||||
@@ -1151,6 +1323,7 @@ impl DbxMcpServer {
|
||||
})
|
||||
.unwrap_or_else(|| format_query_result(&execution.result, max_rows as usize));
|
||||
self.save_mcp_sql_history(
|
||||
"dbx_execute_query",
|
||||
&refreshed.connection,
|
||||
&session.database,
|
||||
&history_sql,
|
||||
@@ -1164,6 +1337,7 @@ impl DbxMcpServer {
|
||||
}
|
||||
Err(error) => {
|
||||
self.save_mcp_sql_history(
|
||||
"dbx_execute_query",
|
||||
&refreshed.connection,
|
||||
&session.database,
|
||||
&history_sql,
|
||||
@@ -1204,7 +1378,17 @@ impl DbxMcpServer {
|
||||
self.backend.execute_agent_tool(connection, &database, "execute_query", arguments, permissions).await;
|
||||
let success = !result.is_error;
|
||||
let error = result.is_error.then(|| result.content.trim_start_matches("Error: ").to_string());
|
||||
self.save_mcp_sql_history(connection, &database, &history_sql, started_at, success, error, None).await;
|
||||
self.save_mcp_sql_history(
|
||||
"dbx_execute_query",
|
||||
connection,
|
||||
&database,
|
||||
&history_sql,
|
||||
started_at,
|
||||
success,
|
||||
error,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
agent_result(result)
|
||||
}
|
||||
|
||||
@@ -1475,6 +1659,7 @@ impl DbxMcpServer {
|
||||
}
|
||||
let status = owner.status();
|
||||
self.save_mcp_sql_history(
|
||||
"dbx_execute_batch",
|
||||
&refreshed.connection,
|
||||
&session.database,
|
||||
sql,
|
||||
@@ -1555,6 +1740,7 @@ impl DbxMcpServer {
|
||||
results.iter().map(|result| result.result.affected_rows).sum::<u64>().min(i64::MAX as u64) as i64
|
||||
});
|
||||
self.save_mcp_sql_history(
|
||||
"dbx_execute_batch",
|
||||
connection,
|
||||
&database,
|
||||
sql,
|
||||
@@ -1573,8 +1759,17 @@ impl DbxMcpServer {
|
||||
tool_result
|
||||
}
|
||||
Err(error) => {
|
||||
self.save_mcp_sql_history(connection, &database, sql, started_at, false, Some(error.clone()), None)
|
||||
.await;
|
||||
self.save_mcp_sql_history(
|
||||
"dbx_execute_batch",
|
||||
connection,
|
||||
&database,
|
||||
sql,
|
||||
started_at,
|
||||
false,
|
||||
Some(error.clone()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
backend_tool_error("DBX_BATCH_EXECUTION_ERROR", error)
|
||||
}
|
||||
}
|
||||
@@ -2578,8 +2773,17 @@ impl DbxMcpServer {
|
||||
.await;
|
||||
let success = !result.is_error;
|
||||
let error = result.is_error.then(|| result.content.trim_start_matches("Error: ").to_string());
|
||||
self.save_mcp_sql_history(connection, &pending.database, &pending.statement, started_at, success, error, None)
|
||||
.await;
|
||||
self.save_mcp_sql_history(
|
||||
"dbx_salesforce_apply_write",
|
||||
connection,
|
||||
&pending.database,
|
||||
&pending.statement,
|
||||
started_at,
|
||||
success,
|
||||
error,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
if !success {
|
||||
return agent_result(result);
|
||||
}
|
||||
@@ -2864,6 +3068,71 @@ impl DbxMcpServer {
|
||||
|
||||
#[tool_handler(router = self.tool_router)]
|
||||
impl ServerHandler for DbxMcpServer {
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: rmcp::model::CallToolRequestParams,
|
||||
context: rmcp::service::RequestContext<rmcp::RoleServer>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
let started = Instant::now();
|
||||
let args = serde_json::Value::Object(request.arguments.clone().unwrap_or_default());
|
||||
let arg = |name: &str| args.get(name).and_then(|value| value.as_str()).unwrap_or_default().to_string();
|
||||
let entry = HistoryEntry {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
connection_id: arg("connection_id"),
|
||||
connection_name: arg("connection_name"),
|
||||
database: arg("database"),
|
||||
sql: arg("sql"),
|
||||
executed_at: chrono::Utc::now().to_rfc3339(),
|
||||
execution_time_ms: 0,
|
||||
success: false,
|
||||
error: None,
|
||||
activity_kind: "mcp".into(),
|
||||
operation: request.name.to_string(),
|
||||
target: arg("table"),
|
||||
affected_rows: None,
|
||||
rollback_sql: None,
|
||||
details_json: Some(r#"{"source":"mcp"}"#.into()),
|
||||
source: "mcp".into(),
|
||||
mcp_tool_name: Some(request.name.to_string()),
|
||||
mcp_request_json: Some(bounded_history_request(&args)),
|
||||
mcp_response_json: None,
|
||||
mcp_session_id: args.get("session_id").and_then(|v| v.as_str()).map(str::to_owned),
|
||||
};
|
||||
let mut guard = CallHistoryGuard { backend: self.backend.clone(), fallback: Some(entry.clone()), started };
|
||||
CALL_HISTORY.scope(std::sync::Mutex::new(entry), async {
|
||||
let cancellation = context.ct.clone();
|
||||
let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
|
||||
let result = tokio::select! {
|
||||
result = self.tool_router.call(tcc) => result,
|
||||
_ = cancellation.cancelled() => Ok(tool_error("REQUEST_CANCELLED", "The MCP request was cancelled.")),
|
||||
};
|
||||
let mut entry = CALL_HISTORY.with(|entry| entry.lock().unwrap().clone());
|
||||
entry.execution_time_ms = started.elapsed().as_millis();
|
||||
entry.success = result.as_ref().is_ok_and(|result| result.is_error != Some(true));
|
||||
// Keep the complete response within a bounded, redacted payload so
|
||||
// the history detail view can be used for troubleshooting without
|
||||
// allowing a large result to grow the local database indefinitely.
|
||||
entry.mcp_response_json = Some(match &result {
|
||||
Ok(result) => bounded_history_response(result),
|
||||
Err(error) => bounded_history_response(error),
|
||||
});
|
||||
if !entry.success {
|
||||
entry.error = Some(if cancellation.is_cancelled() { "MCP request cancelled" } else { "MCP tool call failed (see response summary)" }.into());
|
||||
}
|
||||
// Persist in a separate task so cancellation during the write cannot
|
||||
// lose the record or schedule a second write from the drop guard.
|
||||
guard.fallback = None;
|
||||
let backend = self.backend.clone();
|
||||
let write = tokio::spawn(async move {
|
||||
if let Err(error) = backend.save_history_entry(&entry).await {
|
||||
log::warn!("failed to save MCP call history: {error}");
|
||||
}
|
||||
});
|
||||
let _ = write.await;
|
||||
result
|
||||
}).await
|
||||
}
|
||||
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().enable_resources().build())
|
||||
.with_server_info(Implementation::new("dbx", env!("CARGO_PKG_VERSION")))
|
||||
@@ -3307,6 +3576,11 @@ fn resolved_connection(
|
||||
connection: dbx_core::models::connection::ConnectionConfig,
|
||||
group_path: Option<&dbx_core::mcp_policy::McpConnectionGroupPath>,
|
||||
) -> ResolvedConnection {
|
||||
let _ = CALL_HISTORY.try_with(|entry| {
|
||||
let mut entry = entry.lock().unwrap();
|
||||
entry.connection_id = connection.id.clone();
|
||||
entry.connection_name = connection.name.clone();
|
||||
});
|
||||
let database_scope = database_scope_for_connection(&policy, &connection);
|
||||
let group_ids = group_path.map(|path| path.ids.clone()).unwrap_or_default();
|
||||
ResolvedConnection { connection, policy, database_scope, group_ids }
|
||||
@@ -4208,6 +4482,25 @@ fn format_schema_context(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn history_response_keeps_long_text_and_all_rows() {
|
||||
let value = serde_json::json!({"content": [{"type": "text", "text": "x".repeat(4000)}], "rows": (0..100).collect::<Vec<_>>()});
|
||||
let stored: serde_json::Value = serde_json::from_str(&super::bounded_history_response(&value)).unwrap();
|
||||
assert_eq!(stored, value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_response_redacts_nested_json_and_marks_size_limit() {
|
||||
let value =
|
||||
serde_json::json!({"content": [{"type": "text", "text": r#"{"password":"secret-value","rows":[1,2]}"#}]});
|
||||
let stored = super::bounded_history_response(&value);
|
||||
assert!(!stored.contains("secret-value"));
|
||||
assert!(stored.contains("[redacted]"));
|
||||
let large = super::bounded_history_response(&serde_json::json!({"text": "中".repeat(70000)}));
|
||||
assert!(large.len() <= 64 * 1024);
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&large).unwrap()["truncated"], true);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use dbx_core::models::connection::ConnectionConfig;
|
||||
|
||||
@@ -1304,6 +1304,12 @@ async fn serve() {
|
||||
get(routes::app_settings::load_history_retention_limit)
|
||||
.put(routes::app_settings::save_history_retention_limit),
|
||||
)
|
||||
.route(
|
||||
"/app-settings/mcp-history-retention-limit",
|
||||
get(routes::app_settings::load_mcp_history_retention_limit)
|
||||
.put(routes::app_settings::save_mcp_history_retention_limit),
|
||||
)
|
||||
.route("/app-settings/mcp-history-retention-cleanup", post(routes::app_settings::cleanup_mcp_history_retention))
|
||||
.route(
|
||||
"/app-settings/max-retries",
|
||||
get(routes::app_settings::load_max_retries).put(routes::app_settings::save_max_retries),
|
||||
|
||||
@@ -154,6 +154,23 @@ pub async fn save_history_retention_limit(
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn load_mcp_history_retention_limit(State(state): State<Arc<WebState>>) -> Result<Json<u32>, AppError> {
|
||||
state.app.storage.load_mcp_history_retention_limit().await.map(Json).map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub async fn save_mcp_history_retention_limit(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<SaveHistoryRetentionLimitRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
dbx_core::history::validate_history_retention_limit(body.limit).map_err(AppError::bad_request)?;
|
||||
state.app.storage.save_mcp_history_retention_limit(body.limit).await.map_err(AppError::from)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn cleanup_mcp_history_retention(State(state): State<Arc<WebState>>) -> Result<Json<u64>, AppError> {
|
||||
state.app.storage.cleanup_mcp_history_retention().await.map(Json).map_err(AppError::from)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveMaxRetriesRequest {
|
||||
|
||||
@@ -15,6 +15,11 @@ pub struct HistoryQuery {
|
||||
pub activity_kind: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HistoryClearQuery {
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveHistoryRequest {
|
||||
@@ -53,8 +58,15 @@ pub async fn load_history_connection_options(
|
||||
state.app.storage.load_history_connection_options().await.map(Json).map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub async fn clear_history(State(state): State<Arc<WebState>>) -> Result<Json<()>, AppError> {
|
||||
state.app.storage.clear_history().await.map_err(AppError::from)?;
|
||||
pub async fn clear_history(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(query): Query<HistoryClearQuery>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
if let Some(source) = query.source.as_deref().filter(|source| !source.trim().is_empty()) {
|
||||
state.app.storage.clear_history_by_source(source).await.map_err(AppError::from)?;
|
||||
} else {
|
||||
state.app.storage.clear_history().await.map_err(AppError::from)?;
|
||||
}
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,16 @@ pub async fn save_history_retention_limit(state: State<'_, Arc<AppState>>, limit
|
||||
state.storage.save_history_retention_limit(limit).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_mcp_history_retention_limit(state: State<'_, Arc<AppState>>) -> Result<u32, String> {
|
||||
state.storage.load_mcp_history_retention_limit().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_mcp_history_retention_limit(state: State<'_, Arc<AppState>>, limit: u32) -> Result<(), String> {
|
||||
state.storage.save_mcp_history_retention_limit(limit).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_max_retries(state: State<'_, Arc<AppState>>) -> Result<u32, String> {
|
||||
state.storage.load_max_retries().await
|
||||
|
||||
@@ -39,6 +39,16 @@ pub async fn clear_history(state: State<'_, Arc<AppState>>) -> Result<(), String
|
||||
state.storage.clear_history().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_history_by_source(state: State<'_, Arc<AppState>>, source: String) -> Result<(), String> {
|
||||
state.storage.clear_history_by_source(&source).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cleanup_mcp_history_retention(state: State<'_, Arc<AppState>>) -> Result<u64, String> {
|
||||
state.storage.cleanup_mcp_history_retention().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_history_entry(state: State<'_, Arc<AppState>>, id: String) -> Result<(), String> {
|
||||
state.storage.delete_history_entry(&id).await
|
||||
|
||||
@@ -1845,6 +1845,8 @@ pub fn run() {
|
||||
commands::app_settings::save_max_agent_turns,
|
||||
commands::app_settings::load_history_retention_limit,
|
||||
commands::app_settings::save_history_retention_limit,
|
||||
commands::app_settings::load_mcp_history_retention_limit,
|
||||
commands::app_settings::save_mcp_history_retention_limit,
|
||||
commands::app_settings::load_max_retries,
|
||||
commands::app_settings::save_max_retries,
|
||||
commands::app_settings::set_app_locale,
|
||||
@@ -2661,6 +2663,8 @@ pub fn run() {
|
||||
commands::history::search_history,
|
||||
commands::history::load_history_connection_options,
|
||||
commands::history::clear_history,
|
||||
commands::history::clear_history_by_source,
|
||||
commands::history::cleanup_mcp_history_retention,
|
||||
commands::history::delete_history_entry,
|
||||
commands::mcp::check_mcp_server_status,
|
||||
commands::mcp::install_mcp_server,
|
||||
|
||||
Reference in New Issue
Block a user