Files
Alfredv2/alfred/app/services/alfred_logs_page.py
T

116 lines
4.7 KiB
Python

"""Journal admin Alfred Glass V2 : lecture seule du journal systemd d'Alfred.
Les donnees sont protegees par la meme session admin que /config.
Les sorties Hermes independantes ne sont PAS inventees/aggregees artificiellement.
"""
from __future__ import annotations
from datetime import datetime, timezone
import json
import re
import subprocess
from flask import Blueprint, jsonify, render_template, request
from services import admin_console
# Redaction defensive : le journal applicatif peut contenir des erreurs
# avec des secrets. Aucune methode ne peut promettre une suppression parfaite.
_SECRET = re.compile(
r'(?i)\b((?:PROXMOX_TOKEN_VALUE|HA_TOKEN|OPENAI_API_KEY|API_KEY|PASSWORD|SECRET|'
r'ACCESS_TOKEN|REFRESH_TOKEN|TOKEN|AUTHORIZATION|API[-_]?KEY|BEARER)\s*[:=]\s*)'
r'(?:["\']?[^\s,;"\']+["\']?)'
)
_BEARER = re.compile(r'(?i)\bBearer\s+[a-z0-9._~+/=-]{8,}')
_URI_SECRET = re.compile(r'(?i)([?&](?:token|key|api_key|password|secret)=)[^\s&#]+')
def redact(message: str) -> str:
result = str(message or '')[:2200]
result = _SECRET.sub(r'\1[MASQUÉ]', result)
result = _BEARER.sub('Bearer [MASQUÉ]', result)
result = _URI_SECRET.sub(r'\1[MASQUÉ]', result)
return result
def component(message: str) -> str:
upper = message.upper()
if any(k in upper for k in ('[ALFRED STT]', 'WHISPER', 'VOSK', 'WAKE WORD', 'WAKEWORD', 'TRANSCRIPTION')):
return 'Vocal'
if any(k in upper for k in ('[ALFRED PIPER]', '[ALFRED TTS]', 'WYOMING PIPER', 'SPEAK_ASYNC')):
return 'Piper'
if any(k in upper for k in ('MCP_', 'MCP ', 'TOOL CALL', 'LIST_NODES', 'CREATE_TIMER', 'HA_LIST')):
return 'MCP'
if any(k in upper for k in ('[ALFRED] ENVOI À HERMES', '[ALFRED] RÉPONSE HERMES', 'HERMES', 'AGENT')):
return 'Hermes'
return 'Système'
def level(message: str, priority: str) -> str:
if priority and priority.isdigit() and int(priority) <= 3:
return 'Erreur'
upper = message.upper()
if 'TRACEBACK' in upper or 'EXCEPTION' in upper or '[ERROR]' in upper or ' ERREUR ' in upper:
return 'Erreur'
if priority and priority.isdigit() and int(priority) == 4 or 'WARNING' in upper or 'ATTENTION' in upper:
return 'Avertissement'
return 'Information'
def read_journal(limit: int = 220):
cmd = ['journalctl', '--no-pager', '--quiet', '--output=json',
'--unit=alfred.service', '--lines='+str(limit)]
try:
result = subprocess.run(cmd, capture_output=True, text=True,
timeout=7, check=False)
except (OSError, subprocess.TimeoutExpired) as exc:
raise RuntimeError('Lecture du journal indisponible : '+type(exc).__name__) from exc
if result.returncode != 0:
raise RuntimeError('Lecture du journal refusée. Vérifier les droits systemd.')
items = []
for raw in result.stdout.splitlines()[-limit:]:
try:
record = json.loads(raw)
msg = str(record.get('MESSAGE') or '')
if not msg:
continue
# Certaines traces Flask contiennent des en-tetes d'authentification : prudence.
micros = str(record.get('__REALTIME_TIMESTAMP') or '0')
iso = datetime.fromtimestamp(int(micros)/1_000_000,timezone.utc).isoformat()
items.append({'at': iso, 'component':component(msg),
'level':level(msg,str(record.get('PRIORITY') or '')),
'message':redact(msg)})
except (TypeError, ValueError, OverflowError, json.JSONDecodeError, OSError):
continue
return items
def register_logs_page(app):
bp = Blueprint('alfred_glass_logs_v2', __name__)
@bp.get('/logs')
@bp.get('/logs/')
def logs_page():
# Le HTML ne contient jamais les journaux; l'API exige la session admin.
return render_template('alfred_logs_v2.html')
@bp.get('/api/alfred/logs')
def logs_api():
if not admin_console._authenticated():
return jsonify(error='Authentification administrateur nécessaire.',
login_url='/config'), 401
try:
limit = min(400,max(20,int(request.args.get('limit',220))))
except (TypeError,ValueError):
return jsonify(error='Limite invalide.'),400
try:
records=read_journal(limit)
except RuntimeError as exc:
return jsonify(error=str(exc)),503
response = jsonify(ok=True,source='journalctl -u alfred.service',items=records,
warning='Lecture du journal Alfred uniquement ; les logs Hermes autonomes peuvent être absents.')
response.headers['Cache-Control'] = 'no-store, private'
return response
app.register_blueprint(bp)