v2.1 - Small improvement
This commit is contained in:
+431
@@ -0,0 +1,431 @@
|
||||
"""Authenticated local configuration and health UI for Alfred.
|
||||
|
||||
Registration is additive and doesn't replace the existing audio, camera, HA,
|
||||
weather or Hermes endpoints. Passwords are provisioned locally via CLI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import requests
|
||||
from dotenv import dotenv_values
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request, session
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
AUTH_FILE = ROOT / 'config' / 'admin_auth.json'
|
||||
GENERAL_ENV = ROOT / 'config' / 'control_center.env'
|
||||
WEATHER_ENV = ROOT / 'config' / 'weather_outfits.env'
|
||||
CONFIG_LOCK = threading.RLock()
|
||||
LOGIN_LOCK = threading.Lock()
|
||||
LOGIN_ATTEMPTS = {}
|
||||
|
||||
# Only these keys are writable; no token, command, system file or shell input.
|
||||
FIELDS = {
|
||||
'ALFRED_NAME': ('general', 'text'),
|
||||
'WAKE_WORD': ('general', 'wake'),
|
||||
'PIPER_VOICE': ('general', 'voice'),
|
||||
'ALFRED_OLLAMA_URL': ('general', 'url'),
|
||||
'ALFRED_COMFYUI_URL': ('weather', 'url'),
|
||||
'HA_WEATHER_ENTITY': ('general', 'entity'),
|
||||
'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': ('weather', 'attempts'),
|
||||
'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': ('weather', 'score'),
|
||||
'ALFRED_WEATHER_DISPLAY_SIZE': ('weather', 'size'),
|
||||
'ALFRED_WEATHER_REVIEW': ('weather', 'boolean'),
|
||||
'ALFRED_WEATHER_REFERENCE_MODE': ('weather', 'reference_mode'),
|
||||
'ALFRED_WEATHER_REFERENCE_DENOISE': ('weather', 'denoise'),
|
||||
'ALFRED_WEATHER_PRESERVE_FACE': ('weather', 'boolean'),
|
||||
'ALFRED_WEATHER_ACCESSORY_LIMIT': ('weather', 'accessory'),
|
||||
}
|
||||
DEFAULTS = {
|
||||
'ALFRED_NAME': 'Alfred', 'WAKE_WORD': 'alfred',
|
||||
'PIPER_VOICE': 'fr_FR-tom-medium', 'ALFRED_OLLAMA_URL': '',
|
||||
'ALFRED_COMFYUI_URL': '', 'HA_WEATHER_ENTITY': '',
|
||||
'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': '2',
|
||||
'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': '8.6',
|
||||
'ALFRED_WEATHER_DISPLAY_SIZE': '350',
|
||||
'ALFRED_WEATHER_REVIEW': 'true',
|
||||
'ALFRED_WEATHER_REFERENCE_MODE': 'auto',
|
||||
'ALFRED_WEATHER_REFERENCE_DENOISE': '0.56',
|
||||
'ALFRED_WEATHER_PRESERVE_FACE': 'true',
|
||||
'ALFRED_WEATHER_ACCESSORY_LIMIT': '1',
|
||||
}
|
||||
|
||||
|
||||
def _auth_config():
|
||||
try:
|
||||
return json.loads(AUTH_FILE.read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _authenticated():
|
||||
config = _auth_config()
|
||||
password_hash = config.get('password_hash', '')
|
||||
if not password_hash:
|
||||
return False
|
||||
# Changing the admin password instantly invalidates old signed sessions.
|
||||
expected_version = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24]
|
||||
return bool(session.get('alfred_admin') and
|
||||
hmac.compare_digest(str(session.get('auth_version', '')), expected_version))
|
||||
|
||||
|
||||
def _valid_csrf():
|
||||
token = request.headers.get('X-Alfred-CSRF', '')
|
||||
expected = session.get('alfred_csrf', '')
|
||||
return bool(token and expected and hmac.compare_digest(token, expected))
|
||||
|
||||
|
||||
def _trusted_request():
|
||||
origin = request.headers.get('Origin', '')
|
||||
if origin:
|
||||
parsed = urlsplit(origin)
|
||||
if parsed.netloc.lower() != request.host.lower() or parsed.scheme not in ('http', 'https'):
|
||||
return False
|
||||
if request.headers.get('Sec-Fetch-Site', '').lower() == 'cross-site':
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _require_admin(handler):
|
||||
@functools.wraps(handler)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not _authenticated():
|
||||
return jsonify(error='Authentification administrateur nécessaire.'), 401
|
||||
if request.method != 'GET' and (not _valid_csrf() or not _trusted_request()):
|
||||
return jsonify(error='Protection anti-CSRF : requête refusée.'), 403
|
||||
return handler(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
def _validate(key, value):
|
||||
if key not in FIELDS:
|
||||
raise ValueError(f'Paramètre non autorisé : {key}.')
|
||||
kind = FIELDS[key][1]
|
||||
if kind == 'boolean':
|
||||
if value not in (True, False, 'true', 'false'):
|
||||
raise ValueError(f'{key} doit être vrai ou faux.')
|
||||
return 'true' if value in (True, 'true') else 'false'
|
||||
if isinstance(value, bool):
|
||||
raise ValueError(f'Valeur invalide pour {key}.')
|
||||
value = str(value).strip()
|
||||
if any(c in value for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')):
|
||||
raise ValueError(f'Caractères interdits pour {key}.')
|
||||
if kind == 'text':
|
||||
if not 2 <= len(value) <= 30:
|
||||
raise ValueError('Le nom doit contenir entre 2 et 30 caractères.')
|
||||
elif kind == 'wake':
|
||||
if not re.fullmatch(r'[A-Za-zÀ-ÖØ-öø-ÿ]{2,22}', value):
|
||||
raise ValueError('Le mot-clé Vosk doit être un mot simple de 2 à 22 lettres.')
|
||||
value = value.lower()
|
||||
elif kind == 'voice':
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.-]{2,96}', value):
|
||||
raise ValueError('Identifiant de voix Piper invalide.')
|
||||
elif kind == 'url':
|
||||
if not value:
|
||||
return ''
|
||||
parsed = urlsplit(value)
|
||||
if (parsed.scheme not in ('http', 'https') or not parsed.hostname or
|
||||
parsed.username or parsed.password or parsed.path not in ('', '/') or
|
||||
parsed.query or parsed.fragment):
|
||||
raise ValueError(f'{key} attend une URL http(s) de serveur sans chemin ni identifiants.')
|
||||
if parsed.port and not 1 <= parsed.port <= 65535:
|
||||
raise ValueError('Port réseau invalide.')
|
||||
value = value.rstrip('/')
|
||||
elif kind == 'entity':
|
||||
if value and not re.fullmatch(r'weather\.[a-z0-9_]{2,80}', value):
|
||||
raise ValueError('Attendu : weather.identifiant ou une valeur vide.')
|
||||
elif kind == 'attempts':
|
||||
if value not in ('1', '2', '3'):
|
||||
raise ValueError('Nombre d’essais autorisé : 1, 2 ou 3 par personnage.')
|
||||
elif kind == 'score':
|
||||
value = str(float(value))
|
||||
if not 1 <= float(value) <= 10:
|
||||
raise ValueError('Score entre 1 et 10.')
|
||||
elif kind == 'size':
|
||||
if value not in ('250', '300', '350', '400', '450', '500'):
|
||||
raise ValueError('Taille autorisée : 250, 300, 350, 400, 450 ou 500 px.')
|
||||
elif kind == 'denoise':
|
||||
value = str(float(value))
|
||||
if not 0.25 <= float(value) <= 0.85:
|
||||
raise ValueError('Denoise entre 0.25 et 0.85.')
|
||||
elif kind == 'reference_mode':
|
||||
if value not in ('auto', 'required', 'off'):
|
||||
raise ValueError('Mode référence : auto, required ou off.')
|
||||
elif kind == 'accessory':
|
||||
if value not in ('0', '1', '2'):
|
||||
raise ValueError('Accessoires visibles autorisés : 0, 1 ou 2.')
|
||||
return value
|
||||
|
||||
|
||||
def _read_config():
|
||||
local = dict(dotenv_values(GENERAL_ENV)) if GENERAL_ENV.exists() else {}
|
||||
weather = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {}
|
||||
effective = {}
|
||||
for key, (group, _) in FIELDS.items():
|
||||
env_file = local if group == 'general' else weather
|
||||
# File values take precedence on the next restart. Show saved values.
|
||||
effective[key] = str(env_file.get(key) if env_file.get(key) is not None
|
||||
else os.getenv(key, DEFAULTS[key]))
|
||||
return effective
|
||||
|
||||
|
||||
def _save_env(path: Path, changes: dict):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = path.read_text(encoding='utf-8') if path.exists() else ''
|
||||
lines = existing.splitlines()
|
||||
updated = set()
|
||||
result = []
|
||||
for line in lines:
|
||||
match = re.match(r'^\s*([A-Z][A-Z0-9_]*)\s*=', line)
|
||||
if match and match.group(1) in changes:
|
||||
name = match.group(1)
|
||||
if name not in updated:
|
||||
result.append(f'{name}={changes[name]}')
|
||||
updated.add(name)
|
||||
else:
|
||||
result.append(line)
|
||||
for key, value in changes.items():
|
||||
if key not in updated:
|
||||
result.append(f'{key}={value}')
|
||||
tmp = path.with_name(path.name + f'.{secrets.token_hex(4)}.tmp')
|
||||
try:
|
||||
with tmp.open('w', encoding='utf-8') as fp:
|
||||
fp.write('\n'.join(result).rstrip() + '\n')
|
||||
fp.flush()
|
||||
os.fsync(fp.fileno())
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(path)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _probe_http(url, *, token=None, timeout=2):
|
||||
start = time.monotonic()
|
||||
try:
|
||||
response = requests.get(url, headers={'Authorization': f'Bearer {token}'} if token else {},
|
||||
timeout=timeout, allow_redirects=False)
|
||||
if 200 <= response.status_code < 300:
|
||||
state = 'ok'
|
||||
detail = f'HTTP {response.status_code}'
|
||||
elif response.status_code in (401, 403):
|
||||
state, detail = 'warning', f'Connexion établie, accès refusé ({response.status_code})'
|
||||
else:
|
||||
state, detail = 'warning', f'HTTP {response.status_code}'
|
||||
except requests.RequestException:
|
||||
state, detail = 'down', 'Aucune réponse HTTP'
|
||||
return {'state': state, 'detail': detail, 'latency_ms': round((time.monotonic()-start)*1000)}
|
||||
|
||||
|
||||
def _probe_tcp(host, port, timeout=1.5):
|
||||
try:
|
||||
with socket.create_connection((host, int(port)), timeout=timeout):
|
||||
return {'state': 'ok', 'detail': f'Port {port} accessible'}
|
||||
except (OSError, ValueError):
|
||||
return {'state': 'down', 'detail': f'Port {port} inaccessible'}
|
||||
|
||||
|
||||
def _weather_comfy_url():
|
||||
# Respect the V5 precedence if possible, without reaching into credentials.
|
||||
settings = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {}
|
||||
return (_read_config()['ALFRED_COMFYUI_URL'] or settings.get('ALFRED_COMFYUI_URL') or
|
||||
os.getenv('ALFRED_COMFYUI_URL') or os.getenv('COMFYUI_URL') or
|
||||
dict(dotenv_values('/opt/hermes/MCP_comfyui/.env')).get('COMFYUI_URL') or '')
|
||||
|
||||
|
||||
def _health(wake, audio_settings):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
audio = audio_settings.get_settings()
|
||||
try:
|
||||
wake_state = wake.status()
|
||||
except Exception:
|
||||
wake_state = {'running': False, 'paused': True, 'error': 'Impossible de lire Vosk'}
|
||||
cam_index = int(os.getenv('CAMERA_INDEX', '0'))
|
||||
camera_path = Path(f'/dev/video{cam_index}')
|
||||
camera_ok = camera_path.exists() and os.access(camera_path, os.R_OK)
|
||||
# Checking /dev/videoN doesn't open the device: no conflict with facial recognition.
|
||||
camera = {'state':'ok' if camera_ok else 'warning',
|
||||
'detail':f'{camera_path} détectée' if camera_ok else f'{camera_path} indisponible ou permissions insuffisantes'}
|
||||
mute_micro = audio.get('mic_muted', False)
|
||||
microphone = {'state':'muted' if mute_micro else ('ok' if wake_state.get('running') else 'warning'),
|
||||
'detail':'Micro coupé volontairement' if mute_micro else ('Détection Vosk active' if wake_state.get('running') and not wake_state.get('paused') else 'Vosk non disponible ou en pause')}
|
||||
loudspeaker = {'state':'muted' if audio.get('speaker_muted') else 'ok',
|
||||
'detail':'Son coupé' if audio.get('speaker_muted') else f"Volume {audio.get('volume', '?')} %"}
|
||||
|
||||
config = _read_config()
|
||||
comfy = _weather_comfy_url()
|
||||
ollama = config.get('ALFRED_OLLAMA_URL') or os.getenv('OLLAMA_HOST') or os.getenv('OLLAMA_URL')
|
||||
ha_base = os.getenv('HA_URL', '').rstrip('/')
|
||||
tts_host = os.getenv('PIPER_HOST', '').strip()
|
||||
tts_port = os.getenv('PIPER_PORT', '10200')
|
||||
services = {'alfred': {'state':'ok', 'detail':'Serveur Flask actif'},
|
||||
'hermes_cli': {'state':'ok' if shutil.which('hermes') else 'warning',
|
||||
'detail':'CLI détectée' if shutil.which('hermes') else 'CLI non détectée dans le PATH du service'}}
|
||||
services['hermes_dashboard'] = _probe_tcp('127.0.0.1', 9119)
|
||||
services['hermes_dashboard']['label'] = 'Tableau de bord (optionnel)'
|
||||
services['comfyui'] = _probe_http(comfy.rstrip('/') + '/system_stats') if comfy else {'state':'unknown','detail':'URL non configurée'}
|
||||
services['ollama'] = _probe_http(ollama.rstrip('/') + '/api/tags') if ollama else {'state':'unknown','detail':'URL non configurée'}
|
||||
services['home_assistant'] = _probe_http(ha_base + '/api/', token=os.getenv('HA_TOKEN')) if ha_base else {'state':'unknown','detail':'URL non configurée'}
|
||||
services['wyoming_piper'] = _probe_tcp(tts_host, tts_port) if tts_host else {'state':'unknown','detail':'Wyoming Piper non configuré (Piper local possible)'}
|
||||
|
||||
mcp_path = Path('/opt/hermes')
|
||||
mcps = {}
|
||||
for slug, label in [('MCP_home_assistant','Home Assistant'), ('MCP_comfyui','ComfyUI'), ('MCP_face_recognition','Reconnaissance faciale'),('MCP_wazuh','Wazuh')]:
|
||||
found = (mcp_path / slug).exists()
|
||||
mcps[slug] = {'state':'ok' if found else 'unknown', 'detail':'Dossier présent (connexion non vérifiée)' if found else 'Dossier absent', 'label':label}
|
||||
|
||||
disk = shutil.disk_usage(ROOT)
|
||||
mem = {}
|
||||
try:
|
||||
info = {}
|
||||
for line in Path('/proc/meminfo').read_text().splitlines():
|
||||
k, _, v = line.partition(':')
|
||||
if k in ('MemTotal','MemAvailable'):
|
||||
info[k] = int(v.strip().split()[0]) * 1024
|
||||
if info.get('MemTotal'):
|
||||
mem = {'total': info['MemTotal'], 'used': info['MemTotal'] - info.get('MemAvailable', 0)}
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return {'time': now, 'audio':audio, 'wake':wake_state, 'camera':camera,
|
||||
'microphone':microphone, 'speaker':loudspeaker, 'services':services, 'mcps':mcps,
|
||||
'system':{'disk_total':disk.total,'disk_used':disk.used, 'memory':mem,
|
||||
'load_1m':round(os.getloadavg()[0], 2) if hasattr(os,'getloadavg') else None}}
|
||||
|
||||
|
||||
def register_admin_console(app, wake, audio_settings):
|
||||
"""Register pages/API on the existing Flask app. Call exactly once."""
|
||||
secret_file = ROOT / 'config' / 'admin_secret.key'
|
||||
if not secret_file.exists():
|
||||
raise RuntimeError('Secret administrateur absent ; exécuter install_patch.py.')
|
||||
app.secret_key = secret_file.read_bytes()
|
||||
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Strict',
|
||||
SESSION_COOKIE_SECURE=os.getenv('ALFRED_ADMIN_HTTPS', 'false').lower() == 'true',
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(hours=8))
|
||||
|
||||
bp = Blueprint('alfred_admin', __name__)
|
||||
|
||||
@bp.get('/config')
|
||||
@bp.get('/config.html')
|
||||
def config_page():
|
||||
return render_template('config.html')
|
||||
|
||||
@bp.get('/surveillance')
|
||||
@bp.get('/surveillance.html')
|
||||
def surveillance_page():
|
||||
return render_template('surveillance.html')
|
||||
|
||||
@bp.get('/api/control/auth')
|
||||
def auth_status():
|
||||
authed = _authenticated()
|
||||
return jsonify(authenticated=authed, configured=bool(_auth_config().get('password_hash')),
|
||||
csrf=session.get('alfred_csrf') if authed else None)
|
||||
|
||||
@bp.post('/api/control/login')
|
||||
def login():
|
||||
if not _trusted_request() or not request.is_json:
|
||||
return jsonify(error='Requête refusée.'), 403
|
||||
data = request.get_json(silent=True) or {}
|
||||
password = data.get('password', '')
|
||||
if not isinstance(password, str) or len(password) > 500:
|
||||
return jsonify(error='Mot de passe incorrect.'), 400
|
||||
now = time.monotonic()
|
||||
client = request.remote_addr or 'unknown'
|
||||
with LOGIN_LOCK:
|
||||
events = [x for x in LOGIN_ATTEMPTS.get(client, []) if x > now - 900]
|
||||
LOGIN_ATTEMPTS[client] = events
|
||||
if len(events) >= 5:
|
||||
return jsonify(error='Trop de tentatives : réessaie plus tard.'), 429
|
||||
config = _auth_config()
|
||||
password_hash = config.get('password_hash')
|
||||
if not password_hash:
|
||||
return jsonify(error='Définis d’abord le mot de passe avec set_admin_password.py.'), 503
|
||||
if not check_password_hash(password_hash, password):
|
||||
with LOGIN_LOCK:
|
||||
LOGIN_ATTEMPTS[client].append(now)
|
||||
return jsonify(error='Mot de passe incorrect.'), 401
|
||||
with LOGIN_LOCK:
|
||||
LOGIN_ATTEMPTS.pop(client, None)
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session['alfred_admin'] = True
|
||||
session['auth_version'] = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24]
|
||||
session['alfred_csrf'] = secrets.token_urlsafe(32)
|
||||
return jsonify(authenticated=True, csrf=session['alfred_csrf'])
|
||||
|
||||
@bp.post('/api/control/logout')
|
||||
@_require_admin
|
||||
def logout():
|
||||
session.clear()
|
||||
return jsonify(ok=True)
|
||||
|
||||
@bp.get('/api/control/config')
|
||||
@_require_admin
|
||||
def config_get():
|
||||
return jsonify(values=_read_config(), note='La plupart des réglages nécessitent un redémarrage.')
|
||||
|
||||
@bp.post('/api/control/config')
|
||||
@_require_admin
|
||||
def config_update():
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict) or len(data) > len(FIELDS):
|
||||
return jsonify(error='JSON invalide.'), 400
|
||||
try:
|
||||
normalized = {key: _validate(key, value) for key, value in data.items()}
|
||||
except (ValueError, TypeError, OverflowError) as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
general = {k:v for k,v in normalized.items() if FIELDS[k][0]=='general'}
|
||||
weather = {k:v for k,v in normalized.items() if FIELDS[k][0]=='weather'}
|
||||
try:
|
||||
with CONFIG_LOCK:
|
||||
if general: _save_env(GENERAL_ENV, general)
|
||||
if weather: _save_env(WEATHER_ENV, weather)
|
||||
except OSError as exc:
|
||||
return jsonify(error=f'Impossible d’enregistrer : {exc}'), 500
|
||||
return jsonify(ok=True, restart_required=True, values=_read_config())
|
||||
|
||||
@bp.get('/api/control/health')
|
||||
@_require_admin
|
||||
def health():
|
||||
return jsonify(_health(wake, audio_settings))
|
||||
|
||||
@bp.post('/api/control/restart')
|
||||
@_require_admin
|
||||
def restart():
|
||||
if os.getenv('ALFRED_ALLOW_WEB_RESTART', 'false').lower() not in ('true', '1', 'yes'):
|
||||
return jsonify(error='Redémarrage Web désactivé. Activer ALFRED_ALLOW_WEB_RESTART=true (voir README).'), 403
|
||||
systemctl = shutil.which('systemctl')
|
||||
runner = shutil.which('systemd-run')
|
||||
if not systemctl or not runner:
|
||||
return jsonify(error='systemd-run ou systemctl absent. Redémarrer depuis le terminal.'), 503
|
||||
# Schedule restart in a DIFFERENT systemd unit/cgroup so stopping Alfred
|
||||
# cannot kill the restart command mid-flight.
|
||||
command = [runner, '--unit=alfred-web-restart', '--on-active=2s', '--collect',
|
||||
systemctl, 'restart', 'alfred.service']
|
||||
if os.geteuid() != 0:
|
||||
command = ['sudo', '-n', *command]
|
||||
def do_restart():
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=12, check=False)
|
||||
if result.returncode:
|
||||
app.logger.error('Planification du redémarrage refusée (%s): %s',
|
||||
result.returncode, result.stderr[:350])
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
app.logger.error('Planification du redémarrage échouée: %s', exc)
|
||||
threading.Thread(target=do_restart, daemon=True).start()
|
||||
return jsonify(accepted=True, message='Demande de redémarrage transmise ; contrôler le retour du service.') , 202
|
||||
|
||||
app.register_blueprint(bp)
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
const $ = id => document.getElementById(id);
|
||||
const page = document.body.dataset.page;
|
||||
const state = {csrf: null, logged: false, baseline: {}};
|
||||
const fields = [...document.querySelectorAll('[data-setting]')];
|
||||
let polling = null;
|
||||
const fetchJSON = async (url, options = {}) => {
|
||||
const headers = { ...(options.headers || {}) };
|
||||
if (options.body) headers['Content-Type'] = 'application/json';
|
||||
if (options.method && options.method !== 'GET' && state.csrf) headers['X-Alfred-CSRF'] = state.csrf;
|
||||
const response = await fetch(url, {...options, credentials: 'same-origin', cache:'no-store', headers});
|
||||
let data;
|
||||
try { data = await response.json(); } catch { data = {}; }
|
||||
if (response.status === 401 && url !== '/api/control/login') { showLogin(); throw new Error('Session expirée. Reconnecte-toi.'); }
|
||||
if (!response.ok) throw new Error(data.error || 'Erreur HTTP '+response.status);
|
||||
return data;
|
||||
};
|
||||
const setMessage = (message, bad=false) => {
|
||||
const el = $('message'); if (!el) return;
|
||||
el.textContent = message; el.style.color = bad ? '#f1aaaa' : '#a1dcbe';
|
||||
};
|
||||
const showLogin = () => {
|
||||
state.logged = false; $('mainArea').hidden = true; $('authScreen').hidden = false;
|
||||
if (polling) clearInterval(polling); polling = null;
|
||||
};
|
||||
const showPage = () => { state.logged=true; $('mainArea').hidden=false; $('authScreen').hidden=true; };
|
||||
const getFieldValue = el => el.type === 'checkbox' ? el.checked : el.value;
|
||||
function configDirty() {
|
||||
const changed = fields.some(el => String(getFieldValue(el)) !== String(state.baseline[el.id] ?? ''));
|
||||
if ($('saveState')) $('saveState').textContent = changed ? 'Modifications non enregistrées' : 'Aucun changement';
|
||||
}
|
||||
function markInput() {
|
||||
if ($('scoreOut')) $('scoreOut').textContent = Number($('ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE').value || 8.6).toFixed(1) + ' / 10';
|
||||
if ($('denoiseOut')) $('denoiseOut').textContent = Number($('ALFRED_WEATHER_REFERENCE_DENOISE').value || .56).toFixed(2);
|
||||
configDirty();
|
||||
}
|
||||
async function loadConfig() {
|
||||
const values = (await fetchJSON('/api/control/config')).values || {};
|
||||
fields.forEach(el => {
|
||||
if (el.type==='checkbox') el.checked = String(values[el.id]).toLowerCase() === 'true';
|
||||
else el.value = values[el.id] ?? '';
|
||||
state.baseline[el.id] = getFieldValue(el);
|
||||
});
|
||||
markInput();
|
||||
}
|
||||
async function saveConfig() {
|
||||
const values = {};
|
||||
fields.forEach(el => { values[el.id] = getFieldValue(el); });
|
||||
try {
|
||||
const result = await fetchJSON('/api/control/config', {method:'POST', body:JSON.stringify(values)});
|
||||
state.baseline = {};
|
||||
fields.forEach(el => state.baseline[el.id] = getFieldValue(el));
|
||||
configDirty();
|
||||
$('pendingNotice').hidden = false;
|
||||
setMessage(result.ok ? 'Configuration sauvegardée.' : 'Enregistrement inconnu.');
|
||||
} catch (error) { setMessage(error.message, true); }
|
||||
}
|
||||
function serviceStatus(el, data) {
|
||||
if (!el) return;
|
||||
const labels = {ok:'Disponible',down:'Indisponible',warning:'Attention',muted:'Coupé',unknown:'Non vérifié'};
|
||||
el.dataset.state = data.state || 'unknown';
|
||||
el.textContent = labels[data.state] || 'Inconnu';
|
||||
}
|
||||
const safeText = (id, value) => { if ($(id)) $(id).textContent = String(value ?? '—'); };
|
||||
const readable = value => {
|
||||
const x = Number(value) || 0;
|
||||
if (x >= 1073741824) return (x / 1073741824).toFixed(1) + ' Go';
|
||||
return (x / 1048576).toFixed(1) + ' Mo';
|
||||
};
|
||||
function renderHealth(data) {
|
||||
const timestamp = new Date(data.time).toLocaleTimeString('fr-FR', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||||
safeText('updatedAt', timestamp);
|
||||
serviceStatus($('cameraStatus'), data.camera); safeText('cameraDetail',data.camera?.detail);
|
||||
serviceStatus($('microStatus'), data.microphone); safeText('microDetail',data.microphone?.detail);
|
||||
serviceStatus($('speakerStatus'), data.speaker); safeText('speakerDetail',data.speaker?.detail);
|
||||
const wake = data.wake || {};
|
||||
serviceStatus($('wakeStatus'), {state:!wake.running?'down':wake.paused?'warning':'ok'});
|
||||
safeText('wakeLabel', `Vosk · ${wake.word || 'alfred'}`);
|
||||
safeText('wakeDetail', wake.error || (wake.paused ? 'Détection en pause' : 'Détection active'));
|
||||
const labels = {alfred:'Alfred / Flask', hermes_cli:'Hermes Agent (CLI)', hermes_dashboard:'Hermes WebUI', comfyui:'ComfyUI', ollama:'Ollama', home_assistant:'Home Assistant', wyoming_piper:'Wyoming Piper'};
|
||||
const list = $('serviceList'); list.replaceChildren();
|
||||
for (const [key,service] of Object.entries(data.services || {})) {
|
||||
const row = document.createElement('div'); row.className='service-row';
|
||||
const desc = document.createElement('div');
|
||||
const title=document.createElement('div');title.className='service-label';title.textContent=labels[key] || key;
|
||||
const sub=document.createElement('div');sub.className='service-sub';sub.textContent=service.detail || '';
|
||||
desc.append(title,sub);
|
||||
const aside=document.createElement('div');aside.className='service-tail';
|
||||
if (service.latency_ms != null) {const delay=document.createElement('span');delay.className='muted microtext';delay.textContent=service.latency_ms+' ms';aside.append(delay);}
|
||||
const status=document.createElement('span');status.className='status-pill';serviceStatus(status,service);aside.append(status);
|
||||
row.append(desc,aside);list.append(row);
|
||||
}
|
||||
const sys = data.system || {};
|
||||
const diskPercent = sys.disk_total ? Math.round(100*sys.disk_used/sys.disk_total) : 0;
|
||||
safeText('diskPercent', diskPercent+' %');$('diskBar').style.width=diskPercent+'%';
|
||||
safeText('diskText', readable(sys.disk_used)+' utilisés sur '+readable(sys.disk_total));
|
||||
const mem=sys.memory || {};
|
||||
const memPercent=mem.total ? Math.round(100*mem.used/mem.total) : 0;
|
||||
safeText('memPercent',mem.total ? memPercent+' %' : 'N/D');$('memBar').style.width=memPercent+'%';
|
||||
safeText('memText',mem.total ? readable(mem.used)+' utilisés sur '+readable(mem.total) : 'Non disponible');
|
||||
safeText('loadAvg',sys.load_1m);
|
||||
const mcps=$('mcpList');mcps.replaceChildren();
|
||||
for (const mcp of Object.values(data.mcps || {})) {
|
||||
const card=document.createElement('div');card.className='mcp-item';
|
||||
const title=document.createElement('strong');title.textContent=mcp.label || 'MCP';
|
||||
const status=document.createElement('span');status.className='status-pill';serviceStatus(status,mcp);
|
||||
const p=document.createElement('p');p.textContent=mcp.detail;
|
||||
card.append(title,status,p);mcps.append(card);
|
||||
}
|
||||
}
|
||||
let inFlight = false;
|
||||
async function refreshHealth() {
|
||||
if (inFlight || document.hidden) return;
|
||||
inFlight = true;
|
||||
try { renderHealth(await fetchJSON('/api/control/health'));setMessage(''); }
|
||||
catch(err){setMessage('Surveillance : '+err.message,true);}
|
||||
finally{inFlight=false;}
|
||||
}
|
||||
async function loadAudio() {
|
||||
try {
|
||||
const d=await fetchJSON('/api/audio/settings');
|
||||
$('liveVolume').value=d.volume;$('liveVolumeOut').textContent=d.volume+' %';
|
||||
$('liveMic').disabled = d.mic_gain == null;
|
||||
if(d.mic_gain != null)$('liveMic').value=d.mic_gain;
|
||||
$('liveMicOut').textContent=d.mic_gain == null ? 'N/D' : d.mic_gain+' %';
|
||||
$('muteMicro').checked=Boolean(d.mic_muted);$('muteSpeaker').checked=Boolean(d.speaker_muted);
|
||||
} catch(err){safeText('audioResult',err.message);}
|
||||
}
|
||||
async function updateAudio(values) {
|
||||
try {
|
||||
const d=await fetchJSON('/api/audio/settings', {method:'POST',body:JSON.stringify(values)});
|
||||
safeText('audioResult','Réglage appliqué.');
|
||||
$('liveVolume').value=d.volume;$('liveVolumeOut').textContent=d.volume+' %';
|
||||
$('muteMicro').checked=Boolean(d.mic_muted);$('muteSpeaker').checked=Boolean(d.speaker_muted);
|
||||
} catch(err){safeText('audioResult','Erreur : '+err.message);await loadAudio();}
|
||||
}
|
||||
async function onLogin(event) {
|
||||
event.preventDefault();
|
||||
const password=$('adminPassword').value;
|
||||
try {
|
||||
const response=await fetchJSON('/api/control/login',{method:'POST',body:JSON.stringify({password})});
|
||||
state.csrf=response.csrf;$('adminPassword').value='';await enterApp();
|
||||
} catch(err){safeText('loginMessage',err.message);}
|
||||
}
|
||||
async function enterApp() {
|
||||
showPage();
|
||||
if(page==='config'){
|
||||
await Promise.all([loadConfig(),loadAudio()]);
|
||||
}else{
|
||||
await refreshHealth();
|
||||
polling=setInterval(refreshHealth,15000);
|
||||
}
|
||||
}
|
||||
async function init() {
|
||||
$('loginForm')?.addEventListener('submit', onLogin);
|
||||
$('logoutButton')?.addEventListener('click', async ()=>{
|
||||
try{await fetchJSON('/api/control/logout',{method:'POST'});}catch(e){}
|
||||
state.csrf=null;showLogin();
|
||||
});
|
||||
fields.forEach(el=>{el.addEventListener('change',markInput);el.addEventListener('input',markInput);});
|
||||
$('saveConfig')?.addEventListener('click',saveConfig);
|
||||
$('restartButton')?.addEventListener('click',async ()=>{
|
||||
if(!confirm('Redémarrer Alfred maintenant ? Les opérations en cours seront interrompues.'))return;
|
||||
try{const data=await fetchJSON('/api/control/restart',{method:'POST'});setMessage(data.message||'Demande transmise.');$('restartButton').disabled=true;setTimeout(()=>$('restartButton').disabled=false,5000);}
|
||||
catch(err){setMessage(err.message,true);}
|
||||
});
|
||||
$('testVoice')?.addEventListener('click',async()=>{
|
||||
try{await fetchJSON('/api/tts/say',{method:'POST',body:JSON.stringify({text:'Bonjour, je suis Alfred. Voici un test de ma voix actuelle.'})});setMessage('Test vocal demandé.');}
|
||||
catch(err){setMessage(err.message,true);}
|
||||
});
|
||||
let volDelay, gainDelay;
|
||||
$('liveVolume')?.addEventListener('input',e=>{safeText('liveVolumeOut',e.target.value+' %');clearTimeout(volDelay);volDelay=setTimeout(()=>updateAudio({volume:Number(e.target.value)}),250);});
|
||||
$('liveMic')?.addEventListener('input',e=>{safeText('liveMicOut',e.target.value+' %');clearTimeout(gainDelay);gainDelay=setTimeout(()=>updateAudio({mic_gain:Number(e.target.value)}),250);});
|
||||
$('muteMicro')?.addEventListener('change',e=>updateAudio({mic_muted:e.target.checked}));
|
||||
$('muteSpeaker')?.addEventListener('change',e=>updateAudio({speaker_muted:e.target.checked}));
|
||||
$('refreshHealth')?.addEventListener('click',refreshHealth);
|
||||
const login=await fetchJSON('/api/control/auth').catch(()=>({authenticated:false}));
|
||||
if (login.authenticated){state.csrf=login.csrf;try{await enterApp();}catch(err){setMessage(err.message,true);}}
|
||||
else{showLogin();if(!login.configured)safeText('loginMessage','Mot de passe non défini : exécute set_admin_password.py sur Alfred.');}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded',init);
|
||||
})();
|
||||
@@ -0,0 +1,55 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#121b25"><title>Alfred · Configuration</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred-admin.css">
|
||||
<script defer src="/static/js/alfred-admin.js"></script>
|
||||
</head>
|
||||
<body data-page="config">
|
||||
<div class="admin-layout">
|
||||
<header class="admin-header">
|
||||
<div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div>
|
||||
<nav aria-label="Navigation"><a href="/">Accueil</a><a class="selected" href="/config">Configuration</a><a href="/surveillance">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav>
|
||||
</header>
|
||||
<main class="admin-main" id="mainArea" hidden>
|
||||
<div class="page-top"><div><p class="eyebrow">PERSONNALISATION</p><h1>Configuration</h1><p class="muted">Paramètres de l’assistant, voix et services. Les modifications prennent effet après redémarrage.</p></div><div class="page-actions"><span id="saveState" class="tag">Aucun changement</span><button id="saveConfig" class="button primary" type="button">Enregistrer</button></div></div>
|
||||
<section class="notice" id="pendingNotice" hidden>Réglages enregistrés. <strong>Redémarre Alfred</strong> pour les appliquer.</section>
|
||||
<div class="two-col">
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">✦</span><div><h2>Identité et voix</h2><p>Nom affiché, mot d'activation et voix de synthèse</p></div></div>
|
||||
<div class="field"><label for="ALFRED_NAME">Nom de l’assistant</label><input id="ALFRED_NAME" data-setting placeholder="Alfred" maxlength="30"><small>Visible dans l’interface après redémarrage.</small></div>
|
||||
<div class="field"><label for="WAKE_WORD">Mot de réveil (Vosk)</label><input id="WAKE_WORD" data-setting placeholder="alfred" maxlength="22"><small>Un seul mot. Le modèle Vosk doit pouvoir le reconnaître ; le mode OpenWakeWord nécessite un modèle entraîné.</small></div>
|
||||
<div class="field"><label for="PIPER_VOICE">Voix Wyoming Piper</label><input id="PIPER_VOICE" data-setting placeholder="fr_FR-tom-medium"><small>Exemple : fr_FR-tom-medium. La voix doit exister sur ton serveur Wyoming ; pour Piper local, c’est le fichier modèle qui détermine la voix.</small></div>
|
||||
<div class="inline"><button class="button ghost" type="button" id="testVoice">Tester la voix actuelle</button><span class="muted microtext">Utilise les réglages déjà actifs, sans redémarrage</span></div>
|
||||
</section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">☁</span><div><h2>Illustrations météo</h2><p>Qualité, visages de référence et accessoires ComfyUI</p></div></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS">Essais par personnage</label><select id="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS" data-setting><option value="1">1 — Rapide</option><option value="2">2 — Équilibré</option><option value="3">3 — Qualité</option></select></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE">Score qualité cible</label><div class="range-field"><input type="range" min="5" max="10" step="0.1" id="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" data-setting><output for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" id="scoreOut">8.6 / 10</output></div></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_DISPLAY_SIZE">Taille d’affichage</label><select id="ALFRED_WEATHER_DISPLAY_SIZE" data-setting><option value="250">250 px</option><option value="300">300 px</option><option value="350">350 px</option><option value="400">400 px</option><option value="450">450 px</option><option value="500">500 px</option></select></div>
|
||||
<div class="field switch-field"><label for="ALFRED_WEATHER_REVIEW">Analyse automatique des candidats <small>Nécessite un workflow vision compatible</small></label><input id="ALFRED_WEATHER_REVIEW" type="checkbox" data-setting></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_MODE">Références des visages</label><select id="ALFRED_WEATHER_REFERENCE_MODE" data-setting><option value="auto">Auto — avec repli</option><option value="required">Obligatoires</option><option value="off">Désactivées</option></select></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_DENOISE">Liberté de modification des vêtements</label><div class="range-field"><input id="ALFRED_WEATHER_REFERENCE_DENOISE" data-setting type="range" min="0.25" max="0.85" step="0.01"><output id="denoiseOut">0.56</output></div><small>Une valeur élevée modifie davantage la tenue, mais risque de changer le visage.</small></div>
|
||||
<div class="field switch-field"><label for="ALFRED_WEATHER_PRESERVE_FACE">Préserver les visages <small>Lorsque l’image de référence est utilisable</small></label><input id="ALFRED_WEATHER_PRESERVE_FACE" type="checkbox" data-setting></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_ACCESSORY_LIMIT">Accessoires visibles par personnage</label><select id="ALFRED_WEATHER_ACCESSORY_LIMIT" data-setting><option value="0">Aucun</option><option value="1">1 — Recommandé</option><option value="2">2</option></select></div>
|
||||
</section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">♫</span><div><h2>Audio en direct</h2><p>Volume, sensibilité et coupures immédiates</p></div></div>
|
||||
<div class="field"><label for="liveVolume">Volume de sortie</label><div class="range-field"><input id="liveVolume" type="range" min="0" max="100" step="1"><output id="liveVolumeOut">—</output></div></div>
|
||||
<div class="field"><label for="liveMic">Sensibilité micro</label><div class="range-field"><input id="liveMic" type="range" min="0" max="100" step="1"><output id="liveMicOut">—</output></div></div>
|
||||
<div class="field switch-field"><label for="muteMicro">Microphone coupé</label><input id="muteMicro" type="checkbox"></div>
|
||||
<div class="field switch-field"><label for="muteSpeaker">Haut-parleur coupé</label><input id="muteSpeaker" type="checkbox"></div>
|
||||
<p class="muted microtext" id="audioResult">Les réglages audio sont appliqués sans redémarrage.</p>
|
||||
</section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">⌘</span><div><h2>Connexions et météo</h2><p>Adresses des serveurs et source météo</p></div></div>
|
||||
<div class="field"><label for="ALFRED_COMFYUI_URL">Serveur ComfyUI</label><input data-setting id="ALFRED_COMFYUI_URL" placeholder="http://192.168.1.13:8188"><small>Laisse vide pour conserver la configuration Hermes existante.</small></div>
|
||||
<div class="field"><label for="ALFRED_OLLAMA_URL">Serveur Ollama (surveillance)</label><input data-setting id="ALFRED_OLLAMA_URL" placeholder="http://192.168.1.13:11434"><small>Cette adresse sert au contrôle de disponibilité ; Hermes garde sa propre configuration LLM.</small></div>
|
||||
<div class="field"><label for="HA_WEATHER_ENTITY">Entité météo Home Assistant</label><input data-setting id="HA_WEATHER_ENTITY" placeholder="weather.forecast_home"><small>Exemple : weather.forecast_home. Le jeton Home Assistant n’est pas exposé ici.</small></div>
|
||||
<a href="/surveillance" class="button ghost inline-link">Voir la surveillance des services</a>
|
||||
</section>
|
||||
</div>
|
||||
<section class="panel restart-panel"><div><h2>Maintenance</h2><p class="muted">Redémarrer Alfred pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.</p><span class="microtext muted">Le bouton requiert une autorisation systemd explicitement activée.</span></div><button class="button danger" type="button" id="restartButton">Redémarrer Alfred</button></section>
|
||||
<p role="status" aria-live="polite" class="message" id="message"></p>
|
||||
</main>
|
||||
<div class="auth-screen" id="authScreen"><div class="auth-box"><span class="brand-orb big">A</span><h1>Accès administrateur</h1><p>Connecte-toi pour modifier la configuration d'Alfred.</p><form id="loginForm"><label for="adminPassword">Mot de passe</label><input type="password" id="adminPassword" autocomplete="current-password" required><button class="button primary full" type="submit">Se connecter</button></form><p class="message" id="loginMessage" role="status"></p><small>Accès local / réseau privé uniquement.</small></div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#121b25"><title>Alfred · Surveillance</title><link rel="stylesheet" href="/static/css/alfred-admin.css"><script defer src="/static/js/alfred-admin.js"></script></head>
|
||||
<body data-page="surveillance">
|
||||
<div class="admin-layout">
|
||||
<header class="admin-header"><div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div><nav aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance" class="selected">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav></header>
|
||||
<main class="admin-main" id="mainArea" hidden>
|
||||
<div class="page-top"><div><p class="eyebrow">DIAGNOSTIC</p><h1>Surveillance</h1><p class="muted">État des périphériques, de l’assistant et des services locaux.</p></div><div class="page-actions"><span class="tag"><span class="pulse-dot"></span><span id="updatedAt">Chargement…</span></span><button class="button ghost" type="button" id="refreshHealth">Actualiser</button></div></div>
|
||||
<section class="monitor-grid">
|
||||
<article class="panel metric-card" id="cameraCard"><div class="metric-icon">▣</div><div class="metric-info"><span class="microtext muted">CAMÉRA</span><strong>Webcam</strong><span class="status-pill" id="cameraStatus">—</span><p id="cameraDetail" class="muted">Vérification…</p></div></article>
|
||||
<article class="panel metric-card" id="microCard"><div class="metric-icon">◉</div><div class="metric-info"><span class="microtext muted">MICROPHONE</span><strong>Capture audio</strong><span class="status-pill" id="microStatus">—</span><p id="microDetail" class="muted">Vérification…</p></div></article>
|
||||
<article class="panel metric-card" id="speakerCard"><div class="metric-icon">♫</div><div class="metric-info"><span class="microtext muted">HAUT-PARLEUR</span><strong>Sortie audio</strong><span class="status-pill" id="speakerStatus">—</span><p id="speakerDetail" class="muted">Vérification…</p></div></article>
|
||||
<article class="panel metric-card"><div class="metric-icon">◌</div><div class="metric-info"><span class="microtext muted">MOT DE RÉVEIL</span><strong id="wakeLabel">Vosk</strong><span class="status-pill" id="wakeStatus">—</span><p id="wakeDetail" class="muted">Vérification…</p></div></article>
|
||||
</section>
|
||||
<div class="two-col monitor-lower"><section class="panel"><div class="panel-title"><span class="panel-symbol">⇄</span><div><h2>Services et connexions</h2><p>Tests réels : HTTP, port TCP ou présence du binaire</p></div></div><div class="service-list" id="serviceList"><p class="muted">Chargement…</p></div></section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">▤</span><div><h2>Système</h2><p>Ressources du mini-PC hébergeant Alfred</p></div></div><div class="resource"><div class="resource-title"><span>Disque de l’application</span><strong id="diskPercent">—</strong></div><div class="meter"><span id="diskBar"></span></div><p id="diskText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Mémoire RAM</span><strong id="memPercent">—</strong></div><div class="meter"><span id="memBar"></span></div><p id="memText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Charge système (1 min)</span><strong id="loadAvg">—</strong></div></div><div class="info-note">Les contrôles de caméra n'ouvrent pas le flux vidéo, afin de ne pas bloquer la reconnaissance faciale.</div></section></div>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">⚙</span><div><h2>MCP Hermes détectés</h2><p>Présence des répertoires, pas un test de communication des outils MCP</p></div></div><div class="mcp-grid" id="mcpList"><p class="muted">Chargement…</p></div></section>
|
||||
<p role="status" aria-live="polite" class="message" id="message"></p>
|
||||
</main>
|
||||
<div class="auth-screen" id="authScreen"><div class="auth-box"><span class="brand-orb big">A</span><h1>Accès administrateur</h1><p>Connecte-toi pour consulter l'état d'Alfred.</p><form id="loginForm"><label for="adminPassword">Mot de passe</label><input type="password" id="adminPassword" autocomplete="current-password" required><button class="button primary full" type="submit">Se connecter</button></form><p class="message" id="loginMessage" role="status"></p><small>Accès local / réseau privé uniquement.</small></div></div>
|
||||
</div></body></html>
|
||||
+116
@@ -423,5 +423,121 @@
|
||||
},
|
||||
"cached_at": 1793111827.3695734,
|
||||
"focus_label": "samedi 31 octobre"
|
||||
},
|
||||
"bcd2bb01a6eee2d756888ce1272d02f38fe5cdc2c8b94ee40851fe702d6e8e85": {
|
||||
"file": "bcd2bb01a6eee2d756888ce1272d02f38fe5cdc2c8b94ee40851fe702d6e8e85.jpg",
|
||||
"prompt_ids": [
|
||||
"a40affa5-4232-46a2-8ab7-19ca0e731dd4",
|
||||
"930a85ff-aa99-404b-9356-911324ff34e0"
|
||||
],
|
||||
"reviews": {
|
||||
"child": {
|
||||
"score": 0.0,
|
||||
"ok": false,
|
||||
"issues": [
|
||||
"review_unavailable"
|
||||
],
|
||||
"reviewed": false,
|
||||
"reference_applied": true
|
||||
},
|
||||
"adult": {
|
||||
"score": 0.0,
|
||||
"ok": false,
|
||||
"issues": [
|
||||
"review_unavailable"
|
||||
],
|
||||
"reviewed": false,
|
||||
"reference_applied": true
|
||||
}
|
||||
},
|
||||
"cached_at": 1793116044.4125576,
|
||||
"focus_label": "aujourd’hui"
|
||||
},
|
||||
"9384429b2e4292835018049e1c181d50d6cf911bfb587c2b8b0036c2ecb619a1": {
|
||||
"file": "9384429b2e4292835018049e1c181d50d6cf911bfb587c2b8b0036c2ecb619a1.jpg",
|
||||
"prompt_ids": [
|
||||
"dd15bd0c-ac93-4da8-a93c-17810a04237f",
|
||||
"940eda05-2e4c-4c4c-a9fb-29259c907e1b"
|
||||
],
|
||||
"reviews": {
|
||||
"child": {
|
||||
"score": 0.0,
|
||||
"ok": false,
|
||||
"issues": [
|
||||
"review_unavailable"
|
||||
],
|
||||
"reviewed": false,
|
||||
"reference_applied": true
|
||||
},
|
||||
"adult": {
|
||||
"score": 0.0,
|
||||
"ok": false,
|
||||
"issues": [
|
||||
"review_unavailable"
|
||||
],
|
||||
"reviewed": false,
|
||||
"reference_applied": true
|
||||
}
|
||||
},
|
||||
"cached_at": 1793181286.3307507,
|
||||
"focus_label": "aujourd’hui"
|
||||
},
|
||||
"a0e631c42d8a4fc9dc3abad674a6625d32d9fe3b471d7a101a62c4a790a3926e": {
|
||||
"file": "a0e631c42d8a4fc9dc3abad674a6625d32d9fe3b471d7a101a62c4a790a3926e.jpg",
|
||||
"prompt_ids": [
|
||||
"edd9eaf1-9c36-4b5b-b765-f4346fae3699",
|
||||
"e4c7e52d-00b3-4571-a8b8-dd9c6bf3ff96"
|
||||
],
|
||||
"reviews": {
|
||||
"child": {
|
||||
"score": 0.0,
|
||||
"ok": false,
|
||||
"issues": [
|
||||
"review_unavailable"
|
||||
],
|
||||
"reviewed": false,
|
||||
"reference_applied": true
|
||||
},
|
||||
"adult": {
|
||||
"score": 0.0,
|
||||
"ok": false,
|
||||
"issues": [
|
||||
"review_unavailable"
|
||||
],
|
||||
"reviewed": false,
|
||||
"reference_applied": true
|
||||
}
|
||||
},
|
||||
"cached_at": 1793183681.1423311,
|
||||
"focus_label": "aujourd’hui"
|
||||
},
|
||||
"dd262938ddb64632659bedd83dd2d7e9f173b55663a3d155002468deee01a3b4": {
|
||||
"file": "dd262938ddb64632659bedd83dd2d7e9f173b55663a3d155002468deee01a3b4.jpg",
|
||||
"prompt_ids": [
|
||||
"b1ae1d61-d434-409c-8d89-be73382dd328",
|
||||
"f55b483c-eb69-4792-acb4-4c52b687801f"
|
||||
],
|
||||
"reviews": {
|
||||
"child": {
|
||||
"score": 0.0,
|
||||
"ok": false,
|
||||
"issues": [
|
||||
"review_unavailable"
|
||||
],
|
||||
"reviewed": false,
|
||||
"reference_applied": true
|
||||
},
|
||||
"adult": {
|
||||
"score": 0.0,
|
||||
"ok": false,
|
||||
"issues": [
|
||||
"review_unavailable"
|
||||
],
|
||||
"reviewed": false,
|
||||
"reference_applied": true
|
||||
}
|
||||
},
|
||||
"cached_at": 1793186782.712917,
|
||||
"focus_label": "aujourd’hui"
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -63,5 +63,15 @@
|
||||
"created_at": 1793109993.4915156,
|
||||
"state": "finished",
|
||||
"finished_at": 1793110013.7477841
|
||||
},
|
||||
{
|
||||
"id": "a0634d97d4",
|
||||
"label": "Chronomètre",
|
||||
"kind": "stopwatch",
|
||||
"duration": null,
|
||||
"elapsed": 3067.5718233585358,
|
||||
"started_at": null,
|
||||
"created_at": 1793117300.2246997,
|
||||
"state": "cancelled"
|
||||
}
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -65,6 +65,28 @@ DEFAULTS = {
|
||||
}
|
||||
|
||||
|
||||
# ALFRED_ADMIN_SURVEILLANCE_V2 : champs du moteur STT existant.
|
||||
FIELDS.update({
|
||||
'STT_ENGINE': ('general', 'stt_engine'),
|
||||
'WYOMING_WHISPER_HOST': ('general', 'stt_host'),
|
||||
'WYOMING_WHISPER_PORT': ('general', 'stt_port'),
|
||||
'WYOMING_WHISPER_LANGUAGE': ('general', 'stt_language'),
|
||||
'WYOMING_WHISPER_TIMEOUT': ('general', 'stt_timeout'),
|
||||
'WYOMING_WHISPER_CONNECT_TIMEOUT': ('general', 'stt_connect_timeout'),
|
||||
'STT_MAX_AUDIO_SECONDS': ('general', 'stt_max_audio'),
|
||||
})
|
||||
DEFAULTS.update({
|
||||
'STT_ENGINE': 'vosk',
|
||||
'WYOMING_WHISPER_HOST': '192.168.1.13',
|
||||
'WYOMING_WHISPER_PORT': '10300',
|
||||
'WYOMING_WHISPER_LANGUAGE': 'fr',
|
||||
'WYOMING_WHISPER_TIMEOUT': '20',
|
||||
'WYOMING_WHISPER_CONNECT_TIMEOUT': '3',
|
||||
'STT_MAX_AUDIO_SECONDS': '30',
|
||||
})
|
||||
from services.admin_extensions_v2 import extend_health as _admin_v2_extend_health
|
||||
|
||||
|
||||
def _auth_config():
|
||||
try:
|
||||
return json.loads(AUTH_FILE.read_text(encoding='utf-8'))
|
||||
@@ -124,7 +146,35 @@ def _validate(key, value):
|
||||
value = str(value).strip()
|
||||
if any(c in value for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')):
|
||||
raise ValueError(f'Caractères interdits pour {key}.')
|
||||
if kind == 'text':
|
||||
# ALFRED_ADMIN_SURVEILLANCE_V2: stricte validation des paramètres Whisper.
|
||||
if kind == 'stt_engine':
|
||||
if value not in ('vosk', 'wyoming'):
|
||||
raise ValueError('Moteur STT attendu : vosk ou wyoming.')
|
||||
elif kind == 'stt_host':
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.\-]{0,252}', value):
|
||||
raise ValueError('Hôte Whisper invalide : indiquer une IP ou un nom DNS, sans protocole.')
|
||||
elif kind == 'stt_port':
|
||||
if not value.isdecimal() or not 1 <= int(value) <= 65535:
|
||||
raise ValueError('Port Whisper invalide (1 à 65535).')
|
||||
value = str(int(value))
|
||||
elif kind == 'stt_language':
|
||||
if not re.fullmatch(r'[a-z]{2}', value.lower()):
|
||||
raise ValueError('Langue Whisper invalide (exemple : fr).')
|
||||
value = value.lower()
|
||||
elif kind in ('stt_timeout', 'stt_connect_timeout'):
|
||||
try:
|
||||
number = float(value)
|
||||
except ValueError:
|
||||
raise ValueError('Délai Whisper non numérique.')
|
||||
bounds = (5, 90) if kind == 'stt_timeout' else (1, 15)
|
||||
if not bounds[0] <= number <= bounds[1]:
|
||||
raise ValueError(f'Délai Whisper entre {bounds[0]} et {bounds[1]} secondes.')
|
||||
value = str(number).rstrip('0').rstrip('.') if '.' in str(number) else str(number)
|
||||
elif kind == 'stt_max_audio':
|
||||
if not value.isdecimal() or not 5 <= int(value) <= 60:
|
||||
raise ValueError('Enregistrement entre 5 et 60 secondes.')
|
||||
value = str(int(value))
|
||||
elif kind == 'text':
|
||||
if not 2 <= len(value) <= 30:
|
||||
raise ValueError('Le nom doit contenir entre 2 et 30 caractères.')
|
||||
elif kind == 'wake':
|
||||
@@ -299,10 +349,11 @@ def _health(wake, audio_settings):
|
||||
mem = {'total': info['MemTotal'], 'used': info['MemTotal'] - info.get('MemAvailable', 0)}
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return {'time': now, 'audio':audio, 'wake':wake_state, 'camera':camera,
|
||||
result = {'time': now, 'audio':audio, 'wake':wake_state, 'camera':camera,
|
||||
'microphone':microphone, 'speaker':loudspeaker, 'services':services, 'mcps':mcps,
|
||||
'system':{'disk_total':disk.total,'disk_used':disk.used, 'memory':mem,
|
||||
'load_1m':round(os.getloadavg()[0], 2) if hasattr(os,'getloadavg') else None}}
|
||||
return _admin_v2_extend_health(result, _read_config)
|
||||
|
||||
|
||||
def register_admin_console(app, wake, audio_settings):
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Additive health information for Alfred admin console V2.
|
||||
|
||||
All checks are read-only. No model download, subprocess of Hermes, or microphone
|
||||
stream is triggered by the monitoring endpoint.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MAX_WYOMING_INFO = 65536
|
||||
_WHISPER_CACHE = {'key': None, 'at': 0.0, 'result': None}
|
||||
|
||||
|
||||
def _describe_whisper(host: str, port: int, timeout: float = 1.8) -> dict:
|
||||
"""Check actual Wyoming info/ASR support; *not* a transcription test."""
|
||||
start = time.monotonic()
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout) as sock:
|
||||
sock.settimeout(timeout)
|
||||
sock.sendall(b'{"type":"describe"}\n')
|
||||
with sock.makefile('rb') as stream:
|
||||
header = None
|
||||
# Other Wyoming events may precede info; never wait indefinitely.
|
||||
for _ in range(4):
|
||||
line = stream.readline(4096)
|
||||
if not line:
|
||||
break
|
||||
event = json.loads(line.decode('utf-8'))
|
||||
if event.get('type') == 'info':
|
||||
header = event
|
||||
break
|
||||
if header is None:
|
||||
raise ValueError('Réponse info Wyoming absente')
|
||||
length = int(header.get('data_length', 0))
|
||||
if length <= 0 or length > MAX_WYOMING_INFO:
|
||||
raise ValueError('Taille de réponse Wyoming invalide')
|
||||
payload = stream.read(length)
|
||||
if len(payload) != length:
|
||||
raise ValueError('Réponse Wyoming incomplète')
|
||||
info = json.loads(payload.decode('utf-8'))
|
||||
asrs = [x for x in info.get('asr', []) if isinstance(x, dict) and x.get('installed')]
|
||||
if not asrs:
|
||||
return {'state': 'warning', 'detail': 'Wyoming accessible, aucun moteur ASR installé',
|
||||
'latency_ms': round((time.monotonic() - start) * 1000)}
|
||||
asr = asrs[0]
|
||||
models = [x.get('name') for x in asr.get('models', [])
|
||||
if isinstance(x, dict) and x.get('installed') and x.get('name')]
|
||||
model = ', '.join(models[:3]) or 'non indiqué'
|
||||
name = asr.get('name', 'ASR')
|
||||
return {'state': 'ok', 'detail': f'{name} · modèle {model} · protocole Wyoming',
|
||||
'engine': name, 'model': model,
|
||||
'streaming': bool(asr.get('supports_transcript_streaming')),
|
||||
'latency_ms': round((time.monotonic() - start) * 1000)}
|
||||
except (OSError, ValueError, TypeError, UnicodeError, json.JSONDecodeError):
|
||||
return {'state': 'down', 'detail': f'Pas de réponse ASR Wyoming sur {host}:{port}'}
|
||||
|
||||
|
||||
def _whisper_status(host: str, port: int) -> dict:
|
||||
key = (host, port)
|
||||
now = time.monotonic()
|
||||
if _WHISPER_CACHE['key'] == key and (now - _WHISPER_CACHE['at']) < 10:
|
||||
return dict(_WHISPER_CACHE['result'])
|
||||
result = _describe_whisper(host, port)
|
||||
_WHISPER_CACHE.update(key=key, at=now, result=dict(result))
|
||||
return result
|
||||
|
||||
|
||||
def _timer_snapshot() -> dict:
|
||||
"""Read existing timer store without calling its mutating methods."""
|
||||
installed = (ROOT / 'services' / 'alfred_timers.py').is_file()
|
||||
store_path = Path(os.environ.get('ALFRED_TIMERS_DB', str(ROOT / 'data' / 'timers.json')))
|
||||
if not installed:
|
||||
return {'installed': False, 'state': 'unknown', 'detail': 'Module minuteurs non installé', 'items': []}
|
||||
if not store_path.is_file():
|
||||
return {'installed': True, 'state': 'ok', 'detail': 'Module présent, aucun minuteur enregistré', 'items': []}
|
||||
try:
|
||||
if store_path.stat().st_size > 1024 * 1024:
|
||||
raise ValueError('Fichier minuteurs trop volumineux')
|
||||
data = json.loads(store_path.read_text(encoding='utf-8'))
|
||||
if not isinstance(data, list):
|
||||
raise ValueError('Format minuteurs inattendu')
|
||||
now = time.time()
|
||||
items = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict) or item.get('state') not in ('running', 'paused'):
|
||||
continue
|
||||
duration = item.get('duration')
|
||||
elapsed = max(0, float(item.get('elapsed', 0)))
|
||||
if item.get('state') == 'running':
|
||||
elapsed += max(0, now - float(item['started_at']))
|
||||
remaining = max(0, float(duration) - elapsed) if duration is not None else None
|
||||
# A timer whose deadline passed is omitted even if its worker has not
|
||||
# yet persisted the finished state.
|
||||
if remaining == 0 and item.get('state') == 'running' and duration is not None:
|
||||
continue
|
||||
items.append({'label': str(item.get('label', 'Minuteur'))[:80],
|
||||
'kind': item.get('kind') if item.get('kind') in ('timer', 'stopwatch') else 'timer',
|
||||
'state': item['state'], 'remaining': round(remaining, 1) if remaining is not None else None,
|
||||
'elapsed': round(elapsed, 1), 'duration': duration})
|
||||
return {'installed': True, 'state': 'ok', 'detail': f'{len(items)} en cours ou en pause',
|
||||
'items': items[:20]}
|
||||
except (OSError, ValueError, TypeError, KeyError, OverflowError):
|
||||
return {'installed': True, 'state': 'warning', 'detail': 'Stockage minuteurs illisible', 'items': []}
|
||||
|
||||
|
||||
def extend_health(data: dict, read_config=None) -> dict:
|
||||
"""Enrich existing _health() JSON. The base implementation is left intact."""
|
||||
try:
|
||||
saved = read_config() if callable(read_config) else {}
|
||||
except (ValueError, OSError):
|
||||
saved = {}
|
||||
configured = str(saved.get('STT_ENGINE', os.getenv('STT_ENGINE', 'vosk'))).lower()
|
||||
runtime = os.getenv('STT_ENGINE', 'vosk').lower()
|
||||
runtime = 'wyoming' if runtime in ('whisper', 'wyoming_whisper', 'wyoming') else 'vosk'
|
||||
host = os.getenv('WYOMING_WHISPER_HOST', '192.168.1.13')
|
||||
try:
|
||||
port = int(os.getenv('WYOMING_WHISPER_PORT', '10300'))
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError('Port invalide')
|
||||
except (ValueError, TypeError):
|
||||
port = 10300
|
||||
path = ROOT / 'services' / 'wyoming_stt.py'
|
||||
installed = path.is_file()
|
||||
probe = _whisper_status(host, port) if installed else {
|
||||
'state': 'unknown', 'detail': 'Intégration Wyoming STT non installée'}
|
||||
if configured != runtime:
|
||||
state = 'warning'
|
||||
detail = f'{runtime.upper()} actif ; {configured.upper()} sélectionné (redémarrage nécessaire)'
|
||||
else:
|
||||
state = 'ok' if runtime == 'vosk' or (installed and probe['state'] == 'ok') else 'warning'
|
||||
detail = (f'{runtime.upper()} actif' + (' · retour Vosk si indisponible' if runtime == 'wyoming' else ''))
|
||||
data['stt'] = {'state': state, 'active_engine': runtime, 'configured_engine': configured,
|
||||
'detail': detail, 'host': host, 'port': port,
|
||||
'language': os.getenv('WYOMING_WHISPER_LANGUAGE', 'fr'),
|
||||
'whisper': probe, 'vosk_fallback': runtime == 'wyoming'}
|
||||
data.setdefault('services', {})['wyoming_whisper'] = probe
|
||||
timer = _timer_snapshot()
|
||||
data['timers'] = timer
|
||||
data['services']['alfred_timers'] = {'state': timer['state'], 'detail': timer['detail'] + ' (lecture locale)'}
|
||||
mcps = data.setdefault('mcps', {})
|
||||
for slug, label in [('MCP_alfred_timers', 'Alfred Timers'), ('MCP_autres', 'Alfred Autres')]:
|
||||
found = (Path('/opt/hermes') / slug).is_dir()
|
||||
mcps[slug] = {
|
||||
'state': 'ok' if found else 'unknown',
|
||||
'label': label,
|
||||
'detail': 'Dossier présent · test MCP non exécuté' if found else 'Dossier absent',
|
||||
}
|
||||
data['maintenance'] = {'web_restart_enabled': os.getenv('ALFRED_ALLOW_WEB_RESTART', 'false').lower() in ('true', '1', 'yes')}
|
||||
return data
|
||||
@@ -0,0 +1,17 @@
|
||||
/* Alfred Configuration & Surveillance V2: additive styles only */
|
||||
.admin-v2-stt-panel {margin-top:18px}
|
||||
.admin-v2-form-grid {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 20px}
|
||||
.admin-v2-form-grid .field {min-width:0}
|
||||
.admin-v2-help {line-height:1.6;color:#a7c0d0;font-size:.82rem;background:#ffffff08;border:1px solid #ffffff18;padding:13px 15px;border-radius:11px;margin:1px 0 0}
|
||||
.admin-v2-help code,.admin-v2-monitor code,.admin-v2-maintenance code{font-size:.84em;color:#bde0fc}
|
||||
.admin-v2-monitor {margin:17px 0}
|
||||
.admin-v2-summary {display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;border-bottom:1px solid #ffffff16;padding:10px 0}
|
||||
.admin-v2-summary strong {font-size:.85rem;color:#eaf4fd;overflow-wrap:anywhere}
|
||||
.admin-v2-monitor p.microtext {line-height:1.6;margin:9px 0 18px}
|
||||
.admin-v2-monitor .info-note{margin-top:12px}
|
||||
.admin-v2-timer-list{max-height:200px;overflow:auto;margin-top:8px}
|
||||
.admin-v2-timer-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid #ffffff13;font-size:.82rem}
|
||||
.admin-v2-timer-row span{color:#adc5d7;text-align:right}
|
||||
.admin-v2-maintenance{margin-bottom:17px}
|
||||
.admin-v2-maintenance p{line-height:1.65}
|
||||
@media(max-width:720px){.admin-v2-form-grid{grid-template-columns:1fr}.admin-v2-summary{align-items:flex-start}}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Alfred V2 monitoring extension: no credentials, no write calls. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const byId = id => document.getElementById(id);
|
||||
const text = (id, value) => { const el = byId(id); if (el) el.textContent = String(value ?? '—'); };
|
||||
const pill = (id, state) => {
|
||||
const el = byId(id); if (!el) return;
|
||||
const states = {ok:'Disponible', warning:'Attention', down:'Indisponible', unknown:'Non vérifié'};
|
||||
el.dataset.state = state || 'unknown';
|
||||
el.textContent = states[state] || 'Non vérifié';
|
||||
};
|
||||
const duration = seconds => {
|
||||
const n = Math.max(0, Math.floor(Number(seconds) || 0));
|
||||
if (n >= 3600) return `${Math.floor(n/3600)} h ${String(Math.floor((n%3600)/60)).padStart(2,'0')} min`;
|
||||
return `${Math.floor(n/60)} min ${String(n%60).padStart(2,'0')} s`;
|
||||
};
|
||||
window.alfredAdminV2Health = data => {
|
||||
if (document.body.dataset.page !== 'surveillance') return;
|
||||
const stt = data.stt || {}, whisper = stt.whisper || {}, timers = data.timers || {};
|
||||
text('v2SttActive', stt.active_engine === 'wyoming' ? 'Whisper distant' : 'Vosk local');
|
||||
text('v2SttDetail', stt.detail || 'État indisponible');
|
||||
pill('v2SttState', stt.state);
|
||||
text('v2WhisperAddress', stt.host && stt.port ? `${stt.host}:${stt.port}` : 'Non configuré');
|
||||
text('v2WhisperDetail', whisper.detail || 'Service non vérifié');
|
||||
pill('v2WhisperState', whisper.state);
|
||||
text('v2TimerCount', `${(timers.items || []).length} actif(s)`);
|
||||
pill('v2TimerState', timers.state);
|
||||
const list = byId('v2TimerList');
|
||||
if (list) {
|
||||
list.replaceChildren();
|
||||
const items = timers.items || [];
|
||||
if (!items.length) {
|
||||
const p=document.createElement('p');p.className='muted microtext';
|
||||
p.textContent=timers.detail || 'Aucun minuteur actif'; list.appendChild(p);
|
||||
}
|
||||
items.forEach(item => {
|
||||
const row=document.createElement('div'); row.className='admin-v2-timer-row';
|
||||
const name=document.createElement('strong'); name.textContent=item.label || 'Minuteur';
|
||||
const val=document.createElement('span');
|
||||
val.textContent=item.state === 'paused' ? 'En pause' :
|
||||
(item.remaining == null ? duration(item.elapsed || 0) + ' écoulées' : duration(item.remaining) + ' restantes');
|
||||
row.append(name,val); list.appendChild(row);
|
||||
});
|
||||
}
|
||||
const allowed = Boolean(data.maintenance?.web_restart_enabled);
|
||||
text('v2RestartLabel', allowed ? 'Autorisé' : 'Désactivé');
|
||||
pill('v2RestartState', allowed ? 'ok' : 'warning');
|
||||
};
|
||||
})();
|
||||
@@ -78,7 +78,7 @@
|
||||
serviceStatus($('wakeStatus'), {state:!wake.running?'down':wake.paused?'warning':'ok'});
|
||||
safeText('wakeLabel', `Vosk · ${wake.word || 'alfred'}`);
|
||||
safeText('wakeDetail', wake.error || (wake.paused ? 'Détection en pause' : 'Détection active'));
|
||||
const labels = {alfred:'Alfred / Flask', hermes_cli:'Hermes Agent (CLI)', hermes_dashboard:'Hermes WebUI', comfyui:'ComfyUI', ollama:'Ollama', home_assistant:'Home Assistant', wyoming_piper:'Wyoming Piper'};
|
||||
const labels = {alfred:'Alfred / Flask', hermes_cli:'Hermes Agent (CLI)', hermes_dashboard:'Hermes WebUI', comfyui:'ComfyUI', ollama:'Ollama', home_assistant:'Home Assistant', wyoming_piper:'Wyoming Piper', wyoming_whisper:'Whisper (STT)', alfred_timers:'Minuteurs Alfred'};
|
||||
const list = $('serviceList'); list.replaceChildren();
|
||||
for (const [key,service] of Object.entries(data.services || {})) {
|
||||
const row = document.createElement('div'); row.className='service-row';
|
||||
@@ -108,7 +108,11 @@
|
||||
const p=document.createElement('p');p.textContent=mcp.detail;
|
||||
card.append(title,status,p);mcps.append(card);
|
||||
}
|
||||
if (typeof window.alfredAdminV2Health === 'function') {
|
||||
window.alfredAdminV2Health(data);
|
||||
}
|
||||
}
|
||||
// ALFRED_ADMIN_SURVEILLANCE_V2: monitoring hook
|
||||
let inFlight = false;
|
||||
async function refreshHealth() {
|
||||
if (inFlight || document.hidden) return;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<meta name="theme-color" content="#121b25"><title>Alfred · Configuration</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred-admin.css">
|
||||
<script defer src="/static/js/alfred-admin.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-admin-v2.css?v=2">
|
||||
<script defer src="/static/js/alfred-admin-v2.js?v=2"></script>
|
||||
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 -->
|
||||
</head>
|
||||
<body data-page="config">
|
||||
<div class="admin-layout">
|
||||
@@ -46,6 +49,23 @@
|
||||
<a href="/surveillance" class="button ghost inline-link">Voir la surveillance des services</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 : reconnaissance vocale distante -->
|
||||
<section class="panel admin-v2-stt-panel">
|
||||
<div class="panel-title"><span class="panel-symbol">◉</span><div><h2>Reconnaissance vocale (STT)</h2><p>Vosk local ou Whisper Wyoming distant · configuration effective après redémarrage</p></div></div>
|
||||
<div class="field"><label for="STT_ENGINE">Moteur de transcription final</label>
|
||||
<select id="STT_ENGINE" data-setting><option value="vosk">Vosk local (mode historique)</option><option value="wyoming">Whisper Wyoming distant (conseillé)</option></select>
|
||||
<small>Le mot d’activation et le texte provisoire restent gérés par Vosk. Whisper améliore la transcription finale envoyée à Hermes.</small></div>
|
||||
<div class="admin-v2-form-grid">
|
||||
<div class="field"><label for="WYOMING_WHISPER_HOST">Serveur Wyoming Whisper</label><input id="WYOMING_WHISPER_HOST" data-setting placeholder="192.168.1.13" autocomplete="off"><small>Adresse IP ou nom DNS, sans http://</small></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_PORT">Port TCP Wyoming</label><input type="number" min="1" max="65535" id="WYOMING_WHISPER_PORT" data-setting placeholder="10300"></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_LANGUAGE">Langue</label><input id="WYOMING_WHISPER_LANGUAGE" data-setting maxlength="2" placeholder="fr"></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_TIMEOUT">Délai maximum transcription (s)</label><input id="WYOMING_WHISPER_TIMEOUT" type="number" min="5" max="90" step="0.5" data-setting></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_CONNECT_TIMEOUT">Délai de connexion (s)</label><input id="WYOMING_WHISPER_CONNECT_TIMEOUT" type="number" min="1" max="15" step="0.5" data-setting></div>
|
||||
<div class="field"><label for="STT_MAX_AUDIO_SECONDS">Durée audio maximale (s)</label><input id="STT_MAX_AUDIO_SECONDS" type="number" min="5" max="60" step="1" data-setting></div>
|
||||
</div>
|
||||
<p class="admin-v2-help">Le modèle Whisper (<strong>small</strong> actuellement) et son vocabulaire technique (<code>--initial-prompt</code> pour Proxmox, Wazuh…) se configurent <strong>dans le conteneur Docker distant</strong>, pas sur Alfred. En cas de panne Whisper, retour automatique à Vosk si le correctif STT V1 est installé.</p>
|
||||
</section>
|
||||
<section class="panel restart-panel"><div><h2>Maintenance</h2><p class="muted">Redémarrer Alfred pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.</p><span class="microtext muted">Le bouton requiert une autorisation systemd explicitement activée.</span></div><button class="button danger" type="button" id="restartButton">Redémarrer Alfred</button></section>
|
||||
<p role="status" aria-live="polite" class="message" id="message"></p>
|
||||
</main>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#121b25"><title>Alfred · Surveillance</title><link rel="stylesheet" href="/static/css/alfred-admin.css"><script defer src="/static/js/alfred-admin.js"></script></head>
|
||||
<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#121b25"><title>Alfred · Surveillance</title><link rel="stylesheet" href="/static/css/alfred-admin.css"><script defer src="/static/js/alfred-admin.js"></script><link rel="stylesheet" href="/static/css/alfred-admin-v2.css?v=2">
|
||||
<script defer src="/static/js/alfred-admin-v2.js?v=2"></script>
|
||||
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 -->
|
||||
</head>
|
||||
<body data-page="surveillance">
|
||||
<div class="admin-layout">
|
||||
<header class="admin-header"><div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div><nav aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance" class="selected">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav></header>
|
||||
@@ -12,6 +15,28 @@
|
||||
</section>
|
||||
<div class="two-col monitor-lower"><section class="panel"><div class="panel-title"><span class="panel-symbol">⇄</span><div><h2>Services et connexions</h2><p>Tests réels : HTTP, port TCP ou présence du binaire</p></div></div><div class="service-list" id="serviceList"><p class="muted">Chargement…</p></div></section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">▤</span><div><h2>Système</h2><p>Ressources du mini-PC hébergeant Alfred</p></div></div><div class="resource"><div class="resource-title"><span>Disque de l’application</span><strong id="diskPercent">—</strong></div><div class="meter"><span id="diskBar"></span></div><p id="diskText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Mémoire RAM</span><strong id="memPercent">—</strong></div><div class="meter"><span id="memBar"></span></div><p id="memText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Charge système (1 min)</span><strong id="loadAvg">—</strong></div></div><div class="info-note">Les contrôles de caméra n'ouvrent pas le flux vidéo, afin de ne pas bloquer la reconnaissance faciale.</div></section></div>
|
||||
|
||||
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 : services vocaux et minuteurs -->
|
||||
<div class="two-col admin-v2-monitor">
|
||||
<section class="panel" aria-labelledby="v2-stt-title">
|
||||
<div class="panel-title"><span class="panel-symbol">◉</span><div><h2 id="v2-stt-title">Transcription vocale</h2><p>Mode réellement actif et disponibilité du serveur Wyoming</p></div></div>
|
||||
<div class="admin-v2-summary"><span class="muted">Mode STT</span><strong id="v2SttActive">—</strong><span class="status-pill" id="v2SttState">—</span></div>
|
||||
<p class="muted microtext" id="v2SttDetail">Vérification…</p>
|
||||
<div class="admin-v2-summary"><span class="muted">Serveur Whisper</span><strong id="v2WhisperAddress">—</strong><span class="status-pill" id="v2WhisperState">—</span></div>
|
||||
<p class="muted microtext" id="v2WhisperDetail">Vérification…</p>
|
||||
<div class="info-note">Wyoming est interrogé avec <code>describe</code> sans lancer de transcription. Une connexion réussie ne garantit pas la qualité STT.</div>
|
||||
</section>
|
||||
<section class="panel" aria-labelledby="v2-timers-title">
|
||||
<div class="panel-title"><span class="panel-symbol">◷</span><div><h2 id="v2-timers-title">Minuteurs et chronomètres</h2><p>État enregistré localement, indépendant des appels Hermes</p></div></div>
|
||||
<div class="admin-v2-summary"><span class="muted">Gestionnaire Alfred</span><strong id="v2TimerCount">—</strong><span class="status-pill" id="v2TimerState">—</span></div>
|
||||
<div id="v2TimerList" class="admin-v2-timer-list" aria-live="polite"><p class="muted microtext">Chargement…</p></div>
|
||||
<div class="info-note">La présence d’un MCP dans la liste ci-dessous ne prouve pas qu’Hermes l’a appelé : consulter ses logs pour vérifier les outils exécutés.</div>
|
||||
</section>
|
||||
</div>
|
||||
<section class="panel admin-v2-maintenance"><div class="panel-title"><span class="panel-symbol">✧</span><div><h2>Maintenance et intégrations</h2><p>Vérifications complémentaires des nouveautés</p></div></div>
|
||||
<div class="admin-v2-summary"><span class="muted">Redémarrage depuis /config</span><strong id="v2RestartLabel">—</strong><span class="status-pill" id="v2RestartState">—</span></div>
|
||||
<p class="muted microtext">MCP Autres (heure/date) et Alfred Timers : la grille ci-dessous signale les dossiers détectés dans <code>/opt/hermes</code>. La validation fonctionnelle se fait avec <code>hermes mcp test</code>.</p>
|
||||
</section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">⚙</span><div><h2>MCP Hermes détectés</h2><p>Présence des répertoires, pas un test de communication des outils MCP</p></div></div><div class="mcp-grid" id="mcpList"><p class="muted">Chargement…</p></div></section>
|
||||
<p role="status" aria-live="polite" class="message" id="message"></p>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user