837 lines
45 KiB
HTML
837 lines
45 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}资源监控 - 机器人管理后台{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="page-shell system-page">
|
||
<div class="page-hero">
|
||
<div class="page-hero-copy">
|
||
<div class="page-eyebrow">Resource Overview</div>
|
||
<h1>资源监控</h1>
|
||
<p>直接观察 ABOT 所在服务器的关键资源、应用进程和基础设施状态,不再依赖额外的 glances 进程。</p>
|
||
<div class="hero-meta-row">
|
||
<div class="hero-meta-pill">
|
||
<span class="hero-meta-label">最近刷新</span>
|
||
<span class="hero-meta-value">{% raw %}{{ statusOverview.timestamp || '-' }}{% endraw %}</span>
|
||
</div>
|
||
<div class="hero-meta-pill" v-if="statusOverview.server">
|
||
<span class="hero-meta-label">主机</span>
|
||
<span class="hero-meta-value">{% raw %}{{ statusOverview.server.hostname || '-' }}{% endraw %}</span>
|
||
</div>
|
||
<div class="hero-meta-pill" v-if="statusOverview.process">
|
||
<span class="hero-meta-label">PID</span>
|
||
<span class="hero-meta-value">{% raw %}{{ statusOverview.process.pid || '-' }}{% endraw %}</span>
|
||
</div>
|
||
</div>
|
||
<div class="md2img-health-inline" v-loading="md2imgLoading">
|
||
<span class="health-title">转图运行时</span>
|
||
<el-tag size="mini" :type="runtimeTagType">{% raw %}{{ runtimeTagText }}{% endraw %}</el-tag>
|
||
<el-tag size="mini" :type="browserTagType">{% raw %}{{ browserTagText }}{% endraw %}</el-tag>
|
||
<span class="health-brief">{% raw %}{{ md2imgBrief }}{% endraw %}</span>
|
||
<span class="health-time" v-if="md2imgHealth && md2imgHealth.timestamp">
|
||
{% raw %}{{ md2imgHealth.timestamp }}{% endraw %}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="page-hero-actions">
|
||
<el-button type="success" plain :loading="md2imgWarming" @click="warmupMd2Img">
|
||
<i class="el-icon-magic-stick"></i> 预热转图
|
||
</el-button>
|
||
<el-button type="info" plain :loading="loading" @click="loadStatusOverview(true)">
|
||
<i class="el-icon-refresh"></i> 刷新监控
|
||
</el-button>
|
||
<el-button type="danger" @click="confirmRestart">
|
||
<i class="el-icon-refresh-left"></i> 重启服务
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-card class="alert-strip-card" shadow="hover">
|
||
<div class="alert-strip-card__head">
|
||
<div>
|
||
<h3>观察摘要</h3>
|
||
<p>把当前最值得关注的风险先提到最上面,避免你每次都要自己扫完整页。</p>
|
||
</div>
|
||
<el-tag size="mini" :type="alertItems.length ? 'warning' : 'success'">
|
||
{% raw %}{{ alertItems.length ? `${alertItems.length} 条关注项` : '状态平稳' }}{% endraw %}
|
||
</el-tag>
|
||
</div>
|
||
<div v-if="alertItems.length" class="alert-strip-list">
|
||
<div
|
||
v-for="item in alertItems"
|
||
:key="item.key"
|
||
class="alert-pill"
|
||
:class="`alert-pill--${item.level}`">
|
||
<span class="alert-pill__title">{% raw %}{{ item.title }}{% endraw %}</span>
|
||
<span class="alert-pill__text">{% raw %}{{ item.text }}{% endraw %}</span>
|
||
</div>
|
||
</div>
|
||
<div v-else class="alert-strip-empty">
|
||
当前没有明显异常项,主机资源、进程和基础设施都处于可接受范围。
|
||
</div>
|
||
</el-card>
|
||
|
||
<div class="stats-grid">
|
||
<el-card class="stat-card" shadow="hover">
|
||
<div class="stat-card__label">CPU 使用率</div>
|
||
<div class="stat-card__value">{% raw %}{{ formatPercent(cpu.usage_percent) }}{% endraw %}</div>
|
||
<div class="stat-card__meta">{% raw %}{{ cpu.logical_count || 0 }}{% endraw %} 线程 · load1 {% raw %}{{ formatNumber(cpu.load_1, 2) }}{% endraw %}</div>
|
||
<el-progress :percentage="normalizePercent(cpu.usage_percent)" :stroke-width="10" :status="progressStatus(cpu.usage_percent, 65, 85)"></el-progress>
|
||
<div class="trend-mini">
|
||
<div class="trend-mini__head">
|
||
<span>最近趋势</span>
|
||
<span>{% raw %}{{ trendSummary('cpu') }}{% endraw %}</span>
|
||
</div>
|
||
<svg class="trend-mini__svg" viewBox="0 0 120 36" preserveAspectRatio="none">
|
||
<path class="trend-mini__path" :d="trendPath('cpu')"></path>
|
||
</svg>
|
||
</div>
|
||
</el-card>
|
||
<el-card class="stat-card" shadow="hover">
|
||
<div class="stat-card__label">内存使用率</div>
|
||
<div class="stat-card__value">{% raw %}{{ formatPercent(memory.usage_percent) }}{% endraw %}</div>
|
||
<div class="stat-card__meta">{% raw %}{{ formatBytes(memory.used_bytes) }}{% endraw %} / {% raw %}{{ formatBytes(memory.total_bytes) }}{% endraw %}</div>
|
||
<el-progress :percentage="normalizePercent(memory.usage_percent)" :stroke-width="10" :status="progressStatus(memory.usage_percent, 70, 88)"></el-progress>
|
||
<div class="trend-mini">
|
||
<div class="trend-mini__head">
|
||
<span>最近趋势</span>
|
||
<span>{% raw %}{{ trendSummary('memory') }}{% endraw %}</span>
|
||
</div>
|
||
<svg class="trend-mini__svg" viewBox="0 0 120 36" preserveAspectRatio="none">
|
||
<path class="trend-mini__path" :d="trendPath('memory')"></path>
|
||
</svg>
|
||
</div>
|
||
</el-card>
|
||
<el-card class="stat-card" shadow="hover">
|
||
<div class="stat-card__label">主盘使用率</div>
|
||
<div class="stat-card__value">{% raw %}{{ formatPercent(disk.primary_usage_percent) }}{% endraw %}</div>
|
||
<div class="stat-card__meta">{% raw %}{{ formatBytes(disk.primary_used_bytes) }}{% endraw %} / {% raw %}{{ formatBytes(disk.primary_total_bytes) }}{% endraw %}</div>
|
||
<el-progress :percentage="normalizePercent(disk.primary_usage_percent)" :stroke-width="10" :status="progressStatus(disk.primary_usage_percent, 75, 90)"></el-progress>
|
||
<div class="trend-mini">
|
||
<div class="trend-mini__head">
|
||
<span>最近趋势</span>
|
||
<span>{% raw %}{{ trendSummary('disk') }}{% endraw %}</span>
|
||
</div>
|
||
<svg class="trend-mini__svg" viewBox="0 0 120 36" preserveAspectRatio="none">
|
||
<path class="trend-mini__path" :d="trendPath('disk')"></path>
|
||
</svg>
|
||
</div>
|
||
</el-card>
|
||
<el-card class="stat-card" shadow="hover">
|
||
<div class="stat-card__label">网络速率</div>
|
||
<div class="stat-card__value">{% raw %}{{ formatSpeed(network.download_speed_bps) }}{% endraw %}</div>
|
||
<div class="stat-card__meta">上行 {% raw %}{{ formatSpeed(network.upload_speed_bps) }}{% endraw %} · 连接 {% raw %}{{ network.established_connections || 0 }}{% endraw %}</div>
|
||
<div class="network-balance">
|
||
<span>累计上行 {% raw %}{{ formatBytes(network.bytes_sent) }}{% endraw %}</span>
|
||
<span>累计下行 {% raw %}{{ formatBytes(network.bytes_recv) }}{% endraw %}</span>
|
||
</div>
|
||
<div class="trend-mini">
|
||
<div class="trend-mini__head">
|
||
<span>下载趋势</span>
|
||
<span>{% raw %}{{ trendSummary('network') }}{% endraw %}</span>
|
||
</div>
|
||
<svg class="trend-mini__svg" viewBox="0 0 120 36" preserveAspectRatio="none">
|
||
<path class="trend-mini__path" :d="trendPath('network')"></path>
|
||
</svg>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
|
||
<div class="monitor-grid">
|
||
<el-card class="workspace-card" shadow="hover">
|
||
<div slot="header" class="workspace-header">
|
||
<div>
|
||
<h3>系统概览</h3>
|
||
<p>观察主机、Python 运行环境和负载情况,快速判断是不是机器层面的问题。</p>
|
||
</div>
|
||
</div>
|
||
<div class="detail-grid">
|
||
<div class="detail-item">
|
||
<span class="detail-item__label">主机名</span>
|
||
<span class="detail-item__value">{% raw %}{{ server.hostname || '-' }}{% endraw %}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-item__label">操作系统</span>
|
||
<span class="detail-item__value">{% raw %}{{ server.os || '-' }}{% endraw %}</span>
|
||
</div>
|
||
<div class="detail-item detail-item--wide">
|
||
<span class="detail-item__label">系统版本</span>
|
||
<span class="detail-item__value">{% raw %}{{ server.os_version || '-' }}{% endraw %}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-item__label">Python</span>
|
||
<span class="detail-item__value">{% raw %}{{ server.python_version || '-' }}{% endraw %}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-item__label">开机时间</span>
|
||
<span class="detail-item__value">{% raw %}{{ server.boot_time || '-' }}{% endraw %}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-item__label">系统运行时长</span>
|
||
<span class="detail-item__value">{% raw %}{{ formatDuration(server.uptime_seconds) }}{% endraw %}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-item__label">Load 1 / 5 / 15</span>
|
||
<span class="detail-item__value">{% raw %}{{ formatNumber(cpu.load_1, 2) }} / {{ formatNumber(cpu.load_5, 2) }} / {{ formatNumber(cpu.load_15, 2) }}{% endraw %}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-item__label">CPU 核心</span>
|
||
<span class="detail-item__value">{% raw %}{{ cpu.physical_count || 0 }}{% endraw %} 物理 / {% raw %}{{ cpu.logical_count || 0 }}{% endraw %} 逻辑</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-item__label">Swap 使用率</span>
|
||
<span class="detail-item__value">{% raw %}{{ formatPercent(memory.swap_usage_percent) }}{% endraw %}</span>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<el-card class="workspace-card" shadow="hover">
|
||
<div slot="header" class="workspace-header">
|
||
<div>
|
||
<h3>ABOT 进程</h3>
|
||
<p>确认当前应用自身的资源占用,避免只看主机而忽略进程级热点。</p>
|
||
</div>
|
||
</div>
|
||
<div class="detail-grid">
|
||
<div class="detail-item">
|
||
<span class="detail-item__label">PID</span>
|
||
<span class="detail-item__value">{% raw %}{{ process.pid || '-' }}{% endraw %}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-item__label">进程 CPU</span>
|
||
<span class="detail-item__value">{% raw %}{{ formatPercent(process.cpu_percent) }}{% endraw %}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-item__label">进程内存</span>
|
||
<span class="detail-item__value">{% raw %}{{ formatBytes(process.memory_rss_bytes) }}{% endraw %}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-item__label">内存占比</span>
|
||
<span class="detail-item__value">{% raw %}{{ formatPercent(process.memory_percent) }}{% endraw %}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-item__label">线程数</span>
|
||
<span class="detail-item__value">{% raw %}{{ process.thread_count || 0 }}{% endraw %}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-item__label">打开文件</span>
|
||
<span class="detail-item__value">{% raw %}{{ process.open_files || 0 }}{% endraw %}</span>
|
||
</div>
|
||
<div class="detail-item detail-item--wide">
|
||
<span class="detail-item__label">进程启动时间</span>
|
||
<span class="detail-item__value">{% raw %}{{ process.create_time || '-' }}{% endraw %}</span>
|
||
</div>
|
||
<div class="detail-item detail-item--wide">
|
||
<span class="detail-item__label">进程运行时长</span>
|
||
<span class="detail-item__value">{% raw %}{{ formatDuration(process.uptime_seconds) }}{% endraw %}</span>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<el-card class="workspace-card workspace-card--wide" shadow="hover">
|
||
<div slot="header" class="workspace-header">
|
||
<div>
|
||
<h3>基础设施运行态</h3>
|
||
<p>把数据库和缓存的关键摘要放在同一屏里,日常看状态不需要再跳出去。</p>
|
||
</div>
|
||
</div>
|
||
<div class="infra-grid">
|
||
<div class="infra-panel" :class="`infra-panel--${infrastructure.mysql.status || 'warning'}`">
|
||
<div class="infra-panel__head">
|
||
<div>
|
||
<div class="infra-panel__title">MySQL</div>
|
||
<div class="infra-panel__summary">{% raw %}{{ infrastructure.mysql.summary || '暂无状态' }}{% endraw %}</div>
|
||
</div>
|
||
<el-tag size="mini" :type="tagType(infrastructure.mysql.status)">{% raw %}{{ statusText(infrastructure.mysql.status) }}{% endraw %}</el-tag>
|
||
</div>
|
||
<div class="infra-metrics">
|
||
<div class="infra-metric"><span>数据库</span><strong>{% raw %}{{ infrastructure.mysql.database || '-' }}{% endraw %}</strong></div>
|
||
<div class="infra-metric"><span>版本</span><strong>{% raw %}{{ infrastructure.mysql.version || '-' }}{% endraw %}</strong></div>
|
||
<div class="infra-metric"><span>连接数</span><strong>{% raw %}{{ infrastructure.mysql.threads_connected || 0 }}{% endraw %} / {% raw %}{{ infrastructure.mysql.max_connections || '-' }}{% endraw %}</strong></div>
|
||
<div class="infra-metric"><span>连接负载</span><strong>{% raw %}{{ formatPercent(infrastructure.mysql.connection_usage_percent) }}{% endraw %}</strong></div>
|
||
<div class="infra-metric"><span>运行线程</span><strong>{% raw %}{{ infrastructure.mysql.threads_running || 0 }}{% endraw %}</strong></div>
|
||
<div class="infra-metric"><span>QPS</span><strong>{% raw %}{{ formatNumber(infrastructure.mysql.questions_per_second, 2) }}{% endraw %}</strong></div>
|
||
<div class="infra-metric"><span>表数量</span><strong>{% raw %}{{ infrastructure.mysql.table_count || 0 }}{% endraw %}</strong></div>
|
||
<div class="infra-metric"><span>库体量</span><strong>{% raw %}{{ formatMb(infrastructure.mysql.schema_size_mb) }}{% endraw %}</strong></div>
|
||
</div>
|
||
</div>
|
||
<div class="infra-panel" :class="`infra-panel--${infrastructure.redis.status || 'warning'}`">
|
||
<div class="infra-panel__head">
|
||
<div>
|
||
<div class="infra-panel__title">Redis</div>
|
||
<div class="infra-panel__summary">{% raw %}{{ infrastructure.redis.summary || '暂无状态' }}{% endraw %}</div>
|
||
</div>
|
||
<el-tag size="mini" :type="tagType(infrastructure.redis.status)">{% raw %}{{ statusText(infrastructure.redis.status) }}{% endraw %}</el-tag>
|
||
</div>
|
||
<div class="infra-metrics">
|
||
<div class="infra-metric"><span>DB</span><strong>{% raw %}{{ infrastructure.redis.db_index || 0 }}{% endraw %}</strong></div>
|
||
<div class="infra-metric"><span>Key 数量</span><strong>{% raw %}{{ infrastructure.redis.key_count || 0 }}{% endraw %}</strong></div>
|
||
<div class="infra-metric"><span>客户端</span><strong>{% raw %}{{ infrastructure.redis.connected_clients || 0 }}{% endraw %}</strong></div>
|
||
<div class="infra-metric"><span>阻塞客户端</span><strong>{% raw %}{{ infrastructure.redis.blocked_clients || 0 }}{% endraw %}</strong></div>
|
||
<div class="infra-metric"><span>OPS/s</span><strong>{% raw %}{{ infrastructure.redis.ops_per_sec || 0 }}{% endraw %}</strong></div>
|
||
<div class="infra-metric"><span>命中率</span><strong>{% raw %}{{ formatPercent(infrastructure.redis.hit_rate_percent) }}{% endraw %}</strong></div>
|
||
<div class="infra-metric"><span>已用内存</span><strong>{% raw %}{{ infrastructure.redis.used_memory_human || '-' }}{% endraw %}</strong></div>
|
||
<div class="infra-metric"><span>峰值内存</span><strong>{% raw %}{{ infrastructure.redis.used_memory_peak_human || '-' }}{% endraw %}</strong></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<el-card class="workspace-card workspace-card--wide" shadow="hover">
|
||
<div slot="header" class="workspace-header">
|
||
<div>
|
||
<h3>磁盘挂载点</h3>
|
||
<p>
|
||
{% raw %}{{ diskMountBrief }}{% endraw %}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<el-table :data="disk.items || []" size="mini" style="width: 100%">
|
||
<el-table-column prop="mountpoint" label="挂载点" min-width="180"></el-table-column>
|
||
<el-table-column prop="device" label="设备" min-width="180"></el-table-column>
|
||
<el-table-column prop="fstype" label="文件系统" width="110"></el-table-column>
|
||
<el-table-column label="已用 / 总量" min-width="170">
|
||
<template slot-scope="scope">
|
||
{% raw %}{{ formatBytes(scope.row.used_bytes) }} / {{ formatBytes(scope.row.total_bytes) }}{% endraw %}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="使用率" min-width="180">
|
||
<template slot-scope="scope">
|
||
<div class="table-progress-cell">
|
||
<span>{% raw %}{{ formatPercent(scope.row.usage_percent) }}{% endraw %}</span>
|
||
<el-progress
|
||
:percentage="normalizePercent(scope.row.usage_percent)"
|
||
:stroke-width="8"
|
||
:status="progressStatus(scope.row.usage_percent, 75, 90)">
|
||
</el-progress>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="剩余空间" min-width="120">
|
||
<template slot-scope="scope">
|
||
{% raw %}{{ formatBytes(scope.row.free_bytes) }}{% endraw %}
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-card>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
new Vue({
|
||
el: '#app',
|
||
mixins: [baseApp],
|
||
data() {
|
||
return {
|
||
currentView: '14',
|
||
restarting: false,
|
||
loading: false,
|
||
md2imgLoading: false,
|
||
md2imgWarming: false,
|
||
autoRefreshTimer: null,
|
||
// 资源监控页默认走“低频观察”模式:
|
||
// 1. 你当前更依赖邮件告警,而不是盯着面板秒级刷新;
|
||
// 2. 把轮询频率降到 30 秒,可以明显减少无意义请求;
|
||
// 3. 同时还能保留基础趋势观察能力,适合日常巡检。
|
||
autoRefreshIntervalMs: 30000,
|
||
md2imgHealth: null,
|
||
trendHistory: {
|
||
cpu: [],
|
||
memory: [],
|
||
disk: [],
|
||
network: []
|
||
},
|
||
statusOverview: {
|
||
timestamp: '',
|
||
server: {},
|
||
cpu: {},
|
||
memory: {},
|
||
disk: { items: [] },
|
||
network: {},
|
||
process: {},
|
||
infrastructure: {
|
||
mysql: {},
|
||
redis: {}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
computed: {
|
||
server() {
|
||
return this.statusOverview.server || {};
|
||
},
|
||
cpu() {
|
||
return this.statusOverview.cpu || {};
|
||
},
|
||
memory() {
|
||
return this.statusOverview.memory || {};
|
||
},
|
||
disk() {
|
||
return this.statusOverview.disk || { items: [] };
|
||
},
|
||
network() {
|
||
return this.statusOverview.network || {};
|
||
},
|
||
process() {
|
||
return this.statusOverview.process || {};
|
||
},
|
||
infrastructure() {
|
||
return this.statusOverview.infrastructure || { mysql: {}, redis: {} };
|
||
},
|
||
runtimeTagText() {
|
||
const runtime = this.md2imgHealth && this.md2imgHealth.runtime ? this.md2imgHealth.runtime : {};
|
||
return runtime.loop_running ? '运行时在线' : '运行时未就绪';
|
||
},
|
||
runtimeTagType() {
|
||
const runtime = this.md2imgHealth && this.md2imgHealth.runtime ? this.md2imgHealth.runtime : {};
|
||
return runtime.loop_running ? 'success' : 'warning';
|
||
},
|
||
browserTagText() {
|
||
const browser = this.md2imgHealth && this.md2imgHealth.browser ? this.md2imgHealth.browser : {};
|
||
return browser.connected ? '浏览器已连接' : 'info';
|
||
},
|
||
browserTagType() {
|
||
const browser = this.md2imgHealth && this.md2imgHealth.browser ? this.md2imgHealth.browser : {};
|
||
return browser.connected ? 'success' : 'info';
|
||
},
|
||
md2imgBrief() {
|
||
if (!this.md2imgHealth) return '尚未获取状态';
|
||
const runtime = this.md2imgHealth.runtime || {};
|
||
const browser = this.md2imgHealth.browser || {};
|
||
const loopText = runtime.loop_id ? `loop=${runtime.loop_id}` : 'loop=-';
|
||
const pidText = browser.pid ? `pid=${browser.pid}` : 'pid=-';
|
||
const sourceText = browser.launch_source ? `source=${browser.launch_source}` : 'source=-';
|
||
return `${loopText} · ${pidText} · ${sourceText}`;
|
||
},
|
||
diskMountBrief() {
|
||
const hiddenCount = Number(this.disk.hidden_virtual_mount_count || 0);
|
||
if (hiddenCount > 0) {
|
||
return `按使用率排序展示常用挂载点,已隐藏 ${hiddenCount} 个 snap / loop / squashfs 等虚拟挂载。`;
|
||
}
|
||
return '按使用率排序展示常用挂载点,方便快速发现哪个分区快满了。';
|
||
},
|
||
alertItems() {
|
||
const items = [];
|
||
const cpuUsage = this.normalizePercent(this.cpu.usage_percent);
|
||
const memoryUsage = this.normalizePercent(this.memory.usage_percent);
|
||
const diskUsage = this.normalizePercent(this.disk.primary_usage_percent);
|
||
const mysqlStatus = this.infrastructure.mysql.status || 'healthy';
|
||
const redisStatus = this.infrastructure.redis.status || 'healthy';
|
||
|
||
if (cpuUsage >= 85) {
|
||
items.push({
|
||
key: 'cpu-danger',
|
||
level: 'danger',
|
||
title: 'CPU 过高',
|
||
text: `当前 CPU 使用率 ${this.formatPercent(cpuUsage)},建议先排查是否存在短时计算高峰或死循环。`
|
||
});
|
||
} else if (cpuUsage >= 65) {
|
||
items.push({
|
||
key: 'cpu-warning',
|
||
level: 'warning',
|
||
title: 'CPU 偏高',
|
||
text: `当前 CPU 使用率 ${this.formatPercent(cpuUsage)},建议继续观察最近几轮走势。`
|
||
});
|
||
}
|
||
|
||
if (memoryUsage >= 88) {
|
||
items.push({
|
||
key: 'memory-danger',
|
||
level: 'danger',
|
||
title: '内存紧张',
|
||
text: `当前内存使用率 ${this.formatPercent(memoryUsage)},可能会影响模型调用和浏览器截图稳定性。`
|
||
});
|
||
} else if (memoryUsage >= 70) {
|
||
items.push({
|
||
key: 'memory-warning',
|
||
level: 'warning',
|
||
title: '内存偏高',
|
||
text: `当前内存使用率 ${this.formatPercent(memoryUsage)},建议留意后续是否持续抬升。`
|
||
});
|
||
}
|
||
|
||
if (diskUsage >= 90) {
|
||
items.push({
|
||
key: 'disk-danger',
|
||
level: 'danger',
|
||
title: '磁盘接近满载',
|
||
text: `主盘使用率 ${this.formatPercent(diskUsage)},建议尽快清理日志、缓存或下载目录。`
|
||
});
|
||
} else if (diskUsage >= 75) {
|
||
items.push({
|
||
key: 'disk-warning',
|
||
level: 'warning',
|
||
title: '磁盘占用偏高',
|
||
text: `主盘使用率 ${this.formatPercent(diskUsage)},建议关注媒体目录和日志增长速度。`
|
||
});
|
||
}
|
||
|
||
if (mysqlStatus !== 'healthy') {
|
||
items.push({
|
||
key: 'mysql-status',
|
||
level: mysqlStatus === 'danger' ? 'danger' : 'warning',
|
||
title: 'MySQL 需要关注',
|
||
text: this.infrastructure.mysql.summary || 'MySQL 当前状态异常'
|
||
});
|
||
}
|
||
|
||
if (redisStatus !== 'healthy') {
|
||
items.push({
|
||
key: 'redis-status',
|
||
level: redisStatus === 'danger' ? 'danger' : 'warning',
|
||
title: 'Redis 需要关注',
|
||
text: this.infrastructure.redis.summary || 'Redis 当前状态异常'
|
||
});
|
||
}
|
||
|
||
if ((this.process.open_files || 0) >= 500) {
|
||
items.push({
|
||
key: 'process-open-files',
|
||
level: 'warning',
|
||
title: '打开文件数偏多',
|
||
text: `当前进程已打开 ${this.process.open_files || 0} 个文件,若持续增长可能存在句柄泄漏。`
|
||
});
|
||
}
|
||
|
||
return items.slice(0, 5);
|
||
}
|
||
},
|
||
mounted() {
|
||
this.currentView = '14';
|
||
this.loadStatusOverview(false);
|
||
this.loadMd2ImgHealth();
|
||
// 资源页继续采用前端轮询即可满足日常观察,不需要为了监控再起单独守护进程。
|
||
// 这里改成更低频的 30 秒:
|
||
// 1. 减少后台接口请求频率;
|
||
// 2. 更贴合“异常靠邮件提醒,页面只做巡检”的使用方式;
|
||
// 3. 仍然足够支撑人工查看当前机器状态。
|
||
this.autoRefreshTimer = setInterval(() => {
|
||
this.loadStatusOverview(false);
|
||
}, this.autoRefreshIntervalMs);
|
||
},
|
||
beforeDestroy() {
|
||
if (this.autoRefreshTimer) {
|
||
clearInterval(this.autoRefreshTimer);
|
||
this.autoRefreshTimer = null;
|
||
}
|
||
},
|
||
methods: {
|
||
async loadStatusOverview(showToast) {
|
||
if (this.loading) return;
|
||
this.loading = true;
|
||
try {
|
||
const response = await axios.get('/api/system_status_overview');
|
||
if (response.data && response.data.success) {
|
||
this.statusOverview = response.data.data || this.statusOverview;
|
||
// 趋势只保留页面级最近若干采样点:
|
||
// 1. 不落库,避免为了可视化引入额外存储复杂度;
|
||
// 2. 足够支撑“我刚刷新几轮,它是在升还是在降”的观察场景;
|
||
// 3. 同一页内只看短时变化,比长期历史更符合当前需求。
|
||
this.recordTrendSnapshot();
|
||
if (showToast) {
|
||
this.$message.success('资源监控已刷新');
|
||
}
|
||
} else {
|
||
this.$message.error(response.data?.message || '获取资源监控失败');
|
||
}
|
||
} catch (error) {
|
||
this.$message.error(error.response?.data?.message || '获取资源监控失败');
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
normalizePercent(value) {
|
||
const number = Number(value || 0);
|
||
if (Number.isNaN(number)) return 0;
|
||
return Math.max(0, Math.min(100, Number(number.toFixed(1))));
|
||
},
|
||
recordTrendSnapshot() {
|
||
this.pushTrendPoint('cpu', this.normalizePercent(this.cpu.usage_percent));
|
||
this.pushTrendPoint('memory', this.normalizePercent(this.memory.usage_percent));
|
||
this.pushTrendPoint('disk', this.normalizePercent(this.disk.primary_usage_percent));
|
||
this.pushTrendPoint('network', Number(this.network.download_speed_bps || 0));
|
||
},
|
||
pushTrendPoint(key, value) {
|
||
const numeric = Number(value || 0);
|
||
if (!Object.prototype.hasOwnProperty.call(this.trendHistory, key) || Number.isNaN(numeric)) {
|
||
return;
|
||
}
|
||
const next = (this.trendHistory[key] || []).slice();
|
||
next.push({
|
||
value: numeric,
|
||
timestamp: Date.now()
|
||
});
|
||
// 只保留最近 18 个点,30 秒轮询时大约能覆盖近 9 分钟。
|
||
// 这个窗口故意不做太长:
|
||
// 1. 目标是给当前巡检提供“最近一段时间”的趋势感知;
|
||
// 2. 再长就更适合落库做历史监控,而不是页面内存态缓存;
|
||
// 3. 对你现在的运维习惯来说,9 分钟窗口已经足够判断是否持续抬升。
|
||
while (next.length > 18) {
|
||
next.shift();
|
||
}
|
||
this.$set(this.trendHistory, key, next);
|
||
},
|
||
trendSummary(key) {
|
||
const points = this.trendHistory[key] || [];
|
||
if (points.length < 2) return '采样中';
|
||
const firstValue = Number(points[0].value || 0);
|
||
const lastValue = Number(points[points.length - 1].value || 0);
|
||
const delta = lastValue - firstValue;
|
||
if (Math.abs(delta) < 0.5) return '基本持平';
|
||
if (key === 'network') {
|
||
return delta > 0 ? '下载升高' : '下载回落';
|
||
}
|
||
return delta > 0 ? `较前上升 ${delta.toFixed(1)}` : `较前下降 ${Math.abs(delta).toFixed(1)}`;
|
||
},
|
||
trendPath(key) {
|
||
const points = this.trendHistory[key] || [];
|
||
if (!points.length) return '';
|
||
const width = 120;
|
||
const height = 36;
|
||
const values = points.map(item => Number(item.value || 0));
|
||
const maxValue = Math.max(...values, 1);
|
||
const minValue = Math.min(...values, 0);
|
||
const range = Math.max(maxValue - minValue, 1);
|
||
return values.map((value, index) => {
|
||
const x = values.length === 1 ? 0 : (index / (values.length - 1)) * width;
|
||
const y = height - (((value - minValue) / range) * (height - 4)) - 2;
|
||
return `${index === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`;
|
||
}).join(' ');
|
||
},
|
||
progressStatus(value, warningThreshold, dangerThreshold) {
|
||
const percent = this.normalizePercent(value);
|
||
if (percent >= dangerThreshold) return 'exception';
|
||
if (percent >= warningThreshold) return 'warning';
|
||
return 'success';
|
||
},
|
||
formatNumber(value, digits = 0) {
|
||
if (value === null || value === undefined || value === '') return '-';
|
||
const number = Number(value);
|
||
if (Number.isNaN(number)) return String(value);
|
||
return number.toFixed(digits);
|
||
},
|
||
formatPercent(value) {
|
||
if (value === null || value === undefined || value === '') return '-';
|
||
return `${this.formatNumber(value, 1)}%`;
|
||
},
|
||
formatBytes(value) {
|
||
const bytes = Number(value || 0);
|
||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
let size = bytes;
|
||
let index = 0;
|
||
while (size >= 1024 && index < units.length - 1) {
|
||
size /= 1024;
|
||
index += 1;
|
||
}
|
||
const digits = index === 0 ? 0 : (size >= 100 ? 0 : (size >= 10 ? 1 : 2));
|
||
return `${size.toFixed(digits)} ${units[index]}`;
|
||
},
|
||
formatSpeed(value) {
|
||
return `${this.formatBytes(value)}/s`;
|
||
},
|
||
formatDuration(seconds) {
|
||
const total = parseInt(seconds || 0, 10);
|
||
if (!total || total <= 0) return '-';
|
||
const days = Math.floor(total / 86400);
|
||
const hours = Math.floor((total % 86400) / 3600);
|
||
const minutes = Math.floor((total % 3600) / 60);
|
||
if (days > 0) return `${days}天 ${hours}小时`;
|
||
if (hours > 0) return `${hours}小时 ${minutes}分钟`;
|
||
return `${minutes}分钟`;
|
||
},
|
||
formatMb(value) {
|
||
const number = Number(value || 0);
|
||
if (!Number.isFinite(number) || number <= 0) return '0 MB';
|
||
return `${number.toFixed(number >= 100 ? 0 : 1)} MB`;
|
||
},
|
||
statusText(status) {
|
||
const mapping = {
|
||
healthy: '健康',
|
||
warning: '关注',
|
||
danger: '异常'
|
||
};
|
||
return mapping[status] || '未知';
|
||
},
|
||
tagType(status) {
|
||
const mapping = {
|
||
healthy: 'success',
|
||
warning: 'warning',
|
||
danger: 'danger'
|
||
};
|
||
return mapping[status] || 'info';
|
||
},
|
||
confirmRestart() {
|
||
this.$confirm('确认执行 ./restart.sh 重启服务吗?这会中断当前服务几秒钟。', '重启确认', {
|
||
confirmButtonText: '确认重启',
|
||
cancelButtonText: '取消',
|
||
type: 'warning'
|
||
}).then(() => {
|
||
this.triggerRestart();
|
||
}).catch(() => {});
|
||
},
|
||
async triggerRestart() {
|
||
if (this.restarting) return;
|
||
this.restarting = true;
|
||
try {
|
||
const response = await axios.post('/api/restart_service');
|
||
if (response.data.success) {
|
||
this.$message.success(response.data.message || '已触发重启');
|
||
} else {
|
||
this.$message.error(response.data.message || '重启失败');
|
||
}
|
||
} catch (error) {
|
||
this.$message.error(error.response?.data?.message || '触发重启失败');
|
||
} finally {
|
||
this.restarting = false;
|
||
}
|
||
},
|
||
async loadMd2ImgHealth() {
|
||
if (this.md2imgLoading) return;
|
||
this.md2imgLoading = true;
|
||
try {
|
||
const response = await axios.get('/api/system/md2img_health');
|
||
if (response.data && response.data.success) {
|
||
this.md2imgHealth = response.data.data || null;
|
||
} else {
|
||
this.$message.error(response.data?.message || '获取转图状态失败');
|
||
}
|
||
} catch (error) {
|
||
this.$message.error(error.response?.data?.message || '获取转图状态失败');
|
||
} finally {
|
||
this.md2imgLoading = false;
|
||
}
|
||
},
|
||
async warmupMd2Img() {
|
||
if (this.md2imgWarming) return;
|
||
this.md2imgWarming = true;
|
||
try {
|
||
const response = await axios.post('/api/system/md2img_warmup', { timeout_seconds: 60 });
|
||
if (response.data && response.data.success) {
|
||
this.$message.success(response.data.message || '转图预热成功');
|
||
this.md2imgHealth = response.data.data || this.md2imgHealth;
|
||
} else {
|
||
this.$message.error(response.data?.message || '转图预热失败');
|
||
}
|
||
} catch (error) {
|
||
this.$message.error(error.response?.data?.message || '转图预热失败');
|
||
} finally {
|
||
this.md2imgWarming = false;
|
||
this.loadMd2ImgHealth();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style>
|
||
.page-shell { display: flex; flex-direction: column; gap: 16px; }
|
||
.page-hero {
|
||
display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; padding: 24px 26px; border-radius: 24px;
|
||
background: linear-gradient(135deg, rgba(15,23,42,0.05), rgba(59,130,246,0.10), rgba(255,255,255,0.95));
|
||
border: 1px solid rgba(148, 163, 184, 0.16); box-shadow: 0 18px 40px rgba(15, 23, 42, 0.06);
|
||
}
|
||
.page-hero-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||
.page-eyebrow { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: #2563eb; font-weight: 700; margin-bottom: 8px; }
|
||
.page-hero-copy h1 { font-size: 30px; line-height: 1.1; margin-bottom: 10px; color: #0f172a; }
|
||
.page-hero-copy p { color: #64748b; font-size: 14px; }
|
||
.hero-meta-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 14px; }
|
||
.hero-meta-pill {
|
||
display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 999px;
|
||
background: rgba(255,255,255,0.8); border: 1px solid rgba(148,163,184,0.14); color: #475569; font-size: 12px;
|
||
}
|
||
.hero-meta-label { color: #94a3b8; }
|
||
.hero-meta-value { color: #0f172a; font-weight: 700; }
|
||
.md2img-health-inline {
|
||
margin-top: 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 8px 10px;
|
||
border-radius: 12px; background: rgba(255,255,255,0.72); border: 1px solid rgba(148, 163, 184, 0.18);
|
||
}
|
||
.health-title { font-size: 12px; font-weight: 700; color: #334155; }
|
||
.health-brief { font-size: 12px; color: #475569; }
|
||
.health-time { font-size: 12px; color: #94a3b8; }
|
||
.alert-strip-card { border-radius: 20px; }
|
||
.alert-strip-card__head {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 12px;
|
||
}
|
||
.alert-strip-card__head h3 { font-size: 18px; margin-bottom: 4px; color: #0f172a; }
|
||
.alert-strip-card__head p { font-size: 13px; color: #64748b; }
|
||
.alert-strip-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||
.alert-pill {
|
||
display: flex; flex-direction: column; gap: 6px; padding: 14px 16px; border-radius: 16px;
|
||
border: 1px solid rgba(148,163,184,0.12); background: rgba(248,250,252,0.78);
|
||
}
|
||
.alert-pill--warning { box-shadow: inset 0 0 0 1px rgba(245,158,11,0.10); }
|
||
.alert-pill--danger { box-shadow: inset 0 0 0 1px rgba(239,68,68,0.12); }
|
||
.alert-pill__title { color: #0f172a; font-size: 14px; font-weight: 700; }
|
||
.alert-pill__text { color: #64748b; font-size: 12px; line-height: 1.7; }
|
||
.alert-strip-empty {
|
||
padding: 14px 16px; border-radius: 16px; background: rgba(240,253,244,0.7);
|
||
border: 1px solid rgba(134,239,172,0.5); color: #166534; font-size: 13px;
|
||
}
|
||
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
||
.stat-card { border-radius: 20px; }
|
||
.stat-card__label { color: #64748b; font-size: 13px; margin-bottom: 10px; }
|
||
.stat-card__value { color: #0f172a; font-size: 30px; font-weight: 700; line-height: 1; margin-bottom: 10px; }
|
||
.stat-card__meta { color: #475569; font-size: 12px; margin-bottom: 12px; min-height: 18px; }
|
||
.network-balance { margin-top: 12px; display: flex; justify-content: space-between; gap: 10px; color: #64748b; font-size: 12px; }
|
||
.trend-mini { margin-top: 12px; padding-top: 12px; border-top: 1px dashed rgba(148,163,184,0.20); }
|
||
.trend-mini__head {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 8px; color: #64748b; font-size: 12px; margin-bottom: 8px;
|
||
}
|
||
.trend-mini__svg { width: 100%; height: 36px; display: block; }
|
||
.trend-mini__path {
|
||
fill: none; stroke: #2563eb; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round;
|
||
}
|
||
.monitor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||
.workspace-card { border-radius: 20px; }
|
||
.workspace-card--wide { grid-column: 1 / -1; }
|
||
.workspace-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||
.workspace-header h3 { font-size: 18px; margin-bottom: 4px; }
|
||
.workspace-header p { font-size: 13px; color: #64748b; }
|
||
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||
.detail-item {
|
||
display: flex; flex-direction: column; gap: 6px; padding: 14px 16px; border-radius: 16px;
|
||
background: rgba(248,250,252,0.82); border: 1px solid rgba(148,163,184,0.12);
|
||
}
|
||
.detail-item--wide { grid-column: 1 / -1; }
|
||
.detail-item__label { color: #64748b; font-size: 12px; }
|
||
.detail-item__value { color: #0f172a; font-size: 15px; font-weight: 700; word-break: break-all; }
|
||
.infra-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||
.infra-panel {
|
||
padding: 16px; border-radius: 18px; border: 1px solid rgba(148,163,184,0.12); background: rgba(248,250,252,0.86);
|
||
}
|
||
.infra-panel--healthy { box-shadow: inset 0 0 0 1px rgba(16,185,129,0.08); }
|
||
.infra-panel--warning { box-shadow: inset 0 0 0 1px rgba(245,158,11,0.10); }
|
||
.infra-panel--danger { box-shadow: inset 0 0 0 1px rgba(239,68,68,0.10); }
|
||
.infra-panel__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||
.infra-panel__title { color: #0f172a; font-size: 16px; font-weight: 700; }
|
||
.infra-panel__summary { color: #64748b; font-size: 12px; line-height: 1.6; margin-top: 4px; }
|
||
.infra-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; }
|
||
.infra-metric {
|
||
display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; border-radius: 14px;
|
||
background: rgba(255,255,255,0.75); border: 1px solid rgba(148,163,184,0.10);
|
||
}
|
||
.infra-metric span { color: #64748b; font-size: 12px; }
|
||
.infra-metric strong { color: #0f172a; font-size: 14px; font-weight: 700; word-break: break-word; }
|
||
.table-progress-cell { display: grid; grid-template-columns: 60px 1fr; align-items: center; gap: 10px; }
|
||
@media (max-width: 1200px) {
|
||
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
.monitor-grid { grid-template-columns: 1fr; }
|
||
.infra-grid { grid-template-columns: 1fr; }
|
||
.alert-strip-list { grid-template-columns: 1fr; }
|
||
}
|
||
@media (max-width: 960px) {
|
||
.page-hero { flex-direction: column; align-items: flex-start; }
|
||
.detail-grid { grid-template-columns: 1fr; }
|
||
.detail-item--wide { grid-column: auto; }
|
||
}
|
||
@media (max-width: 640px) {
|
||
.stats-grid { grid-template-columns: 1fr; }
|
||
.network-balance { flex-direction: column; }
|
||
.infra-metrics { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|
||
{% endblock %}
|