| <!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> |
| <style> |
| * { |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| } |
| body { |
| font-family: "PingFang SC", "Microsoft YaHei", sans-serif; |
| background: #f5f7fa; |
| color: #333; |
| padding: 20px; |
| min-height: 100vh; |
| transition: background 0.3s ease, color 0.3s ease; |
| } |
| :root { |
| |
| --background-color: #f5f7fa; |
| --text-color: #333; |
| --card-background: #fff; |
| --card-border: #e8ecef; |
| --metric-background: #f9f9fb; |
| --metric-border: #e8ecef; |
| --metric-hover: #f1f3f5; |
| --secondary-text: #666; |
| --label-color: #999; |
| --network-background: rgba(0, 0, 0, 0.05); |
| --action-button-bg: linear-gradient(90deg, #34c759, #28a745); |
| --action-button-hover: linear-gradient(90deg, #2eb850, #23963d); |
| } |
| .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); |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); |
| transition: background 0.3s ease, border 0.3s ease; |
| } |
| .overview-title { |
| font-size: 20px; |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| margin-bottom: 20px; |
| color: var(--text-color); |
| font-weight: 600; |
| } |
| .overview-title svg { |
| width: 24px; |
| height: 24px; |
| fill: #34c759; |
| } |
| .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: 24px; |
| } |
| #summary div { |
| background: var(--metric-background); |
| padding: 16px; |
| border-radius: 12px; |
| border: 1px solid var(--metric-border); |
| box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03); |
| transition: background 0.3s ease, border 0.3s ease; |
| text-align: center; |
| } |
| #summary div { |
| font-size: 14px; |
| color: var(--secondary-text); |
| } |
| #summary span { |
| display: block; |
| font-size: 24px; |
| font-weight: bold; |
| margin-top: 8px; |
| color: var(--text-color); |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| } |
| #summary .no-data { |
| color: #999; |
| font-size: 16px; |
| margin-top: 8px; |
| } |
| .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.1); |
| } |
| .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: #34c759; |
| color: #34c759; |
| } |
| .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: #fff; |
| 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: 10px; |
| padding: 15px; |
| border-radius: 8px; |
| display: flex; |
| justify-content: space-between; |
| transition: background 0.3s ease, border 0.3s ease; |
| } |
| .network-item { |
| font-size: 14px; |
| color: var(--secondary-text); |
| } |
| .network-item span { |
| color: var(--text-color); |
| font-weight: 500; |
| } |
| @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) { |
| #summary { |
| grid-template-columns: 1fr; |
| gap: 16px; |
| } |
| #summary div { |
| padding: 12px; |
| } |
| #summary span { |
| font-size: 20px; |
| } |
| .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: 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: #fff; |
| 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: #fff; |
| 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 #34c759; |
| 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; |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); |
| 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='%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>"); |
| background-repeat: no-repeat; |
| background-position: right 10px center; |
| padding-right: 36px; |
| } |
| .filter-sort-group select:hover { |
| background-color: var(--metric-hover); |
| border-color: #d1d5db; |
| } |
| .filter-sort-group select:focus { |
| border-color: #34c759; |
| box-shadow: 0 0 0 2px rgba(52, 199, 89, 0.3); |
| } |
| .refresh-button { |
| background: var(--action-button-bg); |
| border: none; |
| color: #fff; |
| 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-button.loading .refresh-icon { |
| animation: spin 1s linear infinite; |
| } |
| .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(--metric-background); |
| color: var(--text-color); |
| border: 1px solid var(--metric-border); |
| 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(--metric-hover); |
| } |
| .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"> |
| |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> |
| <path d="M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h2v10H7V7zm4 4h2v6h-2v-6zm4-4h2v10h-2V7z"/> |
| </svg> |
| 系统概览 |
| </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 class="no-data" id="totalUsersNoData" style="display: none;">暂无数据</div></div> |
| <div>总实例数: <span id="totalServers">0</span><div class="no-data" id="totalServersNoData" style="display: none;">暂无数据</div></div> |
| <div>在线实例: <span id="onlineServers">0</span><div class="no-data" id="onlineServersNoData" style="display: none;">暂无数据</div></div> |
| <div>离线实例: <span id="offlineServers">0</span><div class="no-data" id="offlineServersNoData" style="display: none;">暂无数据</div></div> |
| <div>总上传: <span id="totalUpload">0 B/s</span><div class="no-data" id="totalUploadNoData" style="display: none;">暂无数据</div></div> |
| <div>总下载: <span id="totalDownload">0 B/s</span><div class="no-data" id="totalDownloadNoData" style="display: none;">暂无数据</div></div> |
| </div> |
| <div class="network-stats"> |
| <div class="network-item">当前在线上传速度: <span id="currentUploadSpeed">0 B/s</span></div> |
| <div class="network-item">当前在线下行速度: <span id="currentDownloadSpeed">0 B/s</span></div> |
| <div class="network-item">最后更新: <span id="lastUpdated">未知</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" id="refreshButton" 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; |
| let lastUpdatedTime = null; |
| |
| |
| function showLoading() { |
| document.getElementById('loadingOverlay').style.display = 'flex'; |
| const refreshButton = document.getElementById('refreshButton'); |
| refreshButton.classList.add('loading'); |
| refreshButton.disabled = true; |
| } |
| function hideLoading() { |
| document.getElementById('loadingOverlay').style.display = 'none'; |
| const refreshButton = document.getElementById('refreshButton'); |
| refreshButton.classList.remove('loading'); |
| refreshButton.disabled = false; |
| } |
| |
| |
| function checkLoginStatus() { |
| const token = localStorage.getItem('authToken'); |
| const loginButton = document.getElementById('loginButton'); |
| const logoutButton = document.getElementById('logoutButton'); |
| if (token) { |
| 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) { |
| isLoggedIn = true; |
| loginButton.style.display = 'none'; |
| logoutButton.style.display = 'block'; |
| updateActionButtons(true); |
| } else { |
| localStorage.removeItem('authToken'); |
| isLoggedIn = false; |
| loginButton.style.display = 'block'; |
| logoutButton.style.display = 'none'; |
| updateActionButtons(false); |
| } |
| return data.success; |
| }) |
| .catch(error => { |
| hideLoading(); |
| localStorage.removeItem('authToken'); |
| isLoggedIn = false; |
| loginButton.style.display = 'block'; |
| logoutButton.style.display = 'none'; |
| updateActionButtons(false); |
| return false; |
| }); |
| } else { |
| 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) { |
| localStorage.setItem('authToken', data.token); |
| isLoggedIn = true; |
| hideLoginForm(); |
| document.getElementById('loginButton').style.display = 'none'; |
| document.getElementById('logoutButton').style.display = 'block'; |
| updateActionButtons(true); |
| refreshData(); |
| } else { |
| loginError.textContent = data.message || '登录失败'; |
| loginError.style.display = 'block'; |
| } |
| }) |
| .catch(error => { |
| hideLoading(); |
| 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(); |
| localStorage.removeItem('authToken'); |
| isLoggedIn = false; |
| document.getElementById('loginButton').style.display = 'block'; |
| document.getElementById('logoutButton').style.display = 'none'; |
| updateActionButtons(false); |
| refreshData(); |
| }) |
| .catch(error => { |
| hideLoading(); |
| localStorage.removeItem('authToken'); |
| isLoggedIn = false; |
| document.getElementById('loginButton').style.display = 'block'; |
| document.getElementById('logoutButton').style.display = 'none'; |
| updateActionButtons(false); |
| refreshData(); |
| }); |
| } else { |
| isLoggedIn = false; |
| document.getElementById('loginButton').style.display = 'block'; |
| document.getElementById('logoutButton').style.display = 'none'; |
| updateActionButtons(false); |
| refreshData(); |
| } |
| } |
| function updateActionButtons(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() { |
| await checkLoginStatus(); |
| 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}`; |
| } |
| 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; |
| document.getElementById('totalUsersNoData').style.display = usernamesList.length === 0 ? 'block' : 'none'; |
| 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(); |
| document.getElementById('totalUsers').textContent = 0; |
| document.getElementById('totalUsersNoData').style.display = 'block'; |
| return []; |
| } |
| } |
| |
| |
| async function fetchInstances() { |
| try { |
| showLoading(); |
| const token = localStorage.getItem('authToken'); |
| const headers = {}; |
| if (token) { |
| headers['Authorization'] = `Bearer ${token}`; |
| } |
| const response = await fetch('/api/proxy/spaces', { headers }); |
| const instances = await response.json(); |
| hideLoading(); |
| if (instances.length === 0) { |
| alert('未获取到实例数据,可能是网络问题或数据暂不可用。'); |
| } |
| return instances; |
| } catch (error) { |
| hideLoading(); |
| 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 || '')}`; |
| 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) => { |
| this.eventSource.close(); |
| this.eventSource = null; |
| setTimeout(() => this.connect(subscribedInstances), 5000); |
| }; |
| } |
| disconnect() { |
| if (this.eventSource) { |
| this.eventSource.close(); |
| this.eventSource = null; |
| } |
| } |
| } |
| 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); |
| const runningInstances = instances |
| .filter(instance => instance.status.toLowerCase() === 'running') |
| .map(instance => instance.repo_id); |
| metricsStreamManager.connect(runningInstances); |
| updateSummary(); |
| updateActionButtons(isLoggedIn); |
| lastUpdatedTime = new Date(); |
| updateLastUpdatedTime(); |
| } |
| |
| |
| async function refreshData() { |
| showLoading(); |
| metricsStreamManager.disconnect(); |
| chartInstances.forEach(chart => { |
| if (chart) { |
| chart.destroy(); |
| } |
| }); |
| chartInstances.clear(); |
| await initialize(); |
| applyFiltersAndSort(); |
| lastUpdatedTime = new Date(); |
| updateLastUpdatedTime(); |
| } |
| |
| |
| 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, 0, 0, 0.1)'; |
| const textColor = '#333'; |
| const ctx = canvas.getContext('2d'); |
| const chart = new Chart(ctx, { |
| type: 'line', |
| data: { |
| labels: Array(30).fill(''), |
| datasets: [ |
| { |
| label: 'CPU 使用率 (%)', |
| data: [], |
| borderColor: '#34c759', |
| backgroundColor: 'rgba(52, 199, 89, 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)); |
| downloadData.push((data.rx_bps / 1024).toFixed(2)); |
| 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'); |
| } |
| 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>`; |
| card.innerHTML = ` |
| <div class="server-header"> |
| <div class="server-name"> |
| <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="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 || '未知错误'}`); |
| } |
| } |
| } catch (error) { |
| hideLoading(); |
| 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 || '未知错误'}`); |
| } |
| } |
| } catch (error) { |
| hideLoading(); |
| 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('totalServersNoData').style.display = serverStatus.size === 0 ? 'block' : 'none'; |
| document.getElementById('onlineServers').textContent = online; |
| document.getElementById('onlineServersNoData').style.display = online === 0 ? 'block' : 'none'; |
| document.getElementById('offlineServers').textContent = offline; |
| document.getElementById('offlineServersNoData').style.display = offline === 0 ? 'block' : 'none'; |
| const uploadText = `${formatBytes(totalUpload)}/s`; |
| const downloadText = `${formatBytes(totalDownload)}/s`; |
| document.getElementById('totalUpload').textContent = uploadText; |
| document.getElementById('totalUploadNoData').style.display = totalUpload === 0 ? 'block' : 'none'; |
| document.getElementById('totalDownload').textContent = downloadText; |
| document.getElementById('totalDownloadNoData').style.display = totalDownload === 0 ? 'block' : 'none'; |
| document.getElementById('currentUploadSpeed').textContent = uploadText; |
| document.getElementById('currentDownloadSpeed').textContent = downloadText; |
| } |
| |
| |
| 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]; |
| } |
| |
| |
| function updateLastUpdatedTime() { |
| if (lastUpdatedTime) { |
| const now = new Date(); |
| const diff = Math.floor((now - lastUpdatedTime) / 1000); |
| let timeText = ''; |
| if (diff < 60) { |
| timeText = `${diff}秒前`; |
| } else if (diff < 3600) { |
| timeText = `${Math.floor(diff / 60)}分钟前`; |
| } else { |
| timeText = lastUpdatedTime.toLocaleString('zh-CN'); |
| } |
| document.getElementById('lastUpdated').textContent = timeText; |
| } |
| } |
| |
| |
| setInterval(updateSummary, 5000); |
| setInterval(updateLastUpdatedTime, 1000); |
| 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 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> |