🔨 监控逻辑.

This commit is contained in:
lijiahangmax
2025-09-09 21:25:44 +08:00
parent 3c75aedcec
commit 0b7faa038a
229 changed files with 13303 additions and 358 deletions

View File

@@ -0,0 +1,102 @@
import axios from 'axios';
import qs from 'query-string';
/**
* 主机探针状态
*/
export interface HostAgentStatusResponse {
id: number;
agentVersion: string;
latestVersion: string;
agentInstallStatus: number;
agentOnlineStatus: number;
}
/**
* 探针日志
*/
export interface HostAgentLogResponse {
id: number;
hostId: number;
agentKey: string;
type: string;
status: string;
message: string;
createTime: number;
updateTime: number;
creator: string;
updater: string;
agentStatus: HostAgentStatusResponse;
}
/**
* 安装探针请求
*/
export interface HostInstallAgentRequest {
idList?: Array<number>;
}
/**
* 主机安装探针更新状态请求
*/
export interface HostAgentInstallStatusUpdateRequest {
id?: number;
status?: string;
message?: string;
}
/**
* 安装主机探针
*/
export function installHostAgent(request: HostInstallAgentRequest) {
return axios.post('/asset/host-agent/install', request);
}
/**
* 修改探针安装状态
*/
export function updateAgentInstallStatus(request: HostAgentInstallStatusUpdateRequest) {
return axios.put('/asset/host-agent/update-install-status', request);
}
/**
* 查询主机探针状态
*/
export function getHostAgentStatus(idList: Array<number>) {
return axios.get<Array<HostAgentStatusResponse>>('/asset/host-agent/status', {
params: { idList },
promptBizErrorMessage: false,
promptRequestErrorMessage: false,
paramsSerializer: params => {
return qs.stringify(params, { arrayFormat: 'comma' });
}
});
}
/**
* 查询探针安装状态
*/
export function getAgentInstallLogStatus(idList: Array<number>) {
return axios.get<Array<HostAgentLogResponse>>('/asset/host-agent/install-status', {
params: { idList },
promptBizErrorMessage: false,
promptRequestErrorMessage: false,
paramsSerializer: params => {
return qs.stringify(params, { arrayFormat: 'comma' });
}
});
}
/**
* 上传探针发布包
*/
export function uploadAgentRelease(file: File) {
const formData = new FormData();
formData.append('file', file);
return axios.post<string>('/asset/host-agent/upload-agent-release', formData, {
timeout: 120000,
headers: {
'Content-Type': 'multipart/form-data'
},
});
}

View File

@@ -56,7 +56,6 @@ export interface HostVncConfig extends HostBaseConfig {
identityId?: number;
noUsername?: boolean;
noPassword?: boolean;
portForwardId?: number;
timezone?: string;
clipboardEncoding?: string;
}

View File

@@ -61,7 +61,7 @@ export interface HostSpecExtraModel {
outBandwidth: number;
publicIpAddresses: Array<string>;
privateIpAddresses: Array<string>;
chargePerson: string;
ownerPerson: string;
createdTime: number;
expiredTime: number;
items: Array<{

View File

@@ -69,6 +69,11 @@ export interface HostQueryBaseResponse {
code: string;
address: string;
status: string;
agentKey: string;
agentVersion: string;
agentInstallStatus: number;
agentOnlineStatus: number;
agentOnlineChangeTime: number;
description: string;
createTime: number;
updateTime: number;
@@ -143,8 +148,8 @@ export function updateHostSpec(request: Partial<HostExtraUpdateRequest>) {
/**
* 查询主机
*/
export function getHost(id: number) {
return axios.get<HostQueryResponse>('/asset/host/get', { params: { id } });
export function getHost(id: number, base = false) {
return axios.get<HostQueryResponse>('/asset/host/get', { params: { id, base } });
}
/**

View File

@@ -6,7 +6,7 @@ import { getToken } from '@/utils/auth';
import { httpBaseUrl } from '@/utils/env';
import { reLoginTipsKey } from '@/types/symbol';
axios.defaults.timeout = 10000;
axios.defaults.timeout = 15000;
axios.defaults.setAuthorization = true;
axios.defaults.promptBizErrorMessage = true;
axios.defaults.promptRequestErrorMessage = true;

View File

@@ -0,0 +1,95 @@
import type { TableData } from '@arco-design/web-vue';
import type { DataGrid, OrderDirection, Pagination } from '@/types/global';
import axios from 'axios';
/**
* 监控指标创建请求
*/
export interface MetricsCreateRequest {
name?: string;
measurement?: string;
value?: string;
unit?: string;
suffix?: string;
description?: string;
}
/**
* 监控指标更新请求
*/
export interface MetricsUpdateRequest extends MetricsCreateRequest {
id?: number;
}
/**
* 监控指标查询请求
*/
export interface MetricsQueryRequest extends Pagination, OrderDirection {
searchValue?: string;
id?: number;
name?: string;
measurement?: string;
value?: string;
unit?: string;
suffix?: string;
description?: string;
}
/**
* 监控指标查询响应
*/
export interface MetricsQueryResponse extends TableData {
id: number;
name: string;
measurement: string;
value: string;
unit: string;
suffix: string;
description: string;
createTime: number;
updateTime: number;
creator: string;
updater: string;
}
/**
* 创建监控指标
*/
export function createMetrics(request: MetricsCreateRequest) {
return axios.post<number>('/monitor/monitor-metrics/create', request);
}
/**
* 更新监控指标
*/
export function updateMetrics(request: MetricsUpdateRequest) {
return axios.put<number>('/monitor/monitor-metrics/update', request);
}
/**
* 查询监控指标
*/
export function getMetrics(id: number) {
return axios.get<MetricsQueryResponse>('/monitor/monitor-metrics/get', { params: { id } });
}
/**
* 查询全部监控指标
*/
export function getMetricsList() {
return axios.get<Array<MetricsQueryResponse>>('/monitor/monitor-metrics/list');
}
/**
* 分页查询监控指标
*/
export function getMetricsPage(request: MetricsQueryRequest) {
return axios.post<DataGrid<MetricsQueryResponse>>('/monitor/monitor-metrics/query', request);
}
/**
* 删除监控指标
*/
export function deleteMetrics(id: number) {
return axios.delete<number>('/monitor/monitor-metrics/delete', { params: { id } });
}

View File

@@ -0,0 +1,168 @@
import type { TableData } from '@arco-design/web-vue';
import type { DataGrid, Pagination, TimeChartSeries } from '@/types/global';
import type { HostAgentLogResponse } from '@/api/asset/host-agent';
import axios from 'axios';
/**
* 监控主机更新请求
*/
export interface MonitorHostUpdateRequest {
id?: number;
policyId?: number;
alarmSwitch?: number;
ownerUserId?: number;
cpuName?: string;
diskName?: string;
networkName?: string;
}
/**
* 监控主机更新请求
*/
export interface MonitorHostSwitchUpdateRequest {
id?: number;
alarmSwitch?: number;
}
/**
* 监控主机查询请求
*/
export interface MonitorHostQueryRequest extends Pagination {
agentKeyList?: Array<string>;
searchValue?: string;
alarmSwitch?: number;
policyId?: number;
ownerUserId?: number;
name?: string;
code?: string;
address?: string;
agentKey?: string;
agentInstallStatus?: number;
agentOnlineStatus?: number;
description?: string;
tags?: Array<number>;
}
/**
* 监控主机图表查询请求
*/
export interface MonitorHostChartRequest {
agentKeys?: Array<string>;
measurement?: string;
fields?: Array<string>;
window?: string;
aggregate?: string;
range?: string;
start?: string;
end?: string;
}
/**
* 监控主机查询响应
*/
export interface MonitorHostQueryResponse extends TableData {
id: number;
hostId: number;
policyId: number;
policyName: string;
osType: string;
name: string;
code: string;
address: string;
status: string;
agentKey: string;
agentVersion: string;
latestVersion: string;
agentInstallStatus: number;
agentOnlineStatus: number;
agentOnlineChangeTime: number;
alarmSwitch: number;
ownerUserId: number;
ownerUsername: string;
tags: Array<{ id: number, name: string }>;
meta: MonitorHostMeta;
config: MonitorHostConfig;
metricsData: MonitorHostMetricsData;
installLog: HostAgentLogResponse;
}
/**
* 监控元数据
*/
export interface MonitorHostMeta {
cpus: Array<string>;
disks: Array<string>;
nets: Array<string>;
memoryBytes: number;
}
/**
* 监控配置
*/
export interface MonitorHostConfig {
cpuName: string;
diskName: string;
networkName: string;
}
/**
* 监控数据
*/
export interface MonitorHostMetricsData {
agentKey: string;
noData: boolean;
timestamp: number;
cpuName: string;
diskName: string;
networkName: string;
cpuUsagePercent: number;
memoryUsagePercent: number;
memoryUsageBytes: number;
load1: number;
load5: number;
load15: number;
diskUsagePercent: number;
diskUsageBytes: number;
networkSentPreBytes: number;
networkRecvPreBytes: number;
}
/**
* 查询监控主机指标
*/
export function getMonitorHostMetrics(agentKeyList: Array<string>) {
return axios.post<Array<MonitorHostMetricsData>>('/monitor/monitor-host/metrics', {
agentKeyList
}, {
promptBizErrorMessage: false,
promptRequestErrorMessage: false,
});
}
/**
* 查询监控主机图表
*/
export function getMonitorHostChart(request: MonitorHostChartRequest) {
return axios.post<Array<TimeChartSeries>>('/monitor/monitor-host/chart', request);
}
/**
* 分页查询监控主机
*/
export function getMonitorHostPage(request: MonitorHostQueryRequest) {
return axios.post<DataGrid<MonitorHostQueryResponse>>('/monitor/monitor-host/query', request);
}
/**
* 更新监控主机
*/
export function updateMonitorHost(request: MonitorHostUpdateRequest) {
return axios.put<number>('/monitor/monitor-host/update', request);
}
/**
* 更新监控主机告警开关
*/
export function updateMonitorHostAlarmSwitch(request: MonitorHostSwitchUpdateRequest) {
return axios.put<number>('/monitor/monitor-host/update-switch', request);
}

View File

@@ -20,7 +20,7 @@ export function setSftpFileContent(token: string, content: string) {
formData.append('token', token);
formData.append('file', new File([content], Date.now() + '', { type: 'text/plain' }));
return axios.post<boolean>('/terminal/terminal-sftp/set-content', formData, {
timeout: 60000,
timeout: 120000,
headers: {
'Content-Type': 'multipart/form-data'
}

View File

@@ -31,19 +31,19 @@
background-color: rgb(var(--blue-6));
&.normal {
color: rgb(var(--arcoblue-6));
background-color: rgb(var(--arcoblue-6));
}
&.pass {
color: rgb(var(--green-6));
background-color: rgb(var(--green-6));
}
&.warn {
color: rgb(var(--orange-6));
background-color: rgb(var(--orange-6));
}
&.error {
color: rgb(var(--red-6));
background-color: rgb(var(--red-6));
}
}
}

View File

@@ -0,0 +1,49 @@
// tooltip
.chart-tooltip-wrapper {
// 时间
.chart-tooltip-time {
font-size: 13px;
}
// 表头
.chart-tooltip-header {
margin-top: 5px;
font-size: 12px;
line-height: 1.3;
&-grid {
display: grid;
gap: 0 8px;
align-items: center;
}
&-item {
font-weight: bold;
padding: 2px 0;
white-space: nowrap;
border-bottom: 1px solid #ddd;
}
}
// 圆点
.chart-tooltip-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
}
// 数据行
.chart-tooltip-col {
font-weight: 500;
color: #000;
padding: 3px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
}
}

View File

@@ -282,10 +282,18 @@ body {
margin-left: 4px;
}
.mr2 {
margin-right: 2px;
}
.mr4 {
margin-right: 4px;
}
.mt2 {
margin-top: 2px;
}
.mt4 {
margin-top: 4px;
}

View File

@@ -172,7 +172,7 @@
}
& > .arco-card-body {
padding: 0 16px 16px 16px;
padding: 0 16px 12px 16px;
flex-grow: 1;
}
}
@@ -230,6 +230,10 @@
}
// -- doption
.arco-dropdown-option-disabled .arco-dropdown-option-content .more-doption {
color: var(--color-text-4);
}
.more-doption {
min-width: 42px;
padding: 0 4px;

View File

@@ -152,6 +152,15 @@
defaultVal: appStore.hostIdentityView,
options: cardOptions,
},
{
name: '主机监控',
key: 'monitorHostView',
type: 'radio-group',
margin: '0 0 4px 0',
permission: ['monitor:monitor-host:query'],
defaultVal: appStore.monitorHostView,
options: cardOptions,
},
]);
// 是否展示创建 PWA 应用

View File

@@ -6,6 +6,7 @@
<div class="tags-wrap">
<tab-item v-for="(tag, index) in tagList"
:key="tag.fullPath"
:closeable="tagList.length > 1"
:index="index"
:item-data="tag" />
</div>
@@ -18,7 +19,6 @@
<script lang="ts" setup>
import type { RouteLocationNormalized } from 'vue-router';
import { useRouter } from 'vue-router';
import { computed, onUnmounted, ref, watch } from 'vue';
import { getRouteTag, getRouteTitle } from '@/router';
import { listenerRouteChange, removeRouteListener } from '@/utils/route-listener';
@@ -26,8 +26,6 @@
import qs from 'query-string';
import TabItem from './tab-item.vue';
const router = useRouter();
const appStore = useAppStore();
const tabBarStore = useTabBarStore();
@@ -50,12 +48,23 @@
if (route.meta.noAffix) {
return;
}
const tag = tagList.value.find((tag) => tag.path === route.path);
if (tag) {
// 找到 更新信息
tag.fullPath = route.fullPath;
tag.query = qs.parseUrl(route.fullPath).query;
tag.title = getRouteTitle(route);
if (route.meta.multipleTab) {
// 找到 支持多标签 通过全路径去找
const fullTag = tagList.value.find((tag) => tag.fullPath === route.fullPath);
if (fullTag) {
return;
}
// 没有通过全路径找到则打开新的页签
tabBarStore.addTab(getRouteTag(route), route.meta?.ignoreCache as unknown as boolean);
} else {
// 找到 更新信息
tag.fullPath = route.fullPath;
tag.query = qs.parseUrl(route.fullPath).query;
tag.title = getRouteTitle(route);
}
} else {
// 未找到 添加标签
tabBarStore.addTab(getRouteTag(route), route.meta?.ignoreCache as unknown as boolean);
@@ -96,12 +105,6 @@
margin-right: 6px;
cursor: pointer;
&:first-child {
.arco-tag-close-btn {
display: none;
}
}
.tag-link {
user-select: none;
}

View File

@@ -3,12 +3,13 @@
:popup-max-height="false"
@select="actionSelect">
<span class="arco-tag arco-tag-size-medium arco-tag-checked"
:class="{ 'link-activated': itemData?.path === route.path }"
:class="{ 'link-activated': itemData?.fullPath === route.fullPath }"
@click="goto(itemData as TagProps)">
<span class="tag-link">
{{ itemData.title }}
</span>
<span class="arco-icon-hover arco-tag-icon-hover arco-icon-hover-size-medium arco-tag-close-btn"
<span v-if="closeable"
class="arco-icon-hover arco-tag-icon-hover arco-icon-hover-size-medium arco-tag-close-btn"
@click.stop="tagClose(itemData as TagProps, index)">
<icon-close />
</span>
@@ -67,21 +68,23 @@
const props = defineProps<{
index: number;
itemData: TagProps;
closeable: boolean;
}>();
const route = useRoute();
const router = useRouter();
const tabBarStore = useTabBarStore();
const goto = (tag: TagProps) => {
router.push({ ...tag });
const goto = async (tag: TagProps) => {
await router.push({ ...tag });
};
const tagList = computed(() => {
return tabBarStore.getTabList;
});
const disabledReload = computed(() => {
return props.itemData.path !== route.path;
return props.itemData.fullPath !== route.fullPath;
});
const disabledCurrent = computed(() => {
@@ -97,18 +100,19 @@
});
// 关闭 tag
const tagClose = (tag: TagProps, idx: number) => {
const tagClose = async (tag: TagProps, idx: number) => {
tabBarStore.deleteTab(idx, tag);
if (props.itemData.path === route.path) {
if (props.itemData.fullPath === route.fullPath) {
// 获取队列的前一个 tab
const latest = tagList.value[idx - 1];
router.push({ name: latest.name });
await goto(latest);
}
};
// 获取当前路由索引
const findCurrentRouteIndex = () => {
return tagList.value.findIndex((el) => el.path === route.path);
return tagList.value.findIndex((el) => el.fullPath === route.fullPath);
};
// 选择操作
@@ -121,10 +125,10 @@
} else if (value === Option.left) {
// 关闭左侧
const currentRouteIdx = findCurrentRouteIndex();
copyTagList.splice(1, props.index - 1);
copyTagList.splice(0, props.index - 1);
tabBarStore.freshTabList(copyTagList);
if (currentRouteIdx < index) {
await router.push({ name: itemData.name });
await goto(itemData);
}
} else if (value === Option.right) {
// 关闭右侧
@@ -132,15 +136,15 @@
copyTagList.splice(props.index + 1);
tabBarStore.freshTabList(copyTagList);
if (currentRouteIdx > index) {
await router.push({ name: itemData.name });
await goto(itemData);
}
} else if (value === Option.others) {
// 关闭其他
const filterList = tagList.value.filter((el, idx) => {
return idx === 0 || idx === props.index;
return idx === props.index;
});
tabBarStore.freshTabList(filterList);
await router.push({ name: itemData.name });
await goto(itemData);
} else if (value === Option.reload) {
// 重新加载
tabBarStore.deleteCache(itemData);

View File

@@ -115,7 +115,7 @@
word-break: break-all;
white-space: pre-wrap;
padding-right: 8px;
font-size: 14px;
font-size: 13px;
font-weight: 400;
}

View File

@@ -8,10 +8,9 @@
<script lang="ts" setup>
import type { EChartsOption } from 'echarts';
import { nextTick, ref } from 'vue';
import { useAppStore } from '@/store';
import VCharts from 'vue-echarts';
const props = withDefaults(defineProps<Partial<{
withDefaults(defineProps<Partial<{
options: EChartsOption;
autoResize: boolean;
width: string;
@@ -25,8 +24,6 @@
height: '100%',
});
const appStore = useAppStore();
const renderChart = ref(false);
nextTick(() => {

View File

@@ -49,6 +49,9 @@ export const builtinParams: Array<TemplateParam> = [
}, {
name: 'hostUsername',
desc: '执行主机用户名'
}, {
name: 'agentKey',
desc: 'agentKey'
}, {
name: 'osType',
desc: '执行主机系统类型'

View File

@@ -10,6 +10,7 @@ import './mock';
// 样式通过 arco-plugin 插件导入 详见目录文件 config/plugin/arcoStyleImport.ts
import '@/assets/style/global.less';
import '@/assets/style/layout.less';
import '@/assets/style/chart.less';
import '@/assets/style/arco-extends.less';
import '@/api/interceptor';
import App from './App.vue';

View File

@@ -59,6 +59,7 @@ export const UPDATE_PASSWORD_ROUTE: RouteRecordRaw = {
name: UPDATE_PASSWORD_ROUTE_NAME,
component: () => import('@/views/base/update-password/index.vue'),
meta: {
noAffix: true,
locale: '修改密码'
},
};
@@ -69,6 +70,7 @@ export const FORBIDDEN_ROUTE: RouteRecordRaw = {
name: FORBIDDEN_ROUTER_NAME,
component: () => import('@/views/base/status/forbidden/index.vue'),
meta: {
noAffix: true,
locale: '403'
},
};
@@ -80,6 +82,7 @@ export const NOT_FOUND_ROUTE: RouteRecordRaw = {
name: NOT_FOUND_ROUTER_NAME,
component: () => import('@/views/base/status/not-found/index.vue'),
meta: {
noAffix: true,
locale: '404'
},
};

View File

@@ -0,0 +1,38 @@
import type { AppRouteRecordRaw } from '../types';
import type { RouteLocationNormalized } from 'vue-router';
import { DEFAULT_LAYOUT } from '../base';
const MONITOR: AppRouteRecordRaw = {
name: 'monitorModule',
path: '/monitor-module',
component: DEFAULT_LAYOUT,
children: [
{
name: 'metrics',
path: '/monitor/metrics',
component: () => import('@/views/monitor/metrics/index.vue'),
},
{
name: 'monitorHost',
path: '/monitor/monitor-host',
component: () => import('@/views/monitor/monitor-host/index.vue'),
},
{
name: 'monitorDetail',
path: '/monitor/detail',
meta: {
// 固定到 tab
noAffix: false,
// 是否允许打开多个 tab
multipleTab: true,
// 名称模板
localeTemplate: (route: RouteLocationNormalized) => {
return `${route.meta.locale} - ${route.query.name || ''}`;
},
},
component: () => import('@/views/monitor/monitor-detail/index.vue'),
},
],
};
export default MONITOR;

View File

@@ -22,7 +22,9 @@ declare module 'vue-router' {
newWindow?: boolean;
// 是否活跃
activeMenu?: string;
// 是否允许打开多个 tag
multipleTab?: boolean;
// 名称模板
localeTemplate?: (key: RouteLocationNormalized) => string;
localeTemplate?: (route: RouteLocationNormalized) => string | undefined;
}
}

View File

@@ -23,6 +23,7 @@ const defaultConfig: AppState = {
hostView: 'table',
hostKeyView: 'table',
hostIdentityView: 'table',
monitorHostView: 'table',
};
export default defineStore('app', {

View File

@@ -47,4 +47,5 @@ export interface UserPreferenceView {
hostView: ViewType;
hostKeyView: ViewType;
hostIdentityView: ViewType;
monitorHostView: ViewType;
}

View File

@@ -1,45 +1,68 @@
import type { LineSeriesOption } from 'echarts';
import type { BarSeriesOption, LineSeriesOption } from 'echarts';
import type { LineChartData, Options } from '@/types/global';
type TimeSeriesType = 'line' | 'bar';
/**
* 折线图系列定义
* 时序系列定义
*/
export interface LineSeriesColor {
export interface TimeSeriesColor {
lineColor: string;
itemBorderColor: string;
}
/**
* 折线图系列常量
* 时间系列配置
*/
export const LineSeriesColors: Record<string, LineSeriesColor> = {
export interface TimeSeriesOption {
name: string;
type: TimeSeriesType;
area: boolean;
lineColor: string;
itemBorderColor: string;
data: any[];
}
/**
* 时序系列常量
*/
export const TimeSeriesColors: Record<string, TimeSeriesColor> = {
BLUE: {
lineColor: '#4263EB',
itemBorderColor: '#DBE4FF',
},
CYAN: {
lineColor: '#1098AD',
itemBorderColor: '#C5F6FA',
},
GREEN: {
lineColor: '#37B24D',
itemBorderColor: '#D3F9D8',
},
CYAN: {
lineColor: '#1098AD',
itemBorderColor: '#C5F6FA',
},
YELLOW: {
lineColor: '#F59F00',
itemBorderColor: '#FFF3BF',
},
PURPLE: {
lineColor: '#AE3EC9',
itemBorderColor: '#F3D9FA',
},
ORANGE: {
lineColor: '#F76707',
itemBorderColor: '#FFF3BF',
},
VIOLET: {
lineColor: '#7048E8',
itemBorderColor: '#E5DBFF',
},
YELLOW: {
lineColor: '#F59F00',
LIME: {
lineColor: '#74B816',
itemBorderColor: '#E9FAC8',
},
ORANGE: {
lineColor: '#F76707',
itemBorderColor: '#FFF3BF',
},
INDIGO: {
lineColor: '#4263EB',
itemBorderColor: '#DBE4FF',
},
TEAL: {
lineColor: '#0CA678',
itemBorderColor: '#C3FAE8',
@@ -51,38 +74,80 @@ export const LineSeriesColors: Record<string, LineSeriesColor> = {
};
/**
* 生成折线图系列
* 生成时序系列
*/
export const createLineSeries = (name: string,
lineColor: string,
itemBorderColor: string,
data: number[]): LineSeriesOption => {
export const generateTimeSeriesArr = (options: Array<Options>,
chartData: LineChartData,
type: TimeSeriesType = 'line'): Array<LineSeriesOption | BarSeriesOption> => {
const arr = [];
const optionLen = options.length;
for (let i = 0; i < optionLen; i++) {
// 选项
const option = options[i];
// 获取颜色
let color;
if (option.seriesColor) {
color = TimeSeriesColors[option.seriesColor as keyof typeof TimeSeriesColors];
}
if (!color) {
color = Object.values(TimeSeriesColors)[i % Object.keys(TimeSeriesColors).length];
}
// 获取数据
const data = chartData.data[option.value as keyof typeof chartData.data] || [];
// 生成系列
arr.push(createTimeSeries({
name: option.label,
area: true,
type,
lineColor: color.lineColor,
itemBorderColor: color.itemBorderColor,
data,
}));
}
return arr as Array<LineSeriesOption>;
};
/**
* 创建时序系列
*/
export const createTimeSeries = (option: Partial<TimeSeriesOption>): LineSeriesOption | BarSeriesOption => {
// 设置默认值
if (option.area === undefined) {
option.area = true;
}
if (option.lineColor === undefined) {
option.lineColor = TimeSeriesColors.BLUE.lineColor;
}
if (option.itemBorderColor === undefined) {
option.itemBorderColor = TimeSeriesColors.BLUE.itemBorderColor;
}
// 配置项
return {
name,
data,
type: 'line',
name: option.name,
data: option.data || [],
type: option.type || 'line',
smooth: true,
symbol: 'circle',
symbolSize: 10,
itemStyle: {
color: lineColor,
color: option.lineColor,
},
emphasis: {
focus: 'series',
itemStyle: {
color: lineColor,
color: option.lineColor,
borderWidth: 2,
borderColor: itemBorderColor,
borderColor: option.itemBorderColor,
},
},
lineStyle: {
width: 2,
color: lineColor,
color: option.lineColor,
},
showSymbol: data.length === 1,
areaStyle: {
showSymbol: option.data?.length === 1,
areaStyle: option.area ? {
opacity: 0.1,
color: lineColor,
},
color: option.lineColor,
} : undefined,
};
};

View File

@@ -83,3 +83,10 @@ export interface PieChartData {
export interface BarSingleChartData {
data: Record<string, number>;
}
export interface TimeChartSeries {
name: string;
color: string;
tags: Record<string, any>;
data: Array<[number, number]>;
}

View File

@@ -0,0 +1,10 @@
// 获取百分比进度状态
export const getPercentProgressColor = (percent: number) => {
if (percent < 0.6) {
return 'rgb(var(--green-6))';
} else if (percent < 0.8) {
return 'rgb(var(--orange-6))';
} else {
return 'rgb(var(--red-6))';
}
};

View File

@@ -207,7 +207,7 @@ export function getFileSize(size: number, scale: number = 2) {
result = (size / 1024).toFixed(scale);
unit = 'KB';
} else {
result = size;
result = size.toFixed(scale);
unit = 'B';
}
return `${result} ${unit}`;

View File

@@ -0,0 +1,216 @@
import { dateFormat } from '@/utils/index';
// 监控指标单位
export const MetricsUnit = {
BYTES: 'BYTES',
BITS: 'BITS',
COUNT: 'COUNT',
PER: 'PER',
SECONDS: 'SECONDS',
BYTES_S: 'BYTES_S',
BITS_S: 'BITS_S',
COUNT_S: 'COUNT_S',
TEXT: 'TEXT',
NONE: 'NONE',
};
// 指标单位类型
export type MetricUnitType = keyof typeof MetricsUnit;
// 窗口单位
export type WindowUnit =
| 'SEC'
| 'MIN'
| 'HOUR'
| 'DAY';
// 指标单位格式化选项
export interface MetricUnitFormatOptions {
// 小数位
digit?: number;
// 后缀
suffix?: string;
// 空转0
createEmpty?: number;
[key: string]: any;
}
// 指标单位格式化函数
type MetricUnitFormatterFn = (value: number, option?: MetricUnitFormatOptions) => string;
// 指标单位格式化配置
type WindowTimeFormatterOption = {
// 单位
unit: string;
// 窗口间隔 (ms)
windowInterval: (window: number) => number;
// 窗口格式化
windowFormatter: (window: number) => string;
// 标签格式化
labelFormatter: (time: number) => string;
// 时间格式化
dateFormatter: (time: number) => string;
};
// 指标单位格式化
export const MetricUnitFormatter: Record<MetricUnitType, MetricUnitFormatterFn> = {
// 字节
BYTES: formatBytes,
// 比特
BITS: formatBits,
// 次数
COUNT: formatCount,
// 秒
SECONDS: formatSeconds,
// 百分比
PER: formatPer,
// 字节/秒
BYTES_S: (value, option) => formatBytes(value, option) + '/s',
// 比特/秒
BITS_S: (value, option) => formatBits(value, option) + 'ps',
// 次数/秒
COUNT_S: (value, option) => formatCount(value, option) + '/s',
// 文本
TEXT: formatText,
// 无单位
NONE: (value, option) => formatNumber(value, option),
};
// 窗口单位格式化
export const WindowUnitFormatter: Record<WindowUnit, WindowTimeFormatterOption> = {
// 秒
SEC: {
unit: 's',
windowInterval: (window: number) => window * 1000,
windowFormatter: (window: number) => `${window}${WindowUnitFormatter.SEC.unit}`,
labelFormatter: (time: number) => `${time}`,
dateFormatter: (date: number) => dateFormat(new Date(date), 'mm:ss'),
},
// 分钟
MIN: {
unit: 'm',
windowInterval: (window: number) => window * 60 * 1000,
windowFormatter: (window: number) => `${window}${WindowUnitFormatter.MIN.unit}`,
labelFormatter: (time: number) => `${time}分钟`,
dateFormatter: (date: number) => dateFormat(new Date(date), 'HH:mm'),
},
// 小时
HOUR: {
unit: 'h',
windowInterval: (window: number) => window * 60 * 60 * 1000,
windowFormatter: (window: number) => `${window}${WindowUnitFormatter.HOUR.unit}`,
labelFormatter: (time: number) => `${time}小时`,
dateFormatter: (date: number) => dateFormat(new Date(date), 'dd mm'),
},
// 天
DAY: {
unit: 'd',
windowInterval: (window: number) => window * 24 * 60 * 60 * 1000,
windowFormatter: (window: number) => `${window}${WindowUnitFormatter.DAY.unit}`,
labelFormatter: (time: number) => `${time}`,
dateFormatter: (date: number) => dateFormat(new Date(date), 'MM-dd'),
}
};
// 解析窗口单位
export const parseWindowUnit = (windowValue: string): [number, WindowUnit] => {
const value = Number.parseInt(windowValue);
const item = Object.entries(WindowUnitFormatter).find((item) => windowValue.includes(item[1].unit));
if (item) {
return [value, item[0] as WindowUnit];
} else {
return [value, 'MIN'];
}
};
// 安全取小数位
function getFixed(option?: MetricUnitFormatOptions, defaultValue = 2): number {
return typeof option?.digit === 'number' ? option.digit : defaultValue;
}
// 格式化数字
function formatNumber(value: number, option?: MetricUnitFormatOptions): string {
const fixed = getFixed(option, 2);
const abs = Math.abs(value);
let result: string;
if (abs >= 1e9) {
result = (value / 1e9).toFixed(fixed);
} else if (abs >= 1_000_000) {
result = (value / 1_000_000).toFixed(fixed);
} else if (abs >= 1_000) {
result = (value / 1_000).toFixed(fixed);
} else {
result = value.toFixed(fixed);
}
return parseFloat(result).toString();
}
// 格式化百分比
function formatPer(value: number, option?: MetricUnitFormatOptions): string {
const fixed = getFixed(option, 2);
return parseFloat((value).toFixed(fixed)) + '%';
}
// 格式化字节
function formatBytes(value: number, option?: MetricUnitFormatOptions): string {
const fixed = getFixed(option, 2);
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let v = Math.abs(value);
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
const signedValue = value < 0 ? -v : v;
const formattedNum = parseFloat(signedValue.toFixed(i < 3 ? 0 : fixed));
return `${formattedNum} ${units[i]}`;
}
// 格式化比特
function formatBits(value: number, option?: MetricUnitFormatOptions): string {
const fixed = getFixed(option, 2);
const units = ['b', 'Kb', 'Mb', 'Gb'];
let v = Math.abs(value);
let i = 0;
while (v >= 1000 && i < units.length - 1) {
v /= 1000;
i++;
}
const signedValue = value < 0 ? -v : v;
const formattedNum = parseFloat(signedValue.toFixed(i < 2 ? 0 : fixed));
return `${formattedNum} ${units[i]}`;
}
// 格式化次数
function formatCount(value: number, option?: MetricUnitFormatOptions): string {
const fixed = getFixed(option, 2);
const abs = Math.abs(value);
if (abs >= 1_000_000) {
return parseFloat((value / 1_000_000).toFixed(fixed)) + 'M';
} else if (abs >= 1_000) {
return parseFloat((value / 1_000).toFixed(fixed)) + 'K';
}
return value.toFixed(0);
}
// 格式化时间
function formatSeconds(value: number, option?: MetricUnitFormatOptions): string {
const fixed = getFixed(option, 2);
if (value >= 3600) {
return parseFloat((value / 3600).toFixed(fixed)) + 'h';
} else if (value >= 60) {
return parseFloat((value / 60).toFixed(fixed)) + 'm';
}
return parseFloat(value.toFixed(fixed)) + 's';
}
// 格式化文本
function formatText(value: number, option?: MetricUnitFormatOptions): string {
const fixed = getFixed(option, 2);
const unitText = option?.suffix || '';
const numStr = value.toFixed(fixed);
return unitText ? `${numStr} ${unitText}` : numStr;
}

View File

@@ -188,7 +188,7 @@
:wrap="true">
<template v-for="groupId in record.groupIdList"
:key="groupId">
<a-tag>{{ hostGroupList.find(s => s.key === groupId)?.title || groupId }}</a-tag>
<a-tag>{{ hostGroupList.find((s: HostGroupQueryResponse) => s.key === groupId)?.title || groupId }}</a-tag>
</template>
</a-space>
</template>
@@ -211,6 +211,7 @@
<!-- 单协议连接 -->
<a-button v-if="record.types?.length === 1"
size="mini"
type="text"
v-permission="['terminal:terminal:access']"
@click="openNewRoute({ name: 'terminal', query: { connect: record.id, type: record.types[0] } })">
连接
@@ -219,7 +220,9 @@
<a-popover v-if="(record.types?.length || 0) > 1"
:title="undefined"
:content-style="{ padding: '8px' }">
<a-button v-permission="['terminal:terminal:access']" size="mini">
<a-button v-permission="['terminal:terminal:access']"
type="text"
size="mini">
连接
</a-button>
<template #content>
@@ -282,15 +285,15 @@
import { addSuffix, dataColor, objectTruthKeyCount, resetObject } from '@/utils';
import { deleteHost, getHostPage, updateHostStatus } from '@/api/asset/host';
import { Message, Modal } from '@arco-design/web-vue';
import { getHostOsIcon, hostOsTypeKey, hostArchTypeKey, hostStatusKey, HostType, hostTypeKey, tagColor, TableName } from '../types/const';
import { getHostOsIcon, hostOsTypeKey, hostArchTypeKey, hostStatusKey, hostTypeKey, tagColor, TableName } from '../types/const';
import { copy } from '@/hooks/copy';
import { useCacheStore, useDictStore } from '@/store';
import { useQueryOrder, ASC } from '@/hooks/query-order';
import { GrantKey, GrantRouteName } from '@/views/asset/grant/types/const';
import { useRouter } from 'vue-router';
import { openNewRoute } from '@/router';
import useLoading from '@/hooks/loading';
import fieldConfig from '../types/card.fields';
import { openNewRoute } from '@/router';
import TagMultiSelector from '@/components/meta/tag/multi-selector/index.vue';
const emits = defineEmits(['openAdd', 'openUpdate', 'openHostGroup', 'openCopy']);

View File

@@ -77,8 +77,8 @@
</a-input-number>
<span v-else class="text">{{ addSuffix(formModel.memorySize, 'G') }}</span>
</a-descriptions-item>
<!-- -->
<a-descriptions-item label="盘">
<!-- -->
<a-descriptions-item label="盘">
<a-input-number v-if="editing"
v-model="formModel.diskSize"
class="input"
@@ -138,11 +138,11 @@
<!-- 负责人 -->
<a-descriptions-item label="负责人" :span="2">
<a-input v-if="editing"
v-model="formModel.chargePerson"
v-model="formModel.ownerPerson"
class="input"
size="mini"
allow-clear />
<span v-else class="text">{{ formModel.chargePerson }}</span>
<span v-else class="text">{{ formModel.ownerPerson }}</span>
</a-descriptions-item>
<!-- 创建时间 -->
<a-descriptions-item label="创建时间" :span="2">
@@ -203,36 +203,38 @@
</a-descriptions>
<!-- 操作 -->
<div class="actions">
<!-- 编辑 -->
<a-button v-if="!editing"
type="primary"
long
@click="toggleEditing">
编辑
</a-button>
<!-- 保存 -->
<a-button v-if="editing"
type="primary"
long
@click="saveSpec">
保存
</a-button>
<!-- 取消 -->
<a-button v-if="editing"
class="extra-button"
type="primary"
long
@click="fetchHostSpec">
取消
</a-button>
<!-- 新增规格 -->
<a-button v-if="editing"
class="extra-button"
type="primary"
long
@click="addSpec">
新增规格
</a-button>
<!-- 编辑 -->
<template v-if="!editing">
<!-- 编辑 -->
<a-button type="primary"
long
@click="() => toggleEditing()">
编辑
</a-button>
</template>
<!-- 编辑中 -->
<template v-else>
<!-- 保存 -->
<a-button type="primary"
long
@click="saveSpec">
保存
</a-button>
<!-- 取消 -->
<a-button class="extra-button"
type="primary"
long
@click="fetchHostSpec">
取消
</a-button>
<!-- 新增规格 -->
<a-button class="extra-button"
type="primary"
long
@click="addSpec">
新增规格
</a-button>
</template>
</div>
</a-spin>
</template>
@@ -245,8 +247,9 @@
<script lang="ts" setup>
import type { HostSpecExtraModel } from '@/api/asset/host-extra';
import type { HostQueryResponse } from '@/api/asset/host';
import { onMounted, ref } from 'vue';
import { updateHostSpec } from '@/api/asset/host';
import { getHost, updateHostSpec } from '@/api/asset/host';
import { getHostExtraItem } from '@/api/asset/host-extra';
import { addSuffix, dateFormat } from '@/utils';
import { useToggle } from '@vueuse/core';
@@ -260,6 +263,7 @@
const { loading, setLoading } = useLoading();
const [editing, toggleEditing] = useToggle();
const hostRef = ref<HostQueryResponse>({} as HostQueryResponse);
const formModel = ref<HostSpecExtraModel>({} as HostSpecExtraModel);
// 加载配置
@@ -267,6 +271,10 @@
setLoading(true);
editing.value = false;
try {
// 查询主机信息
const { data: host } = await getHost(props.hostId, true);
hostRef.value = host;
// 查询规格信息
const { data } = await getHostExtraItem<HostSpecExtraModel>({ hostId: props.hostId, item: 'SPEC' });
formModel.value = data;
} catch (e) {

View File

@@ -233,7 +233,7 @@
:wrap="true">
<template v-for="groupId in record.groupIdList"
:key="groupId">
<a-tag>{{ hostGroupList.find(s => s.key === groupId)?.title || groupId }}</a-tag>
<a-tag>{{ hostGroupList.find((s: any) => s.key === groupId)?.title || groupId }}</a-tag>
</template>
</a-space>
</template>
@@ -288,18 +288,6 @@
@click="emits('openUpdate', record)">
修改
</a-button>
<!-- 删除 -->
<a-popconfirm content="确认删除这条记录吗?"
position="left"
type="warning"
@ok="deleteRow(record)">
<a-button v-permission="['asset:host:delete']"
type="text"
size="mini"
status="danger">
删除
</a-button>
</a-popconfirm>
<!-- 更多 -->
<a-dropdown trigger="hover" :popup-max-height="false">
<a-button type="text" size="mini">
@@ -314,6 +302,11 @@
{{ toggleDictValue(hostStatusKey, record.status, 'label') }}
</span>
</a-doption>
<!-- 删除 -->
<a-doption v-permission="['asset:host:delete']"
@click="deleteRow(record)">
<span class="more-doption error">删除</span>
</a-doption>
<!-- 复制 -->
<a-doption v-permission="['asset:host:create']"
@click="emits('openCopy', record)">
@@ -413,17 +406,25 @@
// 删除当前行
const deleteRow = async (record: HostQueryResponse) => {
try {
setLoading(true);
// 调用删除接口
await deleteHost(record.id);
Message.success('删除成功');
// 重新加载
reload();
} catch (e) {
} finally {
setLoading(false);
}
Modal.confirm({
title: '删除前确认!',
titleAlign: 'start',
content: '确定要删除这条记录吗?',
okText: '删除',
onOk: async () => {
try {
setLoading(true);
// 调用删除接口
await deleteHost(record.id);
Message.success('删除成功');
// 重新加载
reload();
} catch (e) {
} finally {
setLoading(false);
}
}
});
};
// 删除选中行

View File

@@ -56,6 +56,18 @@ export const HostAuthType = {
IDENTITY: 'IDENTITY'
};
// 探针安装状态
export const AgentInstallStatus = {
NOT_INSTALL: 0,
INSTALLED: 1,
};
// 探针在线状态
export const AgentOnlineStatus = {
OFFLINE: 0,
ONLINE: 1,
};
// 获取系统类型 icon
export const getHostOsIcon = (osType: string) => {
return HostOsType[osType as keyof typeof HostOsType]?.icon;

View File

@@ -14,7 +14,7 @@ const columns = [
title: '主机信息',
dataIndex: 'hostInfo',
slotName: 'hostInfo',
width: 288,
width: 248,
align: 'left',
fixed: 'left',
default: true,
@@ -100,7 +100,7 @@ const columns = [
}, {
title: '操作',
slotName: 'handle',
width: 198,
width: 168,
align: 'center',
fixed: 'right',
default: true,

View File

@@ -26,7 +26,7 @@
<script lang="ts" setup>
import type { WorkplaceStatisticsData } from '../types/const';
import { createLineSeries, LineSeriesColors } from '@/types/chart';
import { createTimeSeries } from '@/types/chart';
import { useRouter } from 'vue-router';
import useChartOption from '@/hooks/chart-option';
@@ -91,7 +91,10 @@
trigger: 'axis',
},
series: [
createLineSeries('操作数量', LineSeriesColors.BLUE.lineColor, LineSeriesColors.BLUE.itemBorderColor, props.data.infra?.operatorChart.data || []),
createTimeSeries({
name: '操作数量',
data: props.data.infra?.operatorChart.data || []
}),
],
};
});

View File

@@ -80,6 +80,7 @@
import type { WorkplaceStatisticsData } from '@/views/dashboard/workplace/types/const';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { TimeSeriesColors } from '@/types/chart';
import useThemes from '@/hooks/themes';
import useChartOption from '@/hooks/chart-option';
@@ -165,7 +166,7 @@
showSymbol: false,
smooth: true,
lineStyle: {
color: '#165DFF',
color: TimeSeriesColors.BLUE.lineColor,
width: 3,
type: 'dashed',
},
@@ -202,7 +203,9 @@
return {
x: x[index],
value: s,
itemStyle: { color: '#2CAB40' },
itemStyle: {
color: TimeSeriesColors.GREEN.lineColor,
},
};
}),
type: 'bar',

View File

@@ -0,0 +1,178 @@
<template>
<a-modal v-model:visible="visible"
modal-class="modal-form-large"
title-align="start"
:title="title"
:top="80"
:align-center="false"
:draggable="true"
:mask-closable="false"
:unmount-on-close="true"
:ok-button-props="{ disabled: loading }"
:cancel-button-props="{ disabled: loading }"
:on-before-ok="handlerOk"
@close="handleClose">
<a-spin class="full" :loading="loading">
<a-form :model="formModel"
ref="formRef"
label-align="right"
:auto-label-width="true"
:rules="formRules">
<!-- 指标名称 -->
<a-form-item field="name" label="指标名称">
<a-input v-model="formModel.name"
placeholder="请输入指标名称"
allow-clear />
</a-form-item>
<!-- 数据集 -->
<a-form-item field="measurement" label="数据集">
<a-select v-model="formModel.measurement"
:options="toOptions(MeasurementKey)"
placeholder="请选择数据集"
allow-clear />
</a-form-item>
<!-- 指标项 -->
<a-form-item field="value" label="指标项">
<a-input v-model="formModel.value"
placeholder="请输入指标项"
allow-clear />
</a-form-item>
<!-- 单位 -->
<a-form-item field="unit" label="单位">
<a-select v-model="formModel.unit"
:options="toOptions(MetricsUnitKey)"
placeholder="请选择单位"
allow-clear />
</a-form-item>
<!-- 后缀文本 -->
<a-form-item v-if="formModel.unit === MetricsUnit.TEXT"
field="suffix"
label="后缀文本">
<a-input v-model="formModel.suffix"
placeholder="请输入后缀文本"
allow-clear />
</a-form-item>
<!-- 指标描述 -->
<a-form-item field="description" label="指标描述">
<a-textarea v-model="formModel.description"
:auto-size="{ minRows: 3, maxRows: 3 }"
placeholder="请输入指标描述"
allow-clear />
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>
<script lang="ts">
export default {
name: 'metricsFormModal'
};
</script>
<script lang="ts" setup>
import type { MetricsUpdateRequest } from '@/api/monitor/metrics';
import { ref } from 'vue';
import useLoading from '@/hooks/loading';
import useVisible from '@/hooks/visible';
import formRules from '../types/form.rules';
import { MeasurementKey, MetricsUnitKey } from '../types/const';
import { createMetrics, updateMetrics } from '@/api/monitor/metrics';
import { Message } from '@arco-design/web-vue';
import { useDictStore } from '@/store';
import { MetricsUnit } from '@/utils/metrics';
const emits = defineEmits(['added', 'updated']);
const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
const { toOptions } = useDictStore();
const title = ref<string>();
const isAddHandle = ref<boolean>(true);
const formRef = ref<any>();
const formModel = ref<MetricsUpdateRequest>({});
const defaultForm = (): MetricsUpdateRequest => {
return {
id: undefined,
name: undefined,
measurement: undefined,
value: undefined,
unit: MetricsUnit.NONE,
suffix: undefined,
description: undefined,
};
};
// 打开新增
const openAdd = () => {
title.value = '添加监控指标';
isAddHandle.value = true;
renderForm({ ...defaultForm() });
setVisible(true);
};
// 打开修改
const openUpdate = (record: any) => {
title.value = '修改监控指标';
isAddHandle.value = false;
renderForm({ ...defaultForm(), ...record });
setVisible(true);
};
// 渲染表单
const renderForm = (record: any) => {
formModel.value = Object.assign({}, record);
};
defineExpose({ openAdd, openUpdate });
// 确定
const handlerOk = async () => {
setLoading(true);
try {
// 验证参数
const error = await formRef.value.validate();
if (error) {
return false;
}
// 非文本后缀置空
if (MetricsUnit.TEXT !== formModel.value.unit) {
formModel.value.suffix = '';
}
if (isAddHandle.value) {
// 新增
await createMetrics(formModel.value);
Message.success('创建成功');
emits('added');
} else {
// 修改
await updateMetrics(formModel.value);
Message.success('修改成功');
emits('updated');
}
// 清空
handlerClear();
} catch (e) {
return false;
} finally {
setLoading(false);
}
};
// 关闭
const handleClose = () => {
handlerClear();
};
// 清空
const handlerClear = () => {
setLoading(false);
};
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,221 @@
<template>
<!-- 搜索 -->
<a-card class="general-card table-search-card">
<query-header :model="formModel"
label-align="left"
@submit="fetchTableData"
@reset="fetchTableData"
@keyup.enter="() => fetchTableData()">
<!-- 指标名称 -->
<a-form-item field="name" label="指标名称">
<a-input v-model="formModel.name"
placeholder="请输入指标名称"
allow-clear />
</a-form-item>
<!-- 数据集 -->
<a-form-item field="measurement" label="数据集">
<a-select v-model="formModel.measurement"
:options="toOptions(MeasurementKey)"
placeholder="请选择数据集"
allow-clear />
</a-form-item>
<!-- 指标项 -->
<a-form-item field="value" label="指标项">
<a-input v-model="formModel.value"
placeholder="请输入指标项"
allow-clear />
</a-form-item>
<!-- 指标描述 -->
<a-form-item field="description" label="指标描述">
<a-input v-model="formModel.description"
placeholder="请输入指标描述"
allow-clear />
</a-form-item>
</query-header>
</a-card>
<!-- 表格 -->
<a-card class="general-card table-card">
<template #title>
<!-- 左侧操作 -->
<div class="table-left-bar-handle">
<!-- 标题 -->
<div class="table-title">
监控指标列表
</div>
</div>
<!-- 右侧操作 -->
<div class="table-right-bar-handle">
<a-space>
<!-- 新增 -->
<a-button v-permission="['monitor:monitor-metrics:create']"
type="primary"
@click="emits('openAdd')">
新增
<template #icon>
<icon-plus />
</template>
</a-button>
<!-- 调整 -->
<table-adjust :columns="columns"
:columns-hook="columnsHook"
:query-order="queryOrder"
@query="fetchTableData" />
</a-space>
</div>
</template>
<!-- table -->
<a-table row-key="id"
ref="tableRef"
:loading="loading"
:columns="tableColumns"
:data="tableRenderData"
:pagination="pagination"
:bordered="false"
@page-change="(page: number) => fetchTableData(page, pagination.pageSize)"
@page-size-change="(size: number) => fetchTableData(1, size)">
<!-- 数据集 -->
<template #measurement="{ record }">
<span class="span-blue text-copy" @click="copy(record.measurement, '数据集名称已复制')">
{{ getDictValue(MeasurementKey, record.measurement) }}
</span>
</template>
<!-- 指标项 -->
<template #value="{ record }">
<span class="span-blue text-copy" @click="copy(record.measurement, true)">
{{ record.value }}
</span>
</template>
<!-- 单位 -->
<template #unit="{ record }">
<div>
<span v-if="record.unit === MetricsUnit.TEXT">
{{ record.suffix }}
</span>
<span v-else-if="record.unit === MetricsUnit.NONE">
-
</span>
<span v-else>
{{ getDictValue(MetricsUnitKey, record.unit) }}
</span>
</div>
</template>
<!-- 操作 -->
<template #handle="{ record }">
<div class="table-handle-wrapper">
<!-- 修改 -->
<a-button v-permission="['monitor:monitor-metrics:update']"
type="text"
size="mini"
@click="emits('openUpdate', record)">
修改
</a-button>
<!-- 删除 -->
<a-popconfirm content="确认删除这条记录吗?"
position="left"
type="warning"
@ok="deleteRow(record)">
<a-button v-permission="['monitor:monitor-metrics:delete']"
type="text"
size="mini"
status="danger">
删除
</a-button>
</a-popconfirm>
</div>
</template>
</a-table>
</a-card>
</template>
<script lang="ts">
export default {
name: 'metricsTable'
};
</script>
<script lang="ts" setup>
import type { MetricsQueryRequest, MetricsQueryResponse } from '@/api/monitor/metrics';
import { reactive, ref, onMounted } from 'vue';
import { deleteMetrics, getMetricsPage } from '@/api/monitor/metrics';
import { Message } from '@arco-design/web-vue';
import useLoading from '@/hooks/loading';
import columns from '../types/table.columns';
import { MetricsUnit } from '@/utils/metrics';
import { TableName, MeasurementKey, MetricsUnitKey } from '../types/const';
import { useTablePagination, useTableColumns } from '@/hooks/table';
import { useDictStore } from '@/store';
import { useQueryOrder, ASC } from '@/hooks/query-order';
import { copy } from '@/hooks/copy';
import TableAdjust from '@/components/app/table-adjust/index.vue';
const emits = defineEmits(['openAdd', 'openUpdate']);
const pagination = useTablePagination();
const queryOrder = useQueryOrder(TableName, ASC);
const { tableColumns, columnsHook } = useTableColumns(TableName, columns);
const { loading, setLoading } = useLoading();
const { toOptions, getDictValue } = useDictStore();
const tableRenderData = ref<Array<MetricsQueryResponse>>([]);
const formModel = reactive<MetricsQueryRequest>({
id: undefined,
name: undefined,
measurement: undefined,
value: undefined,
unit: undefined,
suffix: undefined,
description: undefined,
});
// 删除当前行
const deleteRow = async (record: MetricsQueryResponse) => {
try {
setLoading(true);
// 调用删除接口
await deleteMetrics(record.id);
Message.success('删除成功');
// 重新加载
reload();
} catch (e) {
} finally {
setLoading(false);
}
};
// 重新加载
const reload = () => {
// 重新加载数据
fetchTableData();
};
defineExpose({ reload });
// 加载数据
const doFetchTableData = async (request: MetricsQueryRequest) => {
try {
setLoading(true);
const { data } = await getMetricsPage(queryOrder.markOrderly(request));
tableRenderData.value = data.rows;
pagination.total = data.total;
pagination.current = request.page;
pagination.pageSize = request.limit;
} catch (e) {
} finally {
setLoading(false);
}
};
// 切换页码
const fetchTableData = (page = 1, limit = pagination.pageSize, form = formModel) => {
doFetchTableData({ page, limit, ...form });
};
onMounted(() => {
fetchTableData();
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,46 @@
<template>
<div class="layout-container" v-if="render">
<!-- 列表-表格 -->
<metrics-table ref="table"
@open-add="() => modal.openAdd()"
@open-update="(e) => modal.openUpdate(e)" />
<!-- 添加修改模态框 -->
<metrics-form-modal ref="modal"
@added="reload"
@updated="reload" />
</div>
</template>
<script lang="ts">
export default {
name: 'metrics'
};
</script>
<script lang="ts" setup>
import { ref, onBeforeMount } from 'vue';
import { useDictStore } from '@/store';
import { dictKeys } from './types/const';
import MetricsTable from './components/metrics-table.vue';
import MetricsFormModal from './components/metrics-form-modal.vue';
const render = ref(false);
const table = ref();
const modal = ref();
// 重新加载
const reload = () => {
table.value.reload();
};
onBeforeMount(async () => {
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
render.value = true;
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,10 @@
export const TableName = 'monitor_metrics';
// 监控指标类型 字典项
export const MeasurementKey = 'metricsMeasurement';
// 监控指标单位 字典项
export const MetricsUnitKey = 'metricsUnit';
// 加载的字典值
export const dictKeys = [MeasurementKey, MetricsUnitKey];

View File

@@ -0,0 +1,52 @@
import type { FieldRule } from '@arco-design/web-vue';
export const name = [{
required: true,
message: '请输入指标名称'
}, {
maxLength: 64,
message: '指标名称长度不能大于64位'
}] as FieldRule[];
export const measurement = [{
required: true,
message: '请输入数据集'
}, {
maxLength: 64,
message: '数据集长度不能大于64位'
}] as FieldRule[];
export const value = [{
required: true,
message: '请输入指标项'
}, {
maxLength: 128,
message: '指标项长度不能大于128位'
}] as FieldRule[];
export const unit = [{
required: true,
message: '请选择单位'
}] as FieldRule[];
export const suffix = [{
required: true,
message: '请输入后缀文本'
}, {
maxLength: 32,
message: '后缀文本长度不能大于32位'
}] as FieldRule[];
export const description = [{
maxLength: 128,
message: '指标描述长度不能大于128位'
}] as FieldRule[];
export default {
name,
measurement,
value,
unit,
suffix,
description,
} as Record<string, FieldRule | FieldRule[]>;

View File

@@ -0,0 +1,98 @@
import type { TableColumnData } from '@arco-design/web-vue';
import { dateFormat } from '@/utils';
const columns = [
{
title: 'id',
dataIndex: 'id',
slotName: 'id',
width: 68,
align: 'left',
fixed: 'left',
default: true,
}, {
title: '指标名称',
dataIndex: 'name',
slotName: 'name',
align: 'left',
width: 238,
ellipsis: true,
tooltip: true,
default: true,
}, {
title: '数据集',
dataIndex: 'measurement',
slotName: 'measurement',
align: 'left',
width: 148,
ellipsis: true,
tooltip: true,
default: true,
}, {
title: '指标项',
dataIndex: 'value',
slotName: 'value',
align: 'left',
minWidth: 288,
ellipsis: true,
tooltip: true,
default: true,
}, {
title: '指标单位',
dataIndex: 'unit',
slotName: 'unit',
align: 'left',
width: 168,
default: true,
}, {
title: '指标描述',
dataIndex: 'description',
slotName: 'description',
align: 'left',
ellipsis: true,
tooltip: true,
default: true,
}, {
title: '创建时间',
dataIndex: 'createTime',
slotName: 'createTime',
align: 'center',
width: 180,
render: ({ record }) => {
return dateFormat(new Date(record.createTime));
},
}, {
title: '修改时间',
dataIndex: 'updateTime',
slotName: 'updateTime',
align: 'center',
width: 180,
render: ({ record }) => {
return dateFormat(new Date(record.updateTime));
},
default: true,
}, {
title: '创建人',
dataIndex: 'creator',
slotName: 'creator',
width: 148,
ellipsis: true,
tooltip: true,
}, {
title: '修改人',
dataIndex: 'updater',
slotName: 'updater',
width: 148,
ellipsis: true,
tooltip: true,
}, {
title: '操作',
slotName: 'handle',
width: 130,
align: 'center',
fixed: 'right',
default: true,
},
] as TableColumnData[];
export default columns;

View File

@@ -0,0 +1,207 @@
<template>
<div class="header-container">
<!-- 左侧 -->
<div class="header-left">
<!-- tab切换 -->
<div class="tab-container">
<a-tabs v-model:active-key="activeKey"
type="rounded"
:hide-content="true">
<a-tab-pane :key="TabKeys.CHART" title="监控图表" />
</a-tabs>
<a-divider direction="vertical"
style="height: 22px; margin: 0 16px 0 8px;"
:size="2" />
</div>
<!-- 基本信息-->
<div class="info-container">
<!-- 标题 -->
<div class="title">{{ host.name }}</div>
<!-- 地址 -->
<a-tag class="text-copy"
color="arcoblue"
@click="copy(host.address, true)">
{{ host.address }}
</a-tag>
<!-- 编码 -->
<a-tag color="arcoblue">{{ host.code }}</a-tag>
<!-- tags -->
<a-tag v-for="tag in (host.tags || [])" color="arcoblue">{{ tag.name }}</a-tag>
<!-- 在线状态 -->
<a-tag :color="getDictValue(OnlineStatusKey, host.agentOnlineStatus, 'color')">
<template #icon>
<component :is="getDictValue(OnlineStatusKey, host.agentOnlineStatus, 'icon')" />
</template>
{{ getDictValue(OnlineStatusKey, host.agentOnlineStatus) }}
</a-tag>
<!-- 版本号 -->
<a-tag color="green">v{{ host.agentVersion }}</a-tag>
</div>
</div>
<!-- 右侧 -->
<div class="header-right">
<!-- 监控图表操作 -->
<div v-if="activeKey === TabKeys.CHART" class="chart-handle">
<a-space>
<!-- 表格时间区间 -->
<a-select v-model="chartRange"
style="width: 138px;"
:options="toOptions(ChartRangeKey)"
@change="changeChartRange">
<template #prefix>
区间
</template>
</a-select>
<!-- 表格窗口 -->
<a-select v-model="chartWindow"
style="width: 138px;"
:options="chartWindowOptions">
<template #prefix>
窗口
</template>
</a-select>
<!-- 刷新 -->
<a-button class="fs16"
title="刷新"
@click="reloadChart">
<template #icon>
<icon-refresh />
</template>
</a-button>
<!-- 切换视图 -->
<a-button class="fs16"
title="切换视图"
@click="chartCompose = !chartCompose">
<template #icon>
<icon-menu v-if="chartCompose" />
<icon-apps v-else />
</template>
</a-button>
</a-space>
</div>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'detail-header'
};
</script>
<script lang="ts" setup>
import type { HostQueryResponse } from '@/api/asset/host';
import type { SelectOptionData } from '@arco-design/web-vue';
import { ref, onMounted, nextTick } from 'vue';
import { copy } from '@/hooks/copy';
import { useDictStore } from '@/store';
import { TabKeys, ChartRangeKey } from '../types/const';
import { OnlineStatusKey } from '@/views/monitor/monitor-host/types/const';
import { parseWindowUnit, WindowUnitFormatter } from '@/utils/metrics';
defineProps<{
host: HostQueryResponse;
}>();
const emits = defineEmits(['reloadChart']);
const activeKey = defineModel('activeKey', { type: String });
const chartCompose = defineModel('chartCompose', { type: Boolean });
const chartRange = ref('-30m');
const chartWindow = ref('1m');
const chartWindowOptions = ref<Array<SelectOptionData>>([{ label: '1分钟', value: '1m' }]);
const { toOptions, getDictValue } = useDictStore();
// 加载图表
const reloadChart = () => {
emits('reloadChart', chartRange.value, chartWindow.value);
};
// 切换图表区间
const changeChartRange = (value: string) => {
const windowValue = getDictValue(ChartRangeKey, value, 'window') as string;
chartWindowOptions.value = windowValue.split(',').map(s => {
const [value, unit] = parseWindowUnit(s);
const label = WindowUnitFormatter[unit].labelFormatter(value);
return {
label,
value: s,
};
});
chartWindow.value = chartWindowOptions.value[0].value as string;
};
onMounted(() => {
nextTick(() => {
reloadChart();
});
});
</script>
<style lang="less" scoped>
.header-container {
width: 100%;
height: 64px;
margin-bottom: 16px;
background-color: var(--color-bg-2);
border-radius: 8px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
.info-container {
display: flex;
color: var(--color-text-2);
& > * {
margin-right: 14px;
display: flex;
align-items: center;
}
.title {
font-size: 20px;
font-weight: bold;
color: var(--color-text-1);
}
}
.tab-container {
padding: 0 0 0 12px;
display: flex;
align-items: center;
}
:deep(.arco-tabs) {
user-select: none;
.arco-tabs-tab {
background-color: var(--color-fill-2);
}
.arco-tabs-nav::before {
height: 0 !important;
}
.arco-tabs-tab {
font-size: 16px;
}
}
}
.header-right {
padding-right: 16px;
.chart-handle {
display: flex;
}
}
</style>

View File

@@ -0,0 +1,179 @@
<template>
<div class="charts-container">
<a-grid :cols="chartCols" :colGap="16" :rowGap="16">
<a-grid-item v-for="(item, index) of chartItems"
:span="item.option?.span || 1"
:style="{ height: (chartCompose ? '400px': '270px') }">
<metrics-chart v-bind="item" :ref="(el: any) => setRef(index, el)" />
</a-grid-item>
</a-grid>
</div>
</template>
<script lang="ts">
export default {
name: 'metricsChartTab'
};
</script>
<script lang="ts" setup>
import type { MetricsChartProps, MetricsChartOption } from '../types/const';
import { computed, ref } from 'vue';
import { parseWindowUnit, MetricsUnit, type MetricUnitType } from '@/utils/metrics';
import { TimeSeriesColors } from '@/types/chart';
import MetricsChart from './metrics-chart.vue';
const props = defineProps<{
agentKey: string;
chartCompose: boolean;
chartRange: string;
chartWindow: string;
}>();
const chartsRef = ref<Record<number, any>>({});
// 响应式布局
const chartCols = computed(() => {
return {
xs: 1,
sm: props.chartCompose ? 2 : 1,
md: props.chartCompose ? 2 : 1,
lg: props.chartCompose ? 2 : 1,
xl: props.chartCompose ? 3 : 1,
xxl: props.chartCompose ? 3 : 1,
};
});
// 设置图表引用
const setRef = (index: number, el: any) => {
chartsRef.value[index] = el;
};
// 图表项
const chartItems = computed<Array<MetricsChartProps>>(() => {
// 获取窗口单位
const [windowValue, windowUnit] = parseWindowUnit(props.chartWindow);
const options: Array<MetricsChartOption> = [
{
name: 'CPU使用率',
measurement: 'cpu',
fields: ['cpu_total_seconds_total'],
colors: [[TimeSeriesColors.BLUE.lineColor, TimeSeriesColors.BLUE.itemBorderColor]],
aggregate: 'mean',
unit: MetricsUnit.PER as MetricUnitType,
unitOption: { digit: 3 }
},
{
name: '内存使用率',
measurement: 'memory',
fields: ['mem_used_percent', 'mem_swap_used_percent'],
colors: [[TimeSeriesColors.LIME.lineColor, TimeSeriesColors.LIME.itemBorderColor], [TimeSeriesColors.TEAL.lineColor, TimeSeriesColors.TEAL.itemBorderColor]],
aggregate: 'mean',
unit: MetricsUnit.PER as MetricUnitType,
unitOption: { digit: 3 }
},
{
name: '内存使用量',
measurement: 'memory',
fields: ['mem_used_bytes_total', 'mem_swap_used_bytes_total'],
colors: [[TimeSeriesColors.LIME.lineColor, TimeSeriesColors.LIME.itemBorderColor], [TimeSeriesColors.TEAL.lineColor, TimeSeriesColors.TEAL.itemBorderColor]],
aggregate: 'mean',
unit: MetricsUnit.BYTES as MetricUnitType,
unitOption: { digit: 2 }
},
{
name: '系统负载',
measurement: 'load',
fields: ['load1', 'load5', 'load15'],
span: 1,
legend: true,
background: false,
colors: [[TimeSeriesColors.LIME.lineColor, TimeSeriesColors.LIME.itemBorderColor], [TimeSeriesColors.RED.lineColor, TimeSeriesColors.RED.itemBorderColor], [TimeSeriesColors.BLUE.lineColor, TimeSeriesColors.BLUE.itemBorderColor]],
aggregate: 'mean',
unit: MetricsUnit.NONE as MetricUnitType,
unitOption: { digit: 2 }
},
{
name: '磁盘使用率',
measurement: 'disk',
fields: ['disk_fs_used_percent'],
colors: [[TimeSeriesColors.VIOLET.lineColor, TimeSeriesColors.VIOLET.itemBorderColor]],
aggregate: 'mean',
unit: MetricsUnit.PER as MetricUnitType,
unitOption: { digit: 2 }
},
{
name: '磁盘使用量',
measurement: 'disk',
fields: ['disk_fs_used_bytes_total'],
colors: [[TimeSeriesColors.LIME.lineColor, TimeSeriesColors.LIME.itemBorderColor]],
aggregate: 'mean',
unit: MetricsUnit.BYTES as MetricUnitType,
unitOption: { digit: 2 }
},
{
name: '网络连接数',
measurement: 'connections',
fields: ['net_tcp_connections', 'net_udp_connections'],
colors: [[TimeSeriesColors.CYAN.lineColor, TimeSeriesColors.CYAN.itemBorderColor]],
aggregate: 'mean',
unit: MetricsUnit.COUNT as MetricUnitType,
unitOption: { digit: 0, suffix: '个' }
},
{
name: '网络带宽',
measurement: 'network',
fields: ['net_sent_bytes_per_second', 'net_recv_bytes_per_second'],
colors: [[TimeSeriesColors.BLUE.lineColor, TimeSeriesColors.BLUE.itemBorderColor], [TimeSeriesColors.GREEN.lineColor, TimeSeriesColors.GREEN.itemBorderColor]],
aggregate: 'mean',
unit: MetricsUnit.BITS_S as MetricUnitType,
unitOption: { digit: 2 }
},
{
name: '磁盘IO',
measurement: 'io',
fields: ['disk_io_read_bytes_per_second', 'disk_io_write_bytes_per_second'],
colors: [[TimeSeriesColors.CYAN.lineColor, TimeSeriesColors.CYAN.itemBorderColor], [TimeSeriesColors.YELLOW.lineColor, TimeSeriesColors.YELLOW.itemBorderColor]],
aggregate: 'mean',
unit: MetricsUnit.BYTES_S as MetricUnitType,
unitOption: { digit: 2 }
},
];
return options.map(option => {
return {
agentKeys: [props.agentKey],
range: props.chartRange,
windowValue: windowValue,
windowUnit,
option,
};
});
});
// 重新加载
const reload = async () => {
const allCharts = Object.values(chartsRef.value);
const chunks = [];
// 分组
for (let i = 0; i < allCharts.length; i += 3) {
chunks.push(allCharts.slice(i, i + 3));
}
// 顺序刷新
for (const chunk of chunks) {
try {
await Promise.all(chunk.map(s => s.refresh()));
} catch (e) {
}
}
};
defineExpose({ reload });
</script>
<style lang="less" scoped>
.charts-container {
width: 100%;
}
</style>

View File

@@ -0,0 +1,241 @@
<template>
<a-card class="full"
size="small"
style="border-radius: 8px;"
:loading="loading"
:bordered="false"
:header-style="{ height: '48px', padding: '8px 16px 0 16px', borderBottom: 'none' }"
:body-style="{ padding: '0 0 0 16px', height: 'calc(100% - 48px)', position: 'relative' }">
<!-- 标题 -->
<template #title>
<div class="chart-title">
<!-- 名称 -->
<h3>{{ option.name }}</h3>
<!-- 聚合 -->
<a-tag color="arcoblue">{{ getDictValue(MetricsAggregateKey, option.aggregate) }}</a-tag>
</div>
</template>
<!-- 无数据 -->
<div v-if="!series.length && !loading"
class="nodata-chart">
<a-empty description="此监控指标暂无数据" />
</div>
<!-- 图表 -->
<chart v-else-if="chartOption"
:options="chartOption"
class="chart"
width="100%"
height="100%" />
</a-card>
</template>
<script lang="ts">
export default {
name: 'metricsChart'
};
</script>
<script lang="ts" setup>
import type { TimeChartSeries } from '@/types/global';
import type { MetricsChartProps } from '../types/const';
import { ref } from 'vue';
import { dateFormat } from '@/utils';
import useLoading from '@/hooks/loading';
import useChartOption from '@/hooks/chart-option';
import { getMonitorHostChart } from '@/api/monitor/monitor-host';
import { createTimeSeries, TimeSeriesColors } from '@/types/chart';
import { MetricsAggregateKey } from '../types/const';
import { MetricUnitFormatter, WindowUnitFormatter } from '@/utils/metrics';
import { useDictStore } from '@/store';
const props = defineProps<MetricsChartProps>();
const { getDictValue } = useDictStore();
const { loading, setLoading } = useLoading(true);
const series = ref<Array<TimeChartSeries>>([]);
// 数量图表配置
const { chartOption } = useChartOption((dark, themeTextColor, themeLineColor) => {
if (!series.value?.length) {
return {};
}
return {
grid: {
left: 64,
right: 24,
top: 28,
bottom: 32,
},
backgroundColor: 'transparent',
animation: false,
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
textStyle: {
color: 'rgba(0, 0, 0, 0.8)',
},
formatter: function(params: any) {
if (!params.length) {
return '';
}
// 表头
const allTagKeys = new Set();
params.forEach((item: any) => {
const tags = series.value?.[item.seriesIndex]?.tags || {};
Object.keys(tags).forEach(key => allTagKeys.add(key));
});
const headerNames = [...allTagKeys, 'value'];
const gridColumnCount = headerNames.length;
const gridTemplate = `grid-template-columns: repeat(${gridColumnCount}, auto);`;
// 模板
let result = `
<div class="chart-tooltip-wrapper">
<div class="chart-tooltip-time">${dateFormat(new Date(params[0].value[0]))}</div>
<div class="chart-tooltip-header">
<div class="chart-tooltip-header-grid" style="${gridTemplate}"">
`;
// 表头
headerNames.forEach(key => {
const textAlign = key === 'value' ? 'right' : 'left';
result += `
<div class="chart-tooltip-header-item" style="text-align: ${textAlign};">${key}</div>
`;
});
// 数据行
params.forEach((item: any) => {
const tags = series.value?.[item.seriesIndex]?.tags || {};
const value = item.data[1];
let displayValue: string;
if (value === undefined || value === null) {
displayValue = '-';
} else {
displayValue = MetricUnitFormatter[props.option.unit](value, props.option.unitOption);
}
headerNames.forEach((key, index) => {
const cellValue = key === 'value' ? displayValue : (tags[key as any] || '-');
const justifyContent = key === 'value' ? 'flex-end' : 'flex-start';
// 圆点
const dot = index === 0
? `<span class="chart-tooltip-dot" style="background-color: ${item.color};"></span>`
: '';
result += `
<div class="chart-tooltip-col" style="justify-content: ${justifyContent};">${dot}${cellValue}</div>
`;
});
});
// 闭合
result += `</div></div></div>`;
return result;
}
},
xAxis: {
type: 'time',
boundaryGap: false,
minInterval: WindowUnitFormatter[props.windowUnit].windowInterval(props.windowValue) * 6,
axisLabel: {
formatter: (value: number) => WindowUnitFormatter[props.windowUnit].dateFormatter(value),
rotate: 0,
interval: 'auto',
margin: 12,
hideOverlap: true,
color: themeTextColor,
},
axisLine: {
show: false,
},
axisTick: {
show: false,
},
splitLine: {
show: false,
},
axisPointer: {
show: true,
lineStyle: {
color: TimeSeriesColors.BLUE.lineColor,
width: 2,
},
},
},
yAxis: {
type: 'value',
axisLabel: {
color: themeTextColor,
formatter: (s: number) => MetricUnitFormatter[props.option.unit](s, props.option.unitOption)
},
axisLine: {
show: false,
},
splitLine: {
lineStyle: {
color: themeLineColor,
},
},
},
legend: {
show: props.option.legend === true,
type: 'scroll'
},
series: series.value.map((s, index) => {
let colors = props.option.colors[index];
return createTimeSeries({
name: s.name,
type: props.option.type,
area: props.option.background,
lineColor: colors?.[0],
itemBorderColor: colors?.[1],
data: s.data
});
})
};
});
// 刷新数据
const refresh = async () => {
setLoading(true);
try {
// 查询数据
const { data } = await getMonitorHostChart({
agentKeys: props.agentKeys,
range: props.range,
measurement: props.option.measurement,
fields: props.option.fields,
aggregate: props.option.aggregate,
window: WindowUnitFormatter[props.windowUnit].windowFormatter(props.windowValue),
});
series.value = data;
} catch (e) {
} finally {
setLoading(false);
}
};
defineExpose({ refresh });
</script>
<style lang="less" scoped>
.chart-title {
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
display: inline-block;
}
}
.nodata-chart {
padding-top: 42px;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<a-spin v-if="hostId"
class="container"
:loading="!host">
<!-- 头部 -->
<detail-header v-if="host"
v-model:activeKey="activeKey"
v-model:chartCompose="chartCompose"
:host="host"
@reload-chart="reloadChart" />
<!-- 内容部分 -->
<div v-if="host" class="content-container">
<!-- 监控图表 -->
<metrics-chart-tab v-show="activeKey === TabKeys.CHART"
ref="chartRef"
:agentKey="host.agentKey"
:chartCompose="chartCompose"
:chartRange="chartRange"
:chartWindow="chartWindow" />
</div>
</a-spin>
</template>
<script lang="ts">
export default {
name: 'monitorDetail'
};
</script>
<script lang="ts" setup>
import { type HostQueryResponse, getHost } from '@/api/asset/host';
import { useRoute } from 'vue-router';
import { onMounted, ref, onUnmounted, onActivated, onDeactivated } from 'vue';
import { TabKeys } from './types/const';
import { Message } from '@arco-design/web-vue';
import { useDictStore } from '@/store';
import { dictKeys } from './types/const';
import { parseWindowUnit, WindowUnitFormatter } from '@/utils/metrics';
import DetailHeader from './compoments/detail-header.vue';
import MetricsChartTab from './compoments/metrics-chart-tab.vue';
const hostId = ref<number>();
const host = ref<HostQueryResponse>();
const activeKey = ref(TabKeys.CHART);
const chartCompose = ref(true);
const chartRef = ref();
const reloadChartId = ref<number>();
const chartRange = ref<string>('-30m');
const chartWindow = ref<string>('1m');
// 重新加载
const reloadChart = (_chartRange: string, _chartWindow: string) => {
chartRange.value = _chartRange;
chartWindow.value = _chartWindow;
// 立即加载和定时加载
setTimeout(() => {
chartRef.value.reload();
}, 50);
// 重置定时加载表格;
resetReloadChartInterval();
};
// 重置定时加载表格
const resetReloadChartInterval = () => {
if (!chartWindow.value) {
return;
}
// 清除定时
window.clearInterval(reloadChartId.value);
// 计算窗口
const [windowTime, windowUnit] = parseWindowUnit(chartWindow.value as string);
const interval = WindowUnitFormatter[windowUnit].windowInterval(windowTime) + 5000;
// 重新设置定时
reloadChartId.value = window.setInterval(() => {
chartRef.value.reload();
}, interval);
};
onMounted(async () => {
const route = useRoute();
hostId.value = parseInt(route.query.hostId as string);
if (!hostId.value) {
Message.error('参数错误');
return;
}
// 加载字典项
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
// 查询主机信息
const { data } = await getHost(hostId.value);
host.value = data;
});
onActivated(resetReloadChartInterval);
onDeactivated(() => window.clearInterval(reloadChartId.value));
onUnmounted(() => window.clearInterval(reloadChartId.value));
</script>
<style lang="less" scoped>
.container {
width: 100%;
height: 100%;
padding: 16px;
position: relative;
}
.content-container {
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,45 @@
import type { WindowUnit, MetricUnitType, MetricUnitFormatOptions } from '@/utils/metrics';
// 图表组件配置
export interface MetricsChartProps {
agentKeys: Array<string>;
range: string;
windowValue: number;
windowUnit: WindowUnit;
option: MetricsChartOption;
}
// 图表显示配置
export interface MetricsChartOption {
name: string;
type?: 'line' | 'bar';
measurement: string;
fields: Array<string>;
span?: number;
legend?: boolean;
background?: boolean;
colors: Array<[string, string]>;
aggregate: string;
unit: MetricUnitType;
unitOption: MetricUnitFormatOptions;
}
// tab
export const TabKeys = {
CHART: 'chart'
};
// 探针在线状态 字典项
export const OnlineStatusKey = 'agentOnlineStatus';
// 监控告警开关 字典项
export const AlarmSwitchKey = 'monitorAlarmSwitch';
// 指标图表区间 字典项
export const ChartRangeKey = 'metricsChartRange';
// 指标聚合函数 字典项
export const MetricsAggregateKey = 'metricsAggregate';
// 加载的字典值
export const dictKeys = [AlarmSwitchKey, OnlineStatusKey, ChartRangeKey, MetricsAggregateKey];

View File

@@ -0,0 +1,68 @@
<template>
<div v-if="record.agentInstallStatus === AgentInstallStatus.INSTALLED">
<!-- 数据列 -->
<template v-if="dataCell">
<div v-if="!record.metricsData?.noData"
class="metrics-wrapper"
:class="dataClass">
<slot name="default" />
</div>
<span v-else class="nodata">{{ NODATA_TIPS }}</span>
</template>
<!-- 非数据列 -->
<template v-else>
<slot name="default" />
</template>
</div>
<!-- 未安装则不显示 -->
<span v-else>-</span>
</template>
<script lang="ts">
export default {
name: 'monitorCell'
};
</script>
<script lang="ts" setup>
import type { MonitorHostQueryResponse } from '@/api/monitor/monitor-host';
import { NODATA_TIPS } from '@/views/monitor/monitor-host/types/const';
import { AgentInstallStatus } from '@/views/asset/host-list/types/const';
defineProps<{
dataCell: boolean;
dataClass?: string;
record: MonitorHostQueryResponse;
}>();
</script>
<style lang="less" scoped>
.nodata {
color: var(--color-text-3);
font-size: 12px;
}
:deep(.metrics-wrapper) {
display: flex;
align-items: center;
justify-content: space-between;
&.network {
flex-direction: column;
align-items: flex-start !important;
}
.metrics-value-per {
width: 60px;
text-align: end;
font-size: 10px;
font-weight: 600;
&::after {
content: '%';
font-weight: 600;
}
}
}
</style>

View File

@@ -0,0 +1,418 @@
<template>
<card-list v-model:searchValue="formModel.searchValue"
search-input-placeholder="输入 id / 名称 / 编码 / 地址"
:create-card-position="false"
:loading="loading"
:field-config="cardFieldConfig"
:list="list"
:pagination="pagination"
:card-layout-cols="cardColLayout"
:filter-count="filterCount"
:fields-hook="fieldsHook"
:handle-visible="{ disableAdd: true }"
@reset="reset"
@search="fetchCardData"
@page-change="fetchCardData">
<!-- 左侧操作 -->
<template #leftHandle>
<a-space>
<!-- 自动刷新 -->
<a-tooltip content="开启后每 60s 会自动刷新" mini>
<a-button class="card-header-button" @click="toggleAutoRefresh">
{{ autoRefresh ? '关闭自动刷新' : '开启自动刷新' }}
<template #icon>
<icon-refresh />
</template>
</a-button>
</a-tooltip>
<!-- 上传发布包 -->
<a-button class="card-header-button" @click="emits('openUpload')">
上传发布包
<template #icon>
<icon-upload />
</template>
</a-button>
</a-space>
</template>
<!-- 过滤条件 -->
<template #filterContent>
<a-form :model="formModel"
class="card-filter-form"
size="small"
ref="formRef"
label-align="right"
:auto-label-width="true"
@keyup.enter="() => fetchCardData()">
<!-- 主机名称 -->
<a-form-item field="name" label="主机名称">
<a-input v-model="formModel.name" placeholder="请输入主机名称" allow-clear />
</a-form-item>
<!-- 主机地址 -->
<a-form-item field="address" label="主机地址">
<a-input v-model="formModel.address" placeholder="请输入主机地址" allow-clear />
</a-form-item>
<!-- 在线状态 -->
<a-form-item field="agentOnlineStatus" label="在线状态">
<a-select v-model="formModel.agentOnlineStatus"
:options="toOptions(OnlineStatusKey)"
placeholder="请选择在线状态"
allow-clear />
</a-form-item>
<!-- 探针状态 -->
<a-form-item field="agentInstallStatus" label="探针状态">
<a-select v-model="formModel.agentInstallStatus"
:options="toOptions(InstallStatusKey)"
placeholder="请选择探针状态"
allow-clear />
</a-form-item>
<!-- 告警开关 -->
<a-form-item field="alarmSwitch" label="告警开关">
<a-select v-model="formModel.alarmSwitch"
:options="toOptions(AlarmSwitchKey)"
placeholder="请选择告警开关"
allow-clear />
</a-form-item>
<!-- agentKey -->
<a-form-item field="agentKey" label="agentKey">
<a-input v-model="formModel.agentKey"
placeholder="请输入 agentKey"
allow-clear />
</a-form-item>
<!-- 负责人 -->
<a-form-item field="ownerUserId" label="负责人">
<user-selector v-model="formModel.ownerUserId"
placeholder="请选择负责人"
allow-clear />
</a-form-item>
</a-form>
</template>
<!-- 标题 -->
<template #title="{ record }">
{{ record.name }}
</template>
<!-- 主机地址 -->
<template #address="{ record }">
<span class="span-blue text-copy"
title="复制"
@click="copy(record.address)">
{{ record.address }}
</span>
</template>
<!-- 在线状态 -->
<template #agentOnlineStatus="{ record }">
<monitor-cell :data-cell="false" :record="record">
<a-tooltip :content="'切换分区时间: ' + dateFormat(new Date(record.lastChangeOnlineTime))" mini>
<a-tag :color="getDictValue(OnlineStatusKey, record.agentOnlineStatus, 'color')">
<template #icon>
<component :is="getDictValue(OnlineStatusKey, record.agentOnlineStatus, 'icon')" />
</template>
{{ getDictValue(OnlineStatusKey, record.agentOnlineStatus) }}
</a-tag>
</a-tooltip>
</monitor-cell>
</template>
<!-- cpu -->
<template #cpuUsage="{ record }">
<monitor-cell :data-cell="true" :record="record">
<a-tooltip :content="'CPU' + record.config?.cpuName +': ' + record.metricsData?.cpuUsagePercent?.toFixed(2) + '%'" mini>
<a-progress size="large"
:animation="true"
:show-text="false"
:color="getPercentProgressColor(record.metricsData?.cpuUsagePercent / 100)"
:percent="record.metricsData.cpuUsagePercent / 100" />
</a-tooltip>
<span class="metrics-value-per">{{ record.metricsData.cpuUsagePercent.toFixed(2) }}</span>
</monitor-cell>
</template>
<!-- 内存 -->
<template #memoryUsage="{ record }">
<monitor-cell :data-cell="true" :record="record">
<a-tooltip :content="getFileSize(record.metricsData?.memoryUsageBytes)" mini>
<a-progress size="large"
:animation="true"
:show-text="false"
:color="getPercentProgressColor(record.metricsData?.memoryUsagePercent / 100)"
:percent="record.metricsData.memoryUsagePercent / 100" />
</a-tooltip>
<span class="metrics-value-per">{{ record.metricsData?.memoryUsagePercent?.toFixed(2) }}</span>
</monitor-cell>
</template>
<!-- 磁盘 -->
<template #diskUsage="{ record }">
<monitor-cell :data-cell="true" :record="record">
<a-tooltip :content="record.config?.diskName +': ' + getFileSize(record.metricsData?.diskUsageBytes)" mini>
<a-progress size="large"
:animation="true"
:show-text="false"
:color="getPercentProgressColor(record.metricsData?.diskUsagePercent / 100)"
:percent="record.metricsData.diskUsagePercent / 100" />
</a-tooltip>
<span class="metrics-value-per">{{ record.metricsData?.diskUsagePercent?.toFixed(2) }}</span>
</monitor-cell>
</template>
<!-- 网络 -->
<template #network="{ record }">
<monitor-cell data-class="network"
:data-cell="true"
:record="record">
<div class="network-inline">
<!-- 上行速度 -->
<a-tooltip :content="record.config?.networkName +': ' + getFileSize(record.metricsData?.networkSentPreBytes) + '/s'" mini>
<b class="span-green fs12" title="上行速度">
<icon-arrow-up />
{{ getFileSize(record.metricsData?.networkSentPreBytes) }}/s
</b>
</a-tooltip>
<!-- 下行速度 -->
<a-tooltip :content="record.config?.networkName +': ' + getFileSize(record.metricsData?.networkRecvPreBytes) + '/s'" mini>
<b class="span-blue fs12" title="下行速度">
<icon-arrow-down />
{{ getFileSize(record.metricsData?.networkRecvPreBytes) }}/s
</b>
</a-tooltip>
</div>
</monitor-cell>
</template>
<!-- 负载 -->
<template #load="{ record }">
<monitor-cell :data-cell="true" :record="record">
<b class="fs12">
{{ record.metricsData?.load1?.toFixed(2) }}, {{ record.metricsData?.load5?.toFixed(2) }}, {{ record.metricsData?.load15?.toFixed(2) }}
</b>
</monitor-cell>
</template>
<!-- 告警策略 -->
<template #alarmPolicy="{ record }">
<monitor-cell :data-cell="false" :record="record">
{{ getDictValue(AlarmSwitchKey, record.alarmSwitch) }}
</monitor-cell>
</template>
<!-- 告警负责人 -->
<template #ownerUsername="{ record }">
<monitor-cell :data-cell="false" :record="record">
{{ record.ownerUsername }}
</monitor-cell>
</template>
<!-- 标签 -->
<template #tags="{ record }">
<a-space v-if="record.tags?.length"
style="margin-bottom: -8px;"
:wrap="true">
<a-tag v-for="tag in record.tags"
:key="tag.id"
:color="dataColor(tag.name, tagColor)">
{{ tag.name }}
</a-tag>
</a-space>
<span v-else>-</span>
</template>
<!-- agentKey -->
<template #agentKey="{ record }">
<span class="text-copy text-ellipsis"
:title="record.agentKey"
@click="copy(record.agentKey, true)">
{{ record.agentKey }}
</span>
</template>
<!-- 探针版本 -->
<template #agentVersion="{ record }">
<!-- 安装状态 -->
<div v-if="record.installLog?.status === AgentLogStatus.WAIT
|| record.installLog?.status === AgentLogStatus.RUNNING
|| record.installLog?.status === AgentLogStatus.FAILED"
class="flex-center">
<!-- 当前状态 -->
<a-tag :color="getDictValue(AgentLogStatusKey, record.installLog.status, 'color')"
:loading="getDictValue(AgentLogStatusKey, record.installLog.status, 'loading')">
{{ getDictValue(AgentLogStatusKey, record.installLog.status, 'installLabel') }}
</a-tag>
<!-- 提示信息 -->
<a-tooltip v-if="record.installLog.message"
:content="record.installLog.message"
mini>
<icon-question-circle class="fs16 span-red ml4" />
</a-tooltip>
</div>
<!-- 已安装显示版本号 -->
<b v-else-if="record.agentInstallStatus === AgentInstallStatus.INSTALLED"
class="fs12"
:class="record.latestVersion && record.latestVersion !== record.agentVersion ? 'span-red' : ''">
{{ record.agentVersion ? 'v' + record.agentVersion : '-' }}
<a-tooltip v-if="record.latestVersion && record.latestVersion !== record.agentVersion"
:content="'存在新版本 v' + record.latestVersion + ', 请及时升级'"
mini>
<icon-arrow-rise />
</a-tooltip>
</b>
<!-- 显示未安装 -->
<span v-else>
<a-tag>{{ getDictValue(InstallStatusKey, record.agentInstallStatus) }}</a-tag>
</span>
</template>
<!-- 拓展操作 -->
<template #extra="{ record }">
<a-space>
<a-button v-permission="['monitor:monitor-host:query']"
type="text"
size="mini"
:disabled="record.agentInstallStatus !== AgentInstallStatus.INSTALLED"
@click="openDetail(record.hostId, record.name)">
详情
</a-button>
<!-- 更多操作 -->
<a-dropdown trigger="hover" :popup-max-height="false">
<icon-more class="card-extra-icon" />
<template #content>
<!-- 修改 -->
<a-doption v-if="record.agentInstallStatus === AgentInstallStatus.INSTALLED"
v-permission="['monitor:monitor-host:update']"
type="text"
size="mini"
@click="emits('openUpdate', record)">
<span class="more-doption normal">修改配置</span>
</a-doption>
<!-- 安装探针 -->
<a-doption v-permission="['asset:host:install-agent']"
:disabled="record.installLog?.status === AgentLogStatus.WAIT || record.installLog?.status === AgentLogStatus.RUNNING"
type="text"
size="mini"
@click="installAgent([record.hostId])">
<span class="more-doption normal">安装探针</span>
</a-doption>
<!-- 安装成功 -->
<a-doption v-if="record.installLog?.id && record.installLog?.status !== AgentLogStatus.SUCCESS"
v-permission="['asset:host:install-agent']"
type="text"
size="mini"
@click="setInstallSuccess(record.installLog)">
<span class="more-doption normal">安装成功</span>
</a-doption>
<!-- 报警开关 -->
<a-doption v-if="record.id"
v-permission="['monitor:monitor-host:update', 'monitor:monitor-host:update-switch']"
type="text"
size="mini"
@click="toggleAlarmSwitch(record)">
<span class="more-doption normal">
{{ toggleDictValue(AlarmSwitchKey, record.alarmSwitch, 'label') + '报警' }}
</span>
</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
</card-list>
</template>
<script lang="ts">
export default {
name: 'monitorHostCardList'
};
</script>
<script lang="ts" setup>
import type { MonitorHostQueryRequest, MonitorHostQueryResponse } from '@/api/monitor/monitor-host';
import { useCardPagination, useCardColLayout, useCardFieldConfig } from '@/hooks/card';
import { computed, reactive, ref, onMounted, } from 'vue';
import useLoading from '@/hooks/loading';
import { dateFormat, objectTruthKeyCount, resetObject, dataColor } from '@/utils';
import fieldConfig from '../types/card.fields';
import { TableName, AlarmSwitchKey, OnlineStatusKey, InstallStatusKey, AgentLogStatus, AgentLogStatusKey } from '../types/const';
import { getMonitorHostPage } from '@/api/monitor/monitor-host';
import { useDictStore } from '@/store';
import { AgentInstallStatus, tagColor } from '@/views/asset/host-list/types/const';
import { copy } from '@/hooks/copy';
import { getFileSize } from '@/utils/file';
import { getPercentProgressColor } from '@/utils/charts';
import useMonitorHostList from '../types/use-monitor-host-list';
import MonitorCell from './monitor-cell.vue';
import UserSelector from '@/components/user/user/selector/index.vue';
const emits = defineEmits(['openUpdate', 'openUpload']);
const cardColLayout = useCardColLayout();
const pagination = useCardPagination();
const { loading, setLoading } = useLoading();
const { cardFieldConfig, fieldsHook } = useCardFieldConfig(TableName, fieldConfig);
const { toOptions, getDictValue, toggleDictValue } = useDictStore();
const list = ref<Array<MonitorHostQueryResponse>>([]);
const formRef = ref();
const formModel = reactive<MonitorHostQueryRequest>({
searchValue: undefined,
alarmSwitch: undefined,
ownerUserId: undefined,
policyId: undefined,
name: undefined,
code: undefined,
address: undefined,
agentInstallStatus: AgentInstallStatus.INSTALLED,
agentOnlineStatus: undefined,
description: undefined,
tags: undefined,
});
// 条件数量
const filterCount = computed(() => {
return objectTruthKeyCount(formModel, ['searchValue']);
});
// 重新加载
const reload = () => {
// 重新加载数据
fetchCardData();
};
defineExpose({ reload });
const {
autoRefresh,
openDetail,
toggleAutoRefresh,
installAgent,
setInstallSuccess,
toggleAlarmSwitch,
} = useMonitorHostList({
hosts: list,
setLoading,
reload,
});
// 重置条件
const reset = () => {
resetObject(formModel);
fetchCardData();
};
// 加载数据
const doFetchCardData = async (request: MonitorHostQueryRequest) => {
try {
setLoading(true);
const { data } = await getMonitorHostPage(request);
list.value = data.rows;
pagination.total = data.total;
pagination.current = request.page;
pagination.pageSize = request.limit;
} catch (e) {
} finally {
setLoading(false);
}
};
// 切换页码
const fetchCardData = (page = 1, limit = pagination.pageSize, form = formModel) => {
doFetchCardData({ page, limit, ...form });
};
onMounted(fetchCardData);
</script>
<style lang="less" scoped>
.network-inline {
b {
width: 100px;
display: inline-block;
}
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<a-drawer v-model:visible="visible"
title="修改监控主机配置"
:width="470"
:mask-closable="false"
:unmount-on-close="true"
:ok-button-props="{ disabled: loading }"
:cancel-button-props="{ disabled: loading }"
:on-before-ok="handlerOk"
@cancel="handleClose">
<a-spin class="full drawer-form-small" :loading="loading">
<a-form :model="formModel"
ref="formRef"
label-align="right"
:label-width="120"
:rules="formRules">
<!-- 主机名称 -->
<a-form-item field="name" label="主机名称">
<a-input v-model="hostRecord.name" readonly />
</a-form-item>
<!-- 主机地址 -->
<a-form-item field="address" label="主机地址">
<a-input v-model="hostRecord.address" readonly />
</a-form-item>
<!-- 负责人 -->
<a-form-item field="ownerUserId" label="负责人">
<user-selector v-model="formModel.ownerUserId"
placeholder="请选择负责人"
allow-clear />
</a-form-item>
<!-- 告警开关 -->
<a-form-item field="alarmSwitch"
label="告警开关"
style="margin-bottom: 0;"
hide-asterisk>
<a-switch v-model="formModel.alarmSwitch"
type="round"
:checked-value="AlarmSwitch.ON"
:unchecked-value="AlarmSwitch.OFF"
checked-text=""
unchecked-text=""
allow-clear />
</a-form-item>
<!-- 展示设置 -->
<a-divider direction="horizontal">
展示设置
</a-divider>
<!-- CPU -->
<!-- <a-form-item field="cpuName" label="CPU">-->
<!-- <a-select v-model="formModel.cpuName"-->
<!-- :options="hostRecord.meta?.cpus || []"-->
<!-- placeholder="请选择展示的CPU" />-->
<!-- </a-form-item>-->
<!-- 磁盘 -->
<a-form-item field="diskName" label="磁盘">
<a-select v-model="formModel.diskName"
:options="hostRecord.meta?.disks || []"
placeholder="请选择展示的磁盘名称" />
</a-form-item>
<!-- 网卡 -->
<a-form-item field="networkName" label="网卡">
<a-select v-model="formModel.networkName"
:options="hostRecord.meta?.nets || []"
placeholder="请选择展示的网卡名称" />
</a-form-item>
</a-form>
</a-spin>
</a-drawer>
</template>
<script lang="ts">
export default {
name: 'monitorHostFormDrawer'
};
</script>
<script lang="ts" setup>
import type { MonitorHostQueryResponse, MonitorHostUpdateRequest } from '@/api/monitor/monitor-host';
import { ref } from 'vue';
import useLoading from '@/hooks/loading';
import useVisible from '@/hooks/visible';
import formRules from '../types/form.rules';
import { AlarmSwitch, } from '../types/const';
import { updateMonitorHost } from '@/api/monitor/monitor-host';
import { Message } from '@arco-design/web-vue';
import UserSelector from '@/components/user/user/selector/index.vue';
const emits = defineEmits(['updated']);
const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
const formRef = ref<any>();
const formModel = ref<MonitorHostUpdateRequest>({});
const hostRecord = ref<MonitorHostQueryResponse>({} as MonitorHostQueryResponse);
// 打开修改
const openUpdate = (record: MonitorHostQueryResponse) => {
hostRecord.value = record;
formModel.value = {
id: record.id,
policyId: record.policyId,
alarmSwitch: record.alarmSwitch,
ownerUserId: record.ownerUserId,
cpuName: record.config?.cpuName,
diskName: record.config?.diskName,
networkName: record.config?.networkName,
};
setVisible(true);
};
defineExpose({ openUpdate });
// 确定
const handlerOk = async () => {
setLoading(true);
try {
// 验证参数
const error = await formRef.value.validate();
if (error) {
return false;
}
// 修改
await updateMonitorHost(formModel.value);
Message.success('修改成功');
emits('updated');
// 清空
handlerClear();
} catch (e) {
return false;
} finally {
setLoading(false);
}
};
// 关闭
const handleClose = () => {
handlerClear();
};
// 清空
const handlerClear = () => {
setLoading(false);
};
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,484 @@
<template>
<!-- 搜索 -->
<a-card class="general-card table-search-card">
<query-header :model="formModel"
label-align="left"
@submit="fetchTableData"
@reset="fetchTableData"
@keyup.enter="() => fetchTableData()">
<!-- 主机名称 -->
<a-form-item field="name" label="主机名称">
<a-input v-model="formModel.name" placeholder="请输入主机名称" allow-clear />
</a-form-item>
<!-- 主机地址 -->
<a-form-item field="address" label="主机地址">
<a-input v-model="formModel.address" placeholder="请输入主机地址" allow-clear />
</a-form-item>
<!-- 在线状态 -->
<a-form-item field="agentOnlineStatus" label="在线状态">
<a-select v-model="formModel.agentOnlineStatus"
:options="toOptions(OnlineStatusKey)"
placeholder="请选择在线状态"
allow-clear />
</a-form-item>
<!-- 探针状态 -->
<a-form-item field="agentInstallStatus" label="探针状态">
<a-select v-model="formModel.agentInstallStatus"
:options="toOptions(InstallStatusKey)"
placeholder="请选择探针状态"
allow-clear />
</a-form-item>
<!-- 告警开关 -->
<a-form-item field="alarmSwitch" label="告警开关">
<a-select v-model="formModel.alarmSwitch"
:options="toOptions(AlarmSwitchKey)"
placeholder="请选择告警开关"
allow-clear />
</a-form-item>
<!-- agentKey -->
<a-form-item field="agentKey" label="agentKey">
<a-input v-model="formModel.agentKey"
placeholder="请输入 agentKey"
allow-clear />
</a-form-item>
<!-- 负责人 -->
<a-form-item field="ownerUserId" label="负责人">
<user-selector v-model="formModel.ownerUserId"
placeholder="请选择负责人"
allow-clear />
</a-form-item>
</query-header>
</a-card>
<!-- 表格 -->
<a-card class="general-card table-card">
<template #title>
<!-- 左侧操作 -->
<div class="table-left-bar-handle">
<!-- 标题 -->
<div class="table-title">
监控主机列表
</div>
</div>
<!-- 右侧操作 -->
<div class="table-right-bar-handle">
<a-space>
<!-- 安装 -->
<a-button v-permission="['asset:host:install-agent']"
type="primary"
:disabled="selectedKeys.length === 0"
@click="installAgent(selectedKeys)">
安装
<template #icon>
<icon-plus />
</template>
</a-button>
<!-- 自动刷新 -->
<a-tooltip content="开启后每 60s 会自动刷新" mini>
<a-button @click="toggleAutoRefresh">
{{ autoRefresh ? '关闭自动刷新' : '开启自动刷新' }}
<template #icon>
<icon-refresh />
</template>
</a-button>
</a-tooltip>
<!-- 上传发布包 -->
<a-button @click="emits('openUpload')">
上传发布包
<template #icon>
<icon-upload />
</template>
</a-button>
<!-- 调整 -->
<table-adjust :columns="columns"
:columns-hook="columnsHook"
@query="fetchTableData" />
</a-space>
</div>
</template>
<!-- table -->
<a-table v-model:selected-keys="selectedKeys"
row-key="hostId"
ref="tableRef"
:loading="loading"
:columns="tableColumns"
:data="tableRenderData"
:pagination="pagination"
:row-class="setRowClassName"
:row-selection="rowSelection"
:bordered="false"
@page-change="(page: number) => fetchTableData(page, pagination.pageSize)"
@page-size-change="(size: number) => fetchTableData(1, size)">
<!-- 主机信息 -->
<template #hostInfo="{ record }">
<div class="info-wrapper">
<div class="info-item">
<span class="info-label">主机名称</span>
<span class="info-value text-copy text-ellipsis"
:title="record.name"
@click="copy(record.name, true)">
{{ record.name }}
</span>
</div>
<div class="info-item">
<span class="info-label">主机地址</span>
<span class="info-value span-blue text-copy text-ellipsis"
:title="record.address"
@click="copy(record.address, true)">
{{ record.address }}
</span>
</div>
</div>
</template>
<!-- 在线状态 -->
<template #agentOnlineStatus="{ record }">
<monitor-cell :data-cell="false" :record="record">
<a-tooltip :content="'切换分区时间: ' + dateFormat(new Date(record.lastChangeOnlineTime))" mini>
<a-tag :color="getDictValue(OnlineStatusKey, record.agentOnlineStatus, 'color')">
<template #icon>
<component :is="getDictValue(OnlineStatusKey, record.agentOnlineStatus, 'icon')" />
</template>
{{ getDictValue(OnlineStatusKey, record.agentOnlineStatus) }}
</a-tag>
</a-tooltip>
</monitor-cell>
</template>
<!-- cpu -->
<template #cpuUsage="{ record }">
<monitor-cell :data-cell="true" :record="record">
<a-tooltip :content="'CPU' + record.config?.cpuName +': ' + record.metricsData?.cpuUsagePercent?.toFixed(2) + '%'" mini>
<a-progress size="large"
width="120px"
:animation="true"
:show-text="false"
:color="getPercentProgressColor(record.metricsData.cpuUsagePercent / 100)"
:percent="record.metricsData.cpuUsagePercent / 100" />
</a-tooltip>
<span class="metrics-value-per">{{ record.metricsData.cpuUsagePercent.toFixed(2) }}</span>
</monitor-cell>
</template>
<!-- 内存 -->
<template #memoryUsage="{ record }">
<monitor-cell :data-cell="true" :record="record">
<a-tooltip :content="getFileSize(record.metricsData?.memoryUsageBytes)" mini>
<a-progress size="large"
width="120px"
:animation="true"
:show-text="false"
:color="getPercentProgressColor(record.metricsData.memoryUsagePercent / 100)"
:percent="record.metricsData.memoryUsagePercent / 100" />
</a-tooltip>
<span class="metrics-value-per">{{ record.metricsData.memoryUsagePercent.toFixed(2) }}</span>
</monitor-cell>
</template>
<!-- 磁盘 -->
<template #diskUsage="{ record }">
<monitor-cell :data-cell="true" :record="record">
<a-tooltip :content="record.config?.diskName +': ' + getFileSize(record.metricsData?.diskUsageBytes)" mini>
<a-progress size="large"
width="120px"
:animation="true"
:show-text="false"
:color="getPercentProgressColor(record.metricsData.diskUsagePercent / 100)"
:percent="record.metricsData.diskUsagePercent / 100" />
</a-tooltip>
<span class="metrics-value-per">{{ record.metricsData.diskUsagePercent.toFixed(2) }}</span>
</monitor-cell>
</template>
<!-- 网络 -->
<template #network="{ record }">
<monitor-cell data-class="network"
:data-cell="true"
:record="record">
<!-- 上行速度 -->
<a-tooltip :content="record.config?.networkName +': ' + getFileSize(record.metricsData?.networkSentPreBytes) + '/s'" mini>
<b class="span-green" title="上行速度">
<icon-arrow-up class="mr2" />
{{ getFileSize(record.metricsData.networkSentPreBytes) }}/s
</b>
</a-tooltip>
<!-- 下行速度 -->
<a-tooltip :content="record.config?.networkName +': ' + getFileSize(record.metricsData?.networkRecvPreBytes) + '/s'" mini>
<b class="mt2 span-blue" title="下行速度">
<icon-arrow-down class="mr2" />
{{ getFileSize(record.metricsData.networkRecvPreBytes) }}/s
</b>
</a-tooltip>
</monitor-cell>
</template>
<!-- 负载 -->
<template #load="{ record }">
<monitor-cell :data-cell="true" :record="record">
<b>{{ record.metricsData?.load1?.toFixed(2) }}, {{ record.metricsData?.load5?.toFixed(2) }}, {{ record.metricsData?.load15?.toFixed(2) }}</b>
</monitor-cell>
</template>
<!-- 告警策略 -->
<template #alarmPolicy="{ record }">
<monitor-cell :data-cell="false" :record="record">
{{ getDictValue(AlarmSwitchKey, record.alarmSwitch) }}
</monitor-cell>
</template>
<!-- 告警负责人 -->
<template #ownerUsername="{ record }">
<monitor-cell :data-cell="false" :record="record">
{{ record.ownerUsername }}
</monitor-cell>
</template>
<!-- 探针版本 -->
<template #agentVersion="{ record }">
<!-- 安装状态 -->
<div v-if="record.installLog?.status === AgentLogStatus.WAIT
|| record.installLog?.status === AgentLogStatus.RUNNING
|| record.installLog?.status === AgentLogStatus.FAILED"
class="flex-center">
<!-- 当前状态 -->
<a-tag :color="getDictValue(AgentLogStatusKey, record.installLog.status, 'color')"
:loading="getDictValue(AgentLogStatusKey, record.installLog.status, 'loading')">
{{ getDictValue(AgentLogStatusKey, record.installLog.status, 'installLabel') }}
</a-tag>
<!-- 提示信息 -->
<a-tooltip v-if="record.installLog.message"
:content="record.installLog.message"
mini>
<icon-question-circle class="fs16 span-red ml4" />
</a-tooltip>
</div>
<!-- 已安装显示版本号 -->
<b v-else-if="record.agentInstallStatus === AgentInstallStatus.INSTALLED"
:class="record.latestVersion && record.latestVersion !== record.agentVersion ? 'span-red' : ''">
{{ record.agentVersion ? 'v' + record.agentVersion : '-' }}
<a-tooltip v-if="record.latestVersion && record.latestVersion !== record.agentVersion"
:content="'存在新版本 v' + record.latestVersion + ', 请及时升级'"
mini>
<icon-arrow-rise />
</a-tooltip>
</b>
<!-- 显示未安装 -->
<span v-else>
<a-tag>{{ getDictValue(InstallStatusKey, record.agentInstallStatus) }}</a-tag>
</span>
</template>
<!-- 标签 -->
<template #tags="{ record }">
<a-space v-if="record.tags?.length"
style="margin-bottom: -8px;"
:wrap="true">
<template v-for="tag in record.tags"
:key="tag.id">
<a-tag :color="dataColor(tag.name, tagColor)">
{{ tag.name }}
</a-tag>
</template>
</a-space>
</template>
<!-- agentKey -->
<template #agentKey="{ record }">
<span class="text-copy text-ellipsis"
:title="record.agentKey"
@click="copy(record.agentKey, true)">
{{ record.agentKey }}
</span>
</template>
<!-- 操作 -->
<template #handle="{ record }">
<div class="table-handle-wrapper">
<a-button v-permission="['monitor:monitor-host:query']"
type="text"
size="mini"
:disabled="record.agentInstallStatus !== AgentInstallStatus.INSTALLED"
@click="openDetail(record.hostId, record.name)">
详情
</a-button>
<a-dropdown trigger="hover" :popup-max-height="false">
<a-button type="text" size="mini">
操作
</a-button>
<template #content>
<!-- 修改 -->
<a-doption v-if="record.agentInstallStatus === AgentInstallStatus.INSTALLED"
v-permission="['monitor:monitor-host:update']"
type="text"
size="mini"
@click="emits('openUpdate', record)">
<span class="more-doption normal">修改配置</span>
</a-doption>
<!-- 安装探针 -->
<a-doption v-permission="['asset:host:install-agent']"
:disabled="record.installLog?.status === AgentLogStatus.WAIT || record.installLog?.status === AgentLogStatus.RUNNING"
type="text"
size="mini"
@click="installAgent([record.hostId])">
<span class="more-doption normal">安装探针</span>
</a-doption>
<!-- 安装成功 -->
<a-doption v-if="record.installLog?.id && record.installLog?.status !== AgentLogStatus.SUCCESS"
v-permission="['asset:host:install-agent']"
type="text"
size="mini"
@click="setInstallSuccess(record.installLog)">
<span class="more-doption normal">安装成功</span>
</a-doption>
<!-- 报警开关 -->
<a-doption v-if="record.id"
v-permission="['monitor:monitor-host:update', 'monitor:monitor-host:update-switch']"
type="text"
size="mini"
@click="toggleAlarmSwitch(record)">
<span class="more-doption normal">
{{ toggleDictValue(AlarmSwitchKey, record.alarmSwitch, 'label') + '报警' }}
</span>
</a-doption>
</template>
</a-dropdown>
</div>
</template>
</a-table>
</a-card>
</template>
<script lang="ts">
export default {
name: 'monitorHostTable'
};
</script>
<script lang="ts" setup>
import type { MonitorHostQueryRequest, MonitorHostQueryResponse } from '@/api/monitor/monitor-host';
import { reactive, ref, onMounted } from 'vue';
import { getMonitorHostPage } from '@/api/monitor/monitor-host';
import useLoading from '@/hooks/loading';
import columns from '../types/table.columns';
import { TableName, AlarmSwitchKey, OnlineStatusKey, InstallStatusKey, AgentLogStatus, AgentLogStatusKey } from '../types/const';
import { AgentInstallStatus, tagColor } from '@/views/asset/host-list/types/const';
import { useTablePagination, useTableColumns, useRowSelection } from '@/hooks/table';
import { useDictStore } from '@/store';
import { copy } from '@/hooks/copy';
import { getPercentProgressColor } from '@/utils/charts';
import { getFileSize } from '@/utils/file';
import { dateFormat, dataColor } from '@/utils';
import useMonitorHostList from '../types/use-monitor-host-list';
import MonitorCell from './monitor-cell.vue';
import TableAdjust from '@/components/app/table-adjust/index.vue';
import UserSelector from '@/components/user/user/selector/index.vue';
const emits = defineEmits(['openUpdate', 'openUpload']);
const rowSelection = useRowSelection();
const pagination = useTablePagination();
const { loading, setLoading } = useLoading();
const { tableColumns, columnsHook } = useTableColumns(TableName, columns);
const { toOptions, getDictValue, toggleDictValue } = useDictStore();
const selectedKeys = ref<Array<number>>([]);
const tableRenderData = ref<Array<MonitorHostQueryResponse>>([]);
const formModel = reactive<MonitorHostQueryRequest>({
alarmSwitch: undefined,
ownerUserId: undefined,
policyId: undefined,
name: undefined,
code: undefined,
address: undefined,
agentInstallStatus: undefined,
agentOnlineStatus: undefined,
description: undefined,
tags: undefined,
});
// 重新加载
const reload = () => {
// 重新加载数据
fetchTableData();
};
defineExpose({ reload });
const {
autoRefresh,
openDetail,
toggleAutoRefresh,
installAgent,
setInstallSuccess,
toggleAlarmSwitch,
} = useMonitorHostList({
hosts: tableRenderData,
setLoading,
reload,
});
// 获取行样式
const setRowClassName = (record: MonitorHostQueryResponse) => {
if (record.agentInstallStatus === AgentInstallStatus.NOT_INSTALL) {
return 'not-install';
}
return '';
};
// 加载数据
const doFetchTableData = async (request: MonitorHostQueryRequest) => {
try {
setLoading(true);
const { data } = await getMonitorHostPage(request);
tableRenderData.value = data.rows;
pagination.total = data.total;
pagination.current = request.page;
pagination.pageSize = request.limit;
selectedKeys.value = [];
} catch (e) {
} finally {
setLoading(false);
}
};
// 切换页码
const fetchTableData = (page = 1, limit = pagination.pageSize, form = formModel) => {
doFetchTableData({ page, limit, ...form });
};
onMounted(fetchTableData);
</script>
<style lang="less" scoped>
:deep(.not-install) {
.arco-table-td {
background-color: rgb(var(--gray-1)) !important;
}
.arco-table-td::before {
background-color: rgb(var(--gray-1)) !important;
}
}
.info-wrapper {
padding: 4px 0;
.info-item {
display: flex;
&:not(:last-child) {
margin-bottom: 4px;
}
.info-label {
width: 60px;
margin-right: 8px;
user-select: none;
font-weight: 600;
&::after {
content: ':';
}
}
.info-value {
width: calc(100% - 68px);
}
}
}
.row-handle-wrapper {
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<a-modal v-model:visible="visible"
top="80px"
width="400px"
title-align="start"
title="探针发布包上传"
ok-text="上传"
:body-style="{ padding: 0 }"
:align-center="false"
:mask-closable="false"
:unmount-on-close="true"
:on-before-ok="handlerOk"
:ok-button-props="{ disabled: !fileList.length }"
@cancel="handleClose">
<a-spin class="upload-container" :loading="loading">
<!-- 选择文件 -->
<a-upload class="file-list-uploader"
v-model:file-list="fileList"
accept=".tar.gz"
:auto-upload="false"
:show-file-list="true"
draggable
@change="onSelectFile" />
</a-spin>
</a-modal>
</template>
<script lang="ts">
export default {
name: 'releaseUploadModal'
};
</script>
<script lang="ts" setup>
import type { FileItem } from '@arco-design/web-vue';
import { ref } from 'vue';
import { Message } from '@arco-design/web-vue';
import { uploadAgentRelease } from '@/api/asset/host-agent';
import useVisible from '@/hooks/visible';
import useLoading from '@/hooks/loading';
const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
const fileList = ref<FileItem[]>([]);
// 打开
const open = () => {
setVisible(true);
};
defineExpose({ open });
// 选择文件回调
const onSelectFile = (files: Array<FileItem>) => {
if (files.length) {
fileList.value = [files[files.length - 1]];
} else {
fileList.value = [];
}
};
// 确定
const handlerOk = async () => {
if (!fileList.value.length) {
Message.error('请选择文件');
return false;
}
setLoading(true);
try {
const { data } = await uploadAgentRelease(fileList.value[0].file as File);
Message.success(`上传成功: 版本 v${data}`);
// 清空
handlerClear();
return true;
} catch (e) {
return false;
} finally {
setLoading(false);
}
};
// 关闭
const handleClose = () => {
handlerClear();
};
// 清空
const handlerClear = () => {
fileList.value = [];
};
</script>
<style lang="less" scoped>
.upload-container {
width: 100%;
padding: 16px;
}
.file-list-uploader {
:deep(.arco-upload-list-item:first-of-type) {
margin-top: 12px !important;
}
:deep(.arco-upload-list-item .arco-upload-progress) {
display: none;
}
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<div class="layout-container" v-if="render">
<!-- 列表-表格 -->
<monitor-host-table v-if="renderTable"
ref="table"
@open-upload="() => uploadModal.open()"
@open-update="(e: any) => drawer.openUpdate(e)" />
<!-- 列表-卡片 -->
<monitor-host-card-list v-else
ref="card"
@open-upload="() => uploadModal.open()"
@open-update="(e: any) => drawer.openUpdate(e)" />
<!-- 添加修改抽屉 -->
<monitor-host-form-drawer ref="drawer"
@added="reload"
@updated="reload" />
<!-- 发布包上传模态框 -->
<release-upload-modal ref="uploadModal" />
</div>
</template>
<script lang="ts">
export default {
name: 'monitorHost'
};
</script>
<script lang="ts" setup>
import { computed, ref, onBeforeMount } from 'vue';
import { useAppStore, useDictStore } from '@/store';
import { dictKeys } from './types/const';
import MonitorHostTable from './components/monitor-host-table.vue';
import MonitorHostCardList from './components/monitor-host-card-list.vue';
import MonitorHostFormDrawer from './components/monitor-host-form-drawer.vue';
import ReleaseUploadModal from './components/release-upload-modal.vue';
const appStore = useAppStore();
const renderTable = computed(() => appStore.monitorHostView === 'table');
const render = ref(false);
const table = ref();
const card = ref();
const drawer = ref();
const uploadModal = ref();
// 重新加载
const reload = () => {
if (renderTable.value) {
table.value.reload();
} else {
card.value.reload();
}
};
onBeforeMount(async () => {
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
render.value = true;
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,81 @@
import type { CardField, CardFieldConfig } from '@/types/card';
const fieldConfig = {
rowGap: '10px',
labelSpan: 6,
minHeight: '22px',
fields: [
{
label: '主机ID',
dataIndex: 'hostId',
slotName: 'hostId',
default: true,
}, {
label: '主机地址',
dataIndex: 'address',
slotName: 'address',
default: true,
}, {
label: '主机编码',
dataIndex: 'code',
slotName: 'code',
default: false,
}, {
label: '负责人',
dataIndex: 'ownerUsername',
slotName: 'ownerUsername',
ellipsis: true,
default: true,
}, {
label: '设备状态',
dataIndex: 'agentOnlineStatus',
slotName: 'agentOnlineStatus',
default: true,
}, {
label: 'CPU',
dataIndex: 'cpuUsage',
slotName: 'cpuUsage',
default: true,
}, {
label: '内存',
dataIndex: 'memoryUsage',
slotName: 'memoryUsage',
default: true,
}, {
label: '磁盘',
dataIndex: 'diskUsage',
slotName: 'diskUsage',
default: true,
}, {
label: '网络',
dataIndex: 'network',
slotName: 'network',
default: true,
}, {
label: '负载',
dataIndex: 'load',
slotName: 'load',
default: true,
}, {
label: '标签',
dataIndex: 'tags',
slotName: 'tags',
default: false,
}, {
label: 'agentKey',
dataIndex: 'agentKey',
slotName: 'agentKey',
ellipsis: true,
default: false,
}, {
label: '探针版本',
dataIndex: 'agentVersion',
slotName: 'agentVersion',
ellipsis: true,
tooltip: true,
default: true,
}
] as CardField[]
} as CardFieldConfig;
export default fieldConfig;

View File

@@ -0,0 +1,32 @@
export const TableName = 'monitor_host';
// 监控告警开关
export const AlarmSwitch = {
OFF: 0,
ON: 1,
};
// 探针日志状态
export const AgentLogStatus = {
WAIT: 'WAIT',
RUNNING: 'RUNNING',
SUCCESS: 'SUCCESS',
FAILED: 'FAILED',
};
export const NODATA_TIPS = '暂无数据';
// 探针安装状态 字典项
export const InstallStatusKey = 'agentInstallStatus';
// 探针在线状态 字典项
export const OnlineStatusKey = 'agentOnlineStatus';
// 监控告警开关 字典项
export const AlarmSwitchKey = 'monitorAlarmSwitch';
// 探针日志状态 字典项
export const AgentLogStatusKey = 'agentLogStatus';
// 加载的字典值
export const dictKeys = [InstallStatusKey, AlarmSwitchKey, OnlineStatusKey, AgentLogStatusKey];

View File

@@ -0,0 +1,16 @@
import type { FieldRule } from '@arco-design/web-vue';
export const policyId = [{
required: false,
message: '请输入策略id'
}] as FieldRule[];
export const alarmSwitch = [{
required: true,
message: '请输入告警开关'
}] as FieldRule[];
export default {
policyId,
alarmSwitch,
} as Record<string, FieldRule | FieldRule[]>;

View File

@@ -0,0 +1,112 @@
import type { TableColumnData } from '@arco-design/web-vue';
const columns = [
{
title: '主机ID',
dataIndex: 'hostId',
slotName: 'hostId',
width: 80,
align: 'left',
fixed: 'left',
default: true,
}, {
title: '主机信息',
dataIndex: 'hostInfo',
slotName: 'hostInfo',
width: 248,
align: 'left',
fixed: 'left',
default: true,
}, {
title: '设备状态',
dataIndex: 'agentOnlineStatus',
slotName: 'agentOnlineStatus',
align: 'center',
width: 120,
default: true,
}, {
title: 'CPU',
dataIndex: 'cpuUsage',
slotName: 'cpuUsage',
align: 'left',
width: 198,
default: true,
}, {
title: '内存',
dataIndex: 'memoryUsage',
slotName: 'memoryUsage',
align: 'left',
width: 198,
default: true,
}, {
title: '磁盘',
dataIndex: 'diskUsage',
slotName: 'diskUsage',
align: 'left',
width: 198,
default: true,
}, {
title: '网络',
dataIndex: 'network',
slotName: 'network',
align: 'left',
width: 148,
default: true,
}, {
title: '负载',
dataIndex: 'load',
slotName: 'load',
align: 'left',
width: 148,
default: true,
}, {
title: '标签',
dataIndex: 'tags',
slotName: 'tags',
align: 'left',
minWidth: 148,
default: false,
}, {
title: 'agentKey',
dataIndex: 'agentKey',
slotName: 'agentKey',
align: 'left',
width: 288,
default: false,
}, {
// TODO
// title: '告警策略',
// dataIndex: 'alarmPolicy',
// slotName: 'alarmPolicy',
// align: 'left',
// width: 120,
// default: true,
// }, {
title: '负责人',
dataIndex: 'ownerUsername',
slotName: 'ownerUsername',
align: 'left',
width: 108,
ellipsis: true,
tooltip: true,
default: true,
}, {
title: '探针版本',
dataIndex: 'agentVersion',
slotName: 'agentVersion',
align: 'left',
width: 118,
ellipsis: true,
tooltip: true,
default: true,
}, {
title: '操作',
slotName: 'handle',
width: 128,
align: 'center',
fixed: 'right',
default: true,
},
] as TableColumnData[];
export default columns;

View File

@@ -0,0 +1,273 @@
import type { MonitorHostQueryResponse, } from '@/api/monitor/monitor-host';
import { getMonitorHostMetrics, updateMonitorHostAlarmSwitch } from '@/api/monitor/monitor-host';
import type { HostAgentLogResponse } from '@/api/asset/host-agent';
import { getAgentInstallLogStatus, getHostAgentStatus, installHostAgent, updateAgentInstallStatus } from '@/api/asset/host-agent';
import type { Ref } from 'vue';
import { onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useDictStore } from '@/store';
import { Message, Modal } from '@arco-design/web-vue';
import { AgentInstallStatus, HostOsType } from '@/views/asset/host-list/types/const';
import { AgentLogStatus, AlarmSwitchKey } from '@/views/monitor/monitor-host/types/const';
// 监控主机列表配置
export interface UseMonitorHostListOptions {
// 主机信息
hosts: Ref<Array<MonitorHostQueryResponse>>;
// 设置加载中
setLoading: (loading: boolean) => void;
// 重新加载
reload: () => void;
}
// 使用监控主机列表
export default function useMonitorHostList(options: UseMonitorHostListOptions) {
const autoRefresh = ref(true);
const autoRefreshId = ref();
const fetchInstallStatusId = ref();
const lastRefreshTime = ref(0);
const router = useRouter();
const { toggleDict } = useDictStore();
const { hosts, setLoading, reload } = options;
// 打开详情
const openDetail = (hostId: number, name: string) => {
router.push({ name: 'monitorDetail', query: { hostId, name } });
};
// 安装探针
const installAgent = async (hostIdList: Array<number>) => {
try {
setLoading(true);
// 获取全部数据
const installHosts = hosts.value.filter(s => hostIdList.includes(s.hostId));
let hasWindows = false;
// 安装前检查
for (let host of installHosts) {
// 检查状态
if (host?.installLog?.status === AgentLogStatus.WAIT || host?.installLog?.status === AgentLogStatus.RUNNING) {
Message.error('主机' + host.name + '正在安装中, 请勿重复操作');
return;
}
// 检查系统类型
if (host.osType === HostOsType.WINDOWS.value) {
hasWindows = true;
}
}
// 二次确认
Modal.confirm({
title: '安装提示',
titleAlign: 'start',
bodyStyle: { 'white-space': 'pre-wrap' },
content: `请确保探针已关闭\n请确认文件夹是否有权限${hasWindows ? '\nWindows 系统仅支持探针上传, 请手动进行安装' : ''}`,
okText: '确定',
onOk: async () => {
try {
// 调用安装
await installHostAgent({ idList: hostIdList });
Message.success('开始安装');
// 重新加载
reload();
} catch (e) {
} finally {
setLoading(false);
}
}
});
} catch (e) {
} finally {
setLoading(false);
}
};
// 手动安装成功
const setInstallSuccess = (log: HostAgentLogResponse) => {
Modal.confirm({
title: '修正状态',
titleAlign: 'start',
content: `确定要手动将安装记录修正为完成吗?`,
okText: '确定',
onOk: async () => {
try {
setLoading(true);
// 调用修改接口
await updateAgentInstallStatus({
id: log.id,
status: AgentLogStatus.SUCCESS,
message: '手动修正',
});
log.status = AgentLogStatus.SUCCESS;
Message.success('状态已修正');
} catch (e) {
} finally {
setLoading(false);
}
}
});
};
// 更新报警开关
const toggleAlarmSwitch = async (record: MonitorHostQueryResponse) => {
const dict = toggleDict(AlarmSwitchKey, record.alarmSwitch);
Modal.confirm({
title: `${dict.label}确认`,
titleAlign: 'start',
content: `确定要${dict.label}报警功能吗?`,
okText: '确定',
onOk: async () => {
try {
setLoading(true);
const newSwitch = dict.value as number;
// 调用修改接口
await updateMonitorHostAlarmSwitch({
id: record.id,
alarmSwitch: newSwitch,
});
record.alarmSwitch = newSwitch;
Message.success(`${dict.label}`);
} catch (e) {
} finally {
setLoading(false);
}
}
});
};
// 获取探针安装状态
const pullInstallLogStatus = async () => {
// 获取安装中的记录
const runningIdList = hosts.value.filter(s => s.installLog?.status === AgentLogStatus.WAIT || s.installLog?.status === AgentLogStatus.RUNNING)
.map(s => s.installLog?.id)
.filter(Boolean);
if (!runningIdList.length) {
return;
}
// 查询状态
const { data } = await getAgentInstallLogStatus(runningIdList);
data.forEach(item => {
hosts.value.filter(s => s.installLog?.id === item.id).forEach(s => {
s.installLog.status = item.status;
s.installLog.message = item.message;
// 若安装成功则修改探针信息
if (item.status === AgentLogStatus.SUCCESS && item.agentStatus) {
s.agentVersion = item.agentStatus.agentVersion;
s.latestVersion = item.agentStatus.latestVersion;
s.agentInstallStatus = item.agentStatus.agentInstallStatus;
s.agentOnlineStatus = item.agentStatus.agentOnlineStatus;
}
});
});
};
// 获取探针状态
const getAgentStatus = async () => {
// 获取全部 hostId
const hostIds = hosts.value.map(s => s.hostId);
if (hostIds.length) {
try {
// 查询状态
const { data } = await getHostAgentStatus(hostIds);
data.forEach(item => {
hosts.value.filter(s => s.hostId === item.id).forEach(s => {
s.agentVersion = item.agentVersion;
s.latestVersion = item.latestVersion;
s.agentInstallStatus = item.agentInstallStatus;
s.agentOnlineStatus = item.agentOnlineStatus;
});
});
} catch (e) {
}
}
};
// 获取指标信息
const getHostMetrics = async () => {
// 获取全部已安装 agentKey
const agentKeys = hosts.value
.filter(s => s.agentInstallStatus === AgentInstallStatus.INSTALLED)
.map(s => s.agentKey)
.filter(Boolean);
if (agentKeys.length) {
try {
// 查询指标
const { data } = await getMonitorHostMetrics(agentKeys);
data.forEach(item => {
hosts.value.filter(s => s.agentKey === item.agentKey).forEach(s => {
s.metricsData = item;
});
});
} catch (e) {
}
}
};
// 刷新指标
const refreshMetrics = async () => {
// 加载状态信息
await getAgentStatus();
// 加载指标数据
await getHostMetrics();
// 设置刷新时间
lastRefreshTime.value = Date.now();
};
// 切换自动刷新
const toggleAutoRefresh = async () => {
autoRefresh.value = !autoRefresh.value;
if (autoRefresh.value) {
// 开启自动刷新
await openAutoRefresh();
} else {
// 关闭自动刷新
closeAutoRefresh();
}
};
// 开启自动刷新
const openAutoRefresh = async () => {
window.clearInterval(autoRefreshId.value);
if (!autoRefresh.value) {
return;
}
if (lastRefreshTime.value === 0) {
// 防止首次就刷新
lastRefreshTime.value = 1;
} else if (Date.now() - lastRefreshTime.value > 60000) {
// 超过刷新的时间
await refreshMetrics();
}
// 设置自动刷新
autoRefreshId.value = window.setInterval(refreshMetrics, 60000);
};
// 关闭自动刷新
const closeAutoRefresh = () => {
window.clearInterval(autoRefreshId.value);
autoRefreshId.value = undefined;
};
onMounted(openAutoRefresh);
onActivated(openAutoRefresh);
onDeactivated(closeAutoRefresh);
onUnmounted(closeAutoRefresh);
onMounted(() => {
window.clearInterval(fetchInstallStatusId.value);
fetchInstallStatusId.value = window.setInterval(pullInstallLogStatus, 5000);
});
onUnmounted(() => {
window.clearInterval(fetchInstallStatusId.value);
});
return {
autoRefresh,
openDetail,
installAgent,
setInstallSuccess,
toggleAlarmSwitch,
toggleAutoRefresh,
};
};

View File

@@ -206,7 +206,11 @@
// 选择文件回调
const onSelectFile = (files: Array<FileItem>) => {
fileList.value = [files[files.length - 1]];
if (files.length) {
fileList.value = [files[files.length - 1]];
} else {
fileList.value = [];
}
};
// 上传文件