nazhua/src/views/components/server-detail/server-monitor.vue
2025-08-12 13:17:01 +08:00

743 lines
18 KiB
Vue

<template>
<dot-dot-box
v-if="monitorData.length"
class="server-monitor-group"
:class="{
'chart-type--multi': config.nazhua.monitorChartTypeToggle && monitorChartType === 'multi',
'chart-type--single': config.nazhua.monitorChartTypeToggle && monitorChartType === 'single',
}"
padding="16px 20px"
>
<div class="module-head-group">
<div class="left-box">
<span class="module-title">
网络监控
</span>
</div>
<div class="right-box">
<div
v-if="config.nazhua.monitorChartTypeToggle"
class="chart-type-switch-group"
title="监控折线图是否聚合"
@click="switchChartType"
>
<span class="label-text">聚合</span>
<div
class="switch-box"
:class="{
active: monitorChartType === 'multi',
}"
>
<span class="switch-dot" />
</div>
</div>
<div
class="refresh-data-group"
title="是否自动刷新"
@click="switchRefresh"
>
<span class="label-text">刷新</span>
<div
class="switch-box"
:class="{
active: refreshData,
}"
>
<span class="switch-dot" />
</div>
</div>
<div
class="peak-shaving-group"
title="过滤太高或太低的数据"
@click="switchPeakShaving"
>
<span class="label-text">削峰</span>
<div
class="switch-box"
:class="{
active: peakShaving,
}"
>
<span class="switch-dot" />
</div>
</div>
<div class="last-update-time-group">
<span class="last-update-time-label">
最近
</span>
<div class="minutes">
<div
v-for="minuteItem in minutes"
:key="minuteItem.value"
class="minute-item"
:class="{
active: minuteItem.value === minute,
}"
@click="toggleMinute(minuteItem.value)"
>
<span>{{ minuteItem.label }}</span>
</div>
<div
class="active-arrow"
:style="minuteActiveArrowStyle"
/>
</div>
</div>
</div>
</div>
<template v-if="monitorChartType === 'single'">
<div class="monitor-chart-group">
<div
v-for="(cateItem, index) in monitorChartData.cateList"
:key="cateItem.id"
class="monitor-chart-item"
>
<div class="cate-name-box">
<popover :title="cateItem.title">
<template #trigger>
<div
class="monitor-cate-item"
:class="{
disabled: showCates[cateItem.id] === false,
}"
:style="{
'--cate-color': cateItem.color,
}"
>
<span class="cate-legend" />
<span
class="cate-name"
>
{{ cateItem.name }}
</span>
<span
v-if="cateItem.avg !== 0"
class="cate-avg-ms"
>
{{ cateItem.avg }}ms
</span>
<span
v-if="cateItem.over !== 0"
class="cate-over-rate"
>
{{ cateItem.over }}%
</span>
</div>
</template>
</popover>
</div>
<line-chart
:date-list="monitorChartData.dateList"
:value-list="[monitorChartData.valueList[index]]"
:size="240"
:connect-nulls="false"
/>
</div>
</div>
</template>
<template v-else>
<div class="monitor-cate-group">
<template
v-for="cateItem in monitorChartData.cateList"
:key="cateItem.id"
>
<popover :title="cateItem.title">
<template #trigger>
<div
class="monitor-cate-item"
:class="{
disabled: showCates[cateItem.id] === false,
}"
:style="{
'--cate-color': cateItem.color,
}"
@click="toggleShowCate(cateItem.id)"
@touchstart="handleTouchStart(cateItem.id)"
@touchend="handleTouchEnd(cateItem.id)"
@touchmove="handleTouchMove(cateItem.id)"
>
<span class="cate-legend" />
<span
class="cate-name"
>
{{ cateItem.name }}
</span>
<span
v-if="cateItem.avg !== 0"
class="cate-avg-ms"
>
{{ cateItem.avg }}ms
</span>
</div>
</template>
</popover>
</template>
</div>
<line-chart
:date-list="monitorChartData.dateList"
:value-list="monitorChartData.valueList"
:connect-nulls="false"
/>
</template>
</dot-dot-box>
</template>
<script setup>
/**
* 服务器监控
*/
import {
ref,
computed,
onMounted,
onUnmounted,
} from 'vue';
import { useStore } from 'vuex';
import config from '@/config';
import request from '@/utils/request';
import validate from '@/utils/validate';
import LineChart from '@/components/charts/line.vue';
import {
getThreshold,
getLineColor,
} from '@/views/composable/server-monitor';
const props = defineProps({
info: {
type: Object,
default: () => ({}),
},
});
const store = useStore();
const minute = ref(1440);
const minutes = [{
label: '30分钟',
value: 30,
}, {
label: '1小时',
value: 60,
}, {
label: '3小时',
value: 180,
}, {
label: '6小时',
value: 360,
}, {
label: '12小时',
value: 720,
}, {
label: '24小时',
value: 1440,
}];
const localData = {
peakShaving: window.localStorage.getItem('nazhua_monitor_peak_shaving'),
refreshData: window.localStorage.getItem('nazhua_monitor_refresh_data'),
chartType: window.localStorage.getItem('nazhua_monitor_chart_type'),
};
localData.peakShaving = validate.isSet(localData.peakShaving) ? localData.peakShaving === 'true' : false;
localData.refreshData = validate.isSet(localData.refreshData) ? localData.refreshData === 'true' : true;
const peakShaving = ref(localData.peakShaving);
const refreshData = ref(localData.refreshData);
const showCates = ref({});
const monitorData = ref([]);
const longPressTimer = ref(null);
const chartType = validate.isSet(localData.chartType)
? ref(localData.chartType)
: ref(config.nazhua.monitorChartType === 'single' ? 'single' : 'multi');
const monitorChartType = computed(() => {
if (config.nazhua.monitorChartTypeToggle) {
return chartType.value;
}
return config.nazhua.monitorChartType;
});
// 服务器时间(后面来自接口)
const nowServerTime = computed(() => store.state.serverTime || Date.now());
const accpetShowTime = computed(() => (Math.floor(nowServerTime.value / 60000) - minute.value) * 60000);
const minuteActiveArrowStyle = computed(() => {
const index = minutes.findIndex((i) => i.value === minute.value);
return {
left: `calc(${index} * var(--minute-item-width))`,
};
});
const monitorChartData = computed(() => {
/**
* 处理监控数据以生成分类的平均延迟随时间变化的列表。
*
* @returns {Object} 返回一个对象,包含:
* - cateList {Array}: 唯一监控名称的列表。
* - dateList {Array}: 排序后的唯一时间戳列表。
* - valueList {Array}: 包含以下内容的对象列表:
* - name {String}: 监控名称。
* - data {Array}: [时间戳, 平均延迟] 对的数组。
*
* 该函数执行以下步骤:
* 1. 遍历监控数据以分类和过滤平均延迟。
* 2. 如果启用了削峰,则应用削峰以过滤异常值。
* 3. 构建监控名称到其各自时间戳和平均延迟的映射。
* 4. 将映射转换为监控名称、时间戳和平均延迟数据的列表。
* 5. 删除重复的时间戳并对其进行排序。
*/
const cateMap = {};
monitorData.value.forEach((i) => {
const dateMap = {};
if (!cateMap[i.monitor_name]) {
cateMap[i.monitor_name] = {
id: i.monitor_id,
dateMap,
avgs: [],
};
}
const showAvgDelay = [];
const showCreateTime = [];
const accpeTimeMap = {};
i.created_at.forEach((o, index) => {
const status = o >= accpetShowTime.value;
if (status) {
accpeTimeMap[o] = i.avg_delay[index];
}
});
const allMintues = Math.floor((Date.now() - accpetShowTime.value) / 60000);
for (let j = 0; j < allMintues; j += 1) {
const time = accpetShowTime.value + j * 60000;
showCreateTime.push(time);
const timeProp = accpeTimeMap[time];
if (timeProp) {
showAvgDelay.push(timeProp);
} else {
showAvgDelay.push(null);
}
}
const {
threshold,
mean,
max,
min,
} = peakShaving.value ? getThreshold(showAvgDelay, 2) : {};
showCreateTime.forEach((o, index) => {
if (Object.prototype.hasOwnProperty.call(dateMap, o)) {
return;
}
const avgDelay = showAvgDelay[index];
if (peakShaving.value) {
if (avgDelay === 0) {
dateMap[o] = null;
return;
}
if (Math.abs(avgDelay - mean) > threshold && max / min > 2) {
dateMap[o] = null;
return;
}
}
dateMap[o] = avgDelay ? (avgDelay).toFixed(2) * 1 : null;
});
});
let dateList = [];
let valueList = [];
const cateList = [];
Object.keys(cateMap).forEach((i) => {
const {
id,
dateMap,
avgs,
} = cateMap[i];
Object.entries(dateMap).forEach(([key, value]) => {
const time = parseInt(key, 10);
avgs.push([time, value]);
dateList.push(time);
});
const color = getLineColor(id);
if (avgs.length) {
if (!validate.hasOwn(showCates.value, id)) {
showCates.value[id] = true;
}
// 计算平均延迟和成功率
const validAvgs = avgs.filter((a) => a[1] !== 0 && a[1] !== null);
const avg = validAvgs.reduce((a, b) => a + b[1], 0) / validAvgs.length;
const over = avgs.filter((a) => a[1] !== 0 && a[1] !== null).length / avgs.length;
const cateItem = {
id,
name: i,
color,
avg: avg.toFixed(2) * 1,
over: (over * 100).toFixed(2) * 1,
};
if (Number.isNaN(cateItem.avg)) {
cateItem.avg = 0;
}
const titles = [
cateItem.name,
cateItem.avg === 0 ? '' : `平均延迟:${cateItem.avg}ms`,
`成功率:${cateItem.over}%`,
];
cateItem.title = titles.filter((s) => s).join('\n');
cateList.push(cateItem);
valueList.push({
id,
name: i,
data: avgs,
itemStyle: {
color,
},
lineStyle: {
color,
},
});
}
});
dateList = dateList.sort((a, b) => a - b);
valueList = valueList.filter((i) => showCates.value[i.id]);
return {
dateList,
cateList,
valueList,
};
});
function switchPeakShaving() {
peakShaving.value = !peakShaving.value;
window.localStorage.setItem('nazhua_monitor_peak_shaving', peakShaving.value);
}
function switchRefresh() {
refreshData.value = !refreshData.value;
window.localStorage.setItem('nazhua_monitor_refresh_data', refreshData.value);
}
function switchChartType() {
chartType.value = chartType.value === 'single' ? 'multi' : 'single';
window.localStorage.setItem('nazhua_monitor_chart_type', chartType.value);
}
function toggleMinute(value) {
minute.value = value;
}
function toggleShowCate(id) {
if (window.innerWidth < 768) {
return;
}
showCates.value[id] = !showCates.value[id];
}
function handleTouchStart(id) {
longPressTimer.value = setTimeout(() => {
showCates.value[id] = !showCates.value[id];
}, 500);
}
function handleTouchEnd() {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value);
longPressTimer.value = null;
}
}
function handleTouchMove() {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value);
longPressTimer.value = null;
}
}
async function loadMonitor() {
await request({
url: (
config.nazhua.nezhaVersion === 'v1' ? config.nazhua.v1ApiMonitorPath : config.nazhua.apiMonitorPath
).replace('{id}', props.info.ID),
}).then((res) => {
const list = config.nazhua.nezhaVersion === 'v1' ? res.data?.data : res.data?.result;
if (Array.isArray(list)) {
monitorData.value = list;
}
}).catch((err) => {
console.error(err);
});
}
let loadMonitorTimer = null;
async function setTimeLoadMonitor(force = false) {
if (loadMonitorTimer) {
clearTimeout(loadMonitorTimer);
}
if (refreshData.value || force) {
await loadMonitor();
}
let monitorRefreshTime = parseInt(config.nazhua.monitorRefreshTime, 10);
// 0 为不刷新
if (monitorRefreshTime === 0) {
return;
}
// 非数字 强制为30
if (Number.isNaN(monitorRefreshTime)) {
monitorRefreshTime = 30;
}
// 最小 10 秒
const sTime = Math.min(monitorRefreshTime, 10);
loadMonitorTimer = setTimeout(() => {
setTimeLoadMonitor();
}, sTime * 1000);
}
onMounted(() => {
setTimeLoadMonitor(true);
});
onUnmounted(() => {
if (loadMonitorTimer) {
clearTimeout(loadMonitorTimer);
}
});
</script>
<style lang="scss" scoped>
.server-monitor-group {
--line-chart-size: 300px;
&.chart-type--single {
--line-chart-size: 240px;
}
.monitor-cate-item {
--cate-item-height: 28px;
--cate-item-font-size: 14px;
--cate-color: #fff;
display: flex;
align-items: center;
width: var(--cate-item-width);
height: var(--cate-item-height);
gap: 6px;
padding: 0 6px;
font-size: var(--cate-item-font-size);
border-radius: 4px;
cursor: pointer;
@media screen and (max-width: 768px) {
cursor: default;
}
.cate-legend {
width: 0.5em;
height: 0.5em;
background: var(--cate-color);
}
.cate-name {
// flex: 1;
height: var(--cate-item-height);
line-height: calc(var(--cate-item-height) + 2px);
color: #eee;
}
.cate-avg-ms {
height: var(--cate-item-height);
line-height: calc(var(--cate-item-height) + 2px);
text-align: right;
color: #fff;
}
.cate-over-rate {
height: var(--cate-item-height);
line-height: calc(var(--cate-item-height) + 2px);
text-align: right;
color: #fffbd8;
}
&.disabled {
filter: grayscale(1) brightness(0.8);
opacity: 0.5;
}
}
}
.module-head-group {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
.module-title {
width: max-content;
height: 30px;
line-height: 30px;
font-size: 16px;
color: #eee;
}
.right-box {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.peak-shaving-group,
.refresh-data-group,
.chart-type-switch-group {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
@media screen and (max-width: 1024px) {
cursor: default;
}
.switch-box {
position: relative;
width: 30px;
height: 16px;
background: #999;
border-radius: 10px;
transition: backgroundColor 0.3s;
.switch-dot {
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
transition: left 0.3s;
}
&.active {
background-color: #4caf50;
.switch-dot {
left: 16px;
box-shadow: 1px 1px 2px rgba(#000, 0.4);
}
}
}
.label-text {
color: #ddd;
font-size: 12px;
}
}
.last-update-time-group {
--minute-item-width: 50px;
--minute-item-height: 20px;
display: flex;
align-items: center;
gap: 4px;
.last-update-time-label {
color: #ddd;
height: var(--minute-item-height);
line-height: var(--minute-item-height);
font-size: 12px;
}
@media screen and (max-width: 660px) {
--minute-item-width: 46px;
}
@media screen and (max-width: 600px) {
--minute-item-width: 46px;
}
@media screen and (max-width: 400px) {
.last-update-time-label {
display: none;
}
}
@media screen and (max-width: 330px) {
margin-left: -12px;
}
@media screen and (max-width: 320px) {
margin-left: -18px;
}
}
.minutes {
position: relative;
display: flex;
align-items: center;
// padding: 0 10px;
height: var(--minute-item-height);
background: rgba(#fff, 0.2);
border-radius: calc(var(--minute-item-height) / 2);
.minute-item {
position: relative;
z-index: 10;
width: var(--minute-item-width);
height: var(--minute-item-height);
line-height: var(--minute-item-height);
font-size: 12px;
text-align: center;
cursor: pointer;
color: #aaa;
transition: color 0.3s;
&.active {
color: #fff;
text-shadow: 1px 1px 2px rgba(#000, 0.6);
}
}
.active-arrow {
position: absolute;
top: 0;
left: 0;
width: var(--minute-item-width);
height: var(--minute-item-height);
border-radius: calc(var(--minute-item-height) / 2);
background: #4caf50;
// opacity: 0.5;
transition: left 0.3s;
z-index: 1;
}
}
}
.monitor-cate-group {
--gap-size: 0;
margin: 10px 0;
display: flex;
flex-wrap: wrap;
gap: var(--gap-size);
margin-right: calc(var(--gap-size) * -1);
}
.monitor-chart-group {
display: flex;
flex-wrap: wrap;
gap: 10px 0;
.monitor-chart-item {
width: 50%;
height: calc(var(--line-chart-size) + 28px);
}
@media screen and (max-width: 768px) {
.monitor-chart-item {
width: 100%;
}
}
.cate-name-box {
display: flex;
align-items: center;
justify-content: center;
}
}
</style>