Spaces:
Paused
Paused
| <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; /* 小屏幕图表高度略减 */ | |
| } | |
| } | |
| /* 监控复选框样式 */ | |
| input[type="checkbox"] { | |
| accent-color: #4CAF50; /* 选中时颜色 */ | |
| width: 16px; | |
| height: 16px; | |
| cursor: pointer; | |
| } | |
| label { | |
| color: var(--text-color); | |
| user-select: none; | |
| } | |
| </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="密码"> | |
| <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> | |
| // 主题切换功能 | |
| 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); | |
| refreshData(); // 登录成功后刷新数据以显示所有实例包括 private | |
| } 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(); // 登出后刷新数据以隐藏 private 实例 | |
| }) | |
| .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(); // 登出后刷新数据以隐藏 private 实例 | |
| }); | |
| } else { | |
| console.log('本地无 token,直接设置为未登录'); | |
| isLoggedIn = false; | |
| document.getElementById('loginButton').style.display = 'block'; | |
| document.getElementById('logoutButton').style.display = 'none'; | |
| updateActionButtons(false); | |
| refreshData(); // 登出后刷新数据以隐藏 private 实例 | |
| } | |
| } | |
| 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('登录状态检查完成,初始化数据'); | |
| 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; | |
| this.clientId = null; | |
| this.subscribedInstances = new Set(); | |
| } | |
| connect(subscribedInstances = []) { | |
| if (this.eventSource) { | |
| this.eventSource.close(); | |
| } | |
| this.subscribedInstances = new Set(subscribedInstances); | |
| const instancesParam = Array.from(this.subscribedInstances).join(','); | |
| const token = localStorage.getItem('authToken'); | |
| // 由于 EventSource 不支持直接设置 Authorization 头,这里通过查询参数传递 token | |
| 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(Array.from(this.subscribedInstances)), 5000); | |
| }; | |
| console.log(`SSE 连接已建立,订阅实例: ${instancesParam || '无'}`); | |
| } | |
| updateSubscriptions(newInstances) { | |
| if (!this.clientId) { | |
| this.clientId = localStorage.getItem('sseClientId') || crypto.randomUUID(); | |
| localStorage.setItem('sseClientId', this.clientId); | |
| } | |
| this.subscribedInstances = new Set(newInstances); | |
| fetch('/api/proxy/update-subscriptions', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| clientId: this.clientId, | |
| instances: Array.from(this.subscribedInstances) | |
| }) | |
| }) | |
| .then(response => response.json()) | |
| .then(data => { | |
| if (data.success) { | |
| console.log(`订阅更新成功: ${Array.from(this.subscribedInstances).join(', ') || '无'}`); | |
| } else { | |
| console.error(`订阅更新失败:`, data.error); | |
| } | |
| }) | |
| .catch(error => { | |
| console.error(`更新订阅请求失败:`, error); | |
| }); | |
| } | |
| 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(); // 存储每个实例的图表实例 | |
| async function initialize() { | |
| await getUsernames(); | |
| const instances = await fetchInstances(); | |
| allInstances = instances; | |
| renderInstances(allInstances); | |
| // 默认只监控状态为 running 的实例 | |
| 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(); | |
| 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} </div> | |
| </div> | |
| <div> | |
| <label style="display: inline-flex; align-items: center; gap: 5px; font-size: 13px; cursor: pointer;"> | |
| 监控 | |
| <input type="checkbox" id="monitor-${instanceId}" onchange="toggleMonitorInstance('${instanceId}', this.checked)" ${instance.status.toLowerCase() === 'running' ? 'checked' : ''}> | |
| </label> | |
| <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="action-buttons" style="display: ${isLoggedIn ? 'flex' : 'none'};"> | |
| <button class="action-button view-button" onclick="viewInstance('${instance.url}')">查看</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(); | |
| renderInstances(filteredInstances); | |
| // 重新计算当前订阅的实例,只保留仍可见的实例 | |
| const visibleInstanceIds = new Set(filteredInstances.map(instance => instance.repo_id)); | |
| const updatedSubscriptions = Array.from(metricsStreamManager.subscribedInstances) | |
| .filter(instanceId => visibleInstanceIds.has(instanceId)); | |
| metricsStreamManager.subscribedInstances = new Set(updatedSubscriptions); | |
| metricsStreamManager.connect(updatedSubscriptions); // 重新连接 | |
| updateSummary(); | |
| updateActionButtons(isLoggedIn); | |
| } | |
| // 新增函数:在新标签页中打开实例的URL | |
| function viewInstance(url) { | |
| window.open(url, '_blank'); | |
| } | |
| // 新增函数:切换实例监控状态 | |
| function toggleMonitorInstance(instanceId, checked) { | |
| const checkbox = document.getElementById(`monitor-${instanceId}`); | |
| if (!checkbox) return; | |
| if (checked) { | |
| metricsStreamManager.subscribedInstances.add(instanceId); | |
| } else { | |
| metricsStreamManager.subscribedInstances.delete(instanceId); | |
| } | |
| metricsStreamManager.updateSubscriptions(Array.from(metricsStreamManager.subscribedInstances)); | |
| } | |
| </script> | |
| </body> | |
| </html> |