fix(etcd): preserve raw key identity in browser actions

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-27 13:18:53 +08:00
1 parent 122853d089
commit 39d0f5b30f
69 files changed
+6577 -742

No files matched your search

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { classifyEtcdDashboardError, sanitizeEtcdDashboardError } from "@/lib/kv/etcdDashboardError";
describe("etcd dashboard errors", () => {
it("recognizes legacy agents that do not implement kv_status", () => {
expect(classifyEtcdDashboardError(new Error("Agent RPC error (-1): Unknown method: kv_status")).kind).toBe("unsupported");
expect(classifyEtcdDashboardError("Agent RPC error (-32601): Method not found: kv_status").kind).toBe("unsupported");
expect(classifyEtcdDashboardError("ETCD_STATUS_UNSUPPORTED: update the driver").kind).toBe("unsupported");
});
it("removes unrelated agent stderr from the user-facing message", () => {
const error = "Agent RPC error (-1): connection refused. recent stderr: SLF4J(W): No SLF4J providers were found. io.netty.resolver.dns.DnsServerAddressStreamProviders warning";
expect(sanitizeEtcdDashboardError(error)).toBe("Agent RPC error (-1): connection refused");
});
it("keeps ordinary request failures as request errors", () => {
const result = classifyEtcdDashboardError(new Error("Agent RPC error (-1): etcdserver: request timed out"));
expect(result).toEqual({
kind: "request",
message: "Agent RPC error (-1): etcdserver: request timed out",
});
});
});
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import type { KvPrometheusMetrics } from "@/lib/backend/tauri";
import { counterMapRates, counterRate, histogramAverageMilliseconds, histogramMapAverageMilliseconds, ratePercentage } from "@/lib/kv/etcdDashboardMetrics";
function snapshot(values: Partial<KvPrometheusMetrics>): KvPrometheusMetrics {
return {
available: true,
sourceUrl: "http://localhost:2380/metrics",
grpcMethodRequestsTotal: {},
grpcMethodFailuresTotal: {},
requestDurationSecondsSumByType: {},
requestDurationSecondsCountByType: {},
...values,
};
}
describe("etcd dashboard metrics", () => {
it("converts cumulative counters to rates using adjacent snapshots", () => {
const previous = snapshot({ collectedAtMs: 10_000, grpcRequestsTotal: 100 });
const current = snapshot({ collectedAtMs: 20_000, grpcRequestsTotal: 160 });
expect(counterRate(current, previous, "grpcRequestsTotal")).toBe(6);
});
it("does not report a misleading rate after a counter reset", () => {
const previous = snapshot({ collectedAtMs: 10_000, grpcRequestsTotal: 100 });
const current = snapshot({ collectedAtMs: 20_000, grpcRequestsTotal: 5 });
expect(counterRate(current, previous, "grpcRequestsTotal")).toBeNull();
});
it("uses delta histogram observations after the first snapshot", () => {
const previous = snapshot({
collectedAtMs: 10_000,
walFsyncDurationSecondsSum: 1,
walFsyncDurationSecondsCount: 20,
});
const current = snapshot({
collectedAtMs: 20_000,
walFsyncDurationSecondsSum: 1.2,
walFsyncDurationSecondsCount: 30,
});
expect(histogramAverageMilliseconds(current, previous, "walFsyncDurationSecondsSum", "walFsyncDurationSecondsCount")).toBeCloseTo(20);
});
it("computes a bounded failure percentage", () => {
expect(ratePercentage(2, 20)).toBe(10);
expect(ratePercentage(2, 0)).toBeNull();
expect(ratePercentage(25, 20)).toBe(100);
});
it("calculates rates for labeled counters", () => {
const previous = snapshot({ collectedAtMs: 10_000, grpcMethodRequestsTotal: { Range: 100, Put: 4 } });
const current = snapshot({ collectedAtMs: 20_000, grpcMethodRequestsTotal: { Range: 160, Put: 14 } });
expect(counterMapRates(current, previous, "grpcMethodRequestsTotal")).toEqual({ Range: 6, Put: 1 });
});
it("calculates per-operation latency from labeled histogram deltas", () => {
const previous = snapshot({
collectedAtMs: 10_000,
requestDurationSecondsSumByType: { Range: 1 },
requestDurationSecondsCountByType: { Range: 20 },
});
const current = snapshot({
collectedAtMs: 20_000,
requestDurationSecondsSumByType: { Range: 1.2 },
requestDurationSecondsCountByType: { Range: 30 },
});
expect(histogramMapAverageMilliseconds(current, previous, "requestDurationSecondsSumByType", "requestDurationSecondsCountByType").Range).toBeCloseTo(20);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { isKeyInKvExportScope, kvDirectoryPrefix, kvExportFilenameStem, kvValueByteIdentity } from "@/lib/kv/kvExportScope";
describe("KV export scope", () => {
it("matches a directory itself and descendants without leaking into adjacent prefixes", () => {
const scope = { path: "/dbx", kind: "prefix" as const };
expect(isKeyInKvExportScope("/dbx", scope)).toBe(true);
expect(isKeyInKvExportScope("/dbx/app/name", scope)).toBe(true);
expect(isKeyInKvExportScope("/dbx-other", scope)).toBe(false);
});
it("matches only the selected key for a key export", () => {
const scope = { path: "dbx/key", kind: "key" as const };
expect(isKeyInKvExportScope("dbx/key", scope)).toBe(true);
expect(isKeyInKvExportScope("dbx/key/child", scope)).toBe(false);
});
it("builds stable directory prefixes and safe filenames", () => {
expect(kvDirectoryPrefix("/dbx")).toBe("/dbx/");
expect(kvDirectoryPrefix("dbx/")).toBe("dbx/");
expect(kvExportFilenameStem("/应用 配置/prod")).toBe("应用-配置-prod");
expect(kvExportFilenameStem("/")).toBe("root");
});
it("compares UTF-8 and Base64 representations by their original bytes", () => {
expect(kvValueByteIdentity({ encoding: "utf8", data: "/dbx/app" })).toBe(kvValueByteIdentity({ encoding: "base64", data: "L2RieC9hcHA=" }));
expect(kvValueByteIdentity({ encoding: "utf8", data: "/dbx/app" })).not.toBe(kvValueByteIdentity({ encoding: "utf8", data: "/dbx-other" }));
});
});
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { filterKvKeysBySearch, kvKeyMatchesSearch } from "@/lib/kv/kvKeySearch";
describe("KV Key path search", () => {
it("matches the query anywhere in the complete Key path", () => {
const keys = ["/a/b/c", "/dbx/a", "/test/a", "/test/c", "a", "aaa", "bb"].map((key) => ({ key }));
expect(filterKvKeysBySearch(keys, "a").map((key) => key.key)).toEqual(["/a/b/c", "/dbx/a", "/test/a", "a", "aaa"]);
});
it("matches case-insensitively and ignores surrounding whitespace", () => {
expect(kvKeyMatchesSearch("/Apps/Production/Name", " production ")).toBe(true);
expect(kvKeyMatchesSearch("/Apps/Production/Name", "staging")).toBe(false);
});
});
+37 -1
View File
@@ -1,7 +1,23 @@
import { describe, expect, it } from "vitest";
import { buildKvKeyTree, collectKvGroupIds, flattenVisibleKvKeyTree, preserveKvExpandedGroupIds } from "@/lib/kv/kvKeyTree";
import { buildKvKeyTree, collectKvGroupIds, flattenVisibleKvKeyTree, kvKeyTreeNodePath, preserveKvExpandedGroupIds } from "@/lib/kv/kvKeyTree";
describe("kv key tree", () => {
it("represents a key that is also a prefix as one expandable value node", () => {
const tree = buildKvKeyTree([
{ key: "/app", modRevision: "2" },
{ key: "/app/name", modRevision: "3" },
]);
expect(tree).toHaveLength(1);
expect(tree[0]).toMatchObject({
kind: "group",
label: "app",
key: "/app",
modRevision: "2",
children: [{ kind: "leaf", key: "/app/name" }],
});
});
it("keeps root keys as leaf nodes", () => {
const tree = buildKvKeyTree([{ key: "/plain", version: 2 }, { key: "/" }]);
@@ -9,6 +25,21 @@ describe("kv key tree", () => {
expect(tree[1]).toMatchObject({ kind: "leaf", key: "/plain", version: 2 });
});
it("keeps colliding display keys as distinct raw-byte leaves", () => {
const binaryBytes = { encoding: "base64" as const, data: "/w==" };
const utf8Bytes = { encoding: "utf8" as const, data: "[base64:/w==]" };
const tree = buildKvKeyTree([
{ key: "[base64:/w==]", keyIdentity: "ff", keyBytes: binaryBytes },
{ key: "[base64:/w==]", keyIdentity: "5b6261736536343a2f773d3d5d", keyBytes: utf8Bytes },
]);
expect(tree).toHaveLength(1);
expect(tree[0]).toMatchObject({ kind: "group", id: "group:[base64:", label: "[base64:" });
if (tree[0].kind !== "group") throw new Error("expected colliding display keys under their shared virtual directory");
expect(tree[0].children.map((node) => node.id)).toEqual(["leaf:ff", "leaf:5b6261736536343a2f773d3d5d"]);
expect(tree[0].children).toEqual([expect.objectContaining({ keyIdentity: "ff", keyBytes: binaryBytes }), expect.objectContaining({ keyIdentity: "5b6261736536343a2f773d3d5d", keyBytes: utf8Bytes })]);
});
it("groups slash-delimited keys and sorts groups before leaves", () => {
const tree = buildKvKeyTree([
{ key: "/app/config/name", modRevision: 3 },
@@ -45,4 +76,9 @@ describe("kv key tree", () => {
expect([...next]).toEqual(["group:app"]);
expect([...preserveKvExpandedGroupIds(tree, new Set(), true)].sort()).toEqual(["group:app", "group:app\u0000config", "group:service"]);
});
it("preserves whether a virtual directory has a leading slash", () => {
expect(kvKeyTreeNodePath(buildKvKeyTree([{ key: "/app/config/name" }])[0])).toBe("/app");
expect(kvKeyTreeNodePath(buildKvKeyTree([{ key: "app/config/name" }])[0])).toBe("app");
});
});
+30 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { KvGetResponse } from "@/lib/backend/api";
import { decideKvMetadataRefresh, knownKvLeaseKeys, KvListRequestGuard, mergeKvKeyMetadata, mergeKvValueRefresh, nextKvLeaseRefreshDelay, removeMissingKvKey, updateKvResponseTtl } from "@/lib/kv/kvMetadataRefresh";
import { decideKvMetadataRefresh, hasPositiveKvLease, knownKvLeaseSummaries, KvListRequestGuard, mergeKvKeyMetadata, mergeKvValueRefresh, nextKvLeaseRefreshDelay, removeMissingKvKey, updateKvResponseTtl } from "@/lib/kv/kvMetadataRefresh";
function found(metadata: NonNullable<KvGetResponse["metadata"]>): KvGetResponse {
return {
@@ -83,10 +83,26 @@ describe("decideKvMetadataRefresh", () => {
});
it("tracks only known leased keys and excludes the selected key", () => {
const keys = [{ key: "/no-lease", lease: 0 }, { key: "/leased", lease: 10 }, { key: "/selected", lease: 20 }, { key: "/unknown" }];
const keys = [{ key: "/no-lease", lease: 0 }, { key: "/leased", lease: "10" }, { key: "/selected", lease: 20 }, { key: "/unknown" }];
expect(knownKvLeaseKeys(keys, "/selected")).toEqual(["/leased"]);
expect(knownKvLeaseKeys(keys, null)).toEqual(["/leased", "/selected"]);
expect(knownKvLeaseSummaries(keys, "/selected")).toEqual([keys[1]]);
expect(knownKvLeaseSummaries(keys, null)).toEqual([keys[1], keys[2]]);
});
it("excludes only the selected raw-byte identity from lease refresh", () => {
const keys = [
{ key: "[base64:/w==]", keyIdentity: "ff", lease: 10 },
{ key: "[base64:/w==]", keyIdentity: "5b6261736536343a2f773d3d5d", lease: 20 },
];
expect(knownKvLeaseSummaries(keys, "ff")).toEqual([keys[1]]);
});
it("recognizes positive lease IDs in the string-based int64 transport", () => {
expect(hasPositiveKvLease("9007199254740993")).toBe(true);
expect(hasPositiveKvLease(10)).toBe(true);
expect(hasPositiveKvLease("0")).toBe(false);
expect(hasPositiveKvLease("")).toBe(false);
});
it("merges refreshed metadata without replacing the known value size and removes expired keys", () => {
@@ -102,6 +118,16 @@ describe("decideKvMetadataRefresh", () => {
expect(mergeKvKeyMetadata(keys, "/expired", { found: false })).toEqual([{ key: "/leased", lease: 10, ttl: 4, valueSize: 12 }]);
});
it("refreshes and removes only the matching raw-byte identity when display keys collide", () => {
const keys = [
{ key: "[base64:/w==]", keyIdentity: "ff", lease: 10, ttl: 4, valueSize: 12 },
{ key: "[base64:/w==]", keyIdentity: "5b6261736536343a2f773d3d5d", lease: 20, ttl: 8, valueSize: 24 },
];
expect(mergeKvKeyMetadata(keys, "[base64:/w==]", found({ lease: 11, ttl: 3, modRevision: 5 }), "ff")).toEqual([{ key: "[base64:/w==]", keyIdentity: "ff", lease: 11, ttl: 3, modRevision: 5, valueSize: 12 }, keys[1]]);
expect(removeMissingKvKey(keys, "[base64:/w==]", "ff")).toEqual([keys[1]]);
});
it("backs off after failures and resets after a successful cycle", () => {
expect(nextKvLeaseRefreshDelay(2000, true)).toBe(4000);
expect(nextKvLeaseRefreshDelay(4000, true)).toBe(8000);
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { classifyKvMutationError, sanitizeKvMutationError } from "@/lib/kv/kvMutationError";
describe("KV mutation errors", () => {
const noisyConflict = "Agent RPC error (-1): ETCD_CAS_CONFLICT: key changed after it was loaded. recent stderr: SLF4J(W): No SLF4J providers were found. io.netty.resolver.dns.macos.MacOSDnsServerAddressStreamProvider warning";
it("turns a duplicate create conflict into an actionable message", () => {
expect(
classifyKvMutationError(noisyConflict, true, {
keyAlreadyExists: "同名 Key 已存在,请更换名称,或编辑现有 Key。",
}),
).toEqual({
kind: "keyAlreadyExists",
message: "同名 Key 已存在,请更换名称,或编辑现有 Key。",
});
});
it("keeps edit conflicts distinct from duplicate creates", () => {
expect(classifyKvMutationError(noisyConflict, false, { conflict: "Key 已被修改,请刷新后重试。" })).toEqual({
kind: "conflict",
message: "Key 已被修改,请刷新后重试。",
});
});
it("removes RPC wrappers and unrelated Agent stderr from request errors", () => {
expect(sanitizeKvMutationError("Agent RPC error (-1): etcdserver: request timed out. recent stderr: noisy")).toBe("etcdserver: request timed out");
});
});
@@ -1,15 +1,22 @@
import { describe, expect, it } from "vitest";
import { refreshedKvSelectionKey } from "@/lib/kv/kvRefreshSelection";
import { refreshedKvSelectionSummary } from "@/lib/kv/kvRefreshSelection";
import type { KvKeySummary } from "@/lib/backend/api";
describe("kv refresh selection", () => {
const keys: KvKeySummary[] = [{ key: "/app/config" }, { key: "/app/feature" }];
it("keeps the previously selected key when it still exists after refresh", () => {
expect(refreshedKvSelectionKey("/app/config", keys)).toBe("/app/config");
expect(refreshedKvSelectionSummary("/app/config", keys)).toEqual({ key: "/app/config" });
});
it("clears the selected key when it no longer exists after refresh", () => {
expect(refreshedKvSelectionKey("/app/missing", keys)).toBeNull();
expect(refreshedKvSelectionSummary("/app/missing", keys)).toBeNull();
});
it("does not switch to a colliding display key when the selected byte identity disappears", () => {
const collision: KvKeySummary[] = [{ key: "[base64:/w==]", keyIdentity: "5b6261736536343a2f773d3d5d" }];
expect(refreshedKvSelectionSummary("ff", collision)).toBeNull();
expect(refreshedKvSelectionSummary("5b6261736536343a2f773d3d5d", collision)).toEqual(collision[0]);
});
});
+16 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parseOptionalTtl } from "@/lib/kv/kvTtl";
import { parseKvLeaseId, parseOptionalTtl } from "@/lib/kv/kvTtl";
describe("parseOptionalTtl", () => {
it("treats an empty TTL as a permanent key", () => {
@@ -23,3 +23,18 @@ describe("parseOptionalTtl", () => {
expect(parseOptionalTtl("9007199254740992")).toEqual({ ok: false });
});
});
describe("parseKvLeaseId", () => {
it("preserves lease IDs above JavaScript's safe integer limit", () => {
expect(parseKvLeaseId("9007199254740993")).toBe("9007199254740993");
expect(parseKvLeaseId("9223372036854775807")).toBe("9223372036854775807");
});
it("rejects empty, non-positive, decimal, and overflowing lease IDs", () => {
expect(parseKvLeaseId("")).toBeNull();
expect(parseKvLeaseId("0")).toBeNull();
expect(parseKvLeaseId("-1")).toBeNull();
expect(parseKvLeaseId("1.5")).toBeNull();
expect(parseKvLeaseId("9223372036854775808")).toBeNull();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { detectKvValueFormat, formatKvValue, validateKvValue } from "@/lib/kv/kvValueFormat";
describe("kv value formats", () => {
it("detects structured UTF-8 values", () => {
expect(detectKvValueFormat('{"enabled":true}')).toBe("json");
expect(detectKvValueFormat("service:\n port: 8080")).toBe("yaml");
expect(detectKvValueFormat("port: 8080\nenabled: true")).toBe("yaml");
expect(detectKvValueFormat("<root><value>1</value></root>")).toBe("xml");
expect(detectKvValueFormat("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app")).toBe("kubernetes");
expect(detectKvValueFormat("SELECT * FROM users;")).toBe("sql");
expect(detectKvValueFormat("server.port=8080\nfeature.enabled=true")).toBe("properties");
expect(detectKvValueFormat("#!/usr/bin/env bash\nset -euo pipefail")).toBe("shell");
expect(detectKvValueFormat("FROM alpine:3.22\nRUN echo ok")).toBe("dockerfile");
expect(detectKvValueFormat("server {\n listen 80;\n}")).toBe("nginx");
expect(detectKvValueFormat("hello")).toBe("text");
});
it("validates and formats JSON", () => {
expect(validateKvValue('{"ok":true}', "json")).toBeNull();
expect(validateKvValue("{", "json")).toBeTruthy();
expect(formatKvValue('{"ok":true}', "json")).toBe('{\n "ok": true\n}');
});
it("validates base64 without decoding it through UTF-8", () => {
expect(validateKvValue("AP+A", "base64")).toBeNull();
expect(validateKvValue("***", "base64")).toBeTruthy();
});
it("validates configuration-oriented formats", () => {
expect(validateKvValue("apiVersion: v1\nkind: ConfigMap", "kubernetes")).toBeNull();
expect(validateKvValue("kind: ConfigMap", "kubernetes")).toContain("apiVersion");
expect(validateKvValue("server.port=8080", "properties")).toBeNull();
expect(validateKvValue("server.port", "properties")).toContain("Properties");
expect(validateKvValue("FROM alpine\nRUN echo ok", "dockerfile")).toBeNull();
expect(validateKvValue("UNKNOWN value", "dockerfile")).toContain("Dockerfile");
expect(validateKvValue("server {\n listen 80;\n}", "nginx")).toBeNull();
expect(validateKvValue("server {", "nginx")).toContain("unbalanced");
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { alignedSidebarCommentLabelWidths, sidebarTreeNaturalContentWidth, trailingCommentAvailableWidth, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
import { alignedSidebarCommentLabelWidths, canTreeNodeShowExpander, sidebarTreeNaturalContentWidth, trailingCommentAvailableWidth, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
describe("sidebar tree item layout", () => {
it("keeps a table row constrained when it displays a comment", () => {
@@ -11,6 +11,11 @@ describe("sidebar tree item layout", () => {
expect(treeLabelWidthClass({ fullWidth: false, hasTrailingComment: true })).toBe("min-w-0 flex-1 truncate");
});
it("renders etcd Keys and Dashboard as aligned leaf actions", () => {
expect(canTreeNodeShowExpander({ type: "etcd-root", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "etcd-dashboard", childCount: 0 })).toBe(false);
});
it("aligns comments to the longest sibling name without crossing parent groups", () => {
const widths = alignedSidebarCommentLabelWidths([
{ id: "tables", depth: 1, alignable: false, hasComment: false, labelWidth: 0 },
+12 -1
View File
@@ -412,6 +412,9 @@ export const etcdSupportsTtl = forward("etcdSupportsTtl");
export const etcdGet = forward("etcdGet");
export const etcdPut = forward("etcdPut");
export const etcdDelete = forward("etcdDelete");
export const etcdRename = forward("etcdRename");
export const etcdHistory = forward("etcdHistory");
export const etcdStatus = forward("etcdStatus");
// ZooKeeper
export const zookeeperListPrefix = forward("zookeeperListPrefix");
@@ -620,18 +623,26 @@ export type {
RedisSlowlogEntry,
RedisNodeEndpoint,
KvValueEncoding,
KvInt64,
KvValue,
KvKeyMetadata,
KvKeySummary,
KvListPrefixResponse,
KvListPrefixOptions,
KvGetResponse,
KvGetOptions,
KvGetResponse,
KvWriteMode,
KvCreateMode,
KvPutOptions,
KvPutResponse,
KvDeleteOptions,
KvDeleteResponse,
KvHistoryEventType,
KvHistoryEvent,
KvHistoryResponse,
KvStatusMember,
KvPrometheusMetrics,
KvStatusResponse,
DocumentQueryResult,
MongoDocumentResult,
HistoryEntry,
+24 -5
View File
@@ -72,6 +72,7 @@ import type {
RedisCommandResult,
RedisSlowlogEntry,
RedisNodeEndpoint,
KvInt64,
KvValue,
KvListPrefixResponse,
KvListPrefixOptions,
@@ -79,7 +80,10 @@ import type {
KvGetOptions,
KvPutOptions,
KvPutResponse,
KvDeleteOptions,
KvDeleteResponse,
KvHistoryResponse,
KvStatusResponse,
DocumentQueryResult,
MongoDocumentResult,
MongoCollectionStatsResult,
@@ -2054,8 +2058,8 @@ export async function redisClusterMasterNodes(connectionId: string): Promise<Red
// etcd
// ---------------------------------------------------------------------------
export async function etcdListPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null): Promise<KvListPrefixResponse> {
return post("/api/etcd/list-prefix", { connectionId, prefix, limit, continuation });
export async function etcdListPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null, options?: KvListPrefixOptions | null): Promise<KvListPrefixResponse> {
return post("/api/etcd/list-prefix", { connectionId, prefix, limit, continuation, revision: options?.revision ?? null, includeValues: options?.includeValues ?? null });
}
export async function etcdSupportsTtl(connectionId: string): Promise<boolean> {
@@ -2063,7 +2067,7 @@ export async function etcdSupportsTtl(connectionId: string): Promise<boolean> {
}
export async function etcdGet(connectionId: string, key: string, options?: KvGetOptions | null): Promise<KvGetResponse> {
return post("/api/etcd/get", { connectionId, key, metadataOnly: options?.metadataOnly ?? null });
return post("/api/etcd/get", { connectionId, key, keyBytes: options?.keyBytes ?? null, revision: options?.revision ?? null, metadataOnly: options?.metadataOnly ?? null });
}
export async function etcdPut(connectionId: string, key: string, value: KvValue, options?: KvPutOptions | number | null): Promise<KvPutResponse> {
@@ -2076,11 +2080,26 @@ export async function etcdPut(connectionId: string, key: string, value: KvValue,
lease: legacyLease ?? putOptions?.lease ?? null,
ttl: putOptions?.ttl ?? null,
preserveLease: putOptions?.preserveLease ?? null,
keyBytes: putOptions?.keyBytes ?? null,
expectedModRevision: putOptions?.expectedModRevision ?? null,
expectedCreateRevision: putOptions?.expectedCreateRevision ?? null,
});
}
export async function etcdDelete(connectionId: string, key: string): Promise<KvDeleteResponse> {
return post("/api/etcd/delete", { connectionId, key });
export async function etcdDelete(connectionId: string, key: string, options?: KvDeleteOptions | null): Promise<KvDeleteResponse> {
return post("/api/etcd/delete", { connectionId, key, keyBytes: options?.keyBytes ?? null, expectedModRevision: options?.expectedModRevision ?? null });
}
export async function etcdRename(connectionId: string, request: { key: string; keyBytes?: KvValue | null; newKey: string; expectedModRevision?: KvInt64 | null }): Promise<{ renamed: boolean; revision?: KvInt64 | null }> {
return post("/api/etcd/rename", { connectionId, request });
}
export async function etcdHistory(connectionId: string, request: { key: string; keyBytes?: KvValue | null; startRevision?: KvInt64 | null; endRevision?: KvInt64 | null; limit: number }): Promise<KvHistoryResponse> {
return post("/api/etcd/history", { connectionId, request });
}
export async function etcdStatus(connectionId: string): Promise<KvStatusResponse> {
return post("/api/etcd/status", { connectionId });
}
// ---------------------------------------------------------------------------
+174 -12
View File
@@ -1770,6 +1770,7 @@ export async function redisClusterMasterNodes(connectionId: string): Promise<Red
// --- etcd ---
export type KvValueEncoding = "utf8" | "base64";
export type KvInt64 = string;
export interface KvValue {
encoding: KvValueEncoding;
@@ -1777,10 +1778,10 @@ export interface KvValue {
}
export interface KvKeyMetadata {
createRevision?: number | null;
modRevision?: number | null;
version?: number | null;
lease?: number | null;
createRevision?: KvInt64 | number | null;
modRevision?: KvInt64 | number | null;
version?: KvInt64 | number | null;
lease?: KvInt64 | number | null;
ttl?: number | null;
valueSize?: number | null;
czxid?: number | null;
@@ -1797,27 +1798,36 @@ export interface KvKeyMetadata {
export interface KvKeySummary extends KvKeyMetadata {
key: string;
keyIdentity?: string | null;
keyBytes?: KvValue | null;
value?: KvValue | null;
}
export interface KvListPrefixResponse {
keys: KvKeySummary[];
continuation?: string | null;
revision?: number | null;
revision?: KvInt64 | number | null;
}
export interface KvListPrefixOptions {
recursive?: boolean | null;
revision?: KvInt64 | null;
includeValues?: boolean | null;
}
export interface KvGetResponse {
found: boolean;
key?: string | null;
keyIdentity?: string | null;
keyBytes?: KvValue | null;
value?: KvValue | null;
metadata?: KvKeyMetadata | null;
}
export interface KvGetOptions {
metadataOnly?: boolean | null;
keyBytes?: KvValue | null;
revision?: KvInt64 | null;
}
export interface KvPutResponse {
@@ -1832,20 +1842,159 @@ export type KvWriteMode = "upsert" | "create" | "update";
export type KvCreateMode = "persistent" | "ephemeral" | "persistent_sequential" | "ephemeral_sequential";
export interface KvPutOptions {
lease?: number | null;
lease?: KvInt64 | number | null;
ttl?: number | null;
preserveLease?: boolean | null;
writeMode?: KvWriteMode | null;
createMode?: KvCreateMode | null;
keyBytes?: KvValue | null;
expectedModRevision?: KvInt64 | null;
expectedCreateRevision?: KvInt64 | null;
}
export interface KvDeleteOptions {
keyBytes?: KvValue | null;
expectedModRevision?: KvInt64 | null;
}
export interface KvDeleteResponse {
deleted: number;
revision?: number | null;
revision?: KvInt64 | number | null;
}
export async function etcdListPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null): Promise<KvListPrefixResponse> {
return invoke("etcd_list_prefix", { connectionId, prefix, limit, continuation });
export type KvHistoryEventType = "put" | "delete";
export interface KvHistoryEvent {
eventType: KvHistoryEventType;
revision: KvInt64;
value?: KvValue | null;
previousValue?: KvValue | null;
metadata?: KvKeyMetadata | null;
}
export interface KvHistoryResponse {
events: KvHistoryEvent[];
observedRevision: KvInt64;
truncated: boolean;
}
export interface KvStatusMember {
endpoint: string;
memberId?: KvInt64 | null;
name?: string | null;
version?: string | null;
leaderId?: KvInt64 | null;
revision?: KvInt64 | null;
raftTerm?: KvInt64 | null;
raftIndex?: KvInt64 | null;
raftAppliedIndex?: KvInt64 | null;
dbSize?: KvInt64 | null;
dbSizeInUse?: KvInt64 | null;
learner: boolean;
reachable: boolean;
latencyMs?: number | null;
errors: string[];
}
export interface KvPrometheusMetrics {
available: boolean;
sourceUrl?: string | null;
error?: string | null;
collectedAtMs?: number | null;
sampleCount?: number | null;
serverVersion?: string | null;
clusterVersion?: string | null;
goVersion?: string | null;
authRevision?: number | null;
hasLeader?: number | null;
isLeader?: number | null;
leaderChangesTotal?: number | null;
proposalsCommittedTotal?: number | null;
proposalsAppliedTotal?: number | null;
proposalsPending?: number | null;
proposalsFailedTotal?: number | null;
grpcRequestsTotal?: number | null;
grpcFailuresTotal?: number | null;
grpcMethodRequestsTotal: Record<string, number>;
grpcMethodFailuresTotal: Record<string, number>;
requestDurationSecondsSumByType: Record<string, number>;
requestDurationSecondsCountByType: Record<string, number>;
mvccPutTotal?: number | null;
mvccDeleteTotal?: number | null;
mvccRangeTotal?: number | null;
mvccTxnTotal?: number | null;
mvccCurrentRevision?: number | null;
mvccCompactRevision?: number | null;
mvccKeysTotal?: number | null;
mvccEventsTotal?: number | null;
mvccPendingEventsTotal?: number | null;
mvccSlowWatcherTotal?: number | null;
mvccWatchStreamTotal?: number | null;
mvccWatcherTotal?: number | null;
mvccTotalPutSizeBytes?: number | null;
openReadTransactions?: number | null;
leaseGrantedTotal?: number | null;
leaseRenewedTotal?: number | null;
leaseRevokedTotal?: number | null;
leaseExpiredTotal?: number | null;
leaseTtlSecondsSum?: number | null;
leaseTtlSecondsCount?: number | null;
clientReceivedBytesTotal?: number | null;
clientSentBytesTotal?: number | null;
peerReceivedBytesTotal?: number | null;
peerSentBytesTotal?: number | null;
peerReceivedFailuresTotal?: number | null;
peerSentFailuresTotal?: number | null;
walFsyncDurationSecondsSum?: number | null;
walFsyncDurationSecondsCount?: number | null;
walWriteBytesTotal?: number | null;
walWriteDurationSecondsSum?: number | null;
walWriteDurationSecondsCount?: number | null;
backendCommitDurationSecondsSum?: number | null;
backendCommitDurationSecondsCount?: number | null;
backendSnapshotDurationSecondsSum?: number | null;
backendSnapshotDurationSecondsCount?: number | null;
backendDefragDurationSecondsSum?: number | null;
backendDefragDurationSecondsCount?: number | null;
diskDefragInflight?: number | null;
snapshotApplyInProgress?: number | null;
quotaBackendBytes?: number | null;
knownPeers?: number | null;
heartbeatSendFailuresTotal?: number | null;
readIndexesFailedTotal?: number | null;
slowApplyTotal?: number | null;
slowReadIndexesTotal?: number | null;
healthSuccessTotal?: number | null;
healthFailuresTotal?: number | null;
residentMemoryBytes?: number | null;
virtualMemoryBytes?: number | null;
cpuSecondsTotal?: number | null;
processStartTimeSeconds?: number | null;
processReceivedBytesTotal?: number | null;
processTransmittedBytesTotal?: number | null;
openFds?: number | null;
maxFds?: number | null;
goroutines?: number | null;
goThreads?: number | null;
goMaxProcs?: number | null;
goHeapAllocBytes?: number | null;
goHeapInuseBytes?: number | null;
goHeapSysBytes?: number | null;
goHeapObjects?: number | null;
goNextGcBytes?: number | null;
goGcDurationSecondsSum?: number | null;
goGcDurationSecondsCount?: number | null;
dbSizeMetricBytes?: number | null;
dbSizeInUseMetricBytes?: number | null;
}
export interface KvStatusResponse {
clusterId?: KvInt64 | null;
revision?: KvInt64 | null;
leaderId?: KvInt64 | null;
keyCount?: KvInt64 | null;
alarms: string[];
members: KvStatusMember[];
metrics?: KvPrometheusMetrics | null;
}
export async function etcdListPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null, options?: KvListPrefixOptions | null): Promise<KvListPrefixResponse> {
return invoke("etcd_list_prefix", { connectionId, prefix, limit, continuation, revision: options?.revision ?? null, includeValues: options?.includeValues ?? null });
}
export async function etcdSupportsTtl(connectionId: string): Promise<boolean> {
@@ -1853,7 +2002,7 @@ export async function etcdSupportsTtl(connectionId: string): Promise<boolean> {
}
export async function etcdGet(connectionId: string, key: string, options?: KvGetOptions | null): Promise<KvGetResponse> {
return invoke("etcd_get", { connectionId, key, metadataOnly: options?.metadataOnly ?? null });
return invoke("etcd_get", { connectionId, key, keyBytes: options?.keyBytes ?? null, revision: options?.revision ?? null, metadataOnly: options?.metadataOnly ?? null });
}
export async function etcdPut(connectionId: string, key: string, value: KvValue, options?: KvPutOptions | number | null): Promise<KvPutResponse> {
@@ -1866,11 +2015,24 @@ export async function etcdPut(connectionId: string, key: string, value: KvValue,
lease: legacyLease ?? putOptions?.lease ?? null,
ttl: putOptions?.ttl ?? null,
preserveLease: putOptions?.preserveLease ?? null,
keyBytes: putOptions?.keyBytes ?? null,
expectedModRevision: putOptions?.expectedModRevision ?? null,
expectedCreateRevision: putOptions?.expectedCreateRevision ?? null,
});
}
export async function etcdDelete(connectionId: string, key: string): Promise<KvDeleteResponse> {
return invoke("etcd_delete", { connectionId, key });
export async function etcdDelete(connectionId: string, key: string, options?: KvDeleteOptions | null): Promise<KvDeleteResponse> {
return invoke("etcd_delete", { connectionId, key, keyBytes: options?.keyBytes ?? null, expectedModRevision: options?.expectedModRevision ?? null });
}
export async function etcdRename(connectionId: string, request: { key: string; keyBytes?: KvValue | null; newKey: string; expectedModRevision?: KvInt64 | null }): Promise<{ renamed: boolean; revision?: KvInt64 | null }> {
return invoke("etcd_rename", { connectionId, request });
}
export async function etcdHistory(connectionId: string, request: { key: string; keyBytes?: KvValue | null; startRevision?: KvInt64 | null; endRevision?: KvInt64 | null; limit: number }): Promise<KvHistoryResponse> {
return invoke("etcd_history", { connectionId, request });
}
export async function etcdStatus(connectionId: string): Promise<KvStatusResponse> {
return invoke("etcd_status", { connectionId });
}
// --- ZooKeeper ---
@@ -0,0 +1,28 @@
export type EtcdDashboardErrorKind = "unsupported" | "request";
export interface EtcdDashboardError {
kind: EtcdDashboardErrorKind;
message: string;
}
const unsupportedStatusPatterns = ["etcd_status_unsupported", "unknown method: kv_status", "method not found: kv_status", "method not found: `kv_status`"];
function errorText(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error || "");
}
export function sanitizeEtcdDashboardError(error: unknown): string {
const raw = errorText(error).trim();
const withoutStderr = raw.split(/\s*(?:\.\s*)?recent stderr\s*:/i, 1)[0]?.trim() || raw;
return withoutStderr.replace(/^ETCD_STATUS_UNSUPPORTED:\s*/i, "").slice(0, 600);
}
export function classifyEtcdDashboardError(error: unknown): EtcdDashboardError {
const raw = errorText(error);
const normalized = raw.toLowerCase();
return {
kind: unsupportedStatusPatterns.some((pattern) => normalized.includes(pattern)) ? "unsupported" : "request",
message: sanitizeEtcdDashboardError(error),
};
}
@@ -0,0 +1,95 @@
import type { KvPrometheusMetrics } from "@/lib/backend/tauri";
type NumericMetricKey = Exclude<
{
[K in keyof KvPrometheusMetrics]: KvPrometheusMetrics[K] extends number | null | undefined ? K : never;
}[keyof KvPrometheusMetrics],
undefined
>;
type MetricMapKey = Exclude<
{
[K in keyof KvPrometheusMetrics]: KvPrometheusMetrics[K] extends Record<string, number> ? K : never;
}[keyof KvPrometheusMetrics],
undefined
>;
function finiteMetric(metrics: KvPrometheusMetrics | null | undefined, key: NumericMetricKey): number | null {
const value = metrics?.[key];
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function comparableWindowSeconds(current: KvPrometheusMetrics, previous: KvPrometheusMetrics | null | undefined): number | null {
if (!previous || current.sourceUrl !== previous.sourceUrl) return null;
const currentTime = finiteMetric(current, "collectedAtMs");
const previousTime = finiteMetric(previous, "collectedAtMs");
if (currentTime == null || previousTime == null || currentTime <= previousTime) return null;
return (currentTime - previousTime) / 1000;
}
export function counterRate(current: KvPrometheusMetrics | null | undefined, previous: KvPrometheusMetrics | null | undefined, key: NumericMetricKey): number | null {
if (!current?.available || !previous?.available) return null;
const seconds = comparableWindowSeconds(current, previous);
const currentValue = finiteMetric(current, key);
const previousValue = finiteMetric(previous, key);
if (seconds == null || currentValue == null || previousValue == null || currentValue < previousValue) return null;
return (currentValue - previousValue) / seconds;
}
export function histogramAverageMilliseconds(current: KvPrometheusMetrics | null | undefined, previous: KvPrometheusMetrics | null | undefined, sumKey: NumericMetricKey, countKey: NumericMetricKey): number | null {
if (!current?.available) return null;
const currentSum = finiteMetric(current, sumKey);
const currentCount = finiteMetric(current, countKey);
if (currentSum == null || currentCount == null || currentCount <= 0) return null;
const previousSum = finiteMetric(previous, sumKey);
const previousCount = finiteMetric(previous, countKey);
const hasComparableWindow = comparableWindowSeconds(current, previous) != null;
if (hasComparableWindow && previousSum != null && previousCount != null && currentSum >= previousSum && currentCount > previousCount) {
return ((currentSum - previousSum) / (currentCount - previousCount)) * 1000;
}
return (currentSum / currentCount) * 1000;
}
export function ratePercentage(numerator: number | null, denominator: number | null): number | null {
if (numerator == null || denominator == null || denominator <= 0) return null;
return Math.min(100, Math.max(0, (numerator / denominator) * 100));
}
export function counterMapRates(current: KvPrometheusMetrics | null | undefined, previous: KvPrometheusMetrics | null | undefined, key: MetricMapKey): Record<string, number> {
if (!current?.available || !previous?.available) return {};
const seconds = comparableWindowSeconds(current, previous);
const currentValues = current[key];
const previousValues = previous[key];
if (seconds == null || !currentValues || !previousValues) return {};
return Object.fromEntries(
Object.entries(currentValues).flatMap(([name, currentValue]) => {
const previousValue = previousValues[name];
return Number.isFinite(currentValue) && Number.isFinite(previousValue) && currentValue >= previousValue ? [[name, (currentValue - previousValue) / seconds]] : [];
}),
);
}
export function histogramMapAverageMilliseconds(current: KvPrometheusMetrics | null | undefined, previous: KvPrometheusMetrics | null | undefined, sumKey: MetricMapKey, countKey: MetricMapKey): Record<string, number> {
if (!current?.available) return {};
const currentSums = current[sumKey];
const currentCounts = current[countKey];
if (!currentSums || !currentCounts) return {};
const previousSums = previous?.[sumKey];
const previousCounts = previous?.[countKey];
const useDelta = comparableWindowSeconds(current, previous) != null;
return Object.fromEntries(
Object.entries(currentSums).flatMap(([name, currentSum]) => {
const currentCount = currentCounts[name];
if (!Number.isFinite(currentSum) || !Number.isFinite(currentCount) || currentCount <= 0) return [];
const previousSum = previousSums?.[name];
const previousCount = previousCounts?.[name];
if (useDelta && Number.isFinite(previousSum) && Number.isFinite(previousCount) && currentSum >= previousSum! && currentCount > previousCount!) {
return [[name, ((currentSum - previousSum!) / (currentCount - previousCount!)) * 1000]];
}
return [[name, (currentSum / currentCount) * 1000]];
}),
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { KvValue } from "@/lib/backend/api";
export type KvExportScopeKind = "key" | "prefix";
export interface KvExportScopeRequest {
path: string;
kind: KvExportScopeKind;
keyBytes?: KvValue | null;
}
export function kvDirectoryPrefix(path: string): string {
if (!path || path === "/") return path;
return `${path.replace(/\/+$/, "")}/`;
}
export function isKeyInKvExportScope(key: string, request: KvExportScopeRequest): boolean {
if (request.kind === "key") return key === request.path;
return key === request.path || key.startsWith(kvDirectoryPrefix(request.path));
}
export function kvExportFilenameStem(path: string): string {
const normalized = path
.split("/")
.filter(Boolean)
.join("-")
.replace(/[<>:"\\|?*\u0000-\u001f]/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return normalized.slice(0, 80) || "root";
}
export function kvValueByteIdentity(value: KvValue): string {
const bytes = value.encoding === "utf8" ? new TextEncoder().encode(value.data) : Uint8Array.from(atob(value.data.replace(/\s+/g, "")), (character) => character.charCodeAt(0));
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
+14
View File
@@ -0,0 +1,14 @@
import type { KvKeySummary } from "@/lib/backend/api";
export function normalizeKvKeySearchQuery(query: string): string {
return query.trim().toLocaleLowerCase();
}
export function kvKeyMatchesSearch(key: string, query: string): boolean {
const normalized = normalizeKvKeySearchQuery(query);
return !normalized || key.toLocaleLowerCase().includes(normalized);
}
export function filterKvKeysBySearch(keys: readonly KvKeySummary[], query: string): KvKeySummary[] {
return keys.filter((key) => kvKeyMatchesSearch(key.key, query));
}
+76 -10
View File
@@ -1,15 +1,17 @@
import type { KvKeySummary } from "@/lib/backend/api";
import type { KvKeySummary, KvValue } from "@/lib/backend/api";
export interface KvKeyTreeLeafNode {
kind: "leaf";
id: string;
label: string;
key: string;
keyIdentity?: string | null;
keyBytes?: KvValue | null;
pathSegments: string[];
createRevision?: number | null;
modRevision?: number | null;
version?: number | null;
lease?: number | null;
createRevision?: string | number | null;
modRevision?: string | number | null;
version?: string | number | null;
lease?: string | number | null;
valueSize?: number | null;
}
@@ -19,6 +21,14 @@ export interface KvKeyTreeGroupNode {
label: string;
pathSegments: string[];
children: KvKeyTreeNode[];
key?: string;
keyIdentity?: string | null;
keyBytes?: KvValue | null;
createRevision?: string | number | null;
modRevision?: string | number | null;
version?: string | number | null;
lease?: string | number | null;
valueSize?: number | null;
}
export type KvKeyTreeNode = KvKeyTreeLeafNode | KvKeyTreeGroupNode;
@@ -36,8 +46,12 @@ function groupId(pathSegments: string[]): string {
return `group:${pathSegments.join("\u0000")}`;
}
function leafId(key: string): string {
return `leaf:${key}`;
function summaryIdentity(key: KvKeySummary): string {
return key.keyIdentity ?? key.key;
}
function leafId(key: KvKeySummary): string {
return `leaf:${summaryIdentity(key)}`;
}
function sortNodes(nodes: KvKeyTreeNode[]): KvKeyTreeNode[] {
@@ -58,9 +72,11 @@ export function buildKvKeyTree(keys: KvKeySummary[]): KvKeyTreeNode[] {
if (segments.length <= 1) {
root.push({
kind: "leaf",
id: leafId(key.key),
id: leafId(key),
label: segments[0] || key.key || "/",
key: key.key,
keyIdentity: key.keyIdentity,
keyBytes: key.keyBytes,
pathSegments: segments,
createRevision: key.createRevision,
modRevision: key.modRevision,
@@ -78,18 +94,49 @@ export function buildKvKeyTree(keys: KvKeySummary[]): KvKeyTreeNode[] {
const id = groupId(groupSegments);
let group = groups.get(id);
if (!group) {
group = { kind: "group", id, label: segment, pathSegments: [...groupSegments], children: [] };
const leafIndex = current.findIndex((candidate) => candidate.kind === "leaf" && candidate.pathSegments.join("\u0000") === groupSegments.join("\u0000"));
const existingLeaf = leafIndex >= 0 ? (current.splice(leafIndex, 1)[0] as KvKeyTreeLeafNode) : null;
group = {
kind: "group",
id,
label: segment,
pathSegments: [...groupSegments],
children: [],
key: existingLeaf?.key,
keyIdentity: existingLeaf?.keyIdentity,
keyBytes: existingLeaf?.keyBytes,
createRevision: existingLeaf?.createRevision,
modRevision: existingLeaf?.modRevision,
version: existingLeaf?.version,
lease: existingLeaf?.lease,
valueSize: existingLeaf?.valueSize,
};
groups.set(id, group);
current.push(group);
}
current = group.children;
}
const existingGroup = groups.get(groupId(segments));
if (existingGroup) {
existingGroup.key = key.key;
existingGroup.keyIdentity = key.keyIdentity;
existingGroup.keyBytes = key.keyBytes;
existingGroup.createRevision = key.createRevision;
existingGroup.modRevision = key.modRevision;
existingGroup.version = key.version;
existingGroup.lease = key.lease;
existingGroup.valueSize = key.valueSize;
continue;
}
current.push({
kind: "leaf",
id: leafId(key.key),
id: leafId(key),
label: segments[segments.length - 1],
key: key.key,
keyIdentity: key.keyIdentity,
keyBytes: key.keyBytes,
pathSegments: segments,
createRevision: key.createRevision,
modRevision: key.modRevision,
@@ -134,3 +181,22 @@ export function flattenVisibleKvKeyTree(nodes: KvKeyTreeNode[], expandedGroupIds
}
return rows;
}
export function kvKeyTreeNodePath(node: KvKeyTreeNode): string {
if (node.kind === "leaf") return node.key;
if (node.key) return node.key;
const descendantKey = firstDescendantKey(node);
const joined = node.pathSegments.join("/");
return descendantKey?.startsWith("/") ? `/${joined}` : joined;
}
function firstDescendantKey(node: KvKeyTreeGroupNode): string | null {
for (const child of node.children) {
if (child.kind === "leaf") return child.key;
if (child.key) return child.key;
const nested = firstDescendantKey(child);
if (nested) return nested;
}
return null;
}
+16 -7
View File
@@ -45,18 +45,27 @@ export function mergeKvValueRefresh(current: KvGetResponse | null, incoming: KvG
return current;
}
export function removeMissingKvKey(keys: readonly KvKeySummary[], missingKey: string): KvKeySummary[] {
return keys.filter((key) => key.key !== missingKey);
function matchesKvKey(summary: KvKeySummary, key: string, keyIdentity?: string | null): boolean {
return keyIdentity != null ? (summary.keyIdentity ?? summary.key) === keyIdentity : summary.key === key;
}
export function knownKvLeaseKeys(keys: readonly KvKeySummary[], selectedKey: string | null): string[] {
return keys.filter((key) => key.key !== selectedKey && typeof key.lease === "number" && key.lease > 0).map((key) => key.key);
export function removeMissingKvKey(keys: readonly KvKeySummary[], missingKey: string, missingKeyIdentity?: string | null): KvKeySummary[] {
return keys.filter((summary) => !matchesKvKey(summary, missingKey, missingKeyIdentity));
}
export function mergeKvKeyMetadata(keys: readonly KvKeySummary[], key: string, incoming: KvGetResponse): KvKeySummary[] {
if (!incoming.found) return removeMissingKvKey(keys, key);
export function hasPositiveKvLease(lease: string | number | null | undefined): boolean {
if (typeof lease === "number") return Number.isInteger(lease) && lease > 0;
return typeof lease === "string" && /^[1-9]\d*$/.test(lease.trim());
}
export function knownKvLeaseSummaries(keys: readonly KvKeySummary[], selectedKeyIdentity: string | null): KvKeySummary[] {
return keys.filter((key) => (key.keyIdentity ?? key.key) !== selectedKeyIdentity && hasPositiveKvLease(key.lease));
}
export function mergeKvKeyMetadata(keys: readonly KvKeySummary[], key: string, incoming: KvGetResponse, keyIdentity?: string | null): KvKeySummary[] {
if (!incoming.found) return removeMissingKvKey(keys, key, keyIdentity);
if (!incoming.metadata) return [...keys];
return keys.map((item) => (item.key === key ? { ...item, ...incoming.metadata, key: item.key, valueSize: item.valueSize } : item));
return keys.map((item) => (matchesKvKey(item, key, keyIdentity) ? { ...item, ...incoming.metadata, key: item.key, valueSize: item.valueSize } : item));
}
export function nextKvLeaseRefreshDelay(currentDelayMs: number, failed: boolean, baseDelayMs = 2000, maxDelayMs = 30000): number {
@@ -0,0 +1,40 @@
export type KvMutationErrorKind = "keyAlreadyExists" | "conflict" | "request";
export interface KvMutationError {
kind: KvMutationErrorKind;
message: string;
}
export interface KvMutationErrorMessages {
keyAlreadyExists?: string;
conflict?: string;
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error || "");
}
export function sanitizeKvMutationError(error: unknown): string {
const raw = errorText(error).trim();
const withoutStderr = raw.split(/\s*(?:\.\s*)?recent stderr\s*:/i, 1)[0]?.trim() || raw;
return withoutStderr.replace(/^Agent RPC error(?:\s*\([^)]*\))?\s*:\s*/i, "").slice(0, 600);
}
export function classifyKvMutationError(error: unknown, creating: boolean, messages: KvMutationErrorMessages = {}): KvMutationError {
const sanitized = sanitizeKvMutationError(error);
const normalized = sanitized.toLowerCase();
if (normalized.includes("etcd_key_already_exists") || (creating && normalized.includes("etcd_cas_conflict"))) {
return {
kind: "keyAlreadyExists",
message: messages.keyAlreadyExists || "A Key with this name already exists. Choose another name or edit the existing Key.",
};
}
if (normalized.includes("etcd_cas_conflict")) {
return {
kind: "conflict",
message: messages.conflict || "The Key changed after it was loaded. Refresh it and try again.",
};
}
return { kind: "request", message: sanitized };
}
@@ -1,6 +1,6 @@
import type { KvKeySummary } from "@/lib/backend/api";
export function refreshedKvSelectionKey(previousKey: string | null | undefined, refreshedKeys: readonly KvKeySummary[]): string | null {
if (!previousKey) return null;
return refreshedKeys.some((item) => item.key === previousKey) ? previousKey : null;
export function refreshedKvSelectionSummary(previousIdentity: string | null | undefined, refreshedKeys: readonly KvKeySummary[]): KvKeySummary | null {
if (!previousIdentity) return null;
return refreshedKeys.find((item) => (item.keyIdentity ?? item.key) === previousIdentity) ?? null;
}
+13
View File
@@ -1,3 +1,5 @@
import type { KvInt64 } from "@/lib/backend/api";
export type ParsedOptionalTtl = { ok: true; ttl: number | null } | { ok: false };
export function parseOptionalTtl(value: string | number | null | undefined): ParsedOptionalTtl {
@@ -11,3 +13,14 @@ export function parseOptionalTtl(value: string | number | null | undefined): Par
if (!Number.isSafeInteger(ttl)) return { ok: false };
return { ok: true, ttl };
}
export function parseKvLeaseId(value: string | number | null | undefined): KvInt64 | null {
const normalized = String(value ?? "").trim();
if (!/^[1-9]\d*$/.test(normalized)) return null;
try {
if (BigInt(normalized) > 9_223_372_036_854_775_807n) return null;
} catch {
return null;
}
return normalized;
}
+1 -1
View File
@@ -28,7 +28,7 @@ export function prettyPrintJsonText(text: string): PrettyPrintJsonResult {
}
}
function valueLabel(value: number | null | undefined): string {
function valueLabel(value: string | number | null | undefined): string {
return value == null ? "-" : String(value);
}
+102
View File
@@ -0,0 +1,102 @@
import { jsonLanguage } from "@codemirror/lang-json";
import { yamlLanguage } from "@codemirror/lang-yaml";
import { xmlLanguage } from "@codemirror/lang-xml";
export type KvValueFormat = "text" | "base64" | "json" | "yaml" | "xml" | "sql" | "properties" | "shell" | "dockerfile" | "nginx" | "kubernetes";
const DOCKERFILE_INSTRUCTIONS = new Set(["ADD", "ARG", "CMD", "COPY", "ENTRYPOINT", "ENV", "EXPOSE", "FROM", "HEALTHCHECK", "LABEL", "MAINTAINER", "ONBUILD", "RUN", "SHELL", "STOPSIGNAL", "USER", "VOLUME", "WORKDIR"]);
function syntaxError(language: { parser: { parse: (text: string) => { cursor: () => { type: { isError: boolean }; next: () => boolean } } } }, text: string): boolean {
const cursor = language.parser.parse(text).cursor();
do {
if (cursor.type.isError) return true;
} while (cursor.next());
return false;
}
export function detectKvValueFormat(text: string, encoding: "utf8" | "base64" = "utf8"): KvValueFormat {
if (encoding === "base64") return "base64";
const trimmed = text.trim();
if (!trimmed) return "text";
if ((trimmed.startsWith("{") || trimmed.startsWith("[")) && !syntaxError(jsonLanguage, text)) return "json";
if (trimmed.startsWith("<") && !syntaxError(xmlLanguage, text)) return "xml";
if (/^#!\s*\/(?:usr\/bin\/env\s+)?(?:ba|z|k)?sh\b/m.test(text)) return "shell";
if (/^(?:#\s*syntax=\S+\s*)?(?:FROM|ARG)\s+\S+/im.test(text)) return "dockerfile";
if (/^\s*(?:events|http|server|upstream|location)\b[^{;]*\{/m.test(text)) return "nginx";
if (/^\s*apiVersion\s*:\s*\S+/m.test(text) && /^\s*kind\s*:\s*\S+/m.test(text) && !syntaxError(yamlLanguage, text)) return "kubernetes";
if (/^\s*(?:SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH|GRANT|REVOKE)\b/im.test(text)) return "sql";
if (/^(?:[#!].*\n)*\s*[A-Za-z0-9_.-]+\s*=\s*\S/m.test(text)) return "properties";
if (/^(---\s*$|[A-Za-z0-9_.-]+\s*:)/m.test(text) && !syntaxError(yamlLanguage, text)) return "yaml";
return "text";
}
export function validateKvValue(text: string, format: KvValueFormat): string | null {
if (format === "json") {
try {
JSON.parse(text);
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
if ((format === "yaml" || format === "kubernetes") && syntaxError(yamlLanguage, text)) return "Invalid YAML syntax";
if (format === "xml" && syntaxError(xmlLanguage, text)) return "Invalid XML syntax";
if (format === "base64") {
const normalized = text.replace(/\s+/g, "");
if (normalized && (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || normalized.length % 4 !== 0)) return "Invalid Base64 data";
}
if (format === "kubernetes" && text.trim()) {
const documents = text
.split(/^---\s*$/m)
.map((document) => document.trim())
.filter(Boolean);
if (documents.some((document) => !/^\s*apiVersion\s*:\s*\S+/m.test(document) || !/^\s*kind\s*:\s*\S+/m.test(document))) {
return "Each Kubernetes document must contain apiVersion and kind";
}
}
if (format === "properties") {
let continuing = false;
const invalidLine = text.split(/\r?\n/).find((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("!")) return false;
if (continuing) {
continuing = trimmed.endsWith("\\");
return false;
}
continuing = trimmed.endsWith("\\");
return !/[=:]/.test(trimmed) && !continuing;
});
if (invalidLine) return `Invalid Properties entry: ${invalidLine}`;
if (continuing) return "Invalid Properties entry: unfinished continuation";
}
if (format === "dockerfile") {
let continuing = false;
const invalidInstruction = text.split(/\r?\n/).find((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return false;
if (continuing) {
continuing = trimmed.endsWith("\\");
return false;
}
continuing = trimmed.endsWith("\\");
return !DOCKERFILE_INSTRUCTIONS.has(trimmed.split(/\s+/, 1)[0].toUpperCase());
});
if (invalidInstruction) return `Unknown Dockerfile instruction: ${invalidInstruction.split(/\s+/, 1)[0]}`;
if (continuing) return "Invalid Dockerfile: unfinished line continuation";
}
if (format === "nginx") {
let depth = 0;
for (const character of text.replace(/#[^\r\n]*/g, "")) {
if (character === "{") depth++;
if (character === "}") depth--;
if (depth < 0) return "Invalid Nginx configuration: unexpected closing brace";
}
if (depth !== 0) return "Invalid Nginx configuration: unbalanced braces";
}
return null;
}
export function formatKvValue(text: string, format: KvValueFormat): string {
if (format === "json") return JSON.stringify(JSON.parse(text), null, 2);
return text;
}
@@ -30,6 +30,10 @@ export type ActiveTabSidebarTarget =
type: "etcd-root";
connectionId: string;
}
| {
type: "etcd-dashboard";
connectionId: string;
}
| {
type: "zookeeper-root";
connectionId: string;
@@ -112,6 +116,10 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
return { type: "etcd-root", connectionId: tab.connectionId };
}
if (tab.mode === "etcd-dashboard") {
return { type: "etcd-dashboard", connectionId: tab.connectionId };
}
if (tab.mode === "zookeeper") {
return { type: "zookeeper-root", connectionId: tab.connectionId };
}
@@ -179,6 +187,10 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
return node.type === "etcd-root" && node.connectionId === target.connectionId;
}
if (target.type === "etcd-dashboard") {
return node.type === "etcd-dashboard" && node.connectionId === target.connectionId;
}
if (target.type === "zookeeper-root") {
return node.type === "zookeeper-root" && node.connectionId === target.connectionId;
}
@@ -14,6 +14,8 @@ const leafTypes: Set<TreeNodeType> = new Set([
"object-browser",
"redis-db",
"mq-tenant",
"etcd-root",
"etcd-dashboard",
"zookeeper-root",
"mongo-gridfs",
"mongo-bucket",
@@ -8,7 +8,7 @@ export type SidebarActivation = "single" | "double";
const dataNodeTypes = new Set<TreeNodeType>(["table", "view", "materialized_view"]);
const documentBrowserNodeTypes = new Set<TreeNodeType>(["mongo-collection", "mongo-bucket"]);
const toggleLeafNodeTypes = new Set<TreeNodeType>(["redis-db", "mq-tenant", "etcd-root", "zookeeper-root", "mongo-gridfs", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index", "user-admin"]);
const toggleLeafNodeTypes = new Set<TreeNodeType>(["redis-db", "mq-tenant", "etcd-root", "etcd-dashboard", "zookeeper-root", "mongo-gridfs", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index", "user-admin"]);
const objectBrowserNodeTypes = new Set<TreeNodeType>(["database", "schema", "object-browser"]);
const sourceNodeTypes = new Set<TreeNodeType>(["materialized_view", "procedure", "function", "trigger", "sequence", "package", "package-body", "type", "type-body"]);
const savedSqlNodeTypes = new Set<TreeNodeType>(["saved-sql-file"]);
+3 -1
View File
@@ -1,5 +1,5 @@
import type { Component } from "vue";
import { Archive, Braces, Columns3, Database, Eye, FileCode, FolderClosed, FolderOpen, Key, Link, ListTree, Network, Package, Plus, ScrollText, Server, Table, TableProperties, UsersRound, Zap } from "@lucide/vue";
import { Archive, Braces, Columns3, Database, Eye, FileCode, FolderClosed, FolderOpen, Gauge, Key, Link, ListTree, Network, Package, Plus, ScrollText, Server, Table, TableProperties, UsersRound, Zap } from "@lucide/vue";
import type { ColumnInfo, TreeNode } from "@/types/database";
export type TreeNodeIconInfo = {
@@ -30,6 +30,8 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null {
case "nacos-namespace":
case "etcd-root":
return { icon: FolderOpen, colorClass: "text-sky-500" };
case "etcd-dashboard":
return { icon: Gauge, colorClass: "text-sky-500" };
case "zookeeper-root":
return { icon: Database, colorClass: "text-blue-500" };
case "table":
@@ -102,6 +102,10 @@ export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
if (compact) return connectionDisplayName(tab.connectionId);
return `${connectionDisplayName(tab.connectionId)}@keys`;
}
if (tab.mode === "etcd-dashboard") {
if (compact) return connectionDisplayName(tab.connectionId);
return `${connectionDisplayName(tab.connectionId)}@dashboard`;
}
if (tab.mode === "zookeeper") {
if (compact) return connectionDisplayName(tab.connectionId);
return `${connectionDisplayName(tab.connectionId)}@keys`;
@@ -331,6 +335,7 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
if (tab.mode === "vector") return t("tabs.vector");
if (tab.mode === "redis") return t("tabs.redis");
if (tab.mode === "etcd") return t("tabs.etcd");
if (tab.mode === "etcd-dashboard") return t("tabs.etcdDashboard");
if (tab.mode === "zookeeper") return t("tabs.zookeeper");
if (tab.mode === "nacos") return "Nacos";
if (tab.mode === "objects") return t("tabs.objects");