116 lines
4.7 KiB
Python
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)
|