Skip to content
Merged
121 changes: 65 additions & 56 deletions apps/frontend/src/components/key-browser/key-details/key-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface BaseKeyInfo {
ttl: number;
size: number;
collectionSize?: number;
elementsWarning?: string;
}

interface ElementInfo {
Expand Down Expand Up @@ -155,68 +156,76 @@ export default function KeyDetails({ selectedKey, selectedKeyInfo, connectionId,
</div>
</div>

{/* show different key types */}
{selectedKeyInfo.type === "string" && (
<KeyDetailsString
connectionId={connectionId}
readOnly={readOnly}
selectedKey={selectedKey}
selectedKeyInfo={selectedKeyInfo}
/>
)}
{selectedKeyInfo.elementsWarning ? (
<div className="text-center text-muted-foreground py-8">
<Typography variant="bodySm">{selectedKeyInfo.elementsWarning}</Typography>
</div>
) : (
<>
{/* show different key types */}
{selectedKeyInfo.type === "string" && (
<KeyDetailsString
connectionId={connectionId}
readOnly={readOnly}
selectedKey={selectedKey}
selectedKeyInfo={selectedKeyInfo}
/>
)}

{selectedKeyInfo.type === "hash" && (
<KeyDetailsHash
connectionId={connectionId}
readOnly={readOnly}
selectedKey={selectedKey}
selectedKeyInfo={selectedKeyInfo}
/>
)}
{selectedKeyInfo.type === "hash" && (
<KeyDetailsHash
connectionId={connectionId}
readOnly={readOnly}
selectedKey={selectedKey}
selectedKeyInfo={selectedKeyInfo}
/>
)}

{selectedKeyInfo.type === "list" && (
<KeyDetailsList
connectionId={connectionId}
readOnly={readOnly}
selectedKey={selectedKey}
selectedKeyInfo={selectedKeyInfo}
/>
)}
{selectedKeyInfo.type === "list" && (
<KeyDetailsList
connectionId={connectionId}
readOnly={readOnly}
selectedKey={selectedKey}
selectedKeyInfo={selectedKeyInfo}
/>
)}

{selectedKeyInfo.type === "set" && (
<KeyDetailsSet
connectionId={connectionId}
readOnly={readOnly}
selectedKey={selectedKey}
selectedKeyInfo={selectedKeyInfo}
/>
)}
{selectedKeyInfo.type === "set" && (
<KeyDetailsSet
connectionId={connectionId}
readOnly={readOnly}
selectedKey={selectedKey}
selectedKeyInfo={selectedKeyInfo}
/>
)}

{selectedKeyInfo.type === "zset" && (
<KeyDetailsZSet
connectionId={connectionId}
readOnly={readOnly}
selectedKey={selectedKey}
selectedKeyInfo={selectedKeyInfo}
/>
)}
{selectedKeyInfo.type === "zset" && (
<KeyDetailsZSet
connectionId={connectionId}
readOnly={readOnly}
selectedKey={selectedKey}
selectedKeyInfo={selectedKeyInfo}
/>
)}

{selectedKeyInfo.type === "stream" && (
<KeyDetailsStream
connectionId={connectionId}
readOnly={readOnly}
selectedKey={selectedKey}
selectedKeyInfo={selectedKeyInfo}
/>
)}
{selectedKeyInfo.type === "stream" && (
<KeyDetailsStream
connectionId={connectionId}
readOnly={readOnly}
selectedKey={selectedKey}
selectedKeyInfo={selectedKeyInfo}
/>
)}

{selectedKeyInfo.type === "ReJSON-RL" && (
<KeyDetailsJson
connectionId={connectionId}
readOnly={readOnly}
selectedKey={selectedKey}
selectedKeyInfo={selectedKeyInfo}
/>
{selectedKeyInfo.type === "ReJSON-RL" && (
<KeyDetailsJson
connectionId={connectionId}
readOnly={readOnly}
selectedKey={selectedKey}
selectedKeyInfo={selectedKeyInfo}
/>
)}
</>
)}
</div>
) : (
Expand Down
75 changes: 74 additions & 1 deletion apps/server/src/__tests__/keys-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe("getKeyInfo", () => {

assert.strictEqual(result.size, 0)
})

})

describe("hash keys", () => {
Expand All @@ -82,10 +83,11 @@ describe("getKeyInfo", () => {
{ key: "field2", value: "value2" },
])
})

})

describe("list keys", () => {
it("should get list key info", async () => {
it("should get list key info with readable elements", async () => {
const mockClient = createMockClient({
TYPE: "list",
TTL: 0,
Expand All @@ -99,8 +101,79 @@ describe("getKeyInfo", () => {
assert.strictEqual(result.name, "mylist")
assert.strictEqual(result.type, "list")
assert.strictEqual(result.collectionSize, 2)
assert.ok(Array.isArray(result.elements))
assert.deepStrictEqual(result.elements, ["item1", "item2"])
})

})

describe("zset keys", () => {
it("should get zset key info with readable members", async () => {
const mockClient = createMockClient({
TYPE: "zset",
TTL: -1,
MEMORY: 200,
ZCARD: 2,
ZRANGE: [{ key: "member1", value: "1.5" }, { key: "member2", value: "2.5" }],
})

const result = await getKeyInfo(mockClient as any, "myzset")

assert.strictEqual(result.name, "myzset")
assert.strictEqual(result.type, "zset")
assert.strictEqual(result.collectionSize, 2)
assert.ok(Array.isArray(result.elements))
// RESP3 ZRANGE with WITHSCORES returns [{key: member, value: score}, ...]
assert.deepStrictEqual(result.elements, [{ key: "member1", value: "1.5" }, { key: "member2", value: "2.5" }])
})

})

describe("stream keys", () => {
it("should get stream key info with readable entries", async () => {
const mockClient = createMockClient({
TYPE: "stream",
TTL: -1,
MEMORY: 300,
XLEN: 2,
XRANGE: [
["1234567890-0", ["field1", "value1", "field2", "value2"]],
["1234567891-0", ["field3", "value3"]],
],
})

const result = await getKeyInfo(mockClient as any, "mystream")

assert.strictEqual(result.name, "mystream")
assert.strictEqual(result.type, "stream")
assert.strictEqual(result.collectionSize, 2)
assert.ok(Array.isArray(result.elements))
// Stream entries are nested arrays: [[id, [field, value, ...]], ...]
const elements = result.elements as string[][]
assert.strictEqual(elements.length, 2)
assert.ok(Array.isArray(elements[0]))
assert.ok(Array.isArray(elements[1]))
})

})

describe("json keys", () => {
it("should get json key info with readable JSON string", async () => {
const jsonValue = "{\"name\":\"test\",\"value\":123}"
const mockClient = createMockClient({
TYPE: "rejson-rl",
TTL: -1,
MEMORY: 100,
"JSON.GET": jsonValue,
})

const result = await getKeyInfo(mockClient as any, "myjson")

assert.strictEqual(result.name, "myjson")
assert.strictEqual(result.type, "rejson-rl")
assert.strictEqual(result.elements, jsonValue)
})

})

describe("error handling", () => {
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,4 @@ valkey_version:8.0.0`,
assert.throws(() => parseClusterInfo(123 as any))
})
})

28 changes: 19 additions & 9 deletions apps/server/src/keys-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
RouteOption,
ConnectionError,
TimeoutError,
ClosingError
ClosingError,
GlideReturnType
} from "@valkey/valkey-glide"
import pLimit from "p-limit"
import { VALKEY, VALKEY_CLIENT } from "../../../common/src/constants.ts"
Expand All @@ -21,6 +22,7 @@ interface EnrichedKeyInfo {
collectionSize?: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
elements?: any; // this can be array, object, or string depending on the key type.
elementsWarning?: string; // alternative for elements when they cannot be displayed.
}

async function getScanKeyInfo(
Expand All @@ -29,8 +31,7 @@ async function getScanKeyInfo(
commands: { sizeCmd: string; elementsCmd: string[] },
): Promise<EnrichedKeyInfo> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const results = new Set<any>()
const results = new Set<string | { key: string; value: string }>()
const isHash = keyInfo.type.toLowerCase() === "hash"
let cursor = "0"

Expand All @@ -40,17 +41,19 @@ async function getScanKeyInfo(
client.customCommand([commands.sizeCmd, keyInfo.name]),
(async () => {
do {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [newCursor, elements] = await client.customCommand([...commands.elementsCmd, cursor]) as [string, any[]]
const [newCursor, elements] = await client.customCommand([...commands.elementsCmd, cursor]) as [string, GlideReturnType[]]

if (isHash) {
// Hash key types require constructing an object from a flat array.
// i.e. converting [key1, value1...] to [{key: key1, value}]
for (let i = 0; i < elements.length; i += 2){
results.add({ key: elements[i], value: elements[i + 1] })
results.add({
key: elements[i] as string,
value: elements[i + 1] as string,
})
}
} else {
elements.forEach((element) => results.add(element))
elements.forEach((element) => results.add(element as string))
}
cursor = newCursor
} while (cursor !== "0")
Expand All @@ -64,7 +67,10 @@ async function getScanKeyInfo(
}
} catch (err) {
console.log(`Could not get elements for key ${keyInfo.name}:`, err)
return keyInfo
return {
...keyInfo,
elementsWarning: VALKEY_CLIENT.MESSAGES.NOT_READABLE,
}
}
}

Expand Down Expand Up @@ -97,6 +103,10 @@ async function getFullKeyInfo(
}
} catch (err) {
console.log(`Could not get elements for key ${keyInfo.name}:`, err)
// Valkey client uses String decoder, which throws this error when it encounters non-UTF-8 bytes
if (err instanceof Error && err.message.includes("Decoding error")) {
return { ...keyInfo, elementsWarning: VALKEY_CLIENT.MESSAGES.NOT_READABLE }
}
return keyInfo
}
}
Expand Down Expand Up @@ -147,7 +157,7 @@ export async function getKeyInfo(
if (commands.sizeCmd){
keyInfo.collectionSize = await (client.customCommand([commands.sizeCmd, key])) as number
}
keyInfo.elements = `This key is ${formatBytes(memoryUsage)}, which is larger than the maximum display size of ${formatBytes(VALKEY_CLIENT.KEY_VALUE_SIZE_LIMIT)}.`
keyInfo.elementsWarning = `This key is ${formatBytes(memoryUsage)}, which is larger than the maximum display size of ${formatBytes(VALKEY_CLIENT.KEY_VALUE_SIZE_LIMIT)}.`

return keyInfo
}
Expand Down
3 changes: 3 additions & 0 deletions common/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ export const VALKEY_CLIENT = {
defaultCount: 50,
} ,
KEY_VALUE_SIZE_LIMIT: 2048, // 2KiB
MESSAGES: {
NOT_READABLE: "Not human readable.",
},
}
export const COMMANDLOG_TYPE = {
SLOW: "slow",
Expand Down
Loading