spaces-dashboard / templates /dashboard.html
mrfakename's picture
update
3e016a0
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #0a0a0a;
color: #e5e5e5;
min-height: 100vh;
}
a {
color: inherit;
text-decoration: none;
}
.navbar {
border-bottom: 1px solid #1a1a1a;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
position: fixed;
top: 0;
left: 0;
right: 0;
background: #0a0a0a;
z-index: 100;
}
.navbar h1 {
font-size: 0.875rem;
font-weight: 500;
color: #888;
}
.user-info {
display: flex;
align-items: center;
gap: 1rem;
}
.avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
}
.username {
font-size: 0.875rem;
color: #e5e5e5;
}
.logout-btn {
color: #666;
font-size: 0.75rem;
transition: color 0.2s;
}
.logout-btn:hover {
color: #e5e5e5;
}
.layout {
display: flex;
padding-top: 57px;
min-height: 100vh;
}
.sidebar {
width: 280px;
border-right: 1px solid #1a1a1a;
padding: 1.5rem;
position: fixed;
top: 57px;
bottom: 0;
overflow-y: auto;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #1a1a1a;
}
.sidebar-title {
font-size: 0.75rem;
font-weight: 500;
color: #666;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sidebar-count {
font-size: 0.75rem;
color: #444;
}
.spaces-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.space-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-radius: 6px;
transition: background 0.15s;
}
.space-item:hover {
background: #141414;
}
.space-name {
font-size: 0.8125rem;
color: #999;
}
.space-item:hover .space-name {
color: #e5e5e5;
}
.space-status {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.space-status.running {
background: #0a2a0a;
color: #22c55e;
}
.space-status.building {
background: #2a2a0a;
color: #eab308;
}
.space-status.stopped, .space-status.paused, .space-status.sleeping {
background: #1a1a1a;
color: #666;
}
.space-status.error, .space-status.runtime_error, .space-status.build_error {
background: #2a0a0a;
color: #ef4444;
}
.space-details {
display: none;
padding: 0.5rem 0.75rem;
margin-top: 2px;
background: #111;
border-radius: 4px;
font-size: 0.75rem;
}
.space-details.open {
display: block;
}
.space-detail-row {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
border-bottom: 1px solid #1a1a1a;
}
.space-detail-row:last-child {
border-bottom: none;
}
.space-detail-label {
color: #555;
}
.space-detail-value {
color: #999;
}
.space-toggle {
cursor: pointer;
}
.main {
flex: 1;
margin-left: 280px;
max-width: 800px;
padding: 1.5rem 2rem 4rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #1a1a1a;
}
.section-title {
font-size: 0.75rem;
font-weight: 500;
color: #666;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.section-count {
font-size: 0.75rem;
color: #444;
}
.controls {
display: flex;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.control-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-label {
font-size: 0.6875rem;
color: #555;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.control-options {
display: flex;
gap: 0.25rem;
}
.control-btn {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid #222;
border-radius: 4px;
color: #666;
cursor: pointer;
transition: all 0.15s;
}
.control-btn:hover {
border-color: #333;
color: #999;
}
.control-btn.active {
background: #1a1a1a;
border-color: #333;
color: #e5e5e5;
}
.feed {
display: flex;
flex-direction: column;
}
.feed-item {
padding: 1rem 0;
border-bottom: 1px solid #141414;
display: block;
transition: background 0.15s;
}
.feed-item:first-child {
padding-top: 0;
}
.feed-item:last-child {
border-bottom: none;
}
.feed-item:hover {
background: #0f0f0f;
margin: 0 -1rem;
padding-left: 1rem;
padding-right: 1rem;
}
.feed-item.status-open {
border-left: 2px solid #22c55e;
padding-left: 1rem;
margin-left: -1rem;
}
.feed-item.status-closed,
.feed-item.status-merged {
border-left: 2px solid #333;
padding-left: 1rem;
margin-left: -1rem;
}
.feed-item.is-report {
border-left-color: #ef4444;
}
.feed-item.is-report .feed-title {
color: #ef4444;
}
.feed-item.responded {
opacity: 0.5;
}
.feed-item.is-own {
opacity: 0.35;
}
.feed-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.375rem;
}
.feed-space {
font-size: 0.75rem;
color: #555;
}
.feed-type {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.feed-type.pr {
background: #1a1a2e;
color: #6366f1;
}
.feed-type.discussion {
background: #1a2e1a;
color: #22c55e;
}
.feed-status {
font-size: 0.625rem;
color: #444;
text-transform: uppercase;
}
.feed-status.open { color: #22c55e; }
.feed-status.merged { color: #a855f7; }
.feed-status.closed { color: #666; }
.feed-title {
font-size: 0.9375rem;
color: #e5e5e5;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.feed-stats {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.75rem;
color: #555;
}
.feed-stat {
display: flex;
align-items: center;
gap: 0.25rem;
}
.feed-reactions {
display: flex;
gap: 0.25rem;
}
.feed-author {
display: flex;
align-items: center;
gap: 0.375rem;
}
.feed-author-avatar {
width: 16px;
height: 16px;
border-radius: 50%;
}
.responded-badge,
.own-badge {
font-size: 0.625rem;
color: #555;
margin-left: 0.25rem;
}
.feed-time {
font-size: 0.75rem;
color: #444;
margin-left: auto;
}
.empty {
text-align: center;
padding: 3rem 1rem;
color: #444;
font-size: 0.875rem;
}
.utils-btn {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid #333;
border-radius: 4px;
color: #888;
cursor: pointer;
transition: all 0.15s;
margin-right: 1rem;
}
.utils-btn:hover {
border-color: #555;
color: #e5e5e5;
}
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-overlay.open {
display: flex;
}
.modal {
background: #141414;
border: 1px solid #222;
border-radius: 8px;
padding: 1.5rem;
max-width: 400px;
width: 90%;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.modal-title {
font-size: 1rem;
font-weight: 500;
color: #e5e5e5;
}
.modal-close {
background: none;
border: none;
color: #666;
font-size: 1.25rem;
cursor: pointer;
padding: 0;
line-height: 1;
}
.modal-close:hover {
color: #e5e5e5;
}
.util-item {
padding: 0.75rem;
background: #1a1a1a;
border: 1px solid #222;
border-radius: 6px;
margin-bottom: 0.5rem;
}
.util-item-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.util-name {
font-size: 0.875rem;
color: #e5e5e5;
}
.util-desc {
font-size: 0.75rem;
color: #666;
margin-top: 0.25rem;
}
.util-btn {
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
background: #22c55e;
border: none;
border-radius: 4px;
color: #000;
cursor: pointer;
transition: opacity 0.15s;
}
.util-btn:hover {
opacity: 0.9;
}
.util-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.progress-container {
margin-top: 1rem;
display: none;
}
.progress-container.active {
display: block;
}
.progress-bar {
height: 4px;
background: #222;
border-radius: 2px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: #22c55e;
width: 0%;
transition: width 0.3s;
}
.progress-text {
font-size: 0.75rem;
color: #666;
}
.progress-results {
margin-top: 0.75rem;
max-height: 150px;
overflow-y: auto;
}
.progress-result {
font-size: 0.75rem;
padding: 0.375rem 0;
display: flex;
justify-content: space-between;
gap: 0.5rem;
border-bottom: 1px solid #1a1a1a;
}
.progress-result:last-child {
border-bottom: none;
}
.progress-result.success {
color: #22c55e;
}
.progress-result.error {
color: #ef4444;
}
.progress-result span:first-child {
flex-shrink: 0;
}
.progress-result span:last-child {
text-align: right;
word-break: break-word;
max-width: 200px;
}
</style>
</head>
<body>
<nav class="navbar">
<h1>Spaces Dashboard</h1>
<div class="user-info">
<button class="utils-btn" id="utils-btn">Utils</button>
{% if user.avatar_url %}
<img src="{{ user.avatar_url }}" alt="" class="avatar">
{% endif %}
<span class="username">{{ user.username }}</span>
<a href="/logout" class="logout-btn">Sign out</a>
</div>
</nav>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header">
<h2 class="sidebar-title">Spaces</h2>
<span class="sidebar-count">{{ spaces|length }}</span>
</div>
{% if spaces %}
<div class="spaces-list">
{% for space in spaces %}
{% set status = space.runtime.stage|default('STOPPED')|upper if space.runtime else 'STOPPED' %}
{% set hardware = space.runtime.hardware.current|default('unknown') if space.runtime and space.runtime.hardware else 'unknown' %}
{% set storage = space.runtime.storage.current|default('none') if space.runtime and space.runtime.storage else 'none' %}
<div class="space-entry">
<div class="space-item space-toggle" data-space="{{ loop.index }}">
<span class="space-name">{{ space.id.split('/')[-1] }} <span style="color:#444;font-size:0.6875rem;">{{ space.likes|default(0) }}</span></span>
<span class="space-status {{ status|lower }}">
{% if status == 'RUNNING' %}running
{% elif status == 'BUILDING' %}building
{% elif status == 'RUNTIME_ERROR' or status == 'BUILD_ERROR' %}error
{% elif status == 'PAUSED' %}paused
{% elif status == 'SLEEPING' %}sleeping
{% else %}stopped{% endif %}
</span>
</div>
<div class="space-details" id="space-{{ loop.index }}">
<div class="space-detail-row">
<span class="space-detail-label">Hardware</span>
<span class="space-detail-value">{{ hardware }}</span>
</div>
<div class="space-detail-row">
<span class="space-detail-label">Storage</span>
<span class="space-detail-value">{{ storage }}</span>
</div>
<div class="space-detail-row">
<span class="space-detail-label">SDK</span>
<span class="space-detail-value">{{ space.sdk|default('unknown') }}</span>
</div>
<div class="space-detail-row">
<span class="space-detail-label">Likes</span>
<span class="space-detail-value">{{ space.likes|default(0) }}</span>
</div>
<a href="https://huggingface.co/spaces/{{ space.id }}" target="_blank" class="space-detail-row" style="color: #6366f1;">
<span>Open on HF</span>
<span></span>
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">No spaces</div>
{% endif %}
</aside>
<main class="main">
<div class="section-header">
<h2 class="section-title">Discussions</h2>
<span class="section-count">{{ discussions|length }}</span>
</div>
<div class="controls">
<div class="control-group">
<span class="control-label">Sort</span>
<div class="control-options">
<a href="?sort=score&status={{ filter_status }}" class="control-btn {{ 'active' if sort_by == 'score' else '' }}">Score</a>
<a href="?sort=comments&status={{ filter_status }}" class="control-btn {{ 'active' if sort_by == 'comments' else '' }}">Comments</a>
<a href="?sort=reactions&status={{ filter_status }}" class="control-btn {{ 'active' if sort_by == 'reactions' else '' }}">Reactions</a>
</div>
</div>
<div class="control-group">
<span class="control-label">Status</span>
<div class="control-options">
<a href="?sort={{ sort_by }}&status=all" class="control-btn {{ 'active' if filter_status == 'all' else '' }}">All</a>
<a href="?sort={{ sort_by }}&status=open" class="control-btn {{ 'active' if filter_status == 'open' else '' }}">Open</a>
<a href="?sort={{ sort_by }}&status=closed" class="control-btn {{ 'active' if filter_status == 'closed' else '' }}">Closed</a>
</div>
</div>
</div>
{% if discussions %}
<div class="feed">
{% for d in discussions %}
<a href="{{ d.url }}" target="_blank" class="feed-item status-{{ d.status }}{{ ' is-report' if 'report' in d.title|lower else '' }}{{ ' responded' if d.owner_responded else '' }}{{ ' is-own' if d.is_own else '' }}">
<div class="feed-meta">
<span class="feed-space">{{ d.space_name }}</span>
<span class="feed-type {{ 'pr' if d.is_pr else 'discussion' }}">
{{ 'PR' if d.is_pr else 'Discussion' }}
</span>
<span class="feed-status {{ d.status }}">{{ d.status }}</span>
{% if d.is_own %}<span class="own-badge">you</span>{% elif d.owner_responded %}<span class="responded-badge">replied</span>{% endif %}
{% if d.relative_time %}<span class="feed-time">{{ d.relative_time }}</span>{% endif %}
</div>
<div class="feed-title">{{ d.title }}</div>
<div class="feed-stats">
<span class="feed-author">
{% if d.author_avatar %}
<img src="{{ d.author_avatar if d.author_avatar.startswith('http') else 'https://huggingface.co' + d.author_avatar }}" alt="" class="feed-author-avatar">
{% endif %}
{{ d.author }}
</span>
<span class="feed-stat">{{ d.num_comments }} comment{{ 's' if d.num_comments != 1 else '' }}</span>
{% if d.num_reactions > 0 %}
<span class="feed-stat">
<span class="feed-reactions">
{% for r in d.top_reactions[:3] %}
{{ r.reaction }}
{% endfor %}
</span>
{{ d.num_reactions }}
</span>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty">No discussions</div>
{% endif %}
</main>
</div>
<div class="modal-overlay" id="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Utils</h3>
<button class="modal-close" id="modal-close">&times;</button>
</div>
<div class="util-item">
<div class="util-item-header">
<span class="util-name">Force Refresh</span>
<button class="util-btn" id="refresh-btn" style="background:#6366f1;">Refresh</button>
</div>
<div class="util-desc">Clear cache and refetch all spaces and discussions</div>
<div class="progress-container" id="refresh-progress">
<div class="progress-bar">
<div class="progress-fill" id="refresh-progress-fill"></div>
</div>
<div class="progress-text" id="refresh-progress-text">Starting...</div>
</div>
</div>
<div class="util-item">
<div class="util-item-header">
<span class="util-name">Wake All Sleeping Spaces</span>
<button class="util-btn" id="wake-all-btn">Wake</button>
</div>
<div class="util-desc">Restart all spaces that are currently sleeping</div>
<div class="progress-container" id="wake-progress">
<div class="progress-bar">
<div class="progress-fill" id="wake-progress-fill"></div>
</div>
<div class="progress-text" id="wake-progress-text">Starting...</div>
<div class="progress-results" id="wake-results"></div>
</div>
</div>
</div>
</div>
<script>
document.querySelectorAll('.space-toggle').forEach(el => {
el.addEventListener('click', () => {
const id = el.dataset.space;
const details = document.getElementById('space-' + id);
details.classList.toggle('open');
});
});
const modalOverlay = document.getElementById('modal-overlay');
const utilsBtn = document.getElementById('utils-btn');
const modalClose = document.getElementById('modal-close');
utilsBtn.addEventListener('click', () => {
modalOverlay.classList.add('open');
});
modalClose.addEventListener('click', () => {
modalOverlay.classList.remove('open');
});
modalOverlay.addEventListener('click', (e) => {
if (e.target === modalOverlay) {
modalOverlay.classList.remove('open');
}
});
// Refresh functionality
const refreshBtn = document.getElementById('refresh-btn');
const refreshProgress = document.getElementById('refresh-progress');
const refreshProgressFill = document.getElementById('refresh-progress-fill');
const refreshProgressText = document.getElementById('refresh-progress-text');
const stageNames = {
'spaces': 'Fetching spaces',
'discussions': 'Loading discussions',
'pending': 'Starting',
'': 'Starting'
};
refreshBtn.addEventListener('click', async () => {
refreshBtn.disabled = true;
refreshProgress.classList.add('active');
refreshProgressFill.style.width = '0%';
refreshProgressFill.style.background = '#6366f1';
refreshProgressText.textContent = 'Clearing cache...';
try {
const resp = await fetch('/api/refresh', { method: 'POST' });
const data = await resp.json();
if (data.error) {
refreshProgressText.textContent = 'Error: ' + data.error;
refreshProgressFill.style.background = '#ef4444';
refreshProgressFill.style.width = '100%';
refreshBtn.disabled = false;
return;
}
const jobId = data.job_id;
const pollJob = async () => {
const jobResp = await fetch('/api/job/' + jobId);
const job = await jobResp.json();
if (job.status === 'completed') {
refreshProgressFill.style.width = '100%';
refreshProgressText.textContent = 'Done! Reloading...';
setTimeout(() => window.location.reload(), 500);
return;
}
if (job.status === 'failed') {
refreshProgressText.textContent = 'Error: ' + (job.error || 'Unknown error');
refreshProgressFill.style.background = '#ef4444';
refreshProgressFill.style.width = '100%';
refreshBtn.disabled = false;
return;
}
const stage = job.progress_stage || '';
refreshProgressText.textContent = stageNames[stage] || stage;
if (job.progress_total > 0) {
const pct = Math.round((job.progress_current / job.progress_total) * 100);
refreshProgressFill.style.width = pct + '%';
refreshProgressText.textContent = `${stageNames[stage] || stage}: ${job.progress_current} / ${job.progress_total}`;
}
setTimeout(pollJob, 500);
};
pollJob();
} catch (err) {
refreshProgressText.textContent = 'Error: ' + err.message;
refreshProgressFill.style.background = '#ef4444';
refreshProgressFill.style.width = '100%';
refreshBtn.disabled = false;
}
});
// Wake functionality
const wakeAllBtn = document.getElementById('wake-all-btn');
const wakeProgress = document.getElementById('wake-progress');
const wakeProgressFill = document.getElementById('wake-progress-fill');
const wakeProgressText = document.getElementById('wake-progress-text');
const wakeResults = document.getElementById('wake-results');
wakeAllBtn.addEventListener('click', async () => {
wakeAllBtn.disabled = true;
wakeProgress.classList.add('active');
wakeProgressFill.style.width = '0%';
wakeProgressFill.style.background = '#22c55e';
wakeProgressText.textContent = 'Starting...';
wakeResults.innerHTML = '';
try {
const resp = await fetch('/api/wake-all', { method: 'POST' });
const data = await resp.json();
if (data.error) {
wakeProgressText.textContent = 'Error: ' + data.error;
wakeProgressFill.style.background = '#ef4444';
wakeProgressFill.style.width = '100%';
wakeAllBtn.disabled = false;
return;
}
if (data.total === 0 || !data.job_id) {
wakeProgressText.textContent = 'No sleeping spaces found';
wakeProgressFill.style.width = '100%';
wakeAllBtn.disabled = false;
return;
}
// Poll for job progress
const jobId = data.job_id;
const pollJob = async () => {
const jobResp = await fetch('/api/job/' + jobId);
const job = await jobResp.json();
if (job.status === 'completed') {
wakeProgressFill.style.width = '100%';
const result = JSON.parse(job.result || '{}');
wakeProgressText.textContent = `Done: ${result.succeeded || 0} succeeded, ${result.failed || 0} failed`;
if (result.results) {
result.results.forEach(r => {
const div = document.createElement('div');
div.className = 'progress-result ' + (r.success ? 'success' : 'error');
const name = r.id.split('/')[1];
const status = r.success ? 'OK' : (r.error || 'Failed');
div.innerHTML = `<span>${name}</span><span title="${r.error || ''}">${status}</span>`;
wakeResults.appendChild(div);
});
}
wakeAllBtn.disabled = false;
return;
}
if (job.status === 'failed') {
wakeProgressText.textContent = 'Error: ' + (job.error || 'Unknown error');
wakeProgressFill.style.background = '#ef4444';
wakeProgressFill.style.width = '100%';
wakeAllBtn.disabled = false;
return;
}
// Update progress
if (job.progress_total > 0) {
const pct = Math.round((job.progress_current / job.progress_total) * 100);
wakeProgressFill.style.width = pct + '%';
wakeProgressText.textContent = `Waking ${job.progress_current} / ${job.progress_total}`;
}
setTimeout(pollJob, 500);
};
pollJob();
} catch (err) {
wakeProgressText.textContent = 'Error: ' + err.message;
wakeProgressFill.style.background = '#ef4444';
wakeProgressFill.style.width = '100%';
wakeAllBtn.disabled = false;
}
});
</script>
</body>
</html>