feat(redis): support multiple value formats in viewer (#2766)
* feat(redis): improve value decoding and viewer formats
* fix(redis): preserve stream field/value blobs
* Revert "fix(redis): preserve stream field/value blobs"
This reverts commit 168af0619a.
This commit is contained in:
1 parent
ad9841ab77
commit
ba81fa4497
13 files changed
+2417
-685
No files matched your search
@@ -26,7 +26,7 @@ import { buildRedisKeyTree, collectExpandedGroupIds, collectRedisGroupKeyRaws, f
|
|||||||
import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety";
|
import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety";
|
||||||
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
|
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
|
||||||
import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession";
|
import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession";
|
||||||
import { formatRedisConsoleValue, formatRedisStringValue } from "@/lib/redis/redisValuePresentation";
|
import { formatRedisConsoleValue, redisValuePreview, redisValueSize } from "@/lib/redis/redisValuePresentation";
|
||||||
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
|
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||||
import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle";
|
import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle";
|
||||||
@@ -388,10 +388,10 @@ function redisValueToKeyInfo(value: RedisValue): RedisKeyInfo {
|
|||||||
return {
|
return {
|
||||||
key_display: value.key_display,
|
key_display: value.key_display,
|
||||||
key_raw: value.key_raw,
|
key_raw: value.key_raw,
|
||||||
key_type: value.key_type,
|
key_type: value.redis_type,
|
||||||
ttl: value.ttl,
|
ttl: value.ttl,
|
||||||
size: typeof value.value === "string" ? value.value.length : (value.total ?? 0),
|
size: redisValueSize(value),
|
||||||
value_preview: createdKeyPreview(value.value),
|
value_preview: redisValuePreview(value),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,23 +642,14 @@ function openCreateKeyDialog() {
|
|||||||
showCreateKeyDialog.value = true;
|
showCreateKeyDialog.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createdKeyPreview(value: any): string {
|
function upsertCreatedKey(value: RedisValue) {
|
||||||
if (typeof value === "string") {
|
|
||||||
const text = formatRedisStringValue(value).replace(/\s+/g, " ").trim();
|
|
||||||
return text.length > 160 ? `${text.slice(0, 160)}…` : text;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value) && value.length > 0) return String(value.length);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function upsertCreatedKey(value: any) {
|
|
||||||
const keyInfo: RedisKeyInfo = {
|
const keyInfo: RedisKeyInfo = {
|
||||||
key_display: value.key_display,
|
key_display: value.key_display,
|
||||||
key_raw: value.key_raw,
|
key_raw: value.key_raw,
|
||||||
key_type: value.key_type,
|
key_type: value.redis_type,
|
||||||
ttl: value.ttl,
|
ttl: value.ttl,
|
||||||
size: typeof value.value === "string" ? value.value.length : (value.total ?? 0),
|
size: redisValueSize(value),
|
||||||
value_preview: createdKeyPreview(value.value),
|
value_preview: redisValuePreview(value),
|
||||||
};
|
};
|
||||||
const existingIndex = flatKeys.value.findIndex((key) => key.key_raw === keyInfo.key_raw);
|
const existingIndex = flatKeys.value.findIndex((key) => key.key_raw === keyInfo.key_raw);
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { formatRedisMemberDetail, formatRedisStringValue, getRedisMemberSelectionKey, sanitizeRedisDisplayText } from "@/lib/redis/redisValuePresentation";
|
import { canRenderRedisValueFormat, formatRedisMemberDetail, formatRedisStringValue, getRedisMemberSelectionKey, preferredRedisValueFormat, redisMemberCopyText, sanitizeRedisDisplayText } from "@/lib/redis/redisValuePresentation";
|
||||||
|
|
||||||
describe("redisValuePresentation", () => {
|
describe("redisValuePresentation", () => {
|
||||||
it("strips control bytes from display without mutating raw member text", () => {
|
it("strips control bytes from display without mutating raw member text", () => {
|
||||||
@@ -26,7 +26,119 @@ describe("redisValuePresentation", () => {
|
|||||||
expect(getRedisMemberSelectionKey("member", raw)).toBe(`member\n${raw}`);
|
expect(getRedisMemberSelectionKey("member", raw)).toBe(`member\n${raw}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("can disambiguate duplicate stream fields with an explicit identity", () => {
|
||||||
|
expect(getRedisMemberSelectionKey("event", "login", "stream:1:0")).not.toBe(getRedisMemberSelectionKey("event", "login", "stream:1:1"));
|
||||||
|
});
|
||||||
|
|
||||||
it("formats string values for display without changing plain text", () => {
|
it("formats string values for display without changing plain text", () => {
|
||||||
expect(formatRedisStringValue("plain-text")).toBe("plain-text");
|
expect(formatRedisStringValue("plain-text")).toBe("plain-text");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("labels raw text views by encoding instead of generic raw text", () => {
|
||||||
|
expect(formatRedisMemberDetail("plain-text").rawLabel).toBe("ASCII");
|
||||||
|
expect(
|
||||||
|
formatRedisMemberDetail({
|
||||||
|
raw_base64: Buffer.from("你好", "utf8").toString("base64"),
|
||||||
|
encoding: "utf8",
|
||||||
|
}).rawLabel,
|
||||||
|
).toBe("UTF-8");
|
||||||
|
expect(
|
||||||
|
formatRedisMemberDetail({
|
||||||
|
raw_base64: "rO0ABQ==",
|
||||||
|
encoding: "binary",
|
||||||
|
}).rawLabel,
|
||||||
|
).toBe("Binary");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders supported formats with the recommended view first", () => {
|
||||||
|
expect(
|
||||||
|
formatRedisMemberDetail({
|
||||||
|
raw_base64: Buffer.from('{"id":1}', "utf8").toString("base64"),
|
||||||
|
encoding: "utf8",
|
||||||
|
}).availableFormats,
|
||||||
|
).toEqual(["utf8", "ascii", "binary", "hex", "base64"]);
|
||||||
|
expect(
|
||||||
|
formatRedisMemberDetail({
|
||||||
|
raw_base64: "rO0ABQ==",
|
||||||
|
encoding: "binary",
|
||||||
|
}).availableFormats,
|
||||||
|
).toEqual(["hex", "binary", "base64"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a UTF-8 decoding available even for binary blobs", () => {
|
||||||
|
const detail = formatRedisMemberDetail({
|
||||||
|
raw_base64: "rO0ABQ==",
|
||||||
|
encoding: "binary",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detail.utf8Text).toBe(new TextDecoder("utf-8").decode(Uint8Array.from([0xac, 0xed, 0x00, 0x05])));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only exposes JSON view when payload text explicitly opts in", () => {
|
||||||
|
expect(
|
||||||
|
formatRedisMemberDetail(
|
||||||
|
{
|
||||||
|
raw_base64: Buffer.from('{"id":1}', "utf8").toString("base64"),
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
{ allowJsonText: true },
|
||||||
|
).availableFormats,
|
||||||
|
).toEqual(["utf8", "ascii", "binary", "json", "hex", "base64"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to utf8 when a binary inspection view was stored for editable text", () => {
|
||||||
|
const blob = {
|
||||||
|
raw_base64: Buffer.from("Ada", "utf8").toString("base64"),
|
||||||
|
encoding: "utf8" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(preferredRedisValueFormat(blob, "hex")).toBe("utf8");
|
||||||
|
expect(preferredRedisValueFormat(blob, "base64")).toBe("utf8");
|
||||||
|
expect(preferredRedisValueFormat(blob, "binary")).toBe("utf8");
|
||||||
|
expect(preferredRedisValueFormat(blob, "json", { allowJsonText: true })).toBe("utf8");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a Java serialized format when the blob uses Java object serialization", () => {
|
||||||
|
const detail = formatRedisMemberDetail({
|
||||||
|
raw_base64: "rO0ABXQACHNvbWV0ZXh0",
|
||||||
|
encoding: "binary",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detail.availableFormats).toEqual(["javaserialize", "binary", "hex", "base64"]);
|
||||||
|
expect(detail.defaultFormat).toBe("javaserialize");
|
||||||
|
expect(detail.javaSerialized?.formattedText).toBe('"sometext"');
|
||||||
|
expect(canRenderRedisValueFormat(detail, "javaserialize")).toBe(true);
|
||||||
|
expect(canRenderRedisValueFormat(formatRedisMemberDetail("plain-text"), "javaserialize")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps self-referential Java maps representable via refs", () => {
|
||||||
|
const detail = formatRedisMemberDetail({
|
||||||
|
raw_base64: "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAx3CAAAABAAAAABdAAEc2VsZnEAfgABeA==",
|
||||||
|
encoding: "binary",
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalized = detail.javaSerialized?.value as { map?: { $entries?: Array<{ value?: { $ref?: string } }> } } | undefined;
|
||||||
|
const entries = normalized?.map?.$entries;
|
||||||
|
expect(entries?.[0]?.value?.$ref).toBe("#1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps editable text round-trippable while exposing separate ascii/binary views", () => {
|
||||||
|
const detail = formatRedisMemberDetail({
|
||||||
|
raw_base64: Buffer.from("send_message_to_esb\x06\x16", "latin1").toString("base64"),
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detail.rawText).toBe("send_message_to_esb\x06\x16");
|
||||||
|
expect(detail.asciiText).toBe("send_message_to_esb\\x06\\x16");
|
||||||
|
expect(detail.binaryText).toBe("011100110110010101101110011001000101111101101101011001010111001101110011011000010110011101100101010111110111010001101111010111110110010101110011011000100000011000010110");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies binary blobs as escaped raw bytes", () => {
|
||||||
|
expect(
|
||||||
|
redisMemberCopyText({
|
||||||
|
raw_base64: "rO0ABQ==",
|
||||||
|
encoding: "binary",
|
||||||
|
}),
|
||||||
|
).toBe("\\xac\\xed\\x00\\x05");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -471,9 +471,18 @@ export type {
|
|||||||
WebDavDownloadResult,
|
WebDavDownloadResult,
|
||||||
McpServerStatus,
|
McpServerStatus,
|
||||||
UpdateInfo,
|
UpdateInfo,
|
||||||
|
RedisBlob,
|
||||||
|
RedisCollectionPage,
|
||||||
RedisDatabaseInfo,
|
RedisDatabaseInfo,
|
||||||
|
RedisHashItem,
|
||||||
RedisKeyInfo,
|
RedisKeyInfo,
|
||||||
|
RedisListItem,
|
||||||
|
RedisSetItem,
|
||||||
|
RedisStreamEntry,
|
||||||
|
RedisStreamField,
|
||||||
RedisValue,
|
RedisValue,
|
||||||
|
RedisValueData,
|
||||||
|
RedisZsetItem,
|
||||||
RedisScanResult,
|
RedisScanResult,
|
||||||
RedisCommandSafety,
|
RedisCommandSafety,
|
||||||
RedisCommandResult,
|
RedisCommandResult,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import type {
|
|||||||
JavaRuntimeConfig,
|
JavaRuntimeConfig,
|
||||||
UpdateInfo,
|
UpdateInfo,
|
||||||
UpdateDownloadSource,
|
UpdateDownloadSource,
|
||||||
|
RedisCollectionPage,
|
||||||
RedisDatabaseInfo,
|
RedisDatabaseInfo,
|
||||||
RedisValue,
|
RedisValue,
|
||||||
RedisScanResult,
|
RedisScanResult,
|
||||||
@@ -1727,7 +1728,7 @@ export async function redisExecuteCommand(connectionId: string, db: number, comm
|
|||||||
return post("/api/redis/execute-command", { connectionId, db, command, skipSafetyCheck: skipSafetyCheck ?? false });
|
return post("/api/redis/execute-command", { connectionId, db, command, skipSafetyCheck: skipSafetyCheck ?? false });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise<RedisValue> {
|
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise<RedisCollectionPage> {
|
||||||
return post("/api/redis/load-more", { connectionId, db, keyRaw, keyType, cursor, count, filter });
|
return post("/api/redis/load-more", { connectionId, db, keyRaw, keyType, cursor, count, filter });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1303,17 +1303,62 @@ export interface RedisDatabaseInfo {
|
|||||||
keys: number;
|
keys: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RedisBlobEncoding = "utf8" | "binary";
|
||||||
|
|
||||||
|
export interface RedisBlob {
|
||||||
|
raw_base64: string;
|
||||||
|
encoding: RedisBlobEncoding;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedisListItem {
|
||||||
|
index: number;
|
||||||
|
value: RedisBlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedisSetItem {
|
||||||
|
member: RedisBlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedisHashItem {
|
||||||
|
field: RedisBlob;
|
||||||
|
value: RedisBlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedisZsetItem {
|
||||||
|
score: string;
|
||||||
|
member: RedisBlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedisStreamField {
|
||||||
|
field: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedisStreamEntry {
|
||||||
|
id: string;
|
||||||
|
fields: RedisStreamField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RedisValueData =
|
||||||
|
| { kind: "string"; content: RedisBlob }
|
||||||
|
| { kind: "json"; value: unknown }
|
||||||
|
| { kind: "list"; items: RedisListItem[]; total: number; scan_cursor?: number }
|
||||||
|
| { kind: "set"; items: RedisSetItem[]; total: number; scan_cursor?: number }
|
||||||
|
| { kind: "hash"; items: RedisHashItem[]; total: number; scan_cursor?: number }
|
||||||
|
| { kind: "zset"; items: RedisZsetItem[]; total: number; scan_cursor?: number }
|
||||||
|
| { kind: "stream"; entries: RedisStreamEntry[] }
|
||||||
|
| { kind: "unknown" };
|
||||||
|
|
||||||
export interface RedisValue {
|
export interface RedisValue {
|
||||||
key_display: string;
|
key_display: string;
|
||||||
key_raw: string;
|
key_raw: string;
|
||||||
key_type: string;
|
|
||||||
ttl: number;
|
ttl: number;
|
||||||
value_is_binary: boolean;
|
redis_type: string;
|
||||||
value: any;
|
data: RedisValueData;
|
||||||
total?: number;
|
|
||||||
scan_cursor?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RedisCollectionPage = { kind: "list"; items: RedisListItem[]; scan_cursor?: number } | { kind: "set"; items: RedisSetItem[]; scan_cursor?: number } | { kind: "hash"; items: RedisHashItem[]; scan_cursor?: number } | { kind: "zset"; items: RedisZsetItem[]; scan_cursor?: number };
|
||||||
|
|
||||||
export interface RedisScanResult {
|
export interface RedisScanResult {
|
||||||
cursor: number;
|
cursor: number;
|
||||||
keys: RedisKeyInfo[];
|
keys: RedisKeyInfo[];
|
||||||
@@ -1434,7 +1479,7 @@ export async function redisExecuteCommand(connectionId: string, db: number, comm
|
|||||||
return invoke("redis_execute_command", { connectionId, db, command, skipSafetyCheck: skipSafetyCheck ?? false });
|
return invoke("redis_execute_command", { connectionId, db, command, skipSafetyCheck: skipSafetyCheck ?? false });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise<RedisValue> {
|
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise<RedisCollectionPage> {
|
||||||
return invoke("redis_load_more", { connectionId, db, keyRaw, keyType, cursor, count, filter });
|
return invoke("redis_load_more", { connectionId, db, keyRaw, keyType, cursor, count, filter });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,559 @@
|
|||||||
|
export interface RedisJavaSerializedDetail {
|
||||||
|
formattedText: string;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type JavaFieldDescriptor = {
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JavaClassDescriptor = {
|
||||||
|
name: string;
|
||||||
|
serialVersionUID: string;
|
||||||
|
flags: number;
|
||||||
|
isEnum: boolean;
|
||||||
|
fields: JavaFieldDescriptor[];
|
||||||
|
superClass: JavaClassDescriptor | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JavaObjectValue = Record<string, unknown> & {
|
||||||
|
[JAVA_CLASS_META]?: JavaClassDescriptor;
|
||||||
|
[JAVA_EXTENDS_META]?: Record<string, Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JavaPostProcessor = (fields: Record<string, unknown>, annotations: unknown[]) => Record<string, unknown>;
|
||||||
|
|
||||||
|
const JAVA_CLASS_META = Symbol("javaClassMeta");
|
||||||
|
const JAVA_EXTENDS_META = Symbol("javaExtendsMeta");
|
||||||
|
const STREAM_MAGIC = 0xaced;
|
||||||
|
const STREAM_VERSION = 5;
|
||||||
|
const BASE_WIRE_HANDLE = 0x7e0000;
|
||||||
|
const END_BLOCK = Symbol("endBlock");
|
||||||
|
|
||||||
|
const TAG_NAMES: Record<number, string> = {
|
||||||
|
0x70: "Null",
|
||||||
|
0x71: "Reference",
|
||||||
|
0x72: "ClassDesc",
|
||||||
|
0x73: "Object",
|
||||||
|
0x74: "String",
|
||||||
|
0x75: "Array",
|
||||||
|
0x76: "Class",
|
||||||
|
0x77: "BlockData",
|
||||||
|
0x78: "EndBlockData",
|
||||||
|
0x79: "Reset",
|
||||||
|
0x7a: "BlockDataLong",
|
||||||
|
0x7b: "Exception",
|
||||||
|
0x7c: "LongString",
|
||||||
|
0x7d: "ProxyClassDesc",
|
||||||
|
0x7e: "Enum",
|
||||||
|
};
|
||||||
|
|
||||||
|
const JAVA_POST_PROCESSORS = new Map<string, JavaPostProcessor>([
|
||||||
|
["java.util.ArrayList@7881d21d99c7619d", (fields, annotations) => ({ ...fields, list: annotations.slice(1) })],
|
||||||
|
["java.util.ArrayDeque@207cda2e240da08b", (fields, annotations) => ({ ...fields, list: annotations.slice(1) })],
|
||||||
|
["java.util.Hashtable@13bb0f25214ae4b8", mapPostProcessor],
|
||||||
|
["java.util.HashMap@0507dac1c31660d1", mapPostProcessor],
|
||||||
|
["java.util.HashMap@0507b0c1331660d1", mapPostProcessor],
|
||||||
|
["java.util.EnumMap@065d7df7be907ca1", enumMapPostProcessor],
|
||||||
|
["java.util.HashSet@ba44859596b8b734", hashSetPostProcessor],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function parseJavaSerializedDetail(bytes: Uint8Array): RedisJavaSerializedDetail | null {
|
||||||
|
if (!isJavaSerialized(bytes)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parser = new JavaSerializationParser(bytes);
|
||||||
|
const contents = parser.parseContents();
|
||||||
|
if (contents.length === 0) return null;
|
||||||
|
const root = contents.length === 1 ? contents[0] : contents;
|
||||||
|
const normalized = normalizeJavaSerializedValue(root, new WeakMap<object, string>(), { nextId: 1 });
|
||||||
|
return {
|
||||||
|
value: normalized,
|
||||||
|
formattedText: JSON.stringify(normalized, null, 2),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isJavaSerialized(bytes: Uint8Array): boolean {
|
||||||
|
return bytes.length >= 4 && bytes[0] === 0xac && bytes[1] === 0xed && bytes[2] === 0x00 && bytes[3] === 0x05;
|
||||||
|
}
|
||||||
|
|
||||||
|
class JavaSerializationParser {
|
||||||
|
private readonly view: DataView;
|
||||||
|
private position = 0;
|
||||||
|
private nextHandle = BASE_WIRE_HANDLE;
|
||||||
|
private readonly handles = new Map<number, unknown>();
|
||||||
|
|
||||||
|
constructor(private readonly bytes: Uint8Array) {
|
||||||
|
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseContents(): unknown[] {
|
||||||
|
if (this.readUint16() !== STREAM_MAGIC) throw new Error("STREAM_MAGIC not found");
|
||||||
|
if (this.readUint16() !== STREAM_VERSION) throw new Error("Only understand protocol version 5");
|
||||||
|
|
||||||
|
const contents: unknown[] = [];
|
||||||
|
while (this.position < this.bytes.length) {
|
||||||
|
contents.push(this.readContent());
|
||||||
|
}
|
||||||
|
return contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readContent(allowed?: string[]): unknown {
|
||||||
|
const tag = this.readUint8();
|
||||||
|
const name = TAG_NAMES[tag];
|
||||||
|
if (!name) throw new Error(`Don't know about type 0x${tag.toString(16)}`);
|
||||||
|
if (allowed && !allowed.includes(name)) throw new Error(`${name} not allowed here`);
|
||||||
|
|
||||||
|
switch (tag) {
|
||||||
|
case 0x70:
|
||||||
|
return null;
|
||||||
|
case 0x71:
|
||||||
|
return this.readReference();
|
||||||
|
case 0x72:
|
||||||
|
return this.readClassDescriptor();
|
||||||
|
case 0x73:
|
||||||
|
return this.readObject();
|
||||||
|
case 0x74:
|
||||||
|
return this.addHandle(this.readUtf());
|
||||||
|
case 0x75:
|
||||||
|
return this.readArray();
|
||||||
|
case 0x76:
|
||||||
|
return this.addHandle(this.readClassDesc());
|
||||||
|
case 0x77:
|
||||||
|
return this.readBytes(this.readUint8());
|
||||||
|
case 0x78:
|
||||||
|
return END_BLOCK;
|
||||||
|
case 0x79:
|
||||||
|
case 0x7b:
|
||||||
|
case 0x7d:
|
||||||
|
throw new Error(`Don't know how to handle ${name}`);
|
||||||
|
case 0x7a:
|
||||||
|
return this.readBytes(this.readUint32());
|
||||||
|
case 0x7c:
|
||||||
|
return this.addHandle(this.readUtfLong());
|
||||||
|
case 0x7e:
|
||||||
|
return this.readEnum();
|
||||||
|
default:
|
||||||
|
throw new Error(`Don't know how to handle ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readClassDesc(): JavaClassDescriptor | null {
|
||||||
|
return this.readContent(["ClassDesc", "ProxyClassDesc", "Null", "Reference"]) as JavaClassDescriptor | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readClassDescriptor(): JavaClassDescriptor {
|
||||||
|
const descriptor: JavaClassDescriptor = {
|
||||||
|
name: this.readUtf(),
|
||||||
|
serialVersionUID: bytesToHex(this.readBytes(8)),
|
||||||
|
flags: 0,
|
||||||
|
isEnum: false,
|
||||||
|
fields: [],
|
||||||
|
superClass: null,
|
||||||
|
};
|
||||||
|
this.addHandle(descriptor);
|
||||||
|
|
||||||
|
descriptor.flags = this.readUint8();
|
||||||
|
descriptor.isEnum = Boolean(descriptor.flags & 0x10);
|
||||||
|
const count = this.readUint16();
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
descriptor.fields.push(this.readFieldDescriptor());
|
||||||
|
}
|
||||||
|
this.readAnnotations();
|
||||||
|
descriptor.superClass = this.readClassDesc();
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readFieldDescriptor(): JavaFieldDescriptor {
|
||||||
|
const type = String.fromCharCode(this.readUint8());
|
||||||
|
const field: JavaFieldDescriptor = { type, name: this.readUtf() };
|
||||||
|
if (type === "[" || type === "L") {
|
||||||
|
const className = this.readContent();
|
||||||
|
field.className = typeof className === "string" ? className : String(className ?? "");
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readObject(): JavaObjectValue {
|
||||||
|
const classDescriptor = this.readClassDesc();
|
||||||
|
if (!classDescriptor) throw new Error("Object must have a class descriptor");
|
||||||
|
|
||||||
|
const value: JavaObjectValue = {};
|
||||||
|
Object.defineProperty(value, JAVA_CLASS_META, { value: classDescriptor, configurable: true });
|
||||||
|
Object.defineProperty(value, JAVA_EXTENDS_META, { value: {}, configurable: true });
|
||||||
|
this.addHandle(value);
|
||||||
|
this.readClassDataRecursive(classDescriptor, value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readClassDataRecursive(classDescriptor: JavaClassDescriptor, value: JavaObjectValue) {
|
||||||
|
if (classDescriptor.superClass) {
|
||||||
|
this.readClassDataRecursive(classDescriptor.superClass, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = this.readClassData(classDescriptor);
|
||||||
|
value[JAVA_EXTENDS_META]![classDescriptor.name] = fields;
|
||||||
|
for (const [name, fieldValue] of Object.entries(fields)) {
|
||||||
|
value[name] = fieldValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readClassData(classDescriptor: JavaClassDescriptor): Record<string, unknown> {
|
||||||
|
const flags = classDescriptor.flags & 0x0f;
|
||||||
|
const postProcessor = JAVA_POST_PROCESSORS.get(`${classDescriptor.name}@${classDescriptor.serialVersionUID}`);
|
||||||
|
|
||||||
|
if (flags === 0x02) return this.readFieldValues(classDescriptor);
|
||||||
|
if (flags === 0x03) {
|
||||||
|
const fields = this.readFieldValues(classDescriptor);
|
||||||
|
const annotations = this.readAnnotations();
|
||||||
|
fields["@"] = annotations;
|
||||||
|
return postProcessor ? postProcessor(fields, annotations) : fields;
|
||||||
|
}
|
||||||
|
if (flags === 0x04) throw new Error("Can't parse version 1 external content");
|
||||||
|
if (flags === 0x0c) {
|
||||||
|
const annotations = this.readAnnotations();
|
||||||
|
return { "@": annotations };
|
||||||
|
}
|
||||||
|
throw new Error(`Don't know how to deserialize class with flags 0x${classDescriptor.flags.toString(16)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readArray(): unknown[] {
|
||||||
|
const classDescriptor = this.readClassDesc();
|
||||||
|
if (!classDescriptor) throw new Error("Array must have a class descriptor");
|
||||||
|
|
||||||
|
const value: unknown[] = [];
|
||||||
|
Object.defineProperty(value, JAVA_CLASS_META, { value: classDescriptor, configurable: true });
|
||||||
|
this.addHandle(value);
|
||||||
|
|
||||||
|
const length = this.readInt32();
|
||||||
|
const reader = this.primitiveReader(classDescriptor.name.charAt(1));
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
value.push(reader());
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readEnum(): unknown {
|
||||||
|
const classDescriptor = this.readClassDesc();
|
||||||
|
if (!classDescriptor) throw new Error("Enum must have a class descriptor");
|
||||||
|
|
||||||
|
const assignHandle = this.reserveHandle();
|
||||||
|
const constant = this.readContent();
|
||||||
|
const value = new String(typeof constant === "string" ? constant : String(constant ?? ""));
|
||||||
|
Object.defineProperty(value, JAVA_CLASS_META, { value: classDescriptor, configurable: true });
|
||||||
|
assignHandle(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readAnnotations(): unknown[] {
|
||||||
|
const annotations: unknown[] = [];
|
||||||
|
while (true) {
|
||||||
|
const value = this.readContent();
|
||||||
|
if (value === END_BLOCK) return annotations;
|
||||||
|
annotations.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readFieldValues(classDescriptor: JavaClassDescriptor): Record<string, unknown> {
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
for (const field of classDescriptor.fields) {
|
||||||
|
values[field.name] = this.primitiveReader(field.type)();
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private primitiveReader(type: string): () => unknown {
|
||||||
|
switch (type) {
|
||||||
|
case "B":
|
||||||
|
return () => this.readInt8();
|
||||||
|
case "C":
|
||||||
|
return () => String.fromCharCode(this.readUint16());
|
||||||
|
case "D":
|
||||||
|
return () => this.readFloat64();
|
||||||
|
case "F":
|
||||||
|
return () => this.readFloat32();
|
||||||
|
case "I":
|
||||||
|
return () => this.readInt32();
|
||||||
|
case "J":
|
||||||
|
return () => this.readInt64();
|
||||||
|
case "S":
|
||||||
|
return () => this.readInt16();
|
||||||
|
case "Z":
|
||||||
|
return () => this.readInt8() !== 0;
|
||||||
|
case "L":
|
||||||
|
case "[":
|
||||||
|
return () => this.readContent();
|
||||||
|
default:
|
||||||
|
throw new Error(`Don't know how to read field of type '${type}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addHandle<T>(value: T): T {
|
||||||
|
this.handles.set(this.nextHandle, value);
|
||||||
|
this.nextHandle += 1;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reserveHandle(): (value: unknown) => void {
|
||||||
|
const handle = this.nextHandle;
|
||||||
|
this.nextHandle += 1;
|
||||||
|
this.handles.set(handle, null);
|
||||||
|
return (value: unknown) => {
|
||||||
|
this.handles.set(handle, value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private readReference(): unknown {
|
||||||
|
const handle = this.readInt32();
|
||||||
|
if (!this.handles.has(handle)) throw new Error(`Unknown reference handle 0x${handle.toString(16)}`);
|
||||||
|
return this.handles.get(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readUint8(): number {
|
||||||
|
const position = this.step(1);
|
||||||
|
return this.view.getUint8(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readInt8(): number {
|
||||||
|
const position = this.step(1);
|
||||||
|
return this.view.getInt8(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readUint16(): number {
|
||||||
|
const position = this.step(2);
|
||||||
|
return this.view.getUint16(position, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readInt16(): number {
|
||||||
|
const position = this.step(2);
|
||||||
|
return this.view.getInt16(position, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readUint32(): number {
|
||||||
|
const position = this.step(4);
|
||||||
|
return this.view.getUint32(position, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readInt32(): number {
|
||||||
|
const position = this.step(4);
|
||||||
|
return this.view.getInt32(position, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readInt64(): bigint {
|
||||||
|
const high = this.readUint32();
|
||||||
|
const low = this.readUint32();
|
||||||
|
let value = (BigInt(high) << 32n) | BigInt(low);
|
||||||
|
if (high & 0x80000000) {
|
||||||
|
value -= 1n << 64n;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readFloat32(): number {
|
||||||
|
const position = this.step(4);
|
||||||
|
return this.view.getFloat32(position, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readFloat64(): number {
|
||||||
|
const position = this.step(8);
|
||||||
|
return this.view.getFloat64(position, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readUtf(): string {
|
||||||
|
return decodeModifiedUtf8(this.readBytes(this.readUint16()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private readUtfLong(): string {
|
||||||
|
const high = this.readUint32();
|
||||||
|
if (high !== 0) throw new Error("Can't handle more than 2^32 bytes in a string");
|
||||||
|
return decodeModifiedUtf8(this.readBytes(this.readUint32()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private readBytes(length: number): Uint8Array {
|
||||||
|
const position = this.step(length);
|
||||||
|
return this.bytes.slice(position, position + length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private step(length: number): number {
|
||||||
|
const position = this.position;
|
||||||
|
this.position += length;
|
||||||
|
if (this.position > this.bytes.length) {
|
||||||
|
throw new Error("Premature end of input");
|
||||||
|
}
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeJavaSerializedValue(value: unknown, seen: WeakMap<object, string>, state: { nextId: number }): unknown {
|
||||||
|
if (value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "bigint") {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
if (value instanceof Uint8Array) {
|
||||||
|
return { $blockDataHex: bytesToHex(value) };
|
||||||
|
}
|
||||||
|
if (value instanceof String) {
|
||||||
|
const classDescriptor = getJavaClassDescriptor(value);
|
||||||
|
if (!classDescriptor) return value.toString();
|
||||||
|
return {
|
||||||
|
$class: classDescriptor.name,
|
||||||
|
$enum: value.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof value !== "object") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return { $ref: seen.get(value) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectId = `#${state.nextId}`;
|
||||||
|
state.nextId += 1;
|
||||||
|
seen.set(value, objectId);
|
||||||
|
|
||||||
|
const classDescriptor = getJavaClassDescriptor(value);
|
||||||
|
const extendsValues = getJavaExtendsValues(value);
|
||||||
|
|
||||||
|
if (value instanceof Map) {
|
||||||
|
return {
|
||||||
|
$id: objectId,
|
||||||
|
...(classDescriptor ? { $class: classDescriptor.name } : {}),
|
||||||
|
$entries: Array.from(value.entries(), ([key, entryValue]) => ({
|
||||||
|
key: normalizeJavaSerializedValue(key, seen, state),
|
||||||
|
value: normalizeJavaSerializedValue(entryValue, seen, state),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Set) {
|
||||||
|
return {
|
||||||
|
$id: objectId,
|
||||||
|
...(classDescriptor ? { $class: classDescriptor.name } : {}),
|
||||||
|
$values: Array.from(value.values(), (entryValue) => normalizeJavaSerializedValue(entryValue, seen, state)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const normalized = {
|
||||||
|
$id: objectId,
|
||||||
|
...(classDescriptor ? { $class: classDescriptor.name } : {}),
|
||||||
|
$values: value.map((entryValue) => normalizeJavaSerializedValue(entryValue, seen, state)),
|
||||||
|
};
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: Record<string, unknown> = {
|
||||||
|
$id: objectId,
|
||||||
|
...(classDescriptor ? { $class: classDescriptor.name } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [name, entryValue] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
normalized[name] = normalizeJavaSerializedValue(entryValue, seen, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extendsValues) {
|
||||||
|
const classNames = Object.keys(extendsValues);
|
||||||
|
if (classNames.length > 1) {
|
||||||
|
normalized.$extends = Object.fromEntries(classNames.map((className) => [className, Object.fromEntries(Object.entries(extendsValues[className]).map(([name, entryValue]) => [name, normalizeJavaSerializedValue(entryValue, seen, state)]))]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJavaClassDescriptor(value: object): JavaClassDescriptor | undefined {
|
||||||
|
return (value as { [JAVA_CLASS_META]?: JavaClassDescriptor })[JAVA_CLASS_META];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJavaExtendsValues(value: object): Record<string, Record<string, unknown>> | undefined {
|
||||||
|
return (value as { [JAVA_EXTENDS_META]?: Record<string, Record<string, unknown>> })[JAVA_EXTENDS_META];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPostProcessor(fields: Record<string, unknown>, annotations: unknown[]): Record<string, unknown> {
|
||||||
|
const header = annotations[0];
|
||||||
|
if (!(header instanceof Uint8Array)) return fields;
|
||||||
|
|
||||||
|
const size = readInt32FromBytes(header, 4);
|
||||||
|
const map = new Map<unknown, unknown>();
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
for (let index = 0; index < size; index += 1) {
|
||||||
|
const key = annotations[2 * index + 1];
|
||||||
|
const value = annotations[2 * index + 2];
|
||||||
|
map.set(key, value);
|
||||||
|
if (typeof key === "string") {
|
||||||
|
obj[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...fields, map, obj };
|
||||||
|
}
|
||||||
|
|
||||||
|
function enumMapPostProcessor(fields: Record<string, unknown>, annotations: unknown[]): Record<string, unknown> {
|
||||||
|
const header = annotations[0];
|
||||||
|
if (!(header instanceof Uint8Array)) return fields;
|
||||||
|
|
||||||
|
const size = readInt32FromBytes(header, 0);
|
||||||
|
const map = new Map<unknown, unknown>();
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
for (let index = 0; index < size; index += 1) {
|
||||||
|
const key = annotations[2 * index + 1];
|
||||||
|
const value = annotations[2 * index + 2];
|
||||||
|
map.set(key, value);
|
||||||
|
obj[String(key)] = value;
|
||||||
|
}
|
||||||
|
return { ...fields, map, obj };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashSetPostProcessor(fields: Record<string, unknown>, annotations: unknown[]): Record<string, unknown> {
|
||||||
|
const header = annotations[0];
|
||||||
|
if (!(header instanceof Uint8Array)) return fields;
|
||||||
|
|
||||||
|
const size = readInt32FromBytes(header, 8);
|
||||||
|
const values = annotations.slice(1);
|
||||||
|
if (values.length !== size) return fields;
|
||||||
|
return { ...fields, set: new Set(values) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInt32FromBytes(bytes: Uint8Array, offset: number): number {
|
||||||
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
return view.getInt32(offset, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeModifiedUtf8(bytes: Uint8Array): string {
|
||||||
|
let output = "";
|
||||||
|
for (let index = 0; index < bytes.length; ) {
|
||||||
|
const byte = bytes[index];
|
||||||
|
if ((byte & 0x80) === 0) {
|
||||||
|
output += String.fromCharCode(byte);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((byte & 0xe0) === 0xc0) {
|
||||||
|
const next = bytes[index + 1];
|
||||||
|
if (next == null) throw new Error("Invalid modified UTF-8 sequence");
|
||||||
|
output += String.fromCharCode(((byte & 0x1f) << 6) | (next & 0x3f));
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((byte & 0xf0) === 0xe0) {
|
||||||
|
const next = bytes[index + 1];
|
||||||
|
const third = bytes[index + 2];
|
||||||
|
if (next == null || third == null) throw new Error("Invalid modified UTF-8 sequence");
|
||||||
|
output += String.fromCharCode(((byte & 0x0f) << 12) | ((next & 0x3f) << 6) | (third & 0x3f));
|
||||||
|
index += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error("Invalid modified UTF-8 sequence");
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
@@ -1,10 +1,30 @@
|
|||||||
|
import type { BinaryHexViewRow } from "@/lib/dataGrid/binaryHexViewer";
|
||||||
|
import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer";
|
||||||
|
import type { RedisBlob, RedisCollectionPage, RedisHashItem, RedisListItem, RedisSetItem, RedisValue, RedisZsetItem } from "@/lib/backend/api";
|
||||||
|
import { parseJavaSerializedDetail, type RedisJavaSerializedDetail } from "@/lib/redis/javaSerialized";
|
||||||
|
|
||||||
|
export type RedisValueFormat = "utf8" | "ascii" | "binary" | "json" | "javaserialize" | "hex" | "base64";
|
||||||
export type RedisMemberDetailFormat = "json" | "text";
|
export type RedisMemberDetailFormat = "json" | "text";
|
||||||
|
|
||||||
|
export const REDIS_VALUE_FORMAT_DISPLAY_ORDER: RedisValueFormat[] = ["utf8", "ascii", "binary", "json", "javaserialize", "hex", "base64"];
|
||||||
|
|
||||||
export interface RedisMemberDetail {
|
export interface RedisMemberDetail {
|
||||||
text: string;
|
text: string;
|
||||||
rawText: string;
|
rawText: string;
|
||||||
|
rawLabel: string;
|
||||||
|
utf8Text: string;
|
||||||
|
asciiText: string;
|
||||||
|
binaryText: string;
|
||||||
format: RedisMemberDetailFormat;
|
format: RedisMemberDetailFormat;
|
||||||
json?: RedisJsonDetail;
|
json?: RedisJsonDetail;
|
||||||
|
javaSerialized?: RedisJavaSerializedDetail;
|
||||||
|
availableFormats: RedisValueFormat[];
|
||||||
|
defaultFormat: RedisValueFormat;
|
||||||
|
byteCount: number;
|
||||||
|
base64Text: string;
|
||||||
|
hexRows: BinaryHexViewRow[];
|
||||||
|
editable: boolean;
|
||||||
|
binary: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RedisJsonDetail {
|
export interface RedisJsonDetail {
|
||||||
@@ -13,13 +33,20 @@ export interface RedisJsonDetail {
|
|||||||
value: unknown;
|
value: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RedisMemberDetailOptions {
|
||||||
|
allowJsonText?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type RedisMemberDetailKind = "list" | "set" | "hash" | "zset" | "stream";
|
export type RedisMemberDetailKind = "list" | "set" | "hash" | "zset" | "stream";
|
||||||
|
export type RedisCollectionItem = RedisListItem | RedisSetItem | RedisHashItem | RedisZsetItem;
|
||||||
|
|
||||||
export const REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH = 360;
|
export const REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH = 360;
|
||||||
export const REDIS_MEMBER_DETAIL_SHEET_MAX_WIDTH = 900;
|
export const REDIS_MEMBER_DETAIL_SHEET_MAX_WIDTH = 900;
|
||||||
|
|
||||||
export function canEditRedisMemberDetail(kind: RedisMemberDetailKind): boolean {
|
export function canEditRedisMemberDetail(kind: RedisMemberDetailKind, value?: unknown): boolean {
|
||||||
return kind !== "stream";
|
if (kind === "stream") return false;
|
||||||
|
if (value == null) return true;
|
||||||
|
return !isRedisBlob(value) || value.encoding !== "binary";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clampRedisMemberDetailSheetWidth(width: number, viewportWidth: number): number {
|
export function clampRedisMemberDetailSheetWidth(width: number, viewportWidth: number): number {
|
||||||
@@ -27,27 +54,97 @@ export function clampRedisMemberDetailSheetWidth(width: number, viewportWidth: n
|
|||||||
return Math.min(Math.min(REDIS_MEMBER_DETAIL_SHEET_MAX_WIDTH, viewportMax), Math.max(REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH, width));
|
return Math.min(Math.min(REDIS_MEMBER_DETAIL_SHEET_MAX_WIDTH, viewportMax), Math.max(REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH, width));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRedisMemberDetail(value: unknown): RedisMemberDetail {
|
export function isRedisBlob(value: unknown): value is RedisBlob {
|
||||||
|
return typeof value === "object" && value !== null && "raw_base64" in value && "encoding" in value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeRedisBlob(blob: RedisBlob): Uint8Array {
|
||||||
|
return base64ToBytes(blob.raw_base64);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redisBlobText(blob: RedisBlob): string | null {
|
||||||
|
if (blob.encoding === "binary") return null;
|
||||||
|
return decodeUtf8Bytes(decodeRedisBlob(blob));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redisBlobRawText(blob: RedisBlob): string {
|
||||||
|
return redisBlobText(blob) ?? escapeRedisBytes(decodeRedisBlob(blob));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redisBlobDisplayText(blob: RedisBlob): string {
|
||||||
|
return sanitizeRedisDisplayText(redisBlobRawText(blob));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRedisMemberDetail(value: unknown, options: RedisMemberDetailOptions = {}): RedisMemberDetail {
|
||||||
|
if (isRedisBlob(value)) return formatRedisBlobDetail(value, options);
|
||||||
|
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
const json = parseRedisJsonDetail(value);
|
const bytes = new TextEncoder().encode(value);
|
||||||
return json ? { text: json.formattedText, rawText: value, format: "json", json } : { text: sanitizeRedisDisplayText(value), rawText: value, format: "text" };
|
const json = options.allowJsonText ? (parseRedisJsonDetail(value) ?? undefined) : undefined;
|
||||||
|
const textFormats: RedisValueFormat[] = ["utf8", "ascii", "binary"];
|
||||||
|
return {
|
||||||
|
text: sanitizeRedisDisplayText(value),
|
||||||
|
rawText: value,
|
||||||
|
rawLabel: isAsciiBytes(bytes) ? "ASCII" : "UTF-8",
|
||||||
|
utf8Text: value,
|
||||||
|
asciiText: asciiBytesToText(bytes),
|
||||||
|
binaryText: binaryBytesToText(bytes),
|
||||||
|
format: "text",
|
||||||
|
json,
|
||||||
|
availableFormats: formatOrderForValue("utf8", textFormats, json ? ["json"] : []),
|
||||||
|
defaultFormat: "utf8",
|
||||||
|
byteCount: bytes.byteLength,
|
||||||
|
base64Text: bytesToBase64(bytes),
|
||||||
|
hexRows: buildBinaryHexViewRows(bytes),
|
||||||
|
editable: true,
|
||||||
|
binary: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formattedText = JSON.stringify(value, null, 2);
|
const formattedText = JSON.stringify(value, null, 2);
|
||||||
|
const bytes = new TextEncoder().encode(formattedText);
|
||||||
return {
|
return {
|
||||||
text: formattedText,
|
text: formattedText,
|
||||||
rawText: formattedText,
|
rawText: formattedText,
|
||||||
|
rawLabel: "Raw",
|
||||||
|
utf8Text: formattedText,
|
||||||
|
asciiText: asciiBytesToText(bytes),
|
||||||
|
binaryText: binaryBytesToText(bytes),
|
||||||
format: "json",
|
format: "json",
|
||||||
json: { rawText: formattedText, formattedText, value },
|
json: { rawText: formattedText, formattedText, value },
|
||||||
|
availableFormats: formatOrderForValue("json", ["utf8"], ["json"]),
|
||||||
|
defaultFormat: "json",
|
||||||
|
byteCount: bytes.byteLength,
|
||||||
|
base64Text: bytesToBase64(bytes),
|
||||||
|
hexRows: buildBinaryHexViewRows(bytes),
|
||||||
|
editable: false,
|
||||||
|
binary: false,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
const text = String(value);
|
const text = String(value);
|
||||||
return { text, rawText: text, format: "text" };
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
rawText: text,
|
||||||
|
rawLabel: "Raw",
|
||||||
|
utf8Text: text,
|
||||||
|
asciiText: asciiBytesToText(bytes),
|
||||||
|
binaryText: binaryBytesToText(bytes),
|
||||||
|
format: "text",
|
||||||
|
availableFormats: formatOrderForValue("utf8", ["utf8"], []),
|
||||||
|
defaultFormat: "utf8",
|
||||||
|
byteCount: bytes.byteLength,
|
||||||
|
base64Text: bytesToBase64(bytes),
|
||||||
|
hexRows: buildBinaryHexViewRows(bytes),
|
||||||
|
editable: false,
|
||||||
|
binary: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRedisStringValue(value: unknown): string {
|
export function formatRedisStringValue(value: unknown): string {
|
||||||
|
if (isRedisBlob(value)) return formatRedisMemberDetail(value).text;
|
||||||
if (typeof value !== "string") return String(value ?? "");
|
if (typeof value !== "string") return String(value ?? "");
|
||||||
return formatRedisJsonString(value) ?? sanitizeRedisDisplayText(value);
|
return formatRedisJsonString(value) ?? sanitizeRedisDisplayText(value);
|
||||||
}
|
}
|
||||||
@@ -85,19 +182,13 @@ export function formatRedisConsoleValue(value: unknown): string {
|
|||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(value, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRedisJsonString(value: string): string | null {
|
|
||||||
return parseRedisJsonDetail(value)?.formattedText ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null {
|
export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null {
|
||||||
if (typeof value !== "string") return null;
|
if (typeof value !== "string") return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
if (!looksLikeJsonContainer(trimmed)) return null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(trimmed);
|
const parsed = JSON.parse(trimmed);
|
||||||
if (!isJsonContainer(parsed)) return null;
|
|
||||||
return {
|
return {
|
||||||
rawText: value,
|
rawText: value,
|
||||||
formattedText: JSON.stringify(parsed, null, 2),
|
formattedText: JSON.stringify(parsed, null, 2),
|
||||||
@@ -108,17 +199,175 @@ export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeJsonContainer(value: string): boolean {
|
export function preferredRedisValueFormat(value: unknown, preferred?: RedisValueFormat | null, options: RedisMemberDetailOptions = {}): RedisValueFormat {
|
||||||
return (value.startsWith("{") && value.endsWith("}")) || (value.startsWith("[") && value.endsWith("]"));
|
const detail = formatRedisMemberDetail(value, options);
|
||||||
|
if (preferred && detail.availableFormats.includes(preferred) && shouldReuseRedisValueFormatPreference(detail, preferred)) return preferred;
|
||||||
|
return detail.defaultFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isJsonContainer(value: unknown): boolean {
|
export function canRenderRedisValueFormat(detail: RedisMemberDetail, format: RedisValueFormat): boolean {
|
||||||
return value !== null && typeof value === "object";
|
switch (format) {
|
||||||
|
case "json":
|
||||||
|
return Boolean(detail.json);
|
||||||
|
case "javaserialize":
|
||||||
|
return Boolean(detail.javaSerialized);
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRedisMemberSelectionKey(title: string, value: unknown): string {
|
export function redisMemberCopyText(value: unknown): string {
|
||||||
|
return isRedisBlob(value) ? redisBlobRawText(value) : formatRedisMemberDetail(value).rawText;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRedisMemberSelectionKey(title: string, value: unknown, identity = title): string {
|
||||||
|
if (isRedisBlob(value)) return `${identity}\n${value.raw_base64}`;
|
||||||
const detail = formatRedisMemberDetail(value);
|
const detail = formatRedisMemberDetail(value);
|
||||||
return `${title}\n${detail.format === "json" ? detail.text : detail.rawText}`;
|
return `${identity}\n${detail.rawText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redisValueCollectionItems(value: RedisValue): RedisCollectionItem[] {
|
||||||
|
switch (value.data.kind) {
|
||||||
|
case "list":
|
||||||
|
case "set":
|
||||||
|
case "hash":
|
||||||
|
case "zset":
|
||||||
|
return value.data.items;
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redisCollectionPageItems(page: RedisCollectionPage): RedisCollectionItem[] {
|
||||||
|
return page.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redisValueCollectionTotal(value: RedisValue): number | null {
|
||||||
|
switch (value.data.kind) {
|
||||||
|
case "list":
|
||||||
|
case "set":
|
||||||
|
case "hash":
|
||||||
|
case "zset":
|
||||||
|
return value.data.total;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redisValueCollectionScanCursor(value: RedisValue): number | undefined {
|
||||||
|
switch (value.data.kind) {
|
||||||
|
case "list":
|
||||||
|
case "set":
|
||||||
|
case "hash":
|
||||||
|
case "zset":
|
||||||
|
return value.data.scan_cursor;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redisValueSize(value: RedisValue): number {
|
||||||
|
switch (value.data.kind) {
|
||||||
|
case "string":
|
||||||
|
return decodeRedisBlob(value.data.content).byteLength;
|
||||||
|
case "json":
|
||||||
|
return new TextEncoder().encode(JSON.stringify(value.data.value)).byteLength;
|
||||||
|
case "list":
|
||||||
|
case "set":
|
||||||
|
case "hash":
|
||||||
|
case "zset":
|
||||||
|
return value.data.total;
|
||||||
|
case "stream":
|
||||||
|
return value.data.entries.length;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redisValuePreview(value: RedisValue): string {
|
||||||
|
switch (value.data.kind) {
|
||||||
|
case "string":
|
||||||
|
return previewText(redisBlobRawText(value.data.content));
|
||||||
|
case "json":
|
||||||
|
return previewText(JSON.stringify(value.data.value));
|
||||||
|
case "list": {
|
||||||
|
const first = value.data.items[0];
|
||||||
|
return first ? previewText(redisBlobRawText(first.value)) : "";
|
||||||
|
}
|
||||||
|
case "set": {
|
||||||
|
const first = value.data.items[0];
|
||||||
|
return first ? previewText(redisBlobRawText(first.member)) : "";
|
||||||
|
}
|
||||||
|
case "hash": {
|
||||||
|
const first = value.data.items[0];
|
||||||
|
return first ? previewText(`${redisBlobRawText(first.field)} ${redisBlobRawText(first.value)}`) : "";
|
||||||
|
}
|
||||||
|
case "zset": {
|
||||||
|
const first = value.data.items[0];
|
||||||
|
return first ? previewText(`${first.score} ${redisBlobRawText(first.member)}`) : "";
|
||||||
|
}
|
||||||
|
case "stream": {
|
||||||
|
const first = value.data.entries[0];
|
||||||
|
if (!first) return "";
|
||||||
|
const joined = first.fields.map(({ field, value: entryValue }) => `${field} ${entryValue}`).join(" ");
|
||||||
|
return previewText(joined);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redisValueCopyText(value: RedisValue, collectionItems: RedisCollectionItem[] = redisValueCollectionItems(value)): string {
|
||||||
|
switch (value.data.kind) {
|
||||||
|
case "string":
|
||||||
|
return redisBlobRawText(value.data.content);
|
||||||
|
case "json":
|
||||||
|
return JSON.stringify(value.data.value, null, 2);
|
||||||
|
case "list":
|
||||||
|
return JSON.stringify(
|
||||||
|
(collectionItems as RedisListItem[]).map((item) => redisBlobRawText(item.value)),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
case "set":
|
||||||
|
return JSON.stringify(
|
||||||
|
(collectionItems as RedisSetItem[]).map((item) => redisBlobRawText(item.member)),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
case "hash":
|
||||||
|
return JSON.stringify(
|
||||||
|
(collectionItems as RedisHashItem[]).map((item) => ({
|
||||||
|
field: redisBlobRawText(item.field),
|
||||||
|
value: redisBlobRawText(item.value),
|
||||||
|
})),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
case "zset":
|
||||||
|
return JSON.stringify(
|
||||||
|
(collectionItems as RedisZsetItem[]).map((item) => ({
|
||||||
|
score: item.score,
|
||||||
|
member: redisBlobRawText(item.member),
|
||||||
|
})),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
case "stream":
|
||||||
|
return JSON.stringify(
|
||||||
|
value.data.entries.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
fields: entry.fields.map((field) => ({
|
||||||
|
field: field.field,
|
||||||
|
value: field.value,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return JSON.stringify(value.data, null, 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeRedisDisplayText(value: string): string {
|
export function sanitizeRedisDisplayText(value: string): string {
|
||||||
@@ -135,10 +384,6 @@ export function sanitizeRedisDisplayText(value: string): string {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUtf8ControlCharacter(ch: string): boolean {
|
|
||||||
return /\p{Cc}/u.test(ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function highlightRedisJsonDetail(json: string): string {
|
export function highlightRedisJsonDetail(json: string): string {
|
||||||
const escaped = json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
const escaped = json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
|
||||||
@@ -150,3 +395,137 @@ export function highlightRedisJsonDetail(json: string): string {
|
|||||||
return `<span class="${cls}">${match}</span>`;
|
return `<span class="${cls}">${match}</span>`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRedisBlobDetail(blob: RedisBlob, options: RedisMemberDetailOptions): RedisMemberDetail {
|
||||||
|
const bytes = decodeRedisBlob(blob);
|
||||||
|
const strictUtf8Text = blob.encoding === "binary" ? null : decodeUtf8Bytes(bytes);
|
||||||
|
const utf8Text = strictUtf8Text ?? decodeUtf8BytesLossy(bytes);
|
||||||
|
const asciiText = asciiBytesToText(bytes);
|
||||||
|
const binaryText = binaryBytesToText(bytes);
|
||||||
|
const rawText = strictUtf8Text ?? binaryText;
|
||||||
|
const javaSerialized = parseJavaSerializedDetail(bytes);
|
||||||
|
const defaultFormat: RedisValueFormat = javaSerialized ? "javaserialize" : strictUtf8Text != null ? "utf8" : "hex";
|
||||||
|
const json = options.allowJsonText && strictUtf8Text != null ? (parseRedisJsonDetail(strictUtf8Text) ?? undefined) : undefined;
|
||||||
|
const textFormats: RedisValueFormat[] = strictUtf8Text != null ? ["utf8", "ascii", "binary"] : ["binary"];
|
||||||
|
const extraFormats: RedisValueFormat[] = [];
|
||||||
|
if (json) extraFormats.push("json");
|
||||||
|
if (javaSerialized) extraFormats.push("javaserialize");
|
||||||
|
return {
|
||||||
|
text: redisBlobDisplayText(blob),
|
||||||
|
rawText,
|
||||||
|
rawLabel: blob.encoding === "binary" ? "Binary" : isAsciiBytes(bytes) ? "ASCII" : "UTF-8",
|
||||||
|
utf8Text,
|
||||||
|
asciiText,
|
||||||
|
binaryText,
|
||||||
|
format: "text",
|
||||||
|
json,
|
||||||
|
javaSerialized: javaSerialized ?? undefined,
|
||||||
|
availableFormats: formatOrderForValue(defaultFormat, textFormats, extraFormats),
|
||||||
|
defaultFormat,
|
||||||
|
byteCount: bytes.byteLength,
|
||||||
|
base64Text: blob.raw_base64,
|
||||||
|
hexRows: buildBinaryHexViewRows(bytes),
|
||||||
|
editable: strictUtf8Text != null,
|
||||||
|
binary: strictUtf8Text == null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRedisJsonString(value: string): string | null {
|
||||||
|
return parseRedisJsonDetail(value)?.formattedText ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOrderForValue(defaultFormat: RedisValueFormat, textFormats: RedisValueFormat[], extraFormats: RedisValueFormat[]): RedisValueFormat[] {
|
||||||
|
const availableFormats: RedisValueFormat[] = [...textFormats];
|
||||||
|
availableFormats.push(...extraFormats);
|
||||||
|
availableFormats.push("hex", "base64");
|
||||||
|
return [defaultFormat, ...availableFormats.filter((format) => format !== defaultFormat)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldReuseRedisValueFormatPreference(detail: RedisMemberDetail, format: RedisValueFormat): boolean {
|
||||||
|
if (!detail.editable) return true;
|
||||||
|
return format === "utf8" || format === "json";
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewText(value: string): string {
|
||||||
|
const singleLine = value.replace(/\s+/g, " ").trim();
|
||||||
|
return singleLine.length > 160 ? `${singleLine.slice(0, 160)}…` : singleLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUtf8ControlCharacter(ch: string): boolean {
|
||||||
|
return /\p{Cc}/u.test(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRedisBytes(bytes: Uint8Array): string {
|
||||||
|
let output = "";
|
||||||
|
for (const byte of bytes) {
|
||||||
|
if (byte === 0x5c) {
|
||||||
|
output += "\\\\";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (byte >= 0x20 && byte <= 0x7e) {
|
||||||
|
output += String.fromCharCode(byte);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
output += `\\x${byte.toString(16).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeUtf8Bytes(bytes: Uint8Array): string | null {
|
||||||
|
try {
|
||||||
|
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeUtf8BytesLossy(bytes: Uint8Array): string {
|
||||||
|
return new TextDecoder("utf-8").decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asciiBytesToText(bytes: Uint8Array): string {
|
||||||
|
let output = "";
|
||||||
|
for (const byte of bytes) {
|
||||||
|
if (byte === 0x0a) {
|
||||||
|
output += "\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (byte === 0x0d) {
|
||||||
|
output += "\r";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (byte === 0x09) {
|
||||||
|
output += "\t";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (byte >= 0x20 && byte <= 0x7e) {
|
||||||
|
output += String.fromCharCode(byte);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
output += `\\x${byte.toString(16).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function binaryBytesToText(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes, (byte) => byte.toString(2).padStart(8, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(value: string): Uint8Array {
|
||||||
|
if (typeof atob !== "function") throw new Error("Base64 decoding is unavailable in this runtime");
|
||||||
|
const binary = atob(value);
|
||||||
|
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
if (typeof btoa !== "function") throw new Error("Base64 encoding is unavailable in this runtime");
|
||||||
|
let binary = "";
|
||||||
|
bytes.forEach((byte) => {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
});
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAsciiBytes(bytes: Uint8Array): boolean {
|
||||||
|
return bytes.every((byte) => byte <= 0x7f);
|
||||||
|
}
|
||||||
@@ -64,12 +64,121 @@ pub struct RedisScanResult {
|
|||||||
pub struct RedisValue {
|
pub struct RedisValue {
|
||||||
pub key_display: String,
|
pub key_display: String,
|
||||||
pub key_raw: String,
|
pub key_raw: String,
|
||||||
pub key_type: String,
|
|
||||||
pub ttl: i64,
|
pub ttl: i64,
|
||||||
pub value_is_binary: bool,
|
pub redis_type: String,
|
||||||
pub value: serde_json::Value,
|
pub data: RedisValueData,
|
||||||
pub total: Option<u64>,
|
}
|
||||||
pub scan_cursor: Option<u64>,
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RedisBlobEncoding {
|
||||||
|
Utf8,
|
||||||
|
Binary,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RedisBlob {
|
||||||
|
pub raw_base64: String,
|
||||||
|
pub encoding: RedisBlobEncoding,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RedisListItem {
|
||||||
|
pub index: u64,
|
||||||
|
pub value: RedisBlob,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RedisSetItem {
|
||||||
|
pub member: RedisBlob,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RedisHashItem {
|
||||||
|
pub field: RedisBlob,
|
||||||
|
pub value: RedisBlob,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RedisZsetItem {
|
||||||
|
pub score: String,
|
||||||
|
pub member: RedisBlob,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RedisStreamField {
|
||||||
|
pub field: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RedisStreamEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub fields: Vec<RedisStreamField>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum RedisValueData {
|
||||||
|
String {
|
||||||
|
content: RedisBlob,
|
||||||
|
},
|
||||||
|
Json {
|
||||||
|
value: serde_json::Value,
|
||||||
|
},
|
||||||
|
List {
|
||||||
|
items: Vec<RedisListItem>,
|
||||||
|
total: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
scan_cursor: Option<u64>,
|
||||||
|
},
|
||||||
|
Set {
|
||||||
|
items: Vec<RedisSetItem>,
|
||||||
|
total: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
scan_cursor: Option<u64>,
|
||||||
|
},
|
||||||
|
Hash {
|
||||||
|
items: Vec<RedisHashItem>,
|
||||||
|
total: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
scan_cursor: Option<u64>,
|
||||||
|
},
|
||||||
|
Zset {
|
||||||
|
items: Vec<RedisZsetItem>,
|
||||||
|
total: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
scan_cursor: Option<u64>,
|
||||||
|
},
|
||||||
|
Stream {
|
||||||
|
entries: Vec<RedisStreamEntry>,
|
||||||
|
},
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum RedisCollectionPage {
|
||||||
|
List {
|
||||||
|
items: Vec<RedisListItem>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
scan_cursor: Option<u64>,
|
||||||
|
},
|
||||||
|
Set {
|
||||||
|
items: Vec<RedisSetItem>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
scan_cursor: Option<u64>,
|
||||||
|
},
|
||||||
|
Hash {
|
||||||
|
items: Vec<RedisHashItem>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
scan_cursor: Option<u64>,
|
||||||
|
},
|
||||||
|
Zset {
|
||||||
|
items: Vec<RedisZsetItem>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
scan_cursor: Option<u64>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -1701,17 +1810,18 @@ where
|
|||||||
let Ok(value) = get_value(con, &key).await else {
|
let Ok(value) = get_value(con, &key).await else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if !redis_value_matches_query(&value.value, query) {
|
if !redis_value_matches_query(&value, query) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let value_preview = redis_search_value_preview(&value.value);
|
let value_preview = redis_search_value_preview(&value.data);
|
||||||
|
let size = redis_search_value_size(&value);
|
||||||
result.push(RedisKeyInfo {
|
result.push(RedisKeyInfo {
|
||||||
key_display: value.key_display,
|
key_display: value.key_display,
|
||||||
key_raw: value.key_raw,
|
key_raw: value.key_raw,
|
||||||
key_type: value.key_type,
|
key_type: value.redis_type,
|
||||||
ttl: value.ttl,
|
ttl: value.ttl,
|
||||||
size: redis_search_value_size(&value.value, value.total),
|
size,
|
||||||
value_preview,
|
value_preview,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1723,19 +1833,18 @@ pub async fn get_value<C>(con: &mut C, key: &[u8]) -> Result<RedisValue, String>
|
|||||||
where
|
where
|
||||||
C: ConnectionLike + Send + Sync + Unpin,
|
C: ConnectionLike + Send + Sync + Unpin,
|
||||||
{
|
{
|
||||||
let key_type: String = redis::cmd("TYPE").arg(key).query_async(con).await.map_err(|e| e.to_string())?;
|
let redis_type: String = redis::cmd("TYPE").arg(key).query_async(con).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let ttl: i64 = redis::cmd("TTL").arg(key).query_async(con).await.unwrap_or(-1);
|
let ttl: i64 = redis::cmd("TTL").arg(key).query_async(con).await.unwrap_or(-1);
|
||||||
|
|
||||||
let (value, value_is_binary, total, scan_cursor) = match key_type.as_str() {
|
let data = match redis_type.as_str() {
|
||||||
"string" => {
|
"string" => {
|
||||||
let v: RedisRawValue = redis::cmd("GET").arg(key).query_async(con).await.map_err(|e| e.to_string())?;
|
let v: RedisRawValue = redis::cmd("GET").arg(key).query_async(con).await.map_err(|e| e.to_string())?;
|
||||||
let value_is_binary = redis_value_contains_binary(&v);
|
RedisValueData::String {
|
||||||
let value = match v {
|
content: redis_value_to_bytes(v)
|
||||||
RedisRawValue::BulkString(bytes) => json_string_to_js_safe(redis_bytes_to_display(&bytes)),
|
.map(|bytes| redis_blob_from_bytes(&bytes))
|
||||||
other => redis_raw_to_json(other),
|
.ok_or_else(|| "Redis string payload is not byte-addressable".to_string())?,
|
||||||
};
|
}
|
||||||
(value, value_is_binary, None, None)
|
|
||||||
}
|
}
|
||||||
"list" => {
|
"list" => {
|
||||||
let len: u64 = redis::cmd("LLEN").arg(key).query_async(con).await.unwrap_or(0);
|
let len: u64 = redis::cmd("LLEN").arg(key).query_async(con).await.unwrap_or(0);
|
||||||
@@ -1743,53 +1852,47 @@ where
|
|||||||
let v: RedisRawValue =
|
let v: RedisRawValue =
|
||||||
redis::cmd("LRANGE").arg(key).arg(0).arg(end).query_async(con).await.map_err(|e| e.to_string())?;
|
redis::cmd("LRANGE").arg(key).arg(0).arg(end).query_async(con).await.map_err(|e| e.to_string())?;
|
||||||
let cursor = if len > COLLECTION_PAGE_SIZE as u64 { Some(COLLECTION_PAGE_SIZE as u64) } else { None };
|
let cursor = if len > COLLECTION_PAGE_SIZE as u64 { Some(COLLECTION_PAGE_SIZE as u64) } else { None };
|
||||||
(redis_array_to_json(v), false, Some(len), cursor)
|
RedisValueData::List { items: redis_list_items_from_raw(v, 0), total: len, scan_cursor: cursor }
|
||||||
}
|
}
|
||||||
"set" => {
|
"set" => {
|
||||||
let len: u64 = redis::cmd("SCARD").arg(key).query_async(con).await.unwrap_or(0);
|
let len: u64 = redis::cmd("SCARD").arg(key).query_async(con).await.unwrap_or(0);
|
||||||
let (next_cursor, items) = sscan_page_raw(con, key, 0, COLLECTION_PAGE_SIZE).await?;
|
let (cursor, items) = sscan_page_raw(con, key, 0, COLLECTION_PAGE_SIZE).await?;
|
||||||
let cursor = if next_cursor > 0 { Some(next_cursor) } else { None };
|
RedisValueData::Set { items, total: len, scan_cursor: (cursor > 0).then_some(cursor) }
|
||||||
(serde_json::Value::Array(items), false, Some(len), cursor)
|
|
||||||
}
|
}
|
||||||
"zset" => {
|
"zset" => {
|
||||||
let len: u64 = redis::cmd("ZCARD").arg(key).query_async(con).await.unwrap_or(0);
|
let len: u64 = redis::cmd("ZCARD").arg(key).query_async(con).await.unwrap_or(0);
|
||||||
let (next_cursor, items) = zscan_page_raw(con, key, 0, COLLECTION_PAGE_SIZE).await?;
|
let (cursor, items) = zscan_page_raw(con, key, 0, COLLECTION_PAGE_SIZE).await?;
|
||||||
let cursor = if next_cursor > 0 { Some(next_cursor) } else { None };
|
RedisValueData::Zset { items, total: len, scan_cursor: (cursor > 0).then_some(cursor) }
|
||||||
(serde_json::Value::Array(items), false, Some(len), cursor)
|
|
||||||
}
|
}
|
||||||
"hash" => {
|
"hash" => {
|
||||||
let len: u64 = redis::cmd("HLEN").arg(key).query_async(con).await.unwrap_or(0);
|
let len: u64 = redis::cmd("HLEN").arg(key).query_async(con).await.unwrap_or(0);
|
||||||
let (next_cursor, items) = hscan_page_raw(con, key, 0, COLLECTION_PAGE_SIZE, None).await?;
|
let (cursor, items) = hscan_page_raw(con, key, 0, COLLECTION_PAGE_SIZE, None).await?;
|
||||||
let cursor = if next_cursor > 0 { Some(next_cursor) } else { None };
|
RedisValueData::Hash { items, total: len, scan_cursor: (cursor > 0).then_some(cursor) }
|
||||||
(serde_json::Value::Array(items), false, Some(len), cursor)
|
|
||||||
}
|
}
|
||||||
"stream" => (get_stream_entries(con, key).await?, false, None, None),
|
"stream" => RedisValueData::Stream { entries: get_stream_entries(con, key).await? },
|
||||||
key_type if is_redis_json_type(key_type) => {
|
key_type if is_redis_json_type(key_type) => {
|
||||||
let raw: RedisRawValue =
|
let raw: RedisRawValue =
|
||||||
redis::cmd("JSON.GET").arg(key).query_async(con).await.map_err(|e| e.to_string())?;
|
redis::cmd("JSON.GET").arg(key).query_async(con).await.map_err(|e| e.to_string())?;
|
||||||
(redis_json_raw_to_json(raw)?, false, None, None)
|
RedisValueData::Json { value: redis_json_raw_to_json(raw)? }
|
||||||
}
|
}
|
||||||
_ => (serde_json::Value::Null, false, None, None),
|
_ => RedisValueData::Unknown,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(RedisValue {
|
Ok(RedisValue {
|
||||||
key_display: redis_key_bytes_to_display(key),
|
key_display: redis_key_bytes_to_display(key),
|
||||||
key_raw: redis_key_bytes_to_raw(key),
|
key_raw: redis_key_bytes_to_raw(key),
|
||||||
key_type,
|
redis_type,
|
||||||
ttl,
|
ttl,
|
||||||
value_is_binary,
|
data,
|
||||||
value,
|
|
||||||
total,
|
|
||||||
scan_cursor,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redis_value_matches_query(value: &serde_json::Value, query: &str) -> bool {
|
fn redis_value_matches_query(value: &RedisValue, query: &str) -> bool {
|
||||||
let query = query.trim();
|
let query = query.trim();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
redis_search_value_text(value).to_lowercase().contains(&query.to_lowercase())
|
redis_search_value_text(&value.data).to_lowercase().contains(&query.to_lowercase())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redis_key_matches_query(key_display: &str, key_raw: &str, query: &str) -> bool {
|
fn redis_key_matches_query(key_display: &str, key_raw: &str, query: &str) -> bool {
|
||||||
@@ -1801,35 +1904,38 @@ fn redis_key_matches_query(key_display: &str, key_raw: &str, query: &str) -> boo
|
|||||||
key_display.to_lowercase().contains(&query) || key_raw.to_lowercase().contains(&query)
|
key_display.to_lowercase().contains(&query) || key_raw.to_lowercase().contains(&query)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redis_search_value_text(value: &serde_json::Value) -> String {
|
fn redis_search_value_text(value: &RedisValueData) -> String {
|
||||||
match value {
|
match value {
|
||||||
serde_json::Value::String(text) => text.clone(),
|
RedisValueData::String { content } => redis_blob_display_text(content),
|
||||||
serde_json::Value::Array(arr) => {
|
RedisValueData::Json { value } => serde_json::to_string(value).unwrap_or_else(|_| value.to_string()),
|
||||||
// Hash type: [{field: "f1", value: "v1"}, ...] — extract field names and values
|
RedisValueData::List { items, .. } => {
|
||||||
if let Some(first) = arr.first() {
|
items.iter().map(|item| redis_blob_display_text(&item.value)).collect::<Vec<_>>().join(" ")
|
||||||
if first.get("field").is_some() && first.get("value").is_some() {
|
|
||||||
let parts: Vec<String> = arr
|
|
||||||
.iter()
|
|
||||||
.flat_map(|item| {
|
|
||||||
let f = item.get("field").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
let v = item.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
vec![f.to_string(), v.to_string()]
|
|
||||||
})
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect();
|
|
||||||
return parts.join(" ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if arr.is_empty() {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
serde_json::to_string(&arr).unwrap_or_default()
|
|
||||||
}
|
}
|
||||||
other => serde_json::to_string(other).unwrap_or_else(|_| other.to_string()),
|
RedisValueData::Set { items, .. } => {
|
||||||
|
items.iter().map(|item| redis_blob_display_text(&item.member)).collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
RedisValueData::Hash { items, .. } => items
|
||||||
|
.iter()
|
||||||
|
.flat_map(|item| [redis_blob_display_text(&item.field), redis_blob_display_text(&item.value)])
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" "),
|
||||||
|
RedisValueData::Zset { items, .. } => items
|
||||||
|
.iter()
|
||||||
|
.flat_map(|item| [item.score.clone(), redis_blob_display_text(&item.member)])
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" "),
|
||||||
|
RedisValueData::Stream { entries } => entries
|
||||||
|
.iter()
|
||||||
|
.flat_map(|entry| {
|
||||||
|
entry.fields.iter().flat_map(|field| [field.field.clone(), field.value.clone()]).collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" "),
|
||||||
|
RedisValueData::Unknown => String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redis_search_value_preview(value: &serde_json::Value) -> String {
|
fn redis_search_value_preview(value: &RedisValueData) -> String {
|
||||||
const MAX_PREVIEW_LEN: usize = 160;
|
const MAX_PREVIEW_LEN: usize = 160;
|
||||||
let text = redis_search_value_text(value);
|
let text = redis_search_value_text(value);
|
||||||
if text.chars().count() <= MAX_PREVIEW_LEN {
|
if text.chars().count() <= MAX_PREVIEW_LEN {
|
||||||
@@ -1840,17 +1946,23 @@ fn redis_search_value_preview(value: &serde_json::Value) -> String {
|
|||||||
preview
|
preview
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redis_search_value_size(value: &serde_json::Value, total: Option<u64>) -> u64 {
|
fn redis_search_value_size(value: &RedisValue) -> u64 {
|
||||||
if let Some(total) = total {
|
match &value.data {
|
||||||
return total;
|
RedisValueData::String { content } => base64::engine::general_purpose::STANDARD
|
||||||
}
|
.decode(&content.raw_base64)
|
||||||
match value {
|
.map(|bytes| bytes.len() as u64)
|
||||||
serde_json::Value::String(text) => text.len() as u64,
|
.unwrap_or(0),
|
||||||
_ => 0,
|
RedisValueData::Json { value } => serde_json::to_vec(value).map(|bytes| bytes.len() as u64).unwrap_or(0),
|
||||||
|
RedisValueData::List { total, .. }
|
||||||
|
| RedisValueData::Set { total, .. }
|
||||||
|
| RedisValueData::Hash { total, .. }
|
||||||
|
| RedisValueData::Zset { total, .. } => *total,
|
||||||
|
RedisValueData::Stream { entries } => entries.len() as u64,
|
||||||
|
RedisValueData::Unknown => 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_stream_entries<C>(con: &mut C, key: &[u8]) -> Result<serde_json::Value, String>
|
async fn get_stream_entries<C>(con: &mut C, key: &[u8]) -> Result<Vec<RedisStreamEntry>, String>
|
||||||
where
|
where
|
||||||
C: ConnectionLike + Send + Sync + Unpin,
|
C: ConnectionLike + Send + Sync + Unpin,
|
||||||
{
|
{
|
||||||
@@ -1892,16 +2004,14 @@ fn parse_scan_keys(raw: RedisRawValue) -> Result<(u64, Vec<Vec<u8>>), String> {
|
|||||||
Ok((cursor, parsed))
|
Ok((cursor, parsed))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_stream_entries(raw: RedisRawValue) -> serde_json::Value {
|
fn parse_stream_entries(raw: RedisRawValue) -> Vec<RedisStreamEntry> {
|
||||||
match raw {
|
match raw {
|
||||||
RedisRawValue::Array(entries) => {
|
RedisRawValue::Array(entries) => entries.into_iter().filter_map(parse_stream_entry).collect(),
|
||||||
serde_json::Value::Array(entries.into_iter().filter_map(parse_stream_entry).collect())
|
_ => Vec::new(),
|
||||||
}
|
|
||||||
_ => serde_json::Value::Null,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_stream_entry(entry: RedisRawValue) -> Option<serde_json::Value> {
|
fn parse_stream_entry(entry: RedisRawValue) -> Option<RedisStreamEntry> {
|
||||||
let mut parts = match entry {
|
let mut parts = match entry {
|
||||||
RedisRawValue::Array(parts) if parts.len() == 2 => parts.into_iter(),
|
RedisRawValue::Array(parts) if parts.len() == 2 => parts.into_iter(),
|
||||||
_ => return None,
|
_ => return None,
|
||||||
@@ -1913,7 +2023,7 @@ fn parse_stream_entry(entry: RedisRawValue) -> Option<serde_json::Value> {
|
|||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut field_map = serde_json::Map::new();
|
let mut parsed_fields = Vec::new();
|
||||||
let mut fields = fields.into_iter();
|
let mut fields = fields.into_iter();
|
||||||
while let Some(field) = fields.next() {
|
while let Some(field) = fields.next() {
|
||||||
let Some(value) = fields.next() else {
|
let Some(value) = fields.next() else {
|
||||||
@@ -1921,14 +2031,11 @@ fn parse_stream_entry(entry: RedisRawValue) -> Option<serde_json::Value> {
|
|||||||
};
|
};
|
||||||
if let Some(field_name) = redis_value_to_string(field) {
|
if let Some(field_name) = redis_value_to_string(field) {
|
||||||
let value = redis_value_to_string(value).unwrap_or_default();
|
let value = redis_value_to_string(value).unwrap_or_default();
|
||||||
field_map.insert(field_name, json_string_to_js_safe(value));
|
parsed_fields.push(RedisStreamField { field: field_name, value });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(serde_json::json!({
|
Some(RedisStreamEntry { id, fields: parsed_fields })
|
||||||
"id": id,
|
|
||||||
"fields": field_map,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redis_value_to_string(value: RedisRawValue) -> Option<String> {
|
fn redis_value_to_string(value: RedisRawValue) -> Option<String> {
|
||||||
@@ -1944,14 +2051,6 @@ fn redis_value_to_string(value: RedisRawValue) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redis_value_contains_binary(value: &RedisRawValue) -> bool {
|
|
||||||
match value {
|
|
||||||
RedisRawValue::BulkString(bytes) => std::str::from_utf8(bytes).is_err(),
|
|
||||||
RedisRawValue::VerbatimString { text, .. } => std::str::from_utf8(text.as_bytes()).is_err(),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn redis_value_to_bytes(value: RedisRawValue) -> Option<Vec<u8>> {
|
fn redis_value_to_bytes(value: RedisRawValue) -> Option<Vec<u8>> {
|
||||||
match value {
|
match value {
|
||||||
RedisRawValue::BulkString(bytes) => Some(bytes),
|
RedisRawValue::BulkString(bytes) => Some(bytes),
|
||||||
@@ -1965,18 +2064,36 @@ fn redis_value_to_bytes(value: RedisRawValue) -> Option<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redis_array_to_json(value: RedisRawValue) -> serde_json::Value {
|
fn redis_blob_from_bytes(bytes: &[u8]) -> RedisBlob {
|
||||||
|
RedisBlob {
|
||||||
|
raw_base64: base64::engine::general_purpose::STANDARD.encode(bytes),
|
||||||
|
encoding: if std::str::from_utf8(bytes).is_ok() { RedisBlobEncoding::Utf8 } else { RedisBlobEncoding::Binary },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redis_blob_display_text(blob: &RedisBlob) -> String {
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD.decode(&blob.raw_base64).unwrap_or_default();
|
||||||
|
if matches!(blob.encoding, RedisBlobEncoding::Utf8) {
|
||||||
|
if let Ok(text) = std::str::from_utf8(&bytes) {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
redis_bytes_to_display(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redis_list_items_from_raw(value: RedisRawValue, start_index: u64) -> Vec<RedisListItem> {
|
||||||
match value {
|
match value {
|
||||||
RedisRawValue::Array(values) => serde_json::Value::Array(
|
RedisRawValue::Array(values) => values
|
||||||
values
|
.into_iter()
|
||||||
.into_iter()
|
.enumerate()
|
||||||
.map(|v| match v {
|
.filter_map(|(offset, value)| {
|
||||||
RedisRawValue::BulkString(bytes) => json_string_to_js_safe(redis_bytes_to_display(&bytes)),
|
redis_value_to_bytes(value).map(|bytes| RedisListItem {
|
||||||
other => redis_raw_to_json(other),
|
index: start_index + offset as u64,
|
||||||
|
value: redis_blob_from_bytes(&bytes),
|
||||||
})
|
})
|
||||||
.collect(),
|
})
|
||||||
),
|
.collect(),
|
||||||
other => redis_raw_to_json(other),
|
_ => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1988,21 +2105,6 @@ fn redis_raw_to_json(value: RedisRawValue) -> serde_json::Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If the text is JSON (starts with `{` or `[`), parse it, apply
|
|
||||||
/// `json_value_for_js` (converting large ints to strings), then re-serialize
|
|
||||||
/// back to a compact JSON string. Otherwise return the text unchanged.
|
|
||||||
fn json_string_to_js_safe(text: String) -> serde_json::Value {
|
|
||||||
let trimmed = text.trim();
|
|
||||||
if trimmed.starts_with('{') || trimmed.starts_with('[') {
|
|
||||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
|
||||||
let safe = json_value_for_js(json);
|
|
||||||
let re_serialized = serde_json::to_string(&safe).unwrap_or(text);
|
|
||||||
return serde_json::Value::String(re_serialized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
serde_json::Value::String(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn redis_bytes_to_display(bytes: &[u8]) -> String {
|
fn redis_bytes_to_display(bytes: &[u8]) -> String {
|
||||||
if let Ok(text) = std::str::from_utf8(bytes) {
|
if let Ok(text) = std::str::from_utf8(bytes) {
|
||||||
return text.replace('\\', "\\\\");
|
return text.replace('\\', "\\\\");
|
||||||
@@ -2206,11 +2308,11 @@ pub async fn load_more_collection<C>(
|
|||||||
cursor: u64,
|
cursor: u64,
|
||||||
count: usize,
|
count: usize,
|
||||||
filter_query: Option<&str>,
|
filter_query: Option<&str>,
|
||||||
) -> Result<RedisValue, String>
|
) -> Result<RedisCollectionPage, String>
|
||||||
where
|
where
|
||||||
C: ConnectionLike + Send + Sync + Unpin,
|
C: ConnectionLike + Send + Sync + Unpin,
|
||||||
{
|
{
|
||||||
let (value, next_cursor) = match key_type {
|
match key_type {
|
||||||
"list" => {
|
"list" => {
|
||||||
let start = cursor as i64;
|
let start = cursor as i64;
|
||||||
let end = start + count as i64 - 1;
|
let end = start + count as i64 - 1;
|
||||||
@@ -2218,41 +2320,29 @@ where
|
|||||||
redis::cmd("LRANGE").arg(key).arg(start).arg(end).query_async(con).await.map_err(|e| e.to_string())?;
|
redis::cmd("LRANGE").arg(key).arg(start).arg(end).query_async(con).await.map_err(|e| e.to_string())?;
|
||||||
let len: u64 = redis::cmd("LLEN").arg(key).query_async(con).await.unwrap_or(0);
|
let len: u64 = redis::cmd("LLEN").arg(key).query_async(con).await.unwrap_or(0);
|
||||||
let next = cursor + count as u64;
|
let next = cursor + count as u64;
|
||||||
let cursor = if next < len { Some(next) } else { None };
|
Ok(RedisCollectionPage::List {
|
||||||
(redis_array_to_json(v), cursor)
|
items: redis_list_items_from_raw(v, cursor),
|
||||||
|
scan_cursor: (next < len).then_some(next),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
"set" => {
|
"set" => {
|
||||||
let (next, items) = sscan_page_raw(con, key, cursor, count).await?;
|
let (next_cursor, items) = sscan_page_raw(con, key, cursor, count).await?;
|
||||||
let cursor = if next > 0 { Some(next) } else { None };
|
Ok(RedisCollectionPage::Set { items, scan_cursor: (next_cursor > 0).then_some(next_cursor) })
|
||||||
(serde_json::Value::Array(items), cursor)
|
|
||||||
}
|
}
|
||||||
"zset" => {
|
"zset" => {
|
||||||
let (next, items) = zscan_page_raw(con, key, cursor, count).await?;
|
let (next_cursor, items) = zscan_page_raw(con, key, cursor, count).await?;
|
||||||
let cursor = if next > 0 { Some(next) } else { None };
|
Ok(RedisCollectionPage::Zset { items, scan_cursor: (next_cursor > 0).then_some(next_cursor) })
|
||||||
(serde_json::Value::Array(items), cursor)
|
|
||||||
}
|
}
|
||||||
"hash" => {
|
"hash" => {
|
||||||
let (next, items) = if let Some(query) = filter_query.filter(|query| !query.is_empty()) {
|
let (next_cursor, items) = if let Some(query) = filter_query.filter(|query| !query.is_empty()) {
|
||||||
hscan_filtered_page_raw(con, key, cursor, count, query).await?
|
hscan_filtered_page_raw(con, key, cursor, count, query).await?
|
||||||
} else {
|
} else {
|
||||||
hscan_page_raw(con, key, cursor, count, None).await?
|
hscan_page_raw(con, key, cursor, count, None).await?
|
||||||
};
|
};
|
||||||
let cursor = if next > 0 { Some(next) } else { None };
|
Ok(RedisCollectionPage::Hash { items, scan_cursor: (next_cursor > 0).then_some(next_cursor) })
|
||||||
(serde_json::Value::Array(items), cursor)
|
|
||||||
}
|
}
|
||||||
_ => return Err(format!("Pagination not supported for type: {key_type}")),
|
_ => Err(format!("Pagination not supported for type: {key_type}")),
|
||||||
};
|
}
|
||||||
|
|
||||||
Ok(RedisValue {
|
|
||||||
key_display: redis_key_bytes_to_display(key),
|
|
||||||
key_raw: redis_key_bytes_to_raw(key),
|
|
||||||
key_type: key_type.to_string(),
|
|
||||||
ttl: -1,
|
|
||||||
value_is_binary: false,
|
|
||||||
value,
|
|
||||||
total: None,
|
|
||||||
scan_cursor: next_cursor,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn hscan_page_raw<C>(
|
async fn hscan_page_raw<C>(
|
||||||
@@ -2261,7 +2351,7 @@ async fn hscan_page_raw<C>(
|
|||||||
cursor: u64,
|
cursor: u64,
|
||||||
count: usize,
|
count: usize,
|
||||||
match_pattern: Option<&str>,
|
match_pattern: Option<&str>,
|
||||||
) -> Result<(u64, Vec<serde_json::Value>), String>
|
) -> Result<(u64, Vec<RedisHashItem>), String>
|
||||||
where
|
where
|
||||||
C: ConnectionLike + Send + Sync + Unpin,
|
C: ConnectionLike + Send + Sync + Unpin,
|
||||||
{
|
{
|
||||||
@@ -2271,7 +2361,7 @@ where
|
|||||||
cmd.arg("MATCH").arg(pattern);
|
cmd.arg("MATCH").arg(pattern);
|
||||||
}
|
}
|
||||||
let raw: RedisRawValue = cmd.query_async(con).await.map_err(|e| e.to_string())?;
|
let raw: RedisRawValue = cmd.query_async(con).await.map_err(|e| e.to_string())?;
|
||||||
parse_scan_pairs(raw, "hash")
|
parse_scan_hash_entries(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn hscan_filtered_page_raw<C>(
|
async fn hscan_filtered_page_raw<C>(
|
||||||
@@ -2280,7 +2370,7 @@ async fn hscan_filtered_page_raw<C>(
|
|||||||
cursor: u64,
|
cursor: u64,
|
||||||
count: usize,
|
count: usize,
|
||||||
query: &str,
|
query: &str,
|
||||||
) -> Result<(u64, Vec<serde_json::Value>), String>
|
) -> Result<(u64, Vec<RedisHashItem>), String>
|
||||||
where
|
where
|
||||||
C: ConnectionLike + Send + Sync + Unpin,
|
C: ConnectionLike + Send + Sync + Unpin,
|
||||||
{
|
{
|
||||||
@@ -2302,13 +2392,13 @@ where
|
|||||||
Ok((cur, items))
|
Ok((cur, items))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_entry_matches_query(item: &serde_json::Value, query: &str) -> bool {
|
fn hash_entry_matches_query(item: &RedisHashItem, query: &str) -> bool {
|
||||||
let query = query.to_lowercase();
|
let query = query.to_lowercase();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let field = item.get("field").and_then(serde_json::Value::as_str).unwrap_or_default();
|
let field = redis_blob_display_text(&item.field);
|
||||||
let value = item.get("value").and_then(serde_json::Value::as_str).unwrap_or_default();
|
let value = redis_blob_display_text(&item.value);
|
||||||
field.to_lowercase().contains(&query) || value.to_lowercase().contains(&query)
|
field.to_lowercase().contains(&query) || value.to_lowercase().contains(&query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2317,7 +2407,7 @@ async fn sscan_page_raw<C>(
|
|||||||
key: &[u8],
|
key: &[u8],
|
||||||
cursor: u64,
|
cursor: u64,
|
||||||
count: usize,
|
count: usize,
|
||||||
) -> Result<(u64, Vec<serde_json::Value>), String>
|
) -> Result<(u64, Vec<RedisSetItem>), String>
|
||||||
where
|
where
|
||||||
C: ConnectionLike + Send + Sync + Unpin,
|
C: ConnectionLike + Send + Sync + Unpin,
|
||||||
{
|
{
|
||||||
@@ -2337,7 +2427,7 @@ async fn zscan_page_raw<C>(
|
|||||||
key: &[u8],
|
key: &[u8],
|
||||||
cursor: u64,
|
cursor: u64,
|
||||||
count: usize,
|
count: usize,
|
||||||
) -> Result<(u64, Vec<serde_json::Value>), String>
|
) -> Result<(u64, Vec<RedisZsetItem>), String>
|
||||||
where
|
where
|
||||||
C: ConnectionLike + Send + Sync + Unpin,
|
C: ConnectionLike + Send + Sync + Unpin,
|
||||||
{
|
{
|
||||||
@@ -2349,10 +2439,11 @@ where
|
|||||||
.query_async(con)
|
.query_async(con)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
parse_scan_pairs(raw, "zset")
|
let (next_cursor, items) = parse_scan_pairs(raw)?;
|
||||||
|
Ok((next_cursor, items.into_iter().map(|(member, score)| RedisZsetItem { score, member }).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_scan_pairs(raw: RedisRawValue, kind: &str) -> Result<(u64, Vec<serde_json::Value>), String> {
|
fn parse_scan_pairs(raw: RedisRawValue) -> Result<(u64, Vec<(RedisBlob, String)>), String> {
|
||||||
let RedisRawValue::Array(parts) = raw else {
|
let RedisRawValue::Array(parts) = raw else {
|
||||||
return Err("Invalid SCAN response".to_string());
|
return Err("Invalid SCAN response".to_string());
|
||||||
};
|
};
|
||||||
@@ -2373,19 +2464,17 @@ fn parse_scan_pairs(raw: RedisRawValue, kind: &str) -> Result<(u64, Vec<serde_js
|
|||||||
let mut iter = entries.iter();
|
let mut iter = entries.iter();
|
||||||
while let Some(a) = iter.next() {
|
while let Some(a) = iter.next() {
|
||||||
let Some(b) = iter.next() else { break };
|
let Some(b) = iter.next() else { break };
|
||||||
let a_str = redis_value_to_string(a.clone()).unwrap_or_default();
|
let member = redis_value_to_bytes(a.clone())
|
||||||
let b = redis_value_to_string(b.clone()).unwrap_or_default();
|
.map(|bytes| redis_blob_from_bytes(&bytes))
|
||||||
if kind == "zset" {
|
.ok_or_else(|| "Invalid SCAN member payload".to_string())?;
|
||||||
items.push(serde_json::json!({"member": a_str, "score": b}));
|
let value = redis_value_to_string(b.clone()).unwrap_or_default();
|
||||||
} else {
|
items.push((member, value));
|
||||||
items.push(serde_json::json!({"field": a_str, "value": json_string_to_js_safe(b)}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((cursor, items))
|
Ok((cursor, items))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_scan_members(raw: RedisRawValue) -> Result<(u64, Vec<serde_json::Value>), String> {
|
fn parse_scan_hash_entries(raw: RedisRawValue) -> Result<(u64, Vec<RedisHashItem>), String> {
|
||||||
let RedisRawValue::Array(parts) = raw else {
|
let RedisRawValue::Array(parts) = raw else {
|
||||||
return Err("Invalid SCAN response".to_string());
|
return Err("Invalid SCAN response".to_string());
|
||||||
};
|
};
|
||||||
@@ -2402,7 +2491,45 @@ fn parse_scan_members(raw: RedisRawValue) -> Result<(u64, Vec<serde_json::Value>
|
|||||||
return Err("Invalid SCAN entries".to_string());
|
return Err("Invalid SCAN entries".to_string());
|
||||||
};
|
};
|
||||||
|
|
||||||
let items = entries.iter().filter_map(|v| redis_value_to_string(v.clone())).map(json_string_to_js_safe).collect();
|
let mut items = Vec::new();
|
||||||
|
let mut iter = entries.iter();
|
||||||
|
while let Some(field) = iter.next() {
|
||||||
|
let Some(value) = iter.next() else { break };
|
||||||
|
let field = redis_value_to_bytes(field.clone())
|
||||||
|
.map(|bytes| redis_blob_from_bytes(&bytes))
|
||||||
|
.ok_or_else(|| "Invalid hash field payload".to_string())?;
|
||||||
|
let value = redis_value_to_bytes(value.clone())
|
||||||
|
.map(|bytes| redis_blob_from_bytes(&bytes))
|
||||||
|
.ok_or_else(|| "Invalid hash value payload".to_string())?;
|
||||||
|
items.push(RedisHashItem { field, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((cursor, items))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_scan_members(raw: RedisRawValue) -> Result<(u64, Vec<RedisSetItem>), String> {
|
||||||
|
let RedisRawValue::Array(parts) = raw else {
|
||||||
|
return Err("Invalid SCAN response".to_string());
|
||||||
|
};
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return Err("Invalid SCAN response".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = redis_value_to_string(parts[0].clone())
|
||||||
|
.ok_or("Invalid cursor")?
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|_| "Invalid cursor".to_string())?;
|
||||||
|
|
||||||
|
let RedisRawValue::Array(entries) = &parts[1] else {
|
||||||
|
return Err("Invalid SCAN entries".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
let items = entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|value| {
|
||||||
|
redis_value_to_bytes(value.clone()).map(|bytes| RedisSetItem { member: redis_blob_from_bytes(&bytes) })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok((cursor, items))
|
Ok((cursor, items))
|
||||||
}
|
}
|
||||||
@@ -2414,11 +2541,13 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
classify_command, connection_info, decode_cluster_cursor, encode_cluster_cursor, is_redis_json_type,
|
classify_command, connection_info, decode_cluster_cursor, encode_cluster_cursor, is_redis_json_type,
|
||||||
parse_cluster_slots, parse_command_argv, parse_database_count, parse_redis_endpoint, parse_scan_keys,
|
parse_cluster_slots, parse_command_argv, parse_database_count, parse_redis_endpoint, parse_scan_keys,
|
||||||
parse_stream_entries, redis_auth_candidates, redis_cluster_slot, redis_command_raw_to_json,
|
parse_stream_entries, redis_auth_candidates, redis_blob_from_bytes, redis_cluster_slot,
|
||||||
redis_database_index, redis_json_raw_to_json, redis_json_value_preview, redis_key_bytes_to_display,
|
redis_command_raw_to_json, redis_database_index, redis_json_raw_to_json, redis_json_value_preview,
|
||||||
redis_key_bytes_to_raw, redis_key_matches_query, redis_key_raw_to_bytes, redis_key_value_preview,
|
redis_key_bytes_to_display, redis_key_bytes_to_raw, redis_key_matches_query, redis_key_raw_to_bytes,
|
||||||
redis_raw_to_json, redis_sentinel_master_endpoint, redis_value_contains_binary, redis_value_matches_query,
|
redis_key_value_preview, redis_raw_to_json, redis_sentinel_master_endpoint, redis_value_matches_query,
|
||||||
RedisAuthCandidate, RedisClusterSlotRange, RedisCommandSafety, RedisNodeEndpoint, RedisRawValue,
|
redis_value_to_bytes, RedisAuthCandidate, RedisBlob, RedisBlobEncoding, RedisClusterSlotRange,
|
||||||
|
RedisCollectionPage, RedisCommandSafety, RedisHashItem, RedisNodeEndpoint, RedisRawValue, RedisSetItem,
|
||||||
|
RedisStreamEntry, RedisStreamField, RedisValue, RedisValueData,
|
||||||
};
|
};
|
||||||
use crate::models::connection::ConnectionConfig;
|
use crate::models::connection::ConnectionConfig;
|
||||||
use redis::{aio::ConnectionLike, Cmd, ConnectionAddr, Pipeline, RedisFuture};
|
use redis::{aio::ConnectionLike, Cmd, ConnectionAddr, Pipeline, RedisFuture};
|
||||||
@@ -2478,6 +2607,49 @@ mod tests {
|
|||||||
RedisRawValue::Array(vec![bulk(cursor), RedisRawValue::Array(entries)])
|
RedisRawValue::Array(vec![bulk(cursor), RedisRawValue::Array(entries)])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn text_blob(value: &str) -> RedisBlob {
|
||||||
|
redis_blob_from_bytes(value.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redis_value(redis_type: &str, data: RedisValueData) -> RedisValue {
|
||||||
|
RedisValue {
|
||||||
|
key_display: "test:key".to_string(),
|
||||||
|
key_raw: redis_key_bytes_to_raw(b"test:key"),
|
||||||
|
ttl: -1,
|
||||||
|
redis_type: redis_type.to_string(),
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_value(value: &str) -> RedisValue {
|
||||||
|
redis_value("string", RedisValueData::String { content: text_blob(value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_value(entries: &[(&str, &str)]) -> RedisValue {
|
||||||
|
redis_value(
|
||||||
|
"hash",
|
||||||
|
RedisValueData::Hash {
|
||||||
|
items: entries
|
||||||
|
.iter()
|
||||||
|
.map(|(field, value)| RedisHashItem { field: text_blob(field), value: text_blob(value) })
|
||||||
|
.collect(),
|
||||||
|
total: entries.len() as u64,
|
||||||
|
scan_cursor: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_value(entries: &[&str]) -> RedisValue {
|
||||||
|
redis_value(
|
||||||
|
"set",
|
||||||
|
RedisValueData::Set {
|
||||||
|
items: entries.iter().map(|value| RedisSetItem { member: text_blob(value) }).collect(),
|
||||||
|
total: entries.len() as u64,
|
||||||
|
scan_cursor: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_stream_entries() {
|
fn parses_stream_entries() {
|
||||||
let raw = RedisRawValue::Array(vec![RedisRawValue::Array(vec![
|
let raw = RedisRawValue::Array(vec![RedisRawValue::Array(vec![
|
||||||
@@ -2489,15 +2661,13 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed,
|
parsed,
|
||||||
serde_json::json!([
|
vec![RedisStreamEntry {
|
||||||
{
|
id: "1714470000000-0".to_string(),
|
||||||
"id": "1714470000000-0",
|
fields: vec![
|
||||||
"fields": {
|
RedisStreamField { field: "event".to_string(), value: "login".to_string() },
|
||||||
"event": "login",
|
RedisStreamField { field: "user_id".to_string(), value: "42".to_string() },
|
||||||
"user_id": "42"
|
],
|
||||||
}
|
}]
|
||||||
}
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2515,14 +2685,10 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed,
|
parsed,
|
||||||
serde_json::json!([
|
vec![RedisStreamEntry {
|
||||||
{
|
id: "1714470000001-0".to_string(),
|
||||||
"id": "1714470000001-0",
|
fields: vec![RedisStreamField { field: "event".to_string(), value: "logout".to_string() }],
|
||||||
"fields": {
|
}]
|
||||||
"event": "logout"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2617,8 +2783,11 @@ mod tests {
|
|||||||
|
|
||||||
let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 1, Some("user")).await.unwrap();
|
let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 1, Some("user")).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(result.scan_cursor, Some(512));
|
let RedisCollectionPage::Hash { items, scan_cursor } = result else {
|
||||||
assert_eq!(result.value, serde_json::json!([{ "field": "user:1", "value": "Ada" }]));
|
panic!("expected hash collection page");
|
||||||
|
};
|
||||||
|
assert_eq!(scan_cursor, Some(512));
|
||||||
|
assert_eq!(items, vec![RedisHashItem { field: text_blob("user:1"), value: text_blob("Ada") }]);
|
||||||
assert_eq!(con.command_count("HSCAN"), 1);
|
assert_eq!(con.command_count("HSCAN"), 1);
|
||||||
assert!(!con.commands[0].contains("\r\nMATCH\r\n"));
|
assert!(!con.commands[0].contains("\r\nMATCH\r\n"));
|
||||||
}
|
}
|
||||||
@@ -2630,8 +2799,11 @@ mod tests {
|
|||||||
|
|
||||||
let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("lovelace")).await.unwrap();
|
let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("lovelace")).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(result.scan_cursor, None);
|
let RedisCollectionPage::Hash { items, scan_cursor } = result else {
|
||||||
assert_eq!(result.value, serde_json::json!([{ "field": "status", "value": "Ada Lovelace" }]));
|
panic!("expected hash collection page");
|
||||||
|
};
|
||||||
|
assert_eq!(scan_cursor, None);
|
||||||
|
assert_eq!(items, vec![RedisHashItem { field: text_blob("status"), value: text_blob("Ada Lovelace") }]);
|
||||||
assert_eq!(con.command_count("HSCAN"), 1);
|
assert_eq!(con.command_count("HSCAN"), 1);
|
||||||
assert!(!con.commands[0].contains("\r\nMATCH\r\n"));
|
assert!(!con.commands[0].contains("\r\nMATCH\r\n"));
|
||||||
}
|
}
|
||||||
@@ -2645,8 +2817,11 @@ mod tests {
|
|||||||
|
|
||||||
let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("missing")).await.unwrap();
|
let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("missing")).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(result.scan_cursor, Some(super::HASH_FILTER_SCAN_MAX_ITERATIONS as u64));
|
let RedisCollectionPage::Hash { items, scan_cursor } = result else {
|
||||||
assert_eq!(result.value, serde_json::json!([]));
|
panic!("expected hash collection page");
|
||||||
|
};
|
||||||
|
assert_eq!(scan_cursor, Some(super::HASH_FILTER_SCAN_MAX_ITERATIONS as u64));
|
||||||
|
assert!(items.is_empty());
|
||||||
assert_eq!(con.command_count("HSCAN"), super::HASH_FILTER_SCAN_MAX_ITERATIONS);
|
assert_eq!(con.command_count("HSCAN"), super::HASH_FILTER_SCAN_MAX_ITERATIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2663,7 +2838,16 @@ mod tests {
|
|||||||
fn does_not_treat_utf8_with_backslashes_as_binary() {
|
fn does_not_treat_utf8_with_backslashes_as_binary() {
|
||||||
let raw = RedisRawValue::BulkString(br#"C:\Users\path"#.to_vec());
|
let raw = RedisRawValue::BulkString(br#"C:\Users\path"#.to_vec());
|
||||||
|
|
||||||
assert!(!redis_value_contains_binary(&raw));
|
let blob = redis_value_to_bytes(raw).map(|bytes| redis_blob_from_bytes(&bytes)).unwrap();
|
||||||
|
assert_eq!(blob.encoding, RedisBlobEncoding::Utf8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_non_ascii_utf8_as_utf8() {
|
||||||
|
let raw = RedisRawValue::BulkString("你好,redis".as_bytes().to_vec());
|
||||||
|
|
||||||
|
let blob = redis_value_to_bytes(raw).map(|bytes| redis_blob_from_bytes(&bytes)).unwrap();
|
||||||
|
assert_eq!(blob.encoding, RedisBlobEncoding::Utf8);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2680,10 +2864,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn matches_redis_values_case_insensitively() {
|
fn matches_redis_values_case_insensitively() {
|
||||||
assert!(redis_value_matches_query(&serde_json::json!("Hello Redis"), "redis"));
|
assert!(redis_value_matches_query(&string_value("Hello Redis"), "redis"));
|
||||||
assert!(redis_value_matches_query(&serde_json::json!({"field": "Ada Lovelace"}), "lovelace"));
|
assert!(redis_value_matches_query(&hash_value(&[("field", "Ada Lovelace")]), "lovelace"));
|
||||||
assert!(!redis_value_matches_query(&serde_json::json!("Hello Redis"), ""));
|
assert!(!redis_value_matches_query(&string_value("Hello Redis"), ""));
|
||||||
assert!(!redis_value_matches_query(&serde_json::json!("Hello Redis"), "mysql"));
|
assert!(!redis_value_matches_query(&string_value("Hello Redis"), "mysql"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2696,33 +2880,27 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn matches_hash_field_name_in_value_search() {
|
fn matches_hash_field_name_in_value_search() {
|
||||||
let hash_value = serde_json::json!([
|
let hash_value = hash_value(&[("name", "Alice"), ("email", "alice@example.com")]);
|
||||||
{"field": "name", "value": "Alice"},
|
|
||||||
{"field": "email", "value": "alice@example.com"},
|
|
||||||
]);
|
|
||||||
assert!(redis_value_matches_query(&hash_value, "name"));
|
assert!(redis_value_matches_query(&hash_value, "name"));
|
||||||
assert!(redis_value_matches_query(&hash_value, "email"));
|
assert!(redis_value_matches_query(&hash_value, "email"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn matches_hash_field_value_in_value_search() {
|
fn matches_hash_field_value_in_value_search() {
|
||||||
let hash_value = serde_json::json!([
|
let hash_value = hash_value(&[("name", "Alice"), ("email", "alice@example.com")]);
|
||||||
{"field": "name", "value": "Alice"},
|
|
||||||
{"field": "email", "value": "alice@example.com"},
|
|
||||||
]);
|
|
||||||
assert!(redis_value_matches_query(&hash_value, "alice"));
|
assert!(redis_value_matches_query(&hash_value, "alice"));
|
||||||
assert!(redis_value_matches_query(&hash_value, "example"));
|
assert!(redis_value_matches_query(&hash_value, "example"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_hash_does_not_match() {
|
fn empty_hash_does_not_match() {
|
||||||
let empty_hash = serde_json::json!([]);
|
let empty_hash = hash_value(&[]);
|
||||||
assert!(!redis_value_matches_query(&empty_hash, "anything"));
|
assert!(!redis_value_matches_query(&empty_hash, "anything"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_hash_array_unaffected() {
|
fn non_hash_array_unaffected() {
|
||||||
let set_value = serde_json::json!(["member1", "member2", "hello"]);
|
let set_value = set_value(&["member1", "member2", "hello"]);
|
||||||
assert!(redis_value_matches_query(&set_value, "member1"));
|
assert!(redis_value_matches_query(&set_value, "member1"));
|
||||||
assert!(redis_value_matches_query(&set_value, "hello"));
|
assert!(redis_value_matches_query(&set_value, "hello"));
|
||||||
assert!(!redis_value_matches_query(&set_value, "nonexistent"));
|
assert!(!redis_value_matches_query(&set_value, "nonexistent"));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::connection::{AppState, PoolKind};
|
use crate::connection::{AppState, PoolKind};
|
||||||
use crate::db::redis_driver::{
|
use crate::db::redis_driver::{
|
||||||
self, RedisCommandResult, RedisConnection, RedisDatabaseInfo, RedisKeyInfo, RedisScanResult, RedisValue,
|
self, RedisCollectionPage, RedisCommandResult, RedisConnection, RedisDatabaseInfo, RedisKeyInfo, RedisScanResult,
|
||||||
|
RedisValue,
|
||||||
};
|
};
|
||||||
|
|
||||||
async fn ensure_redis_pool(state: &AppState, connection_id: &str) -> Result<(), String> {
|
async fn ensure_redis_pool(state: &AppState, connection_id: &str) -> Result<(), String> {
|
||||||
@@ -790,7 +791,7 @@ pub async fn redis_load_more_in_db_core(
|
|||||||
cursor: u64,
|
cursor: u64,
|
||||||
count: usize,
|
count: usize,
|
||||||
filter: Option<&str>,
|
filter: Option<&str>,
|
||||||
) -> Result<redis_driver::RedisValue, String> {
|
) -> Result<RedisCollectionPage, String> {
|
||||||
ensure_redis_pool(state, connection_id).await?;
|
ensure_redis_pool(state, connection_id).await?;
|
||||||
let connections = state.connections.read().await;
|
let connections = state.connections.read().await;
|
||||||
match connections.get(connection_id).ok_or("Not found")? {
|
match connections.get(connection_id).ok_or("Not found")? {
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ impl DriverProductCapabilities {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct DriverProfileEntry {
|
struct DriverProfileEntry {
|
||||||
profile: String,
|
profile: String,
|
||||||
label: String,
|
|
||||||
agent_key: String,
|
agent_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,42 @@
|
|||||||
import { strict as assert } from "node:assert";
|
import { strict as assert } from "node:assert";
|
||||||
import { test } from "vitest";
|
import { test } from "vitest";
|
||||||
import { canEditRedisMemberDetail, clampRedisMemberDetailSheetWidth, formatRedisCommandResult, formatRedisMemberDetail, formatRedisStringValue, getRedisMemberSelectionKey, highlightRedisJsonDetail, parseRedisJsonDetail } from "../../apps/desktop/src/lib/redis/redisValuePresentation.ts";
|
import {
|
||||||
|
canRenderRedisValueFormat,
|
||||||
|
canEditRedisMemberDetail,
|
||||||
|
clampRedisMemberDetailSheetWidth,
|
||||||
|
formatRedisCommandResult,
|
||||||
|
formatRedisMemberDetail,
|
||||||
|
formatRedisStringValue,
|
||||||
|
getRedisMemberSelectionKey,
|
||||||
|
highlightRedisJsonDetail,
|
||||||
|
parseRedisJsonDetail,
|
||||||
|
preferredRedisValueFormat,
|
||||||
|
redisMemberCopyText,
|
||||||
|
redisValueCopyText,
|
||||||
|
} from "../../apps/desktop/src/lib/redis/redisValuePresentation.ts";
|
||||||
|
|
||||||
test("formats JSON object strings for Redis member details", () => {
|
function blobFromText(value: string) {
|
||||||
|
return {
|
||||||
|
raw_base64: Buffer.from(value, "utf8").toString("base64"),
|
||||||
|
encoding: "utf8" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("keeps JSON-like strings as raw text in Redis member details", () => {
|
||||||
const detail = formatRedisMemberDetail('{"id":1,"name":"Ada","tags":["dbx","redis"]}');
|
const detail = formatRedisMemberDetail('{"id":1,"name":"Ada","tags":["dbx","redis"]}');
|
||||||
|
|
||||||
assert.equal(detail.format, "json");
|
assert.equal(detail.format, "text");
|
||||||
assert.equal(detail.rawText, '{"id":1,"name":"Ada","tags":["dbx","redis"]}');
|
assert.equal(detail.rawText, '{"id":1,"name":"Ada","tags":["dbx","redis"]}');
|
||||||
assert.equal(detail.text, '{\n "id": 1,\n "name": "Ada",\n "tags": [\n "dbx",\n "redis"\n ]\n}');
|
assert.equal(detail.text, '{"id":1,"name":"Ada","tags":["dbx","redis"]}');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps plain Redis member strings unchanged", () => {
|
test("keeps plain Redis member strings unchanged", () => {
|
||||||
const detail = formatRedisMemberDetail("plain long member value");
|
const detail = formatRedisMemberDetail("plain long member value");
|
||||||
|
|
||||||
assert.equal(detail.format, "text");
|
assert.equal(detail.format, "text");
|
||||||
|
assert.equal(detail.rawLabel, "ASCII");
|
||||||
assert.equal(detail.text, "plain long member value");
|
assert.equal(detail.text, "plain long member value");
|
||||||
|
assert.deepEqual(detail.availableFormats, ["utf8", "ascii", "binary", "hex", "base64"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("formats JSON string values without changing plain strings", () => {
|
test("formats JSON string values without changing plain strings", () => {
|
||||||
@@ -22,18 +44,125 @@ test("formats JSON string values without changing plain strings", () => {
|
|||||||
assert.equal(formatRedisStringValue("plain redis value"), "plain redis value");
|
assert.equal(formatRedisStringValue("plain redis value"), "plain redis value");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses Redis JSON details only for object and array containers", () => {
|
test("parses Redis JSON details for any valid JSON payload", () => {
|
||||||
const objectDetail = parseRedisJsonDetail('{"id":1,"name":"Ada"}');
|
const objectDetail = parseRedisJsonDetail('{"id":1,"name":"Ada"}');
|
||||||
assert.equal(objectDetail?.rawText, '{"id":1,"name":"Ada"}');
|
assert.equal(objectDetail?.rawText, '{"id":1,"name":"Ada"}');
|
||||||
assert.equal(objectDetail?.formattedText, '{\n "id": 1,\n "name": "Ada"\n}');
|
assert.equal(objectDetail?.formattedText, '{\n "id": 1,\n "name": "Ada"\n}');
|
||||||
assert.deepEqual(objectDetail?.value, { id: 1, name: "Ada" });
|
assert.deepEqual(objectDetail?.value, { id: 1, name: "Ada" });
|
||||||
|
|
||||||
assert.equal(parseRedisJsonDetail("[1,2]")?.formattedText, "[\n 1,\n 2\n]");
|
assert.equal(parseRedisJsonDetail("[1,2]")?.formattedText, "[\n 1,\n 2\n]");
|
||||||
assert.equal(parseRedisJsonDetail('"plain json string"'), null);
|
assert.equal(parseRedisJsonDetail('"plain json string"')?.formattedText, '"plain json string"');
|
||||||
assert.equal(parseRedisJsonDetail("123"), null);
|
assert.equal(parseRedisJsonDetail("123")?.formattedText, "123");
|
||||||
assert.equal(parseRedisJsonDetail("plain redis value"), null);
|
assert.equal(parseRedisJsonDetail("plain redis value"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("string/blob formats stay text-oriented and binary-first where needed", () => {
|
||||||
|
const jsonLikeTextDetail = formatRedisMemberDetail(blobFromText('{"name":"Ada"}'));
|
||||||
|
assert.equal(jsonLikeTextDetail.rawLabel, "ASCII");
|
||||||
|
assert.deepEqual(jsonLikeTextDetail.availableFormats, ["utf8", "ascii", "binary", "hex", "base64"]);
|
||||||
|
assert.equal(jsonLikeTextDetail.defaultFormat, "utf8");
|
||||||
|
assert.equal(jsonLikeTextDetail.utf8Text, '{"name":"Ada"}');
|
||||||
|
assert.equal(jsonLikeTextDetail.asciiText, '{"name":"Ada"}');
|
||||||
|
assert.equal(jsonLikeTextDetail.binaryText, "0111101100100010011011100110000101101101011001010010001000111010001000100100000101100100011000010010001001111101");
|
||||||
|
assert.equal(jsonLikeTextDetail.rawText, '{"name":"Ada"}');
|
||||||
|
|
||||||
|
const utf8Detail = formatRedisMemberDetail({
|
||||||
|
raw_base64: Buffer.from("你好", "utf8").toString("base64"),
|
||||||
|
encoding: "utf8" as const,
|
||||||
|
});
|
||||||
|
assert.equal(utf8Detail.rawLabel, "UTF-8");
|
||||||
|
assert.deepEqual(utf8Detail.availableFormats, ["utf8", "ascii", "binary", "hex", "base64"]);
|
||||||
|
assert.equal(utf8Detail.defaultFormat, "utf8");
|
||||||
|
assert.equal(utf8Detail.utf8Text, "你好");
|
||||||
|
assert.equal(utf8Detail.asciiText, "\\xe4\\xbd\\xa0\\xe5\\xa5\\xbd");
|
||||||
|
assert.equal(utf8Detail.binaryText, "111001001011110110100000111001011010010110111101");
|
||||||
|
assert.equal(utf8Detail.rawText, "你好");
|
||||||
|
|
||||||
|
const binaryDetail = formatRedisMemberDetail({
|
||||||
|
raw_base64: Buffer.from([0xac, 0xed, 0x00, 0x05]).toString("base64"),
|
||||||
|
encoding: "binary" as const,
|
||||||
|
});
|
||||||
|
assert.equal(binaryDetail.rawLabel, "Binary");
|
||||||
|
assert.deepEqual(binaryDetail.availableFormats, ["hex", "binary", "base64"]);
|
||||||
|
assert.equal(binaryDetail.defaultFormat, "hex");
|
||||||
|
assert.equal(binaryDetail.utf8Text, new TextDecoder("utf-8").decode(Uint8Array.from([0xac, 0xed, 0x00, 0x05])));
|
||||||
|
assert.equal(binaryDetail.binaryText, "10101100111011010000000000000101");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects Java-serialized payloads as a dedicated view", () => {
|
||||||
|
const javaSerializedString = formatRedisMemberDetail({
|
||||||
|
raw_base64: "rO0ABXQACHNvbWV0ZXh0",
|
||||||
|
encoding: "binary" as const,
|
||||||
|
});
|
||||||
|
assert.deepEqual(javaSerializedString.availableFormats, ["javaserialize", "binary", "hex", "base64"]);
|
||||||
|
assert.equal(javaSerializedString.defaultFormat, "javaserialize");
|
||||||
|
assert.equal(javaSerializedString.javaSerialized?.formattedText, '"sometext"');
|
||||||
|
|
||||||
|
const javaSerializedMap = formatRedisMemberDetail({
|
||||||
|
raw_base64:
|
||||||
|
"rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcAUHsMEzFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAx3CAAAABAAAAACdAADYmFydAADYmF6dAADZm9vc3IAEWphdmEubGFuZy5JbnRlZ2VyEuKgpPeBhzgCAAFJAAV2YWx1ZXhyABBqYXZhLmxhbmcuTnVtYmVyhqyVHQuU4IsCAAB4cAAAAHt4",
|
||||||
|
encoding: "binary" as const,
|
||||||
|
});
|
||||||
|
assert.equal((javaSerializedMap.javaSerialized?.value as { $class?: string }).$class, "java.util.HashMap");
|
||||||
|
assert.equal(
|
||||||
|
((javaSerializedMap.javaSerialized?.value as { obj?: { bar?: string } }).obj ?? {}).bar,
|
||||||
|
"baz",
|
||||||
|
);
|
||||||
|
|
||||||
|
const plainText = formatRedisMemberDetail(blobFromText("Ada"));
|
||||||
|
assert.equal(canRenderRedisValueFormat(plainText, "json"), false);
|
||||||
|
assert.equal(canRenderRedisValueFormat(plainText, "javaserialize"), false);
|
||||||
|
assert.equal(canRenderRedisValueFormat(plainText, "utf8"), true);
|
||||||
|
|
||||||
|
assert.equal(canRenderRedisValueFormat(javaSerializedMap, "javaserialize"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes self-referential Java maps without recursing forever", () => {
|
||||||
|
const detail = formatRedisMemberDetail({
|
||||||
|
raw_base64: "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAx3CAAAABAAAAABdAAEc2VsZnEAfgABeA==",
|
||||||
|
encoding: "binary" as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalized = detail.javaSerialized?.value as {
|
||||||
|
$id?: string;
|
||||||
|
map?: { $entries?: Array<{ key?: string; value?: { $ref?: string } }> };
|
||||||
|
obj?: { self?: { $ref?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(normalized.$id, "#1");
|
||||||
|
assert.equal(normalized.map?.$entries?.[0]?.key, "self");
|
||||||
|
assert.equal(normalized.map?.$entries?.[0]?.value?.$ref, "#1");
|
||||||
|
assert.equal(normalized.obj?.self?.$ref, "#1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only payload views opt into JSON text formatting", () => {
|
||||||
|
const identityDetail = formatRedisMemberDetail(blobFromText('{"name":"Ada"}'));
|
||||||
|
assert.deepEqual(identityDetail.availableFormats, ["utf8", "ascii", "binary", "hex", "base64"]);
|
||||||
|
assert.equal(identityDetail.json, undefined);
|
||||||
|
|
||||||
|
const payloadDetail = formatRedisMemberDetail(blobFromText('{"name":"Ada"}'), { allowJsonText: true });
|
||||||
|
assert.equal(payloadDetail.rawLabel, "ASCII");
|
||||||
|
assert.deepEqual(payloadDetail.availableFormats, ["utf8", "ascii", "binary", "json", "hex", "base64"]);
|
||||||
|
assert.equal(payloadDetail.defaultFormat, "utf8");
|
||||||
|
assert.equal(payloadDetail.json?.formattedText, '{\n "name": "Ada"\n}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reuses only safe default formats for editable text values", () => {
|
||||||
|
const textBlob = blobFromText("Ada");
|
||||||
|
assert.equal(preferredRedisValueFormat(textBlob, "hex"), "utf8");
|
||||||
|
assert.equal(preferredRedisValueFormat(textBlob, "base64"), "utf8");
|
||||||
|
assert.equal(preferredRedisValueFormat(textBlob, "binary"), "utf8");
|
||||||
|
|
||||||
|
const jsonTextBlob = blobFromText('{"name":"Ada"}');
|
||||||
|
assert.equal(preferredRedisValueFormat(jsonTextBlob, "json", { allowJsonText: true }), "json");
|
||||||
|
|
||||||
|
const binaryBlob = {
|
||||||
|
raw_base64: Buffer.from([0xac, 0xed, 0x00, 0x05]).toString("base64"),
|
||||||
|
encoding: "binary" as const,
|
||||||
|
};
|
||||||
|
assert.equal(preferredRedisValueFormat(binaryBlob, "base64"), "base64");
|
||||||
|
});
|
||||||
|
|
||||||
test("formats Redis command results with JSON strings expanded", () => {
|
test("formats Redis command results with JSON strings expanded", () => {
|
||||||
assert.equal(formatRedisCommandResult('{"balance":42,"unit":"USD"}'), '{\n "balance": 42,\n "unit": "USD"\n}');
|
assert.equal(formatRedisCommandResult('{"balance":42,"unit":"USD"}'), '{\n "balance": 42,\n "unit": "USD"\n}');
|
||||||
assert.equal(formatRedisCommandResult(["a", 2]), '[\n "a",\n 2\n]');
|
assert.equal(formatRedisCommandResult(["a", 2]), '[\n "a",\n 2\n]');
|
||||||
@@ -46,10 +175,69 @@ test("formats non-string Redis member values as JSON", () => {
|
|||||||
assert.equal(detail.text, '{\n "field": "name",\n "value": "{\\"nested\\":true}"\n}');
|
assert.equal(detail.text, '{\n "field": "name",\n "value": "{\\"nested\\":true}"\n}');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("builds stable Redis member selection keys from title and formatted value", () => {
|
test("builds stable Redis member selection keys from title and raw value identity", () => {
|
||||||
const key = getRedisMemberSelectionKey("#2", '{"id":240,"kind":"json"}');
|
const key = getRedisMemberSelectionKey("#2", '{"id":240,"kind":"json"}');
|
||||||
|
|
||||||
assert.equal(key, '#2\n{\n "id": 240,\n "kind": "json"\n}');
|
assert.equal(key, '#2\n{"id":240,"kind":"json"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lets selection keys disambiguate duplicate stream fields", () => {
|
||||||
|
const first = getRedisMemberSelectionKey("event", "login", "stream:1714470000000-0:0");
|
||||||
|
const second = getRedisMemberSelectionKey("event", "login", "stream:1714470000000-0:1");
|
||||||
|
|
||||||
|
assert.notEqual(first, second);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("copies binary members as escaped raw bytes instead of bitstrings", () => {
|
||||||
|
const binaryBlob = {
|
||||||
|
raw_base64: Buffer.from([0xac, 0xed, 0x00, 0x05]).toString("base64"),
|
||||||
|
encoding: "binary" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(redisMemberCopyText(binaryBlob), "\\xac\\xed\\x00\\x05");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("copies collection values as readable content instead of blob transport objects", () => {
|
||||||
|
const value = {
|
||||||
|
key_display: "users",
|
||||||
|
key_raw: "users",
|
||||||
|
ttl: -1,
|
||||||
|
redis_type: "hash",
|
||||||
|
data: {
|
||||||
|
kind: "hash" as const,
|
||||||
|
items: [{ field: blobFromText("name"), value: blobFromText("Ada") }],
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(redisValueCopyText(value), '[\n {\n "field": "name",\n "value": "Ada"\n }\n]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("copies stream entries without collapsing repeated field names", () => {
|
||||||
|
const value = {
|
||||||
|
key_display: "events",
|
||||||
|
key_raw: "events",
|
||||||
|
ttl: -1,
|
||||||
|
redis_type: "stream",
|
||||||
|
data: {
|
||||||
|
kind: "stream" as const,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "1728123456789-0",
|
||||||
|
fields: [
|
||||||
|
{ field: "event", value: "login" },
|
||||||
|
{ field: "event", value: "logout" },
|
||||||
|
{ field: "user_id", value: "42" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
redisValueCopyText(value),
|
||||||
|
'[\n {\n "id": "1728123456789-0",\n "fields": [\n {\n "field": "event",\n "value": "login"\n },\n {\n "field": "event",\n "value": "logout"\n },\n {\n "field": "user_id",\n "value": "42"\n }\n ]\n }\n]',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("highlights formatted Redis JSON detail safely", () => {
|
test("highlights formatted Redis JSON detail safely", () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use tauri::State;
|
|||||||
|
|
||||||
use crate::commands::connection::{ensure_connection_writable, AppState};
|
use crate::commands::connection::{ensure_connection_writable, AppState};
|
||||||
use dbx_core::db::redis_driver::{
|
use dbx_core::db::redis_driver::{
|
||||||
RedisCommandResult, RedisCommandSafety, RedisDatabaseInfo, RedisScanResult, RedisValue,
|
RedisCollectionPage, RedisCommandResult, RedisCommandSafety, RedisDatabaseInfo, RedisScanResult, RedisValue,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -325,7 +325,7 @@ pub async fn redis_load_more(
|
|||||||
cursor: u64,
|
cursor: u64,
|
||||||
count: usize,
|
count: usize,
|
||||||
filter: Option<String>,
|
filter: Option<String>,
|
||||||
) -> Result<RedisValue, String> {
|
) -> Result<RedisCollectionPage, String> {
|
||||||
dbx_core::redis_ops::redis_load_more_in_db_core(
|
dbx_core::redis_ops::redis_load_more_in_db_core(
|
||||||
&state,
|
&state,
|
||||||
&connection_id,
|
&connection_id,
|
||||||
|
|||||||
Reference in new issue
Block a user