570 lines
19 KiB
TypeScript
570 lines
19 KiB
TypeScript
import type { BinaryHexViewRow } from "@/lib/dataGrid/binaryHexViewer";
|
|
import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer";
|
|
import { formatJsonSource, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
|
|
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 const REDIS_VALUE_FORMAT_DISPLAY_ORDER: RedisValueFormat[] = ["utf8", "ascii", "binary", "json", "javaserialize", "hex", "base64"];
|
|
|
|
export interface RedisMemberDetail {
|
|
text: string;
|
|
rawText: string;
|
|
rawLabel: string;
|
|
utf8Text: string;
|
|
asciiText: string;
|
|
binaryText: string;
|
|
format: RedisMemberDetailFormat;
|
|
json?: RedisJsonDetail;
|
|
javaSerialized?: RedisJavaSerializedDetail;
|
|
availableFormats: RedisValueFormat[];
|
|
defaultFormat: RedisValueFormat;
|
|
byteCount: number;
|
|
base64Text: string;
|
|
hexRows: BinaryHexViewRow[];
|
|
editable: boolean;
|
|
binary: boolean;
|
|
}
|
|
|
|
export interface RedisJsonDetail {
|
|
rawText: string;
|
|
formattedText: string;
|
|
value: unknown;
|
|
}
|
|
|
|
export type RedisJsonDraftNormalizationResult =
|
|
| {
|
|
ok: true;
|
|
compactText: string;
|
|
}
|
|
| {
|
|
ok: false;
|
|
error: "invalid_json";
|
|
};
|
|
|
|
export interface RedisMemberDetailOptions {
|
|
allowJsonText?: boolean;
|
|
}
|
|
|
|
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_MAX_WIDTH = 900;
|
|
|
|
export function canEditRedisMemberDetail(kind: RedisMemberDetailKind, value?: unknown): boolean {
|
|
if (kind === "stream") return false;
|
|
if (value == null) return true;
|
|
return !isRedisBlob(value) || value.encoding !== "binary";
|
|
}
|
|
|
|
export function clampRedisMemberDetailSheetWidth(width: number, viewportWidth: number): number {
|
|
const viewportMax = Math.max(REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH, viewportWidth - 32);
|
|
return Math.min(Math.min(REDIS_MEMBER_DETAIL_SHEET_MAX_WIDTH, viewportMax), Math.max(REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH, width));
|
|
}
|
|
|
|
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") {
|
|
const bytes = new TextEncoder().encode(value);
|
|
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 {
|
|
const formattedText = JSON.stringify(value, null, 2);
|
|
const bytes = new TextEncoder().encode(formattedText);
|
|
return {
|
|
text: formattedText,
|
|
rawText: formattedText,
|
|
rawLabel: "Raw",
|
|
utf8Text: formattedText,
|
|
asciiText: asciiBytesToText(bytes),
|
|
binaryText: binaryBytesToText(bytes),
|
|
format: "json",
|
|
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 {
|
|
const text = String(value);
|
|
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 {
|
|
if (isRedisBlob(value)) return formatRedisMemberDetail(value).text;
|
|
if (typeof value !== "string") return String(value ?? "");
|
|
return formatRedisJsonString(value) ?? sanitizeRedisDisplayText(value);
|
|
}
|
|
|
|
export function formatRedisCommandResult(value: unknown): string {
|
|
if (typeof value === "string") return formatRedisStringValue(value);
|
|
return JSON.stringify(value, null, 2);
|
|
}
|
|
|
|
/** RedisJSON source text stays out of JavaScript's numeric representation. */
|
|
export function redisJsonValueText(value: { value: string }): string {
|
|
return value.value;
|
|
}
|
|
|
|
/**
|
|
* Detect if a value is a cluster-aggregated INFO response:
|
|
* `[[addr, infoText], ...]` where each `infoText` starts with `"# "`
|
|
* (the INFO section marker). This is the format produced by the backend
|
|
* in `redis_driver.rs::execute_command` for cluster-mode INFO.
|
|
*/
|
|
function isRedisClusterInfoValue(value: unknown): value is [string, string][] {
|
|
return Array.isArray(value) && value.length > 0 && (value as unknown[]).every((item) => Array.isArray(item) && item.length === 2 && typeof item[0] === "string" && typeof item[1] === "string" && item[1].startsWith("# "));
|
|
}
|
|
|
|
/**
|
|
* Format a Redis command result for the **command console terminal** (RedisKeyBrowser.vue).
|
|
* Unlike `formatRedisCommandResult` (used by the query result table UI), this function
|
|
* renders cluster-aggregated INFO output as plain text (not JSON), matching the native
|
|
* redis-cli display style.
|
|
*
|
|
* - Cluster INFO `[[addr, infoText], ...]` → `"{addr}\n{infoText}"` per node, joined by newlines.
|
|
* - Plain string → passthrough (handles single-node INFO text correctly).
|
|
* - Everything else → JSON.stringify (arrays, objects, etc.).
|
|
*/
|
|
export function formatRedisConsoleValue(value: unknown): string {
|
|
if (isRedisClusterInfoValue(value)) {
|
|
return value.map(([addr, info]) => `${addr}\n${info}`).join("\n");
|
|
}
|
|
if (typeof value === "string") return formatRedisStringValue(value);
|
|
return JSON.stringify(value, null, 2);
|
|
}
|
|
|
|
export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null {
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
|
|
try {
|
|
// Pretty-print from source tokens so duplicate members and number spellings
|
|
// stay intact when Redis string/hash values open in the JSON editor.
|
|
const formattedText = formatJsonSource(trimmed, 2);
|
|
const parsed = parseJsonPreservingLargeNumbers(trimmed);
|
|
return {
|
|
rawText: value,
|
|
formattedText,
|
|
value: parsed,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates a JSON editor draft and produces the compact text Redis should
|
|
* store. Source-preserving minification keeps high-precision numbers and
|
|
* duplicate object members intact.
|
|
*/
|
|
export function normalizeRedisJsonDraft(text: string): RedisJsonDraftNormalizationResult {
|
|
try {
|
|
return { ok: true, compactText: formatJsonSource(text) };
|
|
} catch {
|
|
return { ok: false, error: "invalid_json" };
|
|
}
|
|
}
|
|
|
|
export function preferredRedisValueFormat(value: unknown, preferred?: RedisValueFormat | null, options: RedisMemberDetailOptions = {}): RedisValueFormat {
|
|
const detail = formatRedisMemberDetail(value, options);
|
|
if (preferred && detail.availableFormats.includes(preferred) && shouldReuseRedisValueFormatPreference(detail, preferred)) return preferred;
|
|
return detail.defaultFormat;
|
|
}
|
|
|
|
export function canRenderRedisValueFormat(detail: RedisMemberDetail, format: RedisValueFormat): boolean {
|
|
switch (format) {
|
|
case "json":
|
|
return Boolean(detail.json);
|
|
case "javaserialize":
|
|
return Boolean(detail.javaSerialized);
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
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);
|
|
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(redisJsonValueText(value.data)).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(redisJsonValueText(value.data));
|
|
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": {
|
|
const rawText = redisJsonValueText(value.data);
|
|
try {
|
|
return formatJsonSource(rawText, 2);
|
|
} catch {
|
|
return rawText;
|
|
}
|
|
}
|
|
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 {
|
|
let output = "";
|
|
for (const ch of value) {
|
|
if (ch === "\n" || ch === "\r" || ch === "\t") {
|
|
output += ch;
|
|
continue;
|
|
}
|
|
if (ch >= " " && ch !== "\u007f" && !isUtf8ControlCharacter(ch)) {
|
|
output += ch;
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
|
|
export function highlightRedisJsonDetail(json: string): string {
|
|
const escaped = json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
|
|
return escaped.replace(/("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, (match) => {
|
|
let cls = "json-number";
|
|
if (match.startsWith('"')) cls = match.endsWith(":") ? "json-key" : "json-string";
|
|
else if (match === "true" || match === "false") cls = "json-boolean";
|
|
else if (match === "null") cls = "json-null";
|
|
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);
|
|
}
|