|
<!DOCTYPE html> |
|
<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"> |
|
<title>HF Space Manager</title> |
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script> |
|
|
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700&display=swap"> |
|
<style> |
|
* { |
|
margin: 0; |
|
padding: 0; |
|
box-sizing: border-box; |
|
-webkit-font-smoothing: antialiased; |
|
-moz-osx-font-smoothing: grayscale; |
|
} |
|
body { |
|
font-family: 'Orbitron', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; |
|
background: var(--background-color); |
|
color: var(--text-color); |
|
padding: 20px; |
|
min-height: 100vh; |
|
background-image: radial-gradient(rgba(0, 212, 255, 0.1) 1px, transparent 1px), radial-gradient(rgba(255, 0, 255, 0.1) 1px, transparent 1px); |
|
background-size: 40px 40px; |
|
background-position: 0 0, 20px 20px; |
|
} |
|
:root { |
|
|
|
--background-color: #0A0A1E; |
|
--text-color: #E0E0FF; |
|
--secondary-text: #A0A0CC; |
|
--card-background: rgba(20, 20, 40, 0.7); |
|
--card-border: rgba(0, 212, 255, 0.3); |
|
--metric-background: rgba(30, 30, 50, 0.6); |
|
--metric-border: rgba(0, 212, 255, 0.2); |
|
--metric-hover: rgba(40, 40, 70, 0.8); |
|
--label-color: #A0A0CC; |
|
--action-button-bg: rgba(0, 212, 255, 0.2); |
|
--action-button-hover: rgba(0, 212, 255, 0.4); |
|
--accent-color: #00D4FF; |
|
--neon-pink: #FF00FF; |
|
--neon-green: #00FFAA; |
|
} |
|
.container { |
|
max-width: 1400px; |
|
margin: 0 auto; |
|
padding: 0 15px; |
|
} |
|
.overview { |
|
background: var(--card-background); |
|
border-radius: 8px; |
|
padding: 20px; |
|
margin-bottom: 25px; |
|
border: 1px solid var(--card-border); |
|
box-shadow: 0 0 10px rgba(0, 212, 255, 0.3), inset 0 0 2px rgba(0, 212, 255, 0.5); |
|
transition: background 0.3s ease, border 0.3s ease; |
|
} |
|
.overview-title { |
|
font-size: 18px; |
|
font-weight: 700; |
|
display: flex; |
|
align-items: center; |
|
gap: 8px; |
|
margin-bottom: 16px; |
|
color: var(--accent-color); |
|
text-shadow: 0 0 5px rgba(0, 212, 255, 0.5); |
|
} |
|
#summary { |
|
display: grid; |
|
grid-template-columns: repeat(6, 1fr); |
|
gap: 12px; |
|
} |
|
#summary div { |
|
background: var(--metric-background); |
|
padding: 14px; |
|
border-radius: 6px; |
|
border: 1px solid var(--metric-border); |
|
transition: background 0.3s ease; |
|
box-shadow: inset 0 0 3px rgba(0, 212, 255, 0.3); |
|
} |
|
#summary div { |
|
font-size: 13px; |
|
font-weight: 500; |
|
color: var(--secondary-text); |
|
} |
|
#summary span { |
|
display: block; |
|
font-size: 22px; |
|
font-weight: 700; |
|
margin-top: 6px; |
|
color: var(--text-color); |
|
text-shadow: 0 0 3px rgba(0, 212, 255, 0.3); |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
white-space: nowrap; |
|
} |
|
.stats-container { |
|
display: grid; |
|
grid-template-columns: 1fr; |
|
gap: 24px; |
|
margin-top: 20px; |
|
} |
|
.user-group { |
|
background: var(--card-background); |
|
border-radius: 8px; |
|
border: 1px solid var(--card-border); |
|
overflow: hidden; |
|
box-shadow: 0 0 8px rgba(0, 212, 255, 0.2); |
|
transition: background 0.3s ease, border 0.3s ease; |
|
} |
|
.user-group summary { |
|
padding: 16px; |
|
font-weight: 600; |
|
cursor: pointer; |
|
color: var(--accent-color); |
|
background: var(--metric-background); |
|
transition: background 0.2s ease; |
|
text-shadow: 0 0 4px rgba(0, 212, 255, 0.4); |
|
} |
|
.user-group summary:hover { |
|
background: var(--metric-hover); |
|
box-shadow: inset 0 0 5px rgba(0, 212, 255, 0.5); |
|
} |
|
.user-group summary::-webkit-details-marker { |
|
color: var(--accent-color); |
|
} |
|
.user-servers { |
|
display: grid; |
|
grid-template-columns: repeat(2, 1fr); |
|
gap: 16px; |
|
padding: 16px; |
|
} |
|
.server-card { |
|
background: var(--metric-background); |
|
border-radius: 6px; |
|
padding: 16px; |
|
border: 1px solid var(--metric-border); |
|
transition: background 0.2s ease, box-shadow 0.2s ease; |
|
min-height: 150px; |
|
display: flex; |
|
flex-direction: column; |
|
box-shadow: 0 0 5px rgba(0, 212, 255, 0.2); |
|
position: relative; |
|
overflow: hidden; |
|
} |
|
.server-card.not-logged-in { |
|
min-height: 120px; |
|
} |
|
.server-card:hover { |
|
background: var(--metric-hover); |
|
box-shadow: 0 0 12px rgba(0, 212, 255, 0.4); |
|
} |
|
.server-card::before { |
|
content: ''; |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
width: 100%; |
|
height: 2px; |
|
background: linear-gradient(90deg, var(--accent-color), var(--neon-pink)); |
|
opacity: 0.7; |
|
} |
|
.server-header { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
margin-bottom: 12px; |
|
font-size: 14px; |
|
} |
|
.server-name { |
|
display: flex; |
|
align-items: center; |
|
gap: 8px; |
|
flex: 1; |
|
min-width: 0; |
|
cursor: pointer; |
|
position: relative; |
|
} |
|
.server-name:hover { |
|
opacity: 0.8; |
|
} |
|
.server-name::after { |
|
content: '▼'; |
|
font-size: 12px; |
|
color: var(--accent-color); |
|
margin-left: 8px; |
|
transition: transform 0.3s ease; |
|
} |
|
.server-card.info-expanded .server-name::after { |
|
transform: rotate(180deg); |
|
} |
|
.server-name div { |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
white-space: nowrap; |
|
max-width: 100%; |
|
font-weight: 500; |
|
color: var(--accent-color); |
|
text-shadow: 0 0 3px rgba(0, 212, 255, 0.5); |
|
} |
|
.server-flag { |
|
width: 20px; |
|
height: 20px; |
|
border-radius: 6px; |
|
flex-shrink: 0; |
|
filter: drop-shadow(0 0 3px rgba(0, 212, 255, 0.3)); |
|
} |
|
.metric-grid { |
|
display: grid; |
|
grid-template-columns: repeat(5, 1fr); |
|
gap: 10px; |
|
margin-top: 12px; |
|
} |
|
.metric-item { |
|
background: var(--card-background); |
|
padding: 10px; |
|
border-radius: 4px; |
|
border: 1px solid var(--metric-border); |
|
transition: background 0.2s ease; |
|
overflow: hidden; |
|
box-shadow: inset 0 0 3px rgba(0, 212, 255, 0.2); |
|
} |
|
.metric-item:hover { |
|
background: var(--metric-hover); |
|
box-shadow: inset 0 0 5px rgba(0, 212, 255, 0.4); |
|
} |
|
.metric-label { |
|
color: var(--label-color); |
|
font-size: 12px; |
|
margin-bottom: 4px; |
|
white-space: nowrap; |
|
} |
|
.metric-value { |
|
font-size: 14px; |
|
font-weight: 600; |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
white-space: nowrap; |
|
max-width: 100%; |
|
color: var(--text-color); |
|
text-shadow: 0 0 2px rgba(0, 212, 255, 0.3); |
|
} |
|
|
|
.info-block { |
|
background: var(--card-background); |
|
padding: 10px; |
|
border-radius: 4px; |
|
border: 1px solid var(--metric-border); |
|
margin-top: 12px; |
|
transition: background 0.2s ease, height 0.3s ease; |
|
overflow: hidden; |
|
box-shadow: inset 0 0 3px rgba(0, 212, 255, 0.2); |
|
display: none; |
|
} |
|
.server-card.info-expanded .info-block { |
|
display: block; |
|
} |
|
.info-block:hover { |
|
background: var(--metric-hover); |
|
box-shadow: inset 0 0 5px rgba(0, 212, 255, 0.4); |
|
} |
|
.info-item { |
|
margin-bottom: 6px; |
|
} |
|
.info-item:last-child { |
|
margin-bottom: 0; |
|
} |
|
.info-label { |
|
color: var(--label-color); |
|
font-size: 12px; |
|
margin-bottom: 4px; |
|
white-space: nowrap; |
|
} |
|
.info-value { |
|
font-size: 14px; |
|
font-weight: 500; |
|
color: var(--text-color); |
|
text-shadow: 0 0 2px rgba(0, 212, 255, 0.3); |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
display: -webkit-box; |
|
-webkit-line-clamp: 2; |
|
-webkit-box-orient: vertical; |
|
line-clamp: 2; |
|
} |
|
.status-dot { |
|
display: inline-block; |
|
border-radius: 50%; |
|
animation: pulse 2s infinite; |
|
width: 10px; |
|
height: 10px; |
|
flex-shrink: 0; |
|
} |
|
.status-online { |
|
background-color: var(--neon-green); |
|
color: var(--neon-green); |
|
box-shadow: 0 0 8px rgba(0, 255, 170, 0.6); |
|
} |
|
.status-offline { |
|
background-color: #ff3b30; |
|
color: #ff3b30; |
|
box-shadow: 0 0 8px rgba(255, 59, 48, 0.6); |
|
} |
|
.status-sleep { |
|
background-color: var(--neon-pink); |
|
color: var(--neon-pink); |
|
box-shadow: 0 0 8px rgba(255, 0, 255, 0.6); |
|
animation: none; |
|
} |
|
.action-buttons { |
|
display: flex; |
|
gap: 10px; |
|
margin-top: 12px; |
|
} |
|
.action-button { |
|
background: var(--action-button-bg); |
|
color: var(--accent-color); |
|
border: none; |
|
padding: 8px 14px; |
|
border-radius: 6px; |
|
cursor: pointer; |
|
font-size: 14px; |
|
font-weight: 500; |
|
transition: background 0.2s ease, box-shadow 0.2s ease; |
|
text-shadow: 0 0 3px rgba(0, 212, 255, 0.5); |
|
position: relative; |
|
overflow: hidden; |
|
} |
|
.action-button:hover { |
|
background: var(--action-button-hover); |
|
box-shadow: 0 0 10px rgba(0, 212, 255, 0.5); |
|
} |
|
.action-button::after { |
|
content: ''; |
|
position: absolute; |
|
top: 50%; |
|
left: 50%; |
|
width: 200%; |
|
height: 200%; |
|
background: radial-gradient(circle, rgba(0, 212, 255, 0.2) 0%, transparent 70%); |
|
transform: translate(-50%, -50%); |
|
opacity: 0; |
|
transition: opacity 0.3s ease; |
|
} |
|
.action-button:hover::after { |
|
opacity: 1; |
|
} |
|
.network-stats { |
|
background: var(--metric-background); |
|
border: 1px solid var(--metric-border); |
|
margin-top: 20px; |
|
padding: 16px; |
|
border-radius: 6px; |
|
transition: background 0.3s ease; |
|
box-shadow: 0 0 5px rgba(0, 212, 255, 0.2); |
|
} |
|
.network-item { |
|
font-size: 14px; |
|
color: var(--secondary-text); |
|
} |
|
@keyframes pulse { |
|
0% { box-shadow: 0 0 0 0 rgba(0, 255, 170, 0.6); } |
|
70% { box-shadow: 0 0 0 8px rgba(0, 255, 170, 0); } |
|
100% { box-shadow: 0 0 0 0 rgba(0, 255, 170, 0); } |
|
} |
|
@media (max-width: 900px) { |
|
#summary { |
|
grid-template-columns: repeat(3, 1fr); |
|
gap: 10px; |
|
} |
|
#summary div { |
|
padding: 10px; |
|
} |
|
#summary span { |
|
font-size: 20px; |
|
} |
|
.metric-grid { |
|
grid-template-columns: repeat(3, 1fr); |
|
gap: 8px; |
|
} |
|
.metric-item { |
|
padding: 8px; |
|
} |
|
.metric-value { |
|
font-size: 13px; |
|
} |
|
.info-block { |
|
padding: 8px; |
|
} |
|
.info-value { |
|
font-size: 13px; |
|
} |
|
} |
|
@media (max-width: 600px) { |
|
#summary { |
|
grid-template-columns: repeat(2, 1fr); |
|
gap: 8px; |
|
} |
|
#summary div { |
|
padding: 8px; |
|
} |
|
#summary span { |
|
font-size: 18px; |
|
} |
|
.user-servers { |
|
grid-template-columns: 1fr !important; |
|
} |
|
.metric-grid { |
|
grid-template-columns: repeat(2, 1fr); |
|
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: 16px; |
|
margin-bottom: 20px; |
|
} |
|
.info-block { |
|
padding: 6px; |
|
} |
|
.info-value { |
|
font-size: 12px; |
|
-webkit-line-clamp: 1; |
|
line-clamp: 1; |
|
} |
|
} |
|
@media (max-width: 400px) { |
|
#summary { |
|
grid-template-columns: repeat(2, 1fr); |
|
gap: 6px; |
|
} |
|
#summary div { |
|
padding: 6px; |
|
} |
|
#summary span { |
|
font-size: 16px; |
|
} |
|
.metric-grid { |
|
grid-template-columns: repeat(2, 1fr); |
|
gap: 6px; |
|
} |
|
.metric-item { |
|
padding: 5px; |
|
} |
|
.metric-value { |
|
font-size: 12px; |
|
} |
|
.info-block { |
|
padding: 5px; |
|
} |
|
.info-value { |
|
font-size: 11px; |
|
} |
|
} |
|
.login-overlay, .confirm-overlay, .loading-overlay { |
|
position: fixed; |
|
top: 0; |
|
left: 0; |
|
width: 100%; |
|
height: 100%; |
|
background: rgba(10, 10, 30, 0.7); |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
z-index: 1000; |
|
display: none; |
|
} |
|
.login-box, .confirm-box { |
|
background: var(--card-background); |
|
padding: 24px; |
|
border-radius: 8px; |
|
border: 1px solid var(--card-border); |
|
width: 320px; |
|
text-align: center; |
|
box-shadow: 0 0 15px rgba(0, 212, 255, 0.4); |
|
position: relative; |
|
} |
|
.login-box::before, .confirm-box::before { |
|
content: ''; |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
width: 100%; |
|
height: 3px; |
|
background: linear-gradient(90deg, var(--accent-color), var(--neon-pink)); |
|
} |
|
.login-box h2, .confirm-box h2 { |
|
margin-bottom: 20px; |
|
color: var(--accent-color); |
|
font-size: 18px; |
|
font-weight: 600; |
|
text-shadow: 0 0 5px rgba(0, 212, 255, 0.5); |
|
} |
|
.login-box input { |
|
width: 100%; |
|
padding: 12px 16px; |
|
margin: 12px 0; |
|
border: 1px solid var(--metric-border); |
|
border-radius: 6px; |
|
background: var(--metric-background); |
|
color: var(--text-color); |
|
font-size: 16px; |
|
transition: border 0.2s ease, box-shadow 0.2s ease; |
|
} |
|
.login-box input:focus { |
|
border-color: var(--accent-color); |
|
box-shadow: 0 0 8px rgba(0, 212, 255, 0.5); |
|
outline: none; |
|
} |
|
.login-box button, .confirm-box button { |
|
width: 48%; |
|
padding: 12px; |
|
background: var(--action-button-bg); |
|
border: none; |
|
border-radius: 6px; |
|
color: var(--accent-color); |
|
cursor: pointer; |
|
font-size: 16px; |
|
font-weight: 500; |
|
transition: background 0.2s ease, box-shadow 0.2s ease; |
|
margin: 10px 1%; |
|
text-shadow: 0 0 3px rgba(0, 212, 255, 0.5); |
|
} |
|
.login-box button:hover, .confirm-box button:hover { |
|
background: var(--action-button-hover); |
|
box-shadow: 0 0 10px rgba(0, 212, 255, 0.5); |
|
} |
|
.login-box button:last-child, .confirm-box button:last-child { |
|
background: transparent; |
|
color: var(--neon-pink); |
|
} |
|
.login-box button:last-child:hover, .confirm-box button:last-child:hover { |
|
background: rgba(255, 0, 255, 0.1); |
|
box-shadow: 0 0 10px rgba(255, 0, 255, 0.5); |
|
} |
|
.login-error { |
|
color: #ff3b30; |
|
margin-top: 12px; |
|
font-size: 14px; |
|
text-shadow: 0 0 3px rgba(255, 59, 48, 0.5); |
|
} |
|
.login-button, .logout-button { |
|
background: var(--action-button-bg); |
|
border: none; |
|
color: var(--accent-color); |
|
padding: 8px 16px; |
|
border-radius: 6px; |
|
cursor: pointer; |
|
font-size: 14px; |
|
font-weight: 500; |
|
transition: background 0.2s ease, box-shadow 0.2s ease; |
|
text-shadow: 0 0 3px rgba(0, 212, 255, 0.5); |
|
} |
|
.login-button:hover, .logout-button:hover { |
|
background: var(--action-button-hover); |
|
box-shadow: 0 0 8px rgba(0, 212, 255, 0.5); |
|
} |
|
.header-container { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
margin-bottom: 16px; |
|
} |
|
.auth-buttons { |
|
display: flex; |
|
gap: 10px; |
|
} |
|
|
|
.loader { |
|
border: 5px solid transparent; |
|
border-top: 5px solid var(--accent-color); |
|
border-radius: 50%; |
|
width: 50px; |
|
height: 50px; |
|
animation: spin 1s linear infinite; |
|
box-shadow: 0 0 15px rgba(0, 212, 255, 0.6); |
|
} |
|
@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: 8px; |
|
padding: 16px; |
|
margin-bottom: 20px; |
|
display: flex; |
|
flex-wrap: wrap; |
|
gap: 16px; |
|
align-items: center; |
|
box-shadow: 0 0 8px rgba(0, 212, 255, 0.2); |
|
transition: background 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; |
|
text-shadow: 0 0 2px rgba(0, 212, 255, 0.3); |
|
} |
|
.filter-sort-group select { |
|
flex: 1; |
|
background: var(--metric-background); |
|
border: 1px solid var(--metric-border); |
|
color: var(--text-color); |
|
padding: 10px 14px; |
|
border-radius: 6px; |
|
cursor: pointer; |
|
font-size: 14px; |
|
transition: background 0.2s ease, border 0.2s ease, box-shadow 0.2s ease; |
|
outline: none; |
|
appearance: none; |
|
background-image: url("data:image/svg+xml;utf8,<svg fill='%2300D4FF' 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; |
|
text-shadow: 0 0 2px rgba(0, 212, 255, 0.3); |
|
} |
|
.filter-sort-group select:hover { |
|
background-color: var(--metric-hover); |
|
box-shadow: 0 0 6px rgba(0, 212, 255, 0.4); |
|
} |
|
.filter-sort-group select:focus { |
|
border-color: var(--accent-color); |
|
box-shadow: 0 0 8px rgba(0, 212, 255, 0.5); |
|
} |
|
.refresh-button { |
|
background: var(--action-button-bg); |
|
border: none; |
|
color: var(--accent-color); |
|
padding: 8px 16px; |
|
border-radius: 6px; |
|
cursor: pointer; |
|
font-size: 14px; |
|
font-weight: 500; |
|
transition: background 0.2s ease, box-shadow 0.2s ease; |
|
display: flex; |
|
align-items: center; |
|
gap: 8px; |
|
text-shadow: 0 0 3px rgba(0, 212, 255, 0.5); |
|
} |
|
.refresh-button:hover { |
|
background: var(--action-button-hover); |
|
box-shadow: 0 0 10px rgba(0, 212, 255, 0.5); |
|
} |
|
.refresh-icon { |
|
width: 16px; |
|
height: 16px; |
|
fill: var(--accent-color); |
|
} |
|
@media (max-width: 600px) { |
|
.filter-sort-group { |
|
min-width: 100%; |
|
} |
|
.filter-sort-panel { |
|
gap: 14px; |
|
padding: 14px; |
|
} |
|
} |
|
|
|
.chart-container { |
|
display: none; |
|
margin-top: 16px; |
|
background: var(--card-background); |
|
border: 1px solid var(--card-border); |
|
border-radius: 6px; |
|
padding: 12px; |
|
height: 300px; |
|
transition: background 0.3s ease; |
|
box-shadow: 0 0 8px rgba(0, 212, 255, 0.3); |
|
} |
|
.chart-toggle-button { |
|
background: var(--action-button-bg); |
|
color: var(--accent-color); |
|
border: none; |
|
padding: 8px 16px; |
|
border-radius: 6px; |
|
cursor: pointer; |
|
font-size: 14px; |
|
font-weight: 500; |
|
transition: background 0.2s ease, box-shadow 0.2s ease; |
|
margin-left: auto; |
|
white-space: nowrap; |
|
text-shadow: 0 0 3px rgba(0, 212, 255, 0.5); |
|
} |
|
.chart-toggle-button:hover { |
|
background: var(--action-button-hover); |
|
box-shadow: 0 0 8px rgba(0, 212, 255, 0.5); |
|
} |
|
.expanded .chart-container { |
|
display: block; |
|
} |
|
canvas { |
|
width: 100% !important; |
|
height: auto !important; |
|
} |
|
@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 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="用户名" aria-label="用户名"> |
|
<input type="password" id="password" placeholder="密码" aria-label="密码"> |
|
<div style="display: flex; justify-content: center; gap: 10px; margin-top: 20px;"> |
|
<button onclick="login()">登录</button> |
|
<button onclick="hideLoginForm()">取消</button> |
|
</div> |
|
<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> |
|
|
|
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'; |
|
document.getElementById('username').focus(); |
|
} |
|
|
|
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); |
|
refreshData(); |
|
} 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); |
|
refreshData(); |
|
}) |
|
.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); |
|
refreshData(); |
|
}); |
|
} else { |
|
console.log('本地无 token,直接设置为未登录'); |
|
isLoggedIn = false; |
|
document.getElementById('loginButton').style.display = 'block'; |
|
document.getElementById('logoutButton').style.display = 'none'; |
|
updateActionButtons(false); |
|
refreshData(); |
|
} |
|
} |
|
|
|
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'; |
|
} |
|
|
|
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 = async function() { |
|
console.log('页面加载完成,开始检查登录状态'); |
|
await checkLoginStatus(); |
|
console.log('登录状态检查完成,初始化数据'); |
|
await 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 token = localStorage.getItem('authToken'); |
|
const headers = {}; |
|
if (token) { |
|
headers['Authorization'] = `Bearer ${token}`; |
|
console.log('getUsernames 请求中附加 Token:', token.slice(0, 8) + '...'); |
|
} |
|
const response = await fetch('/api/config', { headers }); |
|
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 token = localStorage.getItem('authToken'); |
|
const headers = {}; |
|
if (token) { |
|
headers['Authorization'] = `Bearer ${token}`; |
|
console.log('fetchInstances 请求中附加 Token:', token.slice(0, 8) + '...'); |
|
} else { |
|
console.log('无可用 Token,未附加 Authorization 头'); |
|
} |
|
const response = await fetch('/api/proxy/spaces', { headers }); |
|
const instances = await response.json(); |
|
console.log('从后端获取的实例列表:', instances); |
|
hideLoading(); |
|
if (instances.length === 0) { |
|
alert('未获取到实例数据,可能是网络问题或数据暂不可用。'); |
|
} |
|
return instances; |
|
} catch (error) { |
|
hideLoading(); |
|
console.error("获取实例列表失败:", error); |
|
alert('获取实例列表失败,请稍后重试。'); |
|
return []; |
|
} |
|
} |
|
|
|
class MetricsStreamManager { |
|
constructor() { |
|
this.eventSource = null; |
|
} |
|
|
|
connect(subscribedInstances = []) { |
|
if (this.eventSource) { |
|
this.eventSource.close(); |
|
} |
|
|
|
const instancesParam = subscribedInstances.join(','); |
|
const token = localStorage.getItem('authToken'); |
|
|
|
const url = `/api/proxy/live-metrics-stream?instances=${encodeURIComponent(instancesParam)}&token=${encodeURIComponent(token || '')}`; |
|
console.log('SSE 连接 URL:', url.split('&token=')[0] + (token ? '&token=... (隐藏)' : '&token=空')); |
|
this.eventSource = new EventSource(url); |
|
|
|
this.eventSource.addEventListener("metric", (event) => { |
|
try { |
|
const data = JSON.parse(event.data); |
|
const { repoId, metrics } = data; |
|
updateServerCard(metrics, repoId); |
|
} catch (error) { |
|
console.error(`解析监控数据失败:`, error); |
|
} |
|
}); |
|
|
|
this.eventSource.onerror = (error) => { |
|
console.error(`SSE 连接错误:`, error); |
|
this.eventSource.close(); |
|
this.eventSource = null; |
|
|
|
setTimeout(() => this.connect(subscribedInstances), 5000); |
|
}; |
|
|
|
console.log(`SSE 连接已建立,订阅实例: ${instancesParam || '无'}`); |
|
} |
|
|
|
disconnect() { |
|
if (this.eventSource) { |
|
this.eventSource.close(); |
|
this.eventSource = null; |
|
console.log(`SSE 连接已断开`); |
|
} |
|
} |
|
} |
|
|
|
const metricsStreamManager = new MetricsStreamManager(); |
|
const instanceMap = new Map(); |
|
const serverStatus = new Map(); |
|
let allInstances = []; |
|
const chartInstances = new Map(); |
|
const chartDataBuffer = new Map(); |
|
|
|
async function initialize() { |
|
await getUsernames(); |
|
const instances = await fetchInstances(); |
|
allInstances = instances; |
|
renderInstances(allInstances); |
|
|
|
|
|
const runningInstances = instances |
|
.filter(instance => instance.status.toLowerCase() === 'running') |
|
.map(instance => instance.repo_id); |
|
|
|
metricsStreamManager.connect(runningInstances); |
|
|
|
updateSummary(); |
|
updateActionButtons(isLoggedIn); |
|
} |
|
|
|
|
|
async function refreshData() { |
|
metricsStreamManager.disconnect(); |
|
|
|
chartInstances.forEach(chart => { |
|
if (chart) { |
|
chart.destroy(); |
|
} |
|
}); |
|
chartInstances.clear(); |
|
chartDataBuffer.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 gridColor = 'rgba(0, 212, 255, 0.1)'; |
|
const textColor = '#E0E0FF'; |
|
const cpuColor = '#00FFAA'; |
|
const memoryColor = '#00D4FF'; |
|
const uploadColor = '#FF9500'; |
|
const downloadColor = '#FF00FF'; |
|
|
|
const ctx = canvas.getContext('2d'); |
|
const chart = new Chart(ctx, { |
|
type: 'line', |
|
data: { |
|
labels: Array(30).fill(''), |
|
datasets: [ |
|
{ |
|
label: 'CPU 使用率 (%)', |
|
data: [], |
|
borderColor: cpuColor, |
|
backgroundColor: 'rgba(0, 255, 170, 0.2)', |
|
tension: 0.4, |
|
fill: true, |
|
}, |
|
{ |
|
label: '内存使用率 (%)', |
|
data: [], |
|
borderColor: memoryColor, |
|
backgroundColor: 'rgba(0, 212, 255, 0.2)', |
|
tension: 0.4, |
|
fill: true, |
|
}, |
|
{ |
|
label: '上传速度 (KB/s)', |
|
data: [], |
|
borderColor: uploadColor, |
|
backgroundColor: 'rgba(255, 149, 0, 0.2)', |
|
tension: 0.4, |
|
fill: true, |
|
}, |
|
{ |
|
label: '下载速度 (KB/s)', |
|
data: [], |
|
borderColor: downloadColor, |
|
backgroundColor: 'rgba(255, 0, 255, 0.2)', |
|
tension: 0.4, |
|
fill: true, |
|
}, |
|
] |
|
}, |
|
options: { |
|
responsive: true, |
|
maintainAspectRatio: false, |
|
plugins: { |
|
legend: { |
|
labels: { |
|
color: textColor, |
|
font: { size: 12, family: "'Orbitron', sans-serif" } |
|
} |
|
}, |
|
tooltip: { |
|
mode: 'index', |
|
intersect: false, |
|
backgroundColor: 'rgba(20, 20, 40, 0.9)', |
|
titleColor: textColor, |
|
bodyColor: textColor, |
|
padding: 12, |
|
boxPadding: 8, |
|
borderRadius: 6, |
|
borderWidth: 1, |
|
borderColor: 'rgba(0, 212, 255, 0.3)' |
|
} |
|
}, |
|
scales: { |
|
y: { |
|
beginAtZero: true, |
|
grid: { color: gridColor, drawTicks: false }, |
|
ticks: { |
|
color: textColor, |
|
font: { size: 11, family: "'Orbitron', sans-serif" } |
|
} |
|
}, |
|
x: { |
|
grid: { color: gridColor, drawTicks: false }, |
|
ticks: { |
|
color: textColor, |
|
font: { size: 11, family: "'Orbitron', sans-serif" }, |
|
maxRotation: 0, |
|
minRotation: 0, |
|
autoSkip: true, |
|
autoSkipPadding: 10 |
|
} |
|
} |
|
}, |
|
elements: { |
|
point: { |
|
radius: 0, |
|
hitRadius: 5 |
|
}, |
|
line: { |
|
borderWidth: 2 |
|
} |
|
}, |
|
animation: false |
|
} |
|
}); |
|
chartInstances.set(instanceId, chart); |
|
return chart; |
|
} |
|
|
|
|
|
function updateChart(instanceId, data) { |
|
let buffer = chartDataBuffer.get(instanceId) || { data: [], count: 0 }; |
|
buffer.data.push(data); |
|
buffer.count++; |
|
|
|
if (buffer.count < 2) { |
|
chartDataBuffer.set(instanceId, buffer); |
|
return; |
|
} |
|
|
|
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; |
|
|
|
|
|
const latestData = buffer.data[buffer.data.length - 1]; |
|
cpuData.push(latestData.cpu_usage_pct); |
|
memoryData.push(((latestData.memory_used_bytes / latestData.memory_total_bytes) * 100).toFixed(2)); |
|
uploadData.push((latestData.tx_bps / 1024).toFixed(2)); |
|
downloadData.push((latestData.rx_bps / 1024).toFixed(2)); |
|
|
|
|
|
if (cpuData.length > 30) { |
|
cpuData.shift(); |
|
memoryData.shift(); |
|
uploadData.shift(); |
|
downloadData.shift(); |
|
} |
|
|
|
|
|
chart.update(); |
|
|
|
|
|
chartDataBuffer.set(instanceId, { data: [], count: 0 }); |
|
} |
|
|
|
|
|
function formatRelativeTime(dateStr) { |
|
if (!dateStr) return '未知时间'; |
|
try { |
|
const date = new Date(dateStr); |
|
const now = new Date(); |
|
const diffMs = now - date; |
|
const diffSecs = Math.floor(diffMs / 1000); |
|
const diffMins = Math.floor(diffSecs / 60); |
|
const diffHrs = Math.floor(diffMins / 60); |
|
const diffDays = Math.floor(diffHrs / 24); |
|
|
|
if (diffDays > 7) { |
|
return date.toLocaleDateString(); |
|
} else if (diffDays > 0) { |
|
return `${diffDays}天前`; |
|
} else if (diffHrs > 0) { |
|
return `${diffHrs}小时前`; |
|
} else if (diffMins > 0) { |
|
return `${diffMins}分钟前`; |
|
} else { |
|
return '刚刚'; |
|
} |
|
} catch (error) { |
|
console.error('时间格式化失败:', error); |
|
return '未知时间'; |
|
} |
|
} |
|
|
|
|
|
function toggleInfoBlock(instanceId) { |
|
const card = document.getElementById(`instance-${instanceId}`); |
|
if (!card) return; |
|
|
|
card.classList.toggle('info-expanded'); |
|
} |
|
|
|
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'); |
|
} |
|
|
|
const iconSvg = instance.private |
|
? `<svg class="server-flag" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> |
|
<path d="M18 8v-3c0-1.656-1.344-3-3-3h-6c-1.656 0-3 1.344-3 3v3h-3v14h18v-14h-3zm-10-1.5c0-.828.672-1.5 1.5-1.5h5c.828 0 1.5.672 1.5 1.5v2.5h-8v-2.5zm4 11.5c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2z"/> |
|
</svg>` |
|
: `<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>`; |
|
|
|
|
|
const description = instance.short_description && instance.short_description.trim() ? instance.short_description : 'N/A'; |
|
|
|
const lastModified = formatRelativeTime(instance.last_modified); |
|
|
|
card.innerHTML = ` |
|
<div class="server-header"> |
|
<div class="server-name" onclick="toggleInfoBlock('${instanceId}')"> |
|
<div class="status-dot status-sleep"></div> |
|
${iconSvg} |
|
<div>${instance.name}</div> |
|
</div> |
|
<div> |
|
<button class="chart-toggle-button" onclick="toggleChart('${instanceId}')">查看图表</button> |
|
</div> |
|
</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="info-block"> |
|
<div class="info-item"> |
|
<div class="info-label">描述</div> |
|
<div class="info-value" title="${description}">${description}</div> |
|
</div> |
|
<div class="info-item"> |
|
<div class="info-label">最近更新</div> |
|
<div class="info-value">${lastModified}</div> |
|
</div> |
|
</div> |
|
<div class="action-buttons" style="display: ${isLoggedIn ? 'flex' : 'none'};"> |
|
<button class="action-button view-button" onclick="viewInstance('${instance.url}')">查看</button> |
|
<button class="action-button" onclick="manageInstance('${instance.repo_id}')">管理</button> |
|
<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 () => { |
|
metricsStreamManager.disconnect(); |
|
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(); |
|
metricsStreamManager.disconnect(); |
|
|
|
chartInstances.forEach(chart => { |
|
if (chart) { |
|
chart.destroy(); |
|
} |
|
}); |
|
chartInstances.clear(); |
|
chartDataBuffer.clear(); |
|
renderInstances(filteredInstances); |
|
|
|
|
|
const runningInstances = filteredInstances |
|
.filter(instance => instance.status.toLowerCase() === 'running') |
|
.map(instance => instance.repo_id); |
|
metricsStreamManager.connect(runningInstances); |
|
|
|
updateSummary(); |
|
updateActionButtons(isLoggedIn); |
|
} |
|
|
|
|
|
function viewInstance(url) { |
|
window.open(url, '_blank'); |
|
} |
|
|
|
|
|
function manageInstance(repoId) { |
|
const manageUrl = `https://huggingface.co/spaces/${repoId}`; |
|
window.open(manageUrl, '_blank'); |
|
} |
|
</script> |
|
</body> |
|
</html> |