|
|
@@ -1,73 +1,413 @@
|
|
|
<template>
|
|
|
- <div class="report-container">
|
|
|
- <el-card class="header-card">
|
|
|
- <h1>📊 容器资源同比巡检报告</h1>
|
|
|
- <p>实线: 今日 | 虚线: 昨日</p>
|
|
|
- </el-card>
|
|
|
-
|
|
|
- <div v-for="item in instances" :key="item.name" class="node-card">
|
|
|
- <el-card shadow="hover">
|
|
|
- <div slot="header" class="node-title">
|
|
|
- <span>节点实例: {{ item.name }}</span>
|
|
|
+ <div class="monitor-report-container" v-loading="loading">
|
|
|
+ <div class="status-banner" :class="'banner-' + overallStatus">
|
|
|
+ <div class="banner-left">
|
|
|
+ <div class="status-visual">
|
|
|
+ <div class="status-icon-wrapper">
|
|
|
+ <i :class="overallStatus === 'success' ? 'el-icon-circle-check' : 'el-icon-warning-outline'"></i>
|
|
|
+ </div>
|
|
|
+ <div class="pulse-ring"></div>
|
|
|
</div>
|
|
|
+ <div class="banner-content">
|
|
|
+ <div class="banner-title">
|
|
|
+ {{ reportData.statusSummary || '核心基础设施巡检' }}
|
|
|
+ <el-tag size="mini" :type="overallStatus === 'success' ? 'success' : 'warning'" effect="plain">
|
|
|
+ {{ overallStatus === 'success' ? 'HEALTHY' : 'ACTION REQUIRED' }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="banner-desc">
|
|
|
+ <span>{{ overallDescription }}</span>
|
|
|
+ <span class="update-time"><i class="el-icon-time"></i> 最后更新: {{ reportData.lastUpdateTime || '-' }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="banner-stats">
|
|
|
+ <div class="stat-item">
|
|
|
+ <div class="stat-label">巡检范围</div>
|
|
|
+ <div class="stat-value">{{ reportData.totalNodes || 0 }} <small>Nodes</small></div>
|
|
|
+ </div>
|
|
|
+ <div class="stat-divider"></div>
|
|
|
+ <div class="stat-item">
|
|
|
+ <div class="stat-label">检测耗时</div>
|
|
|
+ <div class="stat-value">{{ reportData.checkDuration || '0ms' }}</div>
|
|
|
+ </div>
|
|
|
+ <div class="stat-divider" v-if="hasAnyIssue"></div>
|
|
|
+ <div class="stat-item" v-if="hasAnyIssue">
|
|
|
+ <div class="stat-label">风险指数</div>
|
|
|
+ <div class="stat-value risk-text">{{ reportData.riskLevel || 'High' }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="banner-right">
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ size="medium"
|
|
|
+ icon="el-icon-refresh"
|
|
|
+ @click="loadReport"
|
|
|
+ :loading="loading"
|
|
|
+ class="refresh-glow"
|
|
|
+ >
|
|
|
+ 重新巡检
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="report-grid">
|
|
|
+ <el-row :gutter="20" class="force-row">
|
|
|
|
|
|
- <el-row :gutter="20">
|
|
|
- <el-col :span="12">
|
|
|
- <chart-item
|
|
|
- title="CPU 同比 (%)"
|
|
|
- :time-labels="timeLabels"
|
|
|
- :data-group="item.cpu"
|
|
|
- unit="%"
|
|
|
- />
|
|
|
- </el-col>
|
|
|
- <el-col :span="12">
|
|
|
- <chart-item
|
|
|
- title="内存同比 (MB)"
|
|
|
- :time-labels="timeLabels"
|
|
|
- :data-group="item.mem"
|
|
|
- unit="MB"
|
|
|
- />
|
|
|
- </el-col>
|
|
|
- </el-row>
|
|
|
- </el-card>
|
|
|
+ <el-col :span="12" v-for="config in cardConfigs" :key="config.key" class="mb-20">
|
|
|
+ <el-card shadow="hover" class="nice-card" :class="getShadowClass(config)">
|
|
|
+ <div slot="header" class="card-header">
|
|
|
+ <div class="title-wrapper">
|
|
|
+ <i :class="[config.icon, 'icon-accent', config.textColor]"></i>
|
|
|
+ <span class="title-text">{{ config.title }} <small>{{ config.subTitle }}</small></span>
|
|
|
+ </div>
|
|
|
+ <div class="header-ops">
|
|
|
+ <el-tooltip content="排查建议" placement="top">
|
|
|
+ <el-button
|
|
|
+ type="text"
|
|
|
+ icon="el-icon-question"
|
|
|
+ class="advice-btn"
|
|
|
+ @click="showAdvice(config)"
|
|
|
+ ></el-button>
|
|
|
+ </el-tooltip>
|
|
|
+ <el-tag v-if="getData(config.key).length > 0" :type="config.tagType" size="mini" effect="dark" class="status-tag">
|
|
|
+ {{ getData(config.key).length }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="card-body scroll-style">
|
|
|
+ <template v-if="getData(config.key).length > 0">
|
|
|
+ <div v-for="(item, index) in getData(config.key)" :key="index" class="progress-item">
|
|
|
+ <div class="info">
|
|
|
+ <span class="name text-ellipsis">{{ item.instance || item.name }}</span>
|
|
|
+ <span class="val" :class="config.textColor">{{ item.value }}{{ config.unit }}</span>
|
|
|
+ </div>
|
|
|
+ <el-progress
|
|
|
+ :percentage="config.isRatio ? (item.value > 100 ? 100 : item.value) : 100"
|
|
|
+ :show-text="false"
|
|
|
+ :color="config.progColor"
|
|
|
+ :stroke-width="6"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <div v-else class="status-placeholder">
|
|
|
+ <i class="el-icon-circle-check"></i><p>{{ config.emptyText }}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
</div>
|
|
|
+
|
|
|
+ <el-dialog
|
|
|
+ :title="'💡 排查建议: ' + currentAdvice.title"
|
|
|
+ :visible.sync="dialogVisible"
|
|
|
+ width="500px"
|
|
|
+ custom-class="advice-dialog"
|
|
|
+ append-to-body
|
|
|
+ >
|
|
|
+ <div class="advice-content">
|
|
|
+ <div v-if="currentAdvice.text" class="markdown-body">
|
|
|
+ <i class="el-icon-guideAdvice"></i>
|
|
|
+ <p v-html="formatAdvice(currentAdvice.text)"></p>
|
|
|
+ </div>
|
|
|
+ <el-empty v-else description="暂无排查建议" :image-size="60"></el-empty>
|
|
|
+ </div>
|
|
|
+ <span slot="footer" class="dialog-footer">
|
|
|
+ <el-button type="primary" size="small" @click="dialogVisible = false">知道了</el-button>
|
|
|
+ </span>
|
|
|
+ </el-dialog>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
-import ChartItem from '@/components/ChartItem.vue'
|
|
|
import { getDashboard } from '@/api/devops'
|
|
|
|
|
|
export default {
|
|
|
- components: { ChartItem },
|
|
|
data() {
|
|
|
return {
|
|
|
- timeLabels: [], // 后端返回
|
|
|
- instances: [] // 后端返回
|
|
|
+ loading: false,
|
|
|
+ dialogVisible: false,
|
|
|
+ currentAdvice: { title: '', text: '' },
|
|
|
+ reportData: {
|
|
|
+ cpuThrottled: [], advice_cpuThrottled: '',
|
|
|
+ memRisk: [], advice_memRisk: '',
|
|
|
+ diskIo: [], advice_diskIo: '',
|
|
|
+ diskUsageRisk: [], advice_diskUsageRisk: '',
|
|
|
+ inodeRisk: [], advice_inodeRisk: '',
|
|
|
+ fdRisk: [], advice_fdRisk: '',
|
|
|
+ netEstMax: [], advice_netEstMax: '',
|
|
|
+ netTwMax: [], advice_netTwMax: '',
|
|
|
+ netOverflows: [], advice_netOverflows: '',
|
|
|
+ netDrops: [], advice_netDrops: '',
|
|
|
+ advices: {}
|
|
|
+ },
|
|
|
+ // 使用配置化方式渲染卡片,减少 HTML 冗余
|
|
|
+ cardConfigs: [
|
|
|
+ { key: 'cpuThrottled', title: 'CPU 性能节流', subTitle: '24H Throttled', icon: 'el-icon-cpu', textColor: 'warning-text', tagType: 'warning', progColor: '#e6a23c', unit: 's', isRatio: false, emptyText: '无节流限制' },
|
|
|
+ { key: 'memRisk', title: '内存水位线', subTitle: '> 85% Usage', icon: 'el-icon-box', textColor: 'danger-text', tagType: 'danger', progColor: '#f56c6c', unit: '%', isRatio: true, emptyText: '水位正常' },
|
|
|
+ { key: 'diskIo', title: '磁盘 I/O 响应', subTitle: 'Wait > 50ms', icon: 'el-icon-receiving', textColor: 'warning-text', tagType: 'warning', progColor: '#e6a23c', unit: 'ms', isRatio: false, emptyText: 'IO 响应极快' },
|
|
|
+ { key: 'diskUsageRisk', title: '根分区空间', subTitle: '( / ) > 85%', icon: 'el-icon-pie-chart', textColor: 'danger-text', tagType: 'danger', progColor: '#f56c6c', unit: '%', isRatio: true, emptyText: '磁盘空间充沛' },
|
|
|
+ { key: 'inodeRisk', title: 'inode 使用率', subTitle: '> 80% Usage', icon: 'el-icon-files', textColor: 'warning-text', tagType: 'warning', progColor: '#e6a23c', unit: '%', isRatio: true, emptyText: '索引节点充足' },
|
|
|
+ { key: 'fdRisk', title: '文件句柄 (FD)', subTitle: 'Usage Ratio', icon: 'el-icon-link', textColor: 'primary-text', tagType: 'primary', progColor: '#409eff', unit: '%', isRatio: true, emptyText: 'FD 占用正常' },
|
|
|
+ { key: 'netEstMax', title: 'TCP EST 状态数量', subTitle: 'TCP EST', icon: 'el-icon-warning-outline', textColor: 'danger-text', tagType: 'danger', progColor: '#f56c6c', unit: '次', isRatio: false, emptyText: '未超过阈值' },
|
|
|
+ { key: 'netTwMax', title: 'TCP TIME_WAIT 状态', subTitle: 'TCP TIME_WAIT', icon: 'el-icon-warning-outline', textColor: 'danger-text', tagType: 'danger', progColor: '#f56c6c', unit: '次', isRatio: false, emptyText: '未超过阈值' },
|
|
|
+ { key: 'netOverflows', title: 'TCP 全连接队列溢出', subTitle: 'Listen Overflow', icon: 'el-icon-warning-outline', textColor: 'danger-text', tagType: 'danger', progColor: '#f56c6c', unit: '次', isRatio: false, emptyText: '无队列溢出' },
|
|
|
+ { key: 'netDrops', title: 'TCP 数据包丢弃', subTitle: 'TCP Drops', icon: 'el-icon-circle-close', textColor: 'danger-text', tagType: 'danger', progColor: '#f56c6c', unit: '次', isRatio: false, emptyText: '无 TCP 丢弃' }
|
|
|
+ ]
|
|
|
}
|
|
|
},
|
|
|
- mounted() {
|
|
|
- this.fetchData()
|
|
|
+ computed: {
|
|
|
+ hasAnyIssue() {
|
|
|
+ return this.cardConfigs.some(c => this.reportData[c.key]?.length > 0)
|
|
|
+ },
|
|
|
+ overallStatus() { return this.hasAnyIssue ? 'warning' : 'success' },
|
|
|
+ overallDescription() {
|
|
|
+ return this.hasAnyIssue ? '系统检测到部分节点异常,请点击卡片问号查看排查建议。' : '所有基础设施运行参数健康。'
|
|
|
+ }
|
|
|
},
|
|
|
+ created() { this.loadReport() },
|
|
|
methods: {
|
|
|
- fetchData() {
|
|
|
- getDashboard().then(resp => {
|
|
|
+ getData(key) { return this.reportData[key] || [] },
|
|
|
+ getShadowClass(config) {
|
|
|
+ const hasData = this.getData(config.key).length > 0
|
|
|
+ if (!hasData) return ''
|
|
|
+ return config.tagType + '-shadow'
|
|
|
+ },
|
|
|
+ async loadReport() {
|
|
|
+ this.loading = true
|
|
|
+ try {
|
|
|
+ const resp = await getDashboard()
|
|
|
if (resp.code === 0) {
|
|
|
- this.timeLabels = resp.data.timeLabels
|
|
|
- this.instances = resp.data.instances
|
|
|
- } else {
|
|
|
- this.$message.error(resp.msg)
|
|
|
+ this.reportData = Object.assign({}, this.reportData, resp.data)
|
|
|
}
|
|
|
- }).catch(error => {
|
|
|
- this.$message.error(error.message)
|
|
|
- })
|
|
|
+ } finally { this.loading = false }
|
|
|
+ },
|
|
|
+ showAdvice(config) {
|
|
|
+ this.currentAdvice = {
|
|
|
+ title: config.title,
|
|
|
+ text: this.reportData.advices[config.key] || '暂无详细排查指引,请联系运维团队。'
|
|
|
+ }
|
|
|
+ this.dialogVisible = true
|
|
|
+ },
|
|
|
+ formatAdvice(text) {
|
|
|
+ // 简单的换行转义,如果后端返回的是带换行的文本
|
|
|
+ return text.replace(/\n/g, '<br>')
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
</script>
|
|
|
|
|
|
<style scoped>
|
|
|
-.report-container { padding: 20px; background: #f4f7f9; }
|
|
|
-.node-card { margin-bottom: 25px; }
|
|
|
-.node-title { font-weight: bold; color: #1890ff; }
|
|
|
+/* 保持原样式并添加以下内容 */
|
|
|
+
|
|
|
+.header-ops {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.advice-btn {
|
|
|
+ font-size: 18px;
|
|
|
+ color: #94a3b8;
|
|
|
+ margin-right: 8px;
|
|
|
+ padding: 0;
|
|
|
+ transition: color 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.advice-btn:hover {
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+/* 弹窗样式美化 */
|
|
|
+.advice-dialog >>> .el-dialog__header {
|
|
|
+ border-bottom: 1px solid #f1f5f9;
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.advice-dialog >>> .el-dialog__title {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #1e293b;
|
|
|
+}
|
|
|
+
|
|
|
+.advice-content {
|
|
|
+ padding: 10px 5px;
|
|
|
+ line-height: 1.6;
|
|
|
+ color: #475569;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.markdown-body {
|
|
|
+ background: #f8fafc;
|
|
|
+ padding: 15px;
|
|
|
+ border-radius: 8px;
|
|
|
+ border-left: 4px solid #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+/* 之前已有的样式保持不变 */
|
|
|
+.monitor-report-container { padding: 24px; background-color: #f6f8fb; min-height: 100vh; }
|
|
|
+.force-row { display: flex !important; flex-wrap: wrap !important; }
|
|
|
+.nice-card { height: 320px; border-radius: 12px; display: flex; flex-direction: column; margin-bottom: 20px; overflow: hidden; }
|
|
|
+.nice-card >>> .el-card__header { padding: 15px 20px; background-color: #fafbfd; border-bottom: 1px solid #f1f5f9; flex-shrink: 0; }
|
|
|
+.nice-card >>> .el-card__body { padding: 0; flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
|
+.card-header { display: flex; justify-content: space-between; align-items: center; }
|
|
|
+.title-wrapper { display: flex; align-items: center; flex: 1; }
|
|
|
+.status-tag { border-radius: 10px; padding: 0 8px; font-weight: bold; }
|
|
|
+.icon-accent { font-size: 18px; margin-right: 10px; }
|
|
|
+.title-text { font-size: 14px; font-weight: 600; color: #334155; }
|
|
|
+.title-text small { font-weight: normal; font-size: 11px; color: #94a3b8; display: block; }
|
|
|
+.card-body { padding: 15px 20px; flex: 1; overflow-y: auto; }
|
|
|
+.progress-item { margin-bottom: 18px; }
|
|
|
+.progress-item .info { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 12px; }
|
|
|
+.status-placeholder { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #cbd5e1; }
|
|
|
+.warning-shadow { border-top: 3px solid #e6a23c; }
|
|
|
+.danger-shadow { border-top: 3px solid #f56c6c; }
|
|
|
+.primary-shadow { border-top: 3px solid #409eff; }
|
|
|
+.warning-text { color: #e6a23c; }
|
|
|
+.danger-text { color: #f56c6c; }
|
|
|
+.primary-text { color: #409eff; }
|
|
|
+.text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; }
|
|
|
+.scroll-style::-webkit-scrollbar { width: 4px; }
|
|
|
+.scroll-style::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 10px; }
|
|
|
+
|
|
|
+.status-banner {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 24px 32px;
|
|
|
+ background: #ffffff;
|
|
|
+ border-radius: 16px;
|
|
|
+ margin-bottom: 24px;
|
|
|
+ position: relative;
|
|
|
+ overflow: hidden;
|
|
|
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05);
|
|
|
+ border: 1px solid rgba(255, 255, 255, 0.8);
|
|
|
+}
|
|
|
+
|
|
|
+/* 状态颜色背景微调 */
|
|
|
+.banner-success {
|
|
|
+ background: linear-gradient(135deg, #f0fdf4 0%, #ffffff 50%);
|
|
|
+ border-left: 6px solid #22c55e;
|
|
|
+}
|
|
|
+.banner-warning {
|
|
|
+ background: linear-gradient(135deg, #fffbeb 0%, #ffffff 50%);
|
|
|
+ border-left: 6px solid #f59e0b;
|
|
|
+}
|
|
|
+
|
|
|
+/* 左侧状态图标与动画 */
|
|
|
+.banner-left {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.status-visual {
|
|
|
+ position: relative;
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+}
|
|
|
+
|
|
|
+.status-icon-wrapper {
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ border-radius: 14px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 24px;
|
|
|
+ z-index: 2;
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+.banner-success .status-icon-wrapper { background: #dcfce7; color: #16a34a; }
|
|
|
+.banner-warning .status-icon-wrapper { background: #fef3c7; color: #d97706; }
|
|
|
+
|
|
|
+.pulse-ring {
|
|
|
+ position: absolute;
|
|
|
+ top: 0; left: 0; width: 100%; height: 100%;
|
|
|
+ border-radius: 14px;
|
|
|
+ animation: pulse 2s infinite;
|
|
|
+}
|
|
|
+.banner-success .pulse-ring { background: rgba(34, 197, 94, 0.2); }
|
|
|
+.banner-warning .pulse-ring { background: rgba(245, 158, 11, 0.2); }
|
|
|
+
|
|
|
+@keyframes pulse {
|
|
|
+ 0% { transform: scale(1); opacity: 1; }
|
|
|
+ 100% { transform: scale(1.5); opacity: 0; }
|
|
|
+}
|
|
|
+
|
|
|
+/* 内容文本区 */
|
|
|
+.banner-title {
|
|
|
+ font-size: 20px;
|
|
|
+ font-weight: 800;
|
|
|
+ color: #1e293b;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.banner-desc {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #64748b;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.update-time {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #94a3b8;
|
|
|
+}
|
|
|
+
|
|
|
+/* 中间指标区 */
|
|
|
+.banner-stats {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ background: rgba(248, 250, 252, 0.8);
|
|
|
+ padding: 12px 24px;
|
|
|
+ border-radius: 12px;
|
|
|
+ gap: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-item {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-label {
|
|
|
+ font-size: 11px;
|
|
|
+ text-transform: uppercase;
|
|
|
+ letter-spacing: 0.5px;
|
|
|
+ color: #94a3b8;
|
|
|
+ margin-bottom: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-value {
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #334155;
|
|
|
+}
|
|
|
+.stat-value small { font-size: 12px; font-weight: normal; }
|
|
|
+.risk-text { color: #ef4444; }
|
|
|
+
|
|
|
+.stat-divider {
|
|
|
+ width: 1px;
|
|
|
+ height: 24px;
|
|
|
+ background: #e2e8f0;
|
|
|
+}
|
|
|
+
|
|
|
+/* 按钮发光效果 */
|
|
|
+.refresh-glow {
|
|
|
+ box-shadow: 0 4px 14px 0 rgba(64, 158, 255, 0.3);
|
|
|
+ transition: all 0.3s ease;
|
|
|
+}
|
|
|
+.refresh-glow:hover {
|
|
|
+ transform: translateY(-1px);
|
|
|
+ box-shadow: 0 6px 20px rgba(64, 158, 255, 0.5);
|
|
|
+}
|
|
|
</style>
|