Files
LEDMatrix/web_interface/templates/v3/partials/operation_history.html
T
ChuckandClaude Opus 5 d1e821c625 fix(web): harden, polish and optimize the web UI per the Sept 2026 audit (#568)
* fix(web): harden, polish and optimize the web UI per the September 2026 audit

Works through docs/archive/WEB_UI_AUDIT_2026-09.md (health 8/20).

Implementation integrity (P0)
- app.css now defines every utility class the templates and JS use,
  including .hidden, so the ~145 JS show/hide toggles work. Button reset,
  and base component rules (.btn, .form-control) wrapped in :where() so
  utility classes on the same element win. New static-audit test fails
  when a used utility class has no rule.

Accessibility
- Focus rings render (the old ring rule referenced undefined variables);
  one :focus-visible outline everywhere; skip link; labelled nav landmarks.
- Shared dialog helper (js/utils/dialog.js): role/aria-modal, focus trap,
  Escape, focus return, applied to every modal.
- Named icon-only buttons and labelled ~70 form fields.
- Toasts announced once; errors persist >= 10s; one showNotification.
- Captive WiFi page: live region, timeouts, dark mode, 16px inputs.

Performance (Pi Zero 2 W)
- SSE streams and tab timers pause when hidden or off-tab; the display
  stream only runs while a preview is visible. app-shell.js deferred.
- Widget scripts served as one versioned bundle (/assets/widgets.js):
  52 -> 21 script tags, 66 -> 35 requests on first load.
- Stdlib gzip fallback when flask-compress is missing: first-load JS/CSS
  1358 KB -> 291 KB on the wire. SSE untouched.

Theming and responsive
- File managers, form fields and Fonts upload on theme tokens; bare
  inputs themed in dark mode; no more white surfaces.
- No horizontal overflow at 375px on any tab; 44px touch targets on
  coarse pointers; reduced-motion respected; header title truncates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): clear Codacy findings on #568

- json-file-manager: focus-trap releases kept in a Map (no dynamic
  property access or delete; no value-returning forEach callback)
- notification / schedule-picker: style and day-label lookups via Map
- app.js: move the pending-queue assignment out of the expression
- diff_viewer / error_handler: named function declarations instead of
  arrow consts

No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: check the OAuth widget ships in the widget bundle

base.html no longer tags widget scripts one by one; they load through
/assets/widgets.js. Assert the page requests the bundle and the bundle
contains google-oauth.js, which is what the test was protecting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): address review feedback on #568

- widget bundle version fingerprints every file (name, mtime_ns, size)
- gzip fallback appends Accept-Encoding to an existing Vary header
- dialog helper: releasing a non-top dialog no longer moves focus out of
  the dialog the user is in
- labels: file-upload targets its file input; fallback config fields get
  label for/id pairs; native color input has a fallback name
- utility audit also reads class names inside bound :class expressions

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): give the native color-picker input an accessible name

CodeRabbit flagged this on PR #568 as an outside-diff finding (never
posted inline, so it was missed in the round of fixes that addressed
the other 6 review comments). The <input type="color"> only carried a
title attribute; screen readers don't reliably announce title, and
there's no other label naming the control when showHexInput is false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): clear Codacy findings in app-shell.js

- drop the unused catch binding on the SSE JSON parse
- move the pending-notification queue assignment out of the expression

No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): contain plugin widgets/ dir and bound style-editor retries

From CodeRabbit review on #568 (code that arrived with the main merge):
- serve_plugin_widget resolves widgets/ with resolve_under before
  resolving the manifest script under it, so a symlinked widgets
  directory can't become the containment base (CWE-22). New test.
- style-editor init stops polling after ~10s when the widget never
  registers and leaves the plain fallback fields in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 09:42:24 -04:00

396 lines
16 KiB
HTML

<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Operation History</h2>
<p class="mt-1 text-sm text-gray-600">View history of plugin operations and configuration changes for debugging and auditing.</p>
</div>
<!-- Controls -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center space-x-4">
<!-- Filter by Plugin -->
<select id="history-plugin-filter" class="form-control text-sm" aria-label="Filter by plugin">
<option value="">All Plugins</option>
</select>
<!-- Filter by Operation Type -->
<select id="history-type-filter" class="form-control text-sm" aria-label="Filter by operation type">
<option value="">All Operations</option>
<option value="install">Install</option>
<option value="update">Update</option>
<option value="uninstall">Uninstall</option>
<option value="enable">Enable</option>
<option value="disable">Disable</option>
<option value="configure">Configure</option>
</select>
<!-- Filter by Status -->
<select id="history-status-filter" class="form-control text-sm" aria-label="Filter by status">
<option value="">All Statuses</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
</select>
<!-- Search -->
<div class="relative">
<input type="text" id="history-search" placeholder="Search operations..." aria-label="Search operations" class="form-control text-sm pl-8 pr-4 py-1 w-48">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs" aria-hidden="true"></i>
</div>
</div>
<div class="flex items-center space-x-2">
<!-- Refresh -->
<button id="refresh-history-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-sm">
<i class="fas fa-sync-alt mr-1"></i>Refresh
</button>
<!-- Clear History -->
<button id="clear-history-btn" class="btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-sm">
<i class="fas fa-trash mr-1"></i>Clear
</button>
</div>
</div>
<!-- History Table -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Timestamp</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Operation</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plugin</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Details</th>
</tr>
</thead>
<tbody id="history-table-body" class="bg-white divide-y divide-gray-200">
<tr>
<td colspan="6" class="px-6 py-4 text-center text-gray-500">
<div class="animate-pulse">
<div class="h-4 bg-gray-200 rounded w-1/4 mx-auto"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="mt-4 flex items-center justify-between">
<div class="text-sm text-gray-700">
Showing <span id="history-start">0</span> to <span id="history-end">0</span> of <span id="history-total">0</span> operations
</div>
<div class="flex space-x-2">
<button id="history-prev-btn" class="btn bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm disabled:opacity-50" disabled>
<i class="fas fa-chevron-left mr-1"></i>Previous
</button>
<button id="history-next-btn" class="btn bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm disabled:opacity-50" disabled>
Next<i class="fas fa-chevron-right ml-1"></i>
</button>
</div>
</div>
</div>
<script>
(function() {
let currentPage = 1;
const pageSize = 50;
let allHistory = [];
let filteredHistory = [];
// Normalize status values so both server-side conventions match filter values.
// The server may return "completed" or "success" for successful operations,
// and "error" or "failed" for failures.
function normalizeStatus(status) {
const s = (status || '').toLowerCase();
if (s === 'completed' || s === 'success') return 'success';
if (s === 'error' || s === 'failed') return 'failed';
return s;
}
// Load operation history
async function loadHistory() {
try {
const response = await fetch('/api/v3/plugins/operation/history?limit=1000');
const data = await response.json();
if (data.status === 'success') {
allHistory = data.data || [];
applyFilters();
} else {
console.error('Failed to load history:', data.message);
showError('Failed to load operation history');
}
} catch (error) {
console.error('Error loading history:', error);
showError('Error loading operation history');
}
}
// Apply filters
function applyFilters() {
const pluginFilter = document.getElementById('history-plugin-filter')?.value || '';
const typeFilter = document.getElementById('history-type-filter')?.value || '';
const statusFilter = document.getElementById('history-status-filter')?.value || '';
const searchTerm = (document.getElementById('history-search')?.value || '').toLowerCase();
filteredHistory = allHistory.filter(record => {
if (pluginFilter && record.plugin_id !== pluginFilter) return false;
if (typeFilter && record.operation_type !== typeFilter) return false;
if (statusFilter && normalizeStatus(record.status) !== statusFilter) return false;
if (searchTerm) {
const searchable = [
record.operation_type,
record.plugin_id,
record.status,
record.user,
record.error,
JSON.stringify(record.details || {})
].join(' ').toLowerCase();
if (!searchable.includes(searchTerm)) return false;
}
return true;
});
renderHistory();
}
// Render history table
function renderHistory() {
const tbody = document.getElementById('history-table-body');
if (!tbody) return;
const start = (currentPage - 1) * pageSize;
const end = Math.min(start + pageSize, filteredHistory.length);
const pageData = filteredHistory.slice(start, end);
if (pageData.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="px-6 py-4 text-center text-gray-500">
No operations found
</td>
</tr>
`;
return;
}
tbody.innerHTML = pageData.map(record => {
const timestamp = new Date(record.timestamp || record.created_at || Date.now());
const timeStr = timestamp.toLocaleString();
const normalized = normalizeStatus(record.status);
const statusClass = getStatusClass(normalized);
const operationType = record.operation_type || 'unknown';
const pluginId = record.plugin_id || '-';
const user = record.user || '-';
const error = record.error ? `<div class="text-red-600 text-xs mt-1">${escapeHtml(record.error)}</div>` : '';
// Build a concise details summary
let detailsSummary = '';
const d = record.details;
if (d) {
const parts = [];
if (d.version) parts.push(`v${d.version}`);
if (d.branch) parts.push(`branch: ${d.branch}`);
if (d.commit) parts.push(`commit: ${String(d.commit).substring(0, 7)}`);
if (d.previous_commit && d.commit && d.previous_commit !== d.commit) {
parts.push(`from: ${String(d.previous_commit).substring(0, 7)}`);
}
if (d.preserve_config !== undefined) parts.push(d.preserve_config ? 'config preserved' : 'config removed');
detailsSummary = parts.length > 0 ? `<span class="text-xs text-gray-500">${escapeHtml(parts.join(' | '))}</span>` : '';
}
return `
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">${escapeHtml(timeStr)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 py-1 text-xs font-medium rounded ${getOperationTypeClass(operationType)}">
${escapeHtml(operationType)}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">${escapeHtml(pluginId)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 py-1 text-xs font-medium rounded ${statusClass}">
${escapeHtml(normalized)}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${escapeHtml(user)}</td>
<td class="px-6 py-4 text-sm text-gray-500">
${detailsSummary || '-'}
${error}
</td>
</tr>
`;
}).join('');
// Update pagination
document.getElementById('history-start').textContent = filteredHistory.length > 0 ? start + 1 : 0;
document.getElementById('history-end').textContent = end;
document.getElementById('history-total').textContent = filteredHistory.length;
// Update pagination buttons
const prevBtn = document.getElementById('history-prev-btn');
const nextBtn = document.getElementById('history-next-btn');
if (prevBtn) prevBtn.disabled = currentPage === 1;
if (nextBtn) nextBtn.disabled = end >= filteredHistory.length;
}
// Helper functions
function getStatusClass(status) {
if (status === 'success') return 'bg-green-100 text-green-800';
if (status === 'failed') return 'bg-red-100 text-red-800';
return 'bg-gray-100 text-gray-800';
}
function getOperationTypeClass(type) {
const typeLower = (type || '').toLowerCase();
if (typeLower === 'install') {
return 'bg-blue-100 text-blue-800';
} else if (typeLower === 'update') {
return 'bg-purple-100 text-purple-800';
} else if (typeLower === 'uninstall') {
return 'bg-red-100 text-red-800';
} else if (typeLower === 'enable') {
return 'bg-green-100 text-green-800';
} else if (typeLower === 'disable') {
return 'bg-yellow-100 text-yellow-800';
} else if (typeLower === 'configure') {
return 'bg-teal-100 text-teal-800';
}
return 'bg-gray-100 text-gray-800';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showError(message) {
if (typeof showNotification === 'function') {
showNotification(message, 'error');
} else {
alert(message);
}
}
// Populate plugin filter
async function populatePluginFilter() {
try {
// Use PluginAPI if available, otherwise fall back to direct fetch
let data;
if (window.PluginAPI && window.PluginAPI.getInstalledPlugins) {
const plugins = await window.PluginAPI.getInstalledPlugins();
data = { status: 'success', data: { plugins: plugins } };
} else {
const response = await fetch('/api/v3/plugins/installed');
data = await response.json();
}
if (data.status === 'success' && data.data) {
const select = document.getElementById('history-plugin-filter');
if (select) {
const plugins = Object.keys(data.data);
plugins.forEach(pluginId => {
const option = document.createElement('option');
option.value = pluginId;
option.textContent = pluginId;
select.appendChild(option);
});
}
}
} catch (error) {
console.error('Error loading plugins for filter:', error);
}
}
// Event listeners
function setupEventListeners() {
const refreshBtn = document.getElementById('refresh-history-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
loadHistory();
});
}
const clearBtn = document.getElementById('clear-history-btn');
if (clearBtn) {
clearBtn.addEventListener('click', async () => {
if (confirm('Are you sure you want to clear the operation history? This cannot be undone.')) {
try {
const response = await fetch('/api/v3/plugins/operation/history', { method: 'DELETE' });
const data = await response.json();
if (data.status === 'success') {
allHistory = [];
applyFilters();
} else {
showError(data.message || 'Failed to clear history');
}
} catch (error) {
showError('Error clearing history');
}
}
});
}
const prevBtn = document.getElementById('history-prev-btn');
if (prevBtn) {
prevBtn.addEventListener('click', () => {
if (currentPage > 1) {
currentPage--;
renderHistory();
}
});
}
const nextBtn = document.getElementById('history-next-btn');
if (nextBtn) {
nextBtn.addEventListener('click', () => {
const start = (currentPage - 1) * pageSize;
const end = Math.min(start + pageSize, filteredHistory.length);
if (end < filteredHistory.length) {
currentPage++;
renderHistory();
}
});
}
// Filter change listeners
['history-plugin-filter', 'history-type-filter', 'history-status-filter'].forEach(id => {
const element = document.getElementById(id);
if (element) {
element.addEventListener('change', () => {
currentPage = 1;
applyFilters();
});
}
});
const searchInput = document.getElementById('history-search');
if (searchInput) {
let searchTimeout;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentPage = 1;
applyFilters();
}, 300);
});
}
}
// Initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setupEventListeners();
populatePluginFilter();
loadHistory();
});
} else {
setupEventListeners();
populatePluginFilter();
loadHistory();
}
})();
</script>