新增简易的8080面板😊
This commit is contained in:
8
store/@{FutureOSS}/dashboard/SIGNATURE
Normal file
8
store/@{FutureOSS}/dashboard/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "vn4hpZQMQTX0d78Wlze2wtTHjN91qn1PIvsRTK7ZFVm8lZ3eQHrZz9X0uDWcKKjxf5FCI/UVKQOqLwYkHiGhcS7d7+v6UKKKIYph+aftHQRrEcOQtrSnrmDQrqSjEdL3mjkl0KTIwqkFySxVNn9ssmL16JCOtWpWpKU5CnKWVrbeEKvs6yZJrmVVr9C7iDGsNq0/aS3oPDI4vg1iaTYgg/2Sh1smJ0jNtE5EsCq78fcyUcSWTziwq8RnJvFsx8LP3cxacC1QuZIP3hTIrpnApAj0KqSTRDLKY7d7rsQAHgDlnbQfYVtA8x94x91R5ybeDpXwYPSwWMpb7P/7XBDJ5GKL56iFUCV0tceHNK9yyjaXdhf2oUTxfoC4ONOTnkmnP2pZ6vRLjd/0WX7qA0XUTmZtewWur1BnZeZwzOjI5K8IYCda5WKXLVyrH64XmBEAwkEu18LIO9xI+DnhbM7rR9/xO+cXHkOYtKgAJMHCzgi6o6tw/UgS9K0myoMeGg58gYaDIVbXpxpf3rHSyFQAwauI67oye7ZxNxJgKnnOtX92cpQLHDfML8psd+sAIuBazxqxe484qzF2k0F5ZZMP17V6Yd3UWUkvWMoKlktq14OwJ2Q67nrmt9OC+9Epzny4gkq/Q7ih85rGwMVxRvkKhxxLLelQLVIni363yOxn7UE=",
|
||||
"signer": "FutureOSS",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775967256.7737296,
|
||||
"plugin_hash": "68f5ab432690beef86da1c167c704fdd6b60512a359e806516dce1c6be27b9c5",
|
||||
"author": "FutureOSS"
|
||||
}
|
||||
BIN
store/@{FutureOSS}/dashboard/__pycache__/main.cpython-313.pyc
Normal file
BIN
store/@{FutureOSS}/dashboard/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
91
store/@{FutureOSS}/dashboard/assets/css/dashboard.css
Normal file
91
store/@{FutureOSS}/dashboard/assets/css/dashboard.css
Normal file
@@ -0,0 +1,91 @@
|
||||
/* Dashboard 仪表盘样式 */
|
||||
|
||||
.dashboard {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.dashboard-header h2 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dashboard-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.dashboard-section h3 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #666;
|
||||
}
|
||||
28
store/@{FutureOSS}/dashboard/config.json
Normal file
28
store/@{FutureOSS}/dashboard/config.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"refreshInterval": {
|
||||
"type": "number",
|
||||
"name": "刷新间隔",
|
||||
"description": "仪表盘数据自动刷新的间隔时间(秒)",
|
||||
"default": 2,
|
||||
"min": 1,
|
||||
"max": 60,
|
||||
"order": 1
|
||||
},
|
||||
"showDisk": {
|
||||
"type": "boolean",
|
||||
"name": "显示磁盘",
|
||||
"description": "是否在仪表盘显示磁盘使用率",
|
||||
"default": true,
|
||||
"order": 2
|
||||
},
|
||||
"diskThreshold": {
|
||||
"type": "number",
|
||||
"name": "磁盘警告阈值",
|
||||
"description": "磁盘使用率超过此值时显示警告颜色",
|
||||
"default": 80,
|
||||
"min": 50,
|
||||
"max": 95,
|
||||
"show_when": { "field": "showDisk", "value": true },
|
||||
"order": 3
|
||||
}
|
||||
}
|
||||
332
store/@{FutureOSS}/dashboard/main.py
Normal file
332
store/@{FutureOSS}/dashboard/main.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Dashboard 仪表盘插件"""
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import socket
|
||||
import subprocess
|
||||
import platform
|
||||
import psutil
|
||||
from collections import deque
|
||||
from oss.logger.logger import Log
|
||||
from oss.plugin.types import Plugin, Response, register_plugin_type
|
||||
|
||||
|
||||
class DashboardPlugin(Plugin):
|
||||
"""仪表盘插件 - 依赖 WebUI 容器"""
|
||||
|
||||
def __init__(self):
|
||||
self.webui = None
|
||||
self.views_dir = os.path.join(os.path.dirname(__file__), 'views')
|
||||
self._start_time = time.time() # 记录插件启动时间(即项目启动时间)
|
||||
self._history_len = 60
|
||||
self._cpu_history = deque(maxlen=self._history_len)
|
||||
self._ram_history = deque(maxlen=self._history_len)
|
||||
self._net_recv_history = deque(maxlen=self._history_len)
|
||||
self._net_sent_history = deque(maxlen=self._history_len)
|
||||
self._disk_read_history = deque(maxlen=self._history_len)
|
||||
self._disk_write_history = deque(maxlen=self._history_len)
|
||||
self._net_latency_history = deque(maxlen=self._history_len)
|
||||
self._last_net = None
|
||||
self._last_disk = None
|
||||
|
||||
def meta(self):
|
||||
from oss.plugin.types import Metadata, PluginConfig, Manifest
|
||||
return Manifest(
|
||||
metadata=Metadata(
|
||||
name="dashboard",
|
||||
version="2.0.0",
|
||||
author="FutureOSS",
|
||||
description="WebUI 仪表盘"
|
||||
),
|
||||
config=PluginConfig(enabled=True, args={}),
|
||||
dependencies=["http-api", "webui"]
|
||||
)
|
||||
|
||||
def set_webui(self, webui):
|
||||
self.webui = webui
|
||||
|
||||
def init(self, deps: dict = None):
|
||||
if self.webui:
|
||||
Log.info("dashboard", "已获取 WebUI 引用")
|
||||
self.webui.register_page(
|
||||
path='/dashboard',
|
||||
content_provider=self._render_content,
|
||||
nav_item={'icon': 'ri-dashboard-line', 'text': '仪表盘'}
|
||||
)
|
||||
if hasattr(self.webui, 'server') and self.webui.server:
|
||||
self.webui.server.router.get("/api/dashboard/stats", self._handle_stats_api)
|
||||
self.webui.server.router.get("/api/dashboard/history", self._handle_history_api)
|
||||
Log.info("dashboard", "已注册到 WebUI 导航")
|
||||
else:
|
||||
Log.warn("dashboard", "警告: 未找到 WebUI 依赖")
|
||||
|
||||
def _get_uptime_str(self):
|
||||
"""计算项目运行时间(从插件启动时算起)"""
|
||||
elapsed = time.time() - self._start_time
|
||||
days = int(elapsed // 86400)
|
||||
hours = int((elapsed % 86400) // 3600)
|
||||
minutes = int((elapsed % 3600) // 60)
|
||||
seconds = int(elapsed % 60)
|
||||
if days > 0:
|
||||
return f"{days}天{hours}时{minutes}分{seconds}秒"
|
||||
elif hours > 0:
|
||||
return f"{hours}时{minutes}分{seconds}秒"
|
||||
elif minutes > 0:
|
||||
return f"{minutes}分{seconds}秒"
|
||||
else:
|
||||
return f"{seconds}秒"
|
||||
|
||||
def _get_network_stats(self):
|
||||
try:
|
||||
net = psutil.net_io_counters()
|
||||
now = time.time()
|
||||
if self._last_net is None:
|
||||
self._last_net = (now, net.bytes_recv, net.bytes_sent)
|
||||
return {'recv_rate': 0, 'sent_rate': 0, 'total_recv': net.bytes_recv, 'total_sent': net.bytes_sent}
|
||||
elapsed = now - self._last_net[0]
|
||||
if elapsed <= 0: elapsed = 1
|
||||
recv_rate = (net.bytes_recv - self._last_net[1]) / elapsed
|
||||
sent_rate = (net.bytes_sent - self._last_net[2]) / elapsed
|
||||
self._last_net = (now, net.bytes_recv, net.bytes_sent)
|
||||
return {'recv_rate': round(recv_rate, 1), 'sent_rate': round(sent_rate, 1), 'total_recv': net.bytes_recv, 'total_sent': net.bytes_sent}
|
||||
except Exception:
|
||||
return {'recv_rate': 0, 'sent_rate': 0, 'total_recv': 0, 'total_sent': 0}
|
||||
|
||||
def _get_disk_io_stats(self):
|
||||
try:
|
||||
disk_io = psutil.disk_io_counters()
|
||||
if not disk_io:
|
||||
return {'read_rate': 0, 'write_rate': 0}
|
||||
now = time.time()
|
||||
if self._last_disk is None:
|
||||
self._last_disk = (now, disk_io.read_bytes, disk_io.write_bytes)
|
||||
return {'read_rate': 0, 'write_rate': 0}
|
||||
elapsed = now - self._last_disk[0]
|
||||
if elapsed <= 0: elapsed = 1
|
||||
read_rate = (disk_io.read_bytes - self._last_disk[1]) / elapsed
|
||||
write_rate = (disk_io.write_bytes - self._last_disk[2]) / elapsed
|
||||
self._last_disk = (now, disk_io.read_bytes, disk_io.write_bytes)
|
||||
return {'read_rate': round(read_rate, 1), 'write_rate': round(write_rate, 1)}
|
||||
except Exception:
|
||||
return {'read_rate': 0, 'write_rate': 0}
|
||||
|
||||
def _get_network_latency(self) -> float:
|
||||
"""测量到公共 DNS 8.8.8.8 的 TCP 连接延迟(真实网络波动)"""
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(2)
|
||||
start = time.time()
|
||||
s.connect(('8.8.8.8', 53))
|
||||
elapsed = (time.time() - start) * 1000 # 毫秒
|
||||
s.close()
|
||||
return round(elapsed, 1)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _get_network_interfaces(self):
|
||||
try:
|
||||
interfaces = []
|
||||
addrs = psutil.net_if_addrs()
|
||||
stats = psutil.net_if_stats()
|
||||
for name, addr_list in addrs.items():
|
||||
if name == 'lo':
|
||||
continue
|
||||
info = {'name': name, 'ip': 'N/A', 'mac': 'N/A', 'is_up': False, 'speed': 0}
|
||||
for addr in addr_list:
|
||||
if addr.family == socket.AF_INET:
|
||||
info['ip'] = addr.address
|
||||
elif hasattr(psutil, 'AF_LINK') and addr.family == psutil.AF_LINK:
|
||||
info['mac'] = addr.address
|
||||
if name in stats:
|
||||
info['is_up'] = stats[name].isup
|
||||
info['speed'] = stats[name].speed
|
||||
interfaces.append(info)
|
||||
return interfaces
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _get_load_info(self):
|
||||
try:
|
||||
load1, load5, load15 = os.getloadavg()
|
||||
return {'load1': round(load1, 2), 'load5': round(load5, 2), 'load15': round(load15, 2)}
|
||||
except (OSError, AttributeError):
|
||||
return {'load1': 0, 'load5': 0, 'load15': 0}
|
||||
|
||||
def _handle_stats_api(self, request):
|
||||
try:
|
||||
cpu_percent = psutil.cpu_percent(interval=0.3)
|
||||
mem = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage('/')
|
||||
net = self._get_network_stats()
|
||||
disk_io = self._get_disk_io_stats()
|
||||
load = self._get_load_info()
|
||||
latency = self._get_network_latency()
|
||||
|
||||
self._cpu_history.append(round(cpu_percent, 1))
|
||||
self._ram_history.append(round(mem.percent, 1))
|
||||
self._net_recv_history.append(net['recv_rate'])
|
||||
self._net_sent_history.append(net['sent_rate'])
|
||||
self._disk_read_history.append(disk_io['read_rate'])
|
||||
self._disk_write_history.append(disk_io['write_rate'])
|
||||
self._net_latency_history.append(latency)
|
||||
|
||||
uptime_str = self._get_uptime_str()
|
||||
|
||||
data = {
|
||||
'cpu': {'percent': round(cpu_percent, 1), 'cores': psutil.cpu_count(logical=True)},
|
||||
'ram': {'percent': round(mem.percent, 1), 'used': round(mem.used / (1024**3), 1), 'total': round(mem.total / (1024**3), 1)},
|
||||
'disk': {'percent': round(disk.percent, 1), 'used': round(disk.used / (1024**3), 1), 'total': round(disk.total / (1024**3), 1)},
|
||||
'network': net,
|
||||
'disk_io': disk_io,
|
||||
'load': load,
|
||||
'latency': latency,
|
||||
'processes': len(psutil.pids()),
|
||||
'uptime': uptime_str
|
||||
}
|
||||
return Response(status=200, headers={"Content-Type": "application/json"}, body=json.dumps(data))
|
||||
except Exception as e:
|
||||
return Response(status=500, headers={"Content-Type": "application/json"}, body=json.dumps({'error': str(e)}))
|
||||
|
||||
def _handle_history_api(self, request):
|
||||
try:
|
||||
data = {
|
||||
'cpu': list(self._cpu_history),
|
||||
'ram': list(self._ram_history),
|
||||
'net_recv': list(self._net_recv_history),
|
||||
'net_sent': list(self._net_sent_history),
|
||||
'disk_read': list(self._disk_read_history),
|
||||
'disk_write': list(self._disk_write_history),
|
||||
'latency': list(self._net_latency_history)
|
||||
}
|
||||
return Response(status=200, headers={"Content-Type": "application/json"}, body=json.dumps(data))
|
||||
except Exception as e:
|
||||
return Response(status=500, headers={"Content-Type": "application/json"}, body=json.dumps({'error': str(e)}))
|
||||
|
||||
def start(self):
|
||||
Log.info("dashboard", "仪表盘已启动")
|
||||
|
||||
def stop(self):
|
||||
Log.error("dashboard", "仪表盘已停止")
|
||||
|
||||
def _render_content(self) -> str:
|
||||
try:
|
||||
php_file = os.path.join(self.views_dir, 'dashboard.php')
|
||||
if not os.path.exists(php_file):
|
||||
return "<p>仪表盘视图文件丢失</p>"
|
||||
|
||||
cpu_percent = psutil.cpu_percent(interval=0.5)
|
||||
cpu_cores = psutil.cpu_count(logical=True)
|
||||
mem = psutil.virtual_memory()
|
||||
ram_percent = round(mem.percent, 1)
|
||||
ram_used_gb = round(mem.used / (1024**3), 1)
|
||||
ram_total_gb = round(mem.total / (1024**3), 1)
|
||||
disk = psutil.disk_usage('/')
|
||||
disk_percent = round(disk.percent, 1)
|
||||
disk_used_gb = round(disk.used / (1024**3), 1)
|
||||
disk_total_gb = round(disk.total / (1024**3), 1)
|
||||
net = self._get_network_stats()
|
||||
disk_io = self._get_disk_io_stats()
|
||||
load = self._get_load_info()
|
||||
net_interfaces = self._get_network_interfaces()
|
||||
processes = len(psutil.pids())
|
||||
|
||||
if disk_percent < 50:
|
||||
disk_color = 'gauge-green'
|
||||
elif disk_percent < 80:
|
||||
disk_color = 'gauge-orange'
|
||||
else:
|
||||
disk_color = 'gauge-blue'
|
||||
|
||||
circumference = 2 * 3.14159 * 52
|
||||
cpu_dash_offset = round(circumference - (cpu_percent / 100) * circumference, 1)
|
||||
ram_dash_offset = round(circumference - (ram_percent / 100) * circumference, 1)
|
||||
disk_dash_offset = round(circumference - (disk_percent / 100) * circumference, 1)
|
||||
|
||||
uptime_str = self._get_uptime_str()
|
||||
|
||||
def fmt_speed(bps):
|
||||
if bps >= 1024 * 1024:
|
||||
return f"{round(bps / (1024*1024), 1)} MB/s"
|
||||
elif bps >= 1024:
|
||||
return f"{round(bps / 1024, 1)} KB/s"
|
||||
else:
|
||||
return f"{round(bps, 0)} B/s"
|
||||
|
||||
variables = {
|
||||
'cpuPercent': int(cpu_percent),
|
||||
'cpuDashArray': str(circumference),
|
||||
'cpuDashOffset': str(cpu_dash_offset),
|
||||
'cpuCores': str(cpu_cores),
|
||||
'ramPercent': ram_percent,
|
||||
'ramDashArray': str(circumference),
|
||||
'ramDashOffset': str(ram_dash_offset),
|
||||
'ramUsed': f"{ram_used_gb} GB",
|
||||
'ramTotal': f"{ram_total_gb} GB",
|
||||
'diskPercent': disk_percent,
|
||||
'diskDashArray': str(circumference),
|
||||
'diskDashOffset': str(disk_dash_offset),
|
||||
'diskUsed': f"{disk_used_gb} GB",
|
||||
'diskTotal': f"{disk_total_gb} GB",
|
||||
'diskColorClass': disk_color,
|
||||
'uptime': uptime_str,
|
||||
'osName': f"{platform.system()} {platform.release()}",
|
||||
'pythonVersion': platform.python_version(),
|
||||
'phpVersion': self._get_php_version(),
|
||||
'hostname': platform.node(),
|
||||
'netRecvSpeed': fmt_speed(net['recv_rate']),
|
||||
'netSentSpeed': fmt_speed(net['sent_rate']),
|
||||
'diskReadSpeed': fmt_speed(disk_io['read_rate']),
|
||||
'diskWriteSpeed': fmt_speed(disk_io['write_rate']),
|
||||
'load1': str(load['load1']),
|
||||
'load5': str(load['load5']),
|
||||
'load15': str(load['load15']),
|
||||
'processes': str(processes),
|
||||
'netInterfaces': json.dumps(net_interfaces),
|
||||
}
|
||||
|
||||
return self._execute_php(php_file, variables)
|
||||
except Exception as e:
|
||||
return f"<p>仪表盘渲染出错: {e}</p>"
|
||||
|
||||
def _execute_php(self, php_file: str, variables: dict) -> str:
|
||||
php_vars = ""
|
||||
for key, value in variables.items():
|
||||
if isinstance(value, str):
|
||||
escaped = value.replace('\\', '\\\\').replace("'", "\\'").replace("\n", "\\n")
|
||||
php_vars += f"${key} = '{escaped}';\n"
|
||||
else:
|
||||
php_vars += f"${key} = {value};\n"
|
||||
|
||||
with open(php_file, 'r', encoding='utf-8') as f:
|
||||
php_content = f.read()
|
||||
|
||||
tmp_file = os.path.join(os.path.dirname(php_file), '.temp_dashboard.php')
|
||||
try:
|
||||
with open(tmp_file, 'w', encoding='utf-8') as f:
|
||||
f.write(f"<?php\n{php_vars}\n?>\n{php_content}")
|
||||
result = subprocess.run(
|
||||
["php", "-f", tmp_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
return result.stdout if result.returncode == 0 else f"<pre>{result.stderr}</pre>"
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(tmp_file):
|
||||
os.unlink(tmp_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _get_php_version() -> str:
|
||||
try:
|
||||
res = subprocess.run(['php', '-r', 'echo phpversion();'], capture_output=True, text=True, timeout=5)
|
||||
return res.stdout if res.returncode == 0 else 'N/A'
|
||||
except Exception:
|
||||
return 'N/A'
|
||||
|
||||
|
||||
register_plugin_type("DashboardPlugin", DashboardPlugin)
|
||||
|
||||
|
||||
def New():
|
||||
return DashboardPlugin()
|
||||
15
store/@{FutureOSS}/dashboard/manifest.json
Normal file
15
store/@{FutureOSS}/dashboard/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "dashboard",
|
||||
"version": "1.0.0",
|
||||
"author": "FutureOSS",
|
||||
"description": "WebUI 仪表盘",
|
||||
"type": "webui-extension"
|
||||
},
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"args": {}
|
||||
},
|
||||
"dependencies": ["http-api", "webui"],
|
||||
"permissions": ["*"]
|
||||
}
|
||||
350
store/@{FutureOSS}/dashboard/views/dashboard.php
Normal file
350
store/@{FutureOSS}/dashboard/views/dashboard.php
Normal file
@@ -0,0 +1,350 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
.dashboard-container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
.section-title { font-size: 18px; font-weight: 600; color: #00bcd4; margin-bottom: 16px; padding-left: 12px; border-left: 4px solid #3b82f6; }
|
||||
|
||||
.gauges-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 28px; }
|
||||
.gauge-card { background: #1e293b; border-radius: 12px; padding: 20px; display: flex; flex-direction: column; align-items: center; position: relative; }
|
||||
.gauge-card .label { font-size: 14px; color: #94a3b8; margin-bottom: 8px; }
|
||||
.gauge-circle { position: relative; width: 120px; height: 120px; }
|
||||
.gauge-circle svg { transform: rotate(-90deg); }
|
||||
.gauge-circle .bg { fill: none; stroke: #334155; stroke-width: 8; }
|
||||
.gauge-circle .progress { fill: none; stroke: #3b82f6; stroke-width: 8; stroke-linecap: round; transition: stroke-dashoffset 0.8s ease; }
|
||||
.gauge-circle .value { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 24px; font-weight: 700; color: #f1f5f9; }
|
||||
.gauge-circle .unit { font-size: 12px; color: #94a3b8; }
|
||||
.gauge-card .detail { margin-top: 8px; font-size: 12px; color: #64748b; }
|
||||
.gauge-green { stroke: #22c55e; }
|
||||
.gauge-orange { stroke: #f59e0b; }
|
||||
.gauge-blue { stroke: #3b82f6; }
|
||||
.gauge-red { stroke: #ef4444; }
|
||||
|
||||
.io-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-bottom: 28px; }
|
||||
.io-card { background: #1e293b; border-radius: 12px; padding: 20px; }
|
||||
.io-card .card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.io-card .card-header i { font-size: 20px; color: #3b82f6; }
|
||||
.io-card .card-header span { font-size: 15px; font-weight: 600; color: #e2e8f0; }
|
||||
.io-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #334155; }
|
||||
.io-row:last-child { border-bottom: none; }
|
||||
.io-row .io-label { color: #94a3b8; font-size: 13px; }
|
||||
.io-row .io-value { color: #f1f5f9; font-size: 14px; font-weight: 500; }
|
||||
|
||||
.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; margin-bottom: 28px; }
|
||||
.info-card { background: #1e293b; border-radius: 12px; padding: 20px; }
|
||||
.info-card .card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.info-card .card-header i { font-size: 20px; color: #3b82f6; }
|
||||
.info-card .card-header span { font-size: 15px; font-weight: 600; color: #e2e8f0; }
|
||||
.info-table { width: 100%; }
|
||||
.info-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #334155; }
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-row .info-label { color: #94a3b8; font-size: 13px; }
|
||||
.info-row .info-value { color: #f1f5f9; font-size: 14px; font-weight: 500; }
|
||||
|
||||
.net-ifaces { margin-top: 12px; }
|
||||
.net-iface { background: #0f172a; border-radius: 8px; padding: 12px; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.net-iface .iface-name { font-weight: 600; color: #e2e8f0; }
|
||||
.net-iface .iface-info { font-size: 12px; color: #94a3b8; }
|
||||
.net-iface .iface-status { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
|
||||
.status-up { background: #064e3b; color: #34d399; }
|
||||
.status-down { background: #7f1d1d; color: #f87171; }
|
||||
|
||||
.chart-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 16px; margin-bottom: 28px; }
|
||||
.chart-card { background: #1e293b; border-radius: 12px; padding: 20px; }
|
||||
.chart-card .card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.chart-card .card-header i { font-size: 20px; color: #3b82f6; }
|
||||
.chart-card .card-header span { font-size: 15px; font-weight: 600; color: #e2e8f0; }
|
||||
.chart-wrapper { position: relative; height: 200px; }
|
||||
|
||||
.live-dot { width: 8px; height: 8px; background: #22c55e; border-radius: 50%; display: inline-block; margin-right: 6px; animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(1.3); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="dashboard-container">
|
||||
|
||||
<div class="section-title"><span class="live-dot"></span>实时指标</div>
|
||||
<div class="gauges-grid">
|
||||
<div class="gauge-card">
|
||||
<div class="label">CPU 使用率</div>
|
||||
<div class="gauge-circle">
|
||||
<svg width="120" height="120"><circle class="bg" cx="60" cy="60" r="52" stroke-dasharray="<?= $cpuDashArray ?>"></circle><circle class="progress gauge-blue" id="cpu-gauge" cx="60" cy="60" r="52" stroke-dasharray="<?= $cpuDashArray ?>" stroke-dashoffset="<?= $cpuDashOffset ?>"></circle></svg>
|
||||
<div class="value"><span id="cpu-val"><?= $cpuPercent ?></span><span class="unit">%</span></div>
|
||||
</div>
|
||||
<div class="detail"><?= $cpuCores ?> 核心</div>
|
||||
</div>
|
||||
<div class="gauge-card">
|
||||
<div class="label">内存使用</div>
|
||||
<div class="gauge-circle">
|
||||
<svg width="120" height="120"><circle class="bg" cx="60" cy="60" r="52" stroke-dasharray="<?= $ramDashArray ?>"></circle><circle class="progress gauge-green" id="ram-gauge" cx="60" cy="60" r="52" stroke-dasharray="<?= $ramDashArray ?>" stroke-dashoffset="<?= $ramDashOffset ?>"></circle></svg>
|
||||
<div class="value"><span id="ram-val"><?= $ramPercent ?></span><span class="unit">%</span></div>
|
||||
</div>
|
||||
<div class="detail"><?= $ramUsed ?> / <?= $ramTotal ?></div>
|
||||
</div>
|
||||
<div class="gauge-card">
|
||||
<div class="label">磁盘使用</div>
|
||||
<div class="gauge-circle">
|
||||
<svg width="120" height="120"><circle class="bg" cx="60" cy="60" r="52" stroke-dasharray="<?= $diskDashArray ?>"></circle><circle class="progress <?= $diskColorClass ?>" id="disk-gauge" cx="60" cy="60" r="52" stroke-dasharray="<?= $diskDashArray ?>" stroke-dashoffset="<?= $diskDashOffset ?>"></circle></svg>
|
||||
<div class="value"><span id="disk-val"><?= $diskPercent ?></span><span class="unit">%</span></div>
|
||||
</div>
|
||||
<div class="detail"><?= $diskUsed ?> / <?= $diskTotal ?></div>
|
||||
</div>
|
||||
<div class="gauge-card">
|
||||
<div class="label">系统负载</div>
|
||||
<div class="gauge-circle">
|
||||
<svg width="120" height="120"><circle class="bg" cx="60" cy="60" r="52" stroke-dasharray="326.73"></circle><circle class="progress gauge-orange" cx="60" cy="60" r="52" stroke-dasharray="326.73" stroke-dashoffset="0"></circle></svg>
|
||||
<div class="value" style="font-size:16px" id="load-val"><?= $load1 ?></div>
|
||||
</div>
|
||||
<div class="detail">1m / 5m / 15m: <?= $load1 ?> / <?= $load5 ?> / <?= $load15 ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">网络 & 磁盘 I/O</div>
|
||||
<div class="io-grid">
|
||||
<div class="io-card">
|
||||
<div class="card-header"><i class="ri-global-line"></i><span>网络流量</span></div>
|
||||
<div class="io-row"><span class="io-label">下载速度</span><span class="io-value" id="net-recv"><?= $netRecvSpeed ?></span></div>
|
||||
<div class="io-row"><span class="io-label">上传速度</span><span class="io-value" id="net-sent"><?= $netSentSpeed ?></span></div>
|
||||
</div>
|
||||
<div class="io-card">
|
||||
<div class="card-header"><i class="ri-hard-drive-3-line"></i><span>磁盘 I/O</span></div>
|
||||
<div class="io-row"><span class="io-label">读取速度</span><span class="io-value" id="disk-read"><?= $diskReadSpeed ?></span></div>
|
||||
<div class="io-row"><span class="io-label">写入速度</span><span class="io-value" id="disk-write"><?= $diskWriteSpeed ?></span></div>
|
||||
</div>
|
||||
<div class="io-card">
|
||||
<div class="card-header"><i class="ri-stack-line"></i><span>系统概况</span></div>
|
||||
<div class="io-row"><span class="io-label">运行进程</span><span class="io-value" id="proc-count"><?= $processes ?></span></div>
|
||||
<div class="io-row"><span class="io-label">运行时间</span><span class="io-value" id="uptime-val"><?= $uptime ?></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">历史趋势</div>
|
||||
<div class="chart-grid">
|
||||
<div class="chart-card">
|
||||
<div class="card-header"><i class="ri-cpu-line"></i><span>CPU & 内存趋势</span></div>
|
||||
<div class="chart-wrapper"><canvas id="chart-cpu"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="card-header"><i class="ri-exchange-line"></i><span>网络吞吐</span></div>
|
||||
<div class="chart-wrapper"><canvas id="chart-net"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="card-header"><i class="ri-hard-drive-3-line"></i><span>磁盘读写</span></div>
|
||||
<div class="chart-wrapper"><canvas id="chart-disk-io"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="card-header"><i class="ri-pulse-line"></i><span>网络延迟</span></div>
|
||||
<div class="chart-wrapper"><canvas id="chart-latency"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">系统信息</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-card">
|
||||
<div class="card-header"><i class="ri-settings-4-line"></i><span>系统详情</span></div>
|
||||
<div class="info-table">
|
||||
<div class="info-row"><span class="info-label">主机名</span><span class="info-value"><?= $hostname ?></span></div>
|
||||
<div class="info-row"><span class="info-label">操作系统</span><span class="info-value"><?= $osName ?></span></div>
|
||||
<div class="info-row"><span class="info-label">Python</span><span class="info-value"><?= $pythonVersion ?></span></div>
|
||||
<div class="info-row"><span class="info-label">PHP</span><span class="info-value"><?= $phpVersion ?></span></div>
|
||||
<div class="info-row"><span class="info-label">运行时间</span><span class="info-value" id="uptime-info"><?= $uptime ?></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="card-header"><i class="ri-router-line"></i><span>网络接口</span></div>
|
||||
<div class="net-ifaces" id="net-ifaces">
|
||||
<script type="application/json" id="ifaces-data"><?= htmlspecialchars($netInterfaces, ENT_QUOTES, 'UTF-8') ?></script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const $ = id => document.getElementById(id);
|
||||
const circumference = 2 * Math.PI * 52;
|
||||
|
||||
Chart.defaults.color = '#94a3b8';
|
||||
Chart.defaults.borderColor = '#334155';
|
||||
Chart.defaults.font.size = 11;
|
||||
|
||||
const fmtBytes = v => {
|
||||
if (v >= 1048576) return (v/1048576).toFixed(1) + ' MB/s';
|
||||
if (v >= 1024) return (v/1024).toFixed(1) + ' KB/s';
|
||||
return Math.round(v) + ' B/s';
|
||||
};
|
||||
|
||||
const cpuChart = new Chart($('chart-cpu'), {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [
|
||||
{ label: 'CPU %', data: [], borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.1)', tension: 0.4, fill: true, pointRadius: 0 },
|
||||
{ label: '内存 %', data: [], borderColor: '#22c55e', backgroundColor: 'rgba(34,197,94,0.1)', tension: 0.4, fill: true, pointRadius: 0 }
|
||||
]},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
animation: { duration: 1500, easing: 'easeInOutCubic' },
|
||||
plugins: { legend: { display: true } },
|
||||
scales: { x: { display: false }, y: { min: 0, max: 100, grid: { color: '#334155' } } }
|
||||
}
|
||||
});
|
||||
|
||||
const netChart = new Chart($('chart-net'), {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [
|
||||
{ label: '下载', data: [], borderColor: '#06b6d4', tension: 0.4, fill: false, pointRadius: 0 },
|
||||
{ label: '上传', data: [], borderColor: '#f59e0b', tension: 0.4, fill: false, pointRadius: 0 }
|
||||
]},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
animation: { duration: 1500, easing: 'easeInOutCubic' },
|
||||
plugins: { legend: { display: true }, tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + fmtBytes(ctx.raw) } } },
|
||||
scales: { x: { display: false }, y: { grid: { color: '#334155' }, ticks: { callback: v => fmtBytes(v) } } }
|
||||
}
|
||||
});
|
||||
|
||||
const diskIoChart = new Chart($('chart-disk-io'), {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [
|
||||
{ label: '读取', data: [], borderColor: '#8b5cf6', tension: 0.4, fill: false, pointRadius: 0 },
|
||||
{ label: '写入', data: [], borderColor: '#ec4899', tension: 0.4, fill: false, pointRadius: 0 }
|
||||
]},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
animation: { duration: 1500, easing: 'easeInOutCubic' },
|
||||
plugins: { legend: { display: true }, tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + fmtBytes(ctx.raw) } } },
|
||||
scales: { x: { display: false }, y: { grid: { color: '#334155' }, ticks: { callback: v => fmtBytes(v) } } }
|
||||
}
|
||||
});
|
||||
|
||||
const latencyChart = new Chart($('chart-latency'), {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [
|
||||
{ label: '延迟 ms', data: [], borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.1)', tension: 0.4, fill: true, pointRadius: 0 }
|
||||
]},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
animation: { duration: 1500, easing: 'easeInOutCubic' },
|
||||
plugins: { legend: { display: true } },
|
||||
scales: { x: { display: false }, y: { grid: { color: '#334155' }, beginAtZero: true } }
|
||||
}
|
||||
});
|
||||
|
||||
// 加载历史
|
||||
const MAX_POINTS = 10;
|
||||
|
||||
// 初始化空图表
|
||||
[cpuChart, netChart, diskIoChart, latencyChart].forEach(c => {
|
||||
for (let i = 0; i < MAX_POINTS; i++) c.data.labels.push('');
|
||||
});
|
||||
|
||||
fetch('/api/dashboard/history').then(r => r.json()).then(hist => {
|
||||
const data = {
|
||||
cpu: hist.cpu, ram: hist.ram,
|
||||
net_recv: hist.net_recv, net_sent: hist.net_sent,
|
||||
disk_read: hist.disk_read, disk_write: hist.disk_write,
|
||||
latency: hist.latency || []
|
||||
};
|
||||
const start = Math.max(0, data.cpu.length - MAX_POINTS);
|
||||
const slice = data.cpu.slice(start);
|
||||
|
||||
cpuChart.data.datasets[0].data = data.cpu.slice(start);
|
||||
cpuChart.data.datasets[1].data = data.ram.slice(start);
|
||||
netChart.data.datasets[0].data = data.net_recv.slice(start);
|
||||
netChart.data.datasets[1].data = data.net_sent.slice(start);
|
||||
diskIoChart.data.datasets[0].data = data.disk_read.slice(start);
|
||||
diskIoChart.data.datasets[1].data = data.disk_write.slice(start);
|
||||
latencyChart.data.datasets[0].data = data.latency.slice(start);
|
||||
|
||||
// 不足10个补默认值
|
||||
const pad = (chart, vals) => {
|
||||
const diff = MAX_POINTS - chart.data.datasets[0].data.length;
|
||||
for (let i = 0; i < diff; i++) {
|
||||
chart.data.datasets[0].data.push(vals[0]);
|
||||
if (chart.data.datasets[1]) chart.data.datasets[1].data.push(vals[1] ?? vals[0]);
|
||||
}
|
||||
};
|
||||
pad(cpuChart, [50, 50]);
|
||||
pad(netChart, [0, 0]);
|
||||
pad(diskIoChart, [0, 0]);
|
||||
pad(latencyChart, [0]);
|
||||
|
||||
cpuChart.update(); netChart.update(); diskIoChart.update(); latencyChart.update();
|
||||
}).catch(() => {
|
||||
// 加载失败也补默认
|
||||
[cpuChart, netChart, diskIoChart, latencyChart].forEach(c => {
|
||||
c.data.datasets.forEach(ds => {
|
||||
while (ds.data.length < MAX_POINTS) ds.data.push(0);
|
||||
});
|
||||
c.update();
|
||||
});
|
||||
});
|
||||
|
||||
// 渲染网络接口
|
||||
try {
|
||||
const el = $('ifaces-data');
|
||||
if (el) {
|
||||
const ifaces = JSON.parse(el.textContent);
|
||||
const container = $('net-ifaces');
|
||||
if (ifaces.length === 0) {
|
||||
container.innerHTML = '<div class="net-iface"><div class="iface-info">暂无网络接口</div></div>';
|
||||
} else {
|
||||
let html = '';
|
||||
ifaces.forEach(iface => {
|
||||
html += `<div class="net-iface"><div><div class="iface-name">${iface.name}</div><div class="iface-info">${iface.ip}</div></div><span class="iface-status ${iface.is_up ? 'status-up' : 'status-down'}">${iface.is_up ? 'UP' : 'DOWN'}</span></div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// 定时刷新
|
||||
setInterval(() => {
|
||||
fetch('/api/dashboard/stats').then(r => r.json()).then(d => {
|
||||
const setGauge = (id, pct) => {
|
||||
const el = $(id);
|
||||
if (el) el.setAttribute('stroke-dashoffset', circumference - (pct/100)*circumference);
|
||||
};
|
||||
setGauge('cpu-gauge', d.cpu.percent);
|
||||
setGauge('ram-gauge', d.ram.percent);
|
||||
setGauge('disk-gauge', d.disk.percent);
|
||||
$('cpu-val').textContent = d.cpu.percent;
|
||||
$('ram-val').textContent = d.ram.percent;
|
||||
$('disk-val').textContent = d.disk.percent;
|
||||
$('load-val').textContent = d.load.load1;
|
||||
$('net-recv').textContent = fmtBytes(d.network.recv_rate);
|
||||
$('net-sent').textContent = fmtBytes(d.network.sent_rate);
|
||||
$('disk-read').textContent = fmtBytes(d.disk_io.read_rate);
|
||||
$('disk-write').textContent = fmtBytes(d.disk_io.write_rate);
|
||||
$('proc-count').textContent = d.processes;
|
||||
$('uptime-val').textContent = d.uptime;
|
||||
$('uptime-info').textContent = d.uptime;
|
||||
|
||||
// 刷新:固定10个点,数据向左平滑滚动
|
||||
const pushChart = (chart, v1, v2) => {
|
||||
// 移除最左边旧数据
|
||||
chart.data.datasets[0].data.shift();
|
||||
// 新数据从右边加入
|
||||
chart.data.datasets[0].data.push(v1);
|
||||
if (chart.data.datasets[1]) {
|
||||
chart.data.datasets[1].data.shift();
|
||||
chart.data.datasets[1].data.push(v2);
|
||||
}
|
||||
// 触发 Chart.js 内置过渡动画
|
||||
chart.update('default');
|
||||
};
|
||||
pushChart(cpuChart, d.cpu.percent, d.ram.percent);
|
||||
pushChart(netChart, d.network.recv_rate, d.network.sent_rate);
|
||||
pushChart(diskIoChart, d.disk_io.read_rate, d.disk_io.write_rate);
|
||||
|
||||
// 网络延迟图
|
||||
latencyChart.data.datasets[0].data.shift();
|
||||
latencyChart.data.datasets[0].data.push(d.latency || 0);
|
||||
latencyChart.update('default');
|
||||
}).catch(() => {});
|
||||
}, 2000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user