feat(redis): add bulk operations and command runner

This commit is contained in:
t8y2
2026-05-12 13:38:43 +08:00
parent f2e8b49df7
commit 368fa5a102
17 changed files with 673 additions and 20 deletions
+167 -10
View File
@@ -10,6 +10,9 @@ import {
FolderClosed,
FolderOpen,
Trash2,
DatabaseZap,
Play,
Terminal,
} from "lucide-vue-next";
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
@@ -25,9 +28,11 @@ import type { RedisKeyInfo } from "@/lib/api";
import {
buildRedisKeyTree,
collectExpandedGroupIds,
collectRedisGroupKeyRaws,
flattenVisibleRedisKeyTree,
type RedisKeyTreeNode,
} from "@/lib/redisKeyTree";
import { classifyRedisCommandSafety } from "@/lib/redisCommandSafety";
const { t } = useI18n();
@@ -44,7 +49,17 @@ const selectedKeyRaw = ref<string | null>(null);
const hasMore = ref(false);
const expandedGroupIds = ref<Set<string>>(new Set());
const checkedKeys = ref<Set<string>>(new Set());
const showBatchDeleteConfirm = ref(false);
const pendingDanger = ref<
| { kind: "delete-keys"; title: string; keyRaws: string[] }
| { kind: "flush-db" }
| { kind: "command"; command: string }
| null
>(null);
const showDangerConfirm = ref(false);
const commandText = ref("");
const commandResult = ref<any>(null);
const commandError = ref("");
const commandRunning = ref(false);
const PAGE_SIZE = 200;
const keyGridStyle = {
@@ -54,6 +69,22 @@ const keyGridStyle = {
const effectivePattern = computed(() => searchPattern.value.trim() || "*");
const isSearchMode = computed(() => effectivePattern.value !== "*");
const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null);
const dangerDetails = computed(() => {
if (!pendingDanger.value) return "";
if (pendingDanger.value.kind === "delete-keys") {
return t("redis.deleteGroupDetails", {
target: pendingDanger.value.title,
count: pendingDanger.value.keyRaws.length,
});
}
if (pendingDanger.value.kind === "flush-db") return t("redis.flushDbDetails", { db: props.db });
return pendingDanger.value.command;
});
const formattedCommandResult = computed(() => {
if (commandResult.value == null) return "";
if (typeof commandResult.value === "string") return commandResult.value;
return JSON.stringify(commandResult.value, null, 2);
});
const visibleRows = computed(() =>
flattenVisibleRedisKeyTree(treeKeys.value, expandedGroupIds.value).map((row) => ({
...row,
@@ -139,20 +170,99 @@ function toggleCheck(keyRaw: string, event: Event) {
function requestBatchDelete() {
if (checkedKeys.value.size === 0) return;
showBatchDeleteConfirm.value = true;
pendingDanger.value = { kind: "delete-keys", title: t("redis.selectedKeys"), keyRaws: [...checkedKeys.value] };
showDangerConfirm.value = true;
}
async function applyBatchDelete() {
const keys = [...checkedKeys.value];
function requestGroupDelete(node: RedisKeyTreeNode, event: Event) {
event.stopPropagation();
if (node.kind !== "group") return;
const keyRaws = collectRedisGroupKeyRaws(node);
if (keyRaws.length === 0) return;
pendingDanger.value = { kind: "delete-keys", title: node.pathSegments.join(":"), keyRaws };
showDangerConfirm.value = true;
}
function requestFlushDb() {
pendingDanger.value = { kind: "flush-db" };
showDangerConfirm.value = true;
}
function resetLoadedKeys() {
flatKeys.value = [];
treeKeys.value = [];
selectedKeyRaw.value = null;
checkedKeys.value = new Set();
expandedGroupIds.value = new Set();
hasMore.value = false;
}
async function deleteKeyRaws(keys: string[]) {
await api.redisDeleteKeys(props.connectionId, props.db, keys);
flatKeys.value = flatKeys.value.filter((k) => !checkedKeys.value.has(k.key_raw));
if (selectedKeyRaw.value && checkedKeys.value.has(selectedKeyRaw.value)) {
const deleted = new Set(keys);
flatKeys.value = flatKeys.value.filter((k) => !deleted.has(k.key_raw));
if (selectedKeyRaw.value && deleted.has(selectedKeyRaw.value)) {
selectedKeyRaw.value = null;
}
checkedKeys.value = new Set();
rebuildTree(false);
}
async function runRedisCommand(command: string) {
commandRunning.value = true;
commandError.value = "";
commandResult.value = null;
try {
const result = await api.redisExecuteCommand(props.connectionId, props.db, command);
commandResult.value = result.value;
if (result.safety === "confirm") {
await loadKeys();
}
} catch (error) {
commandError.value = error instanceof Error ? error.message : String(error);
} finally {
commandRunning.value = false;
}
}
async function executeCommand() {
const command = commandText.value.trim();
if (!command) {
commandError.value = t("redis.commandEmpty");
commandResult.value = null;
return;
}
const safety = classifyRedisCommandSafety(command);
if (safety === "blocked") {
commandError.value = t("redis.commandBlocked");
commandResult.value = null;
return;
}
if (safety === "confirm") {
pendingDanger.value = { kind: "command", command };
showDangerConfirm.value = true;
return;
}
await runRedisCommand(command);
}
async function applyDangerAction() {
const pending = pendingDanger.value;
pendingDanger.value = null;
showDangerConfirm.value = false;
if (!pending) return;
if (pending.kind === "delete-keys") {
await deleteKeyRaws(pending.keyRaws);
} else if (pending.kind === "flush-db") {
await api.redisFlushDb(props.connectionId, props.db);
resetLoadedKeys();
} else {
await runRedisCommand(pending.command);
}
}
function typeColor(type: string): string {
switch (type) {
case "string":
@@ -213,6 +323,15 @@ onMounted(loadKeys);
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
<RefreshCw v-else class="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 shrink-0 text-destructive"
:title="t('redis.flushDb')"
@click="requestFlushDb"
>
<DatabaseZap class="h-3 w-3" />
</Button>
<span class="text-xs text-muted-foreground shrink-0 ml-1">{{
loading && flatKeys.length === 0 ? t("redis.loadingKeys") : t("redis.keys", { count: flatKeys.length })
}}</span>
@@ -227,6 +346,35 @@ onMounted(loadKeys);
</Button>
</div>
<div class="min-h-9 flex items-center gap-1 px-2 border-b shrink-0">
<Terminal class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<Input
v-model="commandText"
class="h-6 text-xs border-0 shadow-none focus-visible:ring-0 font-mono"
:placeholder="t('redis.commandPlaceholder')"
@keydown.enter="executeCommand"
/>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 shrink-0"
:title="t('redis.executeCommand')"
:disabled="commandRunning"
@click="executeCommand"
>
<Loader2 v-if="commandRunning" class="h-3 w-3 animate-spin" />
<Play v-else class="h-3 w-3" />
</Button>
</div>
<div v-if="commandError || formattedCommandResult" class="border-b px-2 py-1 shrink-0 text-xs">
<pre
class="max-h-24 overflow-auto whitespace-pre-wrap break-words font-mono"
:class="commandError ? 'text-destructive' : 'text-muted-foreground'"
>{{ commandError || formattedCommandResult }}</pre
>
</div>
<!-- Table header -->
<div class="grid border-b bg-muted/50 shrink-0 text-xs font-medium text-muted-foreground" :style="keyGridStyle">
<div class="px-3 py-1 border-r">{{ t("redis.columnKey") }}</div>
@@ -274,6 +422,15 @@ onMounted(loadKeys);
/>
<span class="truncate font-mono">{{ row.node.label }}</span>
<span class="text-muted-foreground ml-1">({{ countLeaves(row.node) }})</span>
<Button
variant="ghost"
size="icon"
class="ml-auto h-5 w-5 shrink-0 text-destructive opacity-0 group-hover:opacity-100"
:title="t('redis.deleteGroup')"
@click="requestGroupDelete(row.node, $event)"
>
<Trash2 class="h-3 w-3" />
</Button>
</template>
<template v-else>
<input
@@ -336,10 +493,10 @@ onMounted(loadKeys);
</Splitpanes>
<DangerConfirmDialog
v-model:open="showBatchDeleteConfirm"
v-model:open="showDangerConfirm"
:message="t('dangerDialog.deleteMessage')"
:details="`${checkedKeys.size} keys`"
:confirm-label="t('dangerDialog.deleteConfirm')"
@confirm="applyBatchDelete"
:details="dangerDetails"
:confirm-label="pendingDanger?.kind === 'command' ? t('dangerDialog.confirm') : t('dangerDialog.deleteConfirm')"
@confirm="applyDangerAction"
/>
</template>
+9
View File
@@ -684,6 +684,15 @@ export default {
columnTTL: "TTL",
binaryStringReadonlyHint:
"Binary string values are shown as escaped text in read-only mode; editing raw bytes is not supported.",
selectedKeys: "Selected keys",
deleteGroup: "Delete group",
deleteGroupDetails: "{target}\n{count} keys",
flushDb: "Clear current DB",
flushDbDetails: "Redis db{db}",
commandPlaceholder: "Redis command, e.g. GET user:1",
executeCommand: "Execute command",
commandEmpty: "Enter a Redis command",
commandBlocked: "This Redis command is blocked for safety",
},
mongo: {
documents: "{count} documents",
+14
View File
@@ -678,8 +678,22 @@ export default {
members: "{count} miembros",
entries: "{count} entradas",
noExpiry: "sin expiración",
columnType: "Tipo",
columnKey: "Clave",
columnValue: "Valor",
columnSize: "Tamaño",
columnTTL: "TTL",
binaryStringReadonlyHint:
"Los valores de cadena binaria se muestran como texto escapado en modo de solo lectura; la edición de bytes sin procesar no está disponible.",
selectedKeys: "Claves seleccionadas",
deleteGroup: "Eliminar grupo",
deleteGroupDetails: "{target}\n{count} claves",
flushDb: "Limpiar DB actual",
flushDbDetails: "Redis db{db}",
commandPlaceholder: "Comando Redis, p. ej. GET user:1",
executeCommand: "Ejecutar comando",
commandEmpty: "Ingresa un comando Redis",
commandBlocked: "Este comando Redis está bloqueado por seguridad",
},
mongo: {
documents: "{count} documentos",
+9
View File
@@ -670,6 +670,15 @@ export default {
columnSize: "大小",
columnTTL: "TTL",
binaryStringReadonlyHint: "二进制字符串按转义文本只读展示;当前不支持直接编辑原始字节值。",
selectedKeys: "已选择的 key",
deleteGroup: "删除分组",
deleteGroupDetails: "{target}\n{count} 个 key",
flushDb: "清空当前 DB",
flushDbDetails: "Redis db{db}",
commandPlaceholder: "Redis 命令,如 GET user:1",
executeCommand: "执行命令",
commandEmpty: "请输入 Redis 命令",
commandBlocked: "出于安全考虑,此 Redis 命令已被阻止",
},
mongo: {
documents: "{count} 个文档",
+4
View File
@@ -114,6 +114,8 @@ export const redisZadd = forward("redisZadd");
export const redisZrem = forward("redisZrem");
export const redisSetTtl = forward("redisSetTtl");
export const redisDeleteKeys = forward("redisDeleteKeys");
export const redisFlushDb = forward("redisFlushDb");
export const redisExecuteCommand = forward("redisExecuteCommand");
export const redisLoadMore = forward("redisLoadMore");
// MongoDB
@@ -152,6 +154,8 @@ export type {
RedisKeyInfo,
RedisValue,
RedisScanResult,
RedisCommandSafety,
RedisCommandResult,
MongoDocumentResult,
HistoryEntry,
SqlFileStatus,
+13
View File
@@ -26,6 +26,7 @@ import type {
UpdateInfo,
RedisValue,
RedisScanResult,
RedisCommandResult,
MongoDocumentResult,
HistoryEntry,
SqlFileRequest,
@@ -584,6 +585,18 @@ export async function redisDeleteKeys(connectionId: string, db: number, keyRaws:
return post("/api/redis/delete-keys", { connectionId, db, keyRaws });
}
export async function redisFlushDb(connectionId: string, db: number): Promise<void> {
return post("/api/redis/flush-db", { connectionId, db });
}
export async function redisExecuteCommand(
connectionId: string,
db: number,
command: string,
): Promise<RedisCommandResult> {
return post("/api/redis/execute-command", { connectionId, db, command });
}
export async function redisLoadMore(
connectionId: string,
db: number,
+82
View File
@@ -0,0 +1,82 @@
export type RedisCommandSafety = "allowed" | "confirm" | "blocked";
const BLOCKED_COMMANDS = new Set([
"KEYS",
"FLUSHALL",
"SHUTDOWN",
"CONFIG",
"SAVE",
"BGSAVE",
"SLAVEOF",
"REPLICAOF",
"MIGRATE",
"MODULE",
"SCRIPT",
"EVAL",
"EVALSHA",
]);
const CONFIRM_COMMANDS = new Set([
"DEL",
"UNLINK",
"EXPIRE",
"EXPIREAT",
"PEXPIRE",
"PEXPIREAT",
"PERSIST",
"RENAME",
"RENAMENX",
"SET",
"SETEX",
"PSETEX",
"SETNX",
"MSET",
"MSETNX",
"HSET",
"HDEL",
"LPUSH",
"RPUSH",
"LPOP",
"RPOP",
"LSET",
"LREM",
"SADD",
"SREM",
"ZADD",
"ZREM",
"XADD",
"XDEL",
"FLUSHDB",
]);
export function firstRedisCommandToken(command: string): string {
const trimmed = command.trimStart();
if (!trimmed) return "";
const quote = trimmed[0] === '"' || trimmed[0] === "'" ? trimmed[0] : "";
let token = "";
let escaping = false;
for (let i = quote ? 1 : 0; i < trimmed.length; i++) {
const ch = trimmed[i];
if (escaping) {
token += ch;
escaping = false;
continue;
}
if (ch === "\\") {
escaping = true;
continue;
}
if (quote && ch === quote) break;
if (!quote && /\s/.test(ch)) break;
token += ch;
}
return token.toUpperCase();
}
export function classifyRedisCommandSafety(command: string): RedisCommandSafety {
const token = firstRedisCommandToken(command);
if (BLOCKED_COMMANDS.has(token)) return "blocked";
if (CONFIRM_COMMANDS.has(token)) return "confirm";
return "allowed";
}
+17
View File
@@ -129,6 +129,23 @@ export function collectExpandedGroupIds(nodes: RedisKeyTreeNode[]): Set<string>
return ids;
}
export function collectRedisGroupKeyRaws(group: RedisKeyTreeGroupNode): string[] {
const keyRaws: string[] = [];
const visit = (nodes: RedisKeyTreeNode[]) => {
for (const node of nodes) {
if (node.kind === "leaf") {
keyRaws.push(node.keyRaw);
} else {
visit(node.children);
}
}
};
visit(group.children);
return keyRaws;
}
export function flattenVisibleRedisKeyTree(
nodes: RedisKeyTreeNode[],
expandedGroupIds: ReadonlySet<string>,
+20
View File
@@ -365,6 +365,14 @@ export interface RedisScanResult {
keys: RedisKeyInfo[];
}
export type RedisCommandSafety = "allowed" | "confirm" | "blocked";
export interface RedisCommandResult {
command: string;
safety: RedisCommandSafety;
value: any;
}
export async function redisListDatabases(connectionId: string): Promise<number[]> {
return invoke("redis_list_databases", { connectionId });
}
@@ -449,6 +457,18 @@ export async function redisDeleteKeys(connectionId: string, db: number, keyRaws:
return invoke("redis_delete_keys", { connectionId, db, keyRaws });
}
export async function redisFlushDb(connectionId: string, db: number): Promise<void> {
return invoke("redis_flush_db", { connectionId, db });
}
export async function redisExecuteCommand(
connectionId: string,
db: number,
command: string,
): Promise<RedisCommandResult> {
return invoke("redis_execute_command", { connectionId, db, command });
}
export async function redisLoadMore(
connectionId: string,
db: number,