diff --git a/Opencode/config/sudoers-jh-photomaton b/Opencode/config/sudoers-jh-photomaton new file mode 100644 index 0000000..90d1c5c --- /dev/null +++ b/Opencode/config/sudoers-jh-photomaton @@ -0,0 +1,18 @@ +# JH Photomaton — droits sudo sans mot de passe pour la gestion des services +# +# INSTALLATION : +# sudo cp sudoers-jh-photomaton /etc/sudoers.d/jh-photomaton +# sudo chmod 440 /etc/sudoers.d/jh-photomaton +# sudo visudo -cf /etc/sudoers.d/jh-photomaton # vérification syntaxe +# +# Ces règles permettent à l'utilisateur "pi" de redémarrer les services +# nécessaires au photomaton ainsi que de rebooter le Pi, sans mot de passe. + +pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart photobooth-app.service +pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart photobooth-kiosk.service +pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart zoraxy.service +pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart hostapd.service +pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart dnsmasq.service +pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart cups.service +pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart jh-photomaton.service +pi ALL=(ALL) NOPASSWD: /usr/sbin/reboot diff --git a/backend/api/system_api.py b/backend/api/system_api.py index bb01483..cde33fa 100644 --- a/backend/api/system_api.py +++ b/backend/api/system_api.py @@ -79,7 +79,6 @@ async def set_delete_button(request: Request, visible: bool): Écrit userdata/private.css puis redémarre photobooth-app pour que le CSS soit pris en compte. """ - import asyncio, subprocess pb = request.app.state.photobooth_service config_svc = request.app.state.config_service @@ -93,7 +92,6 @@ async def set_delete_button(request: Request, visible: bool): async def _restart_photobooth(): - import asyncio await asyncio.sleep(0.5) proc = await asyncio.create_subprocess_exec( "sudo", "systemctl", "restart", "photobooth-app.service", @@ -107,11 +105,71 @@ async def _restart_photobooth(): @router.post("/system/restart-photobooth") async def restart_photobooth(request: Request): """Redémarre photobooth-app.service pour recharger la config (actions, etc.).""" - import asyncio asyncio.create_task(_restart_photobooth()) return {"ok": True, "message": "Redémarrage de photobooth-app en cours…"} +# ── Gestion services & système ──────────────────────────────────────────────── + +@router.get("/system/ping") +async def ping(): + """Endpoint de health-check — utilisé par le polling de reconnexion côté client.""" + return {"ok": True} + + +@router.post("/system/service/{name}/restart") +async def restart_service(name: str): + """Redémarre un service systemd autorisé.""" + if name not in _ALLOWED_SERVICES: + return JSONResponse({"ok": False, "error": f"Service '{name}' non autorisé"}, status_code=403) + asyncio.create_task(_run_systemctl_restart(name)) + return {"ok": True, "message": f"Redémarrage de {name} en cours…"} + + +async def _run_systemctl_restart(name: str): + proc = await asyncio.create_subprocess_exec( + "sudo", "systemctl", "restart", f"{name}.service", + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + logger.info("%s redémarré (exit %d)", name, proc.returncode) + + +@router.post("/system/restart-self") +async def restart_self(): + """Redémarre le service jh-photomaton lui-même (avec délai pour que la réponse parte d'abord).""" + asyncio.create_task(_restart_self_delayed()) + return {"ok": True, "message": "Redémarrage de JH-Photomaton en cours…"} + + +async def _restart_self_delayed(): + await asyncio.sleep(1.5) + proc = await asyncio.create_subprocess_exec( + "sudo", "systemctl", "restart", "jh-photomaton.service", + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + + +@router.post("/system/reboot") +async def reboot_pi(): + """Redémarre le Raspberry Pi (avec délai pour que la réponse parte d'abord).""" + asyncio.create_task(_reboot_delayed()) + return {"ok": True, "message": "Redémarrage du Pi en cours…"} + + +async def _reboot_delayed(): + await asyncio.sleep(2) + proc = await asyncio.create_subprocess_exec( + "sudo", "reboot", + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + + @router.post("/system/screen/refresh") async def screen_refresh(request: Request): """Envoie F5 à l'écran HDMI connecté (Chromium kiosque Wayland/X11).""" diff --git a/frontend/templates/admin/dashboard.html b/frontend/templates/admin/dashboard.html index a599700..f8fafb9 100644 --- a/frontend/templates/admin/dashboard.html +++ b/frontend/templates/admin/dashboard.html @@ -142,12 +142,19 @@
Services
- + {% for svc in services %} + {% endfor %} @@ -155,7 +162,24 @@ + +
+
Actions système
+
+ + +
+
+ + + + + {% endblock %} {% block scripts %} @@ -324,6 +348,50 @@ async function saveEvent(isNew) { } catch(err) { showToast('Erreur sauvegarde événement', 'error'); } } +// ── Restart services ───────────────────────────────────────────────────────── +async function restartService(name, label) { + if (!confirm(`Redémarrer ${label} ?\nLe service sera temporairement indisponible.`)) return; + try { + await api('POST', `/api/system/service/${name}/restart`); + showToast(`🔄 ${label} redémarre…`, 'info'); + } catch(e) { showToast('Erreur restart ' + name, 'error'); } +} + +async function restartSelf() { + if (!confirm('Redémarrer JH-Photomaton ?\nVous serez déconnecté quelques secondes.')) return; + try { + await api('POST', '/api/system/restart-self'); + showReconnectOverlay('Redémarrage de JH-Photomaton…'); + } catch(e) { showToast('Erreur restart', 'error'); } +} + +async function rebootPi() { + if (!confirm('⚡ ATTENTION : Redémarrer le Raspberry Pi ?\n\nTout sera arrêté (photobooth, WiFi, impression).\nLe démarrage prend environ 30 secondes.\n\nConfirmer le redémarrage ?')) return; + try { + await api('POST', '/api/system/reboot'); + showReconnectOverlay('Redémarrage du Raspberry Pi…'); + } catch(e) { showToast('Erreur reboot', 'error'); } +} + +function showReconnectOverlay(msg) { + const overlay = document.getElementById('reconnect-overlay'); + document.getElementById('reconnect-msg').textContent = msg; + overlay.style.display = 'flex'; + // Attendre 5s puis commencer à sonder /api/system/ping + setTimeout(pollReconnect, 5000); +} + +async function pollReconnect() { + try { + const r = await fetch('/api/system/ping'); + if (r.ok) { + window.location.reload(); + return; + } + } catch(e) { /* serveur pas encore là */ } + setTimeout(pollReconnect, 2000); +} + // ── Init ────────────────────────────────────────────────────────────────────── loadQueue(); loadEvent();
ServiceStatut
ServiceStatutAction
{{ svc.label }} {{ 'Actif' if svc.active else 'Arrêté' }} + {% if svc.name == 'jh-photomaton' %} + + {% else %} + + {% endif %} +