feat: add Redis member detail viewer
This commit is contained in:
parent
4d4c8f4224
commit
fea3552774
|
|
@ -1,13 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Copy, Trash2, Save, RefreshCw, Plus, Loader2 } from "lucide-vue-next";
|
||||
import { Copy, Eye, Trash2, Save, RefreshCw, Plus, Loader2 } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import type { RedisValue } from "@/lib/api";
|
||||
import { formatRedisMemberDetail } from "@/lib/redisValuePresentation";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
|
@ -29,10 +31,14 @@ const newField = ref("");
|
|||
const newValue = ref("");
|
||||
const newScore = ref("");
|
||||
const showDeleteConfirm = ref(false);
|
||||
const showMemberDetail = ref(false);
|
||||
const editingTtl = ref(false);
|
||||
const ttlInput = ref("");
|
||||
const collectionItems = ref<any[]>([]);
|
||||
const scanCursor = ref<number | undefined>(undefined);
|
||||
const selectedMemberTitle = ref("");
|
||||
const selectedMemberRaw = ref<unknown>("");
|
||||
const selectedMemberDetail = computed(() => formatRedisMemberDetail(selectedMemberRaw.value));
|
||||
|
||||
type PendingDelete =
|
||||
| { kind: "key" }
|
||||
|
|
@ -123,6 +129,20 @@ function copyValue() {
|
|||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function copyMember(value: unknown) {
|
||||
copyText(formatRedisMemberDetail(value).text);
|
||||
}
|
||||
|
||||
function viewMember(title: string, value: unknown) {
|
||||
selectedMemberTitle.value = title;
|
||||
selectedMemberRaw.value = value;
|
||||
showMemberDetail.value = true;
|
||||
}
|
||||
|
||||
// TTL
|
||||
function startEditTtl() {
|
||||
if (!data.value) return;
|
||||
|
|
@ -312,7 +332,7 @@ onMounted(load);
|
|||
><Plus class="w-3 h-3 mr-1" />Push</Button
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-[60px_1fr_32px] border-b bg-muted/50 shrink-0">
|
||||
<div class="grid grid-cols-[60px_1fr_84px] border-b bg-muted/50 shrink-0">
|
||||
<div class="px-3 py-1 text-xs font-medium text-muted-foreground border-r">#</div>
|
||||
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Value</div>
|
||||
<div />
|
||||
|
|
@ -321,11 +341,27 @@ onMounted(load);
|
|||
<div
|
||||
v-for="(item, idx) in collectionItems"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[60px_1fr_32px] border-b text-sm font-mono hover:bg-accent/50 group"
|
||||
class="grid grid-cols-[60px_1fr_84px] border-b text-sm font-mono hover:bg-accent/50 group"
|
||||
>
|
||||
<div class="px-3 py-1.5 text-xs text-muted-foreground border-r">{{ idx }}</div>
|
||||
<div class="px-3 py-1.5 truncate">{{ item }}</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.viewMember')"
|
||||
@click="viewMember(`#${idx}`, item)"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.copyMember')"
|
||||
@click="copyMember(item)"
|
||||
><Copy class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -356,7 +392,7 @@ onMounted(load);
|
|||
><Plus class="w-3 h-3 mr-1" />Add</Button
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-[1fr_32px] border-b bg-muted/50 shrink-0">
|
||||
<div class="grid grid-cols-[1fr_84px] border-b bg-muted/50 shrink-0">
|
||||
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Member</div>
|
||||
<div />
|
||||
</div>
|
||||
|
|
@ -364,10 +400,26 @@ onMounted(load);
|
|||
<div
|
||||
v-for="(item, idx) in collectionItems"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[1fr_32px] border-b text-sm font-mono hover:bg-accent/50 group"
|
||||
class="grid grid-cols-[1fr_84px] border-b text-sm font-mono hover:bg-accent/50 group"
|
||||
>
|
||||
<div class="px-3 py-1.5 truncate">{{ item }}</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.viewMember')"
|
||||
@click="viewMember(t('redis.member'), item)"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.copyMember')"
|
||||
@click="copyMember(item)"
|
||||
><Copy class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -399,7 +451,7 @@ onMounted(load);
|
|||
><Plus class="w-3 h-3 mr-1" />Set</Button
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(8rem,0.3fr)_1fr_32px] border-b bg-muted/50 shrink-0">
|
||||
<div class="grid grid-cols-[minmax(8rem,0.3fr)_1fr_84px] border-b bg-muted/50 shrink-0">
|
||||
<div class="px-3 py-1 text-xs font-medium text-muted-foreground border-r">Field</div>
|
||||
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Value</div>
|
||||
<div />
|
||||
|
|
@ -408,11 +460,27 @@ onMounted(load);
|
|||
<div
|
||||
v-for="(item, idx) in collectionItems"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[minmax(8rem,0.3fr)_1fr_32px] border-b text-sm font-mono hover:bg-accent/50 group"
|
||||
class="grid grid-cols-[minmax(8rem,0.3fr)_1fr_84px] border-b text-sm font-mono hover:bg-accent/50 group"
|
||||
>
|
||||
<div class="px-3 py-1.5 text-blue-500 truncate border-r">{{ item.field }}</div>
|
||||
<div class="px-3 py-1.5 truncate text-muted-foreground">{{ item.value }}</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.viewMember')"
|
||||
@click="viewMember(String(item.field), item.value)"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.copyMember')"
|
||||
@click="copyMember(item.value)"
|
||||
><Copy class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -444,7 +512,7 @@ onMounted(load);
|
|||
><Plus class="w-3 h-3 mr-1" />Add</Button
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-[100px_1fr_32px] border-b bg-muted/50 shrink-0">
|
||||
<div class="grid grid-cols-[100px_1fr_84px] border-b bg-muted/50 shrink-0">
|
||||
<div class="px-3 py-1 text-xs font-medium text-muted-foreground border-r">Score</div>
|
||||
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Member</div>
|
||||
<div />
|
||||
|
|
@ -453,11 +521,27 @@ onMounted(load);
|
|||
<div
|
||||
v-for="(item, idx) in collectionItems"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[100px_1fr_32px] border-b text-sm font-mono hover:bg-accent/50 group"
|
||||
class="grid grid-cols-[100px_1fr_84px] border-b text-sm font-mono hover:bg-accent/50 group"
|
||||
>
|
||||
<div class="px-3 py-1.5 text-muted-foreground text-xs border-r">{{ item.score }}</div>
|
||||
<div class="px-3 py-1.5 truncate">{{ item.member }}</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.viewMember')"
|
||||
@click="viewMember(String(item.score), item.member)"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.copyMember')"
|
||||
@click="copyMember(item.member)"
|
||||
><Copy class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -490,10 +574,28 @@ onMounted(load);
|
|||
<div
|
||||
v-for="(val, field) in entry.fields"
|
||||
:key="String(field)"
|
||||
class="grid grid-cols-[minmax(6rem,0.35fr)_1fr] gap-3 py-0.5"
|
||||
class="grid grid-cols-[minmax(6rem,0.35fr)_1fr_56px] gap-3 py-0.5 group"
|
||||
>
|
||||
<span class="truncate text-blue-500">{{ field }}</span>
|
||||
<span class="truncate text-muted-foreground">{{ val }}</span>
|
||||
<span class="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.viewMember')"
|
||||
@click="viewMember(String(field), val)"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.copyMember')"
|
||||
@click="copyMember(val)"
|
||||
><Copy class="w-3 h-3"
|
||||
/></Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -511,5 +613,26 @@ onMounted(load);
|
|||
:confirm-label="t('dangerDialog.deleteConfirm')"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
|
||||
<Dialog v-model:open="showMemberDetail">
|
||||
<DialogContent class="sm:max-w-[760px] max-h-[82vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2 pr-8">
|
||||
<span class="truncate">{{ selectedMemberTitle || t("redis.memberDetail") }}</span>
|
||||
<Badge variant="outline" class="shrink-0 text-xs">{{ selectedMemberDetail.format.toUpperCase() }}</Badge>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<pre
|
||||
class="min-h-0 flex-1 overflow-auto rounded-md border bg-muted/20 p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words"
|
||||
>{{ selectedMemberDetail.text }}</pre
|
||||
>
|
||||
<DialogFooter class="shrink-0">
|
||||
<Button variant="outline" @click="copyText(selectedMemberDetail.text)">
|
||||
<Copy class="h-4 w-4" />
|
||||
{{ t("redis.copyMember") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -715,6 +715,10 @@ export default {
|
|||
executeCommand: "Execute command",
|
||||
commandEmpty: "Enter a Redis command",
|
||||
commandBlocked: "This Redis command is blocked for safety",
|
||||
member: "Member",
|
||||
memberDetail: "Member detail",
|
||||
viewMember: "View full value",
|
||||
copyMember: "Copy member",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} documents",
|
||||
|
|
|
|||
|
|
@ -716,6 +716,10 @@ export default {
|
|||
executeCommand: "Ejecutar comando",
|
||||
commandEmpty: "Ingresa un comando Redis",
|
||||
commandBlocked: "Este comando Redis está bloqueado por seguridad",
|
||||
member: "Miembro",
|
||||
memberDetail: "Detalle del miembro",
|
||||
viewMember: "Ver valor completo",
|
||||
copyMember: "Copiar miembro",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} documentos",
|
||||
|
|
|
|||
|
|
@ -701,6 +701,10 @@ export default {
|
|||
executeCommand: "执行命令",
|
||||
commandEmpty: "请输入 Redis 命令",
|
||||
commandBlocked: "出于安全考虑,此 Redis 命令已被阻止",
|
||||
member: "成员",
|
||||
memberDetail: "成员详情",
|
||||
viewMember: "查看完整内容",
|
||||
copyMember: "复制成员",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} 个文档",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
export type RedisMemberDetailFormat = "json" | "text";
|
||||
|
||||
export interface RedisMemberDetail {
|
||||
text: string;
|
||||
format: RedisMemberDetailFormat;
|
||||
}
|
||||
|
||||
export function formatRedisMemberDetail(value: unknown): RedisMemberDetail {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
return { text: JSON.stringify(JSON.parse(trimmed), null, 2), format: "json" };
|
||||
} catch {
|
||||
/* fall through to plain text */
|
||||
}
|
||||
}
|
||||
return { text: value, format: "text" };
|
||||
}
|
||||
|
||||
try {
|
||||
return { text: JSON.stringify(value, null, 2), format: "json" };
|
||||
} catch {
|
||||
return { text: String(value), format: "text" };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { formatRedisMemberDetail } from "../src/lib/redisValuePresentation.ts";
|
||||
|
||||
test("formats JSON object strings for Redis member details", () => {
|
||||
const detail = formatRedisMemberDetail('{"id":1,"name":"Ada","tags":["dbx","redis"]}');
|
||||
|
||||
assert.equal(detail.format, "json");
|
||||
assert.equal(detail.text, '{\n "id": 1,\n "name": "Ada",\n "tags": [\n "dbx",\n "redis"\n ]\n}');
|
||||
});
|
||||
|
||||
test("keeps plain Redis member strings unchanged", () => {
|
||||
const detail = formatRedisMemberDetail("plain long member value");
|
||||
|
||||
assert.equal(detail.format, "text");
|
||||
assert.equal(detail.text, "plain long member value");
|
||||
});
|
||||
|
||||
test("formats non-string Redis member values as JSON", () => {
|
||||
const detail = formatRedisMemberDetail({ field: "name", value: '{"nested":true}' });
|
||||
|
||||
assert.equal(detail.format, "json");
|
||||
assert.equal(detail.text, '{\n "field": "name",\n "value": "{\\"nested\\":true}"\n}');
|
||||
});
|
||||
Loading…
Reference in New Issue