feat(redis): add Redis Instance Info Dashboard panel (#1411)
This commit is contained in:
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())) {
|
||||
|
||||
Reference in new issue
Block a user