* fix(redis):format INFO output as plain text in cluster mode * fix(redis): unify aggregated INFO output format in console
134 lines
4.9 KiB
TypeScript
134 lines
4.9 KiB
TypeScript
export type RedisMemberDetailFormat = "json" | "text";
|
|
|
|
export interface RedisMemberDetail {
|
|
text: string;
|
|
rawText: string;
|
|
format: RedisMemberDetailFormat;
|
|
json?: RedisJsonDetail;
|
|
}
|
|
|
|
export interface RedisJsonDetail {
|
|
rawText: string;
|
|
formattedText: string;
|
|
value: unknown;
|
|
}
|
|
|
|
export type RedisMemberDetailKind = "list" | "set" | "hash" | "zset" | "stream";
|
|
|
|
export const REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH = 360;
|
|
export const REDIS_MEMBER_DETAIL_SHEET_MAX_WIDTH = 900;
|
|
|
|
export function canEditRedisMemberDetail(kind: RedisMemberDetailKind): boolean {
|
|
return kind !== "stream";
|
|
}
|
|
|
|
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 formatRedisMemberDetail(value: unknown): RedisMemberDetail {
|
|
if (typeof value === "string") {
|
|
const json = parseRedisJsonDetail(value);
|
|
return json ? { text: json.formattedText, rawText: value, format: "json", json } : { text: value, rawText: value, format: "text" };
|
|
}
|
|
|
|
try {
|
|
const formattedText = JSON.stringify(value, null, 2);
|
|
return {
|
|
text: formattedText,
|
|
rawText: formattedText,
|
|
format: "json",
|
|
json: { rawText: formattedText, formattedText, value },
|
|
};
|
|
} catch {
|
|
const text = String(value);
|
|
return { text, rawText: text, format: "text" };
|
|
}
|
|
}
|
|
|
|
export function formatRedisStringValue(value: unknown): string {
|
|
if (typeof value !== "string") return String(value ?? "");
|
|
return formatRedisJsonString(value) ?? value;
|
|
}
|
|
|
|
export function formatRedisCommandResult(value: unknown): string {
|
|
if (typeof value === "string") return formatRedisStringValue(value);
|
|
return JSON.stringify(value, null, 2);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
function formatRedisJsonString(value: string): string | null {
|
|
return parseRedisJsonDetail(value)?.formattedText ?? null;
|
|
}
|
|
|
|
export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null {
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
if (!looksLikeJsonContainer(trimmed)) return null;
|
|
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
if (!isJsonContainer(parsed)) return null;
|
|
return {
|
|
rawText: value,
|
|
formattedText: JSON.stringify(parsed, null, 2),
|
|
value: parsed,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function looksLikeJsonContainer(value: string): boolean {
|
|
return (value.startsWith("{") && value.endsWith("}")) || (value.startsWith("[") && value.endsWith("]"));
|
|
}
|
|
|
|
function isJsonContainer(value: unknown): boolean {
|
|
return value !== null && typeof value === "object";
|
|
}
|
|
|
|
export function getRedisMemberSelectionKey(title: string, value: unknown): string {
|
|
return `${title}\n${formatRedisMemberDetail(value).text}`;
|
|
}
|
|
|
|
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>`;
|
|
});
|
|
}
|