Spaces:
Sleeping
Sleeping
<html lang="zh"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <!-- 确保 viewport 设置正确,防止缩放问题 --> | |
<title>HF Space Manager</title> | |
<!-- 引入 Chart.js CDN --> | |
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script> | |
<style> | |
* { | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
} | |
body { | |
font-family: "Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, sans-serif; | |
background: var(--background-color); | |
color: var(--text-color); | |
padding: 20px; | |
min-height: 100vh; | |
transition: background 0.3s ease, color 0.3s ease; | |
} | |
:root { | |
/* 深色模式变量(默认) */ | |
--background-color: #0a0a0a; | |
--text-color: #fff; | |
--card-background: #1a1a1a; | |
--card-border: rgba(255, 255, 255, 0.1); | |
--metric-background: #141414; | |
--metric-border: rgba(255, 255, 255, 0.05); | |
--metric-hover: #202020; | |
--secondary-text: #888; | |
--label-color: #666; | |
--network-background: rgba(255, 255, 255, 0.07); | |
--action-button-bg: #3a3a3a; | |
--action-button-hover: #4a4a4a; | |
} | |
[data-theme="light"] { | |
/* 浅色模式变量 */ | |
--background-color: #f5f5f5; | |
--text-color: #333; | |
--card-background: #fff; | |
--card-border: rgba(0, 0, 0, 0.1); | |
--metric-background: #f9f9f9; | |
--metric-border: rgba(0, 0, 0, 0.05); | |
--metric-hover: #eaeaea; | |
--secondary-text: #666; | |
--label-color: #999; | |
--network-background: rgba(0, 0, 0, 0.07); | |
--action-button-bg: #e0e0e0; | |
--action-button-hover: #d0d0d0; | |
} | |
.container { | |
max-width: 1400px; | |
margin: 0 auto; | |
animation: fadeIn 0.5s ease; | |
padding: 0 15px; | |
} | |
.overview { | |
background: var(--card-background); | |
border-radius: 15px; | |
padding: 20px; | |
margin-bottom: 25px; | |
border: 1px solid var(--card-border); | |
transition: background 0.3s ease, border 0.3s ease; | |
} | |
.overview-title { | |
font-size: 18px; | |
display: flex; | |
align-items: center; | |
gap: 10px; | |
margin-bottom: 15px; | |
color: var(--text-color); | |
} | |
.theme-toggle { | |
display: flex; | |
align-items: center; | |
gap: 10px; | |
margin-bottom: 15px; | |
font-size: 14px; | |
color: var(--secondary-text); | |
} | |
.theme-toggle button { | |
background: var(--metric-background); | |
border: 1px solid var(--metric-border); | |
color: var(--text-color); | |
padding: 6px; | |
border-radius: 6px; | |
cursor: pointer; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
width: 32px; | |
height: 32px; | |
transition: background 0.2s ease, transform 0.2s ease; | |
} | |
.theme-toggle button:hover { | |
background: var(--metric-hover); | |
transform: scale(1.05); | |
} | |
.theme-toggle svg { | |
width: 18px; | |
height: 18px; | |
fill: var(--text-color); | |
} | |
#summary { | |
display: grid; | |
grid-template-columns: repeat(6, 1fr); | |
gap: 12px; | |
} | |
#summary div { | |
background: var(--metric-background); | |
padding: 12px; | |
border-radius: 8px; | |
border: 1px solid var(--metric-border); | |
transition: background 0.3s ease, border 0.3s ease; | |
} | |
#summary div { | |
font-size: 13px; | |
color: var(--secondary-text); | |
} | |
#summary span { | |
display: block; | |
font-size: 22px; | |
font-weight: bold; | |
margin-top: 5px; | |
color: var(--text-color); | |
overflow: hidden; | |
text-overflow: ellipsis; | |
white-space: nowrap; | |
} | |
.stats-container { | |
display: grid; | |
grid-template-columns: 1fr; | |
gap: 20px; | |
margin-top: 20px; | |
} | |
.user-group { | |
background: var(--card-background); | |
border-radius: 10px; | |
border: 1px solid var(--card-border); | |
overflow: hidden; | |
transition: background 0.3s ease, border 0.3s ease; | |
} | |
.user-group summary { | |
padding: 15px; | |
font-weight: bold; | |
cursor: pointer; | |
color: var(--text-color); | |
background: var(--metric-background); | |
transition: background 0.2s ease; | |
} | |
.user-group summary:hover { | |
background: var(--metric-hover); | |
} | |
.user-group summary::-webkit-details-marker { | |
color: var(--text-color); | |
} | |
.user-servers { | |
display: grid; | |
grid-template-columns: repeat(2, 1fr); | |
gap: 15px; | |
padding: 15px; | |
} | |
.server-card { | |
background: var(--metric-background); | |
border-radius: 8px; | |
padding: 15px; | |
border: 1px solid var(--metric-border); | |
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.3s ease, border 0.3s ease; | |
min-height: 150px; /* 登录状态下的最小高度,适配有操作按钮的情况 */ | |
display: flex; | |
flex-direction: column; | |
} | |
.server-card.not-logged-in { | |
min-height: 120px; /* 未登录状态下的最小高度,减少空白 */ | |
} | |
.server-card:hover { | |
transform: translateY(-2px); | |
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); | |
} | |
.server-header { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
margin-bottom: 10px; | |
font-size: 14px; | |
} | |
.server-name { | |
display: flex; | |
align-items: center; | |
gap: 8px; | |
flex: 1; | |
min-width: 0; /* 防止内容溢出 */ | |
} | |
.server-name div { | |
overflow: hidden; | |
text-overflow: ellipsis; | |
white-space: nowrap; | |
max-width: 100%; /* 限制名称的最大宽度 */ | |
} | |
.server-flag { | |
width: 20px; | |
height: 20px; | |
border-radius: 4px; | |
flex-shrink: 0; | |
} | |
.metric-grid { | |
display: grid; | |
grid-template-columns: repeat(5, 1fr); | |
gap: 10px; | |
margin-top: 10px; | |
} | |
.metric-item { | |
background: var(--card-background); | |
padding: 8px; | |
border-radius: 6px; | |
border: 1px solid var(--metric-border); | |
transition: background 0.3s ease; | |
overflow: hidden; /* 防止内容溢出 */ | |
} | |
.metric-item:hover { | |
background: var(--metric-hover); | |
} | |
.metric-label { | |
color: var(--label-color); | |
font-size: 12px; | |
margin-bottom: 3px; | |
white-space: nowrap; | |
} | |
.metric-value { | |
font-size: 14px; | |
font-weight: 500; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
white-space: nowrap; | |
max-width: 100%; /* 限制指标值的宽度 */ | |
} | |
.status-dot { | |
display: inline-block; | |
border-radius: 50%; | |
animation: pulse 2s infinite; | |
width: 10px; | |
height: 10px; | |
flex-shrink: 0; | |
} | |
.status-online { | |
background-color: #4CAF50; | |
color: #4CAF50; | |
} | |
.status-offline { | |
background-color: #f44336; | |
color: #f44336; | |
} | |
.status-sleep { | |
background-color: #ffa500; | |
color: #ffa500; | |
animation: none; | |
} | |
.action-buttons { | |
display: flex; | |
gap: 10px; | |
margin-top: 10px; | |
} | |
.action-button { | |
background: var(--action-button-bg); | |
color: var(--text-color); | |
border: none; | |
padding: 6px 12px; | |
border-radius: 4px; | |
cursor: pointer; | |
font-size: 13px; | |
transition: background 0.2s ease; | |
} | |
.action-button:hover { | |
background: var(--action-button-hover); | |
} | |
.network-stats { | |
background: var(--network-background); | |
border: 1px solid var(--metric-border); | |
margin-top: 20px; | |
padding: 15px; | |
border-radius: 8px; | |
transition: background 0.3s ease, border 0.3s ease; | |
} | |
.network-item { | |
font-size: 14px; | |
color: var(--secondary-text); | |
} | |
@keyframes fadeIn { | |
from { opacity: 0; transform: translateY(20px); } | |
to { opacity: 1; transform: translateY(0); } | |
} | |
@keyframes pulse { | |
0% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.4); } | |
70% { box-shadow: 0 0 0 10px rgba(76, 175, 80, 0); } | |
100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); } | |
} | |
@media (max-width: 600px) { /* 降低阈值,确保覆盖更多小屏幕设备如 iPhone */ | |
#summary { | |
grid-template-columns: 1fr; /* 小屏幕单列显示 */ | |
gap: 10px; | |
} | |
#summary div { | |
padding: 10px; | |
} | |
#summary span { | |
font-size: 20px; /* 小屏幕字体略小 */ | |
} | |
.user-servers { | |
grid-template-columns: 1fr ; /* 小屏幕强制单列显示实例卡片,使用 !important 提高优先级 */ | |
} | |
.metric-grid { | |
grid-template-columns: repeat(2, 1fr); /* 小屏幕指标网格改为2列 */ | |
gap: 8px; | |
} | |
.metric-item { | |
padding: 6px; | |
} | |
.metric-value { | |
font-size: 13px; /* 小屏幕字体略小 */ | |
} | |
.server-header { | |
flex-direction: row; | |
flex-wrap: wrap; | |
gap: 8px; /* 小屏幕避免标题和按钮过于紧密 */ | |
} | |
.container { | |
padding: 0 10px; /* 小屏幕减少整体边距 */ | |
} | |
.overview { | |
padding: 15px; | |
margin-bottom: 20px; | |
} | |
} | |
.login-overlay, .confirm-overlay, .loading-overlay { | |
position: fixed; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
background: rgba(0, 0, 0, 0.6); | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
z-index: 1000; | |
display: none; | |
} | |
.login-box, .confirm-box { | |
background: var(--card-background); | |
padding: 30px; | |
border-radius: 10px; | |
border: 1px solid var(--card-border); | |
width: 300px; | |
text-align: center; | |
} | |
.login-box h2, .confirm-box h2 { | |
margin-bottom: 20px; | |
color: var(--text-color); | |
} | |
.login-box input { | |
width: 100%; | |
padding: 10px; | |
margin: 10px 0; | |
border: 1px solid var(--metric-border); | |
border-radius: 5px; | |
background: var(--metric-background); | |
color: var(--text-color); | |
} | |
.login-box button, .confirm-box button { | |
width: 48%; | |
padding: 10px; | |
background: var(--action-button-bg); | |
border: none; | |
border-radius: 5px; | |
color: var(--text-color); | |
cursor: pointer; | |
transition: background 0.2s ease; | |
margin: 5px 1%; | |
} | |
.login-box button:hover, .confirm-box button:hover { | |
background: var(--action-button-hover); | |
} | |
.login-error { | |
color: #f44336; | |
margin-top: 10px; | |
font-size: 14px; | |
} | |
.login-button, .logout-button { | |
background: var(--action-button-bg); | |
border: none; | |
color: var(--text-color); | |
padding: 6px 12px; | |
border-radius: 4px; | |
cursor: pointer; | |
font-size: 13px; | |
transition: background 0.2s ease; | |
} | |
.login-button:hover, .logout-button:hover { | |
background: var(--action-button-hover); | |
} | |
.header-container { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
margin-bottom: 15px; | |
} | |
.auth-buttons { | |
display: flex; | |
gap: 10px; | |
} | |
/* 加载状态指示器样式 */ | |
.loader { | |
border: 5px solid var(--card-background); | |
border-top: 5px solid #4CAF50; | |
border-radius: 50%; | |
width: 50px; | |
height: 50px; | |
animation: spin 1s linear infinite; | |
} | |
@keyframes spin { | |
0% { transform: rotate(0deg); } | |
100% { transform: rotate(360deg); } | |
} | |
/* 优化后的过滤与排序控件样式 */ | |
.filter-sort-panel { | |
background: var(--card-background); | |
border: 1px solid var(--card-border); | |
border-radius: 10px; | |
padding: 15px; | |
margin-bottom: 20px; | |
display: flex; | |
flex-wrap: wrap; | |
gap: 15px; | |
align-items: center; | |
transition: background 0.3s ease, border 0.3s ease; | |
} | |
.filter-sort-group { | |
display: flex; | |
align-items: center; | |
gap: 10px; | |
font-size: 14px; | |
color: var(--text-color); | |
min-width: 200px; /* 确保控件有足够的宽度 */ | |
} | |
.filter-sort-group label { | |
white-space: nowrap; | |
color: var(--secondary-text); | |
font-weight: 500; | |
} | |
.filter-sort-group select { | |
flex: 1; | |
background: var(--metric-background); | |
border: 1px solid var(--metric-border); | |
color: var(--text-color); | |
padding: 8px 12px; | |
border-radius: 6px; | |
cursor: pointer; | |
font-size: 14px; | |
transition: background 0.2s ease, border 0.2s ease; | |
outline: none; | |
appearance: none; /* 移除默认的下拉箭头 */ | |
background-image: url("data:image/svg+xml;utf8,<svg fill='%23fff' height='24' viewBox='0 0 24 24' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/><path d='M0 0h24v24H0z' fill='none'/></svg>"); | |
background-repeat: no-repeat; | |
background-position: right 10px center; | |
padding-right: 36px; /* 为箭头留出空间 */ | |
} | |
[data-theme="light"] .filter-sort-group select { | |
background-image: url("data:image/svg+xml;utf8,<svg fill='%23333' height='24' viewBox='0 0 24 24' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/><path d='M0 0h24v24H0z' fill='none'/></svg>"); | |
} | |
.filter-sort-group select:hover { | |
background-color: var(--metric-hover); | |
border-color: rgba(255, 255, 255, 0.15); /* 微调 hover 时的边框 */ | |
} | |
.filter-sort-group select:focus { | |
border-color: #4CAF50; | |
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.3); | |
} | |
.refresh-button { | |
background: var(--action-button-bg); | |
border: none; | |
color: var(--text-color); | |
padding: 8px 16px; | |
border-radius: 6px; | |
cursor: pointer; | |
font-size: 14px; | |
transition: background 0.2s ease, transform 0.2s ease; | |
display: flex; | |
align-items: center; | |
gap: 8px; | |
height: 40px; | |
} | |
.refresh-button:hover { | |
background: var(--action-button-hover); | |
transform: translateY(-1px); | |
} | |
.refresh-icon { | |
width: 16px; | |
height: 16px; | |
fill: currentColor; | |
} | |
@media (max-width: 600px) { | |
.filter-sort-group { | |
min-width: 100%; /* 小屏时控件占满一行 */ | |
} | |
.filter-sort-panel { | |
gap: 12px; /* 小屏时减少间距 */ | |
padding: 12px; | |
} | |
} | |
/* 图表相关样式 */ | |
.chart-container { | |
display: none; /* 默认隐藏 */ | |
margin-top: 15px; | |
background: var(--card-background); | |
border: 1px solid var(--card-border); | |
border-radius: 8px; | |
padding: 10px; | |
height: 300px; | |
transition: background 0.3s ease, border 0.3s ease; | |
} | |
.chart-toggle-button { | |
background: var(--action-button-bg); | |
color: var(--text-color); | |
border: none; | |
padding: 6px 12px; | |
border-radius: 4px; | |
cursor: pointer; | |
font-size: 13px; | |
transition: background 0.2s ease; | |
margin-left: auto; | |
white-space: nowrap; | |
} | |
.chart-toggle-button:hover { | |
background: var(--action-button-hover); | |
} | |
.expanded .chart-container { | |
display: block; | |
} | |
canvas { | |
width: 100% ; | |
height: auto ; | |
} | |
@media (max-width: 600px) { | |
.chart-container { | |
height: 250px; /* 小屏幕图表高度略减 */ | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<div class="overview"> | |
<div class="header-container"> | |
<div class="overview-title">📊 系统概览</div> | |
<div class="auth-buttons"> | |
<button class="login-button" id="loginButton" onclick="showLoginForm()">登录</button> | |
<button class="logout-button" id="logoutButton" style="display: none;" onclick="logout()">登出</button> | |
</div> | |
</div> | |
<div class="theme-toggle"> | |
主题: | |
<button onclick="toggleTheme('system')" title="跟随系统"> | |
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> | |
<path d="M3 5h18c1.1 0 2 .9 2 2v10c0 1.1-.9 2-2 2H3c-1.1 0-2-.9-2-2V7c0-1.1.9-2 2-2zm0 12h18V7H3v10zm2-8h2v2H5V9zm0 4h2v2H5v-2zm4-4h10v2H9V9zm0 4h10v2H9v-2z"/> | |
</svg> | |
</button> | |
<button onclick="toggleTheme('light')" title="浅色模式"> | |
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> | |
<path d="M12 15.5a3.5 3.5 0 100-7 3.5 3.5 0 000 7zM12 2a.5.5 0 01.5.5v2a.5.5 0 01-1 0v-2A.5.5 0 0112 2zm0 17a.5.5 0 01.5.5v2a.5.5 0 01-1 0v-2a.5.5 0 01.5-.5zM2 12h2.5a.5.5 0 010 1H2a.5.5 0 010-1zm17.5 0h2a.5.5 0 010 1h-2a.5.5 0 010-1zM4.2 5.8l1.4-1.4a.5.5 0 01.7 0l1.4 1.4a.5.5 0 010 .7l-1.4 1.4a.5.5 0 01-.7 0L4.2 6.5a.5.5 0 010-.7zm13.2 0l1.4 1.4a.5.5 0 010 .7l1.4 1.4a.5.5 0 010 .7l-1.4 1.4a.5.5 0 01-.7 0l-1.4-1.4a.5.5 0 010-.7l-1.4-1.4a.5.5 0 010-.7l1.4-1.4a.5.5 0 01.7 0zM6.5 17.8l-1.4 1.4a.5.5 0 01-.7 0l-1.4-1.4a.5.5 0 010-.7l1.4-1.4a.5.5 0 01.7 0l1.4 1.4a.5.5 0 010 .7zm11 0l1.4 1.4a.5.5 0 010 .7l1.4 1.4a.5.5 0 010 .7l-1.4 1.4a.5.5 0 01-.7 0l-1.4-1.4a.5.5 0 010-.7l-1.4-1.4a.5.5 0 010-.7l1.4-1.4a.5.5 0 01.7 0z"/> | |
</svg> | |
</button> | |
<button onclick="toggleTheme('dark')" title="深色模式"> | |
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> | |
<path d="M21.4 13.7C20.6 13.9 19.8 14 19 14c-5 0-9-4-9-9 0-.2 0-.4.1-.6C5.6 5.6 2.5 9.5 2.2 14c-.1.9.1 1.8.5 2.6.7 1.5 2.1 2.5 3.8 2.8 1.9.2 3.8.3 5.6.3 2.6 0 5.1-.6 7.4-1.7 2-.9 3.7-2.4 4.9-4.2.3-.5.5-1.1.6-1.7 0 .6-.2 1.2-.6 1.6zM12.1 18.5c-1.7 0-3.5-.1-5.2-.4-1.5-.2-2.8-1.2-3.4-2.6-.3-.7-.5-1.6-.4-2.4.3-4.1 3-7.5 6.9-8.5-.1.4-.2.8-.2 1.2 0 4.5 3.3 8.2 7.5 8.9-1.9.9-3.9 1.4-6 1.4-.4 0-.8 0-1.2.4z"/> | |
</svg> | |
</button> | |
</div> | |
<div id="summary"> | |
<div>总用户数: <span id="totalUsers">0</span></div> | |
<div>总实例数: <span id="totalServers">0</span></div> | |
<div>在线实例: <span id="onlineServers">0</span></div> | |
<div>离线实例: <span id="offlineServers">0</span></div> | |
<div>总上传: <span id="totalUpload">0 B/s</span></div> | |
<div>总下载: <span id="totalDownload">0 B/s</span></div> | |
</div> | |
</div> | |
<!-- 优化后的过滤与排序面板 --> | |
<div class="filter-sort-panel"> | |
<div class="filter-sort-group"> | |
<label for="statusFilter">过滤状态:</label> | |
<select id="statusFilter" onchange="applyFiltersAndSort()"> | |
<option value="all">全部状态</option> | |
<option value="running">运行中</option> | |
<option value="sleeping">休眠中</option> | |
<option value="stopped">已停止</option> | |
</select> | |
</div> | |
<div class="filter-sort-group"> | |
<label for="userFilter">过滤用户:</label> | |
<select id="userFilter" onchange="applyFiltersAndSort()"> | |
<option value="all">全部用户</option> | |
</select> | |
</div> | |
<div class="filter-sort-group"> | |
<label for="sortBy">排序方式:</label> | |
<select id="sortBy" onchange="applyFiltersAndSort()"> | |
<option value="name-asc">名称 (A-Z)</option> | |
<option value="name-desc">名称 (Z-A)</option> | |
<option value="status-asc">状态 (运行-停止)</option> | |
<option value="status-desc">状态 (停止-运行)</option> | |
</select> | |
</div> | |
<button class="refresh-button" onclick="refreshData()"> | |
<svg class="refresh-icon" viewBox="0 0 24 24" fill="currentColor"> | |
<path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/> | |
</svg> | |
刷新数据 | |
</button> | |
</div> | |
<div id="servers" class="stats-container"> | |
</div> | |
</div> | |
<!-- 登录弹窗 --> | |
<div id="loginOverlay" class="login-overlay"> | |
<div class="login-box"> | |
<h2>登录</h2> | |
<input type="text" id="username" placeholder="用户名"> | |
<input type="password" id="password" placeholder="密码"> | |
<button onclick="login()">登录</button> | |
<div id="loginError" class="login-error" style="display: none;"></div> | |
</div> | |
</div> | |
<!-- 确认弹窗 --> | |
<div id="confirmOverlay" class="confirm-overlay"> | |
<div class="confirm-box"> | |
<h2 id="confirmTitle">确认操作</h2> | |
<p id="confirmMessage" style="margin-bottom: 20px; color: var(--text-color);"></p> | |
<button onclick="confirmAction()">确认</button> | |
<button onclick="cancelAction()">取消</button> | |
</div> | |
</div> | |
<!-- 加载状态覆盖层 --> | |
<div id="loadingOverlay" class="loading-overlay"> | |
<div class="loader"></div> | |
</div> | |
<script> | |
// 主题切换功能 | |
function setTheme(theme) { | |
if (theme === 'system') { | |
localStorage.removeItem('theme'); | |
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; | |
document.documentElement.setAttribute('data-theme', systemPrefersDark ? 'dark' : 'light'); | |
} else { | |
localStorage.setItem('theme', theme); | |
document.documentElement.setAttribute('data-theme', theme); | |
} | |
} | |
function toggleTheme(theme) { | |
setTheme(theme); | |
} | |
// 初始化主题 | |
function initTheme() { | |
const savedTheme = localStorage.getItem('theme'); | |
if (savedTheme) { | |
setTheme(savedTheme); | |
} else { | |
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; | |
setTheme(systemPrefersDark ? 'dark' : 'light'); | |
} | |
} | |
// 监听系统主题变化 | |
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { | |
if (!localStorage.getItem('theme')) { | |
setTheme('system'); | |
} | |
}); | |
initTheme(); | |
// 全局变量,表示当前是否已登录 | |
let isLoggedIn = false; | |
// 加载状态控制函数 | |
function showLoading() { | |
document.getElementById('loadingOverlay').style.display = 'flex'; | |
} | |
function hideLoading() { | |
document.getElementById('loadingOverlay').style.display = 'none'; | |
} | |
// 登录状态管理 | |
function checkLoginStatus() { | |
const token = localStorage.getItem('authToken'); | |
const loginButton = document.getElementById('loginButton'); | |
const logoutButton = document.getElementById('logoutButton'); | |
if (token) { | |
console.log('本地存储中找到 token,尝试验证:', token.slice(0, 8) + '...'); | |
showLoading(); | |
return fetch('/api/verify-token', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({ token }) | |
}) | |
.then(response => response.json()) | |
.then(data => { | |
hideLoading(); | |
if (data.success) { | |
console.log('Token 验证成功,用户已登录'); | |
isLoggedIn = true; | |
loginButton.style.display = 'none'; | |
logoutButton.style.display = 'block'; | |
updateActionButtons(true); | |
} else { | |
console.log('Token 验证失败,清除本地存储:', data.message); | |
localStorage.removeItem('authToken'); | |
isLoggedIn = false; | |
loginButton.style.display = 'block'; | |
logoutButton.style.display = 'none'; | |
updateActionButtons(false); | |
} | |
return data.success; | |
}) | |
.catch(error => { | |
hideLoading(); | |
console.error('验证 token 失败,清除本地存储:', error); | |
localStorage.removeItem('authToken'); | |
isLoggedIn = false; | |
loginButton.style.display = 'block'; | |
logoutButton.style.display = 'none'; | |
updateActionButtons(false); | |
return false; | |
}); | |
} else { | |
console.log('本地存储中无 token,显示未登录状态'); | |
isLoggedIn = false; | |
loginButton.style.display = 'block'; | |
logoutButton.style.display = 'none'; | |
updateActionButtons(false); | |
return Promise.resolve(false); | |
} | |
} | |
function showLoginForm() { | |
document.getElementById('loginOverlay').style.display = 'flex'; | |
document.getElementById('username').value = ''; | |
document.getElementById('password').value = ''; | |
document.getElementById('loginError').style.display = 'none'; | |
} | |
function hideLoginForm() { | |
document.getElementById('loginOverlay').style.display = 'none'; | |
} | |
function login() { | |
const username = document.getElementById('username').value; | |
const password = document.getElementById('password').value; | |
const loginError = document.getElementById('loginError'); | |
showLoading(); | |
fetch('/api/login', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({ username, password }) | |
}) | |
.then(response => response.json()) | |
.then(data => { | |
hideLoading(); | |
if (data.success) { | |
console.log('登录成功,保存 token'); | |
localStorage.setItem('authToken', data.token); | |
isLoggedIn = true; | |
hideLoginForm(); | |
document.getElementById('loginButton').style.display = 'none'; | |
document.getElementById('logoutButton').style.display = 'block'; | |
updateActionButtons(true); | |
} else { | |
console.log('登录失败:', data.message); | |
loginError.textContent = data.message || '登录失败'; | |
loginError.style.display = 'block'; | |
} | |
}) | |
.catch(error => { | |
hideLoading(); | |
console.error('登录请求失败:', error); | |
loginError.textContent = '登录请求失败,请稍后重试'; | |
loginError.style.display = 'block'; | |
}); | |
} | |
function logout() { | |
const token = localStorage.getItem('authToken'); | |
if (token) { | |
showLoading(); | |
fetch('/api/logout', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({ token }) | |
}) | |
.then(response => response.json()) | |
.then(data => { | |
hideLoading(); | |
console.log('登出成功,清除 token'); | |
localStorage.removeItem('authToken'); | |
isLoggedIn = false; | |
document.getElementById('loginButton').style.display = 'block'; | |
document.getElementById('logoutButton').style.display = 'none'; | |
updateActionButtons(false); | |
}) | |
.catch(error => { | |
hideLoading(); | |
console.error('登出失败,但仍清除 token:', error); | |
localStorage.removeItem('authToken'); | |
isLoggedIn = false; | |
document.getElementById('loginButton').style.display = 'block'; | |
document.getElementById('logoutButton').style.display = 'none'; | |
updateActionButtons(false); | |
}); | |
} else { | |
console.log('本地无 token,直接设置为未登录'); | |
isLoggedIn = false; | |
document.getElementById('loginButton').style.display = 'block'; | |
document.getElementById('logoutButton').style.display = 'none'; | |
updateActionButtons(false); | |
} | |
} | |
function updateActionButtons(loggedIn) { | |
console.log('更新操作按钮状态,是否已登录:', loggedIn); | |
isLoggedIn = loggedIn; | |
const cards = document.querySelectorAll('.server-card'); | |
cards.forEach(card => { | |
const buttons = card.querySelector('.action-buttons'); | |
if (buttons) { | |
buttons.style.display = loggedIn ? 'flex' : 'none'; | |
} | |
// 动态添加或去除 not-logged-in 类以调整卡片高度 | |
if (loggedIn) { | |
card.classList.remove('not-logged-in'); | |
} else { | |
card.classList.add('not-logged-in'); | |
} | |
// 确保图表切换按钮总是可见,不管是否登录 | |
const chartToggleButton = card.querySelector('.chart-toggle-button'); | |
if (chartToggleButton) { | |
chartToggleButton.style.display = 'inline-block'; | |
} | |
}); | |
} | |
// 使用 window.onload 确保页面完全加载后检查登录状态 | |
window.onload = async function() { | |
console.log('页面加载完成,开始检查登录状态'); | |
await checkLoginStatus(); | |
console.log('登录状态检查完成,初始化数据'); | |
initialize(); | |
}; | |
// 二次确认弹窗逻辑 | |
let pendingAction = null; | |
let pendingRepoId = null; | |
function showConfirmDialog(action, repoId, title, message) { | |
pendingAction = action; | |
pendingRepoId = repoId; | |
document.getElementById('confirmTitle').textContent = title; | |
document.getElementById('confirmMessage').textContent = message; | |
document.getElementById('confirmOverlay').style.display = 'flex'; | |
} | |
function confirmAction() { | |
if (pendingAction === 'restart') { | |
restartSpace(pendingRepoId); | |
} else if (pendingAction === 'rebuild') { | |
rebuildSpace(pendingRepoId); | |
} | |
cancelAction(); | |
} | |
function cancelAction() { | |
pendingAction = null; | |
pendingRepoId = null; | |
document.getElementById('confirmOverlay').style.display = 'none'; | |
} | |
async function getUsernames() { | |
try { | |
showLoading(); | |
const response = await fetch('/api/config'); | |
const config = await response.json(); | |
hideLoading(); | |
const usernamesList = config.usernames ? config.usernames.split(',').map(name => name.trim()).filter(name => name) : []; | |
document.getElementById('totalUsers').textContent = usernamesList.length; | |
// 更新用户过滤下拉菜单 | |
const userFilter = document.getElementById('userFilter'); | |
userFilter.innerHTML = '<option value="all">全部用户</option>'; | |
usernamesList.forEach(username => { | |
const option = document.createElement('option'); | |
option.value = username; | |
option.textContent = username; | |
userFilter.appendChild(option); | |
}); | |
return usernamesList; | |
} catch (error) { | |
hideLoading(); | |
console.error('Failed to fetch usernames:', error); | |
document.getElementById('totalUsers').textContent = 0; | |
return []; | |
} | |
} | |
async function fetchInstances() { | |
try { | |
showLoading(); | |
const response = await fetch('/api/proxy/spaces'); | |
const instances = await response.json(); | |
hideLoading(); | |
return instances; | |
} catch (error) { | |
hideLoading(); | |
console.error("获取实例列表失败:", error); | |
return []; | |
} | |
} | |
class MetricsManager { | |
constructor() { | |
this.eventSources = new Map(); | |
this.instanceOwners = new Map(); | |
} | |
async connect(instanceId, username) { | |
if (this.eventSources.has(instanceId)) { | |
return; | |
} | |
try { | |
const eventSource = new EventSource( | |
`/api/proxy/live-metrics/${username}/${instanceId.split('/')[1]}` | |
); | |
this.instanceOwners.set(instanceId, username); | |
eventSource.addEventListener("metric", (event) => { | |
try { | |
const data = JSON.parse(event.data); | |
updateServerCard(data, instanceId); | |
} catch (error) { | |
console.error(`解析数据失败 (${instanceId}):`, error); | |
} | |
}); | |
eventSource.onerror = (error) => { | |
eventSource.close(); | |
this.eventSources.delete(instanceId); | |
updateServerCard(null, instanceId, false); | |
}; | |
this.eventSources.set(instanceId, eventSource); | |
} catch (error) { | |
console.error(`连接失败 (${username}/${instanceId}):`, error); | |
updateServerCard(null, instanceId, false); | |
} | |
} | |
disconnectAll() { | |
this.eventSources.forEach(es => es.close()); | |
this.eventSources.clear(); | |
} | |
} | |
const metricsManager = new MetricsManager(); | |
const instanceMap = new Map(); | |
const serverStatus = new Map(); | |
let allInstances = []; // 存储所有实例数据,用于过滤和排序 | |
const chartInstances = new Map(); // 存储每个实例的图表实例 | |
async function initialize() { | |
await getUsernames(); | |
const instances = await fetchInstances(); | |
allInstances = instances; | |
renderInstances(allInstances); | |
instances.forEach(instance => { | |
if (instance.status.toLowerCase() === 'running') { | |
metricsManager.connect(instance.repo_id, instance.owner); | |
} | |
}); | |
updateSummary(); | |
updateActionButtons(isLoggedIn); | |
} | |
// 手动刷新数据函数 | |
async function refreshData() { | |
metricsManager.disconnectAll(); | |
// 销毁所有图表实例 | |
chartInstances.forEach(chart => { | |
if (chart) { | |
chart.destroy(); | |
} | |
}); | |
chartInstances.clear(); | |
await initialize(); | |
applyFiltersAndSort(); // 确保刷新后重新应用过滤和排序 | |
} | |
function renderInstances(instances) { | |
const serversContainer = document.getElementById('servers'); | |
serversContainer.innerHTML = ''; // 清空现有内容 | |
const userGroups = {}; | |
// 按用户分组 | |
instances.forEach(instance => { | |
if (!userGroups[instance.owner]) { | |
userGroups[instance.owner] = []; | |
} | |
userGroups[instance.owner].push(instance); | |
}); | |
// 渲染每个用户组 | |
Object.keys(userGroups).forEach(owner => { | |
let userGroup = document.createElement('details'); | |
userGroup.className = 'user-group'; | |
userGroup.id = `user-${owner}`; | |
userGroup.setAttribute('open', ''); | |
const summary = document.createElement('summary'); | |
summary.textContent = `用户: ${owner}`; | |
userGroup.appendChild(summary); | |
const userServers = document.createElement('div'); | |
userServers.className = 'user-servers'; | |
userGroup.appendChild(userServers); | |
serversContainer.appendChild(userGroup); | |
// 渲染该用户下的实例 | |
userGroups[owner].forEach(instance => { | |
renderInstanceCard(instance, userServers); | |
}); | |
}); | |
} | |
// 图表配置函数 | |
function createChart(instanceId) { | |
const canvasId = `chart-${instanceId}`; | |
const canvas = document.getElementById(canvasId); | |
if (!canvas) return null; | |
// 根据当前主题设置图表样式 | |
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark'; | |
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; | |
const textColor = isDarkMode ? '#fff' : '#333'; | |
const ctx = canvas.getContext('2d'); | |
const chart = new Chart(ctx, { | |
type: 'line', | |
data: { | |
labels: Array(30).fill(''), // 初始为空标签,最多30个数据点 | |
datasets: [ | |
{ | |
label: 'CPU 使用率 (%)', | |
data: [], | |
borderColor: '#4CAF50', // 绿色 | |
backgroundColor: 'rgba(76, 175, 80, 0.2)', | |
tension: 0.4, | |
fill: true, | |
}, | |
{ | |
label: '内存使用率 (%)', | |
data: [], | |
borderColor: '#2196F3', // 蓝色 | |
backgroundColor: 'rgba(33, 150, 243, 0.2)', | |
tension: 0.4, | |
fill: true, | |
}, | |
{ | |
label: '上传速度 (KB/s)', | |
data: [], | |
borderColor: '#F44336', // 红色 | |
backgroundColor: 'rgba(244, 67, 54, 0.2)', | |
tension: 0.4, | |
fill: true, | |
}, | |
{ | |
label: '下载速度 (KB/s)', | |
data: [], | |
borderColor: '#FF9800', // 橙色 | |
backgroundColor: 'rgba(255, 152, 0, 0.2)', | |
tension: 0.4, | |
fill: true, | |
}, | |
] | |
}, | |
options: { | |
responsive: true, | |
maintainAspectRatio: false, | |
plugins: { | |
legend: { | |
labels: { | |
color: textColor, | |
font: { size: 12 } | |
} | |
}, | |
tooltip: { | |
mode: 'index', | |
intersect: false, | |
} | |
}, | |
scales: { | |
y: { | |
beginAtZero: true, | |
grid: { color: gridColor }, | |
ticks: { | |
color: textColor, | |
font: { size: 11 } | |
} | |
}, | |
x: { | |
grid: { color: gridColor }, | |
ticks: { | |
color: textColor, | |
font: { size: 11 }, | |
maxRotation: 0, | |
minRotation: 0, | |
autoSkip: true, | |
autoSkipPadding: 10 | |
} | |
} | |
}, | |
elements: { | |
point: { | |
radius: 0, // 隐藏数据点 | |
hitRadius: 5 // 确保悬停仍有效 | |
} | |
}, | |
animation: false // 关闭动画,提升性能 | |
} | |
}); | |
chartInstances.set(instanceId, chart); | |
return chart; | |
} | |
// 更新图表数据 | |
function updateChart(instanceId, data) { | |
let chart = chartInstances.get(instanceId); | |
if (!chart) { | |
chart = createChart(instanceId); | |
if (!chart) return; // 如果图表未创建成功,直接返回 | |
} | |
// 获取当前数据集 | |
const cpuData = chart.data.datasets[0].data; | |
const memoryData = chart.data.datasets[1].data; | |
const uploadData = chart.data.datasets[2].data; | |
const downloadData = chart.data.datasets[3].data; | |
// 追加新数据 | |
cpuData.push(data.cpu_usage_pct); | |
memoryData.push(((data.memory_used_bytes / data.memory_total_bytes) * 100).toFixed(2)); | |
uploadData.push((data.tx_bps / 1024).toFixed(2)); // 转换为 KB/s | |
downloadData.push((data.rx_bps / 1024).toFixed(2)); // 转换为 KB/s | |
// 限制数据点数量为 30 个 | |
if (cpuData.length > 30) { | |
cpuData.shift(); | |
memoryData.shift(); | |
uploadData.shift(); | |
downloadData.shift(); | |
} | |
// 更新图表 | |
chart.update(); | |
} | |
function renderInstanceCard(instance, container) { | |
const instanceId = instance.repo_id; | |
instanceMap.set(instanceId, instance); | |
const cardId = `instance-${instanceId}`; | |
let card = document.getElementById(cardId); | |
if (!card) { | |
card = document.createElement('div'); | |
card.id = cardId; | |
card.className = 'server-card'; | |
if (!isLoggedIn) { | |
card.classList.add('not-logged-in'); // 未登录时添加类以调整高度 | |
} | |
card.innerHTML = ` | |
<div class="server-header"> | |
<div class="server-name"> | |
<div class="status-dot status-sleep"></div> | |
<svg class="server-flag" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> | |
<path d="M21 3H3C1.9 3 1 3.9 1 5v3c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-1 5H4V6h16v2zm1 4H3c-1.1 0-2 .9-2 2v3c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2v-3c0-1.1-.9-2-2-2zm-1 5H4v-2h16v2zm1 4H3c-1.1 0-2 .9-2 2v3c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2v-3c0-1.1-.9-2-2-2zm-1 5H4v-2h16v2z"/> | |
</svg> | |
<div>${instance.name} (${instance.repo_id})</div> | |
</div> | |
<button class="chart-toggle-button" onclick="toggleChart('${instanceId}')">查看图表</button> | |
</div> | |
<div class="metric-grid"> | |
<div class="metric-item"> | |
<div class="metric-label">状态</div> | |
<div class="metric-value status">${instance.status}</div> | |
</div> | |
<div class="metric-item"> | |
<div class="metric-label">CPU</div> | |
<div class="metric-value cpu-usage">N/A</div> | |
</div> | |
<div class="metric-item"> | |
<div class="metric-label">内存</div> | |
<div class="metric-value memory-usage">N/A</div> | |
</div> | |
<div class="metric-item"> | |
<div class="metric-label">上传</div> | |
<div class="metric-value upload">N/A</div> | |
</div> | |
<div class="metric-item"> | |
<div class="metric-label">下载</div> | |
<div class="metric-value download">N/A</div> | |
</div> | |
</div> | |
<div class="action-buttons" style="display: ${isLoggedIn ? 'flex' : 'none'};"> | |
<button class="action-button" onclick="showConfirmDialog('restart', '${instance.repo_id}', '确认重启', '您确定要重启实例 ${instance.name} (${instance.repo_id}) 吗?')">重启</button> | |
<button class="action-button" onclick="showConfirmDialog('rebuild', '${instance.repo_id}', '确认重建', '您确定要重建实例 ${instance.name} (${instance.repo_id}) 吗?')">重建</button> | |
</div> | |
<div class="chart-container" id="chart-container-${instanceId}"> | |
<canvas id="chart-${instanceId}"></canvas> | |
</div> | |
`; | |
container.appendChild(card); | |
} | |
const statusDot = card.querySelector('.status-dot'); | |
const initialStatus = instance.status.toLowerCase(); | |
if (initialStatus === 'running') { | |
statusDot.className = 'status-dot status-online'; | |
} else if (initialStatus === 'sleeping') { | |
statusDot.className = 'status-dot status-sleep'; | |
} else { | |
statusDot.className = 'status-dot status-offline'; | |
} | |
serverStatus.set(instanceId, { lastSeen: Date.now(), isOnline: initialStatus === 'running', isSleep: initialStatus === 'sleeping', data: null, status: instance.status }); | |
} | |
// 切换图表显示/隐藏 | |
function toggleChart(instanceId) { | |
const card = document.getElementById(`instance-${instanceId}`); | |
const chartContainer = document.getElementById(`chart-container-${instanceId}`); | |
const toggleButton = card.querySelector('.chart-toggle-button'); | |
if (!card || !chartContainer) return; | |
if (card.classList.contains('expanded')) { | |
card.classList.remove('expanded'); | |
toggleButton.textContent = '查看图表'; | |
} else { | |
card.classList.add('expanded'); | |
toggleButton.textContent = '收起图表'; | |
// 如果图表未初始化,则创建 | |
if (!chartInstances.has(instanceId)) { | |
createChart(instanceId); | |
} | |
} | |
} | |
function updateServerCard(data, instanceId, isSleep = false) { | |
const cardId = `instance-${instanceId}`; | |
let card = document.getElementById(cardId); | |
const instance = instanceMap.get(instanceId); | |
if (!card && instance) { | |
// 如果卡片不存在,但实例存在,说明可能被过滤掉了,不渲染 | |
return; | |
} | |
if (card) { | |
const statusDot = card.querySelector('.status-dot'); | |
let upload = 'N/A', download = 'N/A', cpuUsage = 'N/A', memoryUsage = 'N/A'; | |
let isOnline = false; | |
if (data) { | |
cpuUsage = `${data.cpu_usage_pct}%`; | |
memoryUsage = `${((data.memory_used_bytes / data.memory_total_bytes) * 100).toFixed(2)}%`; | |
upload = `${formatBytes(data.tx_bps)}/s`; | |
download = `${formatBytes(data.rx_bps)}/s`; | |
statusDot.className = 'status-dot status-online'; | |
isOnline = true; | |
isSleep = false; | |
// 更新图表数据 | |
updateChart(instanceId, data); | |
} else { | |
const currentStatus = instance?.status.toLowerCase() || 'unknown'; | |
if (currentStatus === 'running') { | |
statusDot.className = 'status-dot status-online'; | |
isOnline = true; | |
isSleep = false; | |
} else if (currentStatus === 'sleeping') { | |
statusDot.className = 'status-dot status-sleep'; | |
isOnline = false; | |
isSleep = true; | |
} else { | |
statusDot.className = 'status-dot status-offline'; | |
isOnline = false; | |
isSleep = false; | |
} | |
} | |
card.querySelector('.cpu-usage').textContent = cpuUsage; | |
card.querySelector('.memory-usage').textContent = memoryUsage; | |
card.querySelector('.upload').textContent = upload; | |
card.querySelector('.download').textContent = download; | |
serverStatus.set(instanceId, { lastSeen: Date.now(), isOnline, isSleep, data: data || null, status: instance?.status || 'unknown' }); | |
updateSummary(); | |
} | |
} | |
async function restartSpace(repoId) { | |
try { | |
const token = localStorage.getItem('authToken'); | |
if (!token || !isLoggedIn) { | |
alert('请先登录以执行此操作'); | |
showLoginForm(); | |
return; | |
} | |
showLoading(); | |
const encodedRepoId = encodeURIComponent(repoId); | |
const response = await fetch(`/api/proxy/restart/${encodedRepoId}`, { | |
method: 'POST', | |
headers: { | |
'Authorization': `Bearer ${token}` | |
} | |
}); | |
const result = await response.json(); | |
hideLoading(); | |
if (result.success) { | |
alert(`重启成功: ${repoId}`); | |
// 操作成功后立即刷新数据 | |
refreshData(); | |
} else { | |
if (response.status === 401) { | |
alert('登录已过期,请重新登录'); | |
localStorage.removeItem('authToken'); | |
isLoggedIn = false; | |
document.getElementById('loginButton').style.display = 'block'; | |
document.getElementById('logoutButton').style.display = 'none'; | |
updateActionButtons(false); | |
showLoginForm(); | |
} else { | |
alert(`重启失败: ${result.error || '未知错误'}`); | |
console.error(`重启失败 (${repoId}):`, result.error, result.details); | |
} | |
} | |
} catch (error) { | |
hideLoading(); | |
console.error(`重启失败 (${repoId}):`, error); | |
alert(`重启失败: ${error.message}`); | |
} | |
} | |
async function rebuildSpace(repoId) { | |
try { | |
const token = localStorage.getItem('authToken'); | |
if (!token || !isLoggedIn) { | |
alert('请先登录以执行此操作'); | |
showLoginForm(); | |
return; | |
} | |
showLoading(); | |
const encodedRepoId = encodeURIComponent(repoId); | |
const response = await fetch(`/api/proxy/rebuild/${encodedRepoId}`, { | |
method: 'POST', | |
headers: { | |
'Authorization': `Bearer ${token}` | |
} | |
}); | |
const result = await response.json(); | |
hideLoading(); | |
if (result.success) { | |
alert(`重建成功: ${repoId}`); | |
// 操作成功后立即刷新数据 | |
refreshData(); | |
} else { | |
if (response.status === 401) { | |
alert('登录已过期,请重新登录'); | |
localStorage.removeItem('authToken'); | |
isLoggedIn = false; | |
document.getElementById('loginButton').style.display = 'block'; | |
document.getElementById('logoutButton').style.display = 'none'; | |
updateActionButtons(false); | |
showLoginForm(); | |
} else { | |
alert(`重建失败: ${result.error || '未知错误'}`); | |
console.error(`重建失败 (${repoId}):`, result.error, result.details); | |
} | |
} | |
} catch (error) { | |
hideLoading(); | |
console.error(`重建失败 (${repoId}):`, error); | |
alert(`重建失败: ${error.message}`); | |
} | |
} | |
function updateSummary() { | |
let online = 0; | |
let offline = 0; | |
let totalUpload = 0; | |
let totalDownload = 0; | |
serverStatus.forEach((status, instanceId) => { | |
const isRecentlyOnline = status.isOnline || status.status.toLowerCase() === 'running'; | |
if (isRecentlyOnline) { | |
online++; | |
if (status.data) { | |
totalUpload += parseFloat(status.data.tx_bps) || 0; | |
totalDownload += parseFloat(status.data.rx_bps) || 0; | |
} | |
} else { | |
offline++; | |
} | |
}); | |
document.getElementById('totalServers').textContent = serverStatus.size; | |
document.getElementById('onlineServers').textContent = online; | |
document.getElementById('offlineServers').textContent = offline; | |
document.getElementById('totalUpload').textContent = `${formatBytes(totalUpload)}/s`; | |
document.getElementById('totalDownload').textContent = `${formatBytes(totalDownload)}/s`; | |
} | |
function formatBytes(bytes) { | |
if (bytes === 0) return '0 B'; | |
const k = 1024; | |
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; | |
const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; | |
} | |
setInterval(updateSummary, 5000); | |
setInterval(async () => { | |
metricsManager.disconnectAll(); | |
await initialize(); | |
}, 300000); | |
// 应用过滤和排序 | |
function applyFiltersAndSort() { | |
const statusFilter = document.getElementById('statusFilter').value; | |
const userFilter = document.getElementById('userFilter').value; | |
const sortBy = document.getElementById('sortBy').value; | |
// 过滤实例 | |
let filteredInstances = allInstances; | |
if (statusFilter !== 'all') { | |
filteredInstances = filteredInstances.filter(instance => instance.status.toLowerCase() === statusFilter); | |
} | |
if (userFilter !== 'all') { | |
filteredInstances = filteredInstances.filter(instance => instance.owner === userFilter); | |
} | |
// 排序实例 | |
filteredInstances.sort((a, b) => { | |
if (sortBy === 'name-asc') { | |
return a.name.localeCompare(b.name); | |
} else if (sortBy === 'name-desc') { | |
return b.name.localeCompare(a.name); | |
} else if (sortBy === 'status-asc') { | |
const statusOrder = { 'running': 0, 'sleeping': 1, 'stopped': 2 }; | |
return statusOrder[a.status.toLowerCase()] - statusOrder[b.status.toLowerCase()]; | |
} else if (sortBy === 'status-desc') { | |
const statusOrder = { 'running': 2, 'sleeping': 1, 'stopped': 0 }; | |
return statusOrder[a.status.toLowerCase()] - statusOrder[b.status.toLowerCase()]; | |
} | |
return 0; | |
}); | |
// 重新渲染过滤和排序后的实例 | |
instanceMap.clear(); | |
serverStatus.clear(); | |
metricsManager.disconnectAll(); | |
// 销毁现有图表实例 | |
chartInstances.forEach(chart => { | |
if (chart) { | |
chart.destroy(); | |
} | |
}); | |
chartInstances.clear(); | |
renderInstances(filteredInstances); | |
filteredInstances.forEach(instance => { | |
if (instance.status.toLowerCase() === 'running') { | |
metricsManager.connect(instance.repo_id, instance.owner); | |
} | |
}); | |
updateSummary(); | |
updateActionButtons(isLoggedIn); | |
} | |
</script> | |
</body> | |
</html> |