| from flask import Flask, render_template_string, jsonify, Response |
| import requests |
| import os |
|
|
| app = Flask(__name__) |
|
|
| lightModeStyle = """ |
| * { |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| } |
| body { |
| font-family: "Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, sans-serif; |
| background: #f0f2f5; |
| color: #333; |
| padding: 20px; |
| min-height: 100vh; |
| } |
| .container { |
| max-width: 1400px; |
| margin: 0 auto; |
| animation: fadeIn 0.5s ease; |
| padding: 0 20px; |
| } |
| .overview { |
| background: #fff; |
| border-radius: 15px; |
| padding: 25px; |
| margin-bottom: 30px; |
| border: 1px solid #dfe1e6; |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); |
| } |
| .overview-title { |
| font-size: 20px; |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| margin-bottom: 20px; |
| color: #2e86de; |
| font-weight: 600; |
| } |
| .overview-title i { |
| margin-right: 8px; |
| } |
| #summary { |
| display: grid; |
| grid-template-columns: repeat(5, 1fr); |
| gap: 15px; |
| } |
| #summary div { |
| background: #f9fafb; |
| padding: 15px; |
| border-radius: 8px; |
| border: 1px solid #dfe1e6; |
| transition: background-color 0.2s ease; |
| } |
| #summary div:hover { |
| background-color: #f0f2f5; |
| } |
| #summary div { |
| font-size: 14px; |
| color: #555; |
| } |
| #summary span { |
| display: block; |
| font-size: 24px; |
| font-weight: bold; |
| margin-top: 5px; |
| color: #333; |
| } |
| .stats-container { |
| display: grid; |
| grid-template-columns: repeat(2, 1fr); |
| gap: 20px; |
| margin-top: 20px; |
| } |
| .server-card { |
| background: #fff; |
| border-radius: 10px; |
| padding: 20px; |
| border: 1px solid #dfe1e6; |
| transition: transform 0.2s ease, box-shadow 0.2s ease; |
| height: auto; |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); |
| } |
| .server-card:hover { |
| transform: translateY(-3px); |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); |
| } |
| .server-header { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: 15px; |
| font-size: 16px; |
| color: #444; |
| } |
| .server-name { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| .server-flag { |
| width: 20px; |
| height: 20px; |
| border-radius: 4px; |
| } |
| .metric-grid { |
| display: grid; |
| grid-template-columns: repeat(2, 1fr); |
| gap: 15px; |
| margin-top: 15px; |
| } |
| .metric-item { |
| background: #f9fafb; |
| padding: 12px; |
| border-radius: 8px; |
| border: 1px solid #dfe1e6; |
| transition: background-color 0.2s ease; |
| } |
| .metric-item:hover { |
| background-color: #f0f2f5; |
| } |
| .metric-label { |
| color: #777; |
| font-size: 13px; |
| margin-bottom: 5px; |
| } |
| .metric-value { |
| font-size: 16px; |
| font-weight: 500; |
| color: #333; |
| } |
| .status-dot { |
| display: inline-block; |
| border-radius: 50%; |
| animation: pulse 2s infinite; |
| width: 12px; |
| height: 12px; |
| } |
| .status-online { |
| background-color: #2ecc71; |
| color: #2ecc71; |
| box-shadow: 0 0 5px rgba(46, 204, 113, 0.4); |
| } |
| .status-offline { |
| background-color: #e74c3c; |
| color: #e74c3c; |
| box-shadow: 0 0 5px rgba(231, 76, 60, 0.4); |
| } |
| @keyframes fadeIn { |
| from { opacity: 0; transform: translateY(20px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| @keyframes pulse { |
| 0% { box-shadow: 0 0 0 0 rgba(46, 204, 113, 0.4); } |
| 70% { box-shadow: 0 0 0 10px rgba(46, 204, 113, 0); } |
| 100% { box-shadow: 0 0 0 0 rgba(46, 204, 113, 0); } |
| } |
| @media (max-width: 768px) { |
| #summary { |
| grid-template-columns: 1fr; |
| } |
| .stats-container { |
| grid-template-columns: 1fr; |
| } |
| .metric-grid { |
| grid-template-columns: 1fr; |
| } |
| } |
| .progress-bar-container { |
| width: 100%; |
| background-color: #ddd; |
| border-radius: 4px; |
| margin-top: 4px; |
| } |
| .progress-bar { |
| height: 8px; |
| border-radius: 4px; |
| background-color: #2e86de; |
| width: 0%; |
| } |
| .cpu-progress-bar { |
| height: 8px; |
| border-radius: 4px; |
| background-color: #2ecc71; |
| width: 0%; |
| } |
| .memory-progress-bar{ |
| height: 8px; |
| border-radius: 4px; |
| background-color: #e67e22; |
| width: 0%; |
| } |
| """ |
|
|
| htmlTemplate = f""" |
| <!DOCTYPE html> |
| <html lang="zh"> |
| <head> |
| <meta charset="UTF-8"> |
| <title>HF Space Monitor</title> |
| <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🚀</text></svg>"> |
| <style>{lightModeStyle}</style> |
| </head> |
| <body> |
| <div class="container"> |
| <div class="overview"> |
| <div class="overview-title"><i class="fas fa-chart-line"></i>系统概览</div> |
| <div id="summary"> |
| <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 id="servers" class="stats-container"> |
| </div> |
| </div> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/js/all.min.js" integrity="sha512-yFjZbTYRCJodnuyGlsKamNE/LlEaEA/3uWCGാരി7eIq7jWqVl3J8jL/kof/tfu9Xqzh/y/VM5sJd/tq5iEew==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> |
| |
| <script> |
| const username = '{{username}}'; |
| |
| async function fetchInstances() {{ |
| try {{ |
| const response = await fetch(`/instances`); |
| const userInstances = await response.json(); |
| return userInstances; |
| }} catch (error) {{ |
| console.error("获取实例列表失败:", error); |
| return []; |
| }} |
| }} |
| |
| class MetricsManager {{ |
| constructor() {{ |
| this.eventSources = new Map(); |
| this.servers = new Map(); |
| this.instanceOwners = new Map(); |
| this.spaceIds = new Map(); |
| }} |
| |
| async connect(instanceId, username) {{ |
| if (this.eventSources.has(instanceId)) return; |
| |
| try {{ |
| const eventSource = new EventSource(`/metrics/${{username}}/${{instanceId}}`); |
| |
| this.spaceIds.set(instanceId, instanceId); |
| this.instanceOwners.set(instanceId, username); |
| |
| eventSource.addEventListener("metric", (event) => {{ |
| try {{ |
| const data = JSON.parse(event.data); |
| // console.log("Received data:", data); // Debugging line |
| updateServerCard(data, instanceId); |
| }} catch (error) {{ |
| console.error(`解析数据失败 (${{instanceId}}):`, error); |
| }} |
| }}); |
| |
| eventSource.onerror = (error) => {{ |
| console.error(`EventSource 错误 (${{instanceId}}):`, error); |
| eventSource.close(); |
| this.eventSources.delete(instanceId); // Remove on error |
| }}; |
| |
| this.eventSources.set(instanceId, eventSource); |
| }} catch (error) {{ |
| console.error(`连接失败 (${{username}}/${{instanceId}}):`, error); |
| }} |
| }} |
| |
| disconnectAll() {{ |
| this.eventSources.forEach(es => es.close()); |
| this.eventSources.clear(); |
| }} |
| }} |
| |
| const metricsManager = new MetricsManager(); |
| const servers = new Map(); |
| |
| async function initialize() {{ |
| const instances = await fetchInstances(); |
| instances.forEach(instance => {{ |
| metricsManager.connect(instance.id, instance.owner); |
| }}); |
| }} |
| |
| |
| function updateServerCard(data, spaceId) {{ |
| const serverId = data.replica; |
| const serverElement = document.getElementById(`server-${{serverId}}`); |
| const owner = metricsManager.instanceOwners.get(spaceId); |
| |
| if (!serverElement) {{ |
| const card = document.createElement('div'); |
| card.id = `server-${{serverId}}`; |
| card.className = 'server-card'; |
| card.innerHTML = ` |
| <div class="server-header"> |
| <div class="server-name"> |
| <div class="status-dot status-online"></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 10h18c1.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>${{serverId}} (${{owner}}/${{spaceId}})</div> |
| </div> |
| </div> |
| <div class="metric-grid"> |
| <div class="metric-item"> |
| <div class="metric-label">CPU</div> |
| <div class="progress-bar-container"> |
| <div class="cpu-progress-bar"></div> |
| </div> |
| <div class="metric-value cpu-usage">0%</div> |
| </div> |
| <div class="metric-item"> |
| <div class="metric-label">内存</div> |
| <div class="progress-bar-container"> |
| <div class="memory-progress-bar"></div> |
| </div> |
| <div class="metric-value memory-usage">0%</div> |
| </div> |
| <div class="metric-item"> |
| <div class="metric-label">上传</div> |
| <div class="metric-value upload">0 KB/s</div> |
| </div> |
| <div class="metric-item"> |
| <div class="metric-label">下载</div> |
| <div class="metric-value download">0 KB/s</div> |
| </div> |
| </div> |
| `; |
| document.getElementById('servers').appendChild(card); |
| }} |
| |
| const card = document.getElementById(`server-${{serverId}}`); |
| const cpuUsage = data.cpu_usage_pct; |
| const memoryUsage = (data.memory_used_bytes / data.memory_total_bytes) * 100; |
| const uploadBps = data.tx_bps; |
| const downloadBps = data.rx_bps; |
| |
| card.querySelector('.cpu-usage').textContent = `${{cpuUsage.toFixed(2)}}%`; |
| card.querySelector('.cpu-progress-bar').style.width = `${{cpuUsage}}%`; |
| |
| card.querySelector('.memory-usage').textContent = `${{memoryUsage.toFixed(2)}}%`; |
| card.querySelector('.memory-progress-bar').style.width = `${{memoryUsage}}%`; |
| |
| card.querySelector('.upload').textContent = `${{formatBytes(uploadBps)}}/s`; |
| card.querySelector('.download').textContent = `${{formatBytes(downloadBps)}}/s`; |
| |
| servers.set(serverId, Date.now()); |
| updateSummary(); |
| }} |
| |
| function updateSummary() {{ |
| const now = Date.now(); |
| let online = 0; |
| let offline = 0; |
| let totalUpload = 0; |
| let totalDownload = 0; |
| |
| servers.forEach((lastSeen, serverId) => {{ |
| const isOnline = (now - lastSeen) < 10000; |
| const serverCard = document.getElementById(`server-${{serverId}}`); |
| if (serverCard) {{ |
| const statusDot = serverCard.querySelector('.status-dot'); |
| statusDot.className = `status-dot status-${{isOnline ? 'online' : 'offline'}}`; |
| |
| if (isOnline) {{ |
| const uploadText = serverCard.querySelector('.upload').textContent; |
| const downloadText = serverCard.querySelector('.download').textContent; |
| totalUpload += parseFloat(uploadText) || 0; |
| totalDownload += parseFloat(downloadText) || 0; |
| }} |
| }} |
| isOnline ? online++ : offline++; |
| }}); |
| |
| document.getElementById('totalServers').textContent = servers.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]; |
| }} |
| |
| |
| initialize(); // Initial load |
| |
| setInterval(updateSummary, 2000); |
| |
| setInterval(async () => {{ |
| metricsManager.disconnectAll(); |
| await initialize(); |
| }}, 300000); |
| |
| </script> |
| </body> |
| </html> |
| """ |
|
|
| USERNAME = os.environ.get("USERNAME", "yangtb24") |
|
|
| def fetch_instances(username): |
| try: |
| response = requests.get(f"https://huggingface.co/api/spaces?author={username}") |
| response.raise_for_status() |
| user_instances = response.json() |
| return [{"id": instance["id"].split('/')[1], "owner": username} for instance in user_instances] |
| except requests.exceptions.RequestException as e: |
| print(f"Error fetching instances: {e}") |
| return [] |
|
|
| @app.route('/') |
| def index(): |
| return render_template_string(htmlTemplate, username=USERNAME) |
|
|
| @app.route('/instances') |
| def get_instances(): |
| instances = fetch_instances(USERNAME) |
| return jsonify(instances) |
|
|
| @app.route('/metrics/<username>/<instance_id>') |
| def stream_metrics(username, instance_id): |
| url = f"https://api.hf.space/v1/{username}/{instance_id}/live-metrics/sse" |
|
|
| def generate(): |
| try: |
| response = requests.get(url, stream=True, headers={"Accept": "text/event-stream"}, timeout=15) |
| response.raise_for_status() |
|
|
| buffer = "" |
| for chunk in response.iter_content(chunk_size=1024, decode_unicode=True): |
| if chunk: |
| buffer += chunk |
| while "\n\n" in buffer: |
| event_data, buffer = buffer.split("\n\n", 1) |
| lines = event_data.split("\n") |
| event_type = "message" |
| data_lines = [] |
| for line in lines: |
| if line.startswith("event:"): |
| event_type = line.split(":", 1)[1].strip() |
| elif line.startswith("data:"): |
| data_lines.append(line.split(":", 1)[1].strip()) |
| |
| if event_type == "metric": |
| yield f"event: {event_type}\ndata: {''.join(data_lines)}\n\n" |
|
|
| except requests.exceptions.RequestException as e: |
| print(f"Request Exception: {e}") |
| yield f"event: error\ndata: Connection error: {e}\\n\\n" |
| except Exception as e: |
| print(f"An error occurred: {e}") |
| yield f"event: error\ndata: An error occurred: {e}\\n\\n" |
|
|
| return Response(generate(), mimetype='text/event-stream') |
|
|
| if __name__ == '__main__': |
| app.run(debug=True, host='0.0.0.0', port=7860) |