No release notes available.
'; + } + + document.getElementById('update-modal-progress').style.display = 'none'; + document.getElementById('update-modal-progress').innerHTML = ''; + document.getElementById('update-modal-btn').disabled = false; + document.getElementById('update-modal-btn').style.display = ''; + document.getElementById('update-modal-cancel').style.display = ''; + + modal.showModal(); +} + +function performUpdate() { + var btn = document.getElementById('update-modal-btn'); + var progress = document.getElementById('update-modal-progress'); + var changelog = document.getElementById('update-modal-changelog'); + var cancelBtn = document.getElementById('update-modal-cancel'); + + btn.disabled = true; + btn.innerHTML = ' Updating...'; + cancelBtn.style.display = 'none'; + changelog.style.display = 'none'; + progress.style.display = ''; + + fetch('/api/update', { method: 'POST' }).then(function(response) { + var reader = response.body.getReader(); + var decoder = new TextDecoder(); + var buffer = ''; + var sseEventType = ''; + + function processLine(line) { + if (line.indexOf('event: ') === 0) { + sseEventType = line.substring(7).trim(); + return; + } + if (line.indexOf('data: ') !== 0) { + if (line === '') sseEventType = ''; + return; + } + var data = line.substring(6); + var eventType = sseEventType; + sseEventType = ''; + + var el = document.createElement('div'); + + if (eventType === 'done') { + el.className = 'text-success font-semibold'; + el.textContent = data; + progress.appendChild(el); + btn.style.display = 'none'; + + var reloadLine = document.createElement('div'); + reloadLine.className = 'text-muted mt-1'; + reloadLine.textContent = 'Restarting keel... reloading in a few seconds.'; + progress.appendChild(reloadLine); + setTimeout(function() { waitForRestart(); }, 2000); + } else if (eventType === 'app-error') { + el.className = 'text-error font-semibold'; + el.textContent = data; + progress.appendChild(el); + btn.disabled = false; + btn.innerHTML = 'Retry'; + cancelBtn.style.display = ''; + } else { + el.textContent = data; + progress.appendChild(el); + } + progress.scrollTop = progress.scrollHeight; + } + + function read() { + reader.read().then(function(result) { + if (result.done) return; + buffer += decoder.decode(result.value, { stream: true }); + var lines = buffer.split('\n'); + buffer = lines.pop(); + for (var i = 0; i < lines.length; i++) { + processLine(lines[i]); + } + read(); + }); + } + read(); + }).catch(function(err) { + var line = document.createElement('div'); + line.className = 'text-error font-semibold'; + line.textContent = 'Connection failed: ' + err.message; + progress.appendChild(line); + btn.disabled = false; + btn.innerHTML = 'Retry'; + cancelBtn.style.display = ''; + }); +} + +function waitForRestart() { + var attempts = 0; + var maxAttempts = 30; + function tryReload() { + attempts++; + fetch('/api/health').then(function(r) { + if (r.ok) { + window.location.reload(); + } else { + retry(); + } + }).catch(function() { + retry(); + }); + } + function retry() { + if (attempts < maxAttempts) { + setTimeout(tryReload, 1000); + } else { + var progress = document.getElementById('update-modal-progress'); + if (progress) { + var el = document.createElement('div'); + el.className = 'text-warning'; + el.textContent = 'Could not reconnect. Please reload the page manually.'; + progress.appendChild(el); + } + } + } + tryReload(); +} + +// Changelog markdown renderer with category icons +var changelogIcons = { + "added": '', + "changed": '', + "fixed": '', + "removed": '', + "what's new": '' +}; + +function renderMarkdown(text) { + var lines = text.split('\n'); + var html = ''; + var inList = false; + var inSection = false; + + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + + if (/^#{2,3}\s+/.test(line)) { + if (inList) { html += ''; inList = false; } + if (inSection) { html += ''; inSection = false; } + + var heading = line.replace(/^#{2,3}\s+/, '').trim(); + var key = heading.toLowerCase(); + var icon = changelogIcons[key] || ''; + + html += '' + escapeHtml(line) + '
'; + } + } + if (inList) html += ''; + if (inSection) html += 'No release notes available.
'; + } + return html; +} + +function escapeHtml(str) { + var div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; +} + // AnsiUp instance for ANSI color rendering let ansiUp = null; @@ -115,50 +311,132 @@ function confirmAction(title, message, confirmText, confirmClass, onConfirm) { modal.showModal(); } -// SSE stream handler for operation progress +// SSE stream handler for operation progress (supports both GET and POST) function startSSE(url, opts) { opts = opts || {}; + var method = opts.method || 'GET'; var panel = document.getElementById('operation-output'); if (panel) { panel.innerHTML = ''; document.getElementById('operation-panel').classList.remove('hidden'); } - var source = new EventSource(url); + if (method === 'GET') { + var source = new EventSource(url); + + source.onmessage = function(e) { + if (!panel) return; + var converter = getAnsiUp(); + var line = document.createElement('div'); + line.innerHTML = safeAnsiHtml(converter, e.data); + panel.appendChild(line); + panel.scrollTop = panel.scrollHeight; + }; + + source.addEventListener('done', function(e) { + source.close(); + if (panel) { + var banner = document.createElement('div'); + banner.className = 'text-success font-semibold mt-2'; + banner.textContent = e.data || 'Operation completed successfully'; + panel.appendChild(banner); + } + showToast(opts.successMessage || 'Operation completed', 'success'); + htmx.trigger(document.body, 'refreshServices'); + }); - source.onmessage = function(e) { - if (!panel) return; - var converter = getAnsiUp(); - var line = document.createElement('div'); - line.innerHTML = safeAnsiHtml(converter, e.data); - panel.appendChild(line); - panel.scrollTop = panel.scrollHeight; - }; + source.addEventListener('app-error', function(e) { + source.close(); + if (panel) { + var banner = document.createElement('div'); + banner.className = 'text-error font-semibold mt-2'; + banner.textContent = e.data || 'Operation failed'; + panel.appendChild(banner); + } + showToast(opts.errorMessage || 'Operation failed', 'error'); + }); + + return source; + } + + // POST-based SSE: use fetch + ReadableStream + _fetchSSE(url, method, panel, opts); +} + +function _fetchSSE(url, method, panel, opts) { + fetch(url, { method: method }).then(function(response) { + if (!response.ok) { + showToast(opts.errorMessage || 'Operation failed', 'error'); + return; + } + var reader = response.body.getReader(); + var decoder = new TextDecoder(); + var buffer = ''; + + function processChunk(result) { + if (result.done) { + // Process remaining buffer + if (buffer.trim()) _parseSSEBuffer(buffer, panel, opts); + return; + } + buffer += decoder.decode(result.value, { stream: true }); + // Split on double newlines (SSE frame boundary) + var frames = buffer.split('\n\n'); + buffer = frames.pop(); // keep incomplete frame + for (var i = 0; i < frames.length; i++) { + _parseSSEFrame(frames[i].trim(), panel, opts); + } + return reader.read().then(processChunk); + } + + return reader.read().then(processChunk); + }).catch(function() { + showToast(opts.errorMessage || 'Connection failed', 'error'); + }); +} + +function _parseSSEBuffer(buf, panel, opts) { + var frames = buf.split('\n\n'); + for (var i = 0; i < frames.length; i++) { + if (frames[i].trim()) _parseSSEFrame(frames[i].trim(), panel, opts); + } +} - source.addEventListener('done', function(e) { - source.close(); +function _parseSSEFrame(frame, panel, opts) { + if (!frame) return; + var event = 'message'; + var data = ''; + var lines = frame.split('\n'); + for (var i = 0; i < lines.length; i++) { + if (lines[i].indexOf('event: ') === 0) event = lines[i].substring(7).trim(); + else if (lines[i].indexOf('data: ') === 0) data = lines[i].substring(6); + } + + if (event === 'done') { if (panel) { var banner = document.createElement('div'); banner.className = 'text-success font-semibold mt-2'; - banner.textContent = e.data || 'Operation completed successfully'; + banner.textContent = data || 'Operation completed successfully'; panel.appendChild(banner); } showToast(opts.successMessage || 'Operation completed', 'success'); htmx.trigger(document.body, 'refreshServices'); - }); - - source.addEventListener('error', function(e) { - source.close(); + } else if (event === 'app-error') { if (panel) { var banner = document.createElement('div'); banner.className = 'text-error font-semibold mt-2'; - banner.textContent = e.data || 'Operation failed'; + banner.textContent = data || 'Operation failed'; panel.appendChild(banner); } showToast(opts.errorMessage || 'Operation failed', 'error'); - }); - - return source; + } else { + if (!panel) return; + var converter = getAnsiUp(); + var line = document.createElement('div'); + line.innerHTML = safeAnsiHtml(converter, data); + panel.appendChild(line); + panel.scrollTop = panel.scrollHeight; + } } // Keyboard shortcuts @@ -186,6 +464,12 @@ function setActiveNav(page) { } document.addEventListener('click', function(e) { + // Close donate dropdown when clicking outside + var dropdown = document.querySelector('.donate-dropdown.open'); + if (dropdown && !dropdown.contains(e.target)) { + dropdown.classList.remove('open'); + } + var navItem = e.target.closest('[data-page]'); if (!navItem) return; setActiveNav(navItem.getAttribute('data-page')); @@ -199,10 +483,11 @@ function connectToContainer(name) { // Navigate to logs page with a specific service pre-selected function navigateToLogs(serviceName) { htmx.ajax('GET', '/partials/logs', {target: '#main-content', swap: 'innerHTML'}).then(function() { - // Wait for Alpine to initialize the log-viewer component, then dispatch event - setTimeout(function() { + // Dispatch after HTMX finishes settling the swapped DOM + document.addEventListener('htmx:afterSettle', function onSettle() { + document.removeEventListener('htmx:afterSettle', onSettle); document.dispatchEvent(new CustomEvent('open-logs', { detail: { service: serviceName } })); - }, 100); + }); }); history.pushState(null, '', '/logs'); setActiveNav('/logs'); @@ -236,7 +521,21 @@ document.addEventListener('htmx:afterRequest', function(e) { if (!action || !actionMessages[action]) return; if (e.detail.successful) { - showToast(actionMessages[action].ok, 'success'); + // SSE streams return HTTP 200 even on failure — check the response + // body for "event: app-error" to detect errors inside the stream. + var responseText = e.detail.xhr.responseText || ''; + var errorIdx = responseText.indexOf('event: app-error'); + if (errorIdx !== -1) { + var reason = ''; + var dataPrefix = responseText.indexOf('data: ', errorIdx); + if (dataPrefix !== -1) { + var lineEnd = responseText.indexOf('\n', dataPrefix); + reason = responseText.substring(dataPrefix + 6, lineEnd !== -1 ? lineEnd : undefined).trim(); + } + showToast(actionMessages[action].fail + (reason ? ': ' + reason : ''), 'error'); + } else { + showToast(actionMessages[action].ok, 'success'); + } } }); @@ -361,7 +660,7 @@ function seederSSE(url, btn, name) { if (out) { out.innerHTML = ''; sessionStorage.removeItem('seederLog-' + name); } showSeederLog(name); } else { - document.querySelectorAll('.seeder-card').forEach(function(c) { + document.querySelectorAll('.seeder-item').forEach(function(c) { var n = c.id.replace('seeder-card-', ''); setSeederStatus(n, 'running'); var out = document.getElementById('seeder-log-output-' + n); @@ -370,54 +669,98 @@ function seederSSE(url, btn, name) { }); } - var source = new EventSource(url); - - source.onmessage = function(e) { + function onData(data) { if (name) { - appendSeederLog(name, e.data); + appendSeederLog(name, data); } else { - var match = e.data.match(/^\[([^\]]+)\]/); - if (match) appendSeederLog(match[1], e.data); + var match = data.match(/^\[([^\]]+)\]/); + if (match) appendSeederLog(match[1], data); } - }; + } - source.addEventListener('done', function(e) { - source.close(); + function onDone(data) { btn.disabled = false; if (btn.querySelector('span')) btn.querySelector('span').textContent = originalText; if (name) { setSeederStatus(name, 'success'); - appendSeederLog(name, '✓ ' + (e.data || 'completed')); + appendSeederLog(name, '✓ ' + (data || 'completed')); showToast('Seeder completed', 'success'); } else { - document.querySelectorAll('.seeder-card').forEach(function(c) { + document.querySelectorAll('.seeder-item').forEach(function(c) { setSeederStatus(c.id.replace('seeder-card-', ''), 'success'); }); showToast('Seeders completed', 'success'); } - }); + } - source.addEventListener('error', function(e) { - source.close(); + function onError(data) { btn.disabled = false; if (btn.querySelector('span')) btn.querySelector('span').textContent = originalText; showToast('Seeder failed', 'error'); if (name) { setSeederStatus(name, 'error'); - appendSeederLog(name, '✗ ' + (e.data || 'failed')); + appendSeederLog(name, '✗ ' + (data || 'failed')); } else { - var errMatch = e.data ? e.data.match(/seeder "([^"]+)"/) : null; + var errMatch = data ? data.match(/seeder "([^"]+)"/) : null; var failedName = errMatch ? errMatch[1] : null; - document.querySelectorAll('.seeder-card').forEach(function(c) { + document.querySelectorAll('.seeder-item').forEach(function(c) { var n = c.id.replace('seeder-card-', ''); if (n === failedName) { setSeederStatus(n, 'error'); - appendSeederLog(n, '✗ ' + (e.data || 'failed')); + appendSeederLog(n, '✗ ' + (data || 'failed')); } else if (c.classList.contains('seeder-running')) { setSeederStatus(n, 'idle'); } }); } + } + + fetch(url, { method: 'POST' }).then(function(response) { + if (!response.ok) { + onError('request failed: ' + response.status); + return; + } + var reader = response.body.getReader(); + var decoder = new TextDecoder(); + var buffer = ''; + + function processChunk(result) { + if (result.done) { + if (buffer.trim()) processFrames(buffer); + return; + } + buffer += decoder.decode(result.value, { stream: true }); + var frames = buffer.split('\n\n'); + buffer = frames.pop(); + for (var i = 0; i < frames.length; i++) { + if (frames[i].trim()) processFrame(frames[i].trim()); + } + return reader.read().then(processChunk); + } + + function processFrames(buf) { + var parts = buf.split('\n\n'); + for (var i = 0; i < parts.length; i++) { + if (parts[i].trim()) processFrame(parts[i].trim()); + } + } + + function processFrame(frame) { + var event = 'message'; + var data = ''; + var lines = frame.split('\n'); + for (var i = 0; i < lines.length; i++) { + if (lines[i].indexOf('event: ') === 0) event = lines[i].substring(7).trim(); + else if (lines[i].indexOf('data: ') === 0) data = lines[i].substring(6); + } + if (event === 'done') onDone(data); + else if (event === 'app-error') onError(data); + else onData(data); + } + + return reader.read().then(processChunk); + }).catch(function() { + onError('connection failed'); }); } diff --git a/web/static/style.css b/web/static/style.css index 4f30de6..e6ba7c0 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -226,6 +226,38 @@ a:hover { opacity: 0.85; } opacity: 1; } +.donate-dropdown .donate-menu { + display: none; + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 0; + min-width: 160px; + z-index: 100; + box-shadow: 0 4px 12px rgba(0,0,0,.3); +} + +.donate-dropdown.open .donate-menu { display: block; } + +.donate-menu a { + display: block; + padding: 6px 12px; + color: var(--muted); + text-decoration: none; + font-size: 11px; + font-family: var(--mono); + letter-spacing: .03em; +} + +.donate-menu a:hover { + color: var(--text); + background: var(--surface2); +} + .topbar-right { margin-left: auto; display: flex; @@ -1577,18 +1609,10 @@ article.card, .service-card { .seeder-status.pending { color: var(--muted2); } .seeder-status.running { color: var(--yellow); } -/* Legacy seeder card compat */ -.seeder-card { - background: var(--surface); - border-radius: 6px; - border: 1px solid var(--border); - transition: background .15s; -} -.seeder-card:hover { background: var(--surface2); } -.seeder-card.seeder-idle { border-left: 2px solid var(--muted2); } -.seeder-card.seeder-running { border-left: 2px solid var(--yellow); } -.seeder-card.seeder-success { border-left: 2px solid var(--green); } -.seeder-card.seeder-error { border-left: 2px solid var(--red); } +.seeder-item.seeder-idle { border-left: 2px solid var(--muted2); } +.seeder-item.seeder-running { border-left: 2px solid var(--yellow); } +.seeder-item.seeder-success { border-left: 2px solid var(--green); } +.seeder-item.seeder-error { border-left: 2px solid var(--red); } /* ══════════════════════════════════════════ 15. TARGETS VIEW @@ -2133,27 +2157,33 @@ progress.bar-warning::-moz-progress-bar { background: var(--yellow); } progress.bar-error::-moz-progress-bar { background: var(--red); } /* ══════════════════════════════════════════ - 23. UPDATE BADGE + 23. UPDATE PILL ══════════════════════════════════════════ */ -.update-badge { - display: flex; +.update-pill { + display: inline-flex; align-items: center; - gap: 4px; - padding: 2px 7px; - background: var(--yellow-bg); - border: 1px solid var(--yellow-bdr); - color: var(--yellow); - font-family: var(--sans); - font-size: 12px; - letter-spacing: .04em; - text-decoration: none; + gap: 5px; + padding: 3px 10px 3px 8px; + background: linear-gradient(135deg, var(--accent), #7c3aed); + border: none; + color: #fff; + font-family: var(--mono); + font-size: 11px; + font-weight: 600; + letter-spacing: .03em; cursor: pointer; - border-radius: 4px; - animation: pulse 2s ease-in-out infinite; + border-radius: 999px; + transition: transform .15s, box-shadow .15s; + box-shadow: 0 0 8px rgba(139, 92, 246, .35); +} + +.update-pill:hover { + transform: scale(1.05); + box-shadow: 0 0 14px rgba(139, 92, 246, .5); } -.update-badge:hover { background: #241a00; } +.update-pill svg { flex-shrink: 0; } /* ══════════════════════════════════════════ 24. DIALOG / MODAL @@ -2195,6 +2225,131 @@ dialog footer { gap: 8px; } +/* Update modal */ +#update-modal { + max-width: 40rem; + width: 90%; + padding: 0; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; +} + +.update-modal-content { margin: 0; } + +.update-modal-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 24px 28px 18px; + border-bottom: 1px solid var(--border); +} + +.update-modal-header h3 { font-size: 15px; margin-bottom: 4px; } + +.update-modal-changelog { + padding: 20px 28px; + max-height: 400px; + overflow-y: auto; + font-size: 14px; + line-height: 1.7; + color: var(--muted2); +} + +.update-modal-changelog .changelog-section { margin-bottom: 16px; } +.update-modal-changelog .changelog-section:last-child { margin-bottom: 0; } + +.update-modal-changelog .changelog-heading { + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted2); + margin: 0 0 10px; + display: flex; + align-items: center; + gap: 8px; +} + +.changelog-icon { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; +} + +.changelog-icon-added { color: var(--green); } +.changelog-icon-changed { color: var(--accent); } +.changelog-icon-fixed { color: var(--green); } +.changelog-icon-removed { color: var(--red); } + +.update-modal-changelog .changelog-list { + margin: 0; + padding: 0; + list-style: none; +} + +.update-modal-changelog .changelog-list li { + position: relative; + padding-left: 16px; + margin-bottom: 8px; + line-height: 1.6; +} + +.update-modal-changelog .changelog-list li::before { + content: ""; + position: absolute; + left: 0; + top: 9px; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--muted); +} + +.update-modal-changelog .changelog-text { margin: 0 0 8px; } + +.update-modal-progress { + padding: 16px 24px; + max-height: 200px; + overflow-y: auto; + font-family: var(--mono); + font-size: 12px; + line-height: 1.7; + color: var(--muted2); + background: var(--bg); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.update-modal-footer { + margin-top: 0; + border-top: none; + padding: 18px 28px 18px; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.spinner-sm { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--border2); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.6s linear infinite; + vertical-align: middle; + margin-right: 4px; +} + /* ══════════════════════════════════════════ 25. TOOLTIP ══════════════════════════════════════════ */ @@ -2435,7 +2590,9 @@ dialog footer { .text-primary { color: var(--accent); } .text-success { color: var(--green); } .text-error { color: var(--red); } +.text-warning { color: var(--yellow); } .text-muted { color: var(--muted); } +.text-accent { color: var(--accent); } .border-b { border-bottom: 1px solid var(--border); } .border-t { border-top: 1px solid var(--border); } .overflow-auto { overflow-y: auto; } diff --git a/web/templates/layout.html b/web/templates/layout.html index 16f6ff2..851779f 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -37,20 +37,32 @@ - - - buy me a coffee - +