Spaces:
Sleeping
Sleeping
| <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">×</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> | |