feat(oci): run official container images as native LXC containers

Adds the OCI manager: an engine that turns a Docker Compose file into an
LXC definition, a catalog of 365 applications drawn from LinuxServer.io
and other container image sources, and a per-instance registry recording
what each container was built from. Reachable from the main menu.

Catalog text is translated like every other string in the project: the
taglines go through translate() and land in lang/*.json, so the entries
read in all eight languages instead of only English.

Translation cache builder:
- a failed translation leaves the key absent rather than writing English,
  which previously made the string count as translated forever
- a result identical to a 3+ word source is rejected, catching a provider
  that silently returns the text it was given
- strings that are nothing but glossary terms keep their source spelling
  instead of being discarded as failures
- no backoff between attempts when the provider is deterministic
- application names are protected so "HAOS One" survives translation
- argos joins the provider list, and the workflow reads the OCI sources

Audit & Report:
- findings that moved in the wrong direction between runs are reported
  alongside the ones that improved
- an accepted risk can carry a review date and is flagged when it falls due
- backup checks explain in plain language what they looked at and what to
  do next

Monitor:
- disks can be excluded from periodic reads, and an idle disk says so
  instead of showing a stale temperature
- per-disk identity survives a controller or enclosure change
- scheduled Borg backups resolve their SSH key from the repository entry
- PVE upgrades log the package list and the resulting dpkg changes

The web build no longer copies scripts/ into public/: the documentation
links to GitHub, so nothing read that folder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MacRimi
2026-09-22 18:24:59 +02:00
co-authored by Claude Opus 5
parent b36498f215
commit bcabcb618c
670 changed files with 221410 additions and 215 deletions
+64 -11
View File
@@ -1616,8 +1616,10 @@ _system_info_cache = {
'proxmox_version_time': 0,
'available_updates': 0,
'available_updates_time': 0,
'available_updates_stamp': 0.0,
}
_SYSTEM_INFO_CACHE_TTL = 21600 # 6 hours - update notifications are sent once per 24h
_AVAILABLE_UPDATES_MIN_INTERVAL = 30 # seconds between apt recounts when apt state moves
# Cache for pvesh cluster resources (reduces repeated API calls)
_pvesh_cache = {
@@ -3316,15 +3318,39 @@ def get_proxmox_version():
_system_info_cache['proxmox_version_time'] = now
return proxmox_version
def _apt_state_stamp():
"""Newest mtime of the files that decide what `apt list --upgradable`
answers: dpkg's status file (what is installed) and apt's package
lists (what is on offer). Any upgrade moves it — whichever way the
packages were installed."""
newest = 0.0
for path in ('/var/lib/dpkg/status', '/var/lib/apt/lists', '/var/cache/apt/pkgcache.bin'):
try:
newest = max(newest, os.path.getmtime(path))
except OSError:
pass
return newest
def get_available_updates():
"""Get the number of available package updates. Cached for 6 hours."""
"""Get the number of available package updates. Cached for 6 hours,
or until apt's own state moves — an upgrade that finishes two minutes
after the count was taken must not leave the overview showing what
was pending before it ran."""
global _system_info_cache
now = time.time()
if _system_info_cache['available_updates_time'] > 0 and \
now - _system_info_cache['available_updates_time'] < _SYSTEM_INFO_CACHE_TTL:
return _system_info_cache['available_updates']
stamp = _apt_state_stamp()
age = now - _system_info_cache['available_updates_time']
if _system_info_cache['available_updates_time'] > 0:
# dpkg rewrites its status file once per package, so during an
# upgrade the stamp moves with every one of them. The floor keeps
# that from turning each overview poll into an apt call.
if age < _AVAILABLE_UPDATES_MIN_INTERVAL:
return _system_info_cache['available_updates']
if stamp == _system_info_cache['available_updates_stamp'] and age < _SYSTEM_INFO_CACHE_TTL:
return _system_info_cache['available_updates']
available_updates = 0
try:
# Use apt list --upgradable to count available updates
@@ -3340,6 +3366,7 @@ def get_available_updates():
_system_info_cache['available_updates'] = available_updates
_system_info_cache['available_updates_time'] = now
_system_info_cache['available_updates_stamp'] = stamp
return available_updates
# AGREGANDO FUNCIÓN PARA PARSEAR PROCESOS DE INTEL_GPU_TOP (SIN -J)
@@ -4123,9 +4150,13 @@ def get_storage_info():
# temperature graph isn't a monitor bug — the
# disk is parked. See issue #232.
in_standby = False
in_idle = False
in_excluded = False
try:
import disk_temperature_history as _dth
in_standby = _dth.is_disk_in_standby(disk_name)
in_idle = _dth.is_disk_idle(disk_name)
in_excluded = disk_name in _dth.excluded_disk_names()
except Exception:
pass
physical_disks[disk_name] = {
@@ -4135,6 +4166,8 @@ def get_storage_info():
'size_bytes': disk_size_bytes,
'temperature': smart_data.get('temperature', 0),
'standby': in_standby,
'idle': in_idle,
'excluded': in_excluded,
'health': smart_data.get('health', 'unknown'),
'power_on_hours': smart_data.get('power_on_hours', 0),
'smart_status': smart_data.get('smart_status', 'unknown'),
@@ -4860,6 +4893,22 @@ def get_smart_data(disk_name):
if cached and now - cached[0] < _SMART_RESULT_TTL:
return dict(cached[1])
# Excluded, or rotational with no I/O since it was last looked at: send
# it nothing — not even the power-mode question below, which is still a
# command. Serve what is known, without a temperature that would only be
# stale. Same rule as the temperature poller, so the two agree.
try:
import disk_temperature_history as _dth
policy = _dth.disk_read_policy(disk_name)
except Exception:
policy = 'read'
if policy != 'read':
base = dict(cached[1]) if cached else _smart_default_payload()
base['temperature'] = 0
base['excluded'] = policy == 'excluded'
base['idle'] = policy == 'idle'
return base
if _hdd_in_standby(disk_name):
# Keep serving the last known values (temperature blanked, since
# we don't have a fresh one) so the card stays populated while
@@ -14433,7 +14482,7 @@ def api_health_thresholds_get():
@app.route('/api/health/thresholds', methods=['PUT'])
@require_auth
@require_admin_scope
def api_health_thresholds_put():
"""Save a partial threshold payload. Body shape mirrors DEFAULTS
but the leaves are bare numbers, not metadata dicts. Sections not
@@ -14453,7 +14502,7 @@ def api_health_thresholds_put():
@app.route('/api/health/thresholds/reset', methods=['POST'])
@require_auth
@require_admin_scope
def api_health_thresholds_reset():
"""Reset thresholds. ?section=<name> resets one section, no
parameter resets everything to recommended."""
@@ -14473,7 +14522,7 @@ def api_health_thresholds_reset():
@app.route('/api/health/acknowledge', methods=['POST'])
@require_auth
@require_admin_scope
def api_health_acknowledge():
"""Acknowledge/dismiss a health error by error_key.
@@ -14502,7 +14551,7 @@ def api_health_acknowledge():
@app.route('/api/health/un-acknowledge', methods=['POST'])
@require_auth
@require_admin_scope
def api_health_unacknowledge():
"""Reverse a previous dismiss — re-enables the alert so it can fire again.
@@ -20148,7 +20197,11 @@ def _borg_env_for(target: dict, extra: dict | None = None) -> dict:
env['BORG_PASSPHRASE'] = pw
ssh_key = target.get('ssh_key') or ''
if ssh_key:
env['BORG_RSH'] = f'ssh -i {ssh_key} -o StrictHostKeyChecking=accept-new'
# IdentitiesOnly keeps ssh from offering root's default keys first:
# a server that only accepts the ProxMenux key can hit MaxAuthTries
# before it is ever tried. borg adds `-p <port>` from the ssh:// URL.
env['BORG_RSH'] = (f'ssh -i {ssh_key} -o IdentitiesOnly=yes '
'-o StrictHostKeyChecking=accept-new')
# Non-interactive: if borg would prompt about a relocated repo, take
# the safe answer instead of hanging the request.
env['BORG_RELOCATED_REPO_ACCESS_IS_OK'] = 'yes'