mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 07:34:43 +02:00
feat(stt-client): pages détail par service + santé live (probe + Prometheus + Alertmanager)
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:
parent
3b6099ffa0
commit
aff036d2c9
9 changed files with 600 additions and 50 deletions
|
|
@ -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`) |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,10 +1006,44 @@
|
|||
</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p class="hint" style="margin:6px 0 16px">Ouvre un service du homelab dans ton navigateur.</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>
|
||||
|
||||
<!-- Toast (confirmation d'ouverture, clic ou voix) -->
|
||||
|
|
@ -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 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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
203
stt/client/stt/portal/health.py
Normal file
203
stt/client/stt/portal/health.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue