From aff036d2c978ea51ba1db6d193551bad46509108 Mon Sep 17 00:00:00 2001 From: alkatrazz Date: Sat, 20 Jun 2026 23:12:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(stt-client):=20pages=20d=C3=A9tail=20par?= =?UTF-8?q?=20service=20+=20sant=C3=A9=20live=20(probe=20+=20Prometheus=20?= =?UTF-8?q?+=20Alertmanager)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transforme le portail (tuiles ouvrant un lien) en mini-pages par service, avec état de santé temps réel — comme demandé. Page détail (clic sur une tuile) : icône+nom, pilule d'état (en ligne/dégradé/hors ligne + latence), description, composants (pastilles), alertes actives, infos (URL), bouton « Ouvrir ». Tuiles : pastille de santé live + badge global d'alertes. - registry.py : Service enrichi (description, health_url, components[{name,prom}], alerts_match) — toujours config-driven. - health.py : StatusPoller en thread. Probe HTTP (up/down+latence) + Prometheus /api/v1/query (composants via up{}/kube-state-metrics) + Alertmanager /api/v2/alerts (hors Watchdog). Parallélisé (ThreadPoolExecutor), pousse portal-status au HUD. - config.py : métadonnées homelab vérifiées en live (7 services ; traefik retiré — URL injoignable) + section [portal] (intervalle, URLs, timeouts). - app.py : démarre le poller, diffuse portal-status (+ dernier état au connect). - HUD : vues liste⟷détail, pastilles, badge alertes, mise à jour live. - bump 0.9.0 → 0.10.0. Validé : StatusPoller en direct contre le homelab (cycle 0.12s, 7 services ok, composants Prometheus résolus, 0 alerte) ; HUD via Playwright (pastilles ok/dégradé/ hors-ligne, page détail complète, alerte rendue, bouton Ouvrir → open-service). Co-Authored-By: Claude Opus 4.8 --- admin/ia/stt.md | 2 +- stt/client/config/stt.example.toml | 33 ++-- stt/client/pyproject.toml | 2 +- stt/client/stt/config.py | 76 +++++++- stt/client/stt/hud/index.html | 275 ++++++++++++++++++++++++++--- stt/client/stt/portal/__init__.py | 7 +- stt/client/stt/portal/health.py | 203 +++++++++++++++++++++ stt/client/stt/portal/registry.py | 35 ++++ stt/client/stt/ui/app.py | 17 +- 9 files changed, 600 insertions(+), 50 deletions(-) create mode 100644 stt/client/stt/portal/health.py diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 0d73f3b..648eb11 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -148,7 +148,7 @@ au prompt système (`STT_DISABLE_THINKING`, défaut `true` ; inoffensif hors Qwe | — ASR | `stt/client/stt/voice/asr/` | backend enfichable : `whisper` (faster-whisper CPU, défaut) \| `onnx` (Parakeet/Canary/Nemotron via onnx-asr, multilingue, streaming-ready). Choisi par `[voice] asr_engine` | | — api | `stt/client/stt/api.py` | `ServerClient` → `POST /v1/ask` | | — UI/HUD | `stt/client/stt/ui/` + `hud/` | HTTP statique + websocket bidirectionnel (états → HUD ; réglages **et messages texte** → backend) ; HUD embarqué dans le package | -| — portail | `stt/client/stt/portal/` | registre de services (`registry.py`) piloté par `[[services]]` de `stt.toml` → panneau « Services » du HUD (tuiles cliquables) **et** commande vocale « ouvre <service> ». Ouvre l'URL dans le navigateur normal | +| — portail | `stt/client/stt/portal/` | `registry.py` (services config-driven `[[services]]`) + `health.py` (StatusPoller). Panneau « Services » du HUD : tuiles + **pages détail** (description, **santé live**, composants, alertes, bouton Ouvrir). Santé = probe HTTP + **Prometheus** `up{}`/kube-state-metrics + **Alertmanager**. Ouvre l'URL dans le navigateur normal. Aussi : commande vocale « ouvre <service> » | | STT-server | `stt/server/` (conteneur) | FastAPI : `/healthz`, `/v1/ask` ; `brain.py` → LiteLLM | | Image | `ghcr.io/alkatrazz24/funk-stt-server` | construite par `.github/workflows/build-stt-server.yml` | | Manifests | `k8s/apps/stt/` | Deployment, Service, IngressRoute (`stt.lab.local`) | diff --git a/stt/client/config/stt.example.toml b/stt/client/config/stt.example.toml index 7864969..8a484f5 100644 --- a/stt/client/config/stt.example.toml +++ b/stt/client/config/stt.example.toml @@ -34,14 +34,13 @@ enabled = true # cache local de conversation (brut, gitigno local_db = "~/.local/share/stt/memory.sqlite" # --------------------------------------------------------------------------- -# Portail de services — tuiles du HUD + commande vocale « ouvre ». +# Portail de services — tuiles + pages détail (santé live) + voix « ouvre ». # Définir [[services]] ici REMPLACE entièrement la liste par défaut (homelab Funk). -# Ajouter un service = un bloc de plus. Champs : -# id : identifiant unique (interne) -# name : libellé affiché sur la tuile -# url : ouverte dans ton navigateur normal au clic / à la voix -# icon : emoji de la tuile -# aliases : mots déclencheurs pour la voix (« ouvre les métriques » → grafana) +# Champs : id, name, url, icon, aliases (voix) ; + pour la page détail (optionnel) : +# description : phrase « ce que fait le service » +# health_url : endpoint sondé (up/down + latence) ; défaut = url +# components : briques dont dépend le service ; prom = PromQL renvoyant 1/0 +# alerts_match: labels Alertmanager — alerte affichée si TOUS matchent # --------------------------------------------------------------------------- # [[services]] # id = "grafana" @@ -49,10 +48,18 @@ local_db = "~/.local/share/stt/memory.sqlite" # url = "http://grafana.lab.local" # icon = "📊" # aliases = ["graphana", "metriques", "monitoring", "dashboard"] +# description = "Tableaux de bord et visualisation des métriques." +# health_url = "http://grafana.lab.local/api/health" +# alerts_match = { namespace = "monitoring" } # -# [[services]] -# id = "ghostfolio" -# name = "Ghostfolio" -# url = "http://ghostfolio.lab.local" -# icon = "💰" -# aliases = ["portefeuille", "bourse"] +# [[services.components]] +# name = "Pod" +# prom = 'kube_pod_status_ready{namespace="monitoring",pod=~".*grafana.*",condition="true"}' + +# Santé live des pages détail (sondage non bloquant côté client) : +# [portal] +# status_enabled = true +# status_interval_sec = 15 +# probe_timeout_sec = 4 +# prometheus_url = "http://prometheus.lab.local" +# alertmanager_url = "http://alertmanager.lab.local" diff --git a/stt/client/pyproject.toml b/stt/client/pyproject.toml index 50dc0c1..4c5cf6a 100644 --- a/stt/client/pyproject.toml +++ b/stt/client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stt" -version = "0.9.0" +version = "0.10.0" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" requires-python = ">=3.11" readme = "README.md" diff --git a/stt/client/stt/config.py b/stt/client/stt/config.py index 5c98f05..feda808 100644 --- a/stt/client/stt/config.py +++ b/stt/client/stt/config.py @@ -70,52 +70,110 @@ DEFAULT_CONFIG: dict[str, Any] = { "enabled": True, "local_db": str(DATA_DIR / "memory.sqlite"), }, - # Portail de services — alimente le panneau « Services » du HUD ET la commande - # vocale « ouvre ». Ajouter un service = ajouter une entrée ici (ou - # un bloc [[services]] dans stt.toml, qui remplace alors toute cette liste). - # Champs : id (unique), name, url, icon (emoji), aliases (mots déclencheurs voix). + # Portail de services — panneau « Services » du HUD (tuiles + pages détail), santé + # live, ET commande vocale « ouvre ». Ajouter un service = une entrée ici + # (ou un bloc [[services]] dans stt.toml, qui remplace alors toute cette liste). + # Champs : id, name, url, icon, aliases (voix) ; + pour la page détail : + # description, health_url (sondé), components[{name, prom}] (PromQL → 1/0), + # alerts_match (labels Alertmanager : alerte pertinente si TOUS matchent). "services": [ { "id": "ghostfolio", "name": "Ghostfolio", "url": "http://ghostfolio.lab.local", "icon": "💰", "aliases": ["portefeuille", "portfolio", "ghosto", "bourse", "investissements"], + "description": "Suivi de portefeuille et d'investissements (actions, crypto). " + "Base PostgreSQL sur storage-01 + cache Redis in-cluster.", + "health_url": "http://ghostfolio.lab.local/api/v1/health", + "components": [ + {"name": "Application", "prom": 'kube_pod_status_ready{namespace="ai",pod=~"ghostfolio-.+",pod!~"ghostfolio-redis-.+",condition="true"}'}, + {"name": "Redis (cache)", "prom": 'kube_pod_status_ready{namespace="ai",pod=~"ghostfolio-redis-.+",condition="true"}'}, + {"name": "PostgreSQL — storage-01", "prom": 'up{job="storage-01"}'}, + ], + "alerts_match": {"namespace": "ai"}, }, { "id": "grafana", "name": "Grafana", "url": "http://grafana.lab.local", "icon": "📊", "aliases": ["graphana", "metriques", "monitoring", "dashboard", "graphiques"], + "description": "Tableaux de bord et visualisation des métriques du homelab " + "(Prometheus comme source de données).", + "health_url": "http://grafana.lab.local/api/health", + "components": [ + {"name": "Pod", "prom": 'kube_pod_status_ready{namespace="monitoring",pod=~".*grafana.*",condition="true"}'}, + ], + "alerts_match": {"namespace": "monitoring"}, }, { "id": "argocd", "name": "ArgoCD", "url": "http://argocd.lab.local", "icon": "🚢", "aliases": ["argo", "argo cd", "deploiements", "gitops"], + "description": "Déploiement continu GitOps : synchronise les workloads k8s " + "depuis le dépôt Git (pattern App-of-Apps).", + "health_url": "http://argocd.lab.local/healthz", + "components": [ + {"name": "Pods (server, repo, redis…)", "prom": 'kube_pod_status_ready{namespace="argocd",condition="true"}'}, + ], + "alerts_match": {"namespace": "argocd"}, }, { "id": "n8n", "name": "n8n", "url": "http://n8n.lab.local", "icon": "🔀", "aliases": ["workflows", "automatisation", "nodes", "huit n huit"], + "description": "Automatisation et workflows (alertes, rapport horaire, worker " + "doc Hermès). Base PostgreSQL sur storage-01.", + "health_url": "http://n8n.lab.local/healthz", + "components": [ + {"name": "Application", "prom": 'kube_pod_status_ready{namespace="ai",pod=~"n8n-.+",condition="true"}'}, + {"name": "PostgreSQL — storage-01", "prom": 'up{job="storage-01"}'}, + ], + "alerts_match": {"namespace": "ai"}, }, { "id": "openwebui", "name": "Open WebUI", "url": "http://openwebui.lab.local", "icon": "💬", "aliases": ["open web ui", "webui", "chat", "llm", "interface ia"], + "description": "Interface de chat web vers les LLM du homelab " + "(backend LiteLLM sur storage-01).", + "health_url": "http://openwebui.lab.local/health", + "components": [ + {"name": "Application", "prom": 'kube_pod_status_ready{namespace="ai",pod=~"open-webui-.+",condition="true"}'}, + {"name": "LiteLLM — storage-01", "prom": 'up{job="storage-01"}'}, + ], + "alerts_match": {"namespace": "ai"}, }, { "id": "prometheus", "name": "Prometheus", "url": "http://prometheus.lab.local", "icon": "🔥", "aliases": ["prometheus", "metriques brutes", "tsdb"], + "description": "Collecte et stockage des métriques (TSDB) — scrute hôtes, " + "nœuds k8s et services.", + "health_url": "http://prometheus.lab.local/-/healthy", + "components": [ + {"name": "Pod", "prom": 'kube_pod_status_ready{namespace="monitoring",pod=~"prometheus-.+",condition="true"}'}, + ], + "alerts_match": {"namespace": "monitoring"}, }, { "id": "alertmanager", "name": "Alertmanager", "url": "http://alertmanager.lab.local", "icon": "🚨", "aliases": ["alertes", "alert manager", "alarmes"], - }, - { - "id": "traefik", "name": "Traefik", - "url": "http://traefik.lab.local", "icon": "🛣️", - "aliases": ["reverse proxy", "ingress", "routeur", "proxy"], + "description": "Routage et notification des alertes Prometheus " + "(→ webhook Hermès, n8n, e-mail).", + "health_url": "http://alertmanager.lab.local/-/healthy", + "components": [ + {"name": "Pod", "prom": 'kube_pod_status_ready{namespace="monitoring",pod=~"alertmanager-.+",condition="true"}'}, + ], + "alerts_match": {"namespace": "monitoring"}, }, ], + # Santé live du portail (pages détail). Sondage non bloquant en thread. + "portal": { + "status_enabled": True, + "status_interval_sec": 15, # cadence de rafraîchissement + "probe_timeout_sec": 4, + "prometheus_url": "http://prometheus.lab.local", + "alertmanager_url": "http://alertmanager.lab.local", + }, } diff --git a/stt/client/stt/hud/index.html b/stt/client/stt/hud/index.html index f9eec5c..9fc3f64 100644 --- a/stt/client/stt/hud/index.html +++ b/stt/client/stt/hud/index.html @@ -675,6 +675,7 @@ border-radius: 10px; background: var(--bg-soft); } + .svc-tile .svc-meta { min-width: 0; flex: 1; } .svc-tile .svc-name { font-size: 14px; font-weight: 600; @@ -683,6 +684,70 @@ white-space: nowrap; } + /* Pastille de santé (tuile + pilule détail). Couleurs par état. */ + .svc-dot { + width: 9px; height: 9px; border-radius: 50%; + flex: none; background: var(--ink-soft); + box-shadow: 0 0 0 3px rgba(0,0,0,.04); + } + .svc-tile .svc-dot { margin-left: auto; } + [data-health="ok"] .svc-dot, .svc-dot[data-health="ok"] { background: #2bb673; } + [data-health="degraded"] .svc-dot, .svc-dot[data-health="degraded"] { background: #e8a13a; } + [data-health="down"] .svc-dot, .svc-dot[data-health="down"] { background: #e0556b; } + [data-health="unknown"] .svc-dot, .svc-dot[data-health="unknown"] { background: #c2bcbe; } + + /* ============================================================ + PORTAIL — vue détail (« page » par service) + ============================================================ */ + .svc-back { + appearance: none; border: none; background: none; cursor: pointer; + font: inherit; font-size: 13.5px; font-weight: 600; + color: var(--ink-soft); padding: 0 0 14px; display: inline-flex; align-items: center; + } + .svc-back:hover { color: var(--accent-active); } + .svc-detail-head { display: flex; align-items: center; gap: 14px; margin-bottom: 4px; } + .svc-detail-ico { + font-size: 30px; line-height: 1; flex: none; + width: 52px; height: 52px; display: grid; place-items: center; + border-radius: 14px; background: var(--bg-soft); + } + .svc-detail-title h3 { margin: 0 0 4px; font-size: 19px; font-weight: 650; } + .svc-pill { + display: inline-flex; align-items: center; gap: 0; + font-size: 12.5px; font-weight: 600; color: var(--ink-soft); + } + .svc-pill[data-health="ok"] { color: #2bb673; } + .svc-pill[data-health="degraded"] { color: #c5841f; } + .svc-pill[data-health="down"] { color: #d23a52; } + .svc-detail-desc { margin: 14px 0 4px; font-size: 13.5px; line-height: 1.5; } + .svc-section { padding: 16px 0; border-bottom: 1px solid var(--line); } + .svc-section .flabel { display: block; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--ink-soft); margin-bottom: 10px; } + .svc-row { + display: flex; align-items: center; gap: 10px; + font-size: 13.5px; padding: 5px 0; + } + .svc-row .svc-row-name { flex: 1; min-width: 0; } + .svc-row .svc-state { font-size: 12px; font-weight: 600; color: var(--ink-soft); } + .svc-alert-row { + display: flex; gap: 8px; align-items: baseline; + padding: 8px 10px; margin-bottom: 6px; border-radius: 10px; + background: #fff3f4; border: 1px solid #ffd9de; font-size: 13px; + } + .svc-alert-row .sev { font-weight: 700; text-transform: uppercase; font-size: 10.5px; letter-spacing: .04em; flex: none; } + .svc-alert-row.sev-warning { background: #fff7ec; border-color: #ffe2b8; } + .svc-alert-none { font-size: 13px; color: var(--ink-soft); } + .svc-info { font-size: 13px; color: var(--ink-soft); line-height: 1.6; word-break: break-all; } + .svc-info a { color: var(--accent-active); text-decoration: none; } + .svc-open-btn { margin-top: 18px; } + + /* Badge alertes global (en tête de la liste) */ + .svc-alert-badge { + display: flex; align-items: center; gap: 8px; + padding: 10px 14px; margin-bottom: 14px; + border-radius: 12px; background: #fff3f4; border: 1px solid #ffd9de; + font-size: 13px; font-weight: 600; color: #c0344a; + } + /* Actions de service (redémarrer / mettre à jour / arrêter) */ .svc-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .svc-actions .btn { width: auto; font-size: 14px; padding: 11px 10px; } @@ -941,9 +1006,43 @@
-

Ouvre un service du homelab dans ton navigateur.

-
- + +
+ +

Choisis un service pour voir son état et l'ouvrir.

+
+ +
+ + +
@@ -1068,6 +1167,7 @@ function handleEvent(ev) { case 'error': addBubble('error', ev.text || 'Erreur'); break; case 'mic': setMicUI(!!ev.muted); break; // état micro renvoyé par le backend case 'services': renderServices(ev.services || []); break; // tuiles du portail + case 'portal-status': applyPortalStatus(ev); break; // santé live des services case 'toast': showToast(ev.text || ''); break; // confirmation (clic/voix) default: console.warn('Événement inconnu :', ev); } @@ -1190,45 +1290,164 @@ micBtn.addEventListener('click', () => { }); /* ============================================================================ - PORTAIL — tuiles de services + toast - Le backend pousse {"type":"services","services":[{id,name,url,icon}]} à la - connexion ; un clic envoie {"type":"control","action":"open-service","service":id} - et le backend ouvre l'URL dans le navigateur habituel (+ {"type":"toast"}). + PORTAIL — tuiles + pages détail + santé live + Le backend pousse à la connexion {"type":"services","services":[…]} puis, + périodiquement, {"type":"portal-status","services":{id:{state,health,latency_ms, + components,alerts}},"alerts_total":N}. Un clic ouvre la PAGE détail (description, + santé, composants, alertes, bouton Ouvrir). « Ouvrir » envoie open-service. ============================================================================ */ -const svcGrid = document.getElementById('svcGrid'); -const svcEmpty = document.getElementById('svcEmpty'); -const toastEl = document.getElementById('toast'); +const svcGrid = document.getElementById('svcGrid'); +const svcEmpty = document.getElementById('svcEmpty'); +const svcHome = document.getElementById('svcHome'); +const svcDetail = document.getElementById('svcDetail'); +const svcAlertBadge = document.getElementById('svcAlertBadge'); +const toastEl = document.getElementById('toast'); let toastTimer = null; +let servicesById = {}; // id → {id,name,url,icon,description} +let portalStatus = {}; // id → {state,health,latency_ms,components,alerts} +let alertsTotal = null; +let detailId = null; // service affiché en détail, ou null (vue liste) + +const HEALTH_LABELS = { ok:'En ligne', degraded:'Dégradé', down:'Hors ligne', unknown:'Inconnu' }; +const COMP_LABELS = { up:'en ligne', down:'en panne', unknown:'—', info:'' }; +const compHealth = (st) => st === 'up' ? 'ok' : (st === 'down' ? 'down' : 'unknown'); /* Services de secours en mode DÉMO (le backend n'envoie rien sans WS) */ const DEMO_SERVICES = [ - { id:'ghostfolio', name:'Ghostfolio', icon:'💰' }, - { id:'grafana', name:'Grafana', icon:'📊' }, - { id:'argocd', name:'ArgoCD', icon:'🚢' }, - { id:'n8n', name:'n8n', icon:'🔀' }, + { id:'ghostfolio', name:'Ghostfolio', icon:'💰', description:'Suivi de portefeuille.' }, + { id:'grafana', name:'Grafana', icon:'📊', description:'Tableaux de bord.' }, + { id:'argocd', name:'ArgoCD', icon:'🚢', description:'Déploiement GitOps.' }, + { id:'n8n', name:'n8n', icon:'🔀', description:'Workflows.' }, ]; function renderServices(list) { - svcGrid.innerHTML = ''; const items = Array.isArray(list) ? list : []; + servicesById = {}; + svcGrid.innerHTML = ''; svcEmpty.hidden = items.length > 0; for (const s of items) { + servicesById[s.id] = s; const tile = document.createElement('button'); tile.className = 'svc-tile'; tile.type = 'button'; + tile.dataset.id = s.id; + tile.dataset.health = 'unknown'; tile.setAttribute('role', 'listitem'); - tile.title = `Ouvrir ${s.name}`; + tile.title = `Ouvrir la fiche ${s.name}`; tile.innerHTML = `` + - ``; + `` + + ``; tile.querySelector('.svc-name').textContent = s.name || s.id; - tile.addEventListener('click', () => openService(s)); + tile.addEventListener('click', () => showDetail(s.id)); svcGrid.appendChild(tile); } + updateTileDots(); +} + +/* ----- Santé live ----- */ +function applyPortalStatus(msg) { + portalStatus = (msg && msg.services) || {}; + alertsTotal = msg ? msg.alerts_total : null; + updateTileDots(); + updateAlertBadge(); + if (detailId && servicesById[detailId]) renderDetail(detailId); +} + +function updateTileDots() { + for (const tile of svcGrid.querySelectorAll('.svc-tile')) { + const st = portalStatus[tile.dataset.id]; + const h = st ? st.health : 'unknown'; + tile.dataset.health = h; + const dot = tile.querySelector('.svc-dot'); + if (dot) dot.title = HEALTH_LABELS[h] || 'Inconnu'; + } +} + +function updateAlertBadge() { + if (!alertsTotal || alertsTotal <= 0) { svcAlertBadge.hidden = true; return; } + svcAlertBadge.hidden = false; + const s = alertsTotal > 1 ? 's' : ''; + svcAlertBadge.textContent = `⚠ ${alertsTotal} alerte${s} active${s} dans le homelab`; +} + +/* ----- Navigation liste ⟷ détail ----- */ +function showDetail(id) { + if (!servicesById[id]) return; + detailId = id; + svcHome.hidden = true; + svcDetail.hidden = false; + svcDetail.parentElement.scrollTop = 0; + renderDetail(id); +} +function showHome() { + detailId = null; + svcDetail.hidden = true; + svcHome.hidden = false; +} + +function renderDetail(id) { + const s = servicesById[id]; if (!s) return; + const st = portalStatus[id] || {}; + document.getElementById('svcDetailIco').textContent = s.icon || '🔗'; + document.getElementById('svcDetailName').textContent = s.name || id; + + const health = st.health || 'unknown'; + const pill = document.getElementById('svcDetailPill'); + pill.dataset.health = health; + let txt = HEALTH_LABELS[health] || 'Inconnu'; + if (st.state === 'up' && st.latency_ms != null) txt += ` · ${st.latency_ms} ms`; + pill.textContent = '● ' + txt; + + const desc = document.getElementById('svcDetailDesc'); + desc.textContent = s.description || ''; + desc.hidden = !s.description; + + // Composants + const comps = st.components || []; + document.getElementById('svcDetailCompWrap').hidden = comps.length === 0; + const cl = document.getElementById('svcCompList'); cl.innerHTML = ''; + for (const c of comps) { + const row = document.createElement('div'); + row.className = 'svc-row'; + row.dataset.health = compHealth(c.state); + row.innerHTML = `` + + ``; + row.querySelector('.svc-row-name').textContent = c.name; + row.querySelector('.svc-state').textContent = COMP_LABELS[c.state] ?? c.state; + cl.appendChild(row); + } + + // Alertes + const alerts = st.alerts || []; + const al = document.getElementById('svcAlertList'); al.innerHTML = ''; + if (alerts.length === 0) { + const none = document.createElement('div'); + none.className = 'svc-alert-none'; + none.textContent = (st.health ? 'Aucune alerte active ✓' : 'État en cours de récupération…'); + al.appendChild(none); + } else { + for (const a of alerts) { + const row = document.createElement('div'); + row.className = 'svc-alert-row' + (a.severity === 'warning' ? ' sev-warning' : ''); + row.innerHTML = ``; + row.querySelector('.sev').textContent = a.severity || 'alerte'; + row.querySelector('.svc-alert-txt').textContent = + a.summary ? `${a.name} — ${a.summary}` : a.name; + al.appendChild(row); + } + } + + // Infos + const info = document.getElementById('svcInfo'); info.innerHTML = ''; + const link = document.createElement('a'); + link.textContent = s.url; link.href = s.url; link.target = '_blank'; link.rel = 'noopener'; + info.appendChild(document.createTextNode('URL : ')); info.appendChild(link); + + document.getElementById('svcOpenBtn').onclick = () => openService(s); } function openService(s) { - // Optimiste : toast immédiat + on laisse le panneau ouvert (multi-ouverture facile) showToast(`${s.icon || '🔗'} Ouverture de ${s.name || s.id}`); if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) { console.log('[DÉMO] open-service →', s.id); @@ -1237,6 +1456,8 @@ function openService(s) { ws.send(JSON.stringify({ type: 'control', action: 'open-service', service: s.id })); } +document.getElementById('svcBack').addEventListener('click', showHome); + function showToast(text) { if (!text) return; toastEl.textContent = text; @@ -1348,7 +1569,7 @@ function openDrawer(which, onOpen) { if (onOpen) setTimeout(onOpen, 200); } const openSettings = () => openDrawer(drawer, () => intensity.focus()); -const openServices = () => openDrawer(svcDrawer); +const openServices = () => { showHome(); openDrawer(svcDrawer); }; /* ----- Écouteurs Réglages + Services ----- */ el('openSettings').addEventListener('click', openSettings); @@ -1433,7 +1654,19 @@ function runDemo() { ============================================================================ */ restoreLocal(); setState('idle'); -if (DEMO) { renderServices(DEMO_SERVICES); runDemo(); } +if (DEMO) { + renderServices(DEMO_SERVICES); + applyPortalStatus({ alerts_total: 1, services: { + ghostfolio: { state:'up', health:'ok', latency_ms:44, + components:[{name:'Application',state:'up'},{name:'Redis (cache)',state:'up'}], alerts:[] }, + grafana: { state:'up', health:'degraded', latency_ms:24, + components:[{name:'Pod',state:'down'}], + alerts:[{name:'KubePodNotReady',severity:'warning',summary:'Le pod grafana n\'est pas prêt'}] }, + argocd: { state:'down', health:'down', latency_ms:null, components:[], alerts:[] }, + n8n: { state:'up', health:'ok', latency_ms:16, components:[{name:'Application',state:'up'}], alerts:[] }, + }}); + runDemo(); +} else connectWS(); diff --git a/stt/client/stt/portal/__init__.py b/stt/client/stt/portal/__init__.py index eacba8b..86faf94 100644 --- a/stt/client/stt/portal/__init__.py +++ b/stt/client/stt/portal/__init__.py @@ -8,6 +8,9 @@ La même source alimente le panneau « Services » du HUD et la commande vocale from __future__ import annotations -from .registry import Service, load_services, match_service +from .health import StatusPoller +from .registry import Component, Service, load_services, match_service -__all__ = ["Service", "load_services", "match_service"] +__all__ = [ + "Component", "Service", "load_services", "match_service", "StatusPoller", +] diff --git a/stt/client/stt/portal/health.py b/stt/client/stt/portal/health.py new file mode 100644 index 0000000..95f6181 --- /dev/null +++ b/stt/client/stt/portal/health.py @@ -0,0 +1,203 @@ +"""Santé live des services du portail — sondes HTTP + Prometheus + Alertmanager. + +Tout tourne côté backend Python (pas de CORS) dans un thread dédié : à intervalle +régulier, on sonde chaque service et on pousse l'état au HUD via `emit` (le même +pont thread-safe que le moteur vocal). Trois sources : + + • probe HTTP de `service.probe_url` → en ligne / hors ligne + latence + • Prometheus `/api/v1/query` (composants) → état de chaque brique (PromQL → 1/0) + • Alertmanager `/api/v2/alerts` (alertes) → alertes actives, filtrées par service + +Aucune dépendance nouvelle : `requests` (déjà utilisé) + `concurrent.futures`. +""" + +from __future__ import annotations + +import re +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Callable + +from .registry import Service + +# L'alerte « Watchdog » est volontairement toujours active (test du pipeline) : +# on l'exclut pour ne pas polluer chaque page. +_ALWAYS_ON_ALERTS = {"Watchdog"} + + +def probe_http(url: str, timeout: float) -> dict[str, Any]: + """GET `url` → {state: up|down, latency_ms, code}. Toute erreur = down.""" + import requests + + t0 = time.monotonic() + try: + r = requests.get(url, timeout=timeout, allow_redirects=True) + latency = int((time.monotonic() - t0) * 1000) + ok = r.status_code < 400 + return {"state": "up" if ok else "down", "latency_ms": latency, "code": r.status_code} + except requests.RequestException: + return {"state": "down", "latency_ms": None, "code": None} + + +def query_prometheus(base: str, expr: str, timeout: float) -> list[float] | None: + """Évalue une requête instantanée → liste des valeurs, ou None si la requête échoue. + + [] = requête OK mais aucune série (cible inconnue → état « indéterminé »). + """ + import requests + + try: + r = requests.get( + f"{base.rstrip('/')}/api/v1/query", + params={"query": expr}, timeout=timeout, + ) + r.raise_for_status() + data = r.json() + if data.get("status") != "success": + return None + out: list[float] = [] + for s in data.get("data", {}).get("result", []): + try: + out.append(float(s["value"][1])) + except (KeyError, IndexError, ValueError, TypeError): + pass + return out + except (requests.RequestException, ValueError): + return None + + +def _component_state(values: list[float] | None) -> str: + """Interprète les valeurs PromQL d'un composant : up / down / unknown.""" + if values is None: + return "unknown" # Prometheus injoignable + if not values: + return "unknown" # aucune série (cible absente) + if any(v == 0 for v in values): + return "down" + return "up" # toutes les séries non nulles + + +def fetch_alerts(base: str, timeout: float) -> list[dict[str, Any]] | None: + """Alertes actives d'Alertmanager (hors Watchdog), normalisées. None si injoignable.""" + import requests + + try: + r = requests.get(f"{base.rstrip('/')}/api/v2/alerts", + params={"active": "true", "silenced": "false"}, timeout=timeout) + r.raise_for_status() + raw = r.json() + except (requests.RequestException, ValueError): + return None + out: list[dict[str, Any]] = [] + for a in raw if isinstance(raw, list) else []: + labels = a.get("labels", {}) or {} + if labels.get("alertname") in _ALWAYS_ON_ALERTS: + continue + if (a.get("status", {}) or {}).get("state") not in (None, "active"): + continue + ann = a.get("annotations", {}) or {} + out.append({ + "name": labels.get("alertname", "alerte"), + "severity": labels.get("severity", "none"), + "summary": ann.get("summary") or ann.get("description") or "", + "labels": labels, + }) + return out + + +def _alert_matches(alert: dict[str, Any], selectors: tuple[tuple[str, str], ...]) -> bool: + """Vrai si TOUS les (label, regex) du service matchent les labels de l'alerte.""" + if not selectors: + return False + labels = alert.get("labels", {}) or {} + for key, pattern in selectors: + val = str(labels.get(key, "")) + try: + if not re.search(pattern, val): + return False + except re.error: + if pattern != val: + return False + return True + + +class StatusPoller: + """Boucle de sondage en thread : construit l'état de tous les services et l'émet.""" + + def __init__(self, services: list[Service], cfg: dict, emit: Callable[[dict], None]): + self.services = services + self.emit = emit + self.prom_url = cfg.get("prometheus_url", "http://prometheus.lab.local") + self.alert_url = cfg.get("alertmanager_url", "http://alertmanager.lab.local") + self.interval = float(cfg.get("status_interval_sec", 15) or 15) + self.timeout = float(cfg.get("probe_timeout_sec", 4) or 4) + self._stop = threading.Event() + self._thread: threading.Thread | None = None + + # ── cycle de mesure ────────────────────────────────────────────────── + def build_status(self) -> dict[str, Any]: + with ThreadPoolExecutor(max_workers=8) as pool: + # 1) probes HTTP (un par service, en parallèle) + probes = { + s.id: pool.submit(probe_http, s.probe_url, self.timeout) + for s in self.services + } + # 2) requêtes Prometheus distinctes (déduplique les exprs identiques) + exprs = {c.prom for s in self.services for c in s.components if c.prom} + prom_futs = { + e: pool.submit(query_prometheus, self.prom_url, e, self.timeout) + for e in exprs + } + # 3) alertes (une seule requête) + alerts_fut = pool.submit(fetch_alerts, self.alert_url, self.timeout) + + prom = {e: f.result() for e, f in prom_futs.items()} + alerts = alerts_fut.result() + + services_out: dict[str, Any] = {} + for s in self.services: + probe = probes[s.id].result() + comps = [ + {"name": c.name, + "state": _component_state(prom.get(c.prom)) if c.prom else "info"} + for c in s.components + ] + svc_alerts = [a for a in (alerts or []) if _alert_matches(a, s.alerts_match)] + degraded = probe["state"] == "up" and any(c["state"] == "down" for c in comps) + if probe["state"] == "down": + health = "down" + elif degraded: + health = "degraded" + elif probe["state"] == "unknown": + health = "unknown" + else: + health = "ok" + services_out[s.id] = { + "state": probe["state"], + "latency_ms": probe["latency_ms"], + "health": health, + "components": comps, + "alerts": svc_alerts, + } + + total = None if alerts is None else len(alerts) + return {"type": "portal-status", "services": services_out, "alerts_total": total} + + # ── thread ─────────────────────────────────────────────────────────── + def _run(self) -> None: + while not self._stop.is_set(): + try: + self.emit(self.build_status()) + except Exception: # noqa: BLE001 — un cycle raté ne tue pas le poller + pass + self._stop.wait(self.interval) + + def start(self) -> None: + if self._thread is not None: + return + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop.set() diff --git a/stt/client/stt/portal/registry.py b/stt/client/stt/portal/registry.py index 5b8293d..e7b86ad 100644 --- a/stt/client/stt/portal/registry.py +++ b/stt/client/stt/portal/registry.py @@ -21,6 +21,17 @@ import unicodedata from dataclasses import dataclass, field +@dataclass(frozen=True) +class Component: + """Brique dont dépend un service (App, Redis, hôte DB…). + + `prom` : requête PromQL renvoyant 1 = en ligne / 0 = en panne (vide = pas de + sonde, le composant reste informatif). Évaluée par le StatusPoller. + """ + name: str + prom: str = "" + + @dataclass(frozen=True) class Service: id: str @@ -28,6 +39,16 @@ class Service: url: str icon: str = "🔗" aliases: tuple[str, ...] = field(default_factory=tuple) + # Métadonnées de la page détail (toutes optionnelles, config-driven) + description: str = "" + health_url: str = "" # endpoint sondé (défaut : url) + components: tuple[Component, ...] = () + # Une alerte Alertmanager est « pertinente » si TOUS ces (label, regex) matchent. + alerts_match: tuple[tuple[str, str], ...] = () + + @property + def probe_url(self) -> str: + return self.health_url or self.url @property def keywords(self) -> list[str]: @@ -58,6 +79,16 @@ def load_services(cfg: dict) -> list[Service]: aliases = tuple( str(a).strip() for a in (raw.get("aliases") or []) if str(a).strip() ) + components = tuple( + Component(name=str(c.get("name") or "").strip(), + prom=str(c.get("prom") or "").strip()) + for c in (raw.get("components") or []) + if isinstance(c, dict) and str(c.get("name") or "").strip() + ) + alerts_match = tuple( + (str(k), str(v)) + for k, v in (raw.get("alerts_match") or {}).items() + ) if isinstance(raw.get("alerts_match"), dict) else () out.append( Service( id=sid, @@ -65,6 +96,10 @@ def load_services(cfg: dict) -> list[Service]: url=url, icon=str(raw.get("icon") or "🔗").strip() or "🔗", aliases=aliases, + description=str(raw.get("description") or "").strip(), + health_url=str(raw.get("health_url") or "").strip(), + components=components, + alerts_match=alerts_match, ) ) return out diff --git a/stt/client/stt/ui/app.py b/stt/client/stt/ui/app.py index 8ebb4db..91c59a9 100644 --- a/stt/client/stt/ui/app.py +++ b/stt/client/stt/ui/app.py @@ -50,13 +50,14 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: """ import websockets - from stt.portal import load_services + from stt.portal import StatusPoller, load_services ui = cfg["ui"] loop = asyncio.get_running_loop() clients: set = set() last_state = {"type": "state", "state": "idle"} last_mic = {"type": "mic", "muted": False} + last_status: dict | None = None # dernier portal-status (santé des services) # Portail : registre des services (HUD + voix). Construit une fois ; le HUD le # reçoit à la connexion et affiche une tuile par service. @@ -65,17 +66,20 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: services_msg = { "type": "services", "services": [ - {"id": s.id, "name": s.name, "url": s.url, "icon": s.icon} + {"id": s.id, "name": s.name, "url": s.url, "icon": s.icon, + "description": s.description} for s in services ], } def broadcast(event: dict) -> None: - nonlocal last_state, last_mic + nonlocal last_state, last_mic, last_status if event.get("type") == "state": last_state = event elif event.get("type") == "mic": last_mic = event + elif event.get("type") == "portal-status": + last_status = event data = json.dumps(event, ensure_ascii=False) for ws in list(clients): asyncio.create_task(_safe_send(ws, data)) @@ -138,6 +142,8 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: await ws.send(json.dumps(last_state, ensure_ascii=False)) await ws.send(json.dumps(last_mic, ensure_ascii=False)) # état micro courant await ws.send(json.dumps(services_msg, ensure_ascii=False)) # tuiles du portail + if last_status is not None: # santé connue → l'envoyer tout de suite + await ws.send(json.dumps(last_status, ensure_ascii=False)) # Le HUD envoie {"type":"settings","data":{...}} (réglage), # {"type":"text","text":"…"} (message tapé) ou # {"type":"control","action":"stop|mute|unmute"} (boutons HUD). @@ -183,6 +189,11 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: # chargement Whisper + boucle audio dans un thread dédié threading.Thread(target=_run_engine, args=(engine, emit), daemon=True).start() + # Santé live du portail : thread de sondage (HTTP + Prometheus + Alertmanager) + portal_cfg = cfg.get("portal") or {} + if portal_cfg.get("status_enabled", True) and services: + StatusPoller(services, portal_cfg, emit).start() + async with websockets.serve(handler, ui["ws_host"], ui["ws_port"]): url = f"http://{ui['ws_host']}:{ui['http_port']}/" print(f" HUD : {url}")