feat(web): ES-module page lifecycle and one schema field model (stage 1) (#703)

Adds a native ES-module layer to the web UI (core/boot, registry, api, facade; window.LEDMatrix as the one global), a page lifecycle that the Cache tab is converted to as the reference, text/javascript serving and revalidation for unversioned module requests, and src/plugin_system/field_model.py with a parity test against the render_field macro. Also: the cache page toggles its grey 'Not configured' style instead of only adding it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 10:00:26 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 795834811f
commit c6701ac00d
27 changed files with 3006 additions and 175 deletions
+12 -1
View File
@@ -228,6 +228,9 @@
el.setAttribute('data-loaded', 'true');
if (typeof htmx !== 'undefined') htmx.process(el);
if (window.Alpine) window.Alpine.initTree(el);
// No htmx swap events here, so start converted pages
// (data-page roots) directly; see js/core/registry.js.
if (window.LEDMatrix && window.LEDMatrix.pages) window.LEDMatrix.pages.refresh();
if (id === 'plugins-content' && window.initPluginsPage) {
if (window.pluginManager) {
window.pluginManager.initialized = false;
@@ -948,7 +951,15 @@
or when a card is clicked, so it must follow all of them. No other
script defines the same globals. -->
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}" defer></script>
<!-- ES modules: window.LEDMatrix and the page lifecycle (js/core/), and
the converted tab pages (js/pages/), loaded on first use. Module
scripts are deferred like the scripts above and run after them; they
rely on none of them at load time, and a tab partial that arrived
first is started when the registry starts. Served as-is, no bundler:
see docs/WEB_FRONTEND_ARCHITECTURE.md. -->
<script type="module" src="{{ url_for('static', filename='v3/js/core/boot.js') }}"></script>
<!-- Custom feeds table helpers live in js/widgets/custom-feeds.js (the
deferred widget's window assignments always shadowed the inline
copies that used to sit here, so the duplicates were removed) -->
+5 -166
View File
@@ -1,4 +1,7 @@
<div class="bg-white rounded-lg shadow p-6">
{# No inline script: static/v3/js/pages/cache.js runs this page. The page
registry (static/v3/js/core/registry.js) starts it when this root appears
and stops it when the partial is swapped away. #}
<div class="bg-white rounded-lg shadow p-6" data-page="cache">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Cache Management</h2>
<p class="mt-1 text-sm text-gray-600">View and manage cached API responses. Cache files help reduce API calls and improve performance.</p>
@@ -11,7 +14,7 @@
<p class="text-sm font-medium text-blue-900">Cache Directory</p>
<p id="cache-dir" class="text-sm text-blue-700 font-mono mt-1">Loading...</p>
</div>
<button id="refresh-cache-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm">
<button type="button" id="refresh-cache-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm">
<i class="fas fa-sync-alt mr-2"></i>Refresh
</button>
</div>
@@ -54,167 +57,3 @@
</div>
</div>
<script>
// Scoped: this script runs at global scope after every HTMX swap, and the Logs
// partial has helpers with the same names (showError, escapeHtml). Only
// deleteCacheFile, which the row buttons call from onclick, is exported.
(function() {
loadCacheFiles();
document.getElementById('refresh-cache-btn').addEventListener('click', loadCacheFiles);
function loadCacheFiles() {
const tbody = document.getElementById('cache-files-tbody');
const emptyState = document.getElementById('cache-empty');
const errorState = document.getElementById('cache-error');
const errorMessage = document.getElementById('cache-error-message');
// Show loading state
tbody.innerHTML = `
<tr>
<td colspan="5" class="px-6 py-8 text-center text-gray-500">
<i class="fas fa-spinner fa-spin text-2xl mb-2"></i>
<p>Loading cache files...</p>
</td>
</tr>
`;
emptyState.classList.add('hidden');
errorState.classList.add('hidden');
fetch('/api/v3/cache/list')
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
displayCacheFiles(data.data);
updateCacheInfo(data.data.cache_dir);
} else {
showError(data.message || 'Failed to load cache files');
}
})
.catch(error => {
showError('Error loading cache files: ' + error.message);
});
}
function displayCacheFiles(data) {
const tbody = document.getElementById('cache-files-tbody');
const emptyState = document.getElementById('cache-empty');
const errorState = document.getElementById('cache-error');
errorState.classList.add('hidden');
if (!data.cache_files || data.cache_files.length === 0) {
tbody.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
tbody.innerHTML = '';
data.cache_files.forEach(cacheFile => {
const row = document.createElement('tr');
row.className = 'hover:bg-gray-50';
// Format modified time
const modifiedDate = new Date(cacheFile.modified_datetime);
const modifiedStr = modifiedDate.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
// Age color coding
let ageClass = 'text-gray-600';
if (cacheFile.age_seconds < 300) { // Less than 5 minutes
ageClass = 'text-green-600 font-medium';
} else if (cacheFile.age_seconds < 3600) { // Less than 1 hour
ageClass = 'text-yellow-600';
} else { // Older than 1 hour
ageClass = 'text-red-600';
}
row.innerHTML = `
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900 font-mono">${escapeHtml(cacheFile.key)}</div>
<div class="text-xs text-gray-500">${escapeHtml(cacheFile.filename)}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm ${ageClass}">${escapeHtml(cacheFile.age_display)}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-600">${escapeHtml(cacheFile.size_display)}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-600">${escapeHtml(modifiedStr)}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button onclick="deleteCacheFile(this.dataset.cacheKey)"
data-cache-key="${escapeHtml(cacheFile.key)}"
class="text-red-600 hover:text-red-900 px-3 py-1 rounded hover:bg-red-50 transition-colors"
title="Delete cache file">
<i class="fas fa-trash mr-1"></i>Delete
</button>
</td>
`;
tbody.appendChild(row);
});
}
function updateCacheInfo(cacheDir) {
const cacheDirEl = document.getElementById('cache-dir');
if (cacheDir) {
cacheDirEl.textContent = cacheDir;
} else {
cacheDirEl.textContent = 'Not configured';
cacheDirEl.classList.add('text-gray-500');
}
}
function deleteCacheFile(key) {
if (!confirm(`Are you sure you want to delete the cache file for "${key}"?`)) {
return;
}
fetch('/api/v3/cache/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ key: key })
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showNotification(data.message || 'Cache file deleted successfully', 'success');
// Reload cache files list
loadCacheFiles();
} else {
showNotification(data.message || 'Failed to delete cache file', 'error');
}
})
.catch(error => {
showNotification('Error deleting cache file: ' + error.message, 'error');
});
}
function showError(message) {
const tbody = document.getElementById('cache-files-tbody');
const errorState = document.getElementById('cache-error');
const errorMessage = document.getElementById('cache-error-message');
const emptyState = document.getElementById('cache-empty');
tbody.innerHTML = '';
emptyState.classList.add('hidden');
errorState.classList.remove('hidden');
errorMessage.textContent = message;
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
window.deleteCacheFile = deleteCacheFile;
})();
</script>