feat(redis): add Redis Instance Info Dashboard panel (#1411)

This commit is contained in:
dalew authored and GitHub committed 2026-06-24 13:21:00 +08:00
1 parent 4558984016
commit c8291d0d7d
6 files changed
+455 -1

No files matched your search

@@ -34,6 +34,7 @@ function preloadDataGridComponent() {
const DataGrid = defineAsyncComponent(loadDataGridComponent);
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
const RedisDashboard = defineAsyncComponent(() => import("@/components/redis/RedisDashboard.vue"));
const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/EtcdKeyBrowser.vue"));
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/VectorBrowser.vue"));
@@ -1003,6 +1004,13 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</div>
</template>
<!-- Redis Dashboard: instance info -->
<template v-else-if="activeTab.mode === 'redis-dashboard'">
<div class="flex-1 min-h-0">
<RedisDashboard :key="activeTab.id" :connection-id="activeTab.connectionId" />
</div>
</template>
<!-- etcd mode: key browser -->
<template v-else-if="activeTab.mode === 'etcd'">
<div class="flex-1 min-h-0">
@@ -0,0 +1,432 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import { RefreshCw, Loader2, Search, ChevronDown, ChevronRight, Clock, Info, HardDrive, Activity, Target, AlertCircle } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import * as api from "@/lib/api";
import type { RedisNodeEndpoint, RedisCommandResult } from "@/lib/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface InfoEntry {
key: string;
value: string;
}
interface InfoSection {
name: string;
entries: InfoEntry[];
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
const props = defineProps<{
connectionId: string;
}>();
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const loading = ref(true);
const fetching = ref(false);
const error = ref<string | null>(null);
const rawSections = ref<InfoSection[]>([]);
const masterNodes = ref<RedisNodeEndpoint[]>([]);
const selectedNodeIndex = ref("0"); // string for Select compatibility
const searchQuery = ref("");
const collapsedSections = ref<Set<string>>(new Set());
const autoRefreshInterval = ref<number>(0); // 0 = off, 5, 10, 30, 60 (seconds)
let refreshTimer: ReturnType<typeof setInterval> | null = null;
// ---------------------------------------------------------------------------
// Connection mode
// ---------------------------------------------------------------------------
const connectionMode = computed(() => {
return connectionStore.getConfig(props.connectionId)?.redis_connection_mode;
});
const isClusterMode = computed(() => connectionMode.value === "cluster");
const showNodeSelector = computed(() => isClusterMode.value);
const nodeOptions = computed(() => {
return masterNodes.value.map((n) => `${n.host}:${n.port}`);
});
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
function parseInfoText(text: string): InfoSection[] {
const sections: InfoSection[] = [];
let current: InfoSection | null = null;
for (const line of text.split("\n")) {
if (line.startsWith("# ")) {
current = { name: line.slice(2).trim(), entries: [] };
sections.push(current);
} else if (current && line.includes(":")) {
const idx = line.indexOf(":");
current.entries.push({ key: line.slice(0, idx), value: line.slice(idx + 1).replace(/\r$/, "") });
}
}
return sections;
}
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
async function fetchInfo() {
if (fetching.value) return;
fetching.value = true;
loading.value = true;
error.value = null;
try {
const result: RedisCommandResult = await api.redisExecuteCommand(props.connectionId, 0, "INFO ALL");
let infoText = "";
if (isClusterMode.value && Array.isArray(result.value)) {
// Cluster mode: result is [[addr, infoText], ...]
const pairs: [string, string][] = result.value.map((item: any) => {
if (Array.isArray(item) && item.length >= 2) return [String(item[0]), String(item[1])];
return ["", ""];
});
// Populate masterNodes from INFO response on first load if fetchClusterNodes failed
if (masterNodes.value.length === 0) {
masterNodes.value = pairs.map(([addr]) => {
const [host, portStr] = addr.split(":");
return { host, port: parseInt(portStr, 10) || 6379 };
});
}
// Match selected node by address, fall back to first node
const selectedAddr = nodeOptions.value[Number(selectedNodeIndex.value)];
const found = pairs.find(([addr]) => addr === selectedAddr);
if (found) {
infoText = found[1];
} else if (pairs.length > 0) {
infoText = pairs[0][1];
}
} else if (typeof result.value === "string") {
infoText = result.value;
}
rawSections.value = parseInfoText(infoText);
} catch (e: any) {
error.value = e?.message || String(e);
rawSections.value = [];
} finally {
loading.value = false;
fetching.value = false;
}
}
async function fetchClusterNodes() {
if (!isClusterMode.value) return;
try {
masterNodes.value = await api.redisClusterMasterNodes(props.connectionId);
} catch (e: any) {
// Nodes will be populated from the INFO response instead
console.warn("Failed to fetch cluster master nodes:", e?.message || e);
}
}
// ---------------------------------------------------------------------------
// Stat card helpers
// ---------------------------------------------------------------------------
function findValue(sectionName: string, key: string): string | undefined {
for (const sec of rawSections.value) {
if (sec.name === sectionName) {
for (const entry of sec.entries) {
if (entry.key === key) return entry.value;
}
}
}
return undefined;
}
const totalMemory = computed(() => {
return findValue("Memory", "total_system_memory_human") || findValue("Memory", "maxmemory_human") || "N/A";
});
const usedMemory = computed(() => {
return findValue("Memory", "used_memory_human") || "N/A";
});
const operation = computed(() => {
const val = findValue("Stats", "total_commands_processed");
if (val === undefined) return "N/A";
const num = parseInt(val, 10);
if (isNaN(num)) return val;
return num.toLocaleString();
});
const hitRatio = computed(() => {
const hits = findValue("Stats", "keyspace_hits");
const misses = findValue("Stats", "keyspace_misses");
if (hits === undefined || misses === undefined) return "N/A";
const h = parseInt(hits, 10);
const m = parseInt(misses, 10);
if (isNaN(h) || isNaN(m) || h + m === 0) return "N/A";
return `${((h / (h + m)) * 100).toFixed(2)}%`;
});
// ---------------------------------------------------------------------------
// Search / filter
// ---------------------------------------------------------------------------
const filteredSections = computed(() => {
const q = searchQuery.value.trim().toLowerCase();
if (!q) return rawSections.value;
return rawSections.value
.map((sec) => ({
...sec,
entries: sec.entries.filter((e) => e.key.toLowerCase().includes(q)),
}))
.filter((sec) => sec.entries.length > 0);
});
function toggleSection(name: string) {
const next = new Set(collapsedSections.value);
if (next.has(name)) next.delete(name);
else next.add(name);
collapsedSections.value = next;
}
function isCollapsed(name: string): boolean {
return collapsedSections.value.has(name);
}
// ---------------------------------------------------------------------------
// Auto-refresh
// ---------------------------------------------------------------------------
function startAutoRefresh() {
stopAutoRefresh();
if (autoRefreshInterval.value > 0) {
refreshTimer = setInterval(() => {
fetchInfo();
}, autoRefreshInterval.value * 1000);
}
}
function stopAutoRefresh() {
if (refreshTimer !== null) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
watch(autoRefreshInterval, () => {
startAutoRefresh();
});
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
onMounted(async () => {
await fetchClusterNodes();
await fetchInfo();
});
onUnmounted(() => {
stopAutoRefresh();
});
// ---------------------------------------------------------------------------
// Refresh button
// ---------------------------------------------------------------------------
async function handleRefresh() {
// Re-fetch cluster nodes only if in cluster mode
if (isClusterMode.value) {
await fetchClusterNodes();
}
await fetchInfo();
}
// ---------------------------------------------------------------------------
// Copy helpers
// ---------------------------------------------------------------------------
function copyToClipboard(text: string) {
navigator.clipboard
.writeText(text)
.then(() => {
toast("Copied", 1500);
})
.catch(() => {
// Fallback for environments without clipboard API
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
});
}
function copyEntryKey(entry: InfoEntry) {
copyToClipboard(entry.key);
}
function copyEntryValue(entry: InfoEntry) {
copyToClipboard(entry.value);
}
</script>
<template>
<div class="h-full flex flex-col bg-background">
<!-- Header toolbar -->
<div class="shrink-0 border-b bg-muted/20 px-4 py-2">
<!-- Top row: node selector (cluster only) + refresh controls -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<div v-if="showNodeSelector && nodeOptions.length > 0" class="flex items-center gap-2">
<span class="text-xs text-muted-foreground whitespace-nowrap">Node:</span>
<Select v-model="selectedNodeIndex" @update:model-value="fetchInfo">
<SelectTrigger class="h-7 w-auto min-w-[140px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="(node, i) in nodeOptions" :key="i" :value="String(i)" class="text-xs">
{{ node }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<!-- Auto-refresh select -->
<Select v-model="autoRefreshInterval">
<SelectTrigger class="h-7 w-auto min-w-[80px] text-xs">
<Clock class="mr-1 h-3 w-3" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="0" class="text-xs">Off</SelectItem>
<SelectItem :value="5" class="text-xs">5s</SelectItem>
<SelectItem :value="10" class="text-xs">10s</SelectItem>
<SelectItem :value="30" class="text-xs">30s</SelectItem>
<SelectItem :value="60" class="text-xs">60s</SelectItem>
</SelectContent>
</Select>
<!-- Refresh button -->
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :disabled="loading" @click="handleRefresh">
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
<RefreshCw v-else class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Refresh</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<!-- Error banner -->
<div v-if="error" class="shrink-0 mx-4 mt-2 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<AlertCircle class="h-3.5 w-3.5 shrink-0" />
<span>{{ error }}</span>
</div>
<!-- Scrollable content -->
<div class="flex-1 min-h-0 overflow-auto">
<!-- Stat cards -->
<div class="grid grid-cols-4 gap-3 px-4 pt-3 pb-2">
<!-- Total Memory -->
<div class="rounded-lg border bg-card text-card-foreground p-3">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<HardDrive class="h-3.5 w-3.5" />
<span>Total Memory</span>
</div>
<div class="text-xl font-semibold tabular-nums dbx-editor-font-family truncate" :title="totalMemory">
{{ totalMemory }}
</div>
</div>
<!-- Used Memory -->
<div class="rounded-lg border bg-card text-card-foreground p-3">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<Activity class="h-3.5 w-3.5" />
<span>Used Memory</span>
</div>
<div class="text-xl font-semibold tabular-nums dbx-editor-font-family truncate" :title="usedMemory">
{{ usedMemory }}
</div>
</div>
<!-- Operation -->
<div class="rounded-lg border bg-card text-card-foreground p-3">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<Info class="h-3.5 w-3.5" />
<span>Operation</span>
</div>
<div class="text-xl font-semibold tabular-nums dbx-editor-font-family truncate" :title="operation">
{{ operation }}
</div>
</div>
<!-- Hit Ratio -->
<div class="rounded-lg border bg-card text-card-foreground p-3">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<Target class="h-3.5 w-3.5" />
<span>Hit Ratio</span>
</div>
<div class="text-xl font-semibold tabular-nums dbx-editor-font-family truncate" :title="hitRatio">
{{ hitRatio }}
</div>
</div>
</div>
<!-- Search bar -->
<div class="px-4 pb-2">
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input v-model="searchQuery" class="h-8 pl-7 text-xs" placeholder="Search indicators..." />
</div>
</div>
<!-- Loading state -->
<div v-if="loading && rawSections.length === 0" class="flex items-center justify-center py-12 text-xs text-muted-foreground">
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<!-- Info sections -->
<div v-else-if="filteredSections.length > 0" class="px-4 pb-4 space-y-1">
<div v-for="section in filteredSections" :key="section.name" class="rounded-lg border overflow-hidden">
<!-- Section header (clickable) -->
<button class="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-foreground hover:bg-muted/50 transition-colors text-left" @click="toggleSection(section.name)">
<component :is="isCollapsed(section.name) ? ChevronRight : ChevronDown" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{{ section.name }}</span>
<span class="ml-auto text-muted-foreground font-normal">{{ section.entries.length }}</span>
</button>
<!-- Section body -->
<div v-if="!isCollapsed(section.name)" class="border-t">
<table class="w-full text-xs border-collapse">
<tbody>
<tr v-for="(entry, ei) in section.entries" :key="ei" class="hover:bg-muted/20">
<td class="w-1/2 border-r border-b px-3 py-1.5 text-muted-foreground select-text break-all cursor-context-menu" :title="t('common.copy') || 'Copy'" @contextmenu.prevent="copyEntryKey(entry)">{{ entry.key }}</td>
<td class="w-1/2 border-b px-3 py-1.5 dbx-editor-font-family tabular-nums select-text break-all cursor-context-menu" :title="t('common.copy') || 'Copy'" @contextmenu.prevent="copyEntryValue(entry)">{{ entry.value }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Empty / no data -->
<div v-else-if="!loading" class="flex flex-col items-center justify-center py-12 text-xs text-muted-foreground">
<Info class="mb-2 h-5 w-5" />
<span v-if="searchQuery">No matching indicators</span>
<span v-else>No data available</span>
</div>
</div>
</div>
</template>
@@ -53,6 +53,7 @@ import {
FilePlus,
SquarePen,
ListX,
Info,
} from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -1085,6 +1086,14 @@ async function newQuery() {
// SQL template helpers have been extracted to @/lib/tableSqlTemplates.ts
// ---- Template actions ----
function openRedisInstanceInfo() {
const node = props.node;
if (!node.connectionId) return;
const config = connectionStore.getConfig(node.connectionId);
const dbName = config?.name || "Redis";
queryStore.createTab(node.connectionId, "0", `${dbName} - ${t("contextMenu.instanceInfo")}`, "redis-dashboard");
}
async function loadTemplateContext(allowView = false) {
const node = props.node;
if (!node.connectionId || !hasTreeNodeDatabaseContext(node)) return null;
@@ -3350,6 +3359,9 @@ function treeItemMenuItems(): ContextMenuItem[] {
items.push({ label: t("contextMenu.closeConnection"), action: disconnectConnection, icon: Unplug });
}
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
if (currentDatabaseType() === "redis") {
items.push({ label: t("contextMenu.instanceInfo"), action: openRedisInstanceInfo, icon: Info });
}
const sqlHistoryMenu = savedSqlHistorySubmenu();
if (sqlHistoryMenu) items.push(sqlHistoryMenu);
if (supportsDatabaseUserAdmin(currentDatabaseType())) {