fix(etcd): preserve raw key identity in browser actions
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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]];
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user