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();
});
});
+3
View File
@@ -4352,6 +4352,9 @@ export default {
relationshipUpdated: "Relationship updated",
},
etcd: {
adminPermissionRequired: "This etcd view requires the root role.",
targetKeyWriteDenied: "The target connection does not have permission to write this Key.",
selectedKeysWriteDenied: "The target connection does not have permission to write the selected Keys; no changes were made.",
prefixPlaceholder: "Search Key path, e.g. /app/ or service",
newKey: "New Key",
loadingKeys: "Loading keys...",
+3
View File
@@ -4530,6 +4530,9 @@ export default withEnglishFallback({
leaseGrantCancelled: "Espera cancelada; etcd puede estar procesando aún la solicitud de concesión; actualice para confirmar si se ha creado el Lease.",
leaseCreated: "Lease {id} creado",
},
adminPermissionRequired: "Esta página de etcd solo está abierta al rol root.",
targetKeyWriteDenied: "La conexión de destino no tiene permiso para escribir esta Key.",
selectedKeysWriteDenied: "La conexión de destino no tiene permiso para escribir las Keys seleccionadas; no se realizaron cambios.",
},
consul,
sqlServerTrace,
+3
View File
@@ -4528,6 +4528,9 @@ export default withEnglishFallback({
leaseGrantCancelled: "Attesa annullata; etcd potrebbe ancora elaborare la richiesta di concessione. Aggiorna per verificare se il Lease è stato creato.",
leaseCreated: "Lease {id} creato",
},
adminPermissionRequired: "Questa pagina etcd è accessibile solo al ruolo root.",
targetKeyWriteDenied: "La connessione di destinazione non dispone dell'autorizzazione per scrivere questa Key.",
selectedKeysWriteDenied: "La connessione di destinazione non dispone dell'autorizzazione per scrivere le Key selezionate; non è stata eseguita alcuna modifica.",
},
consul,
sqlServerTrace,
+3
View File
@@ -4595,6 +4595,9 @@ export default withEnglishFallback({
leaseGrantCancelled: "待機をキャンセルしました。etcdがまだこの付与リクエストを処理している可能性があります。リースが作成されたかどうかリフレッシュして確認してください。",
leaseCreated: "リース {id} を作成しました",
},
adminPermissionRequired: "この etcd ページは root ロールのみに開放されています。",
targetKeyWriteDenied: "対象の接続にはこの Key への書き込み権限がありません。",
selectedKeysWriteDenied: "対象の接続には選択した Key への書き込み権限がないため、変更は実行されませんでした。",
},
redis: {
setDatabaseAlias: "データベースの別名を設定",
+3
View File
@@ -4161,6 +4161,9 @@ export default withEnglishFallback({
healthy: "정상",
},
},
adminPermissionRequired: "이 etcd 페이지는 root 역할에만 공개됩니다.",
targetKeyWriteDenied: "대상 연결에는 이 Key에 쓸 권한이 없습니다.",
selectedKeysWriteDenied: "대상 연결에는 선택한 Key에 쓸 권한이 없어 변경 사항이 적용되지 않았습니다.",
},
consul,
sqlServerTrace,
+3
View File
@@ -4530,6 +4530,9 @@ export default withEnglishFallback({
leaseGrantCancelled: "Aguardando cancelado; etcd pode ainda estar processando a solicitação de concessão. Atualize para confirmar se o Lease foi criado.",
leaseCreated: "Lease {id} criado",
},
adminPermissionRequired: "Esta página do etcd está disponível apenas para o papel root.",
targetKeyWriteDenied: "A conexão de destino não tem permissão para gravar esta Key.",
selectedKeysWriteDenied: "A conexão de destino não tem permissão para gravar as Keys selecionadas; nenhuma alteração foi feita.",
},
consul,
sqlServerTrace,
+3
View File
@@ -4336,6 +4336,9 @@ export default withEnglishFallback({
relationshipUpdated: "关系已更新",
},
etcd: {
adminPermissionRequired: "此 etcd 页面仅对 root 角色开放。",
targetKeyWriteDenied: "目标连接没有此 Key 的写权限。",
selectedKeysWriteDenied: "目标连接没有所选 Key 的写权限,未执行任何写入。",
prefixPlaceholder: "搜索 Key 路径,例如 /app/ 或 service",
newKey: "新建键(Key)",
loadingKeys: "正在加载 Key...",
+3
View File
@@ -7583,6 +7583,9 @@ export default withEnglishFallback({
leaseGrantCancelled: "已取消等待;etcd 可能仍在處理該授權請求,請重新整理確認是否已建立 Lease。",
leaseCreated: "已建立 Lease {id}",
},
adminPermissionRequired: "此 etcd 頁面僅對 root 角色開放。",
targetKeyWriteDenied: "目標連線沒有此 Key 的寫入權限。",
selectedKeysWriteDenied: "目標連線沒有所選 Key 的寫入權限,未執行任何寫入。",
},
consul,
sqlServerTrace,
@@ -29,7 +29,7 @@ describe("etcd v2 feature trimming", () => {
});
it("hides lease binding and the maintenance/lease workspaces for v2 connections", () => {
expect(browserSource).toContain(':supports-lease-binding="!isV2Api"');
expect(browserSource).toContain('<Button v-if="!isV2Api"');
expect(browserSource).toContain(':supports-lease-binding="!isV2Api && canManageEtcd"');
expect(browserSource).toContain('<Button v-if="!isV2Api && canManageEtcd"');
});
});
@@ -65,6 +65,18 @@ describe("treeNodeClick", () => {
expect(treeNodeRowAction("meilisearch-system", false, "double")).toBe("toggle");
});
it("opens etcd workspaces on one click regardless of object activation preference", () => {
for (const type of ["etcd-root", "etcd-access-control", "etcd-dashboard"] as const) {
expect(isDirectNavigationTreeNode(type), type).toBe(true);
expect(shouldActivateTreeNodeOnSingleClick(type, "double"), type).toBe(true);
const action = treeNodeRowAction(type, false, "double");
expect(action, type).toBe("toggle");
expect(isRepeatableNavigationTreeNode(type), type).toBe(true);
expect(shouldRunTreeNodeRowAction(action, 2, isRepeatableNavigationTreeNode(type)), type).toBe(true);
expect(shouldRunTreeNodeRowAction(action, 3, isRepeatableNavigationTreeNode(type)), type).toBe(true);
}
});
it("keeps Nacos namespace and access-control navigation responsive during rapid row switching", () => {
for (const type of ["nacos-namespace", "nacos-access-control"] as const) {
expect(isDirectNavigationTreeNode(type), type).toBe(true);
+1
View File
@@ -3140,6 +3140,7 @@ export interface EtcdAuthUserListResponse {
export interface EtcdAuthUserDetail {
user: string;
roles: string[];
authEnabled?: boolean;
}
export interface EtcdAuthPermission {
access: "read" | "write" | "readwrite";
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import type { EtcdAuthPermission, KvValue } from "@/lib/backend/api";
import { etcdPermissionAllowsKey, etcdPermissionsAllowKey } from "@/lib/etcd/keyPermissions";
const utf8 = (data: string): KvValue => ({ encoding: "utf8", data });
const base64 = (bytes: number[]): KvValue => ({ encoding: "base64", data: btoa(String.fromCharCode(...bytes)) });
function permission(access: EtcdAuthPermission["access"], key: KvValue, rangeEnd: KvValue, resource: EtcdAuthPermission["resource"]): EtcdAuthPermission {
return { access, key, rangeEnd, resource };
}
describe("etcd Key write permissions", () => {
it("matches exact Key grants only", () => {
const grant = permission("write", utf8("/app/key"), utf8(""), "key");
expect(etcdPermissionAllowsKey(grant, utf8("/app/key"))).toBe(true);
expect(etcdPermissionAllowsKey(grant, utf8("/app/key/child"))).toBe(false);
});
it("uses etcd's half-open byte ranges", () => {
const grant = permission("readwrite", utf8("/app/"), utf8("/app0"), "prefix");
expect(etcdPermissionAllowsKey(grant, utf8("/app/a"))).toBe(true);
expect(etcdPermissionAllowsKey(grant, utf8("/app0"))).toBe(false);
expect(etcdPermissionAllowsKey(grant, utf8("/other"))).toBe(false);
});
it("supports binary Keys and the unbounded range sentinel", () => {
const grant = permission("write", base64([0x80]), base64([0]), "prefix");
expect(etcdPermissionAllowsKey(grant, base64([0x80, 0x01]))).toBe(true);
expect(etcdPermissionAllowsKey(grant, base64([0x7f]))).toBe(false);
});
it("ignores read-only grants when permissions are combined", () => {
const grants = [permission("read", utf8("/readonly/"), utf8("/readonly0"), "prefix"), permission("readwrite", utf8("/editable/"), utf8("/editable0"), "prefix")];
expect(etcdPermissionsAllowKey(grants, utf8("/readonly/key"))).toBe(false);
expect(etcdPermissionsAllowKey(grants, utf8("/editable/key"))).toBe(true);
});
});
@@ -0,0 +1,45 @@
import type { EtcdAuthPermission, KvValue } from "@/lib/backend/api";
const unboundedRangeEnd = new Uint8Array([0]);
function decodeBase64(value: string): Uint8Array {
const decoded = globalThis.atob(value.replace(/\s+/g, ""));
return Uint8Array.from(decoded, (character) => character.charCodeAt(0));
}
export function etcdKvValueBytes(value: KvValue): Uint8Array {
return value.encoding === "base64" ? decodeBase64(value.data) : new TextEncoder().encode(value.data);
}
function compareBytes(left: Uint8Array, right: Uint8Array): number {
const length = Math.min(left.length, right.length);
for (let index = 0; index < length; index += 1) {
const difference = left[index]! - right[index]!;
if (difference !== 0) return difference;
}
return left.length - right.length;
}
function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
return compareBytes(left, right) === 0;
}
function isUnboundedRangeEnd(value: Uint8Array): boolean {
return bytesEqual(value, unboundedRangeEnd);
}
/** Tests one Key against etcd's byte-oriented, half-open permission range. */
export function etcdPermissionAllowsKey(permission: EtcdAuthPermission, key: KvValue): boolean {
if (permission.access === "read") return false;
if (permission.resource === "all") return true;
const keyBytes = etcdKvValueBytes(key);
const start = etcdKvValueBytes(permission.key);
const end = etcdKvValueBytes(permission.rangeEnd);
if (permission.resource === "key" || end.length === 0) return bytesEqual(keyBytes, start);
return compareBytes(keyBytes, start) >= 0 && (isUnboundedRangeEnd(end) || compareBytes(keyBytes, end) < 0);
}
export function etcdPermissionsAllowKey(permissions: readonly EtcdAuthPermission[], key: KvValue): boolean {
return permissions.some((permission) => etcdPermissionAllowsKey(permission, key));
}
@@ -35,8 +35,8 @@ const toggleLeafNodeTypes = new Set<TreeNodeType>([
// These are application entry points rather than database objects. They should
// always navigate on a single click, even when the user prefers double-click
// activation for ordinary tree objects.
const directNavigationTreeNodeTypes = new Set<TreeNodeType>(["consul-root", "consul-overview", "nacos-namespace", "nacos-access-control", "meilisearch-system"]);
const repeatableNavigationTreeNodeTypes = new Set<TreeNodeType>(["nacos-namespace", "nacos-access-control"]);
const directNavigationTreeNodeTypes = new Set<TreeNodeType>(["consul-root", "consul-overview", "etcd-root", "etcd-dashboard", "etcd-access-control", "nacos-namespace", "nacos-access-control", "meilisearch-system"]);
const repeatableNavigationTreeNodeTypes = new Set<TreeNodeType>(["etcd-root", "etcd-dashboard", "etcd-access-control", "nacos-namespace", "nacos-access-control"]);
export function isDirectNavigationTreeNode(type: TreeNodeType): boolean {
return directNavigationTreeNodeTypes.has(type);
@@ -0,0 +1,217 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { EtcdAuthPermission, KvValue } from "@/lib/backend/api";
import type { ConnectionConfig, TreeNode } from "@/types/database";
const utf8 = (data: string): KvValue => ({ encoding: "utf8", data });
const readPermission: EtcdAuthPermission = { access: "read", key: utf8(""), rangeEnd: utf8("\0"), resource: "all" };
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
describe("connectionStore etcd access", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
async function loadEtcdTree(roles: string[], permissions: EtcdAuthPermission[] = [readPermission], options: { connection?: Partial<ConnectionConfig>; authEnabled?: boolean; authUser?: string; loadRoot?: boolean } = {}) {
const connection = {
id: "etcd-reader",
name: "restricted etcd",
db_type: "etcd",
host: "127.0.0.1",
port: 2379,
username: "reader",
password: "secret",
database: "",
...options.connection,
} as ConnectionConfig;
const etcdAuthCall = vi.fn((_connectionId: string, operation: string, args: { user?: string; role?: string }) => {
if (operation === "user_get") return Promise.resolve({ user: args.user || options.authUser || "reader", roles, authEnabled: options.authEnabled ?? true });
if (operation === "role_get") return Promise.resolve({ role: args.role, permissions });
throw new Error(`Unexpected etcd auth operation: ${operation}`);
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
etcdAuthCall,
listInstalledAgents: vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const root: TreeNode = {
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: false,
children: [],
};
store.connections = [connection];
store.treeNodes = [root];
store.connectedIds.add(connection.id);
if (options.loadRoot !== false) await store.loadEtcdRoot(connection.id);
return { root, etcdAuthCall, store };
}
it("hides management views for a regular etcd user", async () => {
const { root, etcdAuthCall, store } = await loadEtcdTree(["reader-role"]);
expect(etcdAuthCall).toHaveBeenCalledWith("etcd-reader", "user_get", {});
expect(etcdAuthCall).toHaveBeenCalledWith("etcd-reader", "role_get", { role: "reader-role" });
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root"]);
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: false, writePermissions: [] });
});
it("shows management views only for the root role", async () => {
const { root } = await loadEtcdTree(["reader-role", "root"]);
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root", "etcd-access-control", "etcd-dashboard"]);
});
it("keeps Key mutations available to a non-admin role with write access", async () => {
const writePermission: EtcdAuthPermission = { access: "readwrite", key: utf8("/editable/"), rangeEnd: utf8("/editable0"), resource: "prefix" };
const { root, store } = await loadEtcdTree(["key-writer"], [writePermission]);
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root"]);
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: true, writePermissions: [writePermission] });
expect(store.canWriteEtcdKey("etcd-reader", "/editable/key")).toBe(true);
expect(store.canWriteEtcdKey("etcd-reader", "/readonly/key")).toBe(false);
});
it("loads capabilities without requiring the sidebar connection to be expanded", async () => {
const writePermission: EtcdAuthPermission = { access: "readwrite", key: utf8("/editable/"), rangeEnd: utf8("/editable0"), resource: "prefix" };
const { root, store } = await loadEtcdTree(["key-writer"], [writePermission], { loadRoot: false });
expect(root.isExpanded).toBe(false);
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: false, writePermissions: [] });
await store.ensureEtcdAccessCapabilities("etcd-reader", { force: true, verifyHealth: false });
expect(root.isExpanded).toBe(false);
expect(store.canWriteEtcdKey("etcd-reader", "/editable/key")).toBe(true);
});
it("preserves confirmed write permissions when a forced refresh fails transiently", async () => {
const writePermission: EtcdAuthPermission = { access: "readwrite", key: utf8("/editable/"), rangeEnd: utf8("/editable0"), resource: "prefix" };
const { etcdAuthCall, store } = await loadEtcdTree(["key-writer"], [writePermission]);
etcdAuthCall.mockRejectedValueOnce(new Error("temporary auth timeout"));
await store.ensureEtcdAccessCapabilities("etcd-reader", { force: true, verifyHealth: false });
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: true, writePermissions: [writePermission] });
expect(store.canWriteEtcdKey("etcd-reader", "/editable/key")).toBe(true);
});
it("preserves a confirmed root role when a forced refresh fails transiently", async () => {
const { etcdAuthCall, store } = await loadEtcdTree(["root"]);
etcdAuthCall.mockRejectedValueOnce(new Error("temporary auth timeout"));
await store.ensureEtcdAccessCapabilities("etcd-reader", { force: true, verifyHealth: false });
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: true, writable: true, writePermissions: null });
});
it("fails closed when initial capability discovery fails", async () => {
const { etcdAuthCall, store } = await loadEtcdTree(["key-writer"], [readPermission], { loadRoot: false });
etcdAuthCall.mockRejectedValueOnce(new Error("temporary auth timeout"));
await store.ensureEtcdAccessCapabilities("etcd-reader", { force: true, verifyHealth: false });
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: false, writePermissions: [] });
});
it("does not let a read grant inherit write access from another range", async () => {
const permissions: EtcdAuthPermission[] = [
{ access: "read", key: utf8("/readonly/"), rangeEnd: utf8("/readonly0"), resource: "prefix" },
{ access: "readwrite", key: utf8("/editable/"), rangeEnd: utf8("/editable0"), resource: "prefix" },
];
const { store } = await loadEtcdTree(["mixed-role"], permissions);
expect(store.canWriteEtcdKey("etcd-reader", "/readonly/key")).toBe(false);
expect(store.canWriteEtcdKey("etcd-reader", "/editable/key")).toBe(true);
});
it("uses the agent's certificate identity for a restricted TLS user", async () => {
const { root, etcdAuthCall, store } = await loadEtcdTree(["cert-reader-role"], [readPermission], {
connection: { username: "", password: "", client_cert_path: "/tmp/cert.pem", client_key_path: "/tmp/key.pem" },
authUser: "cert-reader",
});
expect(etcdAuthCall).toHaveBeenCalledWith("etcd-reader", "user_get", {});
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root"]);
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: false, writePermissions: [] });
});
it("keeps certificate connections unrestricted when server auth is disabled", async () => {
const { root } = await loadEtcdTree([], [readPermission], {
connection: { username: "", password: "", client_cert_path: "/tmp/cert.pem", client_key_path: "/tmp/key.pem" },
authEnabled: false,
authUser: "transport-only-cert",
});
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root", "etcd-access-control", "etcd-dashboard"]);
});
it("keeps anonymous connections unrestricted when server auth is disabled", async () => {
const { root, etcdAuthCall, store } = await loadEtcdTree([], [readPermission], {
connection: { username: "", password: "", client_cert_path: "", client_key_path: "" },
authEnabled: false,
authUser: "",
});
expect(etcdAuthCall).toHaveBeenCalledWith("etcd-reader", "user_get", {});
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root", "etcd-access-control", "etcd-dashboard"]);
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: true, writable: true, writePermissions: null });
});
it("queries the configured user when resolving etcd v2 capabilities", async () => {
const writePermission: EtcdAuthPermission = { access: "write", key: utf8("/editable/"), rangeEnd: utf8("/editable0"), resource: "prefix" };
const { root, etcdAuthCall, store } = await loadEtcdTree(["v2-writer"], [writePermission], {
connection: { driver_profile: "etcd-v2" },
});
expect(etcdAuthCall).toHaveBeenCalledWith("etcd-reader", "user_get", { user: "reader" });
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root"]);
expect(store.canWriteEtcdKey("etcd-reader", "/editable/key")).toBe(true);
expect(store.canWriteEtcdKey("etcd-reader", "/readonly/key")).toBe(false);
});
it("keeps anonymous etcd v2 Key operations available without probing an empty user", async () => {
const { root, etcdAuthCall, store } = await loadEtcdTree([], [readPermission], {
connection: { driver_profile: "etcd-v2", username: "", password: "" },
});
expect(etcdAuthCall).not.toHaveBeenCalled();
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root"]);
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: true, writePermissions: null });
});
it("preserves etcd v2 Key operations when auth capability discovery is unavailable", async () => {
const { etcdAuthCall, store } = await loadEtcdTree(["v2-writer"], [readPermission], {
connection: { driver_profile: "etcd-v2" },
loadRoot: false,
});
etcdAuthCall.mockRejectedValueOnce(new Error("v2 auth endpoint unavailable"));
await store.ensureEtcdAccessCapabilities("etcd-reader", { force: true, verifyHealth: false });
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: true, writePermissions: null });
});
});
+153 -35
View File
@@ -140,6 +140,7 @@ import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
import { mongoCollectionKindFromNode, toMongoCollectionKind, visibleMongoCollections } from "@/lib/sidebar/mongoCollectionMutation";
import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metadata/completionTreeIndex";
import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation";
import { etcdPermissionsAllowKey } from "@/lib/etcd/keyPermissions";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias";
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
@@ -195,6 +196,19 @@ const XUGU_TABLE_CHILD_METADATA_AGENT_VERSION = "0.1.23";
const SUPERSEDED_CONNECTION_ATTEMPT_MESSAGE = "Connection attempt was superseded by a newer attempt";
const SHARDINGSPHERE_PROXY_VERSION_MARKER = "shardingsphere-proxy";
export interface EtcdAccessCapabilities {
/** May access cluster-wide administration APIs such as status and leases. */
admin: boolean;
/** Has at least one etcd role that permits writes to a Key range. */
writable: boolean;
/** Null means unrestricted; otherwise writes are limited to these etcd ranges. */
writePermissions: api.EtcdAuthPermission[] | null;
}
const unrestrictedEtcdAccess: EtcdAccessCapabilities = { admin: true, writable: true, writePermissions: null };
const restrictedEtcdAccess: EtcdAccessCapabilities = { admin: false, writable: false, writePermissions: [] };
const fallbackEtcdV2Access: EtcdAccessCapabilities = { admin: false, writable: true, writePermissions: null };
function usesShardingSphereLogicalTables(databaseInfo: DatabaseConnectionInfo | undefined): boolean {
return databaseInfo?.productVersion?.toLowerCase().includes(SHARDINGSPHERE_PROXY_VERSION_MARKER) === true;
}
@@ -441,6 +455,9 @@ export const useConnectionStore = defineStore("connection", () => {
const activePinnedTreeNodeReorderKey = ref<string | null>(null);
let pinnedTreeNodePersistQueue: Promise<void> = Promise.resolve();
const connectedIds = ref<Set<string>>(new Set());
const etcdAccessCapabilities = ref<Record<string, EtcdAccessCapabilities>>({});
const etcdAccessCapabilityGenerations = new Map<string, number>();
const etcdAccessCapabilityLoads = new Map<string, Promise<EtcdAccessCapabilities>>();
const identifierQuotes = ref<Record<string, string>>({});
const lastConnectionHealthCheckAt = ref<Record<string, number>>({});
const agentDrivers = ref<AgentDriverInstallState[]>([]);
@@ -657,6 +674,99 @@ export const useConnectionStore = defineStore("connection", () => {
return configById.value.get(connectionId);
}
function getEtcdAccessCapabilities(connectionId: string): EtcdAccessCapabilities {
const config = getConfig(connectionId);
if (config?.db_type !== "etcd") return unrestrictedEtcdAccess;
const username = config.username?.trim() || "";
// Only an explicitly configured root user can be trusted before probing
// the server. Anonymous and certificate connections may still target an
// auth-enabled cluster, so keep privileged controls hidden until the
// agent reports the effective identity and authentication status.
if (username === "root") return unrestrictedEtcdAccess;
return etcdAccessCapabilities.value[connectionId] ?? (config.driver_profile === "etcd-v2" ? fallbackEtcdV2Access : restrictedEtcdAccess);
}
function canWriteEtcdKey(connectionId: string, key: string, keyBytes?: api.KvValue | null): boolean {
const access = getEtcdAccessCapabilities(connectionId);
if (!access.writable) return false;
if (access.writePermissions === null) return true;
return etcdPermissionsAllowKey(access.writePermissions, keyBytes ?? { encoding: "utf8", data: key });
}
async function resolveEtcdAccessCapabilities(connectionId: string, config: ConnectionConfig | undefined): Promise<EtcdAccessCapabilities> {
const username = config?.username?.trim() || "";
if (username === "root") return unrestrictedEtcdAccess;
// The v2 auth API cannot resolve the identity behind the active HTTP
// connection. Query an explicitly configured user, while anonymous v2
// connections retain their historical Key access and rely on the server
// as the authorization boundary.
if (config?.driver_profile === "etcd-v2" && !username) return fallbackEtcdV2Access;
// An empty user asks the agent for the authenticated identity. This also
// covers client-certificate CN authentication without duplicating X.509
// parsing in the UI.
const userParams = config?.driver_profile === "etcd-v2" ? { user: username } : {};
const user = await api.etcdAuthCall<api.EtcdAuthUserDetail>(connectionId, "user_get", userParams);
if (user.authEnabled === false) return unrestrictedEtcdAccess;
if (user.roles.includes("root")) return unrestrictedEtcdAccess;
const roles = await Promise.all(user.roles.map((role) => api.etcdAuthCall<api.EtcdAuthRoleDetail>(connectionId, "role_get", { role })));
const writePermissions = roles.flatMap((role) => role.permissions.filter((permission) => permission.access === "write" || permission.access === "readwrite"));
return {
admin: false,
writable: writePermissions.length > 0,
writePermissions,
};
}
async function ensureEtcdAccessCapabilities(connectionId: string, options: { force?: boolean; verifyHealth?: boolean } = {}): Promise<EtcdAccessCapabilities> {
const config = getConfig(connectionId);
if (config?.db_type !== "etcd") return unrestrictedEtcdAccess;
const cached = etcdAccessCapabilities.value[connectionId];
if (cached && !options.force) return cached;
const existing = etcdAccessCapabilityLoads.get(connectionId);
if (existing) return existing;
const load = (async () => {
await ensureConnected(connectionId, { verifyHealth: options.verifyHealth ?? false });
const currentConfig = getConfig(connectionId);
if (currentConfig?.db_type !== "etcd") return unrestrictedEtcdAccess;
const generation = etcdAccessCapabilityGenerations.get(connectionId) ?? 0;
const configFingerprint = connectionConfigFingerprint(currentConfig);
let access: EtcdAccessCapabilities;
try {
access = await resolveEtcdAccessCapabilities(connectionId, currentConfig);
} catch {
// Initial v3 discovery fails closed. Once a capability snapshot has
// been confirmed, however, a transient Auth RPC failure must not
// overwrite it and make controls flicker between states.
// The v2 API cannot discover an implicit current user, so preserve its
// pre-capability behavior on discovery errors and let etcd authorize
// each Key mutation. Administrative views remain hidden.
access = etcdAccessCapabilities.value[connectionId] ?? (currentConfig.driver_profile === "etcd-v2" ? fallbackEtcdV2Access : restrictedEtcdAccess);
}
if (generation === (etcdAccessCapabilityGenerations.get(connectionId) ?? 0) && connectedIds.value.has(connectionId) && connectionConfigFingerprint(getConfig(connectionId) ?? currentConfig) === configFingerprint) {
etcdAccessCapabilities.value = { ...etcdAccessCapabilities.value, [connectionId]: access };
return access;
}
return getEtcdAccessCapabilities(connectionId);
})();
etcdAccessCapabilityLoads.set(connectionId, load);
try {
return await load;
} finally {
if (etcdAccessCapabilityLoads.get(connectionId) === load) etcdAccessCapabilityLoads.delete(connectionId);
}
}
function clearEtcdAccessCapabilities(connectionId: string) {
etcdAccessCapabilityGenerations.set(connectionId, (etcdAccessCapabilityGenerations.get(connectionId) ?? 0) + 1);
etcdAccessCapabilityLoads.delete(connectionId);
if (connectionId in etcdAccessCapabilities.value) {
const next = { ...etcdAccessCapabilities.value };
delete next[connectionId];
etcdAccessCapabilities.value = next;
}
}
function connectionIdentifierQuote(connectionId?: string): string | undefined {
if (!connectionId) return undefined;
const override = gaussdbIdentifierQuoteOverride(getConfig(connectionId));
@@ -1099,6 +1209,7 @@ export const useConnectionStore = defineStore("connection", () => {
function markConnectionLost(connectionId: string, error: unknown) {
connectedIds.value.delete(connectionId);
clearEtcdAccessCapabilities(connectionId);
clearSidebarStorageCaches(connectionId);
clearPrimaryVisibleObjectNames(connectionId);
clearConnectionIdentifierQuote(connectionId);
@@ -3248,6 +3359,7 @@ export const useConnectionStore = defineStore("connection", () => {
for (const id of removedIds) {
clearConnectionError(id);
connectionErrorRevisions.delete(id);
clearEtcdAccessCapabilities(id);
connectedIds.value.delete(id);
clearSidebarStorageCaches(id);
clearPrimaryVisibleObjectNames(id);
@@ -3288,6 +3400,7 @@ export const useConnectionStore = defineStore("connection", () => {
syncTimeoutInheritanceBackup();
rebuildTreeNodes();
if (!runtimeConfigChanged) return;
clearEtcdAccessCapabilities(config.id);
clearPrimaryVisibleObjectNames(config.id);
connectedIds.value.delete(config.id);
clearSidebarStorageCaches(config.id);
@@ -3819,6 +3932,7 @@ export const useConnectionStore = defineStore("connection", () => {
if (!cancelled) return false;
clearConnectionError(connectionId);
connectedIds.value.delete(connectionId);
clearEtcdAccessCapabilities(connectionId);
clearSidebarStorageCaches(connectionId);
clearPrimaryVisibleObjectNames(connectionId);
clearConnectionIdentifierQuote(connectionId);
@@ -3839,6 +3953,7 @@ export const useConnectionStore = defineStore("connection", () => {
cancelLocalConnectionAttempt(connectionId);
connectedIds.value.delete(connectionId);
clearEtcdAccessCapabilities(connectionId);
clearSidebarStorageCaches(connectionId);
clearPrimaryVisibleObjectNames(connectionId);
clearConnectionIdentifierQuote(connectionId);
@@ -3963,6 +4078,7 @@ export const useConnectionStore = defineStore("connection", () => {
cancelObjectMetadataLoadsForConnection(connectionId);
clearMetadataRuntimeCacheForConnection(connectionId);
connectedIds.value.delete(connectionId);
clearEtcdAccessCapabilities(connectionId);
clearPrimaryVisibleObjectNames(connectionId);
clearConnectionHealthCheck(connectionId);
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
@@ -4498,42 +4614,41 @@ export const useConnectionStore = defineStore("connection", () => {
load = reclaimTreeNodeLoad(load, node);
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
setChildren(
targetNode,
withSavedSqlRoot(
const etcdAccess = await ensureEtcdAccessCapabilities(connectionId, { force: true, verifyHealth: false });
const children: TreeNode[] = [
{
id: `${connectionId}:etcd`,
label: kvRootNodeLabel("etcd"),
type: "etcd-root" as const,
connectionId,
[
{
id: `${connectionId}:etcd`,
label: kvRootNodeLabel("etcd"),
type: "etcd-root" as const,
connectionId,
database: "",
isExpanded: false,
children: [],
},
{
id: `${connectionId}:etcd-access-control`,
label: "用户和角色",
type: "etcd-access-control" as const,
connectionId,
database: "",
isExpanded: false,
children: [],
},
{
id: `${connectionId}:etcd-dashboard`,
label: "服务仪表盘",
type: "etcd-dashboard" as const,
connectionId,
database: "",
isExpanded: false,
children: [],
},
],
targetNode,
),
);
database: "",
isExpanded: false,
children: [],
},
];
if (etcdAccess.admin) {
children.push(
{
id: `${connectionId}:etcd-access-control`,
label: "用户和角色",
type: "etcd-access-control" as const,
connectionId,
database: "",
isExpanded: false,
children: [],
},
{
id: `${connectionId}:etcd-dashboard`,
label: "服务仪表盘",
type: "etcd-dashboard" as const,
connectionId,
database: "",
isExpanded: false,
children: [],
},
);
}
setChildren(targetNode, withSavedSqlRoot(connectionId, children, targetNode));
targetNode.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e, load);
@@ -8894,6 +9009,9 @@ export const useConnectionStore = defineStore("connection", () => {
connectionGroupOptions,
selectedConnectionGroupId,
getConfig,
getEtcdAccessCapabilities,
ensureEtcdAccessCapabilities,
canWriteEtcdKey,
connectionIdentifierQuote,
isTreeNodePinned,
orderByPinnedTreeNodes,