Files
opsxdb/src/redis.ts
T

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";
}