Files
LEDMatrix/web_interface/templates/v3/partials/tools.html
T
3b93024993 feat: activate dormant plugin health/metrics subsystem and surface it in the web UI (#388)
* feat(plugin-system): activate dormant plugin health & metrics subsystem

PluginManager shipped a fully-built health tracker, resource monitor and
circuit breaker that were never instantiated (health_tracker/resource_monitor
were left as None), so the circuit breaker never engaged and the existing
health/metrics API routes always returned "not available".

- DisplayController now wires a PluginHealthTracker and PluginResourceMonitor
  onto the plugin manager, enabling the circuit breaker (a repeatedly-failing
  plugin's update() is skipped after consecutive failures, then retried after
  a cooldown) and per-plugin execution-time metrics. Both persist to the
  shared cache.
- load_plugin() now validates each plugin's config against its JSON schema in
  a strictly warn/degrade-only way: a violation logs a warning and flags the
  plugin degraded in the health tracker, but never changes whether the plugin
  loads or its pass/fail behaviour. Adds PluginHealthTracker.set_degraded(),
  which never touches the circuit breaker.
- ResourceMonitor CPU/memory sampling now reuses a cached psutil.Process and
  reads cpu_percent(interval=None), so monitoring no longer blocks ~100ms per
  call on the display loop's update path.
- Fix DiskCache.get() raising TypeError for max_age=None ("never expires"),
  which silently discarded persisted plugin health/metrics on read and thus
  broke cross-process and post-restart surfacing.
- Fix two dead PluginManager helpers that called non-existent tracker methods.

Tests: new test_resource_monitor, test_plugin_health,
test_plugin_manager_schema_soft; extended test_cache_manager and
test_display_controller.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTav268UXv44ub9K11LYq

* feat(web-ui): surface plugin health, metrics and load state

With the health/metrics subsystem now active in the display service, expose it
in the web UI (which runs as a separate process from the display loop):

- Wire a health tracker / resource monitor backed by the shared on-disk cache
  into the web process so /api/v3/plugins/health and /plugins/metrics read the
  data the display service persists.
- Build those route responses per installed plugin id (the tracker's in-memory
  view is empty in a fresh web process) so cross-process data is included.
- Add state + error_info to /plugins/installed entries so the UI can show why a
  plugin isn't running instead of just loaded:false.
- Add a "Plugin Health" panel to the Tools page (circuit status, avg/max update
  time, update count, last error) plus PluginAPI.getPluginMetrics().

Tests: route-level tests for the health/metrics endpoints in test_web_api.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTav268UXv44ub9K11LYq

* fix(plugin-metrics): refresh cross-process health/metrics reads; type hints

Addresses CodeRabbit review on #388:

- Major: the web process's health/resource trackers cached the first persisted
  read in an in-memory dict (and the CacheManager memory tier held max_age=None
  entries indefinitely), so a long-lived web process showed the first snapshot
  and never reflected the display service's later updates. Add an opt-in
  force_reload path (get_health_summary/get_health_state/_load_health_state and
  get_metrics_summary/get_metrics) that bypasses the in-memory copy and, via a
  new memory_ttl passthrough on CacheManager.get, the cache manager's memory
  tier — so each /plugins/health and /plugins/metrics poll reads fresh persisted
  state. Default behaviour (force_reload=False) is unchanged for the display
  process and existing callers.
- Minor: DiskCache.get type hint is now Optional[int] with the None ("never
  expires") semantics documented, matching MemoryCache.get.

Tests: new force_reload staleness cases in test_plugin_health and
test_resource_monitor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTav268UXv44ub9K11LYq

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-09 07:54:18 -04:00

509 lines
27 KiB
HTML

<div class="space-y-6" id="tools-root">
<!-- Git & Updates -->
<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">Git &amp; Updates</h2>
<p class="mt-1 text-sm text-gray-600">Inspect the current git state and pull or reset to the latest remote code.</p>
</div>
<!-- Git status info -->
<div id="git-info-panel" class="mb-6 bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm">
<div class="animate-pulse text-gray-400">Loading git info…</div>
</div>
<div class="space-y-4">
<!-- Pull latest -->
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Pull latest (rebase)</p>
<p class="text-xs text-gray-500 mt-0.5">Stashes any local changes, then runs <code class="bg-gray-100 px-1 rounded">git pull --rebase</code>. The stash is preserved but not re-applied.</p>
</div>
<button id="btn-git-pull" onclick="toolsAction('git_pull', 'btn-git-pull', 'result-git-pull')"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2"></i>Pull Latest
</button>
</div>
<div id="result-git-pull" class="hidden"></div>
<!-- Force reset -->
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Force reset to <code class="bg-gray-100 px-1 rounded">origin/main</code></p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">git fetch origin</code> then <code class="bg-gray-100 px-1 rounded">git reset --hard origin/main</code>. Discards all local changes.</p>
</div>
<div class="shrink-0 flex flex-col items-end gap-2">
<button id="btn-force-reset-confirm" onclick="showForceResetConfirm()"
class="inline-flex items-center px-3 py-2 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50">
<i class="fas fa-exclamation-triangle mr-2"></i>Force Reset…
</button>
<div id="force-reset-confirm-row" class="hidden flex items-center gap-2">
<span class="text-xs text-red-700 font-medium">This discards all local changes. Sure?</span>
<button onclick="toolsAction('force_git_reset', 'btn-force-reset-confirm', 'result-force-reset'); hideForceResetConfirm()"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
Yes, reset
</button>
<button onclick="hideForceResetConfirm()"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
</div>
</div>
</div>
<div id="result-force-reset" class="hidden"></div>
</div>
</div>
<!-- Python Dependencies -->
<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">Python Dependencies</h2>
<p class="mt-1 text-sm text-gray-600">Re-run <code class="bg-gray-100 px-1 rounded">pip install</code> to fix missing or broken packages.</p>
</div>
<div class="space-y-4">
<!-- Base requirements -->
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Reinstall base requirements</p>
<p class="text-xs text-gray-500 mt-0.5">Installs from <code class="bg-gray-100 px-1 rounded">requirements.txt</code> in the project root.</p>
</div>
<button id="btn-base-reqs" onclick="toolsAction('install_base_requirements', 'btn-base-reqs', 'result-base-reqs', true)"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-box mr-2"></i>Reinstall Base
</button>
</div>
<div id="result-base-reqs" class="hidden"></div>
<!-- Plugin requirements -->
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Reinstall plugin requirements</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">pip install</code> for every installed plugin that has a <code class="bg-gray-100 px-1 rounded">requirements.txt</code>.</p>
</div>
<button id="btn-plugin-reqs" onclick="toolsAction('install_plugin_requirements', 'btn-plugin-reqs', 'result-plugin-reqs', false, true)"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-puzzle-piece mr-2"></i>Reinstall Plugin Deps
</button>
</div>
<div id="result-plugin-reqs" class="hidden"></div>
</div>
</div>
<!-- Maintenance -->
<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">Maintenance</h2>
<p class="mt-1 text-sm text-gray-600">Housekeeping operations that don't affect config or plugins.</p>
</div>
<div class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Clear Python cache</p>
<p class="text-xs text-gray-500 mt-0.5">Deletes all <code class="bg-gray-100 px-1 rounded">__pycache__</code> directories in the project. Useful after switching branches or debugging import issues.</p>
</div>
<button id="btn-clear-pycache" onclick="toolsAction('clear_pycache', 'btn-clear-pycache', 'result-clear-pycache')"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-broom mr-2"></i>Clear Cache
</button>
</div>
<div id="result-clear-pycache" class="hidden"></div>
</div>
</div>
<!-- Power Supply Diagnostics -->
<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">Power Supply</h2>
<p class="mt-1 text-sm text-gray-600">Raspberry Pi under-voltage/throttling status (via <code class="bg-gray-100 px-1 rounded">vcgencmd get_throttled</code>). A marginal power supply is a common cause of visible flicker or dimming on LED panels.</p>
</div>
<div id="power-info-panel" class="text-sm">
<div class="animate-pulse text-gray-400">Waiting for live stats…</div>
</div>
</div>
<!-- Services -->
<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">Services</h2>
<p class="mt-1 text-sm text-gray-600">Quick access to service restarts.</p>
</div>
<div class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Restart display service</p>
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix.service</code>.</p>
</div>
<button id="btn-restart-display" onclick="toolsAction('restart_display_service', 'btn-restart-display', 'result-restart-display')"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-sync-alt mr-2"></i>Restart Display
</button>
</div>
<div id="result-restart-display" class="hidden"></div>
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Restart web interface</p>
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix-web.service</code>. The page will go offline briefly.</p>
</div>
<button id="btn-restart-web" onclick="toolsAction('restart_web_service', 'btn-restart-web', 'result-restart-web')"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-globe mr-2"></i>Restart Web
</button>
</div>
<div id="result-restart-web" class="hidden"></div>
</div>
</div>
<!-- Plugin Health -->
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6 flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Plugin Health</h2>
<p class="mt-1 text-sm text-gray-600">Circuit-breaker status and per-plugin update timings recorded by the display service. A plugin whose <code class="bg-gray-100 px-1 rounded">update()</code> keeps failing is paused ("Circuit open") and retried automatically after a cooldown.</p>
</div>
<button id="btn-plugin-health-refresh" onclick="refreshPluginHealth(true)"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-sync-alt mr-2"></i>Refresh
</button>
</div>
<div id="plugin-health-message" class="hidden mb-4 text-sm text-gray-500"></div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plugin</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg update</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Max update</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Updates</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last error</th>
</tr>
</thead>
<tbody id="plugin-health-tbody" class="bg-white divide-y divide-gray-200">
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-500">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<script>
(function () {
// ── helpers ──────────────────────────────────────────────────────────────
function setBusy(btnId, busy) {
const btn = document.getElementById(btnId);
if (!btn) return;
btn.disabled = busy;
btn.style.opacity = busy ? '0.6' : '';
btn.style.cursor = busy ? 'wait' : '';
const icon = btn.querySelector('i');
if (icon) {
if (busy) {
icon.dataset.origClass = icon.className;
icon.className = 'fas fa-spinner fa-spin mr-2';
} else if (icon.dataset.origClass) {
icon.className = icon.dataset.origClass;
}
}
}
function showResult(resultId, ok, message, output, pluginDetails) {
const el = document.getElementById(resultId);
if (!el) return;
el.classList.remove('hidden');
const color = ok ? 'green' : 'red';
const icon = ok ? 'fa-check-circle' : 'fa-times-circle';
let html = `
<div class="mt-3 rounded-md p-3 bg-${color}-50 border border-${color}-200">
<div class="flex items-start gap-2">
<i class="fas ${icon} text-${color}-600 mt-0.5"></i>
<span class="text-sm text-${color}-800">${escHtml(message)}</span>
</div>`;
if (output) {
html += `
<details class="mt-2">
<summary class="text-xs text-${color}-700 cursor-pointer hover:underline">Show output</summary>
<pre class="mt-2 text-xs bg-gray-900 text-gray-100 rounded p-3 overflow-x-auto whitespace-pre-wrap">${escHtml(output)}</pre>
</details>`;
}
if (pluginDetails && pluginDetails.length > 0) {
html += `<ul class="mt-3 space-y-1">`;
for (const d of pluginDetails) {
const dc = d.ok ? 'green' : 'red';
const di = d.ok ? 'fa-check' : 'fa-times';
html += `<li class="text-xs flex items-start gap-1">
<i class="fas ${di} text-${dc}-600 mt-0.5 w-3"></i>
<span class="text-gray-700">${escHtml(d.plugin)}</span>`;
if (d.output) {
html += ` <details class="inline"><summary class="cursor-pointer text-gray-400 hover:underline ml-1">output</summary>
<pre class="mt-1 text-xs bg-gray-900 text-gray-100 rounded p-2 overflow-x-auto whitespace-pre-wrap">${escHtml(d.output)}</pre></details>`;
}
html += `</li>`;
}
html += `</ul>`;
}
html += `</div>`;
el.innerHTML = html;
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── main action dispatcher ────────────────────────────────────────────────
window.toolsAction = function(action, btnId, resultId, showOutput, showPluginDetails) {
setBusy(btnId, true);
const el = document.getElementById(resultId);
if (el) el.classList.add('hidden');
fetch('/api/v3/system/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action})
})
.then(r => {
if (!r.ok) {
return r.json()
.then(d => Promise.reject(new Error(d.message || `HTTP ${r.status}`)))
.catch(() => Promise.reject(new Error(`HTTP ${r.status}`)));
}
return r.json();
})
.then(data => {
const ok = data.status === 'success';
showResult(
resultId, ok,
data.message || (ok ? 'Done' : 'Failed'),
showOutput ? (data.output || '') : '',
showPluginDetails ? (data.details || []) : null
);
})
.catch(err => {
showResult(resultId, false, 'Request failed: ' + err.message);
})
.finally(() => setBusy(btnId, false));
};
// ── force-reset confirm helpers ───────────────────────────────────────────
window.showForceResetConfirm = function() {
document.getElementById('force-reset-confirm-row').classList.remove('hidden');
document.getElementById('btn-force-reset-confirm').classList.add('hidden');
};
window.hideForceResetConfirm = function() {
document.getElementById('force-reset-confirm-row').classList.add('hidden');
document.getElementById('btn-force-reset-confirm').classList.remove('hidden');
};
// ── git info panel ────────────────────────────────────────────────────────
function loadGitInfo() {
const panel = document.getElementById('git-info-panel');
if (!panel) return;
fetch('/api/v3/system/git-info')
.then(r => {
if (!r.ok) return r.json().then(d => Promise.reject(d.message || `HTTP ${r.status}`));
return r.json();
})
.then(d => {
if (d.status === 'error') {
panel.innerHTML = `<span class="text-sm text-red-600">${escHtml(d.message || 'Git info unavailable.')}</span>`;
return;
}
const dirtyBadge = d.dirty
? '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">dirty</span>'
: '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">clean</span>';
let html = `<div class="space-y-2">
<div class="flex items-center gap-2">
<i class="fas fa-code-branch text-gray-400"></i>
<span class="font-mono text-gray-800">${escHtml(d.branch || 'unknown')}</span>
${dirtyBadge}
</div>`;
if (d.dirty && d.status) {
html += `<pre class="text-xs bg-yellow-50 border border-yellow-200 rounded p-2 overflow-x-auto whitespace-pre-wrap text-yellow-900">${escHtml(d.status)}</pre>`;
}
if (d.recent_commits) {
html += `<div class="mt-2">
<p class="text-xs text-gray-500 mb-1">Recent commits</p>
<pre class="text-xs bg-gray-50 border border-gray-200 rounded p-2 overflow-x-auto whitespace-pre-wrap text-gray-700">${escHtml(d.recent_commits)}</pre>
</div>`;
}
if (d.remote_url) {
html += `<p class="text-xs text-gray-400 mt-1"><i class="fas fa-cloud mr-1"></i>${escHtml(d.remote_url)}</p>`;
}
html += `</div>`;
panel.innerHTML = html;
})
.catch(err => {
panel.innerHTML = `<span class="text-sm text-red-600">Could not load git info: ${escHtml(String(err))}</span>`;
});
}
// ── power supply diagnostics panel ────────────────────────────────────────
// Reuses the same SSE stream (window.statsSource, set up in base.html)
// that already drives the header badge/banner and Overview card, instead
// of polling a separate endpoint.
const FLAG_LABELS = [
['under_voltage_now', 'Under-voltage (right now)'],
['throttled_now', 'Throttled (right now)'],
['freq_capped_now', 'ARM frequency capped (right now)'],
['soft_temp_limit_now', 'Soft temperature limit active (right now)'],
['under_voltage_occurred', 'Under-voltage occurred (since boot)'],
['throttled_occurred', 'Throttled occurred (since boot)'],
['freq_capped_occurred', 'ARM frequency capped occurred (since boot)'],
['soft_temp_limit_occurred', 'Soft temperature limit occurred (since boot)'],
];
function renderPowerInfo(power) {
const panel = document.getElementById('power-info-panel');
if (!panel) return;
if (!power) {
panel.innerHTML = '<span class="text-gray-500">Not available on this platform (no <code class="bg-gray-100 px-1 rounded">vcgencmd</code> found — this isn\'t a Raspberry Pi, or it\'s not on PATH).</span>';
return;
}
const activeNow = power.under_voltage_now || power.throttled_now ||
power.freq_capped_now || power.soft_temp_limit_now;
const occurredEarlier = power.under_voltage_occurred || power.throttled_occurred ||
power.freq_capped_occurred || power.soft_temp_limit_occurred;
const summaryColor = activeNow ? 'red' : (occurredEarlier ? 'yellow' : 'green');
// _activePowerConditionLabels is defined in base.html's inline script;
// both are classic (non-module) scripts sharing the global scope.
const activeLabels = (typeof _activePowerConditionLabels === 'function')
? _activePowerConditionLabels(power) : [];
const summaryText = activeNow
? `Actively ${activeLabels.length ? activeLabels.join('/') : 'under-voltage/throttled'} right now`
: (occurredEarlier ? 'OK right now (but occurred earlier this boot)' : 'OK — no issues detected');
let html = `
<div class="flex items-center gap-2 mb-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-${summaryColor}-100 text-${summaryColor}-800">
<i class="fas fa-bolt mr-1"></i>${escHtml(summaryText)}
</span>
</div>
<ul class="space-y-1">`;
for (const [key, label] of FLAG_LABELS) {
const set = !!power[key];
html += `<li class="flex items-center gap-2">
<i class="fas ${set ? 'fa-exclamation-circle text-red-500' : 'fa-check text-green-500'} w-4"></i>
<span class="text-gray-700">${escHtml(label)}</span>
</li>`;
}
html += `</ul>`;
if (activeNow || occurredEarlier) {
html += `<p class="mt-3 text-xs text-gray-500">Consider a higher-amperage 5V supply wired directly to the panel(s), or check for a loose power connector. See the README's Power Supply section for sizing guidance.</p>`;
}
panel.innerHTML = html;
}
if (window.statsSource) {
window.statsSource.addEventListener('message', function(event) {
try {
const data = JSON.parse(event.data);
if (data && 'power' in data) renderPowerInfo(data.power);
} catch (e) { /* ignore malformed frames */ }
});
}
// ── plugin health panel ──────────────────────────────────────────────────
function phEscape(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function phFmtSecs(v) {
if (typeof v !== 'number' || !isFinite(v)) return '—';
return v.toFixed(3) + 's';
}
function phStatus(h) {
if (!h) return { label: 'Unknown', cls: 'warning' };
if (h.circuit_state === 'open') return { label: 'Circuit open', cls: 'error' };
if (h.circuit_state === 'half_open') return { label: 'Recovering', cls: 'warning' };
if (h.degraded) return { label: 'Degraded', cls: 'warning' };
if (h.is_healthy) return { label: 'Healthy', cls: 'success' };
return { label: 'Unknown', cls: 'warning' };
}
async function refreshPluginHealth(force) {
const tbody = document.getElementById('plugin-health-tbody');
const msg = document.getElementById('plugin-health-message');
if (!tbody || !window.PluginAPI) return;
try {
if (force && PluginAPI.clearCache) PluginAPI.clearCache();
const results = await Promise.all([
PluginAPI.getPluginHealth(),
PluginAPI.getPluginMetrics()
]);
const health = results[0] || {};
const metrics = results[1] || {};
const ids = Array.from(new Set(Object.keys(health).concat(Object.keys(metrics)))).sort();
if (!ids.length) {
if (msg) {
msg.textContent = 'No plugin health data yet — it appears once the display service has run plugins.';
msg.classList.remove('hidden');
}
tbody.innerHTML = '<tr><td colspan="6" class="px-4 py-8 text-center text-gray-500">No data</td></tr>';
return;
}
if (msg) msg.classList.add('hidden');
let rows = '';
ids.forEach(function (id) {
const h = health[id] || {};
const m = metrics[id] || {};
const st = phStatus(h);
const lastErr = h.degraded_reason || h.last_error || '';
const calls = (typeof m.call_count === 'number') ? m.call_count : '—';
const errCell = lastErr
? '<span title="' + phEscape(lastErr) + '">' + phEscape(lastErr) + '</span>'
: '<span class="text-gray-400">—</span>';
rows += '<tr>' +
'<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">' + phEscape(id) + '</td>' +
'<td class="px-4 py-3 whitespace-nowrap"><span class="status-indicator ' + st.cls + '">' + st.label + '</span></td>' +
'<td class="px-4 py-3 whitespace-nowrap text-right text-sm text-gray-600">' + phFmtSecs(m.avg_execution_time) + '</td>' +
'<td class="px-4 py-3 whitespace-nowrap text-right text-sm text-gray-600">' + phFmtSecs(m.max_execution_time) + '</td>' +
'<td class="px-4 py-3 whitespace-nowrap text-right text-sm text-gray-600">' + calls + '</td>' +
'<td class="px-4 py-3 text-sm text-red-600 max-w-xs truncate">' + errCell + '</td>' +
'</tr>';
});
tbody.innerHTML = rows;
} catch (e) {
const emsg = (e && e.message) ? e.message : String(e);
tbody.innerHTML = '<tr><td colspan="6" class="px-4 py-6 text-center text-red-500">Failed to load plugin health: ' + phEscape(emsg) + '</td></tr>';
}
}
window.refreshPluginHealth = refreshPluginHealth;
// Load on first render; HTMX will have already swapped us in by this point.
loadGitInfo();
refreshPluginHealth(false);
// Refresh periodically. Guard against duplicate timers if this partial is
// re-swapped in by HTMX; the handler re-resolves DOM nodes by id each tick.
if (!window._pluginHealthTimer) {
window._pluginHealthTimer = setInterval(function () { refreshPluginHealth(true); }, 15000);
}
})();
</script>