feat(监控): 实现Redis监控页面

- 新增Redis监控主页面,展示Redis服务器状态和性能指标
- 实现命令统计饼图组件,支持SVG绘制和交互悬停效果
- 实现内存消耗仪表盘组件,采用圆环进度条设计
- 智能处理Redis无限制内存配置的显示逻辑
- 集成Redis基本信息展示和实时数据刷新功能
This commit is contained in:
Leo 2025-09-28 00:09:19 +08:00
parent 19180e5702
commit a361b7f7a0
3 changed files with 920 additions and 0 deletions

View 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>

View 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>

View 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
// Redismaxmemory0
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>