3 Commits

Author SHA1 Message Date
Chuck
5e6c40ad55 fix(plugins): plugin manager tab doesn't always load on first visit (#319)
* fix(plugins): reset pluginsInitialized on HTMX re-swap; use refreshInstalledPlugins in refreshPlugins

HTMX's `revealed` trigger fires each time the plugins tab becomes
visible (Alpine's x-show toggles display:none which re-triggers the
IntersectionObserver). Each re-reveal fetches a fresh empty HTML
skeleton via hx-swap="innerHTML". The htmx:afterSwap handler reset
window.pluginManager.initialized/initializing but not pluginsInitialized,
so initializePlugins() hit its guard and skipped loadInstalledPlugins()
and searchPluginStore() — leaving the fresh empty DOM unpopulated.

Fix: also reset pluginsInitialized = false in the afterSwap handler.
Existing caches (3s for installed plugins, 5min for store) mean tab
revisits within the TTL render from cache instantly with no extra
API traffic.

Also change refreshPlugins() to call refreshInstalledPlugins() (which
already exists and explicitly invalidates the cache) instead of the
bare loadInstalledPlugins() call that could silently skip the fetch
if the 3-second cache happened to still be valid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugins): use cached store on HTMX re-swap; reserve searchPluginStore(true) for first load

searchPluginStore(true) bypasses the isCacheValid check unconditionally,
so every tab revisit was hitting the GitHub commit-info API even within
the 5-minute cache window.

Set window.pluginManager._reswap = true in the htmx:afterSwap handler
and read it in initializePlugins() to call searchPluginStore(false) on
re-swaps (respects the 5-minute cache) vs searchPluginStore(true) on
first load (always fetches fresh). Explicit user refresh via
refreshPlugins() already calls searchPluginStore(true) directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 11:57:26 -04:00
Chuck
d6bd1ee215 fix(install): prevent weather and music from auto-installing on fresh install (#318)
* fix(install): remove weather and music credential stubs from secrets template

config_secrets.template.json shipped ledmatrix-weather and music as
top-level keys; config_manager deep-merges secrets into the main config
on load, so the reconciler treated them as plugin config entries and
auto-installed both plugins on first web UI visit after a fresh install.

Remove both keys from the template and clear the inline fallback block
in first_time_install.sh so new installs start clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(install): sync fallback secrets with template structure

The fallback block (used when config_secrets.template.json is missing)
was an empty object after the weather/music keys were removed. Mirror
the current template so youtube and github placeholders are always
present regardless of whether the template file exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:22:28 -04:00
Chuck
acaf8a248e feat(web): update-available banner in web UI (#311)
* feat(web): add update-available banner to web UI

Adds a polite, dismissible banner between the header and navigation
tabs that appears when the local repo is behind origin/main. Shows
commit count and a one-click "Update Now" button that triggers the
existing git_pull action.

- New GET /api/v3/system/check-update endpoint (5-min cache, compares
  local HEAD vs origin/main SHA)
- Banner auto-checks on page load then every 30 minutes
- Dismiss persists for the browser session via sessionStorage
- Styled for both light and dark themes
- Cache invalidated after successful git_pull so banner hides immediately

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(update-banner): address review findings — lock, returncode checks, update_available logic, a11y, button state

- Add _update_check_lock (threading.Lock) around all reads/writes to
  _update_check_cache in check_for_update() and git_pull, preventing
  races on concurrent requests
- Validate returncode for git fetch, rev-parse HEAD, and rev-parse
  origin/main; raise RuntimeError on failure so errors are caught and
  returned as error payloads instead of silently producing stale/empty SHAs
- Set update_available = commits_behind > 0 (was unconditionally True
  when local_sha != remote_sha); prevents false positive when local is
  ahead of remote
- Add type="button" and aria-label="Dismiss update" to the icon-only
  dismiss button
- Restore btn.innerHTML and btn.disabled in both success and error paths
  of applyUpdate(); only hide the banner and clear sessionStorage when
  data.status === 'success'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(update-banner): address second-round review findings

api_v3.py:
- Move all git work inside _update_check_lock so concurrent requests
  re-check cache staleness after acquiring the lock; only the first
  caller runs git fetch/rev-parse/log, subsequent callers return the
  cached result
- Check log_result.returncode and raise on failure so a broken git log
  doesn't produce a silent false-negative (commits_behind=0)
- Rename loop variable l → commit_line

base.html:
- Replace boolean _dismissed flag with SHA-scoped sessionStorage key
  'update-sha-dismissed'; dismissing for SHA X still allows the banner
  to reappear when origin/main advances to SHA Y
- Successful applyUpdate clears 'update-sha-dismissed' so the next
  update cycle can show the banner again
- Add aria-live="polite" aria-atomic="true" to #update-banner-text so
  screen readers announce content changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 09:38:03 -04:00
6 changed files with 223 additions and 16 deletions

View File

@@ -1,16 +1,8 @@
{ {
"ledmatrix-weather": {
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
},
"youtube": { "youtube": {
"api_key": "YOUR_YOUTUBE_API_KEY", "api_key": "YOUR_YOUTUBE_API_KEY",
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID" "channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
}, },
"music": {
"SPOTIFY_CLIENT_ID": "YOUR_SPOTIFY_CLIENT_ID_HERE",
"SPOTIFY_CLIENT_SECRET": "YOUR_SPOTIFY_CLIENT_SECRET_HERE",
"SPOTIFY_REDIRECT_URI": "http://127.0.0.1:8888/callback"
},
"github": { "github": {
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN" "api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
} }

View File

@@ -599,8 +599,12 @@ if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
echo "⚠ Template config/config_secrets.template.json not found; creating a minimal secrets file" echo "⚠ Template config/config_secrets.template.json not found; creating a minimal secrets file"
cat > "$PROJECT_ROOT_DIR/config/config_secrets.json" <<'EOF' cat > "$PROJECT_ROOT_DIR/config/config_secrets.json" <<'EOF'
{ {
"weather": { "youtube": {
"api_key": "YOUR_OPENWEATHERMAP_API_KEY" "api_key": "YOUR_YOUTUBE_API_KEY",
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
},
"github": {
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
} }
} }
EOF EOF

View File

@@ -9,6 +9,7 @@ import time
import hashlib import hashlib
import uuid import uuid
import logging import logging
import threading
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple, Dict, Any, Type from typing import Optional, Tuple, Dict, Any, Type
@@ -1626,6 +1627,71 @@ def get_system_version():
logger.exception("[System] get_system_version failed") logger.exception("[System] get_system_version failed")
return jsonify({'status': 'error', 'message': 'Failed to get system version'}), 500 return jsonify({'status': 'error', 'message': 'Failed to get system version'}), 500
_update_check_cache: Dict = {}
_UPDATE_CHECK_TTL = 300 # 5 minutes
_update_check_lock = threading.Lock()
@api_v3.route('/system/check-update', methods=['GET'])
def check_for_update():
"""Check if a newer version is available on the remote."""
now = time.time()
project_dir = str(PROJECT_ROOT)
with _update_check_lock:
if _update_check_cache.get('ts', 0) + _UPDATE_CHECK_TTL > now:
return jsonify(_update_check_cache['data'])
try:
fetch_result = subprocess.run(
['git', 'fetch', 'origin', 'main'],
capture_output=True, text=True, timeout=15, cwd=project_dir
)
if fetch_result.returncode != 0:
raise RuntimeError(f"git fetch failed: {fetch_result.stderr.strip()}")
local_result = subprocess.run(
['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=5, cwd=project_dir
)
if local_result.returncode != 0:
raise RuntimeError(f"git rev-parse HEAD failed: {local_result.stderr.strip()}")
local_sha = local_result.stdout.strip()
remote_result = subprocess.run(
['git', 'rev-parse', 'origin/main'],
capture_output=True, text=True, timeout=5, cwd=project_dir
)
if remote_result.returncode != 0:
raise RuntimeError(f"git rev-parse origin/main failed: {remote_result.stderr.strip()}")
remote_sha = remote_result.stdout.strip()
if local_sha == remote_sha:
data = {'status': 'success', 'update_available': False,
'local_sha': local_sha[:8], 'remote_sha': remote_sha[:8]}
else:
log_result = subprocess.run(
['git', 'log', 'HEAD..origin/main', '--oneline'],
capture_output=True, text=True, timeout=5, cwd=project_dir
)
if log_result.returncode != 0:
raise RuntimeError(f"git log failed: {log_result.stderr.strip()}")
lines = [commit_line for commit_line in log_result.stdout.strip().split('\n') if commit_line]
commits_behind = len(lines)
data = {
'status': 'success',
'update_available': commits_behind > 0,
'local_sha': local_sha[:8],
'remote_sha': remote_sha[:8],
'commits_behind': commits_behind,
'latest_message': lines[0].split(' ', 1)[1] if lines else '',
}
except Exception as e:
logger.warning("[System] check-update failed: %s", e)
data = {'status': 'error', 'update_available': False, 'message': str(e)}
_update_check_cache['ts'] = now
_update_check_cache['data'] = data
return jsonify(data)
@api_v3.route('/system/action', methods=['POST']) @api_v3.route('/system/action', methods=['POST'])
def execute_system_action(): def execute_system_action():
"""Execute system actions (start/stop/reboot/etc)""" """Execute system actions (start/stop/reboot/etc)"""
@@ -1735,6 +1801,10 @@ def execute_system_action():
cwd=project_dir cwd=project_dir
) )
# Invalidate update-check cache so the banner hides immediately
with _update_check_lock:
_update_check_cache.clear()
# Return custom response for git_pull # Return custom response for git_pull
if result.returncode == 0: if result.returncode == 0:
pull_message = "Code updated successfully." pull_message = "Code updated successfully."

View File

@@ -1004,3 +1004,39 @@ button.bg-white {
[data-theme="dark"] .theme-toggle-btn { [data-theme="dark"] .theme-toggle-btn {
color: #fbbf24; color: #fbbf24;
} }
/* Update available banner */
.update-banner {
background-color: #eff6ff;
border-color: #bfdbfe;
color: #1e40af;
}
.update-banner-action {
background-color: #3b82f6;
color: #fff;
}
.update-banner-action:hover {
background-color: #2563eb;
}
.update-banner-dismiss {
color: #1e40af;
opacity: 0.6;
}
.update-banner-dismiss:hover {
opacity: 1;
}
[data-theme="dark"] .update-banner {
background-color: #1e293b;
border-color: #334155;
color: #93c5fd;
}
[data-theme="dark"] .update-banner-action {
background-color: #2563eb;
}
[data-theme="dark"] .update-banner-action:hover {
background-color: #3b82f6;
}
[data-theme="dark"] .update-banner-dismiss {
color: #93c5fd;
}

View File

@@ -1165,9 +1165,11 @@ function initializePluginPageWhenReady() {
if (target.id === 'plugins-content' || if (target.id === 'plugins-content' ||
target.querySelector('#installed-plugins-grid')) { target.querySelector('#installed-plugins-grid')) {
console.log('HTMX swap detected for plugins, initializing...'); console.log('HTMX swap detected for plugins, initializing...');
// Reset initialization flag to allow re-initialization after HTMX swap // Reset all initialization flags so the fresh empty DOM gets populated
window.pluginManager.initialized = false; window.pluginManager.initialized = false;
window.pluginManager.initializing = false; window.pluginManager.initializing = false;
window.pluginManager._reswap = true; // signal: use cached store, don't re-fetch GitHub
pluginsInitialized = false;
initTimer = setTimeout(attemptInit, 100); initTimer = setTimeout(attemptInit, 100);
} }
}, { once: false }); // Allow multiple swaps }, { once: false }); // Allow multiple swaps
@@ -1211,9 +1213,13 @@ function initializePlugins() {
console.warn('[INIT] checkGitHubAuthStatus not available yet'); console.warn('[INIT] checkGitHubAuthStatus not available yet');
} }
// Load both installed plugins and plugin store // Load both installed plugins and plugin store.
// On HTMX re-swaps use cached store data (fetchCommitInfo=false) to avoid
// re-hitting GitHub on every tab switch; only fetch fresh on first load.
const isReswap = !!window.pluginManager._reswap;
window.pluginManager._reswap = false;
loadInstalledPlugins(); loadInstalledPlugins();
searchPluginStore(true); // Load plugin store with fresh metadata from GitHub searchPluginStore(!isReswap);
// Setup search functionality (with guard against duplicate listeners) // Setup search functionality (with guard against duplicate listeners)
const searchInput = document.getElementById('plugin-search'); const searchInput = document.getElementById('plugin-search');
@@ -5127,7 +5133,7 @@ function refreshPlugins() {
pluginStoreCache = null; pluginStoreCache = null;
cacheTimestamp = null; cacheTimestamp = null;
loadInstalledPlugins(); refreshInstalledPlugins(); // invalidates cache before fetching
// Fetch latest metadata from GitHub when refreshing // Fetch latest metadata from GitHub when refreshing
searchPluginStore(true); searchPluginStore(true);
showNotification('Plugins refreshed with latest metadata from GitHub', 'success'); showNotification('Plugins refreshed with latest metadata from GitHub', 'success');

View File

@@ -900,6 +900,34 @@
</div> </div>
</header> </header>
<!-- Update available banner -->
<div id="update-banner" style="display:none"
class="update-banner border-b transition-all duration-300 ease-in-out">
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<i class="fas fa-arrow-circle-up text-lg"></i>
<span class="text-sm font-medium" id="update-banner-text"
aria-live="polite" aria-atomic="true">
A new LEDMatrix update is available
</span>
</div>
<div class="flex items-center space-x-3">
<button onclick="applyUpdate()" id="update-banner-btn"
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md
update-banner-action transition-colors duration-150">
<i class="fas fa-download mr-1"></i> Update Now
</button>
<button type="button" onclick="dismissUpdateBanner()"
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
title="Dismiss" aria-label="Dismiss update">
<i class="fas fa-times text-sm"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Main content --> <!-- Main content -->
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;"> <main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
<!-- Navigation tabs --> <!-- Navigation tabs -->
@@ -4874,6 +4902,77 @@
</form> </form>
</div> </div>
</div> </div>
<!-- Update banner logic -->
<script>
(function() {
var CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes
function getDismissedSha() {
try { return sessionStorage.getItem('update-sha-dismissed'); } catch(e) { return null; }
}
function checkForUpdate() {
fetch('/api/v3/system/check-update')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.update_available && getDismissedSha() !== data.remote_sha) {
var n = data.commits_behind || 0;
var msg = 'A new LEDMatrix update is available';
if (n > 0) msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')';
document.getElementById('update-banner-text').textContent = msg;
document.getElementById('update-banner').style.display = '';
try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {}
} else {
document.getElementById('update-banner').style.display = 'none';
}
})
.catch(function() {});
}
window.dismissUpdateBanner = function() {
document.getElementById('update-banner').style.display = 'none';
try {
var sha = sessionStorage.getItem('update-sha');
if (sha) sessionStorage.setItem('update-sha-dismissed', sha);
} catch(e) {}
};
window.applyUpdate = function() {
var btn = document.getElementById('update-banner-btn');
var originalHTML = '<i class="fas fa-download mr-1"></i> Update Now';
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Updating...';
btn.disabled = true;
fetch('/api/v3/system/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'git_pull' })
})
.then(function(r) { return r.json(); })
.then(function(data) {
btn.innerHTML = originalHTML;
btn.disabled = false;
if (data.status === 'success') {
document.getElementById('update-banner').style.display = 'none';
try { sessionStorage.removeItem('update-sha-dismissed'); } catch(e) {}
}
if (typeof showNotification === 'function') {
showNotification(data.message || 'Update complete', data.status || 'success');
}
})
.catch(function() {
btn.innerHTML = originalHTML;
btn.disabled = false;
if (typeof showNotification === 'function') {
showNotification('Update failed — check your connection', 'error');
}
});
};
// Initial check shortly after page load, then periodic
setTimeout(checkForUpdate, 2000);
setInterval(checkForUpdate, CHECK_INTERVAL);
})();
</script>
</body> </body>
</html> </html>