Merge claude/1

This commit is contained in:
librelad 2026-07-17 22:03:02 +01:00
commit 57f800ca1f
15 changed files with 973 additions and 1 deletions

View File

@ -10,6 +10,10 @@ services:
build:
context: .
image: libreportal-service:latest
# The control plane self-recovers after a host reboot or a rootless-daemon
# recycle (the health-heal path). "no" would leave the WebUI down until
# someone started it by hand — exactly the outage this policy prevents.
restart: unless-stopped
user: "USER_DATA" #LIBREPORTAL|USER_TAG|USER_DATA
group_add:
- SOCKET_GID_DATA #LIBREPORTAL|SOCKET_GID_TAG|SOCKET_GID_DATA

View File

@ -70,6 +70,7 @@ class SystemLoader {
'/core/topbar/js/topbar.js',
'/core/update-notifier/js/update-notifier.js',
'/core/network-notifier/js/network-notifier.js',
'/core/health-notifier/js/health-notifier.js',
'/core/topbar/js/mobile-menu.js'
]
});

View File

@ -0,0 +1,247 @@
/* Health Notifier topbar badge, dashboard banner, and details panel.
Driven by js/health-notifier.js and /data/system/health_status.json. Uses a
warm amber-orange identity hue (--page-health) so a "control plane needs
attention" signal reads distinctly from the amber update pill and the rose
network badge. Falls back gracefully if the token is absent. */
/* ---- Topbar badge -------------------------------------------------------- */
.health-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border: 1px solid var(--page-health, #d9822b);
border-radius: 6px;
background: rgba(var(--page-health-rgb, 217, 130, 43), 0.14);
color: var(--page-health, #d9822b);
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
white-space: nowrap;
}
.health-badge:hover { background: rgba(var(--page-health-rgb, 217, 130, 43), 0.24); }
.health-badge:active { transform: scale(0.97); }
.health-badge-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--page-health, #d9822b);
box-shadow: 0 0 0 0 rgba(var(--page-health-rgb, 217, 130, 43), 0.6);
animation: health-badge-pulse 2s infinite;
}
@keyframes health-badge-pulse {
0% { box-shadow: 0 0 0 0 rgba(var(--page-health-rgb, 217, 130, 43), 0.6); }
70% { box-shadow: 0 0 0 7px rgba(var(--page-health-rgb, 217, 130, 43), 0); }
100% { box-shadow: 0 0 0 0 rgba(var(--page-health-rgb, 217, 130, 43), 0); }
}
@media (prefers-reduced-motion: reduce) {
.health-badge-dot { animation: none; }
}
/* ---- Dashboard banner ---------------------------------------------------- */
.health-banner {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding: 16px 20px;
border: 1px solid var(--page-health, #d9822b);
border-left-width: 4px;
border-radius: 10px;
background: rgba(var(--page-health-rgb, 217, 130, 43), 0.1);
color: var(--text-primary, #fff);
}
.health-banner-icon {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: var(--page-health, #d9822b);
}
.health-banner-text { flex: 1 1 auto; min-width: 0; }
.health-banner-title { font-weight: 700; font-size: 1rem; }
.health-banner-sub {
margin-top: 2px;
font-size: 0.85rem;
color: var(--text-muted, #9aa);
}
.health-banner-actions {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
/* ---- Shared action buttons ----------------------------------------------- */
.health-btn-primary,
.health-btn-secondary {
padding: 8px 16px;
border-radius: 6px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
border: 1px solid transparent;
white-space: nowrap;
}
.health-btn-primary {
background: var(--page-health, #d9822b);
color: var(--text-on-accent, #fff);
}
.health-btn-primary:hover { filter: brightness(1.08); }
.health-btn-secondary {
background: transparent;
border-color: var(--border-color, rgba(255, 255, 255, 0.2));
color: var(--text-primary, #fff);
}
.health-btn-secondary:hover { background: var(--surface-hover, rgba(255, 255, 255, 0.08)); }
/* ---- Details panel (modal) ----------------------------------------------- */
.health-panel-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
}
.health-panel {
width: 100%;
max-width: 460px;
border: 1px solid var(--card-border, var(--border-color, rgba(255, 255, 255, 0.15)));
border-radius: 12px;
background: var(--card-bg, var(--surface-bg-solid, #1b1f2a));
box-shadow: var(--card-shadow, 0 20px 60px rgba(0, 0, 0, 0.45));
color: var(--text-primary, #fff);
overflow: hidden;
}
.health-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
}
.health-panel-header h3 { margin: 0; font-size: 1.05rem; }
.health-panel-close {
border: none;
background: transparent;
color: var(--text-muted, #9aa);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.health-panel-close:hover { color: var(--text-primary, #fff); }
.health-panel-status {
padding: 14px 20px 0;
font-size: 0.9rem;
color: var(--text-secondary, var(--text-muted, #9aa));
}
.health-panel-status.is-issue {
color: var(--page-health, #d9822b);
font-weight: 600;
}
.health-panel-error {
margin: 12px 20px 0;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.82rem;
background: rgba(var(--status-danger-rgb, 220, 53, 69), 0.12);
color: var(--status-danger, #dc3545);
}
.health-panel-rows {
margin: 14px 0 0;
padding: 0 20px;
}
.health-panel-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06));
font-size: 0.88rem;
}
.health-panel-row:last-child { border-bottom: none; }
.health-panel-row dt { color: var(--text-muted, #9aa); margin: 0; }
.health-panel-row dd {
margin: 0;
font-weight: 600;
text-align: right;
word-break: break-word;
}
.health-panel-row dd.is-bad { color: var(--status-danger, #dc3545); }
.health-panel-row dd.is-good { color: var(--status-success, #38a169); }
/* affected-containers list */
.health-panel-apps {
margin: 14px 20px 0;
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
border-radius: 8px;
overflow: hidden;
}
.health-panel-app {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
font-size: 0.84rem;
border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06));
}
.health-panel-app:last-child { border-bottom: none; }
.health-panel-app-name { font-weight: 600; }
.health-panel-app-meta { color: var(--text-muted, #9aa); font-family: var(--font-mono, monospace); }
.health-panel-note {
margin: 14px 20px 0;
font-size: 0.8rem;
color: var(--text-muted, #9aa);
line-height: 1.45;
}
.health-panel-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 18px 20px 20px;
}
@media (max-width: 600px) {
.health-banner { flex-wrap: wrap; }
.health-banner-actions { width: 100%; }
.health-banner-actions button { flex: 1 1 auto; }
}

View File

@ -0,0 +1,336 @@
// Health Notifier
// -----------------------------------------------------------------------------
// Surfaces control-plane health across the WebUI — the rootless docker layer
// going bad in a way that threatens the WebUI itself: its published host port
// torn down (container healthy inside, unreachable outside), or a container
// crash-looping and churning the shared network. Two surfaces, shown ONLY when
// there's a real issue to act on:
// * a badge in the global topbar (after the update pill / network badge), and
// * a banner on the dashboard.
// Both are driven by /data/system/health_status.json, written host-side by
// webuiSystemHealthCheck (scripts/webui/data/generators/system/).
//
// The backend already SELF-HEALS most of this (the task-processor poll dispatches
// `system health heal` when the WebUI's own port is down — you can't click a
// button on a dead WebUI). These surfaces are for visibility and for a manual
// "Repair now" when the WebUI is still reachable (e.g. a crash-loop caught early).
//
// Actions go through the normal task pipeline so progress streams on Tasks:
// * "Repair now" -> task `libreportal system health heal`
// * "Re-check" -> task `libreportal system health check`
//
// This file owns no detection logic — it only reads the status file.
class HealthNotifier {
constructor() {
this.status = null;
this.fetching = null;
this.pollMs = 60 * 1000; // re-read the status file every minute
this.pollTimer = null;
this.started = false;
}
// ---- data ----------------------------------------------------------------
async fetchStatus() {
if (this.fetching) return this.fetching;
this.fetching = (async () => {
try {
const s = await fetch('/data/system/health_status.json', { cache: 'no-store' })
.then(r => r.ok ? r.json() : null).catch(() => null);
if (s !== null) this.status = s; // keep last-good on a failed fetch
return this.status;
} catch {
return null;
} finally {
this.fetching = null;
}
})();
return this.fetching;
}
async refresh() {
await this.fetchStatus();
this.renderTopbarBadge();
this.renderDashboardBanner();
}
// ---- lifecycle -----------------------------------------------------------
start() {
if (this.started) return;
this.started = true;
this.refresh();
// Topbar HTML and this script load independently; retry until
// .topbar-controls exists so the badge appears regardless of the race.
let tries = 0;
const ensure = setInterval(() => {
if (document.querySelector('.topbar-controls')) { this.renderTopbarBadge(); clearInterval(ensure); }
else if (++tries > 30) clearInterval(ensure); // ~15s ceiling
}, 500);
if (this.pollTimer) clearInterval(this.pollTimer);
this.pollTimer = setInterval(() => this.refresh(), this.pollMs);
// Re-read the status as soon as a health heal/check task finishes so the
// badge clears without waiting for the next poll.
window.taskRefresh?.register({
id: 'health-badge',
match: (d) => d.action === 'system_health_heal'
|| /^libreportal system health\b/.test((d.task && d.task.command) || d.command || ''),
run: () => this.refresh(),
debounceMs: 1500,
});
}
// Called by TopbarComponent.init() once the topbar DOM exists.
onTopbarReady() {
this.renderTopbarBadge();
this.refresh();
}
_hasIssues() { return !!(this.status && this.status.issues_found === true); }
// ---- topbar badge --------------------------------------------------------
renderTopbarBadge() {
const controls = document.querySelector('.topbar-controls');
if (!controls) return;
let badge = document.getElementById('health-badge');
if (!this._hasIssues()) {
if (badge) badge.remove();
return;
}
if (!badge) {
badge = document.createElement('button');
badge.id = 'health-badge';
badge.className = 'health-badge';
badge.type = 'button';
badge.addEventListener('click', () => this.openPanel());
// Sit after the network badge (or the update pill) so the pills keep a
// stable order — update, network, health — rather than racing firstChild.
const anchor = document.getElementById('network-badge') || document.getElementById('update-pill');
if (anchor && anchor.parentNode === controls) controls.insertBefore(badge, anchor.nextSibling);
else controls.insertBefore(badge, controls.firstChild);
}
const n = Array.isArray(this.status.crash_loops) ? this.status.crash_loops.length : 0;
badge.title = 'Control plane needs attention';
badge.setAttribute('aria-label', badge.title);
badge.innerHTML = `
<span class="health-badge-dot" aria-hidden="true"></span>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
</svg>
<span class="health-badge-text">System</span>`;
}
// ---- dashboard banner ----------------------------------------------------
renderDashboardBanner() {
const main = document.querySelector('.dashboard-main');
if (!main) return; // not on the dashboard
let banner = document.getElementById('health-banner');
// Attention-only: render nothing unless there's a real issue.
if (!this._hasIssues()) {
if (banner) banner.remove();
return;
}
if (!banner) {
banner = document.createElement('div');
banner.id = 'health-banner';
// Attention-only, so it leads the dashboard when an issue is present. If
// the network banner is also present, sit just after it.
const netBanner = document.getElementById('network-banner');
if (netBanner && netBanner.parentNode === main) main.insertBefore(banner, netBanner.nextSibling);
else main.insertBefore(banner, main.firstChild);
}
const s = this.status;
const sub = s.summary ? this._escape(s.summary) : 'The docker control plane needs attention';
const heartIcon = `
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
</svg>`;
banner.className = 'health-banner';
banner.innerHTML = `
<div class="health-banner-icon" aria-hidden="true">${heartIcon}</div>
<div class="health-banner-text">
<div class="health-banner-title">Control plane attention needed</div>
<div class="health-banner-sub">${sub}</div>
</div>
<div class="health-banner-actions">
<button type="button" class="health-btn-secondary" id="health-banner-details">Details</button>
${s.can_auto_heal ? '<button type="button" class="health-btn-primary" id="health-banner-heal">Repair now</button>' : ''}
</div>`;
const details = banner.querySelector('#health-banner-details');
if (details) details.addEventListener('click', () => this.openPanel());
const healBtn = banner.querySelector('#health-banner-heal');
if (healBtn) healBtn.addEventListener('click', () => this.runHeal());
}
// ---- details panel (self-contained modal) --------------------------------
openPanel() {
this.closePanel();
const s = this.status || {};
const webui = s.webui || {};
const loops = Array.isArray(s.crash_loops) ? s.crash_loops : [];
const overlay = document.createElement('div');
overlay.id = 'health-panel-overlay';
overlay.className = 'health-panel-overlay';
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.closePanel(); });
const reachTxt = webui.reachable === true ? 'reachable'
: webui.reachable === false ? 'unreachable'
: 'unknown';
const reachCls = webui.reachable === true ? 'is-good'
: webui.reachable === false ? 'is-bad' : '';
const rows = [
['Docker daemon', s.daemon_ok ? 'up' : 'unreachable', s.daemon_ok ? 'is-good' : 'is-bad'],
['WebUI container', webui.running ? 'running' : (webui.present ? 'not running' : 'missing'),
webui.running ? 'is-good' : 'is-bad'],
['WebUI host port', webui.port ? `${webui.port} (${reachTxt})` : reachTxt, reachCls],
['Crash-loops', String(loops.length), loops.length ? 'is-bad' : ''],
['Last checked', this._formatTime(s.checked_at), ''],
];
const loopList = loops.length
? `<div class="health-panel-apps">
${loops.map(l => `
<div class="health-panel-app">
<span class="health-panel-app-name">${this._escape(l.container || l.app || '')}</span>
<span class="health-panel-app-meta">restarts: ${this._escape(String(l.restart_count ?? '?'))}</span>
</div>`).join('')}
</div>`
: '';
const statusLine = this._hasIssues()
? (s.summary || 'The docker control plane needs attention.')
: 'Control plane healthy.';
overlay.innerHTML = `
<div class="health-panel" role="dialog" aria-modal="true" aria-label="Control plane health">
<div class="health-panel-header">
<h3>Control Plane Health</h3>
<button type="button" class="health-panel-close" id="health-panel-close" aria-label="Close">&times;</button>
</div>
<div class="health-panel-status ${this._hasIssues() ? 'is-issue' : ''}">${this._escape(statusLine)}</div>
${s.error ? `<div class="health-panel-error">${this._escape(s.error)}</div>` : ''}
<dl class="health-panel-rows">
${rows.map(([k, v, cls]) => `<div class="health-panel-row"><dt>${this._escape(k)}</dt><dd class="${cls || ''}">${this._escape(v)}</dd></div>`).join('')}
</dl>
${loopList}
${s.can_auto_heal && this._hasIssues() ? '<p class="health-panel-note">Repair stops any crash-looping container and, if the WebUIs host port is down, restarts it — recycling the rootless docker daemon only if needed. Progress streams on the Tasks page.</p>' : ''}
<div class="health-panel-actions">
<button type="button" class="health-btn-secondary" id="health-panel-check">Re-check</button>
${s.can_auto_heal && this._hasIssues() ? '<button type="button" class="health-btn-primary" id="health-panel-heal">Repair now</button>' : ''}
</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#health-panel-close').addEventListener('click', () => this.closePanel());
overlay.querySelector('#health-panel-check').addEventListener('click', () => this.checkNow());
const heal = overlay.querySelector('#health-panel-heal');
if (heal) heal.addEventListener('click', () => this.runHeal());
this._escHandler = (e) => { if (e.key === 'Escape') this.closePanel(); };
document.addEventListener('keydown', this._escHandler);
}
closePanel() {
const overlay = document.getElementById('health-panel-overlay');
if (overlay) overlay.remove();
if (this._escHandler) {
document.removeEventListener('keydown', this._escHandler);
this._escHandler = null;
}
}
// ---- actions -------------------------------------------------------------
async runHeal() {
this.closePanel();
try {
await this._createTask('libreportal system health heal');
this._toast('Control-plane repair started — follow progress on the Tasks page.', 'info');
this._goToTasks();
} catch (e) {
this._toast('Could not start the repair: ' + e.message, 'error');
}
}
async checkNow() {
try {
await this._createTask('libreportal system health check');
this._toast('Re-checking control-plane health…', 'info');
setTimeout(() => this.refresh(), 4000);
} catch (e) {
this._toast('Could not re-check health: ' + e.message, 'error');
}
}
// ---- helpers -------------------------------------------------------------
async _createTask(command) {
if (window.tasksManager?.taskManager?.createTask) {
return window.tasksManager.taskManager.createTask(command, 'system_health_heal', null, '');
}
if (typeof TaskManager !== 'undefined') {
return new TaskManager().createTask(command, 'system_health_heal', null, '');
}
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command, type: 'system_health_heal', app: null, config: '' })
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
_goToTasks() {
if (window.librePortalSPA?.navigate) window.librePortalSPA.navigate('/tasks');
else if (typeof navigateToRoute === 'function') navigateToRoute('/tasks');
else window.location.href = '/tasks';
}
_toast(message, type = 'info') {
const ns = window.notificationSystem || window.ensureNotificationSystem?.();
if (ns?.show) ns.show(message, type);
else console.log(`[health] ${message}`);
}
_formatTime(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
return d.toLocaleString();
}
_escape(str) {
return String(str).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
window.healthNotifier = window.healthNotifier || new HealthNotifier();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => window.healthNotifier.start());
} else {
window.healthNotifier.start();
}

View File

@ -40,6 +40,7 @@
<link rel="stylesheet" href="/components/updater/css/updater.css">
<link rel="stylesheet" href="/core/update-notifier/css/update-notifier.css">
<link rel="stylesheet" href="/core/network-notifier/css/network-notifier.css">
<link rel="stylesheet" href="/core/health-notifier/css/health-notifier.css">
<script>
// Inline data-theme bootstrap — runs before any rendering so the right
// palette tokens resolve on first paint. Synchronously injects a

View File

@ -7,7 +7,22 @@ services:
container_name: trivy-service
image: aquasec/trivy:latest
restart: unless-stopped
command: server --listen 0.0.0.0:4954
# Keep the container process ALIVE even when the vuln DB can't be fetched.
# `trivy server` FATAL-exits if it can't download the DB on first boot (no
# internet / registry unreachable); bare, restart:unless-stopped then crash-
# loops it, and on rootless docker that restart storm churns the shared
# network's port-forwarder until the WebUI's own published port is torn down.
# Wrapping the server in a shell retry-loop means the container stays Up and
# quietly retries on a backoff instead of exiting — no restart, no churn. The
# backend already reports the scanner as db_updating/absent until a DB lands.
entrypoint: ["/bin/sh", "-c"]
command:
- |
while true; do
trivy server --listen 0.0.0.0:4954
echo "[libreportal] trivy server exited ($?) — vuln DB unavailable or offline; staying up, retrying in ${TRIVY_RETRY_SECS:-300}s (scanning paused until the DB is reachable)."
sleep "${TRIVY_RETRY_SECS:-300}"
done
# GLUETUN_OFF_BEGIN
ports:
- "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1

View File

@ -109,6 +109,31 @@ cliHandleSystemCommands()
esac
;;
"health")
# libreportal system health check [force] (read-only, rewrites
# health_status.json — used by the task-processor poll + WebUI;
# self-dispatches a heal when the control plane is unreachable)
# libreportal system health heal (mutating — stops
# crash-loopers, repairs the WebUI port forward, recycles the
# rootless daemon; routes through the task system like network heal)
case "$initial_command3" in
"check")
webuiSystemHealthCheck "${initial_command4:-force}"
;;
"heal")
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
dockerHealthHeal
else
cliTaskRun "libreportal system health heal" "system_health_heal" "" ""
fi
;;
*)
isNotice "Invalid health command: $initial_command3"
cliShowSystemHelp
;;
esac
;;
"image")
# libreportal system image rm [--force] <comma-separated ids>
case "$initial_command3" in

View File

@ -15,5 +15,7 @@ cliShowSystemHelp()
echo " libreportal system image rm [--force] <ids> - Remove specific images (comma-separated ids)"
echo " libreportal system network check - Re-scan for apps stranded off the docker subnet"
echo " libreportal system network heal [app] - Re-IP stranded apps from the current subnet (ports kept)"
echo " libreportal system health check - Check the rootless docker / WebUI control plane"
echo " libreportal system health heal - Stop crash-loops + repair the WebUI port forward"
echo ""
}

View File

@ -0,0 +1,83 @@
#!/bin/bash
# Rootless-docker / control-plane health heal — the mutating half of the
# detector. Runs ONLY through the task system (see cli_system_commands.sh
# `health heal`, which enqueues unless LIBREPORTAL_TASK_EXEC=1), never a direct
# API. It re-scans before each heavier step so it does the least it can.
#
# Escalation ladder:
# 1) Stop crash-loopers — an app FATAL-ing on every boot (e.g. trivy offline)
# churns the rootless port-forwarder; stopping it removes the churn. This is
# the failure cap: a broken opt-in app can't take the control plane down.
# (unless-stopped honours a manual stop across a daemon recycle, so a
# stopped crash-looper stays down and can't resume the churn.)
# 2) Restart the WebUI container — re-publishes a mildly-lost port forward.
# 3) Recycle the rootless docker daemon — rebuilds the netns/port-forward state
# when churn has already corrupted it (a container restart alone won't fix
# it), then explicitly starts the core container (it may carry no restart
# policy, so a daemon recycle would otherwise leave it down).
#
# Re-runs the read-only check at the end to rewrite health_status.json (the badge
# clears, or stays if anything is still unhealed).
#
# dockerHealthHeal
dockerHealthHeal() {
isHeader "Healing docker / control-plane health"
dockerHealthScan # populate HEALTH_* (call direct, NOT in $(...))
if [[ "$HEALTH_DAEMON_OK" != "true" ]]; then
isError "Docker daemon unreachable — cannot heal from here."
declare -f webuiSystemHealthCheck >/dev/null 2>&1 && webuiSystemHealthCheck "force" >/dev/null 2>&1
return 1
fi
local rootless="true"
[[ "${CFG_DOCKER_INSTALL_TYPE:-rootless}" == "rootless" ]] || rootless="false"
local acted=0
# 1) Stop crash-loopers (the failure cap).
local row cname
for row in "${HEALTH_CRASHLOOPS[@]}"; do
cname="${row#*|}"; cname="${cname%%|*}" # app|container|rc -> container
[[ "$cname" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || continue
isNotice "Stopping crash-looping container '$cname' (restart storm churning the network)…"
dockerCommandRun "docker stop '$cname'" >/dev/null 2>&1
((acted++))
done
# 2/3) Restore the WebUI host port if the container is up but unreachable.
if [[ "$HEALTH_WEBUI_RUNNING" == "true" && "$HEALTH_WEBUI_REACHABLE" == "false" ]]; then
local webui; webui="$(_healthWebuiContainer)"
isNotice "WebUI up but host port ${HEALTH_WEBUI_PORT:-?} unreachable — restarting '$webui' to re-publish…"
dockerCommandRun "docker restart '$webui'" >/dev/null 2>&1
((acted++))
sleep 4
dockerHealthScan
if [[ "$HEALTH_WEBUI_REACHABLE" != "true" && "$rootless" == "true" ]]; then
isNotice "Still unreachable — recycling the rootless docker daemon to rebuild port-forward state…"
dockerCommandRun "systemctl --user restart docker.service" >/dev/null 2>&1
sleep 8
# The core container may have no restart policy — bring it back explicitly.
dockerCommandRun "docker start '$webui'" >/dev/null 2>&1
sleep 4
((acted++))
fi
fi
# Rewrite the status file + report what (if anything) remains.
declare -f webuiSystemHealthCheck >/dev/null 2>&1 && webuiSystemHealthCheck "force" >/dev/null 2>&1
dockerHealthScan
if [[ "$HEALTH_WEBUI_RUNNING" == "true" && "$HEALTH_WEBUI_REACHABLE" == "false" ]]; then
isError "Health heal ran ${acted} action(s); WebUI still unreachable on port ${HEALTH_WEBUI_PORT:-?} — manual inspection needed."
return 1
fi
if (( acted == 0 )); then
isSuccessful "Nothing to heal — control plane healthy."
else
isSuccessful "Health heal complete — ${acted} action(s); control plane reachable."
fi
}

View File

@ -0,0 +1,97 @@
#!/bin/bash
# Read-only rootless-docker / control-plane health scan — the shared detection
# used by both the WebUI status generator (webuiSystemHealthCheck) and the heal
# verb (system health heal), so the two never diverge.
#
# dockerHealthScan sets these globals (call it DIRECTLY, never in $(...) — a
# subshell would drop them):
# HEALTH_DAEMON_OK "true"/"false" — rootless docker daemon reachable
# HEALTH_WEBUI_PRESENT "true"/"false" — the core WebUI container exists
# HEALTH_WEBUI_RUNNING "true"/"false" — ...and is running (not exited/restarting)
# HEALTH_WEBUI_PORT — its published host port (docker port), "" if none
# HEALTH_WEBUI_REACHABLE "true"/"false"/"unknown" — that host port accepts a TCP connect
# HEALTH_SCAN_ERROR — human note when the daemon is off (else "")
# HEALTH_CRASHLOOPS (array) — "app|container|restartcount" per container
# stuck restarting (State=restarting and
# RestartCount >= CFG_HEALTH_CRASHLOOP_LIMIT):
# a crash-loop churning the shared network.
#
# The incident this guards: an app crash-loop (trivy, offline, FATAL on every
# boot) churned the rootless port-forwarder until the WebUI's published port was
# torn down — the container stayed healthy INSIDE, but nothing on the host could
# reach it. So we probe the host-visible port forward, not just container state.
#
# Nothing here mutates state.
# The core WebUI container + its internal port (the control plane we must keep
# reachable). Kept as tiny functions so a rename only touches one place.
_healthWebuiContainer() { echo "libreportal-service"; }
_healthWebuiInternalPort() { echo "1111"; }
# TCP-connect probe from the host (manager context). Success => the published
# port forward is live. Uses bash /dev/tcp (always present) with a timeout so a
# black-holed forward can't hang the poll.
_healthTcpReachable() {
local host="$1" port="$2"
[[ -n "$port" ]] || return 2
timeout 4 bash -c "exec 3<>/dev/tcp/${host}/${port}" 2>/dev/null
}
dockerHealthScan() {
HEALTH_DAEMON_OK="false"; HEALTH_WEBUI_PRESENT="false"; HEALTH_WEBUI_RUNNING="false"
HEALTH_WEBUI_PORT=""; HEALTH_WEBUI_REACHABLE="unknown"; HEALTH_SCAN_ERROR=""
HEALTH_CRASHLOOPS=()
local limit="${CFG_HEALTH_CRASHLOOP_LIMIT:-3}"
[[ "$limit" =~ ^[0-9]+$ ]] || limit=3
# Daemon reachable? Never alarm on what we can't verify — a daemon blip (or a
# mid-recycle window) is transient, not a conflict.
if ! dockerCommandRun "docker info" >/dev/null 2>&1; then
HEALTH_SCAN_ERROR="docker daemon unreachable"
return 0
fi
HEALTH_DAEMON_OK="true"
# Crash-loopers: containers docker reports as "restarting" whose RestartCount
# has already climbed past the limit — i.e. actively churning, not a one-off
# restart. Container name is enough to name the offender in the badge; derive
# a friendly app label by trimming the conventional "-service" suffix.
local names name rc app
names=$(dockerCommandRun "docker ps -a --filter status=restarting --format '{{.Names}}'" 2>/dev/null)
while IFS= read -r name; do
[[ -n "$name" ]] || continue
rc=$(dockerCommandRun "docker inspect --format '{{.RestartCount}}' '$name'" 2>/dev/null | tr -dc '0-9')
[[ -n "$rc" ]] || rc=0
if (( rc >= limit )); then
app="${name%-service}"
HEALTH_CRASHLOOPS+=("${app}|${name}|${rc}")
fi
done <<< "$names"
# Core WebUI container: present? running? host port reachable?
local webui iport wstate
webui="$(_healthWebuiContainer)"
iport="$(_healthWebuiInternalPort)"
wstate=$(dockerCommandRun "docker inspect --format '{{.State.Status}}' '$webui'" 2>/dev/null | tr -d '[:space:]')
if [[ -z "$wstate" ]]; then
return 0 # container not found — nothing more to probe
fi
HEALTH_WEBUI_PRESENT="true"
[[ "$wstate" == "running" ]] && HEALTH_WEBUI_RUNNING="true"
# docker's view of the published host port (e.g. "1111/tcp -> 0.0.0.0:9781").
# Note: this reports the INTENDED mapping even when the rootless forward has
# been torn down — which is exactly why we then TCP-probe it for real.
HEALTH_WEBUI_PORT=$(dockerCommandRun "docker port '$webui' '$iport'" 2>/dev/null | head -1 | sed -n 's/.*:\([0-9][0-9]*\)$/\1/p')
if [[ "$HEALTH_WEBUI_RUNNING" == "true" && -n "$HEALTH_WEBUI_PORT" ]]; then
if _healthTcpReachable "127.0.0.1" "$HEALTH_WEBUI_PORT"; then
HEALTH_WEBUI_REACHABLE="true"
else
HEALTH_WEBUI_REACHABLE="false"
fi
fi
}

View File

@ -36,6 +36,8 @@ docker_scripts=(
"docker/compose/setup_compose_yml.sh"
"docker/compose/update_and_start.sh"
"docker/compose/update_compose_yml.sh"
"docker/health/docker_health_heal.sh"
"docker/health/docker_health_scan.sh"
"docker/install/rooted/rooted_docker_check.sh"
"docker/install/rooted/rooted_docker_compose.sh"
"docker/install/rooted/rooted_docker.sh"

View File

@ -30,6 +30,7 @@ webui_scripts=(
"webui/data/generators/peers/webui_peers.sh"
"webui/data/generators/system/webui_ssh_access.sh"
"webui/data/generators/system/webui_system_disk.sh"
"webui/data/generators/system/webui_system_health.sh"
"webui/data/generators/system/webui_system_info.sh"
"webui/data/generators/system/webui_system_memory.sh"
"webui/data/generators/system/webui_system_metrics.sh"

View File

@ -405,6 +405,8 @@ declare -gA LP_FN_MAP=(
[dockerContainerOwner]="function/permission/libreportal_folders.sh"
[dockerCopyBuildContext]="docker/compose/copy_build_context.sh"
[dockerDeleteData]="docker/app/uninstall/delete_data.sh"
[dockerHealthHeal]="docker/health/docker_health_heal.sh"
[dockerHealthScan]="docker/health/docker_health_scan.sh"
[dockerInstallApp]="docker/app/functions/function_install_app.sh"
[dockerPruneAppNetworks]="docker/network/network_prune.sh"
[dockerRemoveApp]="docker/app/docker/remove_app.sh"
@ -429,6 +431,7 @@ declare -gA LP_FN_MAP=(
[endStart]="start/start_end.sh"
[engineBackupApp]="backup/engine/engine_dispatch.sh"
[engineBackupSystem]="backup/engine/engine_dispatch.sh"
[_engineCachedPull]="backup/engine/engine_dispatch.sh"
[engineCheckAllLocations]="backup/engine/engine_dispatch.sh"
[engineCheckLocation]="backup/engine/engine_dispatch.sh"
[engineDispatch]="backup/engine/engine_dispatch.sh"
@ -499,6 +502,9 @@ declare -gA LP_FN_MAP=(
[healthLogInfo]="task/crontab_check_processor.sh"
[healthLogSuccess]="task/crontab_check_processor.sh"
[healthLogWarning]="task/crontab_check_processor.sh"
[_healthTcpReachable]="docker/health/docker_health_scan.sh"
[_healthWebuiContainer]="docker/health/docker_health_scan.sh"
[_healthWebuiInternalPort]="docker/health/docker_health_scan.sh"
[hostAppInstall]="install/host_app.sh"
[hostSshAuthKeysFile]="ssh/host_access.sh"
[hostSshEnsureDir]="ssh/host_access.sh"
@ -972,6 +978,7 @@ declare -gA LP_FN_MAP=(
[webuiSystemApps]="webui/data/generators/system/webui_system_metrics.sh"
[webuiSystemAppStorage]="webui/data/generators/system/webui_system_metrics.sh"
[webuiSystemDisk]="webui/data/generators/system/webui_system_disk.sh"
[webuiSystemHealthCheck]="webui/data/generators/system/webui_system_health.sh"
[webuiSystemInfo]="webui/data/generators/system/webui_system_info.sh"
[webuiSystemMemory]="webui/data/generators/system/webui_system_memory.sh"
[webuiSystemMetrics]="webui/data/generators/system/webui_system_metrics.sh"
@ -1390,6 +1397,8 @@ declare -gA LP_FN_ROOT=(
[dockerContainerOwner]="scripts"
[dockerCopyBuildContext]="scripts"
[dockerDeleteData]="scripts"
[dockerHealthHeal]="scripts"
[dockerHealthScan]="scripts"
[dockerInstallApp]="scripts"
[dockerPruneAppNetworks]="scripts"
[dockerRemoveApp]="scripts"
@ -1414,6 +1423,7 @@ declare -gA LP_FN_ROOT=(
[endStart]="scripts"
[engineBackupApp]="scripts"
[engineBackupSystem]="scripts"
[_engineCachedPull]="scripts"
[engineCheckAllLocations]="scripts"
[engineCheckLocation]="scripts"
[engineDispatch]="scripts"
@ -1484,6 +1494,9 @@ declare -gA LP_FN_ROOT=(
[healthLogInfo]="scripts"
[healthLogSuccess]="scripts"
[healthLogWarning]="scripts"
[_healthTcpReachable]="scripts"
[_healthWebuiContainer]="scripts"
[_healthWebuiInternalPort]="scripts"
[hostAppInstall]="scripts"
[hostSshAuthKeysFile]="scripts"
[hostSshEnsureDir]="scripts"
@ -1957,6 +1970,7 @@ declare -gA LP_FN_ROOT=(
[webuiSystemApps]="scripts"
[webuiSystemAppStorage]="scripts"
[webuiSystemDisk]="scripts"
[webuiSystemHealthCheck]="scripts"
[webuiSystemInfo]="scripts"
[webuiSystemMemory]="scripts"
[webuiSystemMetrics]="scripts"
@ -2408,6 +2422,8 @@ dockerConfigSetupToContainer() { unset -f dockerConfigSetupToContainer; __lpAuto
dockerContainerOwner() { unset -f dockerContainerOwner; __lpAutoload "${install_scripts_dir}function/permission/libreportal_folders.sh"; dockerContainerOwner "$@"; }
dockerCopyBuildContext() { unset -f dockerCopyBuildContext; __lpAutoload "${install_scripts_dir}docker/compose/copy_build_context.sh"; dockerCopyBuildContext "$@"; }
dockerDeleteData() { unset -f dockerDeleteData; __lpAutoload "${install_scripts_dir}docker/app/uninstall/delete_data.sh"; dockerDeleteData "$@"; }
dockerHealthHeal() { unset -f dockerHealthHeal; __lpAutoload "${install_scripts_dir}docker/health/docker_health_heal.sh"; dockerHealthHeal "$@"; }
dockerHealthScan() { unset -f dockerHealthScan; __lpAutoload "${install_scripts_dir}docker/health/docker_health_scan.sh"; dockerHealthScan "$@"; }
dockerInstallApp() { unset -f dockerInstallApp; __lpAutoload "${install_scripts_dir}docker/app/functions/function_install_app.sh"; dockerInstallApp "$@"; }
dockerPruneAppNetworks() { unset -f dockerPruneAppNetworks; __lpAutoload "${install_scripts_dir}docker/network/network_prune.sh"; dockerPruneAppNetworks "$@"; }
dockerRemoveApp() { unset -f dockerRemoveApp; __lpAutoload "${install_scripts_dir}docker/app/docker/remove_app.sh"; dockerRemoveApp "$@"; }
@ -2432,6 +2448,7 @@ emailValidation() { unset -f emailValidation; __lpAutoload "${install_scripts_di
endStart() { unset -f endStart; __lpAutoload "${install_scripts_dir}start/start_end.sh"; endStart "$@"; }
engineBackupApp() { unset -f engineBackupApp; __lpAutoload "${install_scripts_dir}backup/engine/engine_dispatch.sh"; engineBackupApp "$@"; }
engineBackupSystem() { unset -f engineBackupSystem; __lpAutoload "${install_scripts_dir}backup/engine/engine_dispatch.sh"; engineBackupSystem "$@"; }
_engineCachedPull() { unset -f _engineCachedPull; __lpAutoload "${install_scripts_dir}backup/engine/engine_dispatch.sh"; _engineCachedPull "$@"; }
engineCheckAllLocations() { unset -f engineCheckAllLocations; __lpAutoload "${install_scripts_dir}backup/engine/engine_dispatch.sh"; engineCheckAllLocations "$@"; }
engineCheckLocation() { unset -f engineCheckLocation; __lpAutoload "${install_scripts_dir}backup/engine/engine_dispatch.sh"; engineCheckLocation "$@"; }
engineDispatch() { unset -f engineDispatch; __lpAutoload "${install_scripts_dir}backup/engine/engine_dispatch.sh"; engineDispatch "$@"; }
@ -2502,6 +2519,9 @@ healthLogError() { unset -f healthLogError; __lpAutoload "${install_scripts_dir}
healthLogInfo() { unset -f healthLogInfo; __lpAutoload "${install_scripts_dir}task/crontab_check_processor.sh"; healthLogInfo "$@"; }
healthLogSuccess() { unset -f healthLogSuccess; __lpAutoload "${install_scripts_dir}task/crontab_check_processor.sh"; healthLogSuccess "$@"; }
healthLogWarning() { unset -f healthLogWarning; __lpAutoload "${install_scripts_dir}task/crontab_check_processor.sh"; healthLogWarning "$@"; }
_healthTcpReachable() { unset -f _healthTcpReachable; __lpAutoload "${install_scripts_dir}docker/health/docker_health_scan.sh"; _healthTcpReachable "$@"; }
_healthWebuiContainer() { unset -f _healthWebuiContainer; __lpAutoload "${install_scripts_dir}docker/health/docker_health_scan.sh"; _healthWebuiContainer "$@"; }
_healthWebuiInternalPort() { unset -f _healthWebuiInternalPort; __lpAutoload "${install_scripts_dir}docker/health/docker_health_scan.sh"; _healthWebuiInternalPort "$@"; }
hostAppInstall() { unset -f hostAppInstall; __lpAutoload "${install_scripts_dir}install/host_app.sh"; hostAppInstall "$@"; }
hostSshAuthKeysFile() { unset -f hostSshAuthKeysFile; __lpAutoload "${install_scripts_dir}ssh/host_access.sh"; hostSshAuthKeysFile "$@"; }
hostSshEnsureDir() { unset -f hostSshEnsureDir; __lpAutoload "${install_scripts_dir}ssh/host_access.sh"; hostSshEnsureDir "$@"; }
@ -2975,6 +2995,7 @@ webuiSyncAppIcons() { unset -f webuiSyncAppIcons; __lpAutoload "${install_script
webuiSystemApps() { unset -f webuiSystemApps; __lpAutoload "${install_scripts_dir}webui/data/generators/system/webui_system_metrics.sh"; webuiSystemApps "$@"; }
webuiSystemAppStorage() { unset -f webuiSystemAppStorage; __lpAutoload "${install_scripts_dir}webui/data/generators/system/webui_system_metrics.sh"; webuiSystemAppStorage "$@"; }
webuiSystemDisk() { unset -f webuiSystemDisk; __lpAutoload "${install_scripts_dir}webui/data/generators/system/webui_system_disk.sh"; webuiSystemDisk "$@"; }
webuiSystemHealthCheck() { unset -f webuiSystemHealthCheck; __lpAutoload "${install_scripts_dir}webui/data/generators/system/webui_system_health.sh"; webuiSystemHealthCheck "$@"; }
webuiSystemInfo() { unset -f webuiSystemInfo; __lpAutoload "${install_scripts_dir}webui/data/generators/system/webui_system_info.sh"; webuiSystemInfo "$@"; }
webuiSystemMemory() { unset -f webuiSystemMemory; __lpAutoload "${install_scripts_dir}webui/data/generators/system/webui_system_memory.sh"; webuiSystemMemory "$@"; }
webuiSystemMetrics() { unset -f webuiSystemMetrics; __lpAutoload "${install_scripts_dir}webui/data/generators/system/webui_system_metrics.sh"; webuiSystemMetrics "$@"; }

View File

@ -519,6 +519,12 @@ maybeRegenPoll() {
# so almost every tick is a single stat() and an early return. Eligible signed
# hotfixes are enqueued as ordinary tasks by the scan, never applied inline.
command -v libreportal >/dev/null 2>&1 && libreportal updater check auto >/dev/null 2>&1 || true
# Control-plane health (rootless docker port-forward + container crash-loops).
# Same shape as the drift check: runs every poll, self-throttled inside the CLI
# to CFG_HEALTH_CHECK_INTERVAL, cheap no-op most ticks. When it finds the
# WebUI's host port down or a container crash-looping it self-dispatches
# `system health heal` — the user can't click a heal button on a dead WebUI.
command -v libreportal >/dev/null 2>&1 && libreportal system health check >/dev/null 2>&1 || true
}
# ============================================================================

View File

@ -0,0 +1,131 @@
#!/bin/bash
# WebUI rootless-docker / control-plane health detector.
# Writes frontend/data/system/health_status.json so the dashboard + topbar can
# surface the control plane going unhealthy — the WebUI's published host port
# torn down (container healthy inside, unreachable outside) or a container
# crash-looping and churning the shared network.
#
# Read-only w.r.t. the system: it inspects docker and writes a status file. The
# actual fix is the locked-down `libreportal system health heal`.
#
# Scheduling: invoked from the task processor's idle poll (~60s) via
# `libreportal system health check`. Self-throttled to CFG_HEALTH_CHECK_INTERVAL
# so most calls no-op. Pass "force" to bypass (manual re-check / post-heal).
#
# Unlike the network badge, this one SELF-DISPATCHES a heal: when the WebUI's own
# port is down the user can't click a button to fix it, so on an auto-healable
# problem the check enqueues `system health heal` itself (throttled by a cooldown
# stamp so it can never stack heals).
webuiSystemHealthCheck() {
local force_flag="$1"
local system_dir="$containers_dir/libreportal/frontend/data/system"
local final_file="${system_dir}/health_status.json"
local stamp_file="${system_dir}/.health_check_stamp"
local heal_stamp="${system_dir}/.health_heal_stamp"
local interval="${CFG_HEALTH_CHECK_INTERVAL:-120}"
local heal_cooldown="${CFG_HEALTH_HEAL_COOLDOWN:-300}"
createFolders "quiet" "$sudo_user_name" "$system_dir"
local do_run="false"
if [[ "$force_flag" == "force" || ! -f "$final_file" || ! -f "$stamp_file" ]]; then
do_run="true"
else
local _now _last; _now=$(date +%s); _last=$(stat -c '%Y' "$stamp_file" 2>/dev/null || echo 0)
(( _now - _last >= interval )) && do_run="true"
fi
[[ "$do_run" == "true" ]] || return 0
runFileOp touch "$stamp_file" 2>/dev/null || true
# Read-only scan — call directly (NOT in $(...)): it sets HEALTH_* globals +
# the HEALTH_CRASHLOOPS array, which a subshell would discard.
dockerHealthScan
local healthy="true" issues_found="false" can_auto_heal="false"
local error_json="null" summary=""
local crash_json=""
if [[ "$HEALTH_DAEMON_OK" != "true" ]]; then
# Daemon unreachable — neutral status; never alarm on what we can't check.
error_json="\"${HEALTH_SCAN_ERROR//\"/\\\"}\""
summary="Docker daemon unreachable"
else
local row app cname rc _a _c
for row in "${HEALTH_CRASHLOOPS[@]}"; do
IFS='|' read -r app cname rc <<< "$row"
[[ -n "$cname" ]] || continue
[[ "$rc" =~ ^[0-9]+$ ]] || rc=0
_a=${app//\\/\\\\}; _a=${_a//\"/\\\"}
_c=${cname//\\/\\\\}; _c=${_c//\"/\\\"}
crash_json+="${crash_json:+,}"$'\n'" {\"app\": \"${_a}\", \"container\": \"${_c}\", \"restart_count\": ${rc}}"
done
local webui_unreachable="false"
[[ "$HEALTH_WEBUI_RUNNING" == "true" && "$HEALTH_WEBUI_REACHABLE" == "false" ]] && webui_unreachable="true"
if [[ -n "$crash_json" || "$webui_unreachable" == "true" ]]; then
healthy="false"; issues_found="true"; can_auto_heal="true"
if [[ "$webui_unreachable" == "true" ]]; then
summary="WebUI unreachable on host port ${HEALTH_WEBUI_PORT:-?} (container is up) — port forward needs repair"
else
summary="Crash-looping container(s) churning the network"
fi
else
summary="Control plane healthy"
fi
fi
local crash_arr="[]"
[[ -n "$crash_json" ]] && crash_arr="[${crash_json}"$'\n'" ]"
local reachable_json
case "$HEALTH_WEBUI_REACHABLE" in
true) reachable_json="true" ;;
false) reachable_json="false" ;;
*) reachable_json="null" ;;
esac
local port_json="null"
[[ "$HEALTH_WEBUI_PORT" =~ ^[0-9]+$ ]] && port_json="$HEALTH_WEBUI_PORT"
local temp_file; temp_file="$(mktemp)"
cat << EOF > "$temp_file"
{
"healthy": ${healthy},
"issues_found": ${issues_found},
"can_auto_heal": ${can_auto_heal},
"daemon_ok": ${HEALTH_DAEMON_OK},
"webui": {
"container": "$(_healthWebuiContainer)",
"present": ${HEALTH_WEBUI_PRESENT},
"running": ${HEALTH_WEBUI_RUNNING},
"port": ${port_json},
"reachable": ${reachable_json}
},
"crash_loops": ${crash_arr},
"summary": "${summary//\"/\\\"}",
"error": ${error_json},
"checked_at": "$(date -Iseconds)"
}
EOF
runFileWrite "$final_file" < "$temp_file"; rm -f "$temp_file"
# Self-dispatch a heal on an auto-healable problem, throttled by a cooldown
# stamp. The WebUI can't be clicked when its own port is down, so the poll
# must drive the fix — this is the backend counterpart of the "Heal now"
# button. The stamp is touched BEFORE enqueue so a heal-triggered re-check
# (which force-runs this generator) can't stack a second heal.
if [[ "$can_auto_heal" == "true" ]]; then
local _hnow _hlast=0
_hnow=$(date +%s)
[[ -f "$heal_stamp" ]] && _hlast=$(stat -c '%Y' "$heal_stamp" 2>/dev/null || echo 0)
if (( _hnow - _hlast >= heal_cooldown )); then
runFileOp touch "$heal_stamp" 2>/dev/null || true
if declare -f cliTaskRun >/dev/null 2>&1; then
cliTaskRun "libreportal system health heal" "system_health_heal" "" "--detach" >/dev/null 2>&1 || true
fi
fi
fi
}