diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index e8a105149..9a80aa6a3 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -73,6 +73,8 @@ import { isPreviewTab } from "@/lib/tabs/tabPresentation"; import { supportsSqlFileExecution } from "@/lib/database/databaseCapabilities"; import { classifyAiSqlExecution } from "@/lib/ai/aiSqlExecutionPolicy"; import { buildAppendedEditorSql } from "@/lib/ai/aiSqlAppend"; +import { assessProductionSql } from "@/lib/database/productionSafety"; +import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import { buildHistoryAiAnalysisPrompt } from "@/lib/history/historyAiAnalysis"; import { countAvailableAgentDriverUpdates, type AgentDriverUpdateBadgeState } from "@/lib/connection/agentDriverUpdateBadge"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; @@ -814,7 +816,22 @@ async function saveActiveObjectSource(tab: QueryTab): Promise { name: source.name, source: tab.sql, }); - await executeObjectSourceSave(tab.connectionId, tab.database, databaseType, statements, source.schema || tab.schema); + const executableSql = statements.filter((sql) => sql.trim()).join(";\n"); + if (executableSql.trim()) { + const saved = await executeWithProductionSqlGuard({ + connection, + database: tab.database, + sql: executableSql, + source: t("production.sourceObjectSource"), + execute: async () => { + await executeObjectSourceSave(tab.connectionId, tab.database, databaseType, statements, source.schema || tab.schema); + return true; + }, + }); + if (!saved) return false; + } else { + await executeObjectSourceSave(tab.connectionId, tab.database, databaseType, statements, source.schema || tab.schema); + } queryStore.markTabClean(tab); toast(t("objects.sourceSaved"), 2000); return true; @@ -1286,6 +1303,12 @@ function onAiRequestAutoExecuteSql(sql: string) { queryStore.updateSql(tabId, buildAppendedEditorSql(activeTab.value?.sql || "", sql)); selectedSql.value = ""; + const productionAssessment = assessProductionSql(sql, activeConnection.value, activeTab.value?.database); + if (productionAssessment.active && productionAssessment.isMutation) { + toast(t("production.aiReviewRequired"), 5000); + return; + } + const decision = classifyAiSqlExecution(sql, activeConnection.value); if (decision.action === "block") { toast(t("ai.autoSqlBlocked"), 5000); diff --git a/apps/desktop/src/components/admin/DamengJobAdmin.vue b/apps/desktop/src/components/admin/DamengJobAdmin.vue index 945fe656b..47a71842b 100644 --- a/apps/desktop/src/components/admin/DamengJobAdmin.vue +++ b/apps/desktop/src/components/admin/DamengJobAdmin.vue @@ -10,6 +10,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { useToast } from "@/composables/useToast"; import type { ConnectionConfig } from "@/types/database"; import * as api from "@/lib/backend/api"; +import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import { DAMENG_JOB_ENVIRONMENT_SQL, damengClearJobHistoriesSql, @@ -169,10 +170,18 @@ async function applyPendingSql() { applying.value = true; try { await ensureConnection(); - await api.executeMulti(props.connection.id, executionDatabase.value, pendingSql.value, undefined, undefined, { - maxRows: 1000, - useTransaction: pendingUseTransaction.value, + const result = await executeWithProductionSqlGuard({ + connection: props.connection, + database: executionDatabase.value, + sql: pendingSql.value, + source: t("production.sourceAdmin"), + execute: () => + api.executeMulti(props.connection.id, executionDatabase.value, pendingSql.value, undefined, undefined, { + maxRows: 1000, + useTransaction: pendingUseTransaction.value, + }), }); + if (!result) return; toast(t("damengJobAdmin.applySuccess"), 2500); previewDialogOpen.value = false; await (pendingAfterApply.value?.() ?? Promise.resolve()); diff --git a/apps/desktop/src/components/admin/DatabaseUserAdmin.vue b/apps/desktop/src/components/admin/DatabaseUserAdmin.vue index ee0ebddf4..e23af1cb1 100644 --- a/apps/desktop/src/components/admin/DatabaseUserAdmin.vue +++ b/apps/desktop/src/components/admin/DatabaseUserAdmin.vue @@ -13,6 +13,7 @@ import { useToast } from "@/composables/useToast"; import { useSqlHighlighter } from "@/composables/useSqlHighlighter"; import type { ConnectionConfig } from "@/types/database"; import * as api from "@/lib/backend/api"; +import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import { grantsFromQueryResult, getDatabaseUserAdminProvider, supportsDatabaseUserAdmin, type DatabaseUserIdentity, type PrivilegeScope } from "@/lib/database/databaseUserAdmin"; const props = defineProps<{ @@ -165,7 +166,14 @@ async function applyPendingSql() { if (!pendingSql.value.trim()) return; applying.value = true; try { - await api.executeMulti(props.connection.id, "", pendingSql.value, undefined, undefined, { maxRows: 1000 }); + const result = await executeWithProductionSqlGuard({ + connection: props.connection, + database: "", + sql: pendingSql.value, + source: t("production.sourceAdmin"), + execute: () => api.executeMulti(props.connection.id, "", pendingSql.value, undefined, undefined, { maxRows: 1000 }), + }); + if (!result) return; toast(t("userAdmin.applySuccess"), 2500); sqlDialogOpen.value = false; await (pendingAfterApply.value?.() ?? Promise.resolve()); diff --git a/apps/desktop/src/components/common/ProductionContextBadge.vue b/apps/desktop/src/components/common/ProductionContextBadge.vue new file mode 100644 index 000000000..b8b693b7e --- /dev/null +++ b/apps/desktop/src/components/common/ProductionContextBadge.vue @@ -0,0 +1,20 @@ + + + diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index c3d944d61..5328cbcc3 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -43,10 +43,10 @@ import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapS import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth"; import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi"; import { isSqlServerLegacyCompatibilityMode, requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityMode, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility"; -import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, Search, ShieldCheck, Square, Trash2 } from "@lucide/vue"; +import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, RefreshCw, Search, ShieldAlert, ShieldCheck, Square, Trash2 } from "@lucide/vue"; import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connection/connectionVisibleDatabases"; import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, isSystemDatabaseName, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases"; -import { isSchemaAware } from "@/lib/database/databaseFeatureSupport"; +import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport"; import VisibleSchemasDialog from "@/components/sidebar/VisibleSchemasDialog.vue"; import { oceanbaseModeConnectionPatch, oceanbaseSubModeFromConfig } from "@/lib/database/oceanbaseConnectionMode"; import { translateBackendError } from "@/i18n/backend-errors"; @@ -57,6 +57,7 @@ type DbCategory = { key: string; title: string; options: DbOption[] }; type DialogStep = "select" | "config"; type DbPickerView = "icon" | "list"; export type ConfigTab = "connection" | "advanced" | "tls" | "transport"; +type ProductionScope = "connection" | "databases"; type MqTokenSigningMode = "none" | "hs256" | "rs256"; type NacosAuthKind = NacosAuthConfig["kind"]; type DremioConnectionMode = "arrow-flight-sql" | "legacy"; @@ -136,6 +137,13 @@ const visibleDatabaseSelection = ref>(new Set()); const visibleDatabaseSearchText = ref(""); const visibleDatabaseError = ref(""); const visibleDatabaseShowSystem = ref(false); +const showProductionDatabasesDialog = ref(false); +const isLoadingProductionDatabases = ref(false); +const productionDatabaseNames = ref([]); +const productionDatabaseSelection = ref>(new Set()); +const productionDatabaseSearchText = ref(""); +const productionDatabaseError = ref(""); +const productionProtectionEnabled = ref(false); const showVisibleSchemasDialog = ref(false); const isLoadingVisibleSchemas = ref(false); const visibleSchemaNames = ref([]); @@ -189,6 +197,8 @@ const defaultForm = (): ConnectionForm => ({ informix_server: "", external_config: undefined, read_only: false, + is_production: false, + production_databases: [], visible_databases: undefined, }); @@ -1430,8 +1440,11 @@ watch( informix_server: config.informix_server || "", external_config: config.external_config, read_only: config.read_only || false, + is_production: config.is_production || false, + production_databases: config.production_databases || [], visible_databases: config.visible_databases, }; + productionProtectionEnabled.value = !!config.is_production || (config.production_databases?.length ?? 0) > 0; connectionUrlInput.value = config.db_type === "h2" && config.connection_string ? config.connection_string : ""; appliedConnectionUrlInput.value = connectionUrlInput.value.trim(); if (config.db_type === "mq") { @@ -1471,6 +1484,7 @@ watch( } else { editingId.value = null; form.value = defaultForm(); + productionProtectionEnabled.value = false; selectedTransportLayerId.value = null; selectedType.value = "mysql"; customDriverName.value = ""; @@ -1893,6 +1907,37 @@ const visibleDatabaseHasSystemDatabases = computed(() => { const connection = connectionConfigSnapshotForVisibleDatabases(); return visibleDatabaseNames.value.some((database) => isSystemDatabaseName(connection.db_type, database)); }); +const filteredProductionDatabaseNames = computed(() => { + const query = productionDatabaseSearchText.value.trim().toLowerCase(); + if (!query) return productionDatabaseNames.value; + return productionDatabaseNames.value.filter((name) => name.toLowerCase().includes(query)); +}); +const productionDatabaseSelectedCount = computed(() => productionDatabaseSelection.value.size); +const productionDatabaseCanSave = computed(() => productionDatabaseNames.value.length > 0 && productionDatabaseSelection.value.size > 0); +const productionDatabaseSummary = computed(() => { + const selected = form.value.production_databases?.length || 0; + if (!selected) return t("production.noDatabasesSelected"); + if (!productionDatabaseNames.value.length) return t("production.databasesConfiguredCount", { count: selected }); + return t("production.databasesSelectedCount", { selected, total: productionDatabaseNames.value.length }); +}); +const productionScope = computed({ + get: () => (isSingleDatabase(form.value.db_type) || form.value.is_production ? "connection" : "databases"), + set: (scope) => { + form.value.is_production = isSingleDatabase(form.value.db_type) || scope === "connection"; + }, +}); +const canSelectProductionDatabases = computed(() => !isSingleDatabase(form.value.db_type)); + +function setProductionProtectionEnabled(enabled: boolean) { + productionProtectionEnabled.value = enabled; + if (!enabled) { + form.value.is_production = false; + form.value.production_databases = []; + } else if (!form.value.is_production && !form.value.production_databases?.length) { + // Enabling protection starts with the broadest scope until the user chooses a narrower one. + form.value.is_production = true; + } +} const canChooseVisibleSchemas = computed(() => isSchemaAware(form.value.db_type)); const visibleSchemasDatabaseKey = computed(() => form.value.database || ""); const hasVisibleSchemaFilter = computed(() => { @@ -2254,6 +2299,14 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { } if (!config.one_time) config.one_time = undefined; if (!config.read_only) config.read_only = undefined; + if (isSingleDatabase(config.db_type) && config.production_databases?.length) { + // Single-database drivers expose schemas or internal names, not independently selectable databases. + config.is_production = true; + config.production_databases = []; + } + if (!config.is_production) config.is_production = undefined; + config.production_databases = [...new Set((config.production_databases || []).map((database) => database.trim()).filter(Boolean))]; + if (!config.production_databases.length) config.production_databases = undefined; if (config.db_type === "mq") { const mqConfig = buildMqAdminConfig(); config.external_config = mqConfig; @@ -2685,6 +2738,16 @@ function resetVisibleDatabaseDraftState() { visibleDatabaseShowSystem.value = false; } +function resetProductionDatabaseDraftState() { + showProductionDatabasesDialog.value = false; + isLoadingProductionDatabases.value = false; + productionDatabaseNames.value = []; + productionDatabaseSelection.value = new Set(); + productionDatabaseSearchText.value = ""; + productionDatabaseError.value = ""; + productionProtectionEnabled.value = false; +} + /** Silently load database names so the summary count shows a real total. */ async function preloadVisibleDatabaseNames() { if (!ensureConnectionHostResolvedFromUrl()) return; @@ -2755,6 +2818,88 @@ async function loadVisibleDatabaseNames(connectionId: string, config: Connection return (await api.listDatabases(connectionId)).map((database) => database.name); } +function normalizeProductionDatabaseSelection(selectedNames: Iterable, databaseNames: string[]): string[] { + const available = new Map(databaseNames.map((name) => [name.toLowerCase(), name])); + const selected = new Set(); + for (const name of selectedNames) { + const canonicalName = available.get(name.toLowerCase()); + if (canonicalName) selected.add(canonicalName); + } + return [...selected]; +} + +function initialProductionDatabaseSelection(databaseNames: string[]): string[] { + const configured = form.value.production_databases || []; + // A new database-level safeguard starts broad; users can explicitly narrow it in the picker. + return configured.length ? normalizeProductionDatabaseSelection(configured, databaseNames) : databaseNames; +} + +async function loadProductionDatabaseNames(connectionId: string, config: ConnectionConfig): Promise { + if (config.db_type === "redis") { + return (await api.redisListDatabases(connectionId)).map((database) => String(database.db)); + } + if (config.db_type === "mongodb") { + return api.mongoListDatabases(connectionId); + } + return (await api.listDatabases(connectionId)).map((database) => database.name); +} + +async function openProductionDatabasesPicker() { + if (!ensureConnectionHostResolvedFromUrl() || !productionProtectionEnabled.value || form.value.is_production || isLoadingProductionDatabases.value) return; + showProductionDatabasesDialog.value = true; + await reloadProductionDatabases(); +} + +async function reloadProductionDatabases() { + if (isLoadingProductionDatabases.value) return; + + isLoadingProductionDatabases.value = true; + productionDatabaseError.value = ""; + productionDatabaseSearchText.value = ""; + const draftId = `__production_database_draft_${uuid()}`; + try { + const draftConfig = { + ...connectionConfigForSubmit(draftId), + id: draftId, + one_time: true, + }; + await api.connectDb(draftConfig); + productionDatabaseNames.value = await loadProductionDatabaseNames(draftId, draftConfig); + productionDatabaseSelection.value = new Set(initialProductionDatabaseSelection(productionDatabaseNames.value)); + } catch (e: any) { + productionDatabaseNames.value = []; + productionDatabaseSelection.value = new Set(); + productionDatabaseError.value = mongodbAuthFailureHint(errorMessage(e)); + } finally { + await api.disconnectDb(draftId).catch(() => undefined); + isLoadingProductionDatabases.value = false; + } +} + +function toggleProductionDatabase(database: string) { + const next = new Set(productionDatabaseSelection.value); + if (next.has(database)) next.delete(database); + else next.add(database); + productionDatabaseSelection.value = next; +} + +function selectAllProductionDatabases() { + productionDatabaseSelection.value = new Set(productionDatabaseNames.value); +} + +function clearProductionDatabaseSelection() { + productionDatabaseSelection.value = new Set(); +} + +function saveProductionDatabaseSelection() { + if (!productionDatabaseCanSave.value) return; + // A database selection is always narrower than a connection-wide marker. + productionProtectionEnabled.value = true; + form.value.is_production = false; + form.value.production_databases = normalizeProductionDatabaseSelection(productionDatabaseSelection.value, productionDatabaseNames.value); + showProductionDatabasesDialog.value = false; +} + function toggleVisibleDatabase(database: string) { const next = new Set(visibleDatabaseSelection.value); if (next.has(database)) next.delete(database); @@ -2905,6 +3050,7 @@ function resetForm() { dbSearchQuery.value = ""; configTab.value = "connection"; resetVisibleDatabaseDraftState(); + resetProductionDatabaseDraftState(); resetVisibleSchemasState(); resetTestState(); } @@ -5084,6 +5230,39 @@ function openExternalUrl(url: string) { {{ t("connection.readOnlyHint") }} +
+ +
+
+ + +
+

{{ t("production.disabledDescription") }}

+ +
+
@@ -5505,6 +5684,74 @@ function openExternalUrl(url: string) { + + + + {{ t("production.databasePickerTitle") }} +

+ {{ t("production.databasePickerDescription", { connection: form.name || selectedProfile().label }) }} +

+
+ +
+ + +
+ +
+ {{ t("production.databasesSelectedCount", { selected: productionDatabaseSelectedCount, total: productionDatabaseNames.length }) }} +
+ + +
+
+

+ {{ t("production.databaseSelectionRequired") }} +

+ +
+
+ + {{ t("common.loading") }} +
+
+

{{ t("production.databaseLoadFailed", { message: productionDatabaseError }) }}

+ +
+
+ {{ productionDatabaseNames.length ? t("grid.noSearchResults") : t("production.noDatabasesAvailable") }} +
+ +
+ + + + + +
+
+ { + executing.value = true; + syncErrors.value = []; + executeTotal.value = syncPlan.value.syncStatements.length; + executedCount.value = 0; + await store.ensureConnected(targetConnectionId.value); + const statements = syncPlan.value.syncStatements; + for (let index = 0; index < statements.length; index += SYNC_EXECUTE_BATCH_SIZE) { + const batch = statements.slice(index, index + SYNC_EXECUTE_BATCH_SIZE); try { - await api.executeBatch(targetConnectionId.value, targetDatabase.value, [stmt], targetSchema.value); - } catch (singleError: any) { - syncErrors.value.push({ sql: stmt, error: singleError?.message || String(singleError) }); + await api.executeBatch(targetConnectionId.value, targetDatabase.value, batch, targetSchema.value); + executedCount.value += batch.length; + } catch (e: any) { + for (const stmt of batch) { + try { + await api.executeBatch(targetConnectionId.value, targetDatabase.value, [stmt], targetSchema.value); + } catch (singleError: any) { + syncErrors.value.push({ sql: stmt, error: singleError?.message || String(singleError) }); + } + executedCount.value++; + } } - executedCount.value++; } - } - } - const failed = syncErrors.value.length; + return syncErrors.value.length; + }, + }); + if (failed === undefined) return; if (failed === 0) { toast(t("dataCompare.syncSuccess"), 2000); } else { diff --git a/apps/desktop/src/components/diff/SchemaDiffDialog.vue b/apps/desktop/src/components/diff/SchemaDiffDialog.vue index 930d25b8d..045362e2d 100644 --- a/apps/desktop/src/components/diff/SchemaDiffDialog.vue +++ b/apps/desktop/src/components/diff/SchemaDiffDialog.vue @@ -7,6 +7,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { useToast } from "@/composables/useToast"; import { GitCompareArrows, ArrowLeft, Play, Loader2, Maximize2, Minimize2, AlertTriangle, CircleCheck } from "@lucide/vue"; import * as api from "@/lib/backend/api"; +import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import { useSchemaDiffConfig } from "@/composables/useSchemaDiffConfig"; import SchemaDiffConfigStep from "@/components/diff/SchemaDiffConfigStep.vue"; import SchemaDiffObjectTree from "@/components/diff/SchemaDiffObjectTree.vue"; @@ -496,7 +497,14 @@ async function handleExecuteScript() { executing.value = true; try { - await api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value); + const result = await executeWithProductionSqlGuard({ + connection: store.getConfig(targetConnectionId.value), + database: targetDatabase.value, + sql: deploySql.value, + source: t("production.sourceSchemaDiff"), + execute: () => api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value), + }); + if (!result) return; toast(t("diff.executeSuccess"), 3000); } catch (e: any) { toast(e?.message || String(e), 5000); @@ -621,7 +629,14 @@ async function onConfirmDeploy() { showConfirmDialog.value = false; executing.value = true; try { - const result = await api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value); + const result = await executeWithProductionSqlGuard({ + connection: store.getConfig(targetConnectionId.value), + database: targetDatabase.value, + sql: deploySql.value, + source: t("production.sourceSchemaDiff"), + execute: () => api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value), + }); + if (!result) return; deployResult.value = { success: true, message: t("diff.deploySuccess"), diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 45c60b146..a29bd206d 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -57,6 +57,9 @@ import { buildAiContext, runAgentStream, isVectorDbType, isValidActionForMode, d import { formatAiModelOption } from "@/lib/ai/aiModelPresentation"; import type { AgentEvent } from "@/lib/backend/tauri"; import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan"; +import { extractFirstSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy"; +import { productionContextForDatabase } from "@/lib/database/productionSafety"; +import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue"; import { buildAiAgentStepItems, toolCallStepKey, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation"; import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter"; import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender"; @@ -496,6 +499,8 @@ const proposalConfirmMessage = computed(() => { let allowWriteSqlForNextRun = false; +const productionContext = computed(() => productionContextForDatabase(props.connection, props.tab?.database)); + function proposalContainsWriteSql(content: string) { return /\b(insert|update|delete|replace|merge|create|alter|drop|truncate|rename|grant|revoke)\b/i.test(content); } @@ -505,6 +510,12 @@ function sendProposalReply(positive: boolean) { if (isGenerating.value) return; const target = proposalConfirmMessage.value; if (!target) return; + if (positive && productionContext.value.active && proposalContainsWriteSql(target.content)) { + const sql = extractFirstSqlCodeBlock(target.content); + if (sql) emit("replaceSql", sql); + toast(t("production.aiReviewRequired"), 5000); + return; + } const isZh = containsChinese(target.content || ""); const replyZh = positive ? "请执行上面你刚提议的操作,不要再反问确认。" : "不用执行上面提到的操作,继续当前对话。"; const replyEn = positive ? "Execute the action you just proposed above; do not ask for confirmation again." : "Do not execute the action mentioned above; continue the current conversation."; @@ -1396,7 +1407,8 @@ async function send() { const requestedAction = activeAction.value; const requestedMode = assistantMode.value; - const allowWriteSql = requestedMode === "agent" && allowWriteSqlForNextRun; + // Agent confirmation cannot grant autonomous writes while the active database is production. + const allowWriteSql = requestedMode === "agent" && allowWriteSqlForNextRun && !productionContext.value.active; allowWriteSqlForNextRun = false; isGenerating.value = true; messages.value.push({ role: "assistant", content: "" }); @@ -1483,6 +1495,7 @@ async function send() { instruction: modelInstruction, assistantContent: msg?.content || "", connection: props.connection, + database: props.tab?.database, }); if (msg && requestedMode === "agent") msg.agentSteps = buildAiAgentStepItems(agentPlan); if (agentPlan.handoffSql) emit("requestAutoExecuteSql", agentPlan.handoffSql); @@ -1739,6 +1752,7 @@ async function openExternalUrl(url: string) { {{ chatTitle }} + diff --git a/apps/desktop/src/components/editor/QueryHistory.vue b/apps/desktop/src/components/editor/QueryHistory.vue index 1faed1fd7..424e209dd 100644 --- a/apps/desktop/src/components/editor/QueryHistory.vue +++ b/apps/desktop/src/components/editor/QueryHistory.vue @@ -9,6 +9,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from " import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import { useHistoryStore } from "@/stores/historyStore"; +import { useConnectionStore } from "@/stores/connectionStore"; import { useToast } from "@/composables/useToast"; import { resolveHistoryActivityKind } from "@/lib/history/historyActivityKind"; import { canRollbackHistoryEntry } from "@/lib/history/historyAiAnalysis"; @@ -16,12 +17,14 @@ import { hasHistoryDateRange, historyDateRangeIsValid, historyEntryMatchesDateRa import { HISTORY_ROW_HEIGHT, HISTORY_SCROLL_BUFFER, shouldVirtualizeHistory } from "@/lib/history/historyVirtualList"; import type { HistoryEntry } from "@/lib/backend/api"; import { copyToClipboard } from "@/lib/common/clipboard"; +import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import * as api from "@/lib/backend/api"; const { t } = useI18n(); const { toast } = useToast(); const { highlight } = useSqlHighlighter(); const store = useHistoryStore(); +const connectionStore = useConnectionStore(); const emit = defineEmits<{ restore: [sql: string, entry: HistoryEntry]; @@ -226,7 +229,14 @@ async function rollback(entry: HistoryEntry) { isRollingBack.value = true; const start = Date.now(); try { - const result = await api.executeScript(connectionId, entry.database, rollbackSql); + const result = await executeWithProductionSqlGuard({ + connection: connectionStore.getConfig(connectionId), + database: entry.database, + sql: rollbackSql, + source: t("production.sourceQueryHistory"), + execute: () => api.executeScript(connectionId, entry.database, rollbackSql), + }); + if (!result) return; await store.add({ connection_id: connectionId, connection_name: entry.connection_name, diff --git a/apps/desktop/src/components/generate/DataGenerateDialog.vue b/apps/desktop/src/components/generate/DataGenerateDialog.vue index efc6aedaf..bcb30cac9 100644 --- a/apps/desktop/src/components/generate/DataGenerateDialog.vue +++ b/apps/desktop/src/components/generate/DataGenerateDialog.vue @@ -8,6 +8,7 @@ import { displayGeneratedValue, findGeneratorKey, formatGeneratedValue, generate import { quoteTableIdentifier } from "@/lib/table/tableSelectSql"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; +import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import GeneratorParamsPanel from "./params/GeneratorParamsPanel.vue"; import type { ColumnInfo, TableInfo } from "@/types/database"; @@ -450,43 +451,57 @@ async function startInsert() { const cid = props.prefillConnectionId; const db = props.prefillDatabase; if (!cid || !db) return; - executing.value = true; - const perTable: TableResult[] = []; - for (const r of generatedResults.value) { - const stmts = sqlStatementsForTable(r); - const rowCount = r.rows.length; - let ok = 0; - let lastError = ""; - for (let si = 0; si < stmts.length; si++) { - try { - if (generateOptions.useTransaction) { - await api.executeInTransaction(cid, db, [stmts[si]], props.prefillSchema); - } else { - await api.executeQuery(cid, db, stmts[si], props.prefillSchema); + const sql = allSqlStatements().join("\n"); + if (!sql.trim()) return; + try { + await executeWithProductionSqlGuard({ + connection: store.getConfig(cid), + database: db, + sql, + source: t("production.sourceDataGenerate"), + execute: async () => { + executing.value = true; + const perTable: TableResult[] = []; + for (const r of generatedResults.value) { + const stmts = sqlStatementsForTable(r); + const rowCount = r.rows.length; + let ok = 0; + let lastError = ""; + for (let si = 0; si < stmts.length; si++) { + try { + if (generateOptions.useTransaction) { + await api.executeInTransaction(cid, db, [stmts[si]], props.prefillSchema); + } else { + await api.executeQuery(cid, db, stmts[si], props.prefillSchema); + } + if (generateOptions.extendedInsert) { + ok = rowCount; + } else if (!(generateOptions.truncate && si === 0)) { + ok++; + } + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + console.error("[startInsert] SQL error:", msg); + if (!lastError) lastError = msg; + if (generateOptions.extendedInsert) { + ok = 0; + } + if (!generateOptions.continueOnError) break; + } + } + perTable.push({ table: r.tableName, total: rowCount, ok, err: rowCount - ok, error: lastError || undefined }); + if (ok > 0) { + store.invalidateMetadataCache(cid, db, props.prefillSchema || undefined, r.tableName); + } } - if (generateOptions.extendedInsert) { - ok = rowCount; - } else if (!(generateOptions.truncate && si === 0)) { - ok++; - } - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - console.error("[startInsert] SQL error:", msg); - if (!lastError) lastError = msg; - if (generateOptions.extendedInsert) { - ok = 0; - } - if (!generateOptions.continueOnError) break; - } - } - perTable.push({ table: r.tableName, total: rowCount, ok, err: rowCount - ok, error: lastError || undefined }); - if (ok > 0) { - store.invalidateMetadataCache(cid, db, props.prefillSchema || undefined, r.tableName); - } + executeResults.value = perTable; + currentStep.value = "result"; + return true; + }, + }); + } finally { + executing.value = false; } - executeResults.value = perTable; - currentStep.value = "result"; - executing.value = false; } const orderDialogOpen = ref(false); diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue index 01bbf8c15..55a217c23 100644 --- a/apps/desktop/src/components/layout/AppDialogs.vue +++ b/apps/desktop/src/components/layout/AppDialogs.vue @@ -18,6 +18,7 @@ const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/sea const DatabaseExportDialog = defineAsyncComponent(() => import("@/components/export/DatabaseExportDialog.vue")); const DataGenerateDialog = defineAsyncComponent(() => import("@/components/generate/DataGenerateDialog.vue")); import { useConnectionStore } from "@/stores/connectionStore"; +import { useProductionSafetyStore } from "@/stores/productionSafetyStore"; import { useDialogSources } from "@/composables/useDialogSources"; import type { ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; import type { SqlParameterDescriptor, SqlParameterSyntax } from "@/lib/sql/sqlParameters"; @@ -82,7 +83,17 @@ const emit = defineEmits<{ const { t } = useI18n(); const connectionStore = useConnectionStore(); +const productionSafetyStore = useProductionSafetyStore(); const dialogs = useDialogSources(); +const productionConfirmationDetails = computed(() => { + const request = productionSafetyStore.pending; + if (!request) return ""; + return t("production.confirmDetails", { + connection: request.connectionName || "-", + database: request.productionDatabases?.join(", ") || request.database || "-", + source: request.source || "-", + }); +}); const editConfig = computed(() => { const id = connectionStore.editingConnectionId; @@ -136,6 +147,18 @@ watch( @update:suppress-future-prompts="emit('update:suppressDangerConfirm', $event)" @confirm="emit('dangerConfirm')" /> + Promise | void; @@ -156,7 +157,7 @@ const emit = defineEmits<{ openConnectionSettings: [connectionId: string, initialTab: "advanced"]; }>(); -const { t } = useI18n(); +const { t, locale } = useI18n(); const queryStore = useQueryStore(); const connectionStore = useConnectionStore(); const settingsStore = useSettingsStore(); @@ -206,6 +207,13 @@ const activeTableMeta = computed(() => props.activeTab.tableMeta); const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTab)); const activeEffectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.activeConnection)); const activeDataTabExecutionDatabase = computed(() => dataTabExecutionDatabase(props.activeConnection, props.activeTab.database, activeDataTabTableMeta.value?.catalog)); +const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database)); +const productionWatermarkText = computed(() => (locale.value.startsWith("zh") ? "生产环境" : "PROD")); +const productionSessionDetail = computed(() => { + if (!activeProductionContext.value.active) return ""; + if (activeProductionContext.value.reason === "connection") return t("production.connection"); + return activeProductionContext.value.databases.join(", ") || t("production.databases"); +}); function findNodeInTree(nodes: TreeNode[], id: string): TreeNode | undefined { for (const node of nodes) { @@ -732,15 +740,23 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe