281 lines
9.0 KiB
TypeScript
281 lines
9.0 KiB
TypeScript
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<string, Bucket>;
|
|
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";
|
|
}
|