feat(etcd): restrict key browsing and writes by auth permissions

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-04 20:24:02 +08:00
1 parent c9c36a8453
commit 259071c09b
32 files changed
+1404 -100

No files matched your search

@@ -154,11 +154,25 @@ const transferLoadingDetail = ref("");
const transferPreviewLoaded = ref(false);
const syncConfigurationExpanded = ref(true);
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const etcdAccess = computed(() => connectionStore.getEtcdAccessCapabilities(props.connectionId));
const canManageEtcd = computed(() => etcdAccess.value.admin);
const canWriteEtcdKeys = computed(() => etcdAccess.value.writable);
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)) || !canWriteEtcdKeys.value);
function canWriteConnectionKey(connectionId: string, key: string, keyBytes?: api.KvValue | null): boolean {
const config = connectionStore.getConfig(connectionId);
return config?.db_type === "etcd" && !connectionIsEffectivelyReadOnly(config) && connectionStore.canWriteEtcdKey(connectionId, key, keyBytes);
}
function canWriteCurrentKey(route: { key: string; keyBytes?: api.KvValue | null }): boolean {
return canWriteConnectionKey(props.connectionId, route.key, route.keyBytes);
}
function permissionDenied(): Error {
return new Error("ETCD_PERMISSION_DENIED: The current etcd user is not authorized to write this Key");
}
// etcd 2.x connections speak the v2 API: no key history, no leases, no maintenance.
const isV2Api = computed(() => connectionStore.getConfig(props.connectionId)?.driver_profile === "etcd-v2");
const etcdConnections = computed(() => connectionStore.connections.filter((connection) => connection.db_type === "etcd"));
const targetReadOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(targetConnectionId.value)));
const targetReadOnly = computed(() => !targetConnectionId.value || connectionIsEffectivelyReadOnly(connectionStore.getConfig(targetConnectionId.value)) || !connectionStore.getEtcdAccessCapabilities(targetConnectionId.value).writable);
const selectedTreeKeysWritable = computed(() => selectedTreeKeys.value.length > 0 && selectedTreeKeys.value.every((item) => canWriteCurrentKey(item)));
const selectedTransferRows = computed(() => transferRows.value.filter((row) => row.selected && isTransferRowSelectable(row)));
const selectableTransferRows = computed(() => transferRows.value.filter(isTransferRowSelectable));
const filteredTransferRows = computed(() => {
@@ -274,13 +288,23 @@ const etcdApi = computed(() => ({
return { ...result, key: shown, keyIdentity: identity };
},
getMetadata: (connectionId: string, key: string, options?: api.KvGetOptions | null) => api.etcdGet(connectionId, key, { ...keyOptions(key), ...options, metadataOnly: true }),
put: (connectionId: string, key: string, value: api.KvValue, options?: api.KvPutOptions | null) =>
api.etcdPut(connectionId, key, value, {
put: (connectionId: string, key: string, value: api.KvValue, options?: api.KvPutOptions | null) => {
const keyBytes = options?.keyBytes ?? (options?.expectedCreateRevision === "0" ? undefined : keyOptions(key).keyBytes);
if (!canWriteConnectionKey(connectionId, key, keyBytes)) throw permissionDenied();
return api.etcdPut(connectionId, key, value, {
...options,
keyBytes: options?.keyBytes ?? (options?.expectedCreateRevision === "0" ? undefined : keyOptions(key).keyBytes),
}),
deleteKey: (connectionId: string, key: string, options?: api.KvDeleteOptions | null) => api.etcdDelete(connectionId, key, { ...options, keyBytes: options?.keyBytes ?? keyOptions(key).keyBytes }),
rename: api.etcdRename,
keyBytes,
});
},
deleteKey: (connectionId: string, key: string, options?: api.KvDeleteOptions | null) => {
const keyBytes = options?.keyBytes ?? keyOptions(key).keyBytes;
if (!canWriteConnectionKey(connectionId, key, keyBytes)) throw permissionDenied();
return api.etcdDelete(connectionId, key, { ...options, keyBytes });
},
rename: (connectionId: string, request: { key: string; keyBytes?: api.KvValue | null; newKey: string; expectedModRevision?: api.KvInt64 | null }) => {
if (!canWriteConnectionKey(connectionId, request.key, request.keyBytes) || !canWriteConnectionKey(connectionId, request.newKey)) throw permissionDenied();
return api.etcdRename(connectionId, request);
},
// The v2 API has no event history; hiding the entry keeps KvKeyBrowser from
// offering restore/compare actions it cannot fulfill.
history: isV2Api.value ? undefined : api.etcdHistory,
@@ -344,6 +368,11 @@ const labels = computed(() => ({
}));
async function refreshTtlCapability() {
if (!canManageEtcd.value) {
supportsTtl.value = false;
ttlCapabilityKnown.value = true;
return;
}
const connectionId = props.connectionId;
if (ttlCapabilityInFlightConnection === connectionId) return;
const request = ++ttlCapabilityRequest;
@@ -373,8 +402,16 @@ function stopTtlCapabilityRefresh() {
function startTtlCapabilityRefresh() {
stopTtlCapabilityRefresh();
void refreshTtlCapability();
ttlCapabilityRefreshTimer = setInterval(() => void refreshTtlCapability(), ttlCapabilityRefreshIntervalMs);
const refreshAccessAndTtl = async () => {
try {
await connectionStore.ensureEtcdAccessCapabilities(props.connectionId, { force: true, verifyHealth: false });
} catch {
// The Key browser surfaces connection failures through its own requests.
}
await refreshTtlCapability();
};
void refreshAccessAndTtl();
ttlCapabilityRefreshTimer = setInterval(() => void refreshAccessAndTtl(), ttlCapabilityRefreshIntervalMs);
}
watch(
@@ -400,6 +437,7 @@ function normalizedLease(metadata?: api.KvKeyMetadata | null) {
function isTransferRowSelectable(row: TransferRow): boolean {
if (["unchanged", "skipped", "applied"].includes(row.operation)) return false;
if (!row.source || !canWriteConnectionKey(targetConnectionId.value, row.displayKey, row.source.key)) return false;
if (transferMode.value !== "sync" || row.operation !== "update") return true;
return transferConflictPolicy.value === "OVERWRITE";
}
@@ -533,7 +571,7 @@ function selectedTreeKeyDetails(): string {
async function deleteSelectedTreeKeys() {
const selected = [...selectedTreeKeys.value];
if (!selected.length || readOnly.value) return;
if (!selected.length || readOnly.value || !selected.every((item) => canWriteCurrentKey(item))) return;
batchDeleting.value = true;
const completed: EtcdMultiSelection[] = [];
let deleted = 0;
@@ -708,6 +746,7 @@ async function onImportFile(event: Event) {
}
async function openSync() {
if (readOnly.value) return;
transferMode.value = "sync";
targetConnectionId.value = etcdConnections.value.find((connection) => connection.id !== props.connectionId)?.id || "";
transferKeyFilter.value = "";
@@ -768,12 +807,17 @@ async function previewTransfer() {
transferError.value = "";
transferPreviewLoaded.value = false;
try {
await connectionStore.ensureEtcdAccessCapabilities(targetId, { force: true, verifyHealth: false });
if (generation !== transferPreviewGeneration) return;
let compared = 0;
const sourceRows = await mapWithConcurrency(bundle.entries, TARGET_LOOKUP_CONCURRENCY, async (source) => {
const shown = displayKey(source.key);
if (normalizedLease(source.metadata) !== "0") {
return { id: `source:${kvValueByteIdentity(source.key)}`, displayKey: shown, source, operation: "skipped" as const, reason: "Leased keys are skipped by default.", selected: false };
}
if (!canWriteConnectionKey(targetId, shown, source.key)) {
return { id: `source:${kvValueByteIdentity(source.key)}`, displayKey: shown, source, operation: "skipped" as const, reason: t("etcd.targetKeyWriteDenied"), selected: false };
}
const target = await api.etcdGet(targetId, shown, { keyBytes: source.key });
if (generation !== transferPreviewGeneration) throw new Error("同步预览已被新的请求替换。");
compared++;
@@ -809,6 +853,10 @@ async function applyTransfer() {
const targetId = targetConnectionId.value;
const rows = [...selectedTransferRows.value];
if (!targetId || rows.length === 0) return;
if (!rows.every((row) => row.source && canWriteConnectionKey(targetId, row.displayKey, row.source.key))) {
transferError.value = t("etcd.selectedKeysWriteDenied");
return;
}
// Invalidate any preview that is still resolving before writes begin.
transferPreviewGeneration++;
transferApplying.value = true;
@@ -966,8 +1014,16 @@ async function openSearchResult(result: SearchResult) {
}
async function openOperations(nextMode: Extract<WorkbenchMode, "maintenance" | "watch" | "lease">) {
if ((nextMode === "maintenance" || nextMode === "lease") && !canManageEtcd.value) return;
activeOperation.value = nextMode;
mode.value = nextMode;
operationsStatus.value = null;
// A watch is scoped to the Key/prefix supplied by the user. Do not probe
// cluster status first: ordinary etcd users normally lack that privilege.
if (nextMode === "watch") {
operationsLoading.value = false;
return;
}
operationsLoading.value = true;
try {
operationsStatus.value = await api.etcdStatus(props.connectionId);
@@ -988,6 +1044,7 @@ function openWatchWorkspaceAfterCreate() {
}
async function refreshLeaseOptions() {
if (!canManageEtcd.value) return;
try {
leaseOptions.value = (await api.etcdLeaseList(props.connectionId)).leases;
} catch (error) {
@@ -1034,7 +1091,7 @@ defineExpose({ focusSearch, refresh });
<Button size="sm" :variant="mode === 'keys' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-3 text-sm" @click="mode = 'keys'"><KeyRound class="h-4 w-4" /> {{ t("etcd.key") }}</Button>
<Button size="sm" :variant="mode === 'search' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-3 text-sm" @click="mode = 'search'"><Search class="h-4 w-4" /> {{ t("etcd.globalSearch") }}</Button>
</div>
<Button v-if="!isV2Api" size="sm" :variant="mode === 'maintenance' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-2.5 text-sm" @click="openOperations('maintenance')"><Wrench class="h-4 w-4" />{{ t("etcd.admin.maintenance") }}</Button>
<Button v-if="!isV2Api && canManageEtcd" size="sm" :variant="mode === 'maintenance' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-2.5 text-sm" @click="openOperations('maintenance')"><Wrench class="h-4 w-4" />{{ t("etcd.admin.maintenance") }}</Button>
<Button
size="sm"
:variant="mode === 'watch' ? 'secondary' : 'ghost'"
@@ -1045,7 +1102,7 @@ defineExpose({ focusSearch, refresh });
"
><Activity class="h-4 w-4" />{{ t("etcd.admin.watch") }}</Button
>
<Button v-if="!isV2Api" size="sm" :variant="mode === 'lease' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-2.5 text-sm" @click="openOperations('lease')"><KeyRound class="h-4 w-4" />{{ t("etcd.admin.lease") }}</Button>
<Button v-if="!isV2Api && canManageEtcd" size="sm" :variant="mode === 'lease' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-2.5 text-sm" @click="openOperations('lease')"><KeyRound class="h-4 w-4" />{{ t("etcd.admin.lease") }}</Button>
<div class="flex-1" />
<Badge v-if="readOnly" variant="outline">{{ t("connection.readOnly") }}</Badge>
<DropdownMenu>
@@ -1064,9 +1121,9 @@ defineExpose({ focusSearch, refresh });
</template>
</DropdownMenuContent>
</DropdownMenu>
<Button size="sm" variant="destructive" class="h-8 gap-1.5" :disabled="readOnly || selectedTreeKeys.length === 0 || batchDeleting" @click="batchDeleteOpen = true"><Trash2 class="h-3.5 w-3.5" />{{ t("etcd.delete") }}</Button>
<Button size="sm" variant="destructive" class="h-8 gap-1.5" :disabled="readOnly || !selectedTreeKeysWritable || batchDeleting" @click="batchDeleteOpen = true"><Trash2 class="h-3.5 w-3.5" />{{ t("etcd.delete") }}</Button>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="readOnly" @click="fileInput?.click()"><Upload class="h-3.5 w-3.5" /> {{ t("etcd.import") }}</Button>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="etcdConnections.length < 2" @click="openSync"><ArrowRightLeft class="h-3.5 w-3.5" /> {{ t("etcd.sync") }}</Button>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="readOnly || etcdConnections.length < 2" @click="openSync"><ArrowRightLeft class="h-3.5 w-3.5" /> {{ t("etcd.sync") }}</Button>
<input ref="fileInput" type="file" accept="application/json,.json" class="hidden" @change="onImportFile" />
</div>
@@ -1077,13 +1134,14 @@ defineExpose({ focusSearch, refresh });
:connection-id="props.connectionId"
:api="etcdApi"
:labels="labels"
:supports-ttl="supportsTtl"
:supports-lease-binding="!isV2Api"
:supports-ttl="supportsTtl && canManageEtcd"
:supports-lease-binding="!isV2Api && canManageEtcd"
:ttl-capability-known="ttlCapabilityKnown"
:enable-node-actions="true"
:safe-write="true"
:allow-binary-edit="true"
:read-only="readOnly"
:can-write-key="canWriteCurrentKey"
:enable-multi-select="true"
:on-watch-key="openWatchForKey"
export-format="dbx-etcd-bundle"
@@ -1164,10 +1222,10 @@ defineExpose({ focusSearch, refresh });
<div v-else class="flex h-52 items-center justify-center px-6 text-center text-sm text-muted-foreground">输入关键词后开始搜索。可使用 Prefix 限定扫描范围。</div>
</div>
</div>
<div v-if="operationsStatus || operationsLoading || watchPreset" v-show="isOperationsMode" class="min-h-0 flex-1 overflow-auto p-4">
<div v-if="isOperationsMode" class="min-h-0 flex-1 overflow-auto p-4">
<div v-if="operationsLoading && !operationsStatus" class="flex h-32 items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />加载集群状态...</div>
<EtcdAdminConsole
v-else-if="operationsStatus || watchPreset"
v-else-if="operationsStatus || activeOperation === 'watch'"
:connection-id="connectionId"
:status="operationsStatus"
:sections="[activeOperation]"
@@ -11,9 +11,14 @@ const backend = vi.hoisted(() => ({
etcdDelete: vi.fn(),
etcdRename: vi.fn(),
etcdHistory: vi.fn(),
etcdStatus: vi.fn(),
etcdGetCalls: [] as unknown[][],
capturedKvBrowserApi: null as any,
capturedCanWriteKey: null as any,
canWriteEtcdKey: vi.fn(),
ensureEtcdAccessCapabilities: vi.fn(),
selectKeyCalls: [] as unknown[],
access: { admin: true, writable: true },
}));
vi.mock("vue-i18n", () => ({
@@ -27,13 +32,17 @@ vi.mock("@/lib/backend/api", () => ({
etcdDelete: backend.etcdDelete,
etcdRename: backend.etcdRename,
etcdHistory: backend.etcdHistory,
etcdStatus: backend.etcdStatus,
etcdSupportsTtl: backend.etcdSupportsTtl,
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
connections: [],
getConfig: () => undefined,
getConfig: () => ({ id: "etcd-1", db_type: "etcd" }),
getEtcdAccessCapabilities: () => backend.access,
ensureEtcdAccessCapabilities: backend.ensureEtcdAccessCapabilities,
canWriteEtcdKey: backend.canWriteEtcdKey,
}),
}));
@@ -45,11 +54,15 @@ vi.mock("@/components/kv/KvKeyBrowser.vue", () => ({
api: { type: Object, required: true },
connectionId: { type: String, required: true },
supportsTtl: { type: Boolean, default: false },
supportsLeaseBinding: { type: Boolean, default: false },
ttlCapabilityKnown: { type: Boolean, default: true },
readOnly: { type: Boolean, default: false },
canWriteKey: { type: Function, default: undefined },
},
emits: ["refreshRequested", "selectionChange"],
setup(props, { emit, expose }) {
backend.capturedKvBrowserApi = props.api;
backend.capturedCanWriteKey = props.canWriteKey;
expose({
focusSearch: () => true,
selectKey: (route: unknown) => {
@@ -73,7 +86,9 @@ vi.mock("@/components/kv/KvKeyBrowser.vue", () => ({
{
id: "kv-browser-stub",
"data-supports-ttl": String(props.supportsTtl),
"data-supports-lease-binding": String(props.supportsLeaseBinding),
"data-capability-known": String(props.ttlCapabilityKnown),
"data-read-only": String(props.readOnly),
onClick: () => emit("refreshRequested"),
},
"refresh",
@@ -112,6 +127,15 @@ vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({
}),
}));
vi.mock("@/components/etcd/EtcdAdminConsole.vue", () => ({
default: defineComponent({
name: "EtcdAdminConsoleStub",
setup() {
return () => h("div", { id: "etcd-admin-console-stub" });
},
}),
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: defineComponent({
name: "DropdownMenuStub",
@@ -175,10 +199,16 @@ beforeEach(() => {
backend.etcdDelete.mockReset();
backend.etcdRename.mockReset();
backend.etcdHistory.mockReset();
backend.etcdStatus.mockReset();
backend.etcdListPrefix.mockResolvedValue({ keys: [], continuation: null, revision: "1" });
backend.etcdGetCalls.length = 0;
backend.capturedKvBrowserApi = null;
backend.capturedCanWriteKey = null;
backend.canWriteEtcdKey.mockReset().mockReturnValue(true);
backend.ensureEtcdAccessCapabilities.mockReset().mockImplementation(async () => backend.access);
backend.selectKeyCalls.length = 0;
backend.access.admin = true;
backend.access.writable = true;
});
afterEach(() => {
@@ -236,6 +266,37 @@ describe("EtcdKeyBrowser TTL capability recovery", () => {
});
});
describe("EtcdKeyBrowser restricted etcd access", () => {
it("loads access capabilities when a restored tab mounts without sidebar expansion", async () => {
await mountBrowser();
await vi.waitFor(() =>
expect(backend.ensureEtcdAccessCapabilities).toHaveBeenCalledWith("etcd-1", {
force: true,
verifyHealth: false,
}),
);
});
it("hides cluster controls and opens a Key watch without reading cluster status", async () => {
backend.access.admin = false;
backend.access.writable = false;
const browser = await mountBrowser();
expect(browser.dataset.readOnly).toBe("true");
expect(browser.dataset.supportsLeaseBinding).toBe("false");
expect(root!.textContent).not.toContain("etcd.admin.maintenance");
expect(root!.textContent).not.toContain("etcd.admin.lease");
const watch = [...root!.querySelectorAll("button")].find((button) => button.textContent?.includes("etcd.admin.watch"));
watch?.click();
await flushUi();
expect(root!.querySelector("#etcd-admin-console-stub")).toBeTruthy();
expect(backend.etcdStatus).not.toHaveBeenCalled();
});
});
describe("EtcdKeyBrowser global search", () => {
it("clears results and counters while retaining the search form", async () => {
backend.etcdListPrefix.mockResolvedValue({
@@ -456,4 +517,18 @@ describe("EtcdKeyBrowser tree batch operations", () => {
const selectedExports = [...root!.querySelectorAll("button")].filter((button) => button.textContent?.includes("etcd.exportSelection"));
expect(selectedExports).toHaveLength(0);
});
it("blocks a selected Key outside the user's write ranges before any delete", async () => {
backend.canWriteEtcdKey.mockImplementation((_connectionId, _key, keyBytes) => keyBytes?.data !== "/w==");
await mountBrowser();
root!.querySelector<HTMLButtonElement>("#emit-tree-selection")!.click();
await flushUi();
const batchDelete = [...root!.querySelectorAll("button")].find((button) => button.textContent?.includes("etcd.delete")) as HTMLButtonElement;
expect(batchDelete.disabled).toBe(true);
expect(() => backend.capturedKvBrowserApi.deleteKey("etcd-1", "[base64:/w==]", { keyBytes: { encoding: "base64", data: "/w==" } })).toThrow("ETCD_PERMISSION_DENIED");
expect(backend.etcdDelete).not.toHaveBeenCalled();
expect(backend.capturedCanWriteKey({ key: "[base64:/w==]", keyBytes: { encoding: "base64", data: "/w==" } })).toBe(false);
});
});
+40 -11
View File
@@ -203,6 +203,7 @@ const props = withDefaults(
exportFileExtension?: string;
exportFallbackName?: string;
enableMultiSelect?: boolean;
canWriteKey?: (route: KvKeyRoute) => boolean;
onWatchKey?: (route: KvKeyRoute) => void;
onDeletePrefix?: (prefix: string) => void;
watchActiveKey?: string | null;
@@ -413,6 +414,21 @@ const selectedValueClipboardText = computed(() => {
return selectedValueUsesUtf8Preview.value && preview?.ok ? preview.value : selectedTextValue.value;
});
const selectedKeyBytes = computed(() => selectedValue.value?.keyBytes ?? selectedRouteKeyBytes.value ?? null);
function keyIsWritable(key: string, keyBytes?: KvValue | null): boolean {
return !props.readOnly && (props.canWriteKey?.({ key, keyBytes }) ?? true);
}
const selectedKeyWritable = computed(() => Boolean(selectedKey.value && keyIsWritable(selectedKey.value, selectedKeyBytes.value)));
const editKeyWritable = computed(() => {
const key = editKey.value.trim();
if (!key) return false;
const keyBytes = !isCreating.value && key === selectedKey.value ? selectedKeyBytes.value : null;
return keyIsWritable(key, keyBytes);
});
const renameTargetWritable = computed(() => {
const target = renameValue.value.trim();
if (!target) return false;
return keyIsWritable(target) && (renameMode.value === "copy" || selectedKeyWritable.value);
});
const selectedKeyLocked = computed(() => Boolean(String(selectedMetadata.value?.session ?? "").trim()));
const isWatchingSelectedKey = computed(() => Boolean(selectedKey.value && props.watchActiveKey === selectedKey.value));
const activeSearchHighlight = computed(() => (props.searchHighlight?.key === selectedKey.value ? props.searchHighlight : null));
@@ -460,8 +476,8 @@ const editValueSize = computed(() => {
}
return new TextEncoder().encode(editValue.value).length;
});
const canEditSelectedValue = computed(() => !props.readOnly && !selectedKeyLocked.value && (!selectedValueIsBase64.value || props.allowBinaryEdit));
const canDeleteSelectedValue = computed(() => !props.readOnly && !selectedKeyLocked.value);
const canEditSelectedValue = computed(() => selectedKeyWritable.value && !selectedKeyLocked.value && (!selectedValueIsBase64.value || props.allowBinaryEdit));
const canDeleteSelectedValue = computed(() => selectedKeyWritable.value && !selectedKeyLocked.value);
const consulMetadataRows = computed(() => {
const metadata = selectedMetadata.value;
return [
@@ -1258,6 +1274,11 @@ async function saveKey() {
return;
}
const key = props.lazyHierarchy ? normalizeLazyKvPath(rawKey, props.lazyPathStyle) : rawKey;
const keyBytes = !isCreating.value && key === selectedKey.value ? selectedKeyBytes.value : null;
if (!keyIsWritable(key, keyBytes)) {
editError.value = t("connection.readOnly");
return;
}
const validationError = validateKvValue(editValue.value, editFormat.value);
if (validationError) {
editError.value = validationError;
@@ -1275,6 +1296,7 @@ async function saveKey() {
async function confirmSaveKey() {
if (!pendingSave.value) return;
const { key, value, options } = pendingSave.value;
if (!keyIsWritable(key, options?.keyBytes)) return;
saving.value = true;
editError.value = "";
editErrorKind.value = "request";
@@ -1367,7 +1389,8 @@ async function selectNodeForAction(node: BrowserTreeNode) {
}
async function openDeleteForNode(node: BrowserTreeNode) {
if (props.readOnly) return;
const route = routeFromNode(node);
if (!keyIsWritable(route.key, route.keyBytes)) return;
await selectNodeForAction(node);
if (!selectedKey.value || !selectedValue.value?.found || !canDeleteSelectedValue.value) return;
showDeleteConfirm.value = true;
@@ -1499,7 +1522,7 @@ function onEditFormatChange(value: unknown) {
}
function openRenameDialog() {
if (!selectedKey.value || !props.api.rename || props.readOnly) return;
if (!selectedKey.value || !props.api.rename || !selectedKeyWritable.value) return;
renameMode.value = "rename";
renameValue.value = selectedKey.value;
renameError.value = "";
@@ -1515,6 +1538,10 @@ async function moveOrCopySelectedKey() {
renameError.value = props.labels.keyRequired;
return;
}
if (!renameTargetWritable.value) {
renameError.value = t("connection.readOnly");
return;
}
renaming.value = true;
renameError.value = "";
try {
@@ -1564,7 +1591,7 @@ function compareHistory(event: KvHistoryEvent) {
}
async function restoreHistory() {
if (!selectedKey.value || !historyRestoreValue.value) return;
if (!selectedKey.value || !historyRestoreValue.value || !selectedKeyWritable.value) return;
restoring.value = true;
try {
await props.api.put(props.connectionId, selectedKey.value, historyRestoreValue.value, {
@@ -1584,6 +1611,8 @@ async function restoreHistory() {
function nodeContextMenuItems(node: BrowserTreeNode): ContextMenuItem[] {
if (!props.enableNodeActions) return [];
const route = routeFromNode(node);
const nodeWritable = keyIsWritable(route.key, route.keyBytes);
const items: ContextMenuItem[] = [
{
label: props.labels.add || props.labels.newKey,
@@ -1598,7 +1627,7 @@ function nodeContextMenuItems(node: BrowserTreeNode): ContextMenuItem[] {
label: props.labels.edit,
icon: Pencil,
action: () => void loadSelectedKey(routeFromNode(node)).then(openEditDialog),
disabled: props.readOnly,
disabled: !nodeWritable,
},
{
label: props.labels.clone || "Clone",
@@ -1617,7 +1646,7 @@ function nodeContextMenuItems(node: BrowserTreeNode): ContextMenuItem[] {
label: props.labels.rename || "Rename",
icon: Pencil,
action: () => void loadSelectedKey(routeFromNode(node)).then(openRenameDialog),
disabled: props.readOnly,
disabled: !nodeWritable,
});
}
if (props.api.history) {
@@ -1649,7 +1678,7 @@ function nodeContextMenuItems(node: BrowserTreeNode): ContextMenuItem[] {
icon: Trash2,
variant: "destructive",
action: () => void openDeleteForNode(node),
disabled: props.readOnly || !nodeHasValue(node),
disabled: !nodeWritable || !nodeHasValue(node),
});
}
return items;
@@ -2239,7 +2268,7 @@ defineExpose({
</div>
<DialogFooter class="mx-0 mb-0 shrink-0 gap-3 border-t bg-muted/10 px-6 py-5">
<Button variant="outline" class="h-10 min-w-20" @click="showEditDialog = false">{{ t("common.cancel") }}</Button>
<Button class="h-10 min-w-20" :disabled="saving || readOnly" @click="saveKey">
<Button class="h-10 min-w-20" :disabled="saving || !editKeyWritable" @click="saveKey">
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
{{ t("common.save") }}
</Button>
@@ -2260,7 +2289,7 @@ defineExpose({
</div>
<DialogFooter>
<Button variant="outline" :disabled="renaming" @click="showRenameDialog = false">{{ t("common.cancel") }}</Button>
<Button :disabled="renaming || readOnly" @click="moveOrCopySelectedKey">
<Button :disabled="renaming || !renameTargetWritable" @click="moveOrCopySelectedKey">
<Loader2 v-if="renaming" class="mr-2 h-4 w-4 animate-spin" />
{{ renameMode === "copy" ? labels.clone || "Clone" : labels.rename || "Rename" }}
</Button>
@@ -2317,7 +2346,7 @@ defineExpose({
:before="selectedTextValue"
:after="historyRestoreValue?.data || ''"
:loading="restoring"
:show-confirm="!readOnly && !!historyRestoreValue"
:show-confirm="selectedKeyWritable && !!historyRestoreValue"
:confirm-label="labels.restore || 'Restore'"
@confirm="restoreHistory"
/>
@@ -14,7 +14,7 @@ describe("KvKeyBrowser edit dialog layout", () => {
it("cancels the shared footer negative margin and preserves bottom safe spacing", () => {
expect(browserSource).toContain('<DialogFooter class="mx-0 mb-0 shrink-0 gap-3 border-t bg-muted/10 px-6 py-5">');
expect(browserSource).toContain('variant="outline" class="h-10 min-w-20"');
expect(browserSource).toContain('<Button class="h-10 min-w-20" :disabled="saving || readOnly"');
expect(browserSource).toContain('<Button class="h-10 min-w-20" :disabled="saving || !editKeyWritable"');
});
it("applies the same safe spacing to the history dialog footer", () => {
@@ -217,6 +217,15 @@ const emit = defineEmits<ContentAreaSurfaceEmits>();
const { t, locale } = useI18n();
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
const canAccessEtcdAdmin = computed(() => connectionStore.getEtcdAccessCapabilities(props.activeTab.connectionId).admin);
watch(
() => [props.activeTab.connectionId, props.activeTab.mode] as const,
([connectionId, mode]) => {
if (mode !== "etcd-dashboard" && mode !== "etcd-access-control") return;
void connectionStore.ensureEtcdAccessCapabilities(connectionId, { force: true, verifyHealth: false }).catch(() => undefined);
},
{ immediate: true },
);
function groupedQueryReadonlyColumnIndexes(tab: QueryTab): number[] | undefined {
if (!tab.queryAnalysis?.groupByColumns?.length || !tab.querySourceColumns || !tab.tableMeta?.primaryKeys.length) return undefined;
@@ -2308,13 +2317,15 @@ defineExpose({
<!-- etcd Dashboard: cluster observation -->
<template v-else-if="activeTab.mode === 'etcd-dashboard'">
<div class="flex-1 min-h-0">
<EtcdDashboard ref="etcdDashboardRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
<EtcdDashboard v-if="canAccessEtcdAdmin" ref="etcdDashboardRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
<div v-else class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground"><ShieldAlert class="h-4 w-4" />{{ t("etcd.adminPermissionRequired") }}</div>
</div>
</template>
<template v-else-if="activeTab.mode === 'etcd-access-control'">
<div class="flex-1 min-h-0">
<EtcdAccessControl :key="activeTab.id" :connection-id="activeTab.connectionId" />
<EtcdAccessControl v-if="canAccessEtcdAdmin" :key="activeTab.id" :connection-id="activeTab.connectionId" />
<div v-else class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground"><ShieldAlert class="h-4 w-4" />{{ t("etcd.adminPermissionRequired") }}</div>
</div>
</template>
@@ -696,14 +696,23 @@ function isGroupLabel(node: TreeNode): boolean {
async function openDirectNavigationNode(node: TreeNode, requestId: number) {
if (!node.connectionId) return;
const isNacosNavigation = node.type === "nacos-namespace" || node.type === "nacos-access-control";
await connectionStore.ensureConnected(node.connectionId, isNacosNavigation ? { verifyHealth: false } : undefined);
const isEtcdNavigation = node.type === "etcd-root" || node.type === "etcd-dashboard" || node.type === "etcd-access-control";
await connectionStore.ensureConnected(node.connectionId, isNacosNavigation || isEtcdNavigation ? { verifyHealth: false } : undefined);
if (!isCurrentNavigationRequest(requestId)) return;
const connectionName = connectionStore.getConfig(node.connectionId)?.name || (isNacosNavigation ? "Nacos" : "Consul");
if ((node.type === "etcd-dashboard" || node.type === "etcd-access-control") && !connectionStore.getEtcdAccessCapabilities(node.connectionId).admin) return;
const connectionName = connectionStore.getConfig(node.connectionId)?.name || (isNacosNavigation ? "Nacos" : isEtcdNavigation ? "etcd" : "Consul");
if (node.type === "consul-root") {
queryStore.createTab(node.connectionId, "", `${connectionName}:keys`, "consul");
refreshActiveKvBrowserAfterOpen("consul", node.connectionId);
} else if (node.type === "consul-overview") {
queryStore.createTab(node.connectionId, "", `${connectionName}:${t("consul.ui.overview")}`, "consul-overview");
} else if (node.type === "etcd-root") {
queryStore.createTab(node.connectionId, "", `${connectionName}:keys`, "etcd");
refreshActiveKvBrowserAfterOpen("etcd", node.connectionId);
} else if (node.type === "etcd-dashboard") {
queryStore.createTab(node.connectionId, "", `${connectionName}:dashboard`, "etcd-dashboard");
} else if (node.type === "etcd-access-control") {
queryStore.createTab(node.connectionId, "", `${connectionName}:access-control`, "etcd-access-control");
} else if (node.type === "nacos-namespace") {
queryStore.openNacosAdmin(node.connectionId, { namespace: node.nacosNamespace || "", namespaceName: node.nacosNamespaceName || node.label });
} else if (node.type === "nacos-access-control") {
@@ -918,19 +927,6 @@ async function toggle(requestId = beginNavigationRequest()) {
await connectionStore.ensureConnected(node.connectionId);
const topicFromId = node.id.endsWith(":mqtt-topic:__console__") ? undefined : node.id.split(":mqtt-topic:")[1] || node.label;
queryStore.openMqttAdmin(node.connectionId, topicFromId ? { initialTopic: topicFromId } : undefined);
} else if (node.type === "etcd-root" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:keys`;
queryStore.createTab(node.connectionId, "", tabTitle, "etcd");
refreshActiveKvBrowserAfterOpen("etcd", node.connectionId);
} else if (node.type === "etcd-dashboard" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:dashboard`;
queryStore.createTab(node.connectionId, "", tabTitle, "etcd-dashboard");
} else if (node.type === "etcd-access-control" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:access-control`;
queryStore.createTab(node.connectionId, "", tabTitle, "etcd-access-control");
} else if (node.type === "zookeeper-root" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "ZooKeeper"}:keys`;
@@ -6239,6 +6235,14 @@ function handleRowClick(node: TreeNode, clickDetail: number) {
}
function handleRowDoubleClick(node: TreeNode, event: MouseEvent) {
// Direct-navigation rows already handle every click in the click sequence.
// Starting a new request for their trailing dblclick would invalidate the
// still-pending single-click navigation without providing a replacement in
// single-click activation mode.
if (isDirectNavigationTreeNode(node.type)) {
activateRuntimeNode(node);
return;
}
beginNavigationRequest();
activateRuntimeNode(node);
onDoubleClick(event);
@@ -16,6 +16,7 @@ const connectionStore = {
canUseLoadedTreeNodeToggle: vi.fn(() => true),
releaseCollapsedTreeNodeChildren: vi.fn(),
getConfig: vi.fn(() => ({ db_type: "mysql", name: "connection" })),
getEtcdAccessCapabilities: vi.fn(() => ({ admin: true, writable: true, writePermissions: null })),
ensureConnected: vi.fn(async () => undefined),
loadPackageMembers: vi.fn(async (node: TreeNode) => {
node.isExpanded = true;
@@ -63,6 +64,7 @@ afterEach(() => {
vi.clearAllMocks();
connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(true);
connectionStore.getConfig.mockReturnValue({ db_type: "mysql", name: "connection" });
connectionStore.getEtcdAccessCapabilities.mockReturnValue({ admin: true, writable: true, writePermissions: null });
});
describe("SidebarTreeRuntimeHost expansion", () => {
@@ -264,6 +266,96 @@ describe("SidebarTreeRuntimeHost expansion", () => {
expect(systemNode.isExpanded).toBe(true);
});
it("opens etcd workspaces without waiting for a connection health probe", async () => {
connectionStore.getConfig.mockReturnValue({ db_type: "etcd", name: "local-etcd-v3" });
connectionStore.ensureConnected.mockImplementation(async (...args: any[]) => {
const options = args[1] as { verifyHealth?: boolean } | undefined;
if (options?.verifyHealth === false) return;
await new Promise<void>(() => undefined);
});
const nodes: TreeNode[] = [
{ id: "etcd:root", label: "Keys", type: "etcd-root", connectionId: "etcd" },
{ id: "etcd:access-control", label: "Users & Roles", type: "etcd-access-control", connectionId: "etcd" },
{ id: "etcd:dashboard", label: "Dashboard", type: "etcd-dashboard", connectionId: "etcd" },
];
connectionStore.treeNodes = nodes;
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const app = createApp(
defineComponent({
setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node: nodes[0], depth: 0 }),
}),
);
mountedApps.push(app);
const container = document.createElement("div");
document.body.append(container);
app.use(i18n);
app.mount(container);
await nextTick();
for (const [index, node] of nodes.entries()) {
host.value!.handleRowClick(node, index + 1);
await vi.waitFor(() => expect(queryStore.createTab).toHaveBeenCalledTimes(index + 1));
}
expect(connectionStore.ensureConnected).toHaveBeenNthCalledWith(1, "etcd", { verifyHealth: false });
expect(connectionStore.ensureConnected).toHaveBeenNthCalledWith(2, "etcd", { verifyHealth: false });
expect(connectionStore.ensureConnected).toHaveBeenNthCalledWith(3, "etcd", { verifyHealth: false });
expect(queryStore.createTab).toHaveBeenNthCalledWith(1, "etcd", "", "local-etcd-v3:keys", "etcd");
expect(queryStore.createTab).toHaveBeenNthCalledWith(2, "etcd", "", "local-etcd-v3:access-control", "etcd-access-control");
expect(queryStore.createTab).toHaveBeenNthCalledWith(3, "etcd", "", "local-etcd-v3:dashboard", "etcd-dashboard");
});
it("does not let the trailing dblclick cancel a pending etcd navigation", async () => {
let resolveConnection!: () => void;
const connection = new Promise<void>((resolve) => {
resolveConnection = resolve;
});
connectionStore.ensureConnected.mockReturnValue(connection);
connectionStore.getConfig.mockReturnValue({ db_type: "etcd", name: "local-etcd-v3" });
const node: TreeNode = { id: "etcd:dashboard", label: "Dashboard", type: "etcd-dashboard", connectionId: "etcd" };
connectionStore.treeNodes = [node];
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const app = createApp(
defineComponent({
setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node, depth: 0 }),
}),
);
mountedApps.push(app);
const container = document.createElement("div");
document.body.append(container);
app.use(i18n);
app.mount(container);
await nextTick();
host.value!.handleRowClick(node, 1);
host.value!.handleRowDoubleClick(node, new MouseEvent("dblclick", { detail: 2 }));
resolveConnection();
await vi.waitFor(() => expect(queryStore.createTab).toHaveBeenCalledWith("etcd", "", "local-etcd-v3:dashboard", "etcd-dashboard"));
});
it("does not reopen a stale etcd admin node after access becomes restricted", async () => {
connectionStore.getConfig.mockReturnValue({ db_type: "etcd", name: "local-etcd-v3" });
connectionStore.getEtcdAccessCapabilities.mockReturnValue({ admin: false, writable: true, writePermissions: [] });
const node: TreeNode = { id: "etcd:dashboard", label: "Dashboard", type: "etcd-dashboard", connectionId: "etcd" };
connectionStore.treeNodes = [node];
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const app = createApp(defineComponent({ setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node, depth: 0 }) }));
mountedApps.push(app);
const container = document.createElement("div");
document.body.append(container);
app.use(i18n);
app.mount(container);
await nextTick();
host.value!.handleRowClick(node, 1);
await vi.waitFor(() => expect(connectionStore.ensureConnected).toHaveBeenCalled());
expect(queryStore.createTab).not.toHaveBeenCalled();
});
it("expands an unloaded object group without leaking the regex source as a search filter", async () => {
const group: TreeNode = {
id: "mysql:basic:__tables",
@@ -503,3 +503,25 @@ describe("TreeItem searched connection activation", () => {
expect(host.handleRowDoubleClick).toHaveBeenCalledWith(node, expect.any(MouseEvent));
});
});
describe("TreeItem etcd workspace activation", () => {
it.each([
["etcd-root", 1],
["etcd-access-control", 2],
["etcd-dashboard", 3],
] as const)("activates %s with click detail %i even in double-click mode", async (type, detail) => {
settingsStore.editorSettings.sidebarActivation = "double";
const node: TreeNode = {
id: `connection-1:${type}`,
label: type,
type,
connectionId: "connection-1",
};
const { row, host } = await mountTreeItem(node);
row.dispatchEvent(new MouseEvent("click", { bubbles: true, detail }));
expect(host.handleRowClick).toHaveBeenCalledWith(node, detail);
expect(host.handleRowDoubleClick).not.toHaveBeenCalled();
});
});