feat(safety): add production environment safeguards
This commit is contained in:
1 parent
be66d6f46b
commit
1be66fb115
80 files changed
+4220
-234
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ShieldAlert } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
compact?: boolean;
|
||||
}>(),
|
||||
{ compact: false },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex h-5 shrink-0 items-center gap-1 rounded-[4px] border border-red-500/40 bg-red-500/10 px-1.5 font-mono text-[10px] font-semibold leading-none text-red-700 dark:text-red-300" :class="compact ? 'px-1' : ''" :title="t('production.title')">
|
||||
<ShieldAlert class="h-3 w-3" aria-hidden="true" />
|
||||
<span>PROD</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -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<Set<string>>(new Set());
|
||||
const visibleDatabaseSearchText = ref("");
|
||||
const visibleDatabaseError = ref("");
|
||||
const visibleDatabaseShowSystem = ref(false);
|
||||
const showProductionDatabasesDialog = ref(false);
|
||||
const isLoadingProductionDatabases = ref(false);
|
||||
const productionDatabaseNames = ref<string[]>([]);
|
||||
const productionDatabaseSelection = ref<Set<string>>(new Set());
|
||||
const productionDatabaseSearchText = ref("");
|
||||
const productionDatabaseError = ref("");
|
||||
const productionProtectionEnabled = ref(false);
|
||||
const showVisibleSchemasDialog = ref(false);
|
||||
const isLoadingVisibleSchemas = ref(false);
|
||||
const visibleSchemaNames = ref<string[]>([]);
|
||||
@@ -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<ProductionScope>({
|
||||
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<string>, databaseNames: string[]): string[] {
|
||||
const available = new Map(databaseNames.map((name) => [name.toLowerCase(), name]));
|
||||
const selected = new Set<string>();
|
||||
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<string[]> {
|
||||
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) {
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.readOnlyHint") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4 rounded-[6px] border border-red-500/25 bg-red-500/[0.035] px-3 py-2.5">
|
||||
<Label :class="[connectionLabelSmallClass, 'pt-0.5 text-red-700 dark:text-red-300']">
|
||||
<span class="inline-flex items-center justify-end gap-1"><ShieldAlert class="h-3.5 w-3.5" />PROD</span>
|
||||
</Label>
|
||||
<div class="col-span-3 grid gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">{{ t("production.enable") }}</Label>
|
||||
<Switch :model-value="productionProtectionEnabled" @update:model-value="setProductionProtectionEnabled" />
|
||||
</div>
|
||||
<p v-if="!productionProtectionEnabled" class="text-xs leading-5 text-muted-foreground">{{ t("production.disabledDescription") }}</p>
|
||||
<template v-else>
|
||||
<Label class="text-xs font-medium">{{ t("production.scope") }}</Label>
|
||||
<Tabs v-model="productionScope" class="w-full">
|
||||
<TabsList class="grid h-8 w-full grid-cols-2">
|
||||
<TabsTrigger value="connection" class="text-xs">{{ t("production.allDatabases") }}</TabsTrigger>
|
||||
<TabsTrigger value="databases" class="text-xs" :disabled="!canSelectProductionDatabases" :title="canSelectProductionDatabases ? undefined : t('production.singleDatabaseScopeHint')">{{ t("production.selectedDatabases") }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ productionScope === "connection" ? t("production.connectionDescription") : t("production.databaseDescription") }}</p>
|
||||
<div v-if="productionScope === 'databases'" class="grid gap-1.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-xs font-medium">{{ t("production.databases") }}</Label>
|
||||
<span class="text-xs text-muted-foreground">{{ productionDatabaseSummary }}</span>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" class="justify-start" :disabled="isTesting || isSaving || isLoadingProductionDatabases || !hasRequiredConnectionTarget" @click="openProductionDatabasesPicker">
|
||||
<Loader2 v-if="isLoadingProductionDatabases" class="mr-1.5 h-4 w-4 animate-spin" />
|
||||
<ListFilter v-else class="mr-1.5 h-4 w-4" />
|
||||
{{ t("production.selectDatabases") }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="form.db_type === 'sqlserver'" class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.sqlServerLegacyCompatibilityMode") }}</Label>
|
||||
<div class="col-span-3 flex flex-col gap-1">
|
||||
@@ -5505,6 +5684,74 @@ function openExternalUrl(url: string) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="showProductionDatabasesDialog">
|
||||
<DialogContent class="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("production.databasePickerTitle") }}</DialogTitle>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t("production.databasePickerDescription", { connection: form.name || selectedProfile().label }) }}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
|
||||
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Input v-model="productionDatabaseSearchText" :placeholder="t('production.databaseSearchPlaceholder')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ t("production.databasesSelectedCount", { selected: productionDatabaseSelectedCount, total: productionDatabaseNames.length }) }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" @click="selectAllProductionDatabases">
|
||||
{{ t("visibleDatabases.selectAll") }}
|
||||
</button>
|
||||
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" @click="clearProductionDatabaseSelection">
|
||||
{{ t("visibleDatabases.clear") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!isLoadingProductionDatabases && !productionDatabaseError && !productionDatabaseCanSave" class="text-xs text-destructive">
|
||||
{{ t("production.databaseSelectionRequired") }}
|
||||
</p>
|
||||
|
||||
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
|
||||
<div v-if="isLoadingProductionDatabases" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div v-else-if="productionDatabaseError" class="flex h-full flex-col items-start justify-center gap-3 p-3 text-sm text-destructive">
|
||||
<p>{{ t("production.databaseLoadFailed", { message: productionDatabaseError }) }}</p>
|
||||
<Button type="button" variant="outline" size="sm" @click="reloadProductionDatabases">
|
||||
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("production.retry") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else-if="!filteredProductionDatabaseNames.length" class="p-3 text-sm text-muted-foreground">
|
||||
{{ productionDatabaseNames.length ? t("grid.noSearchResults") : t("production.noDatabasesAvailable") }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="database in filteredProductionDatabaseNames"
|
||||
:key="database"
|
||||
type="button"
|
||||
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
|
||||
@click="toggleProductionDatabase(database)"
|
||||
>
|
||||
<CheckSquare v-if="productionDatabaseSelection.has(database)" class="h-4 w-4 shrink-0 text-primary" />
|
||||
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate">{{ database }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showProductionDatabasesDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button :disabled="isLoadingProductionDatabases || !!productionDatabaseError || !productionDatabaseCanSave" @click="saveProductionDatabaseSelection">
|
||||
{{ t("visibleDatabases.save") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<VisibleSchemasDialog
|
||||
v-model:open="showVisibleSchemasDialog"
|
||||
draft-mode
|
||||
|
||||
@@ -15,6 +15,7 @@ import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import type { DataCompareCellValue, DataCompareModifiedRow, DataCompareResult, DataCompareRow, DataCompareSyncPlan, DataCompareSyncPlanTableOptions } from "@/lib/dataGrid/dataCompare";
|
||||
import type { ColumnInfo, DatabaseType } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { ArrowLeftRight, CheckSquare, ChevronDown, ChevronRight, Copy, GitCompareArrows, Loader2, Play, Square } from "@lucide/vue";
|
||||
|
||||
@@ -713,30 +714,40 @@ async function copySql() {
|
||||
|
||||
async function executeSql() {
|
||||
if (!syncPlan.value.syncSql.trim() || syncPlan.value.syncStatements.length === 0 || executing.value) return;
|
||||
executing.value = true;
|
||||
syncErrors.value = [];
|
||||
executeTotal.value = syncPlan.value.syncStatements.length;
|
||||
executedCount.value = 0;
|
||||
const targetConnection = store.getConfig(targetConnectionId.value);
|
||||
try {
|
||||
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, batch, targetSchema.value);
|
||||
executedCount.value += batch.length;
|
||||
} catch (e: any) {
|
||||
for (const stmt of batch) {
|
||||
const failed = await executeWithProductionSqlGuard({
|
||||
connection: targetConnection,
|
||||
database: targetDatabase.value,
|
||||
sql: syncPlan.value.syncSql,
|
||||
source: t("production.sourceDataCompare"),
|
||||
execute: async () => {
|
||||
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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<ChatMessage | null>(() => {
|
||||
|
||||
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) {
|
||||
<span class="flex flex-1 self-stretch items-center truncate text-xs font-medium" data-tauri-drag-region>
|
||||
{{ chatTitle }}
|
||||
</span>
|
||||
<ProductionContextBadge v-if="productionContext.active" compact />
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="startNewChat" :title="t('ai.newChat')">
|
||||
<MessageSquarePlus class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')"
|
||||
/>
|
||||
<DangerConfirmDialog
|
||||
v-if="productionSafetyStore.pending"
|
||||
:open="true"
|
||||
:title="t('production.confirmTitle')"
|
||||
:message="t('production.confirmMessage')"
|
||||
:details-text="productionConfirmationDetails"
|
||||
:sql="productionSafetyStore.pending.sql"
|
||||
:confirm-label="t('production.confirmAction')"
|
||||
:close-on-confirm="false"
|
||||
@update:open="(open) => !open && productionSafetyStore.cancel()"
|
||||
@confirm="productionSafetyStore.confirm()"
|
||||
/>
|
||||
<SqlParameterDialog
|
||||
v-if="showSqlParameterDialog"
|
||||
:open="showSqlParameterDialog"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
|
||||
import { canReloadUnavailableDataTab } from "@/lib/table/tableDataRefresh";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus } from "@lucide/vue";
|
||||
import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert } from "@lucide/vue";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -80,6 +80,7 @@ import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
|
||||
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
|
||||
import type { QueryTab, ConnectionConfig, TableInfoTab, TreeNode, VectorCollectionMeta, ObjectBrowserViewport } from "@/types/database";
|
||||
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
|
||||
type DataGridHandle = {
|
||||
onToolbarRefresh: () => Promise<void> | 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
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<div class="production-session-shell flex flex-col flex-1 min-h-0" :class="{ 'production-session-shell--active': activeProductionContext.active }">
|
||||
<div v-if="activeProductionContext.active" class="production-session-strip flex h-7 shrink-0 items-center gap-2 border-b border-red-500/35 bg-red-500/10 px-3 text-xs font-semibold text-red-800 shadow-[inset_0_1px_0_rgb(239_68_68_/_0.28)] dark:text-red-200">
|
||||
<ShieldAlert class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span class="font-mono uppercase tracking-normal">{{ t("production.title") }}</span>
|
||||
<span v-if="productionSessionDetail" class="min-w-0 truncate rounded-[4px] border border-red-500/25 bg-background/65 px-1.5 py-0.5 font-medium text-red-700 dark:text-red-200">{{ productionSessionDetail }}</span>
|
||||
</div>
|
||||
<!-- Query mode: editor + results -->
|
||||
<template v-if="activeTab.mode === 'query'">
|
||||
<Splitpanes horizontal class="query-output-splitpanes flex-1 min-h-0 overflow-hidden" @resized="onResultsResized">
|
||||
<Pane class="min-h-0" :size="editorPaneSize" :min-size="resultsPaneOpen ? 15 : 100">
|
||||
<div class="h-full flex flex-col relative">
|
||||
<div v-if="activeProductionContext.active" class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true">
|
||||
<span v-for="index in 4" :key="index" class="production-watermark__label whitespace-nowrap font-mono text-6xl font-extrabold text-red-700/[0.24] dark:text-red-200/[0.2]">{{ productionWatermarkText }}</span>
|
||||
</div>
|
||||
<QueryEditor
|
||||
ref="queryEditorRef"
|
||||
class="flex-1"
|
||||
class="relative z-0 flex-1"
|
||||
:model-value="activeTab.sql"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:database="activeTab.database"
|
||||
@@ -1554,11 +1570,41 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.production-session-shell--active {
|
||||
box-shadow: inset 3px 0 0 color-mix(in oklch, var(--destructive) 78%, transparent);
|
||||
}
|
||||
|
||||
.production-session-strip {
|
||||
background-image: linear-gradient(90deg, color-mix(in oklch, var(--destructive) 14%, transparent), color-mix(in oklch, var(--destructive) 7%, transparent));
|
||||
}
|
||||
|
||||
.query-output-splitpanes :deep(> .splitpanes__splitter) {
|
||||
z-index: 1;
|
||||
flex: 0 0 3px;
|
||||
}
|
||||
|
||||
.production-watermark {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
gap: 3rem;
|
||||
overflow: hidden;
|
||||
padding: 3rem 2.5rem;
|
||||
}
|
||||
|
||||
.production-watermark__label {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
transform: rotate(-22deg);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.production-watermark {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.result-tab-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||
import { useSchemaOptions } from "@/composables/useSchemaOptions";
|
||||
@@ -15,6 +16,7 @@ import { formatDatabaseLabel, isDefaultDatabase } from "@/lib/database/defaultDa
|
||||
import { connectionDisplayName } from "@/lib/tabs/tabPresentation";
|
||||
import { isSingleDatabase, supportsSqlInListPaste, supportsTransaction as supportsTransactionFeature } from "@/lib/database/databaseCapabilities";
|
||||
import { hexToRgba } from "@/lib/common/color";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import type { QueryTab, ConnectionConfig } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -65,6 +67,9 @@ const activeDatabaseOptions = computed(() => {
|
||||
|
||||
const connectionOptionIds = computed(() => connectionStore.connections.map((connection) => connection.id));
|
||||
const activeDatabaseValue = computed(() => props.activeTab.database || "");
|
||||
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
|
||||
const showConnectionProductionBadge = computed(() => activeProductionContext.value.reason === "connection");
|
||||
const showDatabaseProductionBadge = computed(() => activeProductionContext.value.reason === "database");
|
||||
const activeConnectionValue = computed(() => props.activeConnection?.id || "");
|
||||
const activeSchemaValue = computed(() => props.activeTab.schema || "");
|
||||
const supportsExplain = computed(() => {
|
||||
@@ -87,6 +92,10 @@ const transactionTooltip = computed(() => {
|
||||
if (isAgent) return t("toolbar.autoCommitAgent");
|
||||
return isManual ? t("toolbar.manualTransaction") : t("toolbar.autoCommit");
|
||||
});
|
||||
const executeButtonClass = computed(() => {
|
||||
if (props.activeTab.isExecuting) return "";
|
||||
return activeProductionContext.value.active ? "bg-red-500/10 text-red-700 hover:bg-red-500/20 hover:text-red-800 dark:text-red-300 dark:hover:text-red-200" : "bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200";
|
||||
});
|
||||
|
||||
const isTransactionActive = computed(() => !!props.txnSessionId);
|
||||
|
||||
@@ -144,6 +153,11 @@ function databaseDisplayName(database: string): string {
|
||||
function connectionById(connectionId: string): ConnectionConfig | undefined {
|
||||
return connectionStore.getConfig(connectionId);
|
||||
}
|
||||
|
||||
function databaseOptionIsProduction(database: string): boolean {
|
||||
if (!database || props.activeConnection?.is_production) return false;
|
||||
return productionContextForDatabase(props.activeConnection, database).reason === "database";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -155,7 +169,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
||||
:variant="activeTab.isExecuting ? 'destructive' : 'ghost'"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:class="activeTab.isExecuting ? '' : 'bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200'"
|
||||
:class="executeButtonClass"
|
||||
:disabled="activeTab.isCancelling || activeTab.isExplaining || (!activeTab.isExecuting && !executableSql.trim())"
|
||||
@click="activeTab.isExecuting ? emit('cancel') : emit('execute')"
|
||||
>
|
||||
@@ -318,6 +332,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
||||
<div v-if="activeConnection" class="flex min-w-0 items-center gap-1.5">
|
||||
<DatabaseIcon :db-type="connectionIconType(activeConnection)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ label }}</span>
|
||||
<ProductionContextBadge v-if="showConnectionProductionBadge" compact />
|
||||
</div>
|
||||
<span v-else class="truncate text-muted-foreground">{{ t("editor.selectConnection") }}</span>
|
||||
</template>
|
||||
@@ -354,9 +369,13 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
||||
<template #trigger-label="{ label, loading }">
|
||||
<Database class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ loading ? t("common.loading") : label }}</span>
|
||||
<ProductionContextBadge v-if="showDatabaseProductionBadge" compact />
|
||||
</template>
|
||||
<template #option-label="{ label }">
|
||||
<TruncatedTextTooltip :text="label" class="min-w-0 flex-1" side="left" :side-offset="8" />
|
||||
<template #option-label="{ option, label }">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<TruncatedTextTooltip :text="label" class="min-w-0 flex-1" side="left" :side-offset="8" />
|
||||
<ProductionContextBadge v-if="databaseOptionIsProduction(option)" compact />
|
||||
</div>
|
||||
</template>
|
||||
</SearchableSelect>
|
||||
<Tooltip v-if="activeDatabaseValue && !isSingleDb">
|
||||
|
||||
@@ -7,12 +7,15 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { buildCreateExtensionSql, buildDropExtensionSql } from "@/lib/database/dbAdminSql";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import type { ExtensionInfo, TreeNode } from "@/types/database";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
const props = defineProps<{
|
||||
node: TreeNode;
|
||||
@@ -53,7 +56,14 @@ async function installExtension(name: string) {
|
||||
installing.value = name;
|
||||
try {
|
||||
const sql = buildCreateExtensionSql(name, props.node.schema ?? null);
|
||||
await api.executeQuery(props.node.connectionId, props.node.database, sql, props.node.schema ?? undefined);
|
||||
const result = await executeWithProductionSqlGuard({
|
||||
connection: connectionStore.getConfig(props.node.connectionId),
|
||||
database: props.node.database,
|
||||
sql,
|
||||
source: t("production.sourceExtension"),
|
||||
execute: () => api.executeQuery(props.node.connectionId!, props.node.database!, sql, props.node.schema ?? undefined),
|
||||
});
|
||||
if (!result) return;
|
||||
await loadData();
|
||||
emit("close");
|
||||
} catch (e: any) {
|
||||
@@ -68,7 +78,14 @@ async function dropExtension(name: string) {
|
||||
dropping.value = name;
|
||||
try {
|
||||
const sql = buildDropExtensionSql(name, false);
|
||||
await api.executeQuery(props.node.connectionId, props.node.database, sql, props.node.schema ?? undefined);
|
||||
const result = await executeWithProductionSqlGuard({
|
||||
connection: connectionStore.getConfig(props.node.connectionId),
|
||||
database: props.node.database,
|
||||
sql,
|
||||
source: t("production.sourceExtension"),
|
||||
execute: () => api.executeQuery(props.node.connectionId!, props.node.database!, sql, props.node.schema ?? undefined),
|
||||
});
|
||||
if (!result) return;
|
||||
await loadData();
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
|
||||
@@ -80,6 +80,7 @@ import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||||
import DdlViewDialog from "./DdlViewDialog.vue";
|
||||
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, type BatchTableEmptyPlanItem } from "@/lib/sidebar/batchTableEmpty";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
@@ -1145,9 +1146,13 @@ async function confirmRename() {
|
||||
newName,
|
||||
source: source.source,
|
||||
});
|
||||
for (const sql of statements) {
|
||||
await api.executeQuery(props.connection.id, props.database, sql, schema);
|
||||
}
|
||||
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.join(";\n"), async () => {
|
||||
for (const sql of statements) {
|
||||
await api.executeQuery(props.connection.id, props.database, sql, schema);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!executed) return;
|
||||
} else {
|
||||
const sql = await buildRenameObjectSql({
|
||||
databaseType: effectiveDatabaseType.value,
|
||||
@@ -1156,7 +1161,8 @@ async function confirmRename() {
|
||||
oldName: row.name,
|
||||
newName,
|
||||
});
|
||||
await api.executeQuery(props.connection.id, props.database, sql, schema);
|
||||
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, schema));
|
||||
if (!executed) return;
|
||||
}
|
||||
toast(t("contextMenu.renameObjectSuccess", { oldName: row.name, newName }));
|
||||
showRenameDialog.value = false;
|
||||
@@ -1173,7 +1179,8 @@ async function confirmDrop() {
|
||||
const row = dropTarget.value;
|
||||
try {
|
||||
const sql = dropPreviewSql.value || (await buildDropSqlForRow(row, { cascade: canDropTargetCascade.value && dropTableCascade.value }));
|
||||
await api.executeQuery(props.connection.id, props.database, sql);
|
||||
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
|
||||
if (!executed) return;
|
||||
const successKey = row.type === "VIEW" ? "contextMenu.dropViewSuccess" : row.type === "PROCEDURE" ? "contextMenu.dropProcedureSuccess" : row.type === "FUNCTION" ? "contextMenu.dropFunctionSuccess" : "contextMenu.dropTableSuccess";
|
||||
toast(t(successKey, { name: row.name }));
|
||||
closeDroppedTableObjectTabsForRow(row);
|
||||
@@ -1409,11 +1416,20 @@ async function confirmBatchDropTables() {
|
||||
if (targets.length === 0) return;
|
||||
try {
|
||||
const useCascade = canBatchDropCascade.value && batchDropCascade.value;
|
||||
for (const row of targets) {
|
||||
const sql = await buildDropTableSql(tableAdminSqlOptions(row, { cascade: useCascade }));
|
||||
await api.executeQuery(props.connection.id, props.database, sql);
|
||||
closeDroppedTableObjectTabsForRow(row);
|
||||
}
|
||||
const statements = await Promise.all(
|
||||
targets.map(async (row) => ({
|
||||
row,
|
||||
sql: await buildDropTableSql(tableAdminSqlOptions(row, { cascade: useCascade })),
|
||||
})),
|
||||
);
|
||||
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.map(({ sql }) => sql).join(";\n"), async () => {
|
||||
for (const { row, sql } of statements) {
|
||||
await api.executeQuery(props.connection.id, props.database, sql);
|
||||
closeDroppedTableObjectTabsForRow(row);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!executed) return;
|
||||
toast(t("objects.batchDropSuccess", { count: targets.length }));
|
||||
clearTableSelection();
|
||||
await reload();
|
||||
@@ -1468,14 +1484,23 @@ async function confirmBatchTruncateTables() {
|
||||
if (targets.length === 0) return;
|
||||
try {
|
||||
const useCascade = canBatchTruncateCascade.value && batchTruncateCascade.value;
|
||||
await runBatchTableTruncate(
|
||||
targets,
|
||||
async (row) => {
|
||||
const sql = await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: useCascade }));
|
||||
await api.executeQuery(props.connection.id, props.database, sql);
|
||||
},
|
||||
refreshMutatedTableDataTabsForRows,
|
||||
const statements = await Promise.all(
|
||||
targets.map(async (row) => ({
|
||||
row,
|
||||
sql: await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: useCascade })),
|
||||
})),
|
||||
);
|
||||
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.map(({ sql }) => sql).join(";\n"), async () => {
|
||||
await runBatchTableTruncate(
|
||||
statements,
|
||||
async ({ sql }) => {
|
||||
await api.executeQuery(props.connection.id, props.database, sql);
|
||||
},
|
||||
async (succeeded) => refreshMutatedTableDataTabsForRows(succeeded.map(({ row }) => row)),
|
||||
);
|
||||
return true;
|
||||
});
|
||||
if (!executed) return;
|
||||
toast(t("objects.batchTruncateSuccess", { count: targets.length }));
|
||||
clearTableSelection();
|
||||
showBatchTruncateConfirm.value = false;
|
||||
@@ -1696,7 +1721,8 @@ async function confirmDuplicateStructure() {
|
||||
sourceName: row.name,
|
||||
targetName: newName,
|
||||
});
|
||||
await api.executeQuery(props.connection.id, props.database, sql, schema);
|
||||
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, schema));
|
||||
if (!executed) return;
|
||||
toast(t("contextMenu.duplicateStructureSuccess", { name: newName }));
|
||||
await reload();
|
||||
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, schema);
|
||||
@@ -1799,7 +1825,8 @@ async function confirmPasteTable() {
|
||||
sourceName: entry.sourceName,
|
||||
targetName,
|
||||
});
|
||||
await api.executeQuery(props.connection.id, props.database, structureSql, schema);
|
||||
const executed = await executeObjectBrowserSqlWithProductionGuard(structureSql, () => api.executeQuery(props.connection.id, props.database, structureSql, schema));
|
||||
if (!executed) return;
|
||||
}
|
||||
if (copyData) {
|
||||
const sourceColumns = await api.getColumns(props.connection.id, props.database, schema || "", entry.sourceName);
|
||||
@@ -1814,7 +1841,8 @@ async function confirmPasteTable() {
|
||||
targetName,
|
||||
...dataCopyColumnOptions,
|
||||
});
|
||||
await api.executeQuery(props.connection.id, props.database, dataSql, schema);
|
||||
const executed = await executeObjectBrowserSqlWithProductionGuard(dataSql, () => api.executeQuery(props.connection.id, props.database, dataSql, schema));
|
||||
if (!executed) return;
|
||||
}
|
||||
successCount++;
|
||||
} catch (e: any) {
|
||||
@@ -1841,6 +1869,21 @@ function tableAdminSqlOptions(row: ObjectBrowserRow, options?: { cascade?: boole
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes Object Browser writes through the shared production gate. The SQL is
|
||||
* assessed before the callback runs so generated DDL cannot bypass protection
|
||||
* via executable comments, EXPLAIN ANALYZE, or qualified production targets.
|
||||
*/
|
||||
async function executeObjectBrowserSqlWithProductionGuard<T>(sql: string, execute: () => Promise<T>): Promise<T | undefined> {
|
||||
return executeWithProductionSqlGuard({
|
||||
connection: props.connection,
|
||||
database: props.database,
|
||||
sql,
|
||||
source: t("production.sourceObjectBrowser"),
|
||||
execute,
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshTruncatePreviewSql(row: ObjectBrowserRow) {
|
||||
truncatePreviewSql.value = "";
|
||||
truncatePreviewSql.value = await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: canTruncateTargetCascade.value && truncateTableCascade.value })).catch(() => "");
|
||||
@@ -1858,7 +1901,8 @@ async function confirmTruncateTable() {
|
||||
if (!row) return;
|
||||
try {
|
||||
const sql = truncatePreviewSql.value || (await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: canTruncateTargetCascade.value && truncateTableCascade.value })));
|
||||
await api.executeQuery(props.connection.id, props.database, sql);
|
||||
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
|
||||
if (!executed) return;
|
||||
toast(t("contextMenu.truncateTableSuccess", { name: row.name }));
|
||||
await refreshMutatedTableDataTabsForRows([row]);
|
||||
} catch (e: any) {
|
||||
@@ -1883,7 +1927,8 @@ async function confirmEmptyTable() {
|
||||
if (!row) return;
|
||||
try {
|
||||
const sql = emptyPreviewSql.value || (await buildEmptyTableSql(tableAdminSqlOptions(row)));
|
||||
await api.executeQuery(props.connection.id, props.database, sql);
|
||||
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
|
||||
if (!executed) return;
|
||||
toast(t("contextMenu.emptyTableSuccess", { name: row.name }));
|
||||
await refreshMutatedTableDataTabsForRows([row]);
|
||||
} catch (e: any) {
|
||||
@@ -1949,8 +1994,23 @@ async function saveSource() {
|
||||
name: row.name,
|
||||
source: sourceDraft.value,
|
||||
});
|
||||
await executeObjectSourceSave(connectionId, database, effectiveDatabaseType.value, statements, schema);
|
||||
if (sidePanelGuard.isStale(epoch)) return;
|
||||
const executableSql = statements.filter((sql) => sql.trim()).join(";\n");
|
||||
if (executableSql.trim()) {
|
||||
const saved = await executeWithProductionSqlGuard({
|
||||
connection: props.connection,
|
||||
database,
|
||||
sql: executableSql,
|
||||
source: t("production.sourceObjectSource"),
|
||||
execute: async () => {
|
||||
await executeObjectSourceSave(connectionId, database, effectiveDatabaseType.value, statements, schema);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
if (!saved || sidePanelGuard.isStale(epoch)) return;
|
||||
} else {
|
||||
await executeObjectSourceSave(connectionId, database, effectiveDatabaseType.value, statements, schema);
|
||||
if (sidePanelGuard.isStale(epoch)) return;
|
||||
}
|
||||
toast(t("objects.sourceSaved"));
|
||||
sourceEditing.value = false;
|
||||
sourceDraft.value = "";
|
||||
|
||||
@@ -3,10 +3,12 @@ import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Clipboard, Loader2, PencilLine, RefreshCw } from "@lucide/vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { buildEditableObjectSource, buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -38,6 +40,7 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const content = ref("");
|
||||
@@ -133,18 +136,34 @@ async function saveSource() {
|
||||
return;
|
||||
}
|
||||
if (!draft.value.trim() || !props.databaseType) return;
|
||||
const databaseType = props.databaseType;
|
||||
const schema = props.schema || props.database;
|
||||
saving.value = true;
|
||||
saveError.value = "";
|
||||
try {
|
||||
const statements = await buildExecutableObjectSourceStatements({
|
||||
databaseType: props.databaseType,
|
||||
databaseType,
|
||||
objectType: props.objectType,
|
||||
schema,
|
||||
name: props.name,
|
||||
source: draft.value,
|
||||
});
|
||||
await executeObjectSourceSave(props.connectionId, props.database, props.databaseType, statements, schema);
|
||||
const executableSql = statements.filter((sql) => sql.trim()).join(";\n");
|
||||
if (executableSql.trim()) {
|
||||
const saved = await executeWithProductionSqlGuard({
|
||||
connection: connectionStore.getConfig(props.connectionId),
|
||||
database: props.database,
|
||||
sql: executableSql,
|
||||
source: t("production.sourceObjectSource"),
|
||||
execute: async () => {
|
||||
await executeObjectSourceSave(props.connectionId, props.database, databaseType, statements, schema);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
if (!saved) return;
|
||||
} else {
|
||||
await executeObjectSourceSave(props.connectionId, props.database, databaseType, statements, schema);
|
||||
}
|
||||
toast(t("objects.sourceSaved"));
|
||||
emit("saved");
|
||||
await loadSource(false);
|
||||
|
||||
@@ -160,6 +160,7 @@ import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSql/s
|
||||
import { isSqlServerLinkedNode } from "@/lib/database/sqlServerLinkedServers";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue";
|
||||
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
|
||||
import { isSchemaAware } from "@/lib/database/databaseFeatureSupport";
|
||||
import VisibleDatabasesDialog from "@/components/sidebar/VisibleDatabasesDialog.vue";
|
||||
import SchemaFilterDialog from "@/components/sidebar/VisibleSchemasDialog.vue";
|
||||
@@ -171,6 +172,8 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import { flattenTree } from "@/composables/useFlatTree";
|
||||
import { createDatabaseCollationOptionsForCharset, fallbackCreateDatabaseCharsetMetadata, nextCreateDatabaseCollation, normalizeCreateDatabaseCharset, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
|
||||
const { t } = useI18n();
|
||||
const labelRef = ref<HTMLElement>();
|
||||
@@ -272,6 +275,11 @@ const usesFullWidthLabel = computed(() => usesFullWidthTreeLabel(props.node.type
|
||||
const sidebarTreeContext = inject(sidebarTreeContextKey, null);
|
||||
const rowWidthClass = computed(() => (usesFullWidthLabel.value ? "w-max min-w-full" : "w-full min-w-0"));
|
||||
const labelWidthClass = computed(() => (usesFullWidthLabel.value ? "shrink-0 whitespace-nowrap" : "min-w-0 truncate"));
|
||||
const nodeProductionContext = computed(() => {
|
||||
const connectionId = props.node.connectionId;
|
||||
return productionContextForDatabase(connectionId ? connectionStore.getConfig(connectionId) : undefined, props.node.database);
|
||||
});
|
||||
const showProductionBadge = computed(() => nodeProductionContext.value.active && ["connection", "database", "redis-db", "mongo-db"].includes(props.node.type));
|
||||
|
||||
function currentDatabaseType(): DatabaseType | undefined {
|
||||
return props.node.connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(props.node.connectionId)) : undefined;
|
||||
@@ -2337,6 +2345,18 @@ function openRenameObjectDialog() {
|
||||
showRenameObjectDialog.value = true;
|
||||
}
|
||||
|
||||
async function executeTreeNodeSqlWithProductionGuard(node: Pick<TreeNode, "connectionId" | "database" | "schema">, sql: string, options: { database?: string; schema?: string } = {}) {
|
||||
if (!node.connectionId) return undefined;
|
||||
const database = options.database ?? node.database ?? "";
|
||||
return executeWithProductionSqlGuard({
|
||||
connection: connectionStore.getConfig(node.connectionId),
|
||||
database,
|
||||
sql,
|
||||
source: t("production.sourceSidebar"),
|
||||
execute: () => api.executeQuery(node.connectionId!, database, sql, options.schema ?? node.schema),
|
||||
});
|
||||
}
|
||||
|
||||
let renameObjectPreviewRequestId = 0;
|
||||
|
||||
async function refreshRenameObjectPreviewSql() {
|
||||
@@ -2390,7 +2410,7 @@ async function confirmRenameObject() {
|
||||
source: source.source,
|
||||
});
|
||||
for (const sql of statements) {
|
||||
await api.executeQuery(node.connectionId, node.database, sql, schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema });
|
||||
}
|
||||
} else {
|
||||
const sql = await buildRenameObjectSql({
|
||||
@@ -2400,7 +2420,7 @@ async function confirmRenameObject() {
|
||||
oldName: node.label,
|
||||
newName,
|
||||
});
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
}
|
||||
toast(t("contextMenu.renameObjectSuccess", { oldName: node.label, newName }), 3000);
|
||||
showRenameObjectDialog.value = false;
|
||||
@@ -2418,7 +2438,7 @@ async function confirmDropObject() {
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const sql = dropObjectPreviewSql.value || (await buildDropObjectSql(options));
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
const msgKey = node.type === "view" ? "contextMenu.dropViewSuccess" : node.type === "materialized_view" ? "contextMenu.dropViewSuccess" : node.type === "procedure" ? "contextMenu.dropProcedureSuccess" : "contextMenu.dropFunctionSuccess";
|
||||
toast(t(msgKey, { name: node.label }), 3000);
|
||||
closeDroppedTableObjectTabsForNode(node);
|
||||
@@ -2440,7 +2460,7 @@ async function confirmDropTableChildObject() {
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const sql = dropTableChildObjectPreviewSql.value || (await buildDropTableChildObjectSql(options));
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
toast(t("contextMenu.dropTableChildObjectSuccess", { name: options.name }), 3000);
|
||||
connectionStore.removeTreeNode(node.id);
|
||||
} catch (e: any) {
|
||||
@@ -2484,7 +2504,7 @@ async function confirmBatchDrop() {
|
||||
await connectionStore.ensureConnected(target.connectionId);
|
||||
const sql = await dropSqlForTreeNode(target, { cascade: useCascade });
|
||||
if (!sql) continue;
|
||||
await api.executeQuery(target.connectionId, target.database, sql, target.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema });
|
||||
closeDroppedTableObjectTabsForNode(target);
|
||||
connectionStore.removeTreeNode(target.id);
|
||||
}
|
||||
@@ -2507,7 +2527,8 @@ async function confirmBatchTruncate() {
|
||||
await connectionStore.ensureConnected(target.connectionId);
|
||||
const sql = await truncateSqlForTreeNode(target, { cascade: useCascade });
|
||||
if (!sql) return false;
|
||||
await api.executeQuery(target.connectionId, target.database, sql, target.schema);
|
||||
const result = await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema });
|
||||
return result === undefined ? false : undefined;
|
||||
},
|
||||
refreshMutatedTableDataTabsForNodes,
|
||||
);
|
||||
@@ -2527,7 +2548,7 @@ async function confirmBatchEmpty() {
|
||||
await connectionStore.ensureConnected(target.connectionId);
|
||||
const sql = await emptySqlForTreeNode(target);
|
||||
if (!sql) throw new Error("Empty table SQL is unavailable");
|
||||
await api.executeQuery(target.connectionId, target.database, sql, target.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema });
|
||||
});
|
||||
for (const failure of result.failed) {
|
||||
console.error(`Failed to empty table "${failure.target.label}":`, failure.error);
|
||||
@@ -2719,7 +2740,7 @@ async function confirmDropTable() {
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const sql = dropTablePreviewSql.value || (await buildDropTableSql(dropTableSqlOptions()));
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
toast(t("contextMenu.dropTableSuccess", { name: node.label }), 3000);
|
||||
closeDroppedTableObjectTabsForNode(node);
|
||||
connectionStore.removeTreeNode(node.id);
|
||||
@@ -2739,7 +2760,7 @@ async function confirmEmptyTable() {
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const sql = emptyTablePreviewSql.value || (await buildEmptyTableSql(tableAdminSqlOptions()));
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
const messageKey = currentDatabaseType() === "clickhouse" ? "contextMenu.emptyTableSubmitted" : "contextMenu.emptyTableSuccess";
|
||||
toast(t(messageKey, { name: node.label }), 3000);
|
||||
await refreshMutatedTableDataTabsForNode(node);
|
||||
@@ -2760,7 +2781,7 @@ async function confirmTruncateTable() {
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const sql = truncateTablePreviewSql.value || (await buildTruncateTableSql(truncateTableSqlOptions()));
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
toast(t("contextMenu.truncateTableSuccess", { name: node.label }), 3000);
|
||||
await refreshMutatedTableDataTabsForNode(node);
|
||||
} catch (e: any) {
|
||||
@@ -2895,7 +2916,7 @@ async function confirmEditDatabaseProperties() {
|
||||
const options = databasePropertyEditOptions();
|
||||
if (!options) return;
|
||||
const sql = await buildUpdateDatabasePropertiesSql(options);
|
||||
await api.executeQuery(node.connectionId, databasePropertyName(), sql);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: databasePropertyName() });
|
||||
toast(t("contextMenu.editDatabasePropertiesSuccess", { name: node.label }), 3000);
|
||||
showEditDatabasePropertiesDialog.value = false;
|
||||
await connectionStore.loadDatabases(node.connectionId, { force: true });
|
||||
@@ -2968,7 +2989,7 @@ async function confirmEditSchemaComment() {
|
||||
name: node.schema || node.label,
|
||||
comment: schemaCommentText.value,
|
||||
});
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
toast(t("contextMenu.editSchemaCommentSuccess", { name: node.label }), 3000);
|
||||
showEditSchemaCommentDialog.value = false;
|
||||
await connectionStore.loadSchemas(node.connectionId, node.database, { force: true });
|
||||
@@ -3143,7 +3164,8 @@ async function createDuckDbAttachedDatabaseFile() {
|
||||
duckDbAttachedDatabaseNameFromPath(path),
|
||||
existingDatabases.map((database) => database.name),
|
||||
);
|
||||
await api.executeQuery(node.connectionId, "", await buildDuckDbAttachDatabaseSql(path, name));
|
||||
const sql = await buildDuckDbAttachDatabaseSql(path, name);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" });
|
||||
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
if (config) {
|
||||
@@ -3184,7 +3206,7 @@ async function confirmCreateDatabase() {
|
||||
charset: createDatabaseCharset.value,
|
||||
collation: createDatabaseCollation.value,
|
||||
});
|
||||
await api.executeQuery(node.connectionId, "", sql);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" });
|
||||
toast(t("contextMenu.createDatabaseSuccess", { name }), 3000);
|
||||
await connectionStore.ensureVisibleDatabase(node.connectionId, name);
|
||||
await connectionStore.loadDatabases(node.connectionId, { force: true });
|
||||
@@ -3255,7 +3277,7 @@ async function confirmDropDatabase() {
|
||||
databaseType: currentDatabaseType(),
|
||||
name: node.label,
|
||||
}));
|
||||
await api.executeQuery(node.connectionId, "", sql);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" });
|
||||
toast(t("contextMenu.dropDatabaseSuccess", { name: node.label }), 3000);
|
||||
await connectionStore.loadDatabases(node.connectionId, { force: true });
|
||||
showDropDatabaseConfirm.value = false;
|
||||
@@ -3349,7 +3371,7 @@ async function confirmCreateSchema() {
|
||||
databaseType: effectiveDatabaseTypeForConnection(config),
|
||||
name,
|
||||
});
|
||||
await api.executeQuery(node.connectionId, targetDatabase || "", sql);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: targetDatabase || "" });
|
||||
toast(t("contextMenu.createSchemaSuccess", { name }), 3000);
|
||||
if (isConnectionLevelSchemaCreation) {
|
||||
await connectionStore.loadDatabases(node.connectionId, { force: true });
|
||||
@@ -3379,7 +3401,7 @@ async function confirmDropSchema() {
|
||||
databaseType: currentDatabaseType(),
|
||||
name: node.label,
|
||||
}));
|
||||
await api.executeQuery(node.connectionId, node.database, sql);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database });
|
||||
toast(t("contextMenu.dropSchemaSuccess", { name: node.label }), 3000);
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
if (config?.db_type === "sqlserver") {
|
||||
@@ -3417,7 +3439,7 @@ async function confirmDuplicateStructure() {
|
||||
sourceName: node.label,
|
||||
targetName: newName,
|
||||
});
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
toast(t("contextMenu.duplicateStructureSuccess", { name: newName }), 3000);
|
||||
await refreshTableList(node);
|
||||
} catch (e: any) {
|
||||
@@ -3446,7 +3468,7 @@ async function confirmPasteTable() {
|
||||
sourceName: entry.sourceName,
|
||||
targetName,
|
||||
});
|
||||
await api.executeQuery(entry.connectionId, entry.database, structureSql, entry.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(entry, structureSql, { database: entry.database, schema: entry.schema });
|
||||
}
|
||||
if (copyData) {
|
||||
const sourceColumns = await api.getColumns(entry.connectionId, entry.database, entry.schema || "", entry.sourceName);
|
||||
@@ -3461,7 +3483,7 @@ async function confirmPasteTable() {
|
||||
targetName,
|
||||
...dataCopyColumnOptions,
|
||||
});
|
||||
await api.executeQuery(entry.connectionId, entry.database, dataSql, entry.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(entry, dataSql, { database: entry.database, schema: entry.schema });
|
||||
}
|
||||
successCount++;
|
||||
const refreshKey = `${entry.connectionId}:${entry.database}:${entry.schema || ""}`;
|
||||
@@ -5323,6 +5345,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
@click.stop
|
||||
/>
|
||||
<span v-else ref="labelRef" :class="labelWidthClass">{{ visibleLabel(node) }}</span>
|
||||
<ProductionContextBadge v-if="showProductionBadge" compact />
|
||||
<span
|
||||
v-if="
|
||||
(node.type === 'group-tables' || node.type === 'group-views' || node.type === 'group-materialized-views' || node.type === 'group-procedures' || node.type === 'group-functions' || node.type === 'group-sequences' || node.type === 'group-packages' || node.type === 'group-partitions') &&
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import { requiresSqlFileTargetDatabaseSelection } from "@/lib/connection/connectionLevelDatabaseBootstrap";
|
||||
import { cancelSqlFileExecution, executeSqlFile, listenSqlFileProgress, listDatabases, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/backend/api";
|
||||
@@ -31,6 +33,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const store = useConnectionStore();
|
||||
const productionSafetyStore = useProductionSafetyStore();
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const filePath = ref("");
|
||||
@@ -280,6 +283,18 @@ async function refreshTargetAfterImport() {
|
||||
|
||||
async function startExecution() {
|
||||
if (!canStart.value || !preview.value) return;
|
||||
const productionContext = productionContextForDatabase(selectedConnection.value, database.value);
|
||||
if (productionContext.active) {
|
||||
// File previews are truncated, so production file execution is always reviewed instead of inferring safety from a partial preview.
|
||||
const confirmed = await productionSafetyStore.requestConfirmation({
|
||||
sql: preview.value.preview,
|
||||
connectionName: selectedConnection.value?.name,
|
||||
database: database.value,
|
||||
productionDatabases: productionContext.databases,
|
||||
source: t("production.sourceSqlFile"),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const id = uuid();
|
||||
executionId.value = id;
|
||||
|
||||
@@ -13,6 +13,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { useSettingsStore, type StructureEditorDensity } from "@/stores/settingsStore";
|
||||
@@ -64,6 +66,7 @@ import * as api from "@/lib/backend/api";
|
||||
const { t } = useI18n();
|
||||
const { isDark } = useTheme();
|
||||
const store = useConnectionStore();
|
||||
const productionSafetyStore = useProductionSafetyStore();
|
||||
const queryStore = useQueryStore();
|
||||
const historyStore = useHistoryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -2061,13 +2064,24 @@ function toggleSqlPreviewCollapsed() {
|
||||
|
||||
async function applyChanges() {
|
||||
if (!canApply.value || !props.connectionId || !props.database) return;
|
||||
const sql = previewSqlText.value;
|
||||
const connection = store.getConfig(props.connectionId);
|
||||
const productionContext = productionContextForDatabase(connection, props.database);
|
||||
if (productionContext.active) {
|
||||
const confirmed = await productionSafetyStore.requestConfirmation({
|
||||
sql,
|
||||
connectionName: connection?.name,
|
||||
database: props.database,
|
||||
productionDatabases: productionContext.databases,
|
||||
source: t("production.sourceStructure"),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
saving.value = true;
|
||||
errorMessage.value = "";
|
||||
const sql = previewSqlText.value;
|
||||
const refreshScope = captureStructureRefreshScope();
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const connection = store.getConfig(props.connectionId);
|
||||
const result = hasSqliteTypeChange.value
|
||||
? await api.applySqliteTableStructureChange(props.connectionId, props.database, structureChangeOptions(), sqliteSchemaRevision.value!)
|
||||
: await api.executeBatch(props.connectionId, props.database, pendingStatements.value, props.schema, queryTimeoutSecsForConnection(connection));
|
||||
|
||||
Reference in new issue
Block a user