Files
abot/admin/dashboard/templates/system_status.html

815 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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>按使用率排序展示常用挂载点,方便快速发现哪个分区快满了。</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,
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}`;
},
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();
// 资源页采用前端轮询即可满足日常观察,不需要为了监控再起单独守护进程。
this.autoRefreshTimer = setInterval(() => {
this.loadStatusOverview(false);
}, 10000);
},
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 个点10 秒轮询时大约能覆盖近 3 分钟。
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 %}