|
|
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) |