feat: 告警中心仪表盘

This commit is contained in:
leiqiaochu 2023-02-16 16:07:14 +08:00
parent 40a62317d9
commit 293bba37a1
10 changed files with 1054 additions and 3 deletions

View File

@ -0,0 +1,20 @@
import server from '@/utils/request';
/**
*
*/
export const dashboard = (data:Record<string,any[]>)=> server.post('/dashboard/_multi',data);
/**
*
*/
export const getAlarm = (params:Record<string,any[]>) => server.get('/alarm/record/_query',params);
/**
*
*/
export const getAlarmConfigCount = (data:Record<string,any>) => server.post('/alarm/config/_count',data);
/**
*
*/
export const getAlarmLevel = () => server.get('/alarm/config/default/level');

View File

@ -40,7 +40,7 @@
<Charts :options="TodayDevOptions"></Charts> </TopCard <Charts :options="TodayDevOptions"></Charts> </TopCard
></a-col> ></a-col>
</a-row> </a-row>
<a-row :span="24"> <a-row :gutter="24">
<a-col :span="24"> <a-col :span="24">
<div class="message-card"> <div class="message-card">
<Guide title="设备消息"> <Guide title="设备消息">
@ -452,6 +452,7 @@ const getEcharts = (data: any) => {
_time = '1M'; _time = '1M';
format = 'yyyy年-M月'; format = 'yyyy年-M月';
} }
dashboard([ dashboard([
{ {
dashboard: 'device', dashboard: 'device',

View File

@ -0,0 +1,43 @@
<template>
<div class="chart" ref="chart"></div>
</template>
<script setup lang="ts">
import * as echarts from 'echarts';
const { proxy } = <any>getCurrentInstance();
const props = defineProps({
//
options:{
type:Object,
default:()=>{}
}
});
/**
* 绘制图表
*/
const createChart = () => {
nextTick(() => {
const myChart = echarts.init(proxy.$refs.chart);
myChart.setOption(props.options);
window.addEventListener('resize', function () {
myChart.resize();
});
});
};
watch(
() => props.options,
() => createChart(),
{ immediate: true, deep: true },
);
</script>
<style scoped lang="less">
.chart {
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,60 @@
<template>
<div class="home-title">
<div v-if="title">{{ title }}</div>
<div v-else class="title">
<slot name="title"></slot>
</div>
<div class="extra-text">
<slot name="extra"></slot>
</div>
<div class="home-title-english">{{ english }}</div>
</div>
</template>
<script setup lang="ts" name="Guide">
interface guideProps {
title?: string;
english?: string;
}
const props = defineProps<guideProps>();
</script>
<style scoped lang="less">
.home-title {
position: relative;
z-index: 2;
display: flex;
justify-content: space-between;
margin-bottom: 12px;
padding-left: 18px;
font-weight: 700;
font-size: 18px;
&::after {
position: absolute;
top: 50%;
left: 0;
width: 8px;
height: 8px;
background-color: @primary-color;
border: 1px solid #b4c0da;
transform: translateY(-50%);
content: ' ';
}
.extra-text {
font-size: 14px;
font-weight: 400;
}
.title{
flex: 1;
}
.home-title-english {
position: absolute;
top: 30px;
color: rgba(0, 0, 0, 0.3);
font-size: 12px;
}
}
</style>

View File

@ -0,0 +1,152 @@
<template>
<div class="new-alarm">
<div class="title">最新警告</div>
<div v-if="alarmList.length" class="new-alarm-items">
<ul>
<li v-for="item in alarmList.slice(0, 3)" :key="item">
<div class="new-alarm-item">
<div class="new-alarm-item-time">
<img
:src="getImage('/alarm/bashboard.png')"
alt=""
/>{{
moment(item.alarmTime).format(
'YYYY-MM-DD HH:mm:ss',
)
}}
</div>
<div class="new-alarm-item-content">
<a-tooltip
:title="item.alarmName"
placement="topLeft"
>
<a>{{ item.alarmName }}</a>
</a-tooltip>
</div>
<div class="new-alarm-item-state">
<a-badge
:status="
item.state?.value === 'warning'
? 'error'
: 'default'
"
>
</a-badge>
<span
:class="
item.state?.value === 'warning'
? 'error'
: 'default'
"
>
{{ item.state?.text }}
</span>
</div>
<div
:class="[
'new-alarm-item-level',
`level-${item.level}`,
]"
>
{{ item.levelName }}
</div>
</div>
</li>
</ul>
</div>
<div v-else class="empty-body">
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE"></a-empty>
</div>
</div>
</template>
<script lang="ts" setup>
import { Empty } from 'ant-design-vue';
import { getImage } from '@/utils/comm';
import moment from 'moment';
const props = defineProps({
alarmList: {
type: Array,
default: [],
},
});
</script>
<style scoped lang="less">
.new-alarm {
background-color: white;
padding: 24px;
background-color: #fff;
border: 1px solid #e0e4e8;
border-radius: 2px;
}
.new-alarm-items {
ul {
list-style: none;
padding: 0;
}
.new-alarm-item {
display: flex;
gap: 12px;
margin: 18px 0;
font-size: 12px;
.new-alarm-item-time {
width: 180px;
font-size: 14px;
> img {
margin-right: 8px;
}
}
}
.new-alarm-item-content {
width: ~'calc(100% - 360px)';
}
.new-alarm-item-state {
width: 90px;
text-align: center;
font-size: 14px;
.error {
color: @error-color;
}
.default {
color: @text-color;
}
}
.new-alarm-item-level {
width: 52px;
padding: 2px 8px;
color: #fff;
text-align: center;
border-radius: 2px;
&.level-1 {
background-color: #e50012;
}
&.level-2 {
background-color: #ff9457;
}
&.level-3 {
background-color: #fabd47;
}
&.level-4 {
background-color: #999;
}
&.level-5 {
background-color: #bbb;
}
}
}
.empty-body {
height: 142px;
display: flex;
flex-direction: column;
align-content: center;
justify-content: center;
width: 100%;
}
</style>

View File

@ -0,0 +1,117 @@
<template>
<div>
<a-radio-group
v-if="quickBtn"
default-value="today"
button-style="solid"
v-model:value="radioValue"
@change="(e) => handleBtnChange(e.target.value)"
>
<a-radio-button
v-for="item in quickBtnList"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</a-radio-button>
</a-radio-group>
<a-range-picker
format="YYYY-MM-DD HH:mm:ss"
valueFormat="YYYY-MM-DD HH:mm:ss"
style="margin-left: 12px"
@change="rangeChange"
v-model:value="rangeVal"
:allowClear="false"
>
</a-range-picker>
</div>
</template>
<script setup lang="ts">
import moment from 'moment';
import { PropType } from 'vue';
interface BtnOptions {
label: string;
value: string;
}
interface EmitProps {
(e: 'change', data: Record<string, any>): void;
}
const emit = defineEmits<EmitProps>();
const props = defineProps({
//
quickBtn: {
type: Boolean,
default: true,
},
//
quickBtnList: {
type: Array as PropType<BtnOptions[]>,
default: [
{ label: '今日', value: 'today' },
{ label: '近一周', value: 'week' },
{ label: '近一月', value: 'month' },
{ label: '近一年', value: 'year' },
],
},
type: {
type: String,
default: 'today',
},
});
const radioValue = ref(props.type || 'week' || undefined);
const rangeVal = ref<[string, string]>();
const rangeChange = (val: any) => {
radioValue.value = undefined;
emit('change', {
start: moment(val[0]).valueOf(),
end: moment(val[1]).valueOf(),
type: undefined,
});
};
const getTimeByType = (type: string) => {
switch (type) {
case 'hour':
return moment().subtract(1, 'hours').valueOf();
case 'week':
return moment().subtract(6, 'days').valueOf();
case 'month':
return moment().subtract(29, 'days').valueOf();
case 'year':
return moment().subtract(365, 'days').valueOf();
default:
return moment().startOf('day').valueOf();
}
};
const handleBtnChange = (val: string) => {
radioValue.value = val;
let endTime = moment(new Date()).valueOf();
let startTime = getTimeByType(val);
if (val === 'yesterday') {
startTime = moment().subtract(1, 'days').startOf('day').valueOf();
endTime = moment().subtract(1, 'days').endOf('day').valueOf();
}
rangeVal.value = [
moment(startTime).format('YYYY-MM-DD HH:mm:ss'),
moment(endTime).format('YYYY-MM-DD HH:mm:ss'),
];
emit('change', {
start: startTime,
end: endTime,
type: val,
});
};
handleBtnChange(radioValue.value);
watch(
() => radioValue.value,
{ deep: true, immediate: true },
);
</script>

View File

@ -0,0 +1,106 @@
<template>
<div class="top-card">
<div class="top-card-content">
<div class="content-left">
<div class="content-left-title">
<span>{{ title }}</span>
<a-tooltip placement="top" v-if="tooltip">
<template #title>
<span>{{ tooltip }}</span>
</template>
<AIcon type="QuestionCircleOutlined" />
</a-tooltip>
</div>
<div class="content-left-value">{{ value }}</div>
</div>
<div class="content-right" v-if="img">
<img :src="img" alt="" />
</div>
<div class="content-right-echart" v-else>
<slot></slot>
</div>
</div>
<div class="top-card-footer">
<template v-for="(item, index) in footer" :key="index">
<span v-if="!item.status">{{ item.title }}</span>
<a-badge v-else :text="item.title" :status="item.status" />
<div class="footer-item-value">{{ item.value }}</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { PropType } from 'vue';
import type { Footer } from '@/views/device/DashBoard/typings'
const props = defineProps({
title: { type: String, default: '' },
tooltip: { type: String, default: '' },
img: { type: String, default: '' },
footer: { type: Array as PropType<Footer[]>, default: '' },
value: { type: Number, default: 0 },
});
</script>
<style lang="less" scoped>
.top-card {
display: flex;
flex-direction: column;
// height: 200px;
padding: 24px;
background-color: #fff;
border: 1px solid #e0e4e8;
border-radius: 2px;
.top-card-content {
display: flex;
flex-direction: row;
flex-grow: 1;
justify-content: space-between;
.content-left {
height: 100%;
width: 50%;
&-title {
color: rgba(0, 0, 0, 0.64);
}
&-value {
padding: 12px 0;
color: #323130;
font-weight: 700;
font-size: 36px;
}
}
.content-right {
width: 0;
height: 123px;
display: flex;
flex-grow: .7;
align-items: flex-end;
justify-content: flex-end;
img {
width: 100%;
height: 100%;
}
}
.content-right-echart{
height: 123px;
display: flex;
flex-grow: 1;
align-items: flex-end;
justify-content: flex-end;
}
}
.top-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
.footer-item-value {
color: #323130;
font-weight: 700;
font-size: 16px;
}
}
}
</style>

View File

@ -0,0 +1,547 @@
<template>
<page-container>
<div class="DashBoardBox">
<a-row :gutter="24">
<a-col :span="6">
<TopCard
title="今日告警"
:value="state.today"
:footer="currentMonAlarm"
>
<Charts :options="state.fifteenOptions"></Charts>
</TopCard>
</a-col>
<a-col :span="6">
<TopCard
title="告警配置"
:value="state.config"
:footer="alarmState"
:img="getImage('/device/device-number.png')"
></TopCard>
</a-col>
<a-col :span="12">
<NewAlarm :alarm-list="state.alarmList"></NewAlarm>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="24">
<div class="alarm-card">
<Guide>
<template #title>
<span style="margin-right: 24px">告警统计</span>
<a-select
style="width: 40%"
v-model:value="queryCodition.targetType"
:options="
isNoCommunity ? selectOpt1 : selectOpt2
"
@change="selectChange"
></a-select>
</template>
<template #extra>
<TimeSelect
key="flow-static"
:type="'week'"
:quickBtnList="quickBtnList"
@change="initQueryTime"
/>
</template>
</Guide>
<div class="alarmBox">
<div class="alarmStatistics-chart">
<Charts
:options="alarmStatisticsOption"
></Charts>
</div>
<div class="alarmRank">
<h4>告警排名</h4>
<ul v-if="state.ranking.length" class="rankingList">
<li v-for="(item,i) in state.ranking" :key="item.targetId">
<img :src="getImage(`/rule-engine/dashboard/ranking/${i+1}.png`)" alt="">
<span class="rankingItemTitle" :title="item.targetName">{{item.targetName}}</span>
<span class="rankingItemValue">{{item.count}}</span>
</li>
</ul>
<div v-else class="empty-body">
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE"></a-empty>
</div>
</div>
</div>
</div>
</a-col>
</a-row>
</div>
</page-container>
</template>
<script lang="ts" setup>
import { Empty } from 'ant-design-vue';
import { getImage } from '@/utils/comm';
import Charts from './components/Charts.vue';
import TopCard from './components/TopCard.vue';
import NewAlarm from './components/NewAlarm.vue';
import TimeSelect from './components/TimeSelect.vue';
import Guide from './components/Guide.vue';
import encodeQuery from '@/utils/encodeQuery';
import type { SelectTypes } from 'ant-design-vue/es/select';
import type { Footer } from '@/views/rule-engine/DashBoard/typings';
import { isNoCommunity } from '@/utils/utils';
import {
dashboard,
getAlarm,
getAlarmConfigCount,
getAlarmLevel,
} from '@/api/rule-engine/dashboard';
import moment from 'moment';
let currentMonAlarm = ref<Footer[]>([
{
title: '当月告警',
value: 0,
status: 'success',
},
]);
let alarmState = ref<Footer[]>([
{
title: '正常',
value: 0,
status: 'success',
},
{
title: '禁用',
value: 0,
status: 'error',
},
]);
const selectOpt1 = ref<Object[]>([
{ label: '设备', value: 'device' },
{ label: '产品', value: 'product' },
{ label: '组织', value: 'org' },
{ label: '其它', value: 'other' },
]);
const selectOpt2 = ref<SelectTypes['options']>([
{ label: '设备', value: 'device' },
{ label: '产品', value: 'product' },
{ label: '其它', value: 'other' },
]);
let queryCodition = reactive({
startTime: 0,
endTime: 0,
targetType: 'device',
});
let alarmStatisticsOption = ref<any>({});
const quickBtnList = [
{ label: '昨日', value: 'yesterday' },
{ label: '近一周', value: 'week' },
{ label: '近一月', value: 'month' },
{ label: '近一年', value: 'year' },
];
type DashboardItem = {
group: string;
data: Record<string, any>;
};
let state = reactive<{
today: number;
thisMonth: number;
config: number;
enabledConfig: number;
disabledConfig: number;
alarmList: any[];
ranking: { targetId: string; targetName: string; count: number }[];
fifteenOptions: any;
}>({
today: 0,
thisMonth: 0,
config: 0,
enabledConfig: 0,
disabledConfig: 0,
alarmList: [],
ranking: [],
fifteenOptions: {},
});
//
const today = {
dashboard: 'alarm',
object: 'record',
measurement: 'trend',
dimension: 'agg',
group: 'today',
params: {
time: '1d',
// targetType: 'device',
format: 'HH:mm:ss',
from: moment(new Date(new Date().setHours(0, 0, 0, 0))).format(
'YYYY-MM-DD HH:mm:ss',
),
to: 'now',
// limit: 24,
},
};
//
const thisMonth = {
dashboard: 'alarm',
object: 'record',
measurement: 'trend',
dimension: 'agg',
group: 'thisMonth',
params: {
time: '1M',
// targetType: 'device',
format: 'yyyy-MM',
limit: 1,
from: 'now-1M',
},
};
const fifteen = {
dashboard: 'alarm',
object: 'record',
measurement: 'trend',
dimension: 'agg',
group: '15day',
params: {
time: '1d',
format: 'yyyy-MM-dd',
// targetType: 'product',
from: 'now-15d',
to: 'now',
limit: 15,
},
};
const getDashBoard = () => {
dashboard([today, thisMonth, fifteen]).then((res) => {
if (res.status == 200) {
const _data = res.result as DashboardItem[];
state.today = _data.find(
(item) => item.group === 'today',
)?.data.value;
state.thisMonth = _data.find(
(item) => item.group === 'thisMonth',
)?.data.value;
currentMonAlarm.value[0].value = state.thisMonth;
const fifteenData = _data
.filter((item) => item.group === '15day')
.map((item) => item.data)
.sort((a, b) => b.timestamp - a.timestamp);
state.fifteenOptions = {
xAxis: {
type: 'category',
data: fifteenData.map((item) => item.timeString),
show: false,
},
yAxis: {
type: 'value',
show: false,
},
grid: {
top: '2%',
bottom: 0,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
series: [
{
name: '告警数',
data: fifteenData.map((item) => item.value),
type: 'bar',
itemStyle: {
color: '#2F54EB',
},
},
],
};
}
});
};
getDashBoard();
const getAlarmConfig = async () => {
const countRes = await getAlarmConfigCount({});
const enabeldRes = await getAlarmConfigCount({
terms: [
{
column: 'state',
value: 'enabled',
},
],
});
const disableRes = await getAlarmConfigCount({
terms: [
{
column: 'state',
value: 'disabled',
},
],
});
if (countRes.status == 200) {
state.config = countRes.result;
}
if (enabeldRes.status == 200) {
state.enabledConfig = enabeldRes.result;
alarmState.value[0].value = state.enabledConfig;
}
if (disableRes.status == 200) {
state.disabledConfig = disableRes.result;
alarmState.value[1].value = state.disabledConfig;
}
};
getAlarmConfig();
const getCurrentAlarm = async () => {
const alarmLevel: any = await getAlarmLevel();
const sorts = { alarmTime: 'desc' };
const currentAlarm: any = await getAlarm(encodeQuery({ sorts }));
if (currentAlarm.status === 200) {
if (alarmLevel.status === 200) {
const levels = alarmLevel.result.levels;
state.alarmList = currentAlarm.result?.data
.filter((i: any) => i?.state?.value === 'warning')
.map((item: { level: any }) => ({
...item,
levelName: levels.find((l: any) => l.level === item.level)
?.title,
}));
} else {
state.alarmList = currentAlarm.result?.data.filter(
(item: any) => item?.state?.value === 'warning',
);
}
}
};
getCurrentAlarm();
//
const initQueryTime = (data: any) => {
queryCodition.startTime = data.start;
queryCodition.endTime = data.end;
console.log(queryCodition);
selectChange();
};
const selectChange = () => {
let time = '1h';
let format = 'HH';
let limit = 12;
const dt = queryCodition.endTime - queryCodition.startTime;
const hour = 60 * 60 * 1000;
const day = hour * 24;
const month = day * 30;
const year = 365 * day;
if (dt <= day) {
limit = Math.abs(Math.ceil(dt / hour));
} else if (dt > day && dt < year) {
limit = Math.abs(Math.ceil(dt / day)) + 1;
time = '1d';
format = 'M月dd日';
} else if (dt >= year) {
limit = Math.abs(Math.floor(dt / month));
time = '1M';
format = 'yyyy年-M月';
}
//
const chartData = {
dashboard: 'alarm',
object: 'record',
measurement: 'trend',
dimension: 'agg',
group: 'alarmTrend',
params: {
targetType: queryCodition.targetType, // productdeviceorgother
format: format,
time: time,
// from: 'now-1y', // now-1dnow-1wnow-1Mnow-1y
// to: 'now',
limit: limit, // 12
// time: params.time.type === 'today' ? '1h' : '1d',
from: moment(queryCodition.startTime).format('YYYY-MM-DD HH:mm:ss'),
to: moment(queryCodition.endTime).format('YYYY-MM-DD HH:mm:ss'),
// limit: 30,
},
};
//
const order = {
dashboard: 'alarm',
object: 'record',
measurement: 'rank',
dimension: 'agg',
group: 'alarmRank',
params: {
// time: '1h',
time: time,
targetType: queryCodition.targetType,
from: moment(queryCodition.startTime).format('YYYY-MM-DD HH:mm:ss'),
to: moment(queryCodition.endTime).format('YYYY-MM-DD HH:mm:ss'),
limit: 9,
},
};
let tip = '其它';
if (queryCodition.targetType === 'device') {
tip = '设备';
} else if (queryCodition.targetType === 'product') {
tip = '产品';
} else if (queryCodition.targetType === 'org') {
tip = '组织';
}
//
dashboard([chartData, order]).then((res) => {
if (res.status == 200) {
const xData: string[] = [];
const sData: number[] = [];
res.result
.filter((item: any) => item.group === 'alarmTrend')
.forEach((item: any) => {
xData.push(item.data.timeString);
sData.push(item.data.value);
});
alarmStatisticsOption.value = {
xAxis: {
type: 'category',
boundaryGap: false,
data: xData.reverse(),
},
yAxis: {
type: 'value',
},
tooltip: {
trigger: 'axis',
// axisPointer: {
// type: 'shadow',
// },
},
grid: {
top: '2%',
bottom: '5%',
left: '24px',
right: '48px',
},
series: [
{
name: tip,
data: sData.reverse(),
type: 'line',
smooth: true,
color: '#685DEB',
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: '#685DEB', // 100%
},
{
offset: 1,
color: '#FFFFFF', // 0%
},
],
global: false, // false
},
},
},
],
};
state.ranking = res.result
?.filter((item: any) => item.group === 'alarmRank')
.map((d: { data: { value: any } }) => d.data?.value)
.sort(
(a: { count: number }, b: { count: number }) =>
b.count - a.count,
);
}
});
};
</script>
<style scoped lang="less">
.alarm-card {
width: 100%;
background-color: white;
padding: 24px;
margin-top: 24px;
}
.alarmBox {
width: 100%;
display: flex;
.alarmStatistics-chart {
width: 70%;
height: 500px;
}
.alarmRank {
position: relative;
width: 30%;
padding-left: 48px;
}
}
.rankingList {
margin: 25px 0 0;
padding: 0;
list-style: none;
li {
display: flex;
align-items: center;
margin-top: 16px;
zoom: 1;
&::before,
&::after {
display: table;
content: ' ';
}
&::after {
clear: both;
height: 0;
font-size: 0;
visibility: hidden;
}
span {
//color: red;
font-size: 14px;
line-height: 22px;
}
.rankingItemNumber {
display: inline-block;
width: 20px;
height: 20px;
margin-top: 1.5px;
margin-right: 16px;
font-weight: 600;
font-size: 12px;
line-height: 20px;
text-align: center;
background-color: #edf0f3;
border-radius: 20px;
&.active {
color: #fff;
background-color: #314659;
}
}
.rankingItemTitle {
flex: 1;
margin-right: 8px;
padding-left: 8px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.empty-body {
height: 490px;
display: flex;
flex-direction: column;
align-content: center;
justify-content: center;
width: 100%;
// height: 100%;
}
</style>

View File

@ -0,0 +1,5 @@
export type Footer = {
title: string;
value: number | string;
status?: "default" | "error" | "success" | "warning" | "processing"
}

View File

@ -82,8 +82,8 @@ export default defineConfig(({ mode}) => {
// target: 'http://192.168.33.22:8800', // target: 'http://192.168.33.22:8800',
// target: 'http://192.168.32.244:8881', // target: 'http://192.168.32.244:8881',
// target: 'http://47.112.135.104:5096', // opcua // target: 'http://47.112.135.104:5096', // opcua
// target: 'http://120.77.179.54:8844', // 120测试 target: 'http://120.77.179.54:8844', // 120测试
target: 'http://47.108.63.174:8845', // 测试 // target: 'http://47.108.63.174:8845', // 测试
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '') rewrite: (path) => path.replace(/^\/api/, '')
} }