Files
fnmon/static/index.html
T

1275 lines
54 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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>fnmon · fnOS</title>
<style>
:root {
--bg: #0d1117;
--sidebar: #161b22;
--card: #161b22;
--card-border: #30363d;
--text: #e6edf3;
--dim: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--orange: #f0883e;
--red: #f85149;
--pink: #bc8cff;
--cyan: #56d4dd;
--sidebar-w: 64px;
--active-tab: #1f6feb;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', sans-serif;
display: flex;
min-height: 100vh;
}
/* ===== Sidebar ===== */
.sidebar {
width: var(--sidebar-w);
background: var(--sidebar);
border-right: 1px solid var(--card-border);
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 0;
gap: 4px;
flex-shrink: 0;
position: sticky;
top: 0;
height: 100vh;
z-index: 10;
}
.sidebar .logo {
font-size: 22px;
margin-bottom: 16px;
opacity: .6;
}
.sidebar .tab-btn {
width: 44px; height: 44px;
border-radius: 10px;
border: none;
background: transparent;
color: var(--dim);
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all .15s;
position: relative;
}
.sidebar .tab-btn:hover {
background: rgba(255,255,255,.06);
color: var(--text);
}
.sidebar .tab-btn.active {
background: var(--active-tab);
color: #fff;
box-shadow: 0 0 12px rgba(31,111,235,.3);
}
.sidebar .tab-btn .tooltip {
position: absolute;
left: 56px;
background: #1c2128;
color: var(--text);
font-size: 12px;
padding: 4px 10px;
border-radius: 6px;
border: 1px solid var(--card-border);
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity .15s;
}
.sidebar .tab-btn:hover .tooltip {
opacity: 1;
}
.sidebar .status-dot {
width: 6px; height: 6px;
border-radius: 50%;
margin-top: auto;
margin-bottom: 12px;
}
.sidebar .status-dot.on { background: var(--green); }
.sidebar .status-dot.off { background: var(--red); }
/* ===== Main ===== */
.main {
flex: 1;
padding: 20px 24px;
max-width: calc(100vw - var(--sidebar-w));
overflow-x: hidden;
}
.panel { display: none; }
.panel.active { display: block; }
.panel h2 {
font-size: 18px; font-weight: 600;
margin-bottom: 20px;
display: flex; align-items: center; gap: 8px;
}
.panel h2 .badge {
font-size: 12px; font-weight: 400;
color: var(--dim);
margin-left: 8px;
}
/* ===== Cards ===== */
.card {
background: var(--card);
border: 1px solid var(--card-border);
border-radius: 10px;
padding: 16px 18px;
margin-bottom: 14px;
}
.card-title {
font-size: 12px; text-transform: uppercase; letter-spacing: .5px;
color: var(--dim); margin-bottom: 10px;
}
.card-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
}
.stat-item .label {
font-size: 11px; color: var(--dim); margin-bottom: 2px;
}
.stat-item .value {
font-size: 18px; font-weight: 600; font-variant-numeric: tabular-nums;
overflow-wrap: break-word; word-wrap: break-word;
}
.stat-item .value.green { color: var(--green); }
.stat-item .value.orange { color: var(--orange); }
.stat-item .value.accent { color: var(--accent); }
.stat-item .value.pink { color: var(--pink); }
.stat-item .sub { font-size: 12px; color: var(--dim); }
/* Progress bar */
.progress-bar {
width: 100%; height: 10px;
background: #21262d; border-radius: 5px; overflow: hidden;
margin: 4px 0;
}
.progress-bar .fill {
height: 100%; border-radius: 5px;
transition: width .5s ease;
}
/* ===== Gauges (CPU) ===== */
.gauges-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px; margin-bottom: 14px;
}
.gauge-wrap {
background: var(--card); border: 1px solid var(--card-border);
border-radius: 10px; padding: 14px; text-align: center;
}
.gauge-wrap .core-label { font-size: 11px; color: var(--dim); }
.gauge-wrap .freq-val {
font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums;
margin: 4px 0;
}
.gauge-wrap .freq-unit { font-size: 11px; color: var(--dim); font-weight: 400; }
.gauge-wrap .bar-wrapper {
width: 100%; height: 6px; background: #21262d; border-radius: 3px; overflow: hidden;
}
.gauge-wrap .bar-fill { height: 100%; border-radius: 3px; transition: width .4s; }
/* ===== Chart ===== */
.chart-container {
background: var(--card); border: 1px solid var(--card-border);
border-radius: 10px; padding: 16px 18px; margin-bottom: 14px;
}
.chart-container .chart-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 8px;
}
.chart-legend { display: flex; gap: 12px; flex-wrap: wrap; }
.chart-legend .leg-item {
display: flex; align-items: center; gap: 4px;
font-size: 11px; color: var(--dim);
}
.chart-legend .leg-dot { width: 10px; height: 3px; border-radius: 2px; }
.chart-container canvas { width: 100%; height: 160px; display: block; }
/* ===== Memory Donut ===== */
.mem-container { display: flex; gap: 24px; align-items: center; flex-wrap: wrap; }
.mem-donut { position: relative; width: 160px; height: 160px; flex-shrink: 0; }
.mem-donut canvas { width: 160px; height: 160px; }
.mem-donut .center {
position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
.mem-donut .center .pct { font-size: 28px; font-weight: 700; }
.mem-donut .center .pct-label { font-size: 11px; color: var(--dim); }
.mem-details { flex: 1; min-width: 200px; }
.mem-details .row {
display: flex; justify-content: space-between;
padding: 5px 0; font-size: 13px;
border-bottom: 1px solid rgba(48,54,61,.4);
}
.mem-details .row:last-child { border: none; }
.mem-details .row .val { font-weight: 600; font-variant-numeric: tabular-nums; }
/* ===== Network ===== */
.net-grid { display: grid; gap: 10px; }
.net-card {
background: var(--card); border: 1px solid var(--card-border);
border-radius: 8px; padding: 12px 16px;
display: flex; align-items: center; gap: 16px;
}
.net-card .iface-icon {
width: 36px; height: 36px; border-radius: 8px;
background: #1c2128; display: flex; align-items: center; justify-content: center;
font-size: 16px; flex-shrink: 0;
}
.net-card .iface-info { flex: 1; min-width: 0; }
.net-card .iface-name { font-size: 13px; font-weight: 600; }
.net-card .iface-ip { font-size: 11px; color: var(--dim); }
.net-card .iface-traffic {
display: flex; gap: 20px; margin-top: 4px;
}
.net-card .traffic-item { font-size: 12px; }
.net-card .traffic-item .arrow { font-size: 10px; }
.net-card .traffic-item .speed { font-weight: 600; font-variant-numeric: tabular-nums; }
.net-card .traffic-item .total { color: var(--dim); font-size: 11px; }
/* ===== Disk ===== */
.disk-grid { display: grid; gap: 10px; }
.disk-card {
background: var(--card); border: 1px solid var(--card-border);
border-radius: 8px; padding: 12px 16px;
}
.disk-card .disk-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 6px;
}
.disk-card .disk-name { font-size: 13px; font-weight: 600; }
.disk-card .disk-pct { font-size: 13px; font-weight: 600; }
.disk-card .disk-detail { font-size: 11px; color: var(--dim); }
.disk-card .disk-io {
display: flex; gap: 16px; margin-top: 6px; font-size: 11px;
}
.disk-card .disk-io .r { color: var(--accent); }
.disk-card .disk-io .w { color: var(--orange); }
/* ===== Responsive ===== */
@media (max-width: 700px) {
.gauges-grid { grid-template-columns: repeat(2, 1fr); }
.mem-container { flex-direction: column; align-items: stretch; }
.main { padding: 14px; }
}
@media (max-width: 450px) {
:root { --sidebar-w: 48px; }
.sidebar .tab-btn { width: 36px; height: 36px; font-size: 16px; }
.main { padding: 10px; }
}
</style>
</head>
<body>
<!-- ===== Sidebar ===== -->
<nav class="sidebar" id="sidebar">
<div class="logo">📊</div>
<button class="tab-btn active" data-tab="cpu" onclick="switchTab('cpu')">
⚡<span class="tooltip">CPU</span>
</button>
<button class="tab-btn" data-tab="mem" onclick="switchTab('mem')">
🧠<span class="tooltip">内存</span>
</button>
<button class="tab-btn" data-tab="net" onclick="switchTab('net')">
🌐<span class="tooltip">网络</span>
</button>
<button class="tab-btn" data-tab="disk" onclick="switchTab('disk')">
💾<span class="tooltip">磁盘</span>
</button>
<button class="tab-btn" data-tab="app" onclick="switchTab('app')">
📱<span class="tooltip">应用</span>
</button>
<div style="flex:1"></div>
<button class="tab-btn" id="exportBtn" onclick="doExport()" title="一键导出所有历史数据">
⬇️<span class="tooltip">导出</span>
</button>
<button class="tab-btn" id="exportConfigBtn" onclick="openExportConfig()" title="设置导出目录路径">
⚙️<span class="tooltip">设置</span>
</button>
<div class="status-dot on" id="statusDot" title="实时"></div>
</nav>
<!-- ===== Main Content ===== -->
<div class="main">
<!-- ==================== CPU Panel ==================== -->
<div class="panel active" id="panel-cpu">
<h2>⚡ CPU <span class="badge" id="cpuModel"></span></h2>
<!-- Stat cards -->
<div class="card-row" style="margin-bottom:14px">
<div class="card" style="margin:0">
<div class="stat-item">
<div class="label">Governor</div>
<div class="value accent" id="cpuGov">—</div>
</div>
</div>
<div class="card" style="margin:0">
<div class="stat-item">
<div class="label">EPP</div>
<div class="value pink" id="cpuEpp">—</div>
</div>
</div>
<div class="card" style="margin:0">
<div class="stat-item">
<div class="label">🌡 温度</div>
<div class="value orange" id="cpuTemp">—</div>
</div>
</div>
<div class="card" style="margin:0">
<div class="stat-item">
<div class="label">频率范围</div>
<div class="value" id="cpuRange" style="font-size:15px">—</div>
</div>
</div>
<div class="card" style="margin:0">
<div class="stat-item">
<div class="label">📊 负载</div>
<div class="value" id="cpuLoad">—</div>
<div class="sub" id="cpuLoadSub"></div>
</div>
</div>
<div class="card" style="margin:0">
<div class="stat-item">
<div class="label">🌬 风扇</div>
<div class="value" id="cpuFan" style="font-size:15px">—</div>
</div>
</div>
<div class="card" style="margin:0">
<div class="stat-item">
<div class="label">⚙️ 运行模式</div>
<div class="value" id="cpuMode" style="font-size:13px">—</div>
</div>
</div>
<div class="card" style="margin:0">
<div class="stat-item">
<div class="label">📻 Boost</div>
<div class="value" id="cpuOC" style="font-size:14px">—</div>
<div class="sub" id="cpuOCSub"></div>
</div>
</div>
<div class="card" style="margin:0">
<div class="stat-item">
<div class="label">💠 核心</div>
<div class="value" id="cpuCores" style="font-size:15px">—</div>
</div>
</div>
</div>
<!-- Per-core gauges -->
<div class="gauges-grid" id="gaugesGrid"></div>
<!-- Frequency history -->
<div class="chart-container">
<div class="chart-header">
<span style="font-size:13px;font-weight:600">📈 频率历史 (60s)</span>
<div class="chart-legend" id="cpuLegend"></div>
</div>
<canvas id="cpuChart"></canvas>
</div>
<!-- Process ranking -->
<div class="card">
<div class="card-title">🔝 进程 CPU 占用排行 (Top 20)</div>
<div id="procTable" style="font-size:12px;max-height:400px;overflow-y:auto">
<table style="width:100%;border-collapse:collapse">
<thead><tr style="color:var(--dim);font-size:11px;text-align:left">
<th style="padding:3px 6px">PID</th>
<th>用户</th>
<th>CPU%</th>
<th>内存%</th>
<th>启动时间</th>
<th>进程</th>
</tr></thead>
<tbody id="procBody"></tbody>
</table>
</div>
</div>
<!-- Benchmark scores -->
<div class="card">
<div class="card-title">📊 性能评测</div>
<div class="card-row" id="benchGrid" style="margin-bottom:0"></div>
</div>
<!-- CPU comparison -->
<div class="card" style="margin-top:14px">
<div class="card-title">🆚 同级CPU对比 (PassMark多核)</div>
<div id="compareChart" style="margin-top:4px"></div>
</div>
</div>
<!-- ==================== Memory Panel ==================== -->
<div class="panel" id="panel-mem">
<h2>🧠 内存 <span class="badge" id="memTotal"></span></h2>
<div class="card" style="display:flex;gap:24px;align-items:center;flex-wrap:wrap">
<div class="mem-donut">
<canvas id="memDonut" width="320" height="320"></canvas>
<div class="center">
<div class="pct" id="memPct">—</div>
<div class="pct-label">已用</div>
</div>
</div>
<div class="mem-details" style="flex:1;min-width:200px">
<div class="row"><span>总计</span><span class="val" id="memTotalVal">—</span></div>
<div class="row"><span>已用</span><span class="val orange" id="memUsedVal">—</span></div>
<div class="row"><span>可用</span><span class="val green" id="memAvailVal">—</span></div>
<div class="row"><span>空闲</span><span class="val" id="memFreeVal">—</span></div>
<div class="row"><span>缓存</span><span class="val accent" id="memCachedVal">—</span></div>
<div class="row" style="border-top:1px solid rgba(48,54,61,.6);margin-top:4px;padding-top:8px">
<span>💱 交换分区</span><span class="val" id="memSwapVal">—</span>
</div>
<div class="row">
<span>交换已用</span><span class="val orange" id="memSwapUsedVal">—</span>
</div>
<div style="margin-top:8px">
<div class="progress-bar"><div class="fill" id="memBar" style="width:0%;background:var(--orange)"></div></div>
</div>
</div>
</div>
</div>
<!-- ==================== Network Panel ==================== -->
<div class="panel" id="panel-net">
<h2>🌐 网络 <span class="badge" id="netInfo"></span></h2>
<!-- Traffic Stats -->
<div class="card-row" style="margin-bottom:14px">
<div class="card" style="margin:0">
<div class="stat-item">
<div class="label">📊 总流量</div>
<div class="value" id="trafficTotal" style="font-size:15px">—</div>
<div class="sub">▼ <span id="trafficTotalRX">—</span> · ▲ <span id="trafficTotalTX">—</span></div>
</div>
</div>
<div class="card" style="margin:0">
<div class="stat-item">
<div class="label">📅 本月流量</div>
<div class="value accent" id="trafficMonth" style="font-size:15px">—</div>
<div class="sub">▼ <span id="trafficMonthRX">—</span> · ▲ <span id="trafficMonthTX">—</span></div>
</div>
</div>
<div class="card" style="margin:0">
<div class="stat-item">
<div class="label">📆 今日流量</div>
<div class="value green" id="trafficDay" style="font-size:15px">—</div>
<div class="sub">▼ <span id="trafficDayRX">—</span> · ▲ <span id="trafficDayTX">—</span></div>
</div>
</div>
</div>
<!-- App Ranking -->
<div class="card" style="margin-bottom:14px">
<div class="card-title">🏆 应用流量排行 (Top 15)</div>
<div id="trafficRanking" style="font-size:12px;color:var(--dim)">加载中…</div>
</div>
<!-- Interface cards -->
<div class="net-grid" id="netGrid"></div>
<div class="chart-container" style="margin-top:14px">
<div class="chart-header">
<span style="font-size:13px;font-weight:600">📈 流量趋势 (60s)</span>
<div class="chart-legend">
<span class="leg-item"><span class="leg-dot" style="background:var(--accent)"></span>下载</span>
<span class="leg-item"><span class="leg-dot" style="background:var(--orange)"></span>上传</span>
</div>
</div>
<canvas id="netChart"></canvas>
</div>
</div>
<!-- ==================== Disk Panel ==================== -->
<div class="panel" id="panel-disk">
<h2>💾 磁盘 <span class="badge" id="diskInfo"></span></h2>
<!-- Physical drive health -->
<div id="driveGrid" style="display:grid;gap:10px;margin-bottom:14px"></div>
<!-- Mount points -->
<div class="disk-grid" id="diskGrid"></div>
<div class="chart-container" style="margin-top:14px">
<div class="chart-header">
<span style="font-size:13px;font-weight:600">📈 磁盘 I/O (60s)</span>
<div class="chart-legend">
<span class="leg-item"><span class="leg-dot" style="background:var(--accent)"></span>读取</span>
<span class="leg-item"><span class="leg-dot" style="background:var(--orange)"></span>写入</span>
</div>
</div>
<canvas id="diskChart"></canvas>
</div>
</div>
<!-- ==================== Apps Panel ==================== -->
<div class="panel" id="panel-app">
<h2>📱 应用用量排行 <span class="badge" id="appPeriod"></span></h2>
<!-- Period selector -->
<div style="display:flex;gap:6px;margin-bottom:14px">
<button class="period-btn active" data-period="total" onclick="switchAppPeriod('total',this)" style="padding:4px 12px;border:1px solid var(--card-border);background:var(--card);color:var(--text);border-radius:6px;cursor:pointer;font-size:12px">📊 总计</button>
<button class="period-btn" data-period="year" onclick="switchAppPeriod('year',this)" style="padding:4px 12px;border:1px solid var(--card-border);background:var(--card);color:var(--text);border-radius:6px;cursor:pointer;font-size:12px">📅 年</button>
<button class="period-btn" data-period="month" onclick="switchAppPeriod('month',this)" style="padding:4px 12px;border:1px solid var(--card-border);background:var(--card);color:var(--text);border-radius:6px;cursor:pointer;font-size:12px">📆 月</button>
<button class="period-btn" data-period="week" onclick="switchAppPeriod('week',this)" style="padding:4px 12px;border:1px solid var(--card-border);background:var(--card);color:var(--text);border-radius:6px;cursor:pointer;font-size:12px">📋 周</button>
<button class="period-btn" data-period="day" onclick="switchAppPeriod('day',this)" style="padding:4px 12px;border:1px solid var(--card-border);background:var(--card);color:var(--text);border-radius:6px;cursor:pointer;font-size:12px">📌 日</button>
</div>
<!-- Combined ranking -->
<div class="card" style="margin-bottom:14px">
<div class="card-title">🏆 综合排行 (CPU + 内存 + 网络 加权)</div>
<div id="appRanking"></div>
</div>
</div>
<div style="text-align:center;font-size:11px;color:var(--dim);padding:12px 0;border-top:1px solid var(--card-border);margin-top:8px">
<span id="footerInfo">fnmon · 正在更新…</span>
</div>
</div>
<script>
// ==================== Config ====================
const CORE_COLORS = ['#58a6ff', '#3fb950', '#d29922', '#bc8cff', '#f0883e', '#f85149'];
// ==================== Tab Switching ====================
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
document.querySelectorAll('.panel').forEach(p => p.classList.toggle('active', p.id === 'panel-' + name));
}
let appPeriod = 'total';
function switchAppPeriod(p, btn) {
appPeriod = p;
document.querySelectorAll('.period-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Reload app ranking with new period
fetch('/api/apps/' + p).then(r => r.json()).then(data => renderAppRanking(data));
}
// ==================== Utility ====================
function fmtBytes(b) {
if (!b) return '0 B';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0; let v = b;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return v.toFixed(i === 0 ? 0 : 1) + ' ' + u[i];
}
function fmtSpeed(bps) {
if (!bps || bps < 0) return '0 b/s';
const u = ['', 'K', 'M', 'G'];
let i = 0; let v = bps * 8;
while (v >= 1000 && i < u.length - 1) { v /= 1000; i++; }
return v.toFixed(i === 0 ? 0 : 1) + ' ' + u[i] + 'bps';
}
function fmtFreq(mhz) {
if (mhz >= 1000) return (mhz / 1000).toFixed(2) + ' GHz';
return mhz + ' MHz';
}
function minMax(v, lo, hi) { return Math.min(Math.max(v, lo), hi); }
// ==================== Data Store ====================
const MAX_HIST = 30;
let cpuHist = {};
let netHistRx = { _main: [] };
let netHistTx = { _main: [] };
let diskHistR = {};
let diskHistW = {};
let maxFreq = 3700;
let coreCount = 0;
// ==================== CPU Rendering ====================
function buildGauges() {
const grid = document.getElementById('gaugesGrid');
grid.innerHTML = '';
for (let i = 0; i < coreCount; i++) {
const idx = i % CORE_COLORS.length;
const div = document.createElement('div');
div.className = 'gauge-wrap'; div.id = `gauge-${i}`;
div.innerHTML = `
<div class="core-label">CPU${i}</div>
<div class="freq-val" id="freq-${i}">— <span class="freq-unit">MHz</span></div>
<div class="bar-wrapper"><div class="bar-fill" id="bar-${i}"></div></div>`;
grid.appendChild(div);
}
}
function updateCPU(data) {
const d = data.cpu;
if (!d) return;
document.getElementById('cpuGov').textContent = d.governor || '—';
document.getElementById('cpuEpp').textContent = d.epp || '—';
if (d.temp_max) {
document.getElementById('cpuTemp').textContent = d.temp_max + '°C';
}
document.getElementById('cpuRange').textContent = `${d.min_freq}–${d.max_freq} MHz`;
if (d.load_1 !== undefined) {
document.getElementById('cpuLoad').textContent = d.load_1.toFixed(2);
document.getElementById('cpuLoadSub').textContent = `${d.load_5.toFixed(2)} / ${d.load_15.toFixed(2)}`;
}
// Model
if (d.model_short) document.getElementById('cpuModel').textContent = d.model_short;
// Fan
if (d.fan_rpm) {
document.getElementById('cpuFan').innerHTML = `${d.fan_rpm.rpm} <span style="font-size:12px;color:var(--dim)">RPM</span>`;
} else {
document.getElementById('cpuFan').innerHTML = '<span style="font-size:12px;color:var(--dim)">无传感器</span>';
}
// Mode
document.getElementById('cpuMode').textContent = `${d.governor || '?'} / ${d.epp || '?'}`;
// OC / Turbo
if (d.oc) {
document.getElementById('cpuOC').textContent = '🚀 已超频';
document.getElementById('cpuOCSub').textContent = '';
} else if (d.turbo) {
document.getElementById('cpuOC').textContent = '⚡ Turbo Boost';
document.getElementById('cpuOCSub').textContent = `基频 ${d.base_freq}MHz`;
} else {
document.getElementById('cpuOC').textContent = '基频运行';
document.getElementById('cpuOCSub').textContent = `${d.base_freq || d.min_freq}MHz`;
}
// Core count
if (d.physical_cores) {
const ht = d.threads > d.physical_cores ? ` (HT: ${d.threads}T)` : '';
document.getElementById('cpuCores').innerHTML = `${d.physical_cores}C${ht}`;
}
// Benchmark
if (d.bench) {
const b = d.bench;
const items = [
{label:'PassMark 单核', v:b.passmark_single},
{label:'PassMark 多核', v:b.passmark_multi, hl:true},
{label:'Cinebench R23 单', v:b.cinebench_r23_single},
{label:'Cinebench R23 多', v:b.cinebench_r23_multi},
{label:'Geekbench 6 单', v:b.geekbench6_single},
{label:'Geekbench 6 多', v:b.geekbench6_multi},
];
document.getElementById('benchGrid').innerHTML = items.map(i =>
`<div class="stat-item" style="padding:4px 0"><div class="label">${i.label}</div>
<div class="value" style="font-size:14px;${i.hl?'color:var(--accent)':''}">${i.v}</div></div>`
).join('');
}
// Comparison chart
if (d.compare) {
const your = d.compare.find(c => c.name.includes('←你'));
const myScore = your ? your.score : (d.bench ? d.bench.passmark_multi : 5500);
const maxScore = Math.max(...d.compare.map(c => c.score), 1);
document.getElementById('compareChart').innerHTML = d.compare.map(c => {
const pct = Math.round(c.score / maxScore * 100);
const isMe = c.name.includes('←你');
const barColor = isMe ? 'var(--accent)' : 'var(--card-border)';
return `<div style="display:flex;align-items:center;gap:8px;margin-bottom:3px">
<span style="width:120px;font-size:11px;text-align:right;color:${isMe?'var(--accent)':'var(--dim)'}">${c.name}</span>
<div style="flex:1;height:8px;background:#21262d;border-radius:4px;overflow:hidden">
<div style="height:100%;width:${pct}%;background:${barColor};border-radius:4px;${isMe?'box-shadow:0 0 6px var(--accent)':''}"></div>
</div>
<span style="width:50px;font-size:11px;text-align:right;font-weight:600">${c.score}</span>
<span style="width:30px;font-size:10px;color:var(--dim)">${c.tdp}W</span>
<span style="font-size:10px;color:var(--dim)">${c.note}</span>
</div>`;
}).join('');
}
if (d.max_freq > 0) maxFreq = d.max_freq;
// Gauges
if (d.freqs) {
for (const [core, freq] of Object.entries(d.freqs)) {
const idx = parseInt(core.replace('cpu', ''));
const el = document.getElementById(`freq-${idx}`);
if (!el) continue;
const pct = maxFreq > 0 ? minMax((freq / maxFreq) * 100, 0, 100) : 0;
el.innerHTML = `${freq} <span class="freq-unit">MHz</span>`;
const bar = document.getElementById(`bar-${idx}`);
if (bar) {
bar.style.width = pct + '%';
bar.style.background = pct > 80 ? CORE_COLORS[idx % CORE_COLORS.length].replace(')', ',.8)').replace('rgb','rgba') || CORE_COLORS[idx % CORE_COLORS.length] : CORE_COLORS[idx % CORE_COLORS.length];
if (pct > 70) bar.style.background = `linear-gradient(90deg, ${CORE_COLORS[idx % CORE_COLORS.length]}, #f85149)`;
}
// History
if (!cpuHist[core]) cpuHist[core] = [];
cpuHist[core].push(freq);
if (cpuHist[core].length > MAX_HIST) cpuHist[core].shift();
}
drawCPUChart();
}
}
function drawCPUChart() {
const canvas = document.getElementById('cpuChart');
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = canvas.clientWidth * (window.devicePixelRatio || 1);
canvas.height = canvas.clientHeight * (window.devicePixelRatio || 1);
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth, h = canvas.clientHeight;
const pad = { top: 8, right: 40, bottom: 22, left: 44 };
const pw = w - pad.left - pad.right, ph = h - pad.top - pad.bottom;
ctx.clearRect(0, 0, w * dpr, h * dpr);
ctx.scale(dpr, dpr);
ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1;
ctx.font = '10px sans-serif'; ctx.fillStyle = '#8b949e';
for (let i = 0; i <= 4; i++) {
const y = pad.top + (ph * (1 - i / 4));
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(w - pad.right, y); ctx.stroke();
ctx.fillText(Math.round((i / 4) * maxFreq), 2, y + 4);
}
const keys = Object.keys(cpuHist).sort();
const len = keys.length > 0 ? cpuHist[keys[0]].length : 0;
if (len < 2) { ctx.restore(); return; }
for (let ci = 0; ci < keys.length; ci++) {
const data = cpuHist[keys[ci]];
if (!data || data.length < 2) continue;
ctx.strokeStyle = CORE_COLORS[ci % CORE_COLORS.length];
ctx.lineWidth = 1.5; ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = pad.left + (i / (data.length - 1)) * pw;
const y = pad.top + ph * (1 - (data[i] / (maxFreq || 3700)));
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Right edge label
const val = data[data.length - 1];
ctx.fillStyle = CORE_COLORS[ci % CORE_COLORS.length];
ctx.font = '600 10px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`${val}`, w - pad.right + 4, pad.top + ph * (1 - (val / (maxFreq || 3700))) + 3);
}
}
// ==================== Memory Rendering ====================
function updateMem(data) {
const d = data.mem;
if (!d) return;
document.getElementById('memTotal').textContent = fmtBytes(d.total * 1024);
document.getElementById('memTotalVal').textContent = fmtBytes(d.total * 1024);
document.getElementById('memUsedVal').textContent = fmtBytes(d.used * 1024);
document.getElementById('memAvailVal').textContent = fmtBytes(d.available * 1024);
document.getElementById('memFreeVal').textContent = fmtBytes(d.free * 1024);
document.getElementById('memCachedVal').textContent = fmtBytes((d.cached + d.buffers) * 1024);
document.getElementById('memSwapVal').textContent = fmtBytes(d.swap_total * 1024);
document.getElementById('memSwapUsedVal').textContent = fmtBytes(d.swap_used * 1024);
const pct = d.used_pct || 0;
document.getElementById('memPct').textContent = pct + '%';
document.getElementById('memBar').style.width = pct + '%';
drawMemDonut(pct);
}
function drawMemDonut(pct) {
const canvas = document.getElementById('memDonut');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const w = 160, h = 160;
ctx.clearRect(0, 0, 320, 320);
ctx.scale(2, 2);
const cx = w / 2, cy = h / 2, r = 62, lw = 18;
const angle = (pct / 100) * Math.PI * 2;
// Background
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = '#21262d'; ctx.lineWidth = lw; ctx.stroke();
// Used
ctx.beginPath(); ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + angle);
const grad = ctx.createConicGradient(-Math.PI / 2, cx, cy);
grad.addColorStop(0, '#f0883e'); grad.addColorStop(1, '#f85149');
ctx.strokeStyle = grad; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.stroke();
}
// ==================== Network Rendering ====================
function updateNet(data) {
const d = data.net;
if (!d || !d.interfaces) return;
const grid = document.getElementById('netGrid');
// Render cards
grid.innerHTML = '';
let mainRx = 0, mainTx = 0;
for (let i = 0; i < d.interfaces.length; i++) {
const iface = d.interface_names[i];
const info = d.interfaces[i];
const isMain = iface.startsWith('eno') || iface.startsWith('enx');
if (isMain) { mainRx += info.rx_speed || 0; mainTx += info.tx_speed || 0; }
const icon = iface.startsWith('eno') || iface.startsWith('enx') ? '🔌' :
iface.startsWith('br-') ? '🔀' :
iface.startsWith('docker') ? '🐳' :
iface.startsWith('tun') ? '🔒' : '🔗';
const card = document.createElement('div');
card.className = 'net-card';
card.innerHTML = `
<div class="iface-icon">${icon}</div>
<div class="iface-info">
<div class="iface-name">${info.display || iface} <span style="font-size:10px;color:var(--dim)">${iface}</span></div>
<div class="iface-traffic">
<span class="traffic-item"><span class="arrow" style="color:var(--accent)">▼</span> <span class="speed" style="color:var(--accent)">${fmtSpeed(info.rx_speed)}</span> <span class="total">${fmtBytes(info.rx_bytes)}</span></span>
<span class="traffic-item"><span class="arrow" style="color:var(--orange)">▲</span> <span class="speed" style="color:var(--orange)">${fmtSpeed(info.tx_speed)}</span> <span class="total">${fmtBytes(info.tx_bytes)}</span></span>
</div>
</div>`;
grid.appendChild(card);
}
// History for main interfaces
netHistRx._main.push(mainRx);
netHistTx._main.push(mainTx);
if (netHistRx._main.length > MAX_HIST) netHistRx._main.shift();
if (netHistTx._main.length > MAX_HIST) netHistTx._main.shift();
drawNetChart();
document.getElementById('netInfo').textContent = `${d.interfaces.length} 个接口`;
}
function drawNetChart() {
const canvas = document.getElementById('netChart');
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = canvas.clientWidth * (window.devicePixelRatio || 1);
canvas.height = canvas.clientHeight * (window.devicePixelRatio || 1);
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth, h = canvas.clientHeight;
const pad = { top: 8, right: 8, bottom: 22, left: 50 };
const pw = w - pad.left - pad.right, ph = h - pad.top - pad.bottom;
ctx.clearRect(0, 0, w * dpr, h * dpr);
ctx.scale(dpr, dpr);
const rx = netHistRx._main, tx = netHistTx._main;
const len = rx.length;
if (len < 2) return;
const maxV = Math.max(...rx, ...tx, 1);
ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1;
ctx.font = '10px sans-serif'; ctx.fillStyle = '#8b949e';
for (let i = 0; i <= 3; i++) {
const y = pad.top + (ph * (1 - i / 3));
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(w - pad.right, y); ctx.stroke();
ctx.fillText(fmtSpeed((i / 3) * maxV), 2, y + 4);
}
// RX line
ctx.strokeStyle = '#58a6ff'; ctx.lineWidth = 2; ctx.beginPath();
for (let i = 0; i < len; i++) {
const x = pad.left + (i / (len - 1)) * pw;
const y = pad.top + ph * (1 - rx[i] / maxV);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
} ctx.stroke();
// TX line
ctx.strokeStyle = '#f0883e'; ctx.lineWidth = 2; ctx.beginPath();
for (let i = 0; i < len; i++) {
const x = pad.left + (i / (len - 1)) * pw;
const y = pad.top + ph * (1 - tx[i] / maxV);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
} ctx.stroke();
}
// ==================== Traffic Stats ====================
function updateTraffic(data) {
const t = data.traffic;
if (!t) return;
document.getElementById('trafficTotal').textContent = fmtBytes(t.total_rx + t.total_tx);
document.getElementById('trafficTotalRX').textContent = fmtBytes(t.total_rx);
document.getElementById('trafficTotalTX').textContent = fmtBytes(t.total_tx);
document.getElementById('trafficMonth').textContent = fmtBytes(t.monthly_rx + t.monthly_tx);
document.getElementById('trafficMonthRX').textContent = fmtBytes(t.monthly_rx);
document.getElementById('trafficMonthTX').textContent = fmtBytes(t.monthly_tx);
document.getElementById('trafficDay').textContent = fmtBytes(t.daily_rx + t.daily_tx);
document.getElementById('trafficDayRX').textContent = fmtBytes(t.daily_rx);
document.getElementById('trafficDayTX').textContent = fmtBytes(t.daily_tx);
// Ranking
if (t.containers && t.containers.length) {
const maxW = Math.max(...t.containers.map(c => c.rx + c.tx), 1);
const html = t.containers.map((c, i) => {
const pct = Math.round((c.rx + c.tx) / maxW * 100);
return `<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<span style="width:20px;text-align:right;font-weight:600;color:var(--dim)">${i+1}.</span>
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${c.name}</span>
<div style="flex:2;min-width:40px;height:6px;background:#21262d;border-radius:3px;overflow:hidden">
<div style="height:100%;width:${pct}%;background:var(--accent);border-radius:3px;opacity:.7"></div>
</div>
<span style="width:80px;text-align:right;font-variant-numeric:tabular-nums;font-size:11px">▼${fmtBytes(c.rx)} ▲${fmtBytes(c.tx)}</span>
</div>`;
}).join('');
document.getElementById('trafficRanking').innerHTML = html;
}
}
// ==================== Process Ranking ====================
function updateProcs(data) {
const procs = data.procs;
if (!procs || !procs.length) return;
const tbody = document.getElementById('procBody');
tbody.innerHTML = procs.map((p, i) => {
const cpu = parseFloat(p.cpu) || 0;
const color = cpu > 50 ? '#f85149' : cpu > 20 ? '#f0883e' : cpu > 5 ? '#d29922' : 'var(--text)';
return `<tr style="border-bottom:1px solid rgba(48,54,61,.3);line-height:22px">
<td style="padding:2px 6px;color:var(--dim)">${p.pid}</td>
<td style="font-size:11px;color:var(--dim)">${p.user}</td>
<td style="color:${color};font-weight:600">${p.cpu}</td>
<td style="font-size:11px;color:var(--dim)">${p.mem}%</td>
<td style="font-size:11px;color:var(--dim)">${p.start}</td>
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${p.cmd}">${p.cmd}</td>
</tr>`;
}).join('');
}
// ==================== App Ranking ====================
function renderAppRanking(apps) {
const el = document.getElementById('appRanking');
if (!apps || !apps.length) { el.innerHTML = '<span style="color:var(--dim)">收集数据中…</span>'; return; }
const maxScore = Math.max(...apps.map(a => a.score), 0.01);
document.getElementById('appPeriod').textContent =
appPeriod === 'day' ? '今日' : appPeriod === 'week' ? '本周' :
appPeriod === 'month' ? '本月' : appPeriod === 'year' ? '今年' : '总计';
el.innerHTML = apps.map((a, i) => {
const pct = Math.round(a.score / maxScore * 100);
const med = i < 3 ? `var(--green)` : 'var(--accent)';
return `<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;font-size:12px">
<span style="width:18px;text-align:right;font-weight:600;color:var(--dim)">${i+1}</span>
<span style="flex:1;min-width:60px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${a.app}</span>
<div style="flex:2;height:8px;background:#21262d;border-radius:4px;overflow:hidden">
<div style="height:100%;width:${pct}%;background:${med};border-radius:4px;opacity:.8"></div>
</div>
<span style="width:40px;text-align:right;font-size:11px;color:var(--dim)">${a.score.toFixed(1)}</span>
<span style="width:55px;text-align:right;font-size:11px;color:var(--accent);font-weight:600">CPU ${a.cpu?.toFixed(1)||0}%</span>
<span style="width:55px;text-align:right;font-size:11px;color:var(--orange)">MEM ${a.mem?.toFixed(0)||0}M</span>
<span style="width:60px;text-align:right;font-size:11px;color:var(--dim)">${fmtBytes(a.net_total||0)}</span>
</div>`;
}).join('');
}
function updateApps(data) {
if (data.apps) renderAppRanking(data.apps);
}
// ==================== Disk Rendering ====================
function updateDisk(data) {
const d = data.disk;
if (!d) return;
// === Physical drive health ===
if (d.drives && d.drives.length) {
const driveGrid = document.getElementById('driveGrid');
driveGrid.innerHTML = d.drives.map(drive => {
const h = drive.health || {};
const icon = drive.type === 'NVMe' ? '⚡' : drive.type === 'SSD' ? '💿' : '🖴';
// Health color
let statusColor = 'var(--green)';
let statusIcon = '✅';
if (h.status === 'FAILED') { statusColor = 'var(--red)'; statusIcon = '🔴'; }
else if (h.status === 'UNKNOWN') { statusColor = 'var(--dim)'; statusIcon = '❓'; }
// Temp color
const temp = h.temp || 0;
let tempColor = 'var(--green)';
if (temp > 55) tempColor = 'var(--red)';
else if (temp > 45) tempColor = 'var(--orange)';
const rows = [
`<div style="display:flex;align-items:center;gap:4px;margin-bottom:2px">
<span>${icon}</span>
<span style="font-weight:600;font-size:13px">${drive.model}</span>
<span style="font-size:11px;color:var(--dim);margin-left:4px">${drive.size} ${drive.type} ${drive.interface}</span>
</div>`,
`<div style="display:flex;gap:16px;flex-wrap:wrap;font-size:12px;margin-top:4px">`,
h.temp !== undefined ? `<span>🌡 <span style="color:${tempColor};font-weight:600">${h.temp}°C</span></span>` : '',
h.status ? `<span>${statusIcon} <span style="color:${statusColor};font-weight:600">${h.status}</span></span>` : '',
h.life_remaining !== undefined ? `<span>❤️ 寿命剩余 <span style="color:${h.life_remaining>50?'var(--green)':h.life_remaining>20?'var(--orange)':'var(--red)'};font-weight:600">${h.life_remaining}%</span></span>` : '',
h.reallocated !== undefined ? `<span>⚠️ 坏道: <span style="color:${h.reallocated>0?'var(--orange)':'var(--green)'};font-weight:600">${h.reallocated}</span></span>` : '',
h.available_spare !== undefined ? `<span>💾 备用块: <span style="color:${h.available_spare>50?'var(--green)':'var(--orange)'};font-weight:600">${h.available_spare}%</span></span>` : '',
h.power_on_days !== undefined ? `<span>⏱ 通电 ${h.power_on_days}天</span>` : '',
h.data_written ? `<span>📤 写入 ${h.data_written}</span>` : '',
`</div>`
].filter(s => s).join('');
return `<div class="card" style="margin:0">${rows}</div>`;
}).join('');
}
// === Mount points ===
const grid = document.getElementById('diskGrid');
grid.innerHTML = '';
if (d.mounts) {
for (const m of d.mounts) {
const pct = m.used_pct || 0;
const color = pct > 90 ? 'var(--red)' : pct > 70 ? 'var(--orange)' : 'var(--accent)';
const card = document.createElement('div');
card.className = 'disk-card';
const ioR = d.devices ? Object.values(d.devices).reduce((s, v) => s + (v.read_speed || 0), 0) : 0;
const ioW = d.devices ? Object.values(d.devices).reduce((s, v) => s + (v.write_speed || 0), 0) : 0;
card.innerHTML = `
<div class="disk-header">
<span class="disk-name">${m.mount}</span>
<span class="disk-pct" style="color:${color}">${pct}%</span>
</div>
<div class="progress-bar"><div class="fill" style="width:${pct}%;background:${color}"></div></div>
<div class="disk-detail">${fmtBytes(m.used)} / ${fmtBytes(m.total)}</div>`;
grid.appendChild(card);
}
}
// I/O history for main devices
if (d.devices) {
let totalR = 0, totalW = 0;
for (const [name, dev] of Object.entries(d.devices)) {
if (name.startsWith('sd') || name.startsWith('nvme')) {
totalR += dev.read_speed || 0;
totalW += dev.write_speed || 0;
}
}
if (!diskHistR._main) diskHistR._main = [];
if (!diskHistW._main) diskHistW._main = [];
diskHistR._main.push(totalR);
diskHistW._main.push(totalW);
if (diskHistR._main.length > MAX_HIST) diskHistR._main.shift();
if (diskHistW._main.length > MAX_HIST) diskHistW._main.shift();
}
drawDiskChart();
document.getElementById('diskInfo').textContent = `${d.mounts ? d.mounts.length : 0} 个挂载点`;
}
function drawDiskChart() {
const canvas = document.getElementById('diskChart');
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = canvas.clientWidth * (window.devicePixelRatio || 1);
canvas.height = canvas.clientHeight * (window.devicePixelRatio || 1);
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth, h = canvas.clientHeight;
const pad = { top: 8, right: 8, bottom: 22, left: 50 };
const pw = w - pad.left - pad.right, ph = h - pad.top - pad.bottom;
ctx.clearRect(0, 0, w * dpr, h * dpr);
ctx.scale(dpr, dpr);
const rd = diskHistR._main || [], wd = diskHistW._main || [];
const len = rd.length;
if (len < 2) return;
const maxV = Math.max(...rd, ...wd, 1);
ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1;
ctx.font = '10px sans-serif'; ctx.fillStyle = '#8b949e';
for (let i = 0; i <= 3; i++) {
const y = pad.top + (ph * (1 - i / 3));
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(w - pad.right, y); ctx.stroke();
ctx.fillText(fmtSpeed(i / 3 * maxV), 2, y + 4);
}
// Read
ctx.strokeStyle = '#58a6ff'; ctx.lineWidth = 2; ctx.beginPath();
for (let i = 0; i < len; i++) {
const x = pad.left + (i / (len - 1)) * pw;
const y = pad.top + ph * (1 - (rd[i] || 0) / maxV);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
} ctx.stroke();
// Write
ctx.strokeStyle = '#f0883e'; ctx.lineWidth = 2; ctx.beginPath();
for (let i = 0; i < len; i++) {
const x = pad.left + (i / (len - 1)) * pw;
const y = pad.top + ph * (1 - (wd[i] || 0) / maxV);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
} ctx.stroke();
}
// ==================== Traffic Stats Rendering ====================
function updateTraffic(data) {
const d = data.traffic;
if (!d) return;
// Total
const totalBytes = (d.total_rx || 0) + (d.total_tx || 0);
document.getElementById('trafficTotal').textContent = fmtBytes(totalBytes);
document.getElementById('trafficTotalRX').textContent = fmtBytes(d.total_rx || 0);
document.getElementById('trafficTotalTX').textContent = fmtBytes(d.total_tx || 0);
// Monthly
const monthBytes = (d.monthly_rx || 0) + (d.monthly_tx || 0);
document.getElementById('trafficMonth').textContent = fmtBytes(monthBytes);
document.getElementById('trafficMonthRX').textContent = fmtBytes(d.monthly_rx || 0);
document.getElementById('trafficMonthTX').textContent = fmtBytes(d.monthly_tx || 0);
// Daily
const dayBytes = (d.daily_rx || 0) + (d.daily_tx || 0);
document.getElementById('trafficDay').textContent = fmtBytes(dayBytes);
document.getElementById('trafficDayRX').textContent = fmtBytes(d.daily_rx || 0);
document.getElementById('trafficDayTX').textContent = fmtBytes(d.daily_tx || 0);
// App Ranking
const rankingEl = document.getElementById('trafficRanking');
if (d.containers && d.containers.length > 0) {
let html = '<table style="width:100%;border-collapse:collapse">';
html += '<thead><tr style="font-size:11px;color:var(--dim);border-bottom:1px solid var(--card-border)">';
html += '<th style="text-align:left;padding:4px 6px">#</th>';
html += '<th style="text-align:left;padding:4px 6px">应用</th>';
html += '<th style="text-align:right;padding:4px 6px">▼ 下载</th>';
html += '<th style="text-align:right;padding:4px 6px">▲ 上传</th>';
html += '<th style="text-align:right;padding:4px 6px">合计</th>';
html += '<th style="text-align:right;padding:4px 6px">实时速率</th>';
html += '</tr></thead><tbody>';
const maxTotal = d.containers[0] ? (d.containers[0].rx + d.containers[0].tx) : 1;
const topColors = ['#f0883e','#d29922','#58a6ff','#3fb950','#bc8cff'];
for (let i = 0; i < d.containers.length; i++) {
const c = d.containers[i];
const totalC = (c.rx || 0) + (c.tx || 0);
const pct = maxTotal > 0 ? (totalC / maxTotal * 100) : 0;
const rxStr = fmtBytes(c.rx || 0);
const txStr = fmtBytes(c.tx || 0);
const totalStr = fmtBytes(totalC);
const speedStr = fmtSpeed((c.rx_speed || 0) + (c.tx_speed || 0));
const rankBg = i < topColors.length ? topColors[i] : 'var(--dim)';
html += `<tr style="border-bottom:1px solid rgba(48,54,61,.3)">`;
html += `<td style="padding:6px 6px;font-weight:700;color:${rankBg};font-size:11px">${i + 1}</td>`;
html += `<td style="padding:6px 6px;font-weight:600;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px" title="${c.name}">${c.name}</td>`;
html += `<td style="padding:6px 6px;text-align:right;font-size:11px;color:var(--accent);font-variant-numeric:tabular-nums">${rxStr}</td>`;
html += `<td style="padding:6px 6px;text-align:right;font-size:11px;color:var(--orange);font-variant-numeric:tabular-nums">${txStr}</td>`;
html += `<td style="padding:6px 6px;text-align:right;font-size:11px;font-weight:600;font-variant-numeric:tabular-nums">${totalStr}</td>`;
html += `<td style="padding:6px 6px;text-align:right;font-size:11px;color:var(--green);font-variant-numeric:tabular-nums">${speedStr}</td>`;
html += '</tr>';
// Mini progress bar per row
html += `<tr style="border:none"><td colspan="6" style="padding:0 6px 6px 6px"><div class="progress-bar" style="height:3px"><div class="fill" style="width:${pct.toFixed(1)}%;background:${i === 0 ? 'var(--orange)' : 'var(--active-tab)'};opacity:${Math.max(0.3, 1 - i * 0.07)}"></div></div></td></tr>`;
}
html += '</tbody></table>';
rankingEl.innerHTML = html;
} else {
rankingEl.innerHTML = '<span style="color:var(--dim)">暂无容器数据</span>';
}
}
// ==================== SSE ====================
function connectSSE() {
const dot = document.getElementById('statusDot');
const evtSource = new EventSource('/stream');
evtSource.onopen = () => { dot.className = 'status-dot on'; };
evtSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
updateCPU(data);
updateMem(data);
updateNet(data);
updateDisk(data);
updateTraffic(data);
updateProcs(data);
updateApps(data);
const t = new Date(data.ts * 1000);
document.getElementById('footerInfo').textContent = `fnmon · 更新于 ${t.toLocaleTimeString()}`;
} catch (err) {}
};
evtSource.onerror = () => { dot.className = 'status-dot off'; };
}
// ==================== Init ====================
async function init() {
try {
const res = await fetch('/api/all');
const data = await res.json();
if (data.cpu && data.cpu.freqs) {
coreCount = Object.keys(data.cpu.freqs).length;
maxFreq = data.cpu.hw_max || data.cpu.max_freq || 3700;
buildGauges();
// Build legend
const legend = document.getElementById('cpuLegend');
legend.innerHTML = '';
for (let i = 0; i < coreCount; i++) {
const item = document.createElement('span');
item.className = 'leg-item';
item.innerHTML = `<span class="leg-dot" style="background:${CORE_COLORS[i % CORE_COLORS.length]}"></span>CPU${i}`;
legend.appendChild(item);
}
updateCPU(data);
}
updateMem(data);
updateNet(data);
updateDisk(data);
updateTraffic(data);
updateProcs(data);
updateApps(data);
} catch (err) {
console.error('Init error:', err);
}
connectSSE();
}
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { drawCPUChart(); drawNetChart(); drawDiskChart(); }, 200);
});
document.addEventListener('DOMContentLoaded', init);
/* ===== Export Functions ===== */
async function doExport() {
const btn = document.getElementById('exportBtn');
btn.style.opacity = '0.5';
btn.title = '正在导出…';
try {
const r = await fetch('/api/export');
const data = await r.json();
if (data.ok) {
showToast(`✅ 导出成功!\n文件: ${data.filepath}\n大小: ${(data.size_bytes/1024).toFixed(1)}KB\n${data.records.app_raw} 条应用记录`);
} else {
showToast('❌ 导出失败: ' + (data.error || '未知错误'));
}
} catch (e) {
showToast('❌ 请求失败: ' + e.message);
}
btn.style.opacity = '1';
btn.title = '一键导出所有历史数据';
}
async function openExportConfig() {
// Fetch current path
const r = await fetch('/api/export/config');
const data = await r.json();
const currentPath = data.path || '/var/exports';
// Create modal overlay
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:9999;display:flex;align-items:center;justify-content:center;';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
const modal = document.createElement('div');
modal.style.cssText = 'background:#161b22;border:1px solid #30363d;border-radius:12px;padding:28px;width:480px;max-width:90vw;color:#e6edf3;font-size:14px;';
modal.onclick = (e) => e.stopPropagation();
modal.innerHTML = `
<h3 style="margin:0 0 16px;font-size:18px">⚙️ 导出设置</h3>
<label style="display:block;margin-bottom:6px;color:#8b949e">导出目录路径</label>
<input id="exportPathInput" type="text" value="${currentPath}"
style="width:100%;padding:10px 14px;background:#0d1117;border:1px solid #30363d;border-radius:8px;color:#e6edf3;font-size:14px;font-family:monospace;box-sizing:border-box;">
<p style="margin:8px 0 0;color:#8b949e;font-size:12px">⚠️ 必须是容器内可写的绝对路径。建议挂载到NAS目录:<code>-v /vol2/exports:/var/exports</code></p>
<div style="display:flex;gap:10px;margin-top:20px;justify-content:flex-end">
<button onclick="this.closest('[style]').closest('[style]').remove()"
style="padding:8px 20px;border:1px solid #30363d;border-radius:8px;background:transparent;color:#e6edf3;cursor:pointer">取消</button>
<button onclick="saveExportPath()"
style="padding:8px 20px;border:none;border-radius:8px;background:#1f6feb;color:#fff;cursor:pointer">保存</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
async function saveExportPath() {
const input = document.getElementById('exportPathInput');
const path = input.value.trim();
if (!path) return;
try {
const r = await fetch('/api/export/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path})
});
const data = await r.json();
if (data.ok) {
showToast('✅ 导出路径已保存: ' + path);
document.querySelector('[style*="background:rgba(0,0,0,0.6)"]')?.remove();
} else {
showToast('❌ ' + (data.error || '保存失败'));
}
} catch (e) {
showToast('❌ 请求失败: ' + e.message);
}
}
function showToast(msg) {
const existing = document.querySelector('.fnmon-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'fnmon-toast';
toast.style.cssText = 'position:fixed;bottom:30px;left:50%;transform:translateX(-50%);background:#1f2937;color:#e6edf3;padding:14px 24px;border-radius:10px;font-size:14px;z-index:99999;white-space:pre-line;line-height:1.6;box-shadow:0 8px 32px rgba(0,0,0,0.4);border:1px solid #30363d;max-width:80vw;';
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity .3s'; setTimeout(() => toast.remove(), 300); }, 4000);
}
</script>
</body>
</html>