feat(监控): 实现Redis监控页面
- 新增Redis监控主页面,展示Redis服务器状态和性能指标 - 实现命令统计饼图组件,支持SVG绘制和交互悬停效果 - 实现内存消耗仪表盘组件,采用圆环进度条设计 - 智能处理Redis无限制内存配置的显示逻辑 - 集成Redis基本信息展示和实时数据刷新功能
This commit is contained in:
parent
19180e5702
commit
a361b7f7a0
332
src/views/monitor/redis/components/CommandStatsPieChart.vue
Normal file
332
src/views/monitor/redis/components/CommandStatsPieChart.vue
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pie-chart-container">
|
||||||
|
<div v-if="!data || data.length === 0" class="empty-state">
|
||||||
|
<n-empty description="暂无命令统计数据">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><IconParkOutlineChartPie /></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-empty>
|
||||||
|
</div>
|
||||||
|
<div v-else class="chart-content">
|
||||||
|
<!-- SVG饼图 -->
|
||||||
|
<svg class="pie-chart" :width="chartSize" :height="chartSize" :viewBox="`0 0 ${chartSize} ${chartSize}`">
|
||||||
|
<!-- 饼图扇形 -->
|
||||||
|
<g :transform="`translate(${chartSize / 2}, ${chartSize / 2})`">
|
||||||
|
<path
|
||||||
|
v-for="(segment, index) in pieSegments"
|
||||||
|
:key="index"
|
||||||
|
:d="segment.path"
|
||||||
|
:fill="segment.color"
|
||||||
|
stroke="var(--card-color)"
|
||||||
|
:stroke-width="2"
|
||||||
|
class="pie-segment"
|
||||||
|
@mouseenter="hoveredIndex = index"
|
||||||
|
@mouseleave="hoveredIndex = -1"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 中心文本 -->
|
||||||
|
<g :transform="`translate(${chartSize / 2}, ${chartSize / 2})`">
|
||||||
|
<text
|
||||||
|
class="center-text-title"
|
||||||
|
text-anchor="middle"
|
||||||
|
:y="-8"
|
||||||
|
fill="var(--text-color-1)"
|
||||||
|
>
|
||||||
|
命令统计
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
class="center-text-value"
|
||||||
|
text-anchor="middle"
|
||||||
|
:y="12"
|
||||||
|
fill="var(--text-color-2)"
|
||||||
|
>
|
||||||
|
{{ totalCommands.toLocaleString() }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 图例 -->
|
||||||
|
<div class="chart-legend">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in topCommands"
|
||||||
|
:key="index"
|
||||||
|
class="legend-item"
|
||||||
|
:class="{ active: hoveredIndex === index }"
|
||||||
|
@mouseenter="hoveredIndex = index"
|
||||||
|
@mouseleave="hoveredIndex = -1"
|
||||||
|
>
|
||||||
|
<div class="legend-color" :style="{ backgroundColor: colors[index] }" />
|
||||||
|
<div class="legend-content">
|
||||||
|
<div class="legend-name">
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
<div class="legend-value">
|
||||||
|
{{ Number(item.value).toLocaleString() }} ({{ item.percentage }}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="otherCommands > 0" class="legend-item">
|
||||||
|
<div class="legend-color" :style="{ backgroundColor: colors[topCommands.length] }" />
|
||||||
|
<div class="legend-content">
|
||||||
|
<div class="legend-name">
|
||||||
|
其他
|
||||||
|
</div>
|
||||||
|
<div class="legend-value">
|
||||||
|
{{ otherCommands.toLocaleString() }} ({{ otherPercentage }}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { RedisCommandStatVo } from '@/service/api/monitor/redis'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
interface Props {
|
||||||
|
data: RedisCommandStatVo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const hoveredIndex = ref(-1)
|
||||||
|
const chartSize = 240
|
||||||
|
const radius = 80
|
||||||
|
|
||||||
|
// 颜色配置
|
||||||
|
const colors = [
|
||||||
|
'#18a058',
|
||||||
|
'#2080f0',
|
||||||
|
'#f0a020',
|
||||||
|
'#d03050',
|
||||||
|
'#722ed1',
|
||||||
|
'#eb2f96',
|
||||||
|
'#fa8c16',
|
||||||
|
'#52c41a',
|
||||||
|
'#13c2c2',
|
||||||
|
'#1890ff',
|
||||||
|
]
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const totalCommands = computed(() => {
|
||||||
|
return props.data.reduce((sum, item) => sum + Number(item.value), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 取前8个命令,其余归为"其他"
|
||||||
|
const topCommands = computed(() => {
|
||||||
|
const sorted = [...props.data]
|
||||||
|
.sort((a, b) => Number(b.value) - Number(a.value))
|
||||||
|
.slice(0, 8)
|
||||||
|
|
||||||
|
return sorted.map(item => ({
|
||||||
|
...item,
|
||||||
|
percentage: totalCommands.value > 0
|
||||||
|
? ((Number(item.value) / totalCommands.value) * 100).toFixed(1)
|
||||||
|
: '0.0',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const otherCommands = computed(() => {
|
||||||
|
if (props.data.length <= 8)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
const topTotal = topCommands.value.reduce((sum, item) => sum + Number(item.value), 0)
|
||||||
|
return totalCommands.value - topTotal
|
||||||
|
})
|
||||||
|
|
||||||
|
const otherPercentage = computed(() => {
|
||||||
|
if (otherCommands.value === 0 || totalCommands.value === 0)
|
||||||
|
return '0.0'
|
||||||
|
return ((otherCommands.value / totalCommands.value) * 100).toFixed(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 饼图扇形数据
|
||||||
|
const pieSegments = computed(() => {
|
||||||
|
const segments = []
|
||||||
|
let currentAngle = -Math.PI / 2 // 从12点钟方向开始
|
||||||
|
|
||||||
|
// 添加主要命令扇形
|
||||||
|
topCommands.value.forEach((item, index) => {
|
||||||
|
const percentage = Number(item.value) / totalCommands.value
|
||||||
|
const angle = percentage * 2 * Math.PI
|
||||||
|
|
||||||
|
if (angle > 0) {
|
||||||
|
const path = createArcPath(currentAngle, currentAngle + angle, radius)
|
||||||
|
segments.push({
|
||||||
|
path,
|
||||||
|
color: colors[index],
|
||||||
|
percentage: item.percentage,
|
||||||
|
})
|
||||||
|
currentAngle += angle
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加"其他"扇形
|
||||||
|
if (otherCommands.value > 0) {
|
||||||
|
const percentage = otherCommands.value / totalCommands.value
|
||||||
|
const angle = percentage * 2 * Math.PI
|
||||||
|
|
||||||
|
if (angle > 0) {
|
||||||
|
const path = createArcPath(currentAngle, currentAngle + angle, radius)
|
||||||
|
segments.push({
|
||||||
|
path,
|
||||||
|
color: colors[topCommands.value.length],
|
||||||
|
percentage: otherPercentage.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建弧形路径
|
||||||
|
function createArcPath(startAngle: number, endAngle: number, radius: number): string {
|
||||||
|
const x1 = Math.cos(startAngle) * radius
|
||||||
|
const y1 = Math.sin(startAngle) * radius
|
||||||
|
const x2 = Math.cos(endAngle) * radius
|
||||||
|
const y2 = Math.sin(endAngle) * radius
|
||||||
|
|
||||||
|
const largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0
|
||||||
|
|
||||||
|
return [
|
||||||
|
'M',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
'L',
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
'A',
|
||||||
|
radius,
|
||||||
|
radius,
|
||||||
|
0,
|
||||||
|
largeArcFlag,
|
||||||
|
1,
|
||||||
|
x2,
|
||||||
|
y2,
|
||||||
|
'Z',
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pie-chart-container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-chart {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-segment {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-segment:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-text-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-text-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-legend {
|
||||||
|
flex: 1;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item:hover,
|
||||||
|
.legend-item.active {
|
||||||
|
background-color: var(--hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color-1);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-color-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
.chart-legend::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-legend::-webkit-scrollbar-track {
|
||||||
|
background: var(--scrollbar-track-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-legend::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-legend::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--scrollbar-thumb-hover-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
272
src/views/monitor/redis/components/MemoryGaugeChart.vue
Normal file
272
src/views/monitor/redis/components/MemoryGaugeChart.vue
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
<template>
|
||||||
|
<div class="memory-chart-container">
|
||||||
|
<div class="chart-content">
|
||||||
|
<!-- 圆环图表 -->
|
||||||
|
<div class="ring-chart-wrapper">
|
||||||
|
<svg class="ring-chart" :width="chartSize" :height="chartSize" :viewBox="`0 0 ${chartSize} ${chartSize}`">
|
||||||
|
<!-- 背景圆环 -->
|
||||||
|
<circle
|
||||||
|
:cx="centerX"
|
||||||
|
:cy="centerY"
|
||||||
|
:r="radius"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--border-color)"
|
||||||
|
:stroke-width="strokeWidth"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 进度圆环 -->
|
||||||
|
<circle
|
||||||
|
:cx="centerX"
|
||||||
|
:cy="centerY"
|
||||||
|
:r="radius"
|
||||||
|
fill="none"
|
||||||
|
:stroke="progressColor"
|
||||||
|
:stroke-width="strokeWidth"
|
||||||
|
:stroke-dasharray="circumference"
|
||||||
|
:stroke-dashoffset="strokeDashoffset"
|
||||||
|
stroke-linecap="round"
|
||||||
|
class="progress-ring"
|
||||||
|
:transform="`rotate(-90 ${centerX} ${centerY})`"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 中心内容 -->
|
||||||
|
<g :transform="`translate(${centerX}, ${centerY})`">
|
||||||
|
<text
|
||||||
|
class="usage-percentage"
|
||||||
|
text-anchor="middle"
|
||||||
|
:y="-8"
|
||||||
|
fill="var(--text-color-1)"
|
||||||
|
>
|
||||||
|
{{ formattedUsage }}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
class="usage-label"
|
||||||
|
text-anchor="middle"
|
||||||
|
:y="12"
|
||||||
|
fill="var(--text-color-3)"
|
||||||
|
>
|
||||||
|
内存使用率
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内存统计信息 -->
|
||||||
|
<div class="memory-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">
|
||||||
|
已用内存
|
||||||
|
</div>
|
||||||
|
<div class="stat-value" :style="{ color: progressColor }">
|
||||||
|
{{ formatBytes(usedMemory) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">
|
||||||
|
总内存
|
||||||
|
</div>
|
||||||
|
<div class="stat-value">
|
||||||
|
{{ formatBytes(maxMemory) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">
|
||||||
|
可用内存
|
||||||
|
</div>
|
||||||
|
<div class="stat-value">
|
||||||
|
{{ formatBytes(availableMemory) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">
|
||||||
|
状态
|
||||||
|
</div>
|
||||||
|
<div class="stat-value" :style="{ color: statusColor }">
|
||||||
|
{{ memoryStatus }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
interface Props {
|
||||||
|
usedMemory: number
|
||||||
|
maxMemory: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// 图表配置
|
||||||
|
const chartSize = 280
|
||||||
|
const centerX = chartSize / 2
|
||||||
|
const centerY = chartSize / 2
|
||||||
|
const radius = 90
|
||||||
|
const strokeWidth = 16
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const memoryUsage = computed(() => {
|
||||||
|
if (props.maxMemory === 0 || props.usedMemory === 0)
|
||||||
|
return 0
|
||||||
|
const usage = (props.usedMemory / props.maxMemory) * 100
|
||||||
|
return Math.min(Math.max(usage, 0), 100) // 确保在0-100之间
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedUsage = computed(() => {
|
||||||
|
return `${memoryUsage.value.toFixed(1)}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableMemory = computed(() => {
|
||||||
|
return Math.max(props.maxMemory - props.usedMemory, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 圆环图相关计算
|
||||||
|
const circumference = computed(() => 2 * Math.PI * radius)
|
||||||
|
|
||||||
|
const strokeDashoffset = computed(() => {
|
||||||
|
const progress = memoryUsage.value / 100
|
||||||
|
return circumference.value * (1 - progress)
|
||||||
|
})
|
||||||
|
|
||||||
|
const progressColor = computed(() => {
|
||||||
|
const usage = memoryUsage.value
|
||||||
|
if (usage < 50)
|
||||||
|
return '#18a058' // 绿色
|
||||||
|
if (usage < 80)
|
||||||
|
return '#f0a020' // 黄色
|
||||||
|
return '#d03050' // 红色
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusColor = computed(() => {
|
||||||
|
return progressColor.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const memoryStatus = computed(() => {
|
||||||
|
const usage = memoryUsage.value
|
||||||
|
if (usage === 0)
|
||||||
|
return '未知'
|
||||||
|
if (usage < 50)
|
||||||
|
return '良好'
|
||||||
|
if (usage < 80)
|
||||||
|
return '警告'
|
||||||
|
return '危险'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 格式化字节
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0)
|
||||||
|
return '0 B'
|
||||||
|
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
|
||||||
|
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.memory-chart-container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-chart-wrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-chart {
|
||||||
|
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-ring {
|
||||||
|
transition: stroke-dashoffset 1.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
stroke 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-ring:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-percentage {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-stats {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item:hover {
|
||||||
|
background: rgba(var(--primary-color-rgb), 0.08);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-color-1);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.chart-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-stats {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
316
src/views/monitor/redis/index.vue
Normal file
316
src/views/monitor/redis/index.vue
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
<template>
|
||||||
|
<div class="redis-monitor-container">
|
||||||
|
<!-- 页面头部 -->
|
||||||
|
<div class="page-header mb-6">
|
||||||
|
<div class="header-left">
|
||||||
|
<h2 class="page-title">
|
||||||
|
Redis监控
|
||||||
|
</h2>
|
||||||
|
<p class="page-subtitle">
|
||||||
|
实时监控Redis服务器状态和性能指标
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<n-button type="primary" :loading="loading" @click="refreshData">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><IconParkOutlineRefresh /></n-icon>
|
||||||
|
</template>
|
||||||
|
刷新数据
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Redis基本信息 -->
|
||||||
|
<n-card title="基本信息" class="mb-6">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-icon><IconParkOutlineServer /></n-icon>
|
||||||
|
</template>
|
||||||
|
<n-grid :x-gap="16" :y-gap="16">
|
||||||
|
<n-gi :span="8">
|
||||||
|
<n-descriptions :column="1" label-placement="left" :label-style="{ width: '100px' }">
|
||||||
|
<n-descriptions-item label="Redis版本">
|
||||||
|
{{ getRedisInfo('redis_version') || 'N/A' }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="运行模式">
|
||||||
|
{{ getRedisMode() }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="端口">
|
||||||
|
{{ getRedisInfo('tcp_port') || 'N/A' }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="客户端连接">
|
||||||
|
{{ getRedisInfo('connected_clients') || '0' }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi :span="8">
|
||||||
|
<n-descriptions :column="1" label-placement="left" :label-style="{ width: '120px' }">
|
||||||
|
<n-descriptions-item label="运行时间(天)">
|
||||||
|
{{ formatUptime(getRedisInfo('uptime_in_days')) }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="使用内存">
|
||||||
|
{{ formatMemory(getRedisInfo('used_memory_human')) }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="最大内存">
|
||||||
|
{{ getRedisInfo('maxmemory') === '0' ? '无限制' : formatBytes(getRedisInfo('maxmemory')) }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="使用CPU">
|
||||||
|
{{ getRedisInfo('used_cpu_user_children') || '0' }}%
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="Key数量">
|
||||||
|
{{ redisData?.dbSize || 0 }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi :span="8">
|
||||||
|
<n-descriptions :column="1" label-placement="left" :label-style="{ width: '120px' }">
|
||||||
|
<n-descriptions-item label="网络输入/输出">
|
||||||
|
{{ formatNetwork() }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="AOF是否开启">
|
||||||
|
{{ getRedisInfo('aof_enabled') === '1' ? '是' : '否' }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="RDB是否成功">
|
||||||
|
{{ getRedisInfo('rdb_last_save_time') ? '是' : '否' }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="Key命中率">
|
||||||
|
{{ getKeyHitRate() }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<!-- 图表区域 -->
|
||||||
|
<n-grid :x-gap="16" :y-gap="16">
|
||||||
|
<!-- 命令统计饼图 -->
|
||||||
|
<n-gi :span="12">
|
||||||
|
<n-card title="命令统计" class="chart-card">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-icon><IconParkOutlineChartPie /></n-icon>
|
||||||
|
</template>
|
||||||
|
<div class="chart-container">
|
||||||
|
<CommandStatsPieChart :data="redisData?.commandStats || []" />
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
|
||||||
|
<!-- 内存消耗仪表盘 -->
|
||||||
|
<n-gi :span="12">
|
||||||
|
<n-card title="内存消耗" class="chart-card">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-icon><IconParkOutlineData /></n-icon>
|
||||||
|
</template>
|
||||||
|
<div class="chart-container">
|
||||||
|
<MemoryGaugeChart :used-memory="getUsedMemory()" :max-memory="getMaxMemory()" />
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { coiMsgError, coiMsgSuccess } from '@/utils/coi'
|
||||||
|
import { getRedisInformation } from '@/service/api/monitor/redis'
|
||||||
|
import type { RedisInfoVo } from '@/service/api/monitor/redis'
|
||||||
|
import CommandStatsPieChart from './components/CommandStatsPieChart.vue'
|
||||||
|
import MemoryGaugeChart from './components/MemoryGaugeChart.vue'
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const loading = ref(false)
|
||||||
|
const redisData = ref<RedisInfoVo>()
|
||||||
|
|
||||||
|
// 获取Redis信息的辅助函数
|
||||||
|
function getRedisInfo(key: string): string {
|
||||||
|
return redisData.value?.info?.[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取Redis运行模式
|
||||||
|
function getRedisMode(): string {
|
||||||
|
const mode = getRedisInfo('redis_mode')
|
||||||
|
if (mode === 'standalone')
|
||||||
|
return '单机'
|
||||||
|
if (mode === 'cluster')
|
||||||
|
return '集群'
|
||||||
|
if (mode === 'sentinel')
|
||||||
|
return '哨兵'
|
||||||
|
return mode || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化运行时间
|
||||||
|
function formatUptime(days: string): string {
|
||||||
|
const numDays = Number(days) || 0
|
||||||
|
return `${numDays}天`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化内存
|
||||||
|
function formatMemory(memory: string): string {
|
||||||
|
return memory || '0B'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化网络输入输出
|
||||||
|
function formatNetwork(): string {
|
||||||
|
const inputKb = getRedisInfo('total_net_input_bytes')
|
||||||
|
const outputKb = getRedisInfo('total_net_output_bytes')
|
||||||
|
return `${formatBytes(inputKb)}/${formatBytes(outputKb)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化字节
|
||||||
|
function formatBytes(bytes: string): string {
|
||||||
|
const num = Number(bytes) || 0
|
||||||
|
if (num === 0)
|
||||||
|
return '0B'
|
||||||
|
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(num) / Math.log(k))
|
||||||
|
|
||||||
|
return `${Number.parseFloat((num / k ** i).toFixed(1))}${sizes[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取Key命中率
|
||||||
|
function getKeyHitRate(): string {
|
||||||
|
const hits = Number(getRedisInfo('keyspace_hits')) || 0
|
||||||
|
const misses = Number(getRedisInfo('keyspace_misses')) || 0
|
||||||
|
const total = hits + misses
|
||||||
|
|
||||||
|
if (total === 0)
|
||||||
|
return '0.00%'
|
||||||
|
|
||||||
|
const rate = (hits / total) * 100
|
||||||
|
return `${rate.toFixed(2)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取已用内存(数值)
|
||||||
|
function getUsedMemory(): number {
|
||||||
|
const used = getRedisInfo('used_memory')
|
||||||
|
return Number(used) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最大内存(数值)
|
||||||
|
function getMaxMemory(): number {
|
||||||
|
const max = getRedisInfo('maxmemory')
|
||||||
|
const maxMemory = Number(max) || 0
|
||||||
|
|
||||||
|
// 如果Redis没有设置maxmemory(值为0),尝试获取系统总内存作为参考
|
||||||
|
if (maxMemory === 0) {
|
||||||
|
const systemMemory = getRedisInfo('total_system_memory')
|
||||||
|
if (systemMemory) {
|
||||||
|
return Number(systemMemory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有系统内存信息,使用一个合理的默认值
|
||||||
|
// 基于已用内存估算一个合理的总内存(已用内存的2-4倍作为参考)
|
||||||
|
const used = getUsedMemory()
|
||||||
|
if (used > 0) {
|
||||||
|
return Math.max(used * 3, 1024 * 1024 * 1024) // 至少1GB
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后的兜底值:2GB
|
||||||
|
return 2 * 1024 * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxMemory
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取Redis监控数据
|
||||||
|
async function fetchRedisData() {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const { data, isSuccess } = await getRedisInformation()
|
||||||
|
|
||||||
|
if (isSuccess && data) {
|
||||||
|
redisData.value = data
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
coiMsgError('获取Redis监控数据失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('获取Redis监控数据异常:', error)
|
||||||
|
coiMsgError('获取Redis监控数据异常')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新数据
|
||||||
|
async function refreshData() {
|
||||||
|
await fetchRedisData()
|
||||||
|
coiMsgSuccess('数据刷新成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载时获取数据
|
||||||
|
onMounted(() => {
|
||||||
|
fetchRedisData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.redis-monitor-container {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: var(--body-color);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color-1);
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-color-3);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 420px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-card) {
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-card .n-card__header) {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-card .n-card__content) {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-descriptions .n-descriptions-item-label) {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue
Block a user