|
<!DOCTYPE html> |
|
<html lang="zh"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>HF Space Manager - 控制面板</title> |
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"> |
|
<style> |
|
:root { |
|
--primary-color: #00ff9d; |
|
--primary-dark: #00cc7d; |
|
--background-color: #0a0b0f; |
|
--card-background: #12141c; |
|
--text-primary: #ffffff; |
|
--text-secondary: #7f8ea3; |
|
--border-color: #1e2029; |
|
--success-color: #00ff9d; |
|
--warning-color: #ff9d00; |
|
--danger-color: #ff2d55; |
|
--sleeping-color: #00ffff; |
|
} |
|
|
|
* { |
|
margin: 0; |
|
padding: 0; |
|
box-sizing: border-box; |
|
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|
} |
|
|
|
body { |
|
background-color: var(--background-color); |
|
background-image: |
|
radial-gradient(circle at 10% 20%, rgba(0, 255, 157, 0.03) 0%, transparent 20%), |
|
radial-gradient(circle at 90% 80%, rgba(255, 0, 255, 0.03) 0%, transparent 20%); |
|
min-height: 100vh; |
|
color: var(--text-primary); |
|
} |
|
|
|
.header { |
|
background: rgba(18, 20, 28, 0.95); |
|
backdrop-filter: blur(10px); |
|
position: fixed; |
|
top: 0; |
|
left: 0; |
|
right: 0; |
|
z-index: 1000; |
|
border-bottom: 1px solid var(--border-color); |
|
} |
|
|
|
.header-content { |
|
max-width: 1400px; |
|
margin: 0 auto; |
|
padding: 1rem 2rem; |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
} |
|
|
|
.nav-section { |
|
display: flex; |
|
align-items: center; |
|
gap: 2rem; |
|
} |
|
|
|
.logo { |
|
font-size: 1.5rem; |
|
font-weight: 600; |
|
color: var(--text-primary); |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
} |
|
|
|
.logo i { |
|
color: var(--primary-color); |
|
text-shadow: 0 0 10px var(--primary-color); |
|
} |
|
|
|
.search-bar { |
|
position: relative; |
|
width: 300px; |
|
} |
|
|
|
.search-bar input { |
|
width: 100%; |
|
padding: 0.5rem 1rem 0.5rem 2.5rem; |
|
background: rgba(255, 255, 255, 0.05); |
|
border: 1px solid var(--border-color); |
|
border-radius: 6px; |
|
color: var(--text-primary); |
|
font-size: 0.9rem; |
|
} |
|
|
|
.search-bar i { |
|
position: absolute; |
|
left: 0.8rem; |
|
top: 50%; |
|
transform: translateY(-50%); |
|
color: var(--text-secondary); |
|
} |
|
|
|
.user-section { |
|
display: flex; |
|
align-items: center; |
|
gap: 1rem; |
|
} |
|
|
|
.theme-toggle { |
|
background: none; |
|
border: none; |
|
color: var(--text-secondary); |
|
cursor: pointer; |
|
padding: 0.5rem; |
|
border-radius: 4px; |
|
transition: all 0.3s ease; |
|
} |
|
|
|
.theme-toggle:hover { |
|
color: var(--primary-color); |
|
background: rgba(0, 255, 157, 0.1); |
|
} |
|
|
|
.container { |
|
max-width: 1400px; |
|
margin: 80px auto 0; |
|
padding: 2rem; |
|
} |
|
|
|
.dashboard-header { |
|
margin-bottom: 2rem; |
|
padding: 1.5rem; |
|
background: var(--card-background); |
|
border-radius: 12px; |
|
border: 1px solid var(--border-color); |
|
} |
|
|
|
.stats-grid { |
|
display: grid; |
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
|
gap: 1rem; |
|
margin-top: 1rem; |
|
} |
|
|
|
.stat-card { |
|
background: rgba(255, 255, 255, 0.03); |
|
padding: 1.5rem; |
|
border-radius: 8px; |
|
border: 1px solid var(--border-color); |
|
transition: all 0.3s ease; |
|
} |
|
|
|
.stat-card:hover { |
|
transform: translateY(-2px); |
|
border-color: var(--primary-color); |
|
} |
|
|
|
.stat-value { |
|
font-size: 2rem; |
|
font-weight: 600; |
|
margin-bottom: 0.5rem; |
|
color: var(--primary-color); |
|
} |
|
|
|
.stat-label { |
|
color: var(--text-secondary); |
|
font-size: 0.9rem; |
|
text-transform: uppercase; |
|
letter-spacing: 0.5px; |
|
} |
|
|
|
.owner-section { |
|
background: var(--card-background); |
|
border-radius: 12px; |
|
margin-bottom: 2rem; |
|
border: 1px solid var(--border-color); |
|
overflow: hidden; |
|
} |
|
|
|
.owner-header { |
|
padding: 1.5rem; |
|
border-bottom: 1px solid var(--border-color); |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
background: rgba(255, 255, 255, 0.02); |
|
} |
|
|
|
.owner-name { |
|
font-size: 1.25rem; |
|
font-weight: 600; |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
} |
|
|
|
.status-stats { |
|
display: flex; |
|
gap: 1rem; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.status-badge { |
|
padding: 0.25rem 0.75rem; |
|
border-radius: 6px; |
|
font-size: 0.875rem; |
|
font-weight: 500; |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
} |
|
|
|
.space-grid { |
|
display: grid; |
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); |
|
gap: 1.5rem; |
|
padding: 1.5rem; |
|
} |
|
|
|
.space-card { |
|
background: rgba(255, 255, 255, 0.02); |
|
border-radius: 10px; |
|
border: 1px solid var(--border-color); |
|
overflow: hidden; |
|
transition: all 0.3s ease; |
|
} |
|
|
|
.space-card:hover { |
|
transform: translateY(-2px); |
|
border-color: var(--primary-color); |
|
box-shadow: 0 0 20px rgba(0, 255, 157, 0.1); |
|
} |
|
|
|
.space-header { |
|
padding: 1rem; |
|
background: rgba(255, 255, 255, 0.02); |
|
border-bottom: 1px solid var(--border-color); |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
} |
|
|
|
.space-content { |
|
padding: 1rem; |
|
} |
|
|
|
.space-info { |
|
display: grid; |
|
grid-template-columns: repeat(2, 1fr); |
|
gap: 1rem; |
|
margin-bottom: 1rem; |
|
} |
|
|
|
.info-item { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.25rem; |
|
} |
|
|
|
.info-label { |
|
color: var(--text-secondary); |
|
font-size: 0.8rem; |
|
text-transform: uppercase; |
|
letter-spacing: 0.5px; |
|
} |
|
|
|
.info-value { |
|
color: var(--text-primary); |
|
font-size: 0.9rem; |
|
} |
|
|
|
.space-metrics { |
|
padding: 1rem; |
|
background: rgba(255, 255, 255, 0.02); |
|
border-radius: 8px; |
|
margin-bottom: 1rem; |
|
display: flex; |
|
justify-content: space-around; |
|
} |
|
|
|
.metric-item { |
|
text-align: center; |
|
} |
|
|
|
.metric-value { |
|
font-size: 1.25rem; |
|
font-weight: 600; |
|
color: var(--primary-color); |
|
} |
|
|
|
.metric-label { |
|
font-size: 0.8rem; |
|
color: var(--text-secondary); |
|
} |
|
|
|
.action-buttons { |
|
display: grid; |
|
grid-template-columns: repeat(3, 1fr); |
|
gap: 0.75rem; |
|
padding: 1rem; |
|
background: rgba(0, 0, 0, 0.2); |
|
} |
|
|
|
.action-button { |
|
padding: 0.75rem; |
|
border-radius: 6px; |
|
font-size: 0.9rem; |
|
font-weight: 500; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
gap: 0.5rem; |
|
cursor: pointer; |
|
transition: all 0.3s ease; |
|
border: 1px solid var(--border-color); |
|
background: transparent; |
|
color: var(--text-primary); |
|
text-decoration: none; |
|
} |
|
|
|
.action-button:hover { |
|
border-color: var(--primary-color); |
|
background: rgba(0, 255, 157, 0.1); |
|
} |
|
|
|
.action-button.restart { |
|
border-color: var(--primary-color); |
|
color: var(--primary-color); |
|
} |
|
|
|
.action-button.restart:hover { |
|
background: var(--primary-color); |
|
color: var(--background-color); |
|
} |
|
|
|
.status-RUNNING { |
|
background: rgba(0, 255, 157, 0.1); |
|
border: 1px solid var(--success-color); |
|
color: var(--success-color); |
|
} |
|
|
|
.status-BUILDING { |
|
background: rgba(255, 157, 0, 0.1); |
|
border: 1px solid var(--warning-color); |
|
color: var(--warning-color); |
|
} |
|
|
|
.status-SLEEPING { |
|
background: rgba(0, 255, 255, 0.1); |
|
border: 1px solid var(--sleeping-color); |
|
color: var(--sleeping-color); |
|
} |
|
|
|
.status-STOPPED { |
|
background: rgba(127, 142, 163, 0.1); |
|
border: 1px solid var(--text-secondary); |
|
color: var(--text-secondary); |
|
} |
|
|
|
.status-FAILED { |
|
background: rgba(255, 45, 85, 0.1); |
|
border: 1px solid var(--danger-color); |
|
color: var(--danger-color); |
|
} |
|
|
|
.loading-overlay { |
|
position: fixed; |
|
top: 0; |
|
left: 0; |
|
right: 0; |
|
bottom: 0; |
|
background: rgba(10, 11, 15, 0.8); |
|
backdrop-filter: blur(5px); |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
z-index: 9999; |
|
} |
|
|
|
.loading-spinner { |
|
position: relative; |
|
width: 60px; |
|
height: 60px; |
|
} |
|
|
|
.loading-spinner::after { |
|
content: ''; |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
width: 100%; |
|
height: 100%; |
|
border-radius: 50%; |
|
border: 3px solid var(--border-color); |
|
border-top-color: var(--primary-color); |
|
animation: spin 1s infinite linear; |
|
} |
|
|
|
@keyframes spin { |
|
to { transform: rotate(360deg); } |
|
} |
|
|
|
@media (max-width: 768px) { |
|
.header-content { |
|
flex-direction: column; |
|
gap: 1rem; |
|
padding: 1rem; |
|
} |
|
|
|
.nav-section { |
|
width: 100%; |
|
flex-direction: column; |
|
gap: 1rem; |
|
} |
|
|
|
.search-bar { |
|
width: 100%; |
|
} |
|
|
|
.container { |
|
padding: 1rem; |
|
} |
|
|
|
.stats-grid { |
|
grid-template-columns: 1fr; |
|
} |
|
|
|
.space-grid { |
|
grid-template-columns: 1fr; |
|
} |
|
|
|
.space-info { |
|
grid-template-columns: 1fr; |
|
} |
|
|
|
.status-stats { |
|
flex-direction: column; |
|
align-items: flex-start; |
|
} |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div id="loading" class="loading-overlay"> |
|
<div class="loading-spinner"></div> |
|
</div> |
|
|
|
<header class="header"> |
|
<div class="header-content"> |
|
<div class="nav-section"> |
|
<div class="logo"> |
|
<i class="fas fa-server"></i> |
|
HF Space Manager |
|
</div> |
|
<div class="search-bar"> |
|
<i class="fas fa-search"></i> |
|
<input type="text" placeholder="搜索 Spaces..." id="spaceSearch"> |
|
</div> |
|
</div> |
|
<div class="user-section"> |
|
<button class="theme-toggle" title="切换主题"> |
|
<i class="fas fa-moon"></i> |
|
</button> |
|
<a href="/logout" class="action-button"> |
|
<i class="fas fa-sign-out-alt"></i> |
|
退出 |
|
</a> |
|
</div> |
|
</div> |
|
</header> |
|
|
|
<div class="container"> |
|
{% if spaces %} |
|
{% set grouped_spaces = {} %} |
|
{% for space in spaces %} |
|
{% if space.owner not in grouped_spaces %} |
|
{% set _ = grouped_spaces.update({space.owner: []}) %} |
|
{% endif %} |
|
{% set _ = grouped_spaces[space.owner].append(space) %} |
|
{% endfor %} |
|
|
|
<div class="dashboard-header"> |
|
<div class="stats-grid"> |
|
{% set total_spaces = spaces|length %} |
|
{% set running_spaces = spaces|selectattr('status', 'equalto', 'RUNNING')|list|length %} |
|
{% set sleeping_spaces = spaces|selectattr('status', 'equalto', 'SLEEPING')|list|length %} |
|
{% set stopped_spaces = spaces|selectattr('status', 'equalto', 'STOPPED')|list|length %} |
|
{% set failed_spaces = spaces|selectattr('status', 'equalto', 'FAILED')|list|length %} |
|
|
|
<div class="stat-card"> |
|
<div class="stat-value">{{ total_spaces }}</div> |
|
<div class="stat-label">总空间数</div> |
|
</div> |
|
<div class="stat-card"> |
|
<div class="stat-value">{{ running_spaces }}</div> |
|
<div class="stat-label">运行中</div> |
|
</div> |
|
<div class="stat-card"> |
|
<div class="stat-value">{{ sleeping_spaces }}</div> |
|
<div class="stat-label">休眠中</div> |
|
</div> |
|
<div class="stat-card"> |
|
<div class="stat-value">{{ stopped_spaces }}</div> |
|
<div class="stat-label">已停止</div> |
|
</div> |
|
<div class="stat-card"> |
|
<div class="stat-value">{{ failed_spaces }}</div> |
|
<div class="stat-label">运行失败</div> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
{% for owner, owner_spaces in grouped_spaces.items() %} |
|
{% set running_count = owner_spaces|selectattr('status', 'equalto', 'RUNNING')|list|length %} |
|
{% set building_count = owner_spaces|selectattr('status', 'equalto', 'BUILDING')|list|length %} |
|
{% set sleeping_count = owner_spaces|selectattr('status', 'equalto', 'SLEEPING')|list|length %} |
|
{% set stopped_count = owner_spaces|selectattr('status', 'equalto', 'STOPPED')|list|length %} |
|
{% set failed_count = owner_spaces|selectattr('status', 'equalto', 'FAILED')|list|length %} |
|
|
|
<div class="owner-section"> |
|
<div class="owner-header"> |
|
<div class="owner-name"> |
|
<i class="fas fa-user-circle"></i> |
|
{{ owner }} |
|
</div> |
|
<div class="status-stats"> |
|
<span class="status-badge status-RUNNING"> |
|
<i class="fas fa-play-circle"></i> |
|
运行中: {{ running_count }} |
|
</span> |
|
<span class="status-badge status-SLEEPING"> |
|
<i class="fas fa-moon"></i> |
|
休眠: {{ sleeping_count }} |
|
</span> |
|
<span class="status-badge status-STOPPED"> |
|
<i class="fas fa-stop-circle"></i> |
|
停止: {{ stopped_count }} |
|
</span> |
|
<span class="status-badge status-FAILED"> |
|
<i class="fas fa-exclamation-circle"></i> |
|
失败: {{ failed_count }} |
|
</span> |
|
</div> |
|
</div> |
|
|
|
<div class="space-grid"> |
|
{% for space in owner_spaces %} |
|
<div class="space-card" data-space-id="{{ space.repo_id }}"> |
|
<div class="space-header"> |
|
<div class="space-name"> |
|
<i class="fas fa-cube"></i> |
|
{{ space.name }} |
|
</div> |
|
<span class="status-badge status-{{ space.status }}"> |
|
<i class="fas fa-circle"></i> |
|
{{ space.status }} |
|
</span> |
|
</div> |
|
|
|
<div class="space-content"> |
|
<div class="space-info"> |
|
<div class="info-item"> |
|
<span class="info-label">Space ID</span> |
|
<span class="info-value">{{ space.repo_id }}</span> |
|
</div> |
|
<div class="info-item"> |
|
<span class="info-label">创建时间</span> |
|
<span class="info-value">{{ space.created_at }}</span> |
|
</div> |
|
<div class="info-item"> |
|
<span class="info-label">最后修改</span> |
|
<span class="info-value">{{ space.last_modified }}</span> |
|
</div> |
|
<div class="info-item"> |
|
<span class="info-label">应用端口</span> |
|
<span class="info-value">{{ space.app_port }}</span> |
|
</div> |
|
</div> |
|
|
|
<div class="space-metrics"> |
|
<div class="metric-item"> |
|
<div class="metric-value">{{ space.sdk }}</div> |
|
<div class="metric-label">SDK 版本</div> |
|
</div> |
|
<div class="metric-item"> |
|
<div class="metric-value">{{ '私有' if space.private else '公开' }}</div> |
|
<div class="metric-label">访问权限</div> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<div class="action-buttons"> |
|
<a href="{{ space.url }}" target="_blank" class="action-button"> |
|
<i class="fas fa-external-link-alt"></i> |
|
查看 |
|
</a> |
|
<button onclick="confirmAction('restart', '{{ space.repo_id }}')" class="action-button restart"> |
|
<i class="fas fa-sync-alt"></i> |
|
重启 |
|
</button> |
|
<button onclick="confirmAction('rebuild', '{{ space.repo_id }}')" class="action-button"> |
|
<i class="fas fa-tools"></i> |
|
重建 |
|
</button> |
|
</div> |
|
</div> |
|
{% endfor %} |
|
</div> |
|
</div> |
|
{% endfor %} |
|
{% else %} |
|
<div class="owner-section"> |
|
<p style="text-align: center; padding: 2rem; color: var(--text-secondary);"> |
|
<i class="fas fa-info-circle"></i> |
|
没有找到任何 Spaces。请确保你的账户中有创建的 Spaces,并且提供的 token 有正确的权限。 |
|
</p> |
|
</div> |
|
{% endif %} |
|
</div> |
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script> |
|
<script> |
|
const socket = io(); |
|
|
|
|
|
window.addEventListener('load', function() { |
|
document.getElementById('loading').style.display = 'none'; |
|
}); |
|
|
|
|
|
document.getElementById('spaceSearch').addEventListener('input', function(e) { |
|
const searchTerm = e.target.value.toLowerCase(); |
|
document.querySelectorAll('.space-card').forEach(card => { |
|
const spaceName = card.querySelector('.space-name').textContent.toLowerCase(); |
|
const spaceId = card.dataset.spaceId.toLowerCase(); |
|
if (spaceName.includes(searchTerm) || spaceId.includes(searchTerm)) { |
|
card.style.display = ''; |
|
} else { |
|
card.style.display = 'none'; |
|
} |
|
}); |
|
}); |
|
|
|
|
|
const themeToggle = document.querySelector('.theme-toggle'); |
|
themeToggle.addEventListener('click', function() { |
|
document.body.classList.toggle('light-theme'); |
|
const icon = this.querySelector('i'); |
|
icon.classList.toggle('fa-sun'); |
|
icon.classList.toggle('fa-moon'); |
|
}); |
|
|
|
|
|
socket.on('connect', () => { |
|
console.log('Connected to server'); |
|
}); |
|
|
|
socket.on('disconnect', () => { |
|
console.log('Disconnected from server'); |
|
}); |
|
|
|
socket.on('spaces_updated', (data) => { |
|
updateSpaceStatuses(); |
|
}); |
|
|
|
document.addEventListener('visibilitychange', function() { |
|
if (document.hidden) { |
|
socket.disconnect(); |
|
} else { |
|
socket.connect(); |
|
} |
|
}); |
|
|
|
function updateSpaceStatuses() { |
|
document.querySelectorAll('.space-card').forEach(card => { |
|
const spaceId = card.dataset.spaceId; |
|
fetch(`/api/space/${spaceId}/status`) |
|
.then(response => response.json()) |
|
.then(data => { |
|
const statusElement = card.querySelector('.status-badge'); |
|
if (statusElement && data.status) { |
|
statusElement.className = `status-badge status-${data.status}`; |
|
statusElement.innerHTML = `<i class="fas fa-circle"></i> ${data.status}`; |
|
} |
|
}) |
|
.catch(error => console.error('Error updating status:', error)); |
|
}); |
|
} |
|
|
|
function confirmAction(action, spaceId) { |
|
const actionText = action === 'restart' ? '重启' : '重建'; |
|
if (confirm(`确定要${actionText} "${spaceId}" 吗?`)) { |
|
document.getElementById('loading').style.display = 'flex'; |
|
window.location.href = `/action/${action}/${spaceId}`; |
|
} |
|
} |
|
|
|
|
|
setInterval(updateSpaceStatuses, 30000); |
|
|
|
|
|
document.querySelectorAll('.space-card').forEach(card => { |
|
card.addEventListener('mouseenter', function() { |
|
this.style.transform = 'translateY(-5px)'; |
|
}); |
|
|
|
card.addEventListener('mouseleave', function() { |
|
this.style.transform = 'translateY(0)'; |
|
}); |
|
}); |
|
</script> |
|
</body> |
|
</html> |