feat: 新增 redis 监控

This commit is contained in:
xlsea 2025-04-27 18:32:40 +08:00
parent 9edd78e581
commit 63b49f1d40
9 changed files with 762 additions and 2 deletions

View File

@ -185,7 +185,8 @@ const local: App.I18n.Schema = {
system_notice: 'Notice Management',
'social-callback': 'Social Callback',
system_oss: 'File Management',
'system_oss-config': 'OSS Config'
'system_oss-config': 'OSS Config',
monitor_cache: 'Cache Monitor'
},
page: {
login: {

View File

@ -185,7 +185,8 @@ const local: App.I18n.Schema = {
system_notice: '通知公告',
'social-callback': '单点登录回调',
system_oss: '文件管理',
'system_oss-config': 'OSS配置'
'system_oss-config': 'OSS配置',
monitor_cache: '缓存监控'
},
page: {
login: {

View File

@ -22,6 +22,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
login: () => import("@/views/_builtin/login/index.vue"),
"social-callback": () => import("@/views/_builtin/social-callback/index.vue"),
home: () => import("@/views/home/index.vue"),
monitor_cache: () => import("@/views/monitor/cache/index.vue"),
"monitor_login-infor": () => import("@/views/monitor/login-infor/index.vue"),
"monitor_oper-log": () => import("@/views/monitor/oper-log/index.vue"),
system_client: () => import("@/views/system/client/index.vue"),

View File

@ -84,6 +84,15 @@ export const generatedRoutes: GeneratedRoute[] = [
i18nKey: 'route.monitor'
},
children: [
{
name: 'monitor_cache',
path: '/monitor/cache',
component: 'view.monitor_cache',
meta: {
title: 'monitor_cache',
i18nKey: 'route.monitor_cache'
}
},
{
name: 'monitor_login-infor',
path: '/monitor/login-infor',

View File

@ -170,6 +170,7 @@ const routeMap: RouteMap = {
"iframe-page": "/iframe-page/:url",
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?",
"monitor": "/monitor",
"monitor_cache": "/monitor/cache",
"monitor_login-infor": "/monitor/login-infor",
"monitor_oper-log": "/monitor/oper-log",
"social-callback": "/social-callback",

View File

@ -0,0 +1,8 @@
import { request } from '@/service/request';
export function fetchGetMonitorCacheInfo() {
return request<Api.Monitor.CacheInfo>({
url: '/monitor/cache',
method: 'get'
});
}

View File

@ -94,5 +94,45 @@ declare namespace Api {
/** login infor list */
type LoginInforList = Api.Common.PaginatingQueryRecord<LoginInfor>;
/** cache info */
type CacheInfo = Common.CommonRecord<{
/** info */
info: {
/** Redis 版本 */
redis_version: string;
/** 运行模式 */
redis_mode: string;
/** 端口 */
tcp_port: number;
/** 客户端数 */
connected_clients: number;
/** 运行时间(天) */
uptime_in_days: number;
/** 使用内存 */
used_memory_human: string;
/** 使用 CPU */
used_cpu_user_children: string;
/** 内存配置 */
maxmemory_human: number;
/** AOF 是否开启 */
aof_enabled: string;
/** RDB 是否成功 */
rdb_last_bgsave_status: string;
/** Key 数量 */
dbSize: number;
/** 网络入口 */
instantaneous_input_kbps: number;
/** 网络出口 */
instantaneous_output_kbps: number;
};
/** db size */
dbSize: number;
/** command stats */
commandStats: {
name: string;
value: number;
}[];
}>;
}
}

View File

@ -24,6 +24,7 @@ declare module "@elegant-router/types" {
"iframe-page": "/iframe-page/:url";
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?";
"monitor": "/monitor";
"monitor_cache": "/monitor/cache";
"monitor_login-infor": "/monitor/login-infor";
"monitor_oper-log": "/monitor/oper-log";
"social-callback": "/social-callback";
@ -107,6 +108,7 @@ declare module "@elegant-router/types" {
| "login"
| "social-callback"
| "home"
| "monitor_cache"
| "monitor_login-infor"
| "monitor_oper-log"
| "system_client"

697
src/views/monitor/cache/index.vue vendored Normal file
View File

@ -0,0 +1,697 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useLoading } from '@sa/hooks';
import { fetchGetMonitorCacheInfo } from '@/service/api/monitor/cache';
import { useEcharts } from '@/hooks/common/echarts';
const { loading, startLoading, endLoading } = useLoading();
const cacheInfo = ref<Api.Monitor.CacheInfo>();
const fetchError = ref<string | null>(null);
//
const autoRefresh = ref(false);
// 30
const refreshInterval = ref(30);
const refreshTimer = ref<NodeJS.Timeout | null>(null);
async function getCacheInfo() {
startLoading();
fetchError.value = null;
try {
const { error, data } = await fetchGetMonitorCacheInfo();
if (!error) {
cacheInfo.value = data;
//
nextTick(() => {
updateCharts();
//
updateMemoryChart();
});
} else {
fetchError.value = '获取缓存信息失败';
}
} catch {
fetchError.value = '获取缓存信息出错';
} finally {
endLoading();
}
}
//
function handleRefresh() {
return getCacheInfo()
.then(() => {
//
nextTick(() => {
forceUpdateCharts();
});
})
.catch(() => {
// 使
nextTick(() => {
if (cacheInfo.value) {
forceUpdateCharts();
}
});
});
}
//
function toggleAutoRefresh() {
if (autoRefresh.value) {
startAutoRefresh();
} else {
stopAutoRefresh();
}
}
//
function startAutoRefresh() {
stopAutoRefresh(); //
refreshTimer.value = setInterval(() => {
getCacheInfo();
}, refreshInterval.value * 1000);
}
//
function stopAutoRefresh() {
if (refreshTimer.value) {
clearInterval(refreshTimer.value);
refreshTimer.value = null;
}
}
//
function updateRefreshInterval(value: number | null) {
if (value !== null) {
refreshInterval.value = value;
if (autoRefresh.value) {
startAutoRefresh(); // 使
}
}
}
const { domRef: commandChartRef, updateOptions: updateCommandChart } = useEcharts(() => ({
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderColor: '#e6e6e6',
borderWidth: 1,
textStyle: {
color: '#666'
},
extraCssText: 'box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);'
},
legend: {
type: 'scroll',
orient: 'vertical',
right: 10,
top: 20,
bottom: 20,
textStyle: {
color: '#666'
}
},
series: [
{
name: '命令',
type: 'pie',
roseType: 'radius',
radius: [15, 95],
center: ['40%', '50%'],
data: [] as Array<{ name: string; value: number }>,
animationEasing: 'cubicInOut',
animationDuration: 1000,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
formatter: '{b}: {d}%',
color: '#666'
},
emphasis: {
label: {
show: true,
fontSize: '14',
fontWeight: 'bold'
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
}));
const { domRef: memoryGaugeRef, setOptions } = useEcharts(
() => ({
tooltip: {
formatter: '内存使用情况'
},
series: [
{
name: '内存',
type: 'gauge',
min: 0,
max: 100,
detail: {
formatter: '{value}%',
fontSize: 16,
fontWeight: 'bold',
offsetCenter: [0, '70%']
},
data: [
{
value: 0,
name: '内存使用率'
}
],
axisLine: {
lineStyle: {
width: 8,
color: [
[0.3, '#58d9f9'],
[0.7, '#26deca'],
[1, '#ff8c6a']
]
}
},
pointer: {
itemStyle: {
color: 'auto'
}
},
axisTick: {
distance: -12,
length: 4,
lineStyle: {
color: '#999',
width: 1
}
},
splitLine: {
distance: -18,
length: 12,
lineStyle: {
color: '#999',
width: 1
}
},
axisLabel: {
color: '#666',
distance: 25,
fontSize: 12
},
title: {
offsetCenter: [0, '90%'],
fontSize: 14
},
animationDuration: 1000
}
]
}),
{
//
onRender: chart => {
chart.hideLoading();
},
onUpdated: chart => {
chart.hideLoading();
}
}
);
//
const colorPalette = [
'#5da8ff',
'#8e9dff',
'#fedc69',
'#26deca',
'#ff8c6a',
'#58d9f9',
'#05c091',
'#7367f0',
'#9e86ff',
'#f8d3a5',
'#4a5bcc',
'#22bd7c'
];
//
function updateCharts() {
//
if (!cacheInfo.value) {
return;
}
try {
//
updateCommandChart(opts => {
try {
const commandStats = cacheInfo.value?.commandStats || [];
if (!commandStats.length) {
return opts;
}
// - {name, value}
const data = commandStats
.map(item => {
try {
if (!item || typeof item !== 'object') {
return { name: '未知命令', value: 0 };
}
// API
if ('name' in item && 'value' in item) {
const name = String(item.name);
// value
let value = 0;
try {
value = Number.parseInt(String(item.value), 10);
if (Number.isNaN(value)) value = 0;
} catch {
value = 0;
}
return { name, value };
}
//
const key = Object.keys(item)[0] || '未知命令';
let value = 0;
try {
value = Number.parseInt(String(Object.values(item)[0] || '0'), 10);
if (Number.isNaN(value)) value = 0;
} catch {
value = 0;
}
return { name: key, value };
} catch {
return { name: '未知命令', value: 0 };
}
})
.filter(item => item.name !== '未知命令' || item.value > 0);
if (!data.length) {
return opts;
}
// 10""
const sortedData = data.sort((a, b) => b.value - a.value);
let pieData: Array<{ name: string; value: number }>;
if (sortedData.length > 10) {
const top10 = sortedData.slice(0, 10);
const others = sortedData.slice(10);
const othersValue = others.reduce((sum, item) => sum + item.value, 0);
pieData = [...top10, { name: '其他命令', value: othersValue }];
} else {
pieData = sortedData;
}
//
if (opts.series && Array.isArray(opts.series) && opts.series[0]) {
opts.series[0].data = pieData;
//
const newItemStyle = {
...(opts.series[0].itemStyle || {}),
color(param: { dataIndex: number }) {
const index = param.dataIndex % colorPalette.length;
return colorPalette[index >= 0 ? index : 0];
}
};
opts.series[0].itemStyle = newItemStyle;
// tooltip
opts.tooltip = {
...(opts.tooltip || {}),
formatter: function tooltipFormatter(params: any) {
if (!params || typeof params !== 'object') return '';
const name = params.name || '未知命令';
const value = typeof params.value === 'number' ? params.value : 0;
const percent = typeof params.percent === 'number' ? params.percent : 0;
return (
`<div style="font-weight:bold;margin-bottom:5px;font-size:14px;">${name}</div>` +
`<div>执行次数: <span style="font-weight:bold;float:right;">${value.toLocaleString()}</span></div>` +
`<div>占比: <span style="font-weight:bold;float:right;">${percent.toFixed(2)}%</span></div>`
);
} as any
};
}
return opts;
} catch {
return opts;
}
});
//
updateMemoryChart();
} catch {
//
}
}
//
function updateMemoryChart() {
try {
const info = cacheInfo.value?.info;
if (!info) {
return;
}
// 使API
const infoAny = info as any;
//
const usedMemoryHuman = String(infoAny.used_memory_human || '0B');
const maxMemoryHuman = String(infoAny.maxmemory_human || '0B');
//
const usedMem = parseMemoryString(usedMemoryHuman);
const maxMem = parseMemoryString(maxMemoryHuman);
// 使
let usagePercent = 0;
if (maxMem.bytes > 0) {
// 使
usagePercent = Math.min(100, Math.round((usedMem.bytes / maxMem.bytes) * 100));
} else if (infoAny.total_system_memory) {
// 使
try {
const totalMemoryBytes = Number(infoAny.total_system_memory);
if (totalMemoryBytes > 0) {
usagePercent = Math.min(100, Math.round((usedMem.bytes / totalMemoryBytes) * 100));
} else {
//
usagePercent = 50; //
}
} catch {
usagePercent = 50;
}
} else {
//
usagePercent = 50; //
}
//
const gaugeOption = {
tooltip: {
formatter:
maxMem.bytes > 0
? `内存使用: ${usedMemoryHuman}<br/>最大内存: ${maxMemoryHuman}<br/>使用率: ${usagePercent}%`
: `内存使用: ${usedMemoryHuman}<br/>最大内存: 未设置限制`
},
series: [
{
name: '内存',
type: 'gauge' as const,
min: 0,
max: 100,
detail: {
formatter: maxMem.bytes > 0 ? '{value}%' : usedMemoryHuman,
fontSize: 16,
fontWeight: 'bold' as const,
offsetCenter: [0, '70%']
},
data: [
{
value: usagePercent,
name: maxMem.bytes > 0 ? '内存使用率' : '内存使用量'
}
],
axisLine: {
lineStyle: {
width: 8,
color: [
[0.3, '#58d9f9'],
[0.7, '#26deca'],
[1, '#ff8c6a']
]
}
},
pointer: {
itemStyle: {
color: 'auto'
}
},
axisTick: {
distance: -12,
length: 4,
lineStyle: {
color: '#999',
width: 1
}
},
splitLine: {
distance: -18,
length: 12,
lineStyle: {
color: '#999',
width: 1
}
},
axisLabel: {
color: '#666',
distance: 25,
fontSize: 12
},
title: {
offsetCenter: [0, '90%'],
fontSize: 14
},
animationDuration: 1000
}
]
};
//
if (memoryGaugeRef.value) {
// 使TypeScript
setOptions(gaugeOption as any);
}
} catch {
//
}
}
// "3.6MB" -> { value: 3.6, unit: "MB" }
function parseMemoryString(memoryStr: string): { value: number; unit: string; bytes: number } {
if (!memoryStr || typeof memoryStr !== 'string') {
return { value: 0, unit: 'B', bytes: 0 };
}
try {
//
const match = memoryStr.match(/^([\d.]+)([KMGTP]?B)?$/i);
if (!match) {
return { value: 0, unit: 'B', bytes: 0 };
}
const value = Number.parseFloat(match[1]);
const unit = (match[2] || 'B').toUpperCase();
//
const unitIndex = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'].indexOf(unit);
const bytes = unitIndex >= 0 ? value * 1024 ** unitIndex : value;
return { value, unit, bytes };
} catch {
return { value: 0, unit: 'B', bytes: 0 };
}
}
//
function forceUpdateCharts() {
try {
if (memoryGaugeRef.value) {
updateMemoryChart();
}
if (commandChartRef.value) {
//
updateCommandChart(opts => {
// ... existing code ...
return opts;
});
}
} catch {
//
}
}
//
onMounted(async () => {
try {
await getCacheInfo();
//
setTimeout(() => {
forceUpdateCharts();
}, 500);
} catch {
fetchError.value = '初始化数据失败,请尝试刷新';
}
});
//
onUnmounted(() => {
stopAutoRefresh();
//
try {
if (commandChartRef.value) {
commandChartRef.value = null;
}
if (memoryGaugeRef.value) {
memoryGaugeRef.value = null;
}
} catch {
//
}
});
</script>
<template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-y-auto lt-sm:overflow-auto">
<NSpace vertical :size="16">
<!-- 控制面板 -->
<NCard :bordered="false" class="control-panel card-wrapper">
<div class="flex flex-wrap items-center justify-between gap-y-12px">
<h3 class="m-0 text-16px font-medium">Redis 缓存监控</h3>
<div class="flex flex-wrap items-center gap-16px">
<div class="flex items-center gap-8px">
<span class="whitespace-nowrap">自动刷新</span>
<NSwitch v-model:value="autoRefresh" @update:value="toggleAutoRefresh" />
</div>
<div v-if="autoRefresh" class="flex items-center gap-8px">
<span class="whitespace-nowrap">间隔 ()</span>
<NInputNumber
v-model:value="refreshInterval"
:min="5"
:max="300"
size="small"
@update:value="updateRefreshInterval"
/>
</div>
<NButton
type="primary"
:loading="loading"
:disabled="autoRefresh"
class="min-w-80px"
@click="handleRefresh"
>
{{ loading ? '刷新中...' : '刷新数据' }}
</NButton>
</div>
</div>
</NCard>
<!-- 错误提示 -->
<NAlert v-if="fetchError" type="error" closable>
{{ fetchError }}
</NAlert>
<NCard title="Redis 基本信息" :bordered="false" class="info-card card-wrapper">
<NSpin :show="loading">
<NDescriptions :column="4" bordered label-placement="left" label-class="w-150px">
<NDescriptionsItem label="Redis 版本">{{ cacheInfo?.info?.redis_version }}</NDescriptionsItem>
<NDescriptionsItem label="运行模式">
{{ cacheInfo?.info?.redis_mode === 'standalone' ? '单机' : '集群' }}
</NDescriptionsItem>
<NDescriptionsItem label="端口">{{ cacheInfo?.info?.tcp_port }}</NDescriptionsItem>
<NDescriptionsItem label="客户端数">{{ cacheInfo?.info?.connected_clients }}</NDescriptionsItem>
<NDescriptionsItem label="运行时间(天)">{{ cacheInfo?.info?.uptime_in_days }}</NDescriptionsItem>
<NDescriptionsItem label="使用内存">{{ cacheInfo?.info?.used_memory_human }}</NDescriptionsItem>
<NDescriptionsItem label="使用CPU">
{{
cacheInfo?.info?.used_cpu_user_children
? parseFloat(cacheInfo?.info?.used_cpu_user_children).toFixed(2)
: ''
}}
</NDescriptionsItem>
<NDescriptionsItem label="内存配置">{{ cacheInfo?.info?.maxmemory_human }}</NDescriptionsItem>
<NDescriptionsItem label="AOF 开启">
{{ cacheInfo?.info?.aof_enabled === '0' ? '否' : '是' }}
</NDescriptionsItem>
<NDescriptionsItem label="RDB 状态">
{{ cacheInfo?.info?.rdb_last_bgsave_status }}
</NDescriptionsItem>
<NDescriptionsItem label="Key 数量">{{ cacheInfo?.dbSize }}</NDescriptionsItem>
<NDescriptionsItem label="网络入口/出口">
{{ cacheInfo?.info?.instantaneous_input_kbps }}kps/{{ cacheInfo?.info?.instantaneous_output_kbps }}kps
</NDescriptionsItem>
</NDescriptions>
</NSpin>
</NCard>
<NGrid :cols="2" :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
<NGi span="0:24 1000:12">
<NCard title="命令统计" :bordered="false" class="chart-card card-wrapper">
<NSpin :show="loading">
<div ref="commandChartRef" class="h-360px overflow-hidden"></div>
</NSpin>
</NCard>
</NGi>
<NGi span="0:24 1000:12">
<NCard title="内存信息" :bordered="false" class="chart-card card-wrapper">
<NSpin :show="loading">
<div ref="memoryGaugeRef" class="h-360px overflow-hidden"></div>
</NSpin>
</NCard>
</NGi>
</NGrid>
</NSpace>
</div>
</template>
<style scoped>
.card-wrapper {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
border-radius: 8px;
transition: all 0.3s ease;
}
.card-wrapper:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.control-panel {
background: linear-gradient(to right, rgba(115, 103, 240, 0.05), rgba(115, 103, 240, 0.01));
}
.info-card {
position: relative;
overflow: hidden;
}
.chart-card {
min-height: 420px;
}
@media (max-width: 768px) {
.flex-wrap {
flex-wrap: wrap;
}
.chart-card {
min-height: 360px;
}
}
@media (max-width: 480px) {
.chart-card {
min-height: 300px;
}
}
</style>