import type { TreeNode } from "./store"; export type RedisKeyMeta = { name: string; type: string; ttl: number }; /** 类型点颜色,对齐常见 Redis GUI */ export function redisTypeColor(type?: string): string { switch ((type || "").toLowerCase()) { case "string": return "#22c55e"; case "hash": return "#3b82f6"; case "list": return "#f59e0b"; case "set": return "#a855f7"; case "zset": return "#06b6d4"; case "stream": return "#ec4899"; case "rejson": case "json": case "rejson-rl": return "#14b8a6"; default: return "#ef4444"; } } /** TTL 可读:永久 / 不存在 / Ns */ export function formatRedisTtl(ttl: number | undefined): string { if (ttl == null) return ""; if (ttl === -1) return "永久"; if (ttl === -2) return "不存在"; if (ttl < 0) return String(ttl); if (ttl < 60) return `${ttl}s`; if (ttl < 3600) return `${Math.floor(ttl / 60)}m${ttl % 60 ? `${ttl % 60}s` : ""}`; if (ttl < 86400) { const h = Math.floor(ttl / 3600); const m = Math.floor((ttl % 3600) / 60); return m ? `${h}h${m}m` : `${h}h`; } const d = Math.floor(ttl / 86400); const h = Math.floor((ttl % 86400) / 3600); return h ? `${d}d${h}h` : `${d}d`; } /** 数值若形如 Unix 时间戳(秒/毫秒)则给可读时间,否则 null —— zset Score 常存毫秒时间戳(如 K 线) */ export function tsLabel(n: number): string | null { if (!Number.isFinite(n)) return null; let d: Date | null = null; if (n > 1e11 && n < 9e12) d = new Date(n); // 毫秒 else if (n > 1e8 && n < 9e9) d = new Date(n * 1000); // 秒 if (!d || Number.isNaN(d.getTime())) return null; const p = (x: number) => String(x).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; } export function redisLeafLabel(full: string, sep: string): string { if (!sep) return full; const i = full.lastIndexOf(sep); if (i < 0) return full; const leaf = full.slice(i + sep.length); return leaf || full; } type Bucket = { folders: Map; keys: RedisKeyMeta[]; }; function keyCount(n: TreeNode): number { if (n.kind === "key") return 1; return n.count ?? (n.children ?? []).reduce((a, c) => a + keyCount(c), 0); } /** 按分隔符把 SCAN 结果编成文件夹树(默认 `:`,对标 Another Redis Desktop Manager)。 */ export function buildRedisKeyTree(keys: RedisKeyMeta[], separator = ":"): TreeNode[] { const sep = separator || ":"; const root: Bucket = { folders: new Map(), keys: [] }; for (const k of keys) { const parts = k.name.split(sep); if (parts.length <= 1) { root.keys.push(k); continue; } let cur = root; for (let i = 0; i < parts.length - 1; i++) { const p = parts[i] ?? ""; let next = cur.folders.get(p); if (!next) { next = { folders: new Map(), keys: [] }; cur.folders.set(p, next); } cur = next; } cur.keys.push(k); } return bucketToNodes(root, "", sep); } function bucketToNodes(b: Bucket, prefix: string, sep: string): TreeNode[] { const folderNames = [...b.folders.keys()].sort((a, c) => a.localeCompare(c, undefined, { numeric: true })); const folders: TreeNode[] = folderNames.map((name) => { const child = b.folders.get(name)!; const fullName = prefix + name + sep; const children = bucketToNodes(child, fullName, sep); return { name, kind: "folder" as const, groupId: "folder", fullName, loaded: true, count: children.reduce((a, n) => a + keyCount(n), 0), children, }; }); const keys: TreeNode[] = [...b.keys] .sort((a, c) => a.name.localeCompare(c.name, undefined, { numeric: true })) .map((k) => ({ name: k.name, kind: "key" as const, type: k.type, ttl: k.ttl, })); return [...folders, ...keys]; } export function removeRedisKeyFromTree(nodes: TreeNode[], key: string): TreeNode[] { const out: TreeNode[] = []; for (const n of nodes) { if (n.kind === "key" && n.name === key) continue; if (n.kind === "folder") { const children = removeRedisKeyFromTree(n.children ?? [], key); if (!children.length) continue; out.push({ ...n, children, count: children.reduce((a, c) => a + keyCount(c), 0), }); continue; } out.push(n); } return out; } /** 前端过滤键树:子串匹配键名/路径 + 类型筛选;空文件夹剪枝,计数同步 */ export function filterRedisTree(nodes: TreeNode[], q: string, type: string): TreeNode[] { const needle = q.trim().toLowerCase(); const typeOn = type && type !== "all"; if (!needle && !typeOn) return nodes; const keep = (n: TreeNode): boolean => !needle || n.name.toLowerCase().includes(needle) || (n.fullName || "").toLowerCase().includes(needle); const typeOk = (n: TreeNode): boolean => !typeOn || (n.type || "").toLowerCase() === type; const walk = (list: TreeNode[]): TreeNode[] => { const out: TreeNode[] = []; for (const n of list) { if (n.kind === "folder") { const children = walk(n.children ?? []); if (!children.length) continue; out.push({ ...n, children, count: children.reduce((a, c) => a + keyCount(c), 0) }); continue; } if (n.kind === "key") { if (keep(n) && typeOk(n)) out.push(n); continue; } out.push(n); } return out; }; return walk(nodes); } export function quoteRedisArg(s: string): string { if (s === "") return '""'; if (/^[\w\-.:/@+=]+$/.test(s)) return s; return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } export function redisCmd(...parts: string[]): string { return parts.map(quoteRedisArg).join(" "); } export function tryPrettyJson(raw: unknown): string | null { if (raw == null) return null; if (typeof raw === "object") { try { return JSON.stringify(raw, null, 2); } catch { return null; } } const s = String(raw).trim(); if (!s || (s[0] !== "{" && s[0] !== "[" && s[0] !== '"')) return null; try { return JSON.stringify(JSON.parse(s), null, 2); } catch { return null; } } export function displayRedisValue(raw: unknown, max = 180): { text: string; pretty: string | null } { const pretty = tryPrettyJson(raw); const full = pretty ?? (raw == null ? "" : String(raw)); if (full.length > max) return { text: `${full.slice(0, max)}…`, pretty }; return { text: full, pretty }; } export const REDIS_COMMANDS = [ "PING", "GET", "SET", "DEL", "EXISTS", "TTL", "PTTL", "EXPIRE", "PERSIST", "TYPE", "RENAME", "KEYS", "SCAN", "INFO", "DBSIZE", "SELECT", "ECHO", "HGET", "HGETALL", "HSET", "HDEL", "HKEYS", "HVALS", "HLEN", "HEXISTS", "HSCAN", "LPUSH", "RPUSH", "LRANGE", "LLEN", "LSET", "LPOP", "RPOP", "LINDEX", "SADD", "SREM", "SMEMBERS", "SCARD", "SISMEMBER", "SSCAN", "ZADD", "ZREM", "ZRANGE", "ZREVRANGE", "ZCARD", "ZSCORE", "ZSCAN", "MGET", "MSET", "INCR", "DECR", "INCRBY", "DECRBY", "APPEND", "STRLEN", "FLUSHDB", "FLUSHALL", "CONFIG", "CLIENT", "SLOWLOG", "MEMORY", ].map((label) => ({ label, type: "keyword" as const })); /** Redis 按行切命令(不是 SQL 分号)。空行与 # 注释跳过。 */ export function splitRedisCommands(sql: string): { start: number; end: number; text: string }[] { const out: { start: number; end: number; text: string }[] = []; let i = 0; while (i <= sql.length) { const nl = sql.indexOf("\n", i); const end = nl < 0 ? sql.length : nl; const line = sql.slice(i, end); const trimmed = line.trim(); if (trimmed && !trimmed.startsWith("#")) { const lead = line.search(/\S/); const from = i + (lead < 0 ? 0 : lead); out.push({ start: from, end: from + trimmed.length, text: trimmed }); } if (nl < 0) break; i = end + 1; } return out.length ? out : [{ start: 0, end: sql.length, text: sql }]; } export function isDangerousRedis(sql: string): boolean { const cmd = sql.trim().split(/\s+/)[0]?.toUpperCase() ?? ""; return ["FLUSHALL", "FLUSHDB", "KEYS", "CONFIG", "SHUTDOWN", "DEBUG", "REPLICAOF", "SLAVEOF"].includes(cmd); } export function redisHintForType(key: string, type?: string): string { const k = quoteRedisArg(key); switch (type) { case "hash": return `HGETALL ${k}`; case "list": return `LRANGE ${k} 0 499`; case "set": return `SMEMBERS ${k}`; case "zset": return `ZRANGE ${k} 0 499 WITHSCORES`; default: return `GET ${k}`; } } export function createKeyCommand(type: string, name: string, value: string): string { switch (type) { case "hash": return redisCmd("HSET", name, "field", value || ""); case "list": return redisCmd("RPUSH", name, value || ""); case "set": return redisCmd("SADD", name, value || ""); case "zset": return redisCmd("ZADD", name, "0", value || ""); default: return redisCmd("SET", name, value || ""); } } /** 是否 Redis 连接(按驱动名) */ export function isRedisConn(driver?: string): boolean { return (driver || "").toLowerCase() === "redis"; }