feat(safety): temporary write-unlock window for read-only connections
This commit is contained in:
1 parent
7933b7656f
commit
955558c81f
66 files changed
+1176
-124
No files matched your search
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Lock, Unlock } from "@lucide/vue";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useReadOnlyUnlockStore } from "@/stores/readOnlyUnlockStore";
|
||||
import { formatWriteUnlockRemaining } from "@/lib/database/readOnlyWriteAccess";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
compact?: boolean;
|
||||
showLabel?: boolean;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const unlockStore = useReadOnlyUnlockStore();
|
||||
|
||||
const persistentlyReadOnly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only === true);
|
||||
const remainingMs = computed(() => unlockStore.remainingMs(props.connectionId));
|
||||
const unlocked = computed(() => remainingMs.value > 0);
|
||||
const remainingLabel = computed(() => formatWriteUnlockRemaining(remainingMs.value));
|
||||
const tooltip = computed(() => {
|
||||
if (unlocked.value) return `${t("readOnlyUnlock.unlockedBadge")} · ${t("readOnlyUnlock.remaining", { time: remainingLabel.value })}`;
|
||||
return t("connection.readOnlyBadge");
|
||||
});
|
||||
|
||||
async function onActivate() {
|
||||
if (!persistentlyReadOnly.value) return;
|
||||
if (unlocked.value) {
|
||||
await unlockStore.lockNow(props.connectionId);
|
||||
return;
|
||||
}
|
||||
await unlockStore.requestUnlock({
|
||||
connectionId: props.connectionId,
|
||||
connectionName: connectionStore.getConfig(props.connectionId)?.name,
|
||||
source: t("readOnlyUnlock.sourceStatus"),
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
(connectionId) => {
|
||||
if (persistentlyReadOnly.value) void unlockStore.refreshFromBackend(connectionId);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (persistentlyReadOnly.value) void unlockStore.refreshFromBackend(props.connectionId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip v-if="persistentlyReadOnly">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-0.5 rounded text-muted-foreground hover:bg-muted-foreground/15 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="compact ? 'h-4 px-0.5' : 'h-4 px-1'"
|
||||
:aria-label="unlocked ? t('readOnlyUnlock.lockNow') : t('readOnlyUnlock.unlockAction')"
|
||||
:title="unlocked ? t('readOnlyUnlock.lockNow') : t('readOnlyUnlock.unlockAction')"
|
||||
@click.stop.prevent="onActivate"
|
||||
>
|
||||
<Unlock v-if="unlocked" class="h-3 w-3 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<Lock v-else class="h-3 w-3 shrink-0" />
|
||||
<span v-if="showLabel && !unlocked" class="text-[10px] leading-none">{{ t("connection.readOnlyBadge") }}</span>
|
||||
<span v-if="unlocked" class="text-[10px] tabular-nums leading-none">{{ remainingLabel }}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ tooltip }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConsulAclItem, ConsulAclKind, ConsulAclReferences, ConsulAclWrite, ConsulCapabilities } from "@/types/consul";
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -13,7 +14,7 @@ import { useI18n } from "vue-i18n";
|
||||
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId)));
|
||||
const active = ref<ConsulAclKind>("token");
|
||||
const items = ref<Record<string, unknown>[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as api from "@/lib/backend/api";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { kvExportFilenameStem, type KvExportScopeRequest } from "@/lib/kv/kvExportScope";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useConsulStore } from "@/stores/consulStore";
|
||||
import { isCurrentWatchEvent, nextWatchIndex } from "@/lib/consul/watchState";
|
||||
import type { ConsulDeletePrefixPreview, ConsulDeletePrefixReport, ConsulImportConflictPolicy, ConsulImportPreview, ConsulImportReport, ConsulKvBundle, ConsulSearchMatch, ConsulScope, ConsulWatchEvent } from "@/types/consul";
|
||||
@@ -26,7 +27,7 @@ const connectionStore = useConnectionStore();
|
||||
const consulStore = useConsulStore();
|
||||
const browserRef = ref<InstanceType<typeof KvKeyBrowser> | null>(null);
|
||||
const fileInputRef = ref<HTMLInputElement>();
|
||||
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
|
||||
const consulConnections = computed(() => connectionStore.connections.filter((connection) => connection.db_type === "consul"));
|
||||
const transferTargetConnections = computed(() => (transferMode.value === "migration" ? consulConnections.value.filter((connection) => connection.id !== props.connectionId) : consulConnections.value));
|
||||
|
||||
@@ -66,7 +67,7 @@ const transferReport = ref<ConsulImportReport | null>(null);
|
||||
const transferLoading = ref(false);
|
||||
const transferApplying = ref(false);
|
||||
const transferError = ref("");
|
||||
const transferTargetReadOnly = computed(() => Boolean(connectionStore.getConfig(transferTargetId.value)?.read_only));
|
||||
const transferTargetReadOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(transferTargetId.value)));
|
||||
|
||||
const deleteOpen = ref(false);
|
||||
const deletePrefix = ref("");
|
||||
|
||||
@@ -10,13 +10,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import type { ConsulCapabilities, ConsulConfigEntry, ConsulDiscoveryChain, ConsulExportedService, ConsulIntention, ConsulPeering } from "@/types/consul";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
type MeshTab = "config" | "intentions" | "discovery" | "peering" | "exported";
|
||||
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
|
||||
const store = useConnectionStore();
|
||||
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId)));
|
||||
const { t } = useI18n();
|
||||
function capabilityLabel(value?: string) {
|
||||
if (value === "supported") return t("consul.ui.capabilitySupported");
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConsulCapabilities, ConsulOperatorDocument, ConsulOperatorReadKind } from "@/types/consul";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
|
||||
@@ -26,7 +27,7 @@ const settings = computed(() => {
|
||||
const external = connectionStore.getConfig(props.connectionId)?.external_config;
|
||||
return external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
});
|
||||
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
|
||||
const autopilotWriteVisible = computed(() => settings.value.consulOperatorAutopilotWriteEnabled && props.capabilities?.operatorAutopilot === "supported");
|
||||
const raftWriteVisible = computed(() => settings.value.consulOperatorRaftWriteEnabled && props.capabilities?.operatorRaft === "supported");
|
||||
const keyringWriteVisible = computed(() => settings.value.consulOperatorKeyringWriteEnabled && props.capabilities?.operatorKeyring === "supported");
|
||||
|
||||
@@ -6,13 +6,14 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsulCapabilities, ConsulEnterpriseKind, ConsulEnterpriseWrite, ConsulScopeImpact } from "@/types/consul";
|
||||
|
||||
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId)));
|
||||
const active = ref<ConsulEnterpriseKind>("namespace");
|
||||
const items = ref<Record<string, unknown>[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { clampConsulPage, CONSUL_LIST_PAGE_SIZE, paginateConsulItems } from "@/l
|
||||
import { consulSessionRenewDelayMs, parseConsulDurationMs } from "@/lib/consul/sessionRenew";
|
||||
import { normalizeConsulSession } from "@/lib/consul/sessionModel";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useConsulStore } from "@/stores/consulStore";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsulResponseMetadata, ConsulSession, ConsulSessionKeysResponse, ConsulSessionServiceCheck } from "@/types/consul";
|
||||
@@ -19,7 +20,7 @@ const props = defineProps<{ connectionId: string }>();
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const consulStore = useConsulStore();
|
||||
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId)));
|
||||
function behaviorLabel(behavior: string) {
|
||||
if (behavior === "delete") return t("consul.ui.behaviorDelete");
|
||||
if (behavior === "release") return t("consul.ui.behaviorRelease");
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConsulCapabilities, ConsulCoordinate, ConsulEvent, ConsulPreparedQuery, ConsulPreparedQueryExecuteResponse, ConsulPreparedQueryNode } from "@/types/consul";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
type ToolTab = "queries" | "events" | "coordinates";
|
||||
@@ -21,7 +22,7 @@ function capabilityLabel(value?: string) {
|
||||
return t("consul.ui.capabilityUnknown");
|
||||
}
|
||||
const connectionStore = useConnectionStore();
|
||||
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
|
||||
const canWriteQueries = computed(() => !readOnly.value && props.capabilities?.preparedQueries === "supported");
|
||||
const canFireEvents = computed(() => !readOnly.value && props.capabilities?.events === "supported");
|
||||
const queryAvailable = computed(() => !["unsupported", "disabled", "forbidden"].includes(props.capabilities?.preparedQueries || ""));
|
||||
|
||||
@@ -18,6 +18,7 @@ import { buildGridFsFilesStructuredFilter, createGridFsFileFilterRule, currentGr
|
||||
import { buildGridFsDownloadArchive, defaultGridFsArchiveFileName, formatGridFsUploadDate } from "@/lib/document/gridfsFiles";
|
||||
import { clampGridFsPage, gridFsTotalPages, paginateGridFsItems } from "@/lib/document/gridfsPagination";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -62,7 +63,7 @@ const partiallyChecked = computed(() => checkedFileIds.value.size > 0 && checked
|
||||
const downloadTargets = computed(() => (checkedFileCount.value > 0 ? checkedFiles.value : selectedFile.value ? [selectedFile.value] : []));
|
||||
const downloadButtonLabel = computed(() => (checkedFileCount.value > 0 ? t("gridfsBrowser.downloadSelected") : t("gridfsBrowser.downloadFile")));
|
||||
const selectedMetadata = computed(() => (selectedFile.value?.metadata ? JSON.stringify(selectedFile.value.metadata, null, 2) : ""));
|
||||
const isReadonly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
|
||||
const isReadonly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
|
||||
const mongoProvider = documentStoreProviderFor("mongodb");
|
||||
const activeStructuredRuleCount = computed(() => {
|
||||
if (!appliedStructuredFilter.value) return 0;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useToast } from "@/composables/useToast";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { currentGridFsBucketFilter, currentGridFsBucketSort, currentGridFsBucketSortDirection, gridFsBucketSortInputForColumn } from "@/lib/document/gridFsBrowser";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -39,7 +40,7 @@ const filterBuilderOpen = ref(false);
|
||||
const selectedBucket = computed(() => buckets.value.find((bucket) => bucket.name === selectedBucketName.value) || null);
|
||||
const totalFiles = computed(() => buckets.value.reduce((sum, bucket) => sum + bucket.fileCount, 0));
|
||||
const totalBytes = computed(() => buckets.value.reduce((sum, bucket) => sum + bucket.totalBytes, 0));
|
||||
const isReadonly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
|
||||
const isReadonly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
type DoltClientSessionScope,
|
||||
} from "@/lib/dolt/doltVersionControl";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
@@ -240,7 +241,7 @@ const tableDiffPageSize = ref(DOLT_DIFF_DEFAULT_PAGE_SIZE);
|
||||
const tableDiffTotalRows = ref(0);
|
||||
const tableDiffMaximumPage = computed(() => Math.max(1, Math.ceil(tableDiffTotalRows.value / tableDiffPageSize.value)));
|
||||
const selectedRevisionKeySet = computed(() => new Set(selectedRevisionKeys.value));
|
||||
const connectionReadOnly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
|
||||
const connectionReadOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
|
||||
const createsNamedRef = computed(() => branchDialog.value === "create" || branchDialog.value === "create-tag");
|
||||
const mutationDialogTitle = computed(() => {
|
||||
if (branchDialog.value === "create") return t("doltVersionControl.createBranch");
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
||||
import { Input } from "@/components/ui/input";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
|
||||
type AccessView = "users" | "roles";
|
||||
type PermissionAccess = "read" | "write" | "readwrite";
|
||||
@@ -52,7 +53,7 @@ const approvalExpected = ref("");
|
||||
let pendingApproval: (() => Promise<void>) | null = null;
|
||||
let detailRequest = 0;
|
||||
|
||||
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
|
||||
const selectedUserRoles = computed(() => userDetail.value?.roles ?? []);
|
||||
const grantableRoles = computed(() => roles.value.filter((role) => !selectedUserRoles.value.includes(role)));
|
||||
const initialUserRoleLabel = computed(() => (newUserRoles.value.length ? t("etcd.access.createAndAssignRoles", { count: newUserRoles.value.length }) : t("etcd.access.createUserAction")));
|
||||
|
||||
@@ -17,6 +17,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { isKeyInKvExportScope, kvExportFilenameStem, kvValueByteIdentity, type KvExportScopeKind, type KvExportScopeRequest } from "@/lib/kv/kvExportScope";
|
||||
import { detectKvValueFormat } from "@/lib/kv/kvValueFormat";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
|
||||
type WorkbenchMode = "keys" | "search" | "maintenance" | "watch" | "lease";
|
||||
type SearchScope = "key" | "value" | "all";
|
||||
@@ -153,9 +154,9 @@ const transferLoadingDetail = ref("");
|
||||
const transferPreviewLoaded = ref(false);
|
||||
const syncConfigurationExpanded = ref(true);
|
||||
|
||||
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
|
||||
const etcdConnections = computed(() => connectionStore.connections.filter((connection) => connection.db_type === "etcd"));
|
||||
const targetReadOnly = computed(() => Boolean(connectionStore.getConfig(targetConnectionId.value)?.read_only));
|
||||
const targetReadOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(targetConnectionId.value)));
|
||||
const selectedTransferRows = computed(() => transferRows.value.filter((row) => row.selected && isTransferRowSelectable(row)));
|
||||
const selectableTransferRows = computed(() => transferRows.value.filter(isTransferRowSelectable));
|
||||
const filteredTransferRows = computed(() => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import { encodeHBaseTextInput, hbaseCellInput } from "@/lib/hbase/hbaseValues";
|
||||
import { loadHBaseRowLimit, saveHBaseRowLimit } from "@/lib/hbase/hbaseBrowserPreferences";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
import type { HBaseCellInput, HBasePutRowInput, HBaseRow, HBaseTableSchema, HBaseValueEncoding } from "@/types/hbase";
|
||||
@@ -61,7 +62,7 @@ const deleteTableLoading = ref(false);
|
||||
const deleteTableError = ref("");
|
||||
|
||||
const hasTable = computed(() => props.table.trim().length > 0);
|
||||
const readOnly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only === true);
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
|
||||
const qualifiedTableLabel = computed(() => {
|
||||
if (!hasTable.value) return props.namespace;
|
||||
return props.namespace && props.namespace !== "default" ? `${props.namespace}:${props.table}` : props.table;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { AlertTriangle, ArrowLeft, ArrowRight, Check, CheckCircle2, FileCode, FileJson, FileSpreadsheet, FileText, FileUp, Loader2, RefreshCw, Square, Upload, X } from "@lucide/vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import {
|
||||
@@ -713,6 +714,9 @@ async function goNext() {
|
||||
|
||||
async function startImport() {
|
||||
saveActiveBatchTask();
|
||||
if (!(await ensureReadOnlyWriteAccess({ connection: store.getConfig(props.prefillConnectionId ?? ""), source: t("readOnlyUnlock.sourceImport"), treatAsMutation: true }))) {
|
||||
return;
|
||||
}
|
||||
if (isBatchImport.value) {
|
||||
await startBatchImport();
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, defineAsyncComponent } from "vue";
|
||||
import { computed, ref, watch, defineAsyncComponent } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -24,6 +24,7 @@ const DataGenerateDialog = defineAsyncComponent(() => import("@/components/gener
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import { useReadOnlyUnlockStore, WRITE_UNLOCK_FIVE_MINUTES_SECS, WRITE_UNLOCK_ONE_MINUTE_SECS, type WriteUnlockDurationSecs } from "@/stores/readOnlyUnlockStore";
|
||||
import { useDialogSources } from "@/composables/useDialogSources";
|
||||
import type { ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
|
||||
import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
|
||||
@@ -92,6 +93,8 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const productionSafetyStore = useProductionSafetyStore();
|
||||
const readOnlyUnlockStore = useReadOnlyUnlockStore();
|
||||
const unlockDuration = ref<WriteUnlockDurationSecs>(WRITE_UNLOCK_ONE_MINUTE_SECS);
|
||||
const sqlExecutionDangerStore = useSqlExecutionDangerStore();
|
||||
const dialogs = useDialogSources();
|
||||
const productionConfirmationDetails = computed(() => {
|
||||
@@ -103,6 +106,20 @@ const productionConfirmationDetails = computed(() => {
|
||||
source: request.source || "-",
|
||||
});
|
||||
});
|
||||
const readOnlyUnlockDetails = computed(() => {
|
||||
const request = readOnlyUnlockStore.pending;
|
||||
if (!request) return "";
|
||||
return t("readOnlyUnlock.confirmDetails", {
|
||||
connection: request.connectionName || "-",
|
||||
source: request.source || "-",
|
||||
});
|
||||
});
|
||||
watch(
|
||||
() => readOnlyUnlockStore.pending,
|
||||
(pending) => {
|
||||
if (pending) unlockDuration.value = WRITE_UNLOCK_ONE_MINUTE_SECS;
|
||||
},
|
||||
);
|
||||
const multiDbDangerDetails = computed(() => {
|
||||
const request = sqlExecutionDangerStore.pending;
|
||||
if (!request) return "";
|
||||
@@ -178,6 +195,32 @@ watch(
|
||||
@update:open="(open) => !open && productionSafetyStore.cancel()"
|
||||
@confirm="productionSafetyStore.confirm()"
|
||||
/>
|
||||
<DangerConfirmDialog
|
||||
v-if="readOnlyUnlockStore.pending"
|
||||
:open="true"
|
||||
:title="t('readOnlyUnlock.confirmTitle')"
|
||||
:message="t('readOnlyUnlock.confirmMessage')"
|
||||
:details-text="readOnlyUnlockDetails"
|
||||
:sql="readOnlyUnlockStore.pending.sql"
|
||||
:confirm-label="t('readOnlyUnlock.confirmAction')"
|
||||
:close-on-confirm="false"
|
||||
@update:open="(open) => !open && readOnlyUnlockStore.cancel()"
|
||||
@confirm="readOnlyUnlockStore.confirm(unlockDuration)"
|
||||
>
|
||||
<template #options>
|
||||
<fieldset class="mb-3 space-y-2 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<legend class="text-xs font-medium text-foreground">{{ t("readOnlyUnlock.durationLabel") }}</legend>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="unlockDuration" type="radio" class="accent-primary" :value="WRITE_UNLOCK_ONE_MINUTE_SECS" />
|
||||
{{ t("readOnlyUnlock.durationOneMinute") }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="unlockDuration" type="radio" class="accent-primary" :value="WRITE_UNLOCK_FIVE_MINUTES_SECS" />
|
||||
{{ t("readOnlyUnlock.durationFiveMinutes") }}
|
||||
</label>
|
||||
</fieldset>
|
||||
</template>
|
||||
</DangerConfirmDialog>
|
||||
<DangerConfirmDialog
|
||||
v-if="sqlExecutionDangerStore.pending"
|
||||
:open="true"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, ref, watch, nextTick, onUnmounted } from "vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { X, Pin, ChevronDown, Search, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge, ShieldCheck, Database, GitBranch, Crosshair } from "@lucide/vue";
|
||||
import { X, Pin, ChevronDown, Search, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge, ShieldCheck, Database, GitBranch, Crosshair } from "@lucide/vue";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
@@ -16,7 +16,8 @@ import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useTabScroll } from "@/composables/useTabScroll";
|
||||
import { useTabDrag } from "@/composables/useTabDrag";
|
||||
import { connectionColor, isConnectionReadonly, tabDisplayTitle, tabTooltipLines } from "@/lib/tabs/tabPresentation";
|
||||
import { connectionColor, tabDisplayTitle, tabTooltipLines } from "@/lib/tabs/tabPresentation";
|
||||
import ReadOnlySessionControl from "@/components/connection/ReadOnlySessionControl.vue";
|
||||
import { hexToRgba } from "@/lib/common/color";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
@@ -740,12 +741,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
|
||||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
|
||||
</span>
|
||||
<Tooltip v-if="isConnectionReadonly(tab.connectionId)">
|
||||
<TooltipTrigger as-child>
|
||||
<Lock class="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.readOnlyBadge") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
|
||||
<button class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0" @click.stop="queryStore.closeTab(tab.id)">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
@@ -852,7 +848,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
|
||||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
|
||||
</span>
|
||||
<Lock v-if="isConnectionReadonly(tab.connectionId)" class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
|
||||
<Pin v-if="tab.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
|
||||
<span class="w-5 shrink-0">
|
||||
<button
|
||||
@@ -950,12 +946,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
|
||||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
|
||||
</span>
|
||||
<Tooltip v-if="isConnectionReadonly(tab.connectionId)">
|
||||
<TooltipTrigger as-child>
|
||||
<Lock class="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.readOnlyBadge") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
|
||||
<button class="rounded p-0.5 text-primary hover:bg-muted-foreground/20 shrink-0" :aria-label="t('contextMenu.unfixTab')" :title="t('contextMenu.unfixTab')" @click.stop="queryStore.togglePinnedTab(tab.id)">
|
||||
<Pin class="h-3 w-3 fill-current" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -1008,7 +999,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
|
||||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
|
||||
</span>
|
||||
<Lock v-if="isConnectionReadonly(tab.connectionId)" class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
|
||||
<Pin v-if="tab.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
|
||||
<span class="w-5 shrink-0">
|
||||
<button
|
||||
|
||||
@@ -162,6 +162,7 @@ import type { QueryTab, ConnectionConfig, TableInfoTab, TreeNode, VectorCollecti
|
||||
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
|
||||
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
|
||||
type DataGridHandle = DataGridColumnLayoutHandle & {
|
||||
onToolbarRefresh: () => Promise<void> | void;
|
||||
@@ -2226,7 +2227,7 @@ defineExpose({
|
||||
|
||||
<template v-else-if="activeTab.mode === 'nacos-access-control'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<NacosAccessControlConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :read-only="activeConnection?.read_only ?? false" />
|
||||
<NacosAccessControlConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :read-only="connectionIsEffectivelyReadOnly(activeConnection)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2297,7 +2298,7 @@ defineExpose({
|
||||
|
||||
<template v-else-if="activeTab.mode === 'mq'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<MqAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :initial-tenant="activeTab.mqTenant" :initial-tab="activeTab.mqInitialTab" :read-only="activeConnection?.read_only ?? false" />
|
||||
<MqAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :initial-tenant="activeTab.mqTenant" :initial-tab="activeTab.mqInitialTab" :read-only="connectionIsEffectivelyReadOnly(activeConnection)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2318,7 +2319,7 @@ defineExpose({
|
||||
:target-group="activeTab.nacosTargetGroup"
|
||||
:target-keyword="activeTab.nacosTargetKeyword"
|
||||
:target-request-id="activeTab.nacosTargetRequestId"
|
||||
:read-only="activeConnection?.read_only ?? false"
|
||||
:read-only="connectionIsEffectivelyReadOnly(activeConnection)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as api from "@/lib/backend/api";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { loadMeilisearchKeyColumns, saveMeilisearchKeyColumns, type MeilisearchKeyColumnKey } from "@/lib/meilisearch/meilisearchKeyColumns";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import type { KeyCreateInput, KeyListItem } from "@/types/meilisearchManagement";
|
||||
|
||||
@@ -53,7 +54,7 @@ const props = defineProps<{ connectionId: string }>();
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
|
||||
|
||||
const rows = ref<KeyListItem[]>([]);
|
||||
const offset = ref(0);
|
||||
|
||||
@@ -12,6 +12,7 @@ import ErrorBanner from "@/components/ui/ErrorBanner.vue";
|
||||
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import {
|
||||
formatMeilisearchTaskDateTime,
|
||||
@@ -32,7 +33,7 @@ const props = defineProps<{ connectionId: string; fixedIndexUid?: string }>();
|
||||
const { locale, t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
|
||||
|
||||
const rows = ref<MeilisearchTask[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
@@ -55,6 +55,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import type {
|
||||
NacosBatchPreview,
|
||||
NacosBatchReport,
|
||||
@@ -280,7 +281,7 @@ const namespace = computed(() => props.namespace ?? connectionInfo.value?.namesp
|
||||
const nacosProductionContext = computed(() => productionContextForDatabase(connectionStore.getConfig(props.connectionId), namespace.value));
|
||||
const batchTargetConnections = computed<NacosConfigTransferTarget[]>(() =>
|
||||
connectionStore.connections
|
||||
.filter((connection) => connection.db_type === "nacos" && !connection.read_only)
|
||||
.filter((connection) => connection.db_type === "nacos" && !connectionIsEffectivelyReadOnly(connection))
|
||||
.map((connection) => {
|
||||
const address = [connection.host, connection.port].filter(Boolean).join(":");
|
||||
return { id: connection.id, label: connection.name ? `${connection.name} (${address})` : address || connection.id };
|
||||
|
||||
@@ -111,6 +111,7 @@ import MySqlEventEditor from "@/components/objects/MySqlEventEditor.vue";
|
||||
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { buildXuguCompileSql } from "@/lib/database/xuguCompileSql";
|
||||
import { formatShortcut } from "@/lib/editor/shortcutRegistry";
|
||||
import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, type BatchTableEmptyPlanItem } from "@/lib/sidebar/batchTableEmpty";
|
||||
@@ -335,7 +336,7 @@ const canOpenStructureEditor = computed(() => supportsTableStructureEditing(tabl
|
||||
const canOpenDiagram = computed(() => !!props.database && supportsSchemaDiagram(effectiveDatabaseType.value));
|
||||
const canOpenTableImport = computed(() => !!props.database && supportsTableImport(effectiveDatabaseType.value));
|
||||
const supportsTruncateTable = computed(() => supportsTableTruncate(effectiveDatabaseType.value));
|
||||
const supportsVacuumTable = computed(() => !props.connection.read_only && supportsTableVacuum(effectiveDatabaseType.value));
|
||||
const supportsVacuumTable = computed(() => !connectionIsEffectivelyReadOnly(props.connection) && supportsTableVacuum(effectiveDatabaseType.value));
|
||||
const vacuumRiskMessage = computed(() => (vacuumExecuting.value ? t("contextMenu.vacuumTableRunningHint") : vacuumTableFull.value ? t("contextMenu.vacuumTableFullRisk") : vacuumTableAnalyze.value ? t("contextMenu.vacuumTableAnalyzeRisk") : t("contextMenu.vacuumTableDefaultRisk")));
|
||||
const sourceDialect = computed(() => codeMirrorSqlDialect(effectiveDatabaseType.value));
|
||||
const sourceFormatDialect = computed<SqlFormatDialect>(() => sqlFormatDialectForDbType(effectiveDatabaseType.value));
|
||||
@@ -2212,7 +2213,7 @@ function canTransferTableClipboard(): boolean {
|
||||
if (isVictoriaMetrics.value) return false;
|
||||
const entries = normalizedObjectBrowserTableClipboardEntries();
|
||||
const target = pasteTableTargetContext();
|
||||
if (entries.length === 0 || props.connection.read_only) return false;
|
||||
if (entries.length === 0 || connectionIsEffectivelyReadOnly(props.connection)) return false;
|
||||
const source = tableClipboardSourceContext(entries);
|
||||
const sourceConfig = source ? connectionStore.getConfig(source.connectionId) : undefined;
|
||||
return !!source && !!sourceConfig && supportsTransfer(sourceConfig.db_type) && supportsTransfer(props.connection.db_type) && !tableClipboardMatchesTarget(entries, target);
|
||||
|
||||
@@ -184,6 +184,7 @@ import { isSqlServerLinkedNode } from "@/lib/database/sqlServerLinkedServers";
|
||||
import { flattenTree } from "@/composables/useFlatTree";
|
||||
import { createDatabaseCollationOptionsForCharset, nextCreateDatabaseCollation, normalizeCreateDatabaseCharset, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
|
||||
import { executeWithProductionContextGuard, executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { buildXuguCompileSql } from "@/lib/database/xuguCompileSql";
|
||||
import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator";
|
||||
import { createSidebarActionTarget, findSidebarActionTarget, releaseRemovedSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
|
||||
@@ -1153,7 +1154,7 @@ function canTransferTreeClipboardToCurrentNode(): boolean {
|
||||
const source = tableClipboardSourceContext(entries);
|
||||
const sourceConfig = source ? connectionStore.getConfig(source.connectionId) : undefined;
|
||||
const targetConfig = connectionStore.getConfig(target.connectionId);
|
||||
return !!source && !!sourceConfig && !!targetConfig && supportsTransfer(sourceConfig.db_type) && supportsTransfer(targetConfig.db_type) && !targetConfig.read_only && !tableClipboardMatchesTarget(entries, target);
|
||||
return !!source && !!sourceConfig && !!targetConfig && supportsTransfer(sourceConfig.db_type) && supportsTransfer(targetConfig.db_type) && !connectionIsEffectivelyReadOnly(targetConfig) && !tableClipboardMatchesTarget(entries, target);
|
||||
}
|
||||
|
||||
function openTransferFromTreeClipboard(): boolean {
|
||||
@@ -3013,7 +3014,7 @@ async function confirmBatchEmpty() {
|
||||
|
||||
const canCreateTable = computed(() => {
|
||||
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
|
||||
const supportsHBaseTableCreation = config?.db_type === "hbase" && !config.read_only;
|
||||
const supportsHBaseTableCreation = config?.db_type === "hbase" && !connectionIsEffectivelyReadOnly(config);
|
||||
return (
|
||||
(activeNode.value.type === "database" || activeNode.value.type === "schema" || activeNode.value.type === "group-tables") &&
|
||||
!isSqlServerLinkedNode(activeNode.value) &&
|
||||
@@ -3029,13 +3030,13 @@ const canCreateDatabase = computed(() => {
|
||||
|
||||
const canCreateNacosNamespace = computed(() => {
|
||||
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
|
||||
return activeNode.value.type === "connection" && config?.db_type === "nacos" && !config.read_only;
|
||||
return activeNode.value.type === "connection" && config?.db_type === "nacos" && !connectionIsEffectivelyReadOnly(config);
|
||||
});
|
||||
|
||||
const canEditNacosNamespace = computed(() => {
|
||||
if (activeNode.value.type !== "nacos-namespace" || !activeNode.value.connectionId || !activeNode.value.nacosNamespace) return false;
|
||||
const config = connectionStore.getConfig(activeNode.value.connectionId);
|
||||
return config?.db_type === "nacos" && !config.read_only;
|
||||
return config?.db_type === "nacos" && !connectionIsEffectivelyReadOnly(config);
|
||||
});
|
||||
|
||||
const canDeleteNacosNamespace = computed(() => canEditNacosNamespace.value);
|
||||
@@ -3083,7 +3084,7 @@ const canRenameDatabase = computed(() => {
|
||||
const node = activeNode.value;
|
||||
if (node.type !== "database" || !node.database) return false;
|
||||
const config = node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined;
|
||||
return !!config && !config.read_only && supportsDatabaseRename(config.db_type);
|
||||
return !!config && !connectionIsEffectivelyReadOnly(config) && supportsDatabaseRename(config.db_type);
|
||||
});
|
||||
|
||||
const renameObjectDialogTitle = computed(() => {
|
||||
@@ -3104,7 +3105,7 @@ const canDropSchema = computed(() => {
|
||||
|
||||
const canEditSchemaComment = computed(() => {
|
||||
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
|
||||
return activeNode.value.type === "schema" && !!activeNode.value.database && !config?.read_only && supportsSchemaComment(effectiveDatabaseTypeForConnection(config));
|
||||
return activeNode.value.type === "schema" && !!activeNode.value.database && !connectionIsEffectivelyReadOnly(config) && supportsSchemaComment(effectiveDatabaseTypeForConnection(config));
|
||||
});
|
||||
|
||||
const canBatchDropCascade = computed(() => {
|
||||
@@ -5221,7 +5222,7 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
icon: RefreshCw,
|
||||
shortcut: shortcutRefresh,
|
||||
});
|
||||
if (!connectionStore.getConfig(node.connectionId || "")?.read_only) {
|
||||
if (!connectionIsEffectivelyReadOnly(connectionStore.getConfig(node.connectionId || ""))) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({
|
||||
label: t("hbase.deleteTable"),
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
CalendarClock,
|
||||
Gauge,
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
Archive,
|
||||
Square,
|
||||
Minus,
|
||||
@@ -46,6 +45,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue";
|
||||
import ReadOnlySessionControl from "@/components/connection/ReadOnlySessionControl.vue";
|
||||
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -1510,7 +1510,7 @@ function onKeydown(event: KeyboardEvent) {
|
||||
</div>
|
||||
<span v-if="node.type === 'connection' && node.connectionId && connectionStore.connectedIds.has(node.connectionId)" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
|
||||
<span v-if="databaseOpenVisual.showsIndicator" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
|
||||
<Badge v-if="isConnectionReadonly" variant="secondary" class="h-4 px-1.5 text-[10px] gap-0.5"> <Lock class="w-2.5 h-2.5" />{{ t("connection.readOnlyBadge") }} </Badge>
|
||||
<ReadOnlySessionControl v-if="isConnectionReadonly && activeNode.connectionId" :connection-id="activeNode.connectionId" show-label />
|
||||
<ConnectionErrorIndicator v-if="node.type === 'connection'" :connection-id="node.connectionId" trigger-class="h-4 w-4" />
|
||||
<span v-if="formattedObjectStorage()" class="ml-auto shrink-0 text-right text-xs tabular-nums text-muted-foreground">{{ formattedObjectStorage() }}</span>
|
||||
<button
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useToast } from "@/composables/useToast";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { connectionIsEffectivelyReadOnly, ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { fetchSqlFileTargetOptions } from "@/composables/useDatabaseOptions";
|
||||
import { requiresSqlFileTargetDatabaseSelection } from "@/lib/connection/connectionLevelDatabaseBootstrap";
|
||||
import { cancelSqlFileExecution, executeSqlFiles, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/backend/api";
|
||||
@@ -357,6 +358,12 @@ async function refreshTargetAfterImport() {
|
||||
|
||||
async function startExecution() {
|
||||
if (!canStart.value || previews.value.length === 0) return;
|
||||
// Await the unlock guard only when it can actually prompt/block (effectively
|
||||
// read-only); writable or already-unlocked connections stay synchronous so
|
||||
// the running state flips in the same tick as the click.
|
||||
if (connectionIsEffectivelyReadOnly(selectedConnection.value)) {
|
||||
if (!(await ensureReadOnlyWriteAccess({ connection: selectedConnection.value, source: t("readOnlyUnlock.sourceSqlFile"), treatAsMutation: true }))) 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.
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
|
||||
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { TransferContent, TransferMode, TransferObjectKind, TransferTableNameCase } from "@/lib/backend/api";
|
||||
import { crossFamilyTransferableKinds, isSameTransferFamily, transferObjectKindsForDatabase } from "@/lib/database/transferObjectKinds";
|
||||
@@ -735,6 +736,9 @@ function swapSourceAndTarget() {
|
||||
|
||||
async function startTransfer() {
|
||||
if (!canStart.value || isSubmitting.value) return;
|
||||
if (!(await ensureReadOnlyWriteAccess({ connection: store.getConfig(targetConnectionId.value), source: t("readOnlyUnlock.sourceTransfer"), treatAsMutation: true }))) {
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
|
||||
const effectiveSourceSchema = sourceSchema.value || sourceDatabaseName.value;
|
||||
|
||||
Reference in new issue
Block a user