feat(stt-client): pages détail par service + santé live (probe + Prometheus + Alertmanager) (#36)

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 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-20 23:17:27 +02:00 committed by GitHub
parent 3b6099ffa0
commit d4b053622c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 600 additions and 50 deletions

View file

@ -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 <service> ».
# Portail de services — tuiles + pages détail (santé live) + voix « ouvre <x> ».
# 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"

View file

@ -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"

View file

@ -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 <service> ». 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 <service> ». 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",
},
}

View file

@ -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 @@
</button>
</header>
<div class="body">
<p class="hint" style="margin:6px 0 16px">Ouvre un service du homelab dans ton navigateur.</p>
<div class="svc-grid" id="svcGrid" role="list"></div>
<p class="svc-empty hint" id="svcEmpty" hidden>Aucun service configuré (voir <code>[[services]]</code> dans stt.toml).</p>
<!-- Vue liste -->
<div id="svcHome">
<div class="svc-alert-badge" id="svcAlertBadge" hidden></div>
<p class="hint" style="margin:6px 0 16px">Choisis un service pour voir son état et l'ouvrir.</p>
<div class="svc-grid" id="svcGrid" role="list"></div>
<p class="svc-empty hint" id="svcEmpty" hidden>Aucun service configuré (voir <code>[[services]]</code> dans stt.toml).</p>
</div>
<!-- Vue détail (une « page » par service) -->
<div id="svcDetail" hidden>
<button class="svc-back" id="svcBack">← Tous les services</button>
<div class="svc-detail-head">
<span class="svc-ico svc-detail-ico" id="svcDetailIco" aria-hidden="true">🔗</span>
<div class="svc-detail-title">
<h3 id="svcDetailName">Service</h3>
<span class="svc-pill" id="svcDetailPill" data-health="unknown">● Inconnu</span>
</div>
</div>
<p class="svc-detail-desc hint" id="svcDetailDesc"></p>
<div class="svc-section" id="svcDetailCompWrap" hidden>
<span class="flabel">Composants</span>
<div id="svcCompList"></div>
</div>
<div class="svc-section" id="svcDetailAlertsWrap">
<span class="flabel">Alertes</span>
<div id="svcAlertList"></div>
</div>
<div class="svc-section">
<span class="flabel">Infos</span>
<div class="svc-info" id="svcInfo"></div>
</div>
<button class="btn svc-open-btn" id="svcOpenBtn">↗ Ouvrir le service</button>
</div>
</div>
</aside>
@ -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 =
`<span class="svc-ico" aria-hidden="true">${s.icon || '🔗'}</span>` +
`<span class="svc-name"></span>`;
`<span class="svc-meta"><span class="svc-name"></span></span>` +
`<span class="svc-dot" aria-hidden="true"></span>`;
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 = `<span class="svc-dot" aria-hidden="true"></span>` +
`<span class="svc-row-name"></span><span class="svc-state"></span>`;
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 = `<span class="sev"></span><span class="svc-alert-txt"></span>`;
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();
</script>
</body>

View file

@ -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",
]

View file

@ -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()

View file

@ -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

View file

@ -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}")