feat(oci): GPU selection per host and per image, one notification per update, and App tab for stack containers

- Immich asks what runs its video and its recognition in one menu, in both
  modes, and gives the GPU to the server and to Machine learning; AMD uses ROCm
- Frigate, Ollama, llama.cpp, Faster Whisper and Piper take the image built
  for the chosen GPU
- The acceleration menu offers only what the host can run
- An update or a recreation sends one notification with its result instead of
  the stop, backup and start of each container
- A private bridge with nothing connected is not reported as down
- Secondary containers of a stack appear in the App tab with their version and
  logo; Secure Gateway shows the same update state in both views
- A mistyped value in the wizard asks the same question again
This commit is contained in:
MacRimi
2026-10-02 21:45:38 +02:00
parent 20ee21c08f
commit 9b5cefb81a
55 changed files with 2294 additions and 113 deletions
@@ -1625,6 +1625,54 @@ def internal_shutdown_event():
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
# ─── Internal OCI Event Endpoint ─────────────────────────────────
_OCI_EVENTS = {
'oci_update_completed': 'INFO', 'oci_update_failed': 'WARNING',
'oci_recreate_completed': 'INFO', 'oci_recreate_failed': 'WARNING',
}
@notification_bp.route('/api/internal/oci-event', methods=['POST'])
def internal_oci_event():
"""Called by the OCI engine when an update or a recreation ends, with its
result. Only accepts requests from this host."""
remote_addr = request.remote_addr or ''
try:
import ipaddress
addr = ipaddress.ip_address(remote_addr.split('%')[0])
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
addr = addr.ipv4_mapped
is_loopback = addr.is_loopback
except (ValueError, TypeError):
is_loopback = remote_addr in ('127.0.0.1', '::1', 'localhost')
if not is_loopback:
return jsonify({'error': 'forbidden', 'detail': 'localhost only'}), 403
try:
data = request.get_json(silent=True) or {}
event_type = str(data.get('event') or '')
if event_type not in _OCI_EVENTS:
return jsonify({'error': 'invalid_event_type'}), 400
vmid = str(data.get('vmid') or '')
notification_manager.emit_event(
event_type=event_type,
severity=_OCI_EVENTS[event_type],
data={
'hostname': str(data.get('hostname') or 'unknown'),
'app_name': str(data.get('app_name') or f'CT {vmid}')[:120],
'vmid': vmid,
'containers': str(data.get('containers') or '')[:400],
'reason': str(data.get('reason') or '')[:600],
},
source='proxmenux',
entity='ct',
entity_id=vmid,
)
return jsonify({'success': True, 'event_type': event_type}), 200
except Exception as e:
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
# ─── Internal Restore Event Endpoint ─────────────────────────────
@notification_bp.route('/api/internal/restore-event', methods=['POST'])