feat(stt-client): page détail des services en pleine page + métriques live (#60)

La page détail d'un service (panneau « Services » du HUD) passe en PLEINE
PAGE : le drawer prend tout l'écran, le titre devient le nom du service, et
le contenu s'organise en 2 colonnes sur grand écran (données à gauche, état
+ actions à droite).

Nouvelle section « Métriques » par service, config-driven (comme `components`) :
- `[[services.metrics]]` → {label, prom, unit, hide_if_empty} évalué par le
  StatusPoller (dédup + parallèle avec les composants).
- Défauts via `config._pod_metrics(ns, pod_re)` pour les 7 services : redémarrages
  + uptime (kube-state-metrics, toujours dispo) ; mémoire + CPU (cAdvisor,
  `hide_if_empty` → carte masquée si non scrappé).
- Formatage HUD par unité : duration (8 j 9 h), bytes (497 Mo), cpu (30 mcœur),
  count.

Client 0.19.0. PromQL vérifié live (les 4 métriques renvoient des valeurs réelles
sur les 7 services). Rendu pleine page + formats validés via Playwright.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-24 00:18:39 +02:00 committed by GitHub
parent 4bf4655a83
commit ebb2eec184
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 182 additions and 26 deletions

View file

@ -214,6 +214,7 @@ au prompt système (`STT_DISABLE_THINKING`, défaut `true` ; inoffensif hors Qwe
| — UI/HUD | `stt/client/stt/ui/` + `hud/` | HTTP statique + websocket bidirectionnel (états → HUD ; réglages **et messages texte** → backend) ; HUD embarqué dans le package | | — 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/` | `registry.py` (services config-driven `[[services]]`) + `health.py` (StatusPoller). Panneau « Services » du HUD : tuiles + **pages détail** (description, **santé live**, composants, alertes, **aperçu enrichi**, bouton Ouvrir). Santé = probe HTTP + **Prometheus** `up{}`/kube-state-metrics + **Alertmanager**. 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, **aperçu enrichi**, bouton Ouvrir). Santé = probe HTTP + **Prometheus** `up{}`/kube-state-metrics + **Alertmanager**. Ouvre l'URL dans le navigateur normal |
| — aperçu enrichi (≥ 0.18.0) | `hud/index.html` (`renderServiceData`) + `ui/app.py` (`service-detail`) | Aperçu live spécifique au service dans sa page détail : **Ghostfolio** → valeur + performance + positions (alloc % et P/L par ligne, via `POST /v1/portfolio/details` du serveur, jeton client) ; **Alertmanager****toutes** les alertes actives (rendu HUD à partir de `all_alerts` poussé par le poller, auto-rafraîchi). Extensible : `DETAIL_PROVIDERS` (`server` = requête backend \| `alerts` = liste locale) | | — aperçu enrichi (≥ 0.18.0) | `hud/index.html` (`renderServiceData`) + `ui/app.py` (`service-detail`) | Aperçu live spécifique au service dans sa page détail : **Ghostfolio** → valeur + performance + positions (alloc % et P/L par ligne, via `POST /v1/portfolio/details` du serveur, jeton client) ; **Alertmanager****toutes** les alertes actives (rendu HUD à partir de `all_alerts` poussé par le poller, auto-rafraîchi). Extensible : `DETAIL_PROVIDERS` (`server` = requête backend \| `alerts` = liste locale) |
| — page détail pleine page + métriques (≥ 0.19.0) | `hud/index.html` (`renderMetrics`, `is-detail`) + `registry.Metric` + `health.py` | La page détail passe en **pleine page** (le drawer prend tout l'écran, 2 colonnes sur grand écran : données ⟂ état). Section **Métriques** par service (config `[[services.metrics]]``{label, prom, unit, hide_if_empty}`) évaluée par le `StatusPoller` : redémarrages, uptime (kube-state-metrics) + mémoire, CPU (cAdvisor, masqués si absents). Défauts via `config._pod_metrics(ns, pod_re)`. Formatage HUD par unité (`duration`/`bytes`/`cpu`/`count`) |
| — intentions vocales | `stt/client/stt/portal/intents.py` + `ghostfolio.py` | lecture seule, court-circuitent le LLM (interceptées dans `engine._respond`) : « ouvre &lt;service&gt; », « état du cluster/services » (résumé santé parlé), « combien sur mon ghostfolio » (API Ghostfolio, `[ghostfolio] access_token`) | | — intentions vocales | `stt/client/stt/portal/intents.py` + `ghostfolio.py` | lecture seule, court-circuitent le LLM (interceptées dans `engine._respond`) : « ouvre &lt;service&gt; », « état du cluster/services » (résumé santé parlé), « combien sur mon ghostfolio » (API Ghostfolio, `[ghostfolio] access_token`) |
| — contexte HUD | `stt/client/stt/ui/app.py` + `hud/` | sélecteur de contexte (Réglages) envoyé par requête + **visualiseur** (drawer) affichant le contexte assemblé renvoyé par le serveur ; affichage de la version installée | | — contexte HUD | `stt/client/stt/ui/app.py` + `hud/` | sélecteur de contexte (Réglages) envoyé par requête + **visualiseur** (drawer) affichant le contexte assemblé renvoyé par le serveur ; affichage de la version installée |
| STT-server | `stt/server/` (conteneur) | FastAPI : `/healthz`, `/v1/ask` ; `brain.py` → LiteLLM | | STT-server | `stt/server/` (conteneur) | FastAPI : `/healthz`, `/v1/ask` ; `brain.py` → LiteLLM |

View file

@ -1,6 +1,6 @@
[project] [project]
name = "stt" name = "stt"
version = "0.18.0" version = "0.19.0"
description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)"
requires-python = ">=3.11" requires-python = ">=3.11"
readme = "README.md" readme = "README.md"

View file

@ -29,6 +29,29 @@ def resolve_model(name: str) -> str:
"""Nom court → alias LiteLLM (ou renvoie tel quel si déjà un alias).""" """Nom court → alias LiteLLM (ou renvoie tel quel si déjà un alias)."""
return MODEL_PRESETS.get(name, name) return MODEL_PRESETS.get(name, name)
def _pod_metrics(ns: str, pod_re: str) -> list[dict]:
"""Jeu de métriques standard pour les pods d'un service (page détail HUD).
Redémarrages + uptime : kube-state-metrics (toujours scrappé). Mémoire + CPU :
cAdvisor/kubelet (`hide_if_empty` la carte disparaît si non disponible). Les
requêtes s'agrègent à une seule valeur (sum/max) → le poller prend la 1re.
"""
sel = f'namespace="{ns}",pod=~"{pod_re}"'
csel = f'namespace="{ns}",pod=~"{pod_re}",container!="",container!="POD"'
return [
{"label": "Redémarrages",
"prom": f"sum(kube_pod_container_status_restarts_total{{{sel}}})", "unit": "count"},
{"label": "Démarré depuis",
"prom": f"time()-max(kube_pod_start_time{{{sel}}})", "unit": "duration"},
{"label": "Mémoire",
"prom": f"sum(container_memory_working_set_bytes{{{csel}}})",
"unit": "bytes", "hide_if_empty": True},
{"label": "CPU",
"prom": f"sum(rate(container_cpu_usage_seconds_total{{{csel}}}[5m]))",
"unit": "cpu", "hide_if_empty": True},
]
DEFAULT_CONFIG: dict[str, Any] = { DEFAULT_CONFIG: dict[str, Any] = {
"server": { "server": {
# STT-server (in-cluster) — joignable via Traefik / wildcard *.lab.local # STT-server (in-cluster) — joignable via Traefik / wildcard *.lab.local
@ -109,6 +132,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
"description": "Suivi de portefeuille et d'investissements (actions, crypto). " "description": "Suivi de portefeuille et d'investissements (actions, crypto). "
"Base PostgreSQL sur storage-01 + cache Redis in-cluster.", "Base PostgreSQL sur storage-01 + cache Redis in-cluster.",
"health_url": "http://ghostfolio.lab.local/api/v1/health", "health_url": "http://ghostfolio.lab.local/api/v1/health",
"metrics": _pod_metrics("ai", "ghostfolio-.+"),
"components": [ "components": [
{"name": "Application", "prom": 'kube_pod_status_ready{namespace="ai",pod=~"ghostfolio-.+",pod!~"ghostfolio-redis-.+",condition="true"}'}, {"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": "Redis (cache)", "prom": 'kube_pod_status_ready{namespace="ai",pod=~"ghostfolio-redis-.+",condition="true"}'},
@ -123,6 +147,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
"description": "Tableaux de bord et visualisation des métriques du homelab " "description": "Tableaux de bord et visualisation des métriques du homelab "
"(Prometheus comme source de données).", "(Prometheus comme source de données).",
"health_url": "http://grafana.lab.local/api/health", "health_url": "http://grafana.lab.local/api/health",
"metrics": _pod_metrics("monitoring", ".*grafana.*"),
"components": [ "components": [
{"name": "Pod", "prom": 'kube_pod_status_ready{namespace="monitoring",pod=~".*grafana.*",condition="true"}'}, {"name": "Pod", "prom": 'kube_pod_status_ready{namespace="monitoring",pod=~".*grafana.*",condition="true"}'},
], ],
@ -135,6 +160,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
"description": "Déploiement continu GitOps : synchronise les workloads k8s " "description": "Déploiement continu GitOps : synchronise les workloads k8s "
"depuis le dépôt Git (pattern App-of-Apps).", "depuis le dépôt Git (pattern App-of-Apps).",
"health_url": "http://argocd.lab.local/healthz", "health_url": "http://argocd.lab.local/healthz",
"metrics": _pod_metrics("argocd", ".+"),
"components": [ "components": [
{"name": "Pods (server, repo, redis…)", "prom": 'kube_pod_status_ready{namespace="argocd",condition="true"}'}, {"name": "Pods (server, repo, redis…)", "prom": 'kube_pod_status_ready{namespace="argocd",condition="true"}'},
], ],
@ -147,6 +173,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
"description": "Automatisation et workflows (alertes, rapport horaire, worker " "description": "Automatisation et workflows (alertes, rapport horaire, worker "
"doc Hermès). Base PostgreSQL sur storage-01.", "doc Hermès). Base PostgreSQL sur storage-01.",
"health_url": "http://n8n.lab.local/healthz", "health_url": "http://n8n.lab.local/healthz",
"metrics": _pod_metrics("ai", "n8n-.+"),
"components": [ "components": [
{"name": "Application", "prom": 'kube_pod_status_ready{namespace="ai",pod=~"n8n-.+",condition="true"}'}, {"name": "Application", "prom": 'kube_pod_status_ready{namespace="ai",pod=~"n8n-.+",condition="true"}'},
{"name": "PostgreSQL — storage-01", "prom": 'up{job="storage-01"}'}, {"name": "PostgreSQL — storage-01", "prom": 'up{job="storage-01"}'},
@ -160,6 +187,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
"description": "Interface de chat web vers les LLM du homelab " "description": "Interface de chat web vers les LLM du homelab "
"(backend LiteLLM sur storage-01).", "(backend LiteLLM sur storage-01).",
"health_url": "http://openwebui.lab.local/health", "health_url": "http://openwebui.lab.local/health",
"metrics": _pod_metrics("ai", "open-webui-.+"),
"components": [ "components": [
{"name": "Application", "prom": 'kube_pod_status_ready{namespace="ai",pod=~"open-webui-.+",condition="true"}'}, {"name": "Application", "prom": 'kube_pod_status_ready{namespace="ai",pod=~"open-webui-.+",condition="true"}'},
{"name": "LiteLLM — storage-01", "prom": 'up{job="storage-01"}'}, {"name": "LiteLLM — storage-01", "prom": 'up{job="storage-01"}'},
@ -173,6 +201,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
"description": "Collecte et stockage des métriques (TSDB) — scrute hôtes, " "description": "Collecte et stockage des métriques (TSDB) — scrute hôtes, "
"nœuds k8s et services.", "nœuds k8s et services.",
"health_url": "http://prometheus.lab.local/-/healthy", "health_url": "http://prometheus.lab.local/-/healthy",
"metrics": _pod_metrics("monitoring", "prometheus-.+"),
"components": [ "components": [
{"name": "Pod", "prom": 'kube_pod_status_ready{namespace="monitoring",pod=~"prometheus-.+",condition="true"}'}, {"name": "Pod", "prom": 'kube_pod_status_ready{namespace="monitoring",pod=~"prometheus-.+",condition="true"}'},
], ],
@ -185,6 +214,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
"description": "Routage et notification des alertes Prometheus " "description": "Routage et notification des alertes Prometheus "
"(→ webhook Hermès, n8n, e-mail).", "(→ webhook Hermès, n8n, e-mail).",
"health_url": "http://alertmanager.lab.local/-/healthy", "health_url": "http://alertmanager.lab.local/-/healthy",
"metrics": _pod_metrics("monitoring", "alertmanager-.+"),
"components": [ "components": [
{"name": "Pod", "prom": 'kube_pod_status_ready{namespace="monitoring",pod=~"alertmanager-.+",condition="true"}'}, {"name": "Pod", "prom": 'kube_pod_status_ready{namespace="monitoring",pod=~"alertmanager-.+",condition="true"}'},
], ],

View file

@ -780,6 +780,27 @@
.svc-data-row .svc-data-val[data-tone="bad"] { color: #d23a52; } .svc-data-row .svc-data-val[data-tone="bad"] { color: #d23a52; }
.svc-data-row .svc-data-val[data-tone="warn"] { color: #c5841f; } .svc-data-row .svc-data-val[data-tone="warn"] { color: #c5841f; }
.svc-data-empty { font-size: 13px; color: var(--ink-soft); margin: 4px 0 0; } .svc-data-empty { font-size: 13px; color: var(--ink-soft); margin: 4px 0 0; }
.svc-stat .svc-stat-sub { display: block; font-size: 11px; color: var(--ink-soft); margin-top: 2px; font-weight: 500; }
/* ============================================================
PORTAIL — page détail PLEINE PAGE (le drawer prend tout l'écran)
============================================================ */
#servicesDrawer.is-detail { width: 100vw; max-width: 100vw; }
#servicesDrawer.is-detail .body { padding: 0; }
/* contenu centré et large, façon « vraie page » */
#svcDetail { max-width: 1040px; margin: 0 auto; padding: 18px 28px 40px; }
/* 2 colonnes sur grand écran : données à gauche, état/actions à droite */
.svc-detail-grid { display: block; }
@media (min-width: 820px) {
.svc-detail-grid {
display: grid; grid-template-columns: 1.4fr 1fr;
gap: 0 40px; align-items: start;
}
.svc-col-side .svc-section:first-child { padding-top: 0; }
.svc-col-side .svc-section:first-child .flabel { margin-top: 0; }
}
/* en pleine page, les cartes de stats peuvent s'étaler davantage */
#servicesDrawer.is-detail .svc-stat { flex-basis: 150px; }
/* Badge alertes global (en tête de la liste) */ /* Badge alertes global (en tête de la liste) */
.svc-alert-badge { .svc-alert-badge {
@ -1120,31 +1141,44 @@
</div> </div>
<p class="svc-detail-desc hint" id="svcDetailDesc"></p> <p class="svc-detail-desc hint" id="svcDetailDesc"></p>
<!-- Aperçu enrichi spécifique au service (portefeuille Ghostfolio, toutes les <!-- Pleine page : 2 colonnes sur grand écran (données à gauche, état à droite) -->
alertes Alertmanager…). Cartes de stats + lignes de données. --> <div class="svc-detail-grid">
<div class="svc-section" id="svcDataWrap" hidden> <div class="svc-col-main">
<span class="flabel" id="svcDataLabel">Aperçu</span> <!-- Aperçu enrichi spécifique au service (portefeuille Ghostfolio, toutes
<div class="svc-stats" id="svcStats"></div> les alertes Alertmanager…). Cartes de stats + lignes de données. -->
<div class="svc-data-rows" id="svcDataRows"></div> <div class="svc-section" id="svcDataWrap" hidden>
<p class="svc-data-empty hint" id="svcDataEmpty" hidden></p> <span class="flabel" id="svcDataLabel">Aperçu</span>
</div> <div class="svc-stats" id="svcStats"></div>
<div class="svc-data-rows" id="svcDataRows"></div>
<p class="svc-data-empty hint" id="svcDataEmpty" hidden></p>
</div>
<div class="svc-section" id="svcDetailCompWrap" hidden> <!-- Métriques live des pods (redémarrages, uptime, mémoire, CPU) -->
<span class="flabel">Composants</span> <div class="svc-section" id="svcMetricsWrap" hidden>
<div id="svcCompList"></div> <span class="flabel">Métriques</span>
</div> <div class="svc-stats" id="svcMetrics"></div>
</div>
</div>
<div class="svc-section" id="svcDetailAlertsWrap"> <div class="svc-col-side">
<span class="flabel">Alertes</span> <div class="svc-section" id="svcDetailCompWrap" hidden>
<div id="svcAlertList"></div> <span class="flabel">Composants</span>
</div> <div id="svcCompList"></div>
</div>
<div class="svc-section"> <div class="svc-section" id="svcDetailAlertsWrap">
<span class="flabel">Infos</span> <span class="flabel">Alertes</span>
<div class="svc-info" id="svcInfo"></div> <div id="svcAlertList"></div>
</div> </div>
<button class="btn svc-open-btn" id="svcOpenBtn">↗ Ouvrir le service</button> <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>
</div> </div>
</div> </div>
</aside> </aside>
@ -1551,12 +1585,16 @@ function updateAlertBadge() {
svcAlertBadge.textContent = `⚠ ${alertsTotal} alerte${s} active${s} dans le homelab`; svcAlertBadge.textContent = `⚠ ${alertsTotal} alerte${s} active${s} dans le homelab`;
} }
/* ----- Navigation liste ⟷ détail ----- */ /* ----- Navigation liste ⟷ détail (la vue détail passe en PLEINE PAGE) ----- */
const servicesDrawerEl = document.getElementById('servicesDrawer');
const servicesTitleEl = document.getElementById('servicesTitle');
function showDetail(id) { function showDetail(id) {
if (!servicesById[id]) return; const s = servicesById[id]; if (!s) return;
detailId = id; detailId = id;
svcHome.hidden = true; svcHome.hidden = true;
svcDetail.hidden = false; svcDetail.hidden = false;
servicesDrawerEl.classList.add('is-detail'); // drawer → pleine page
servicesTitleEl.textContent = s.name || id; // titre = nom du service
svcDetail.parentElement.scrollTop = 0; svcDetail.parentElement.scrollTop = 0;
renderDetail(id); renderDetail(id);
} }
@ -1564,6 +1602,8 @@ function showHome() {
detailId = null; detailId = null;
svcDetail.hidden = true; svcDetail.hidden = true;
svcHome.hidden = false; svcHome.hidden = false;
servicesDrawerEl.classList.remove('is-detail');
servicesTitleEl.textContent = 'Services';
} }
function renderDetail(id) { function renderDetail(id) {
@ -1583,8 +1623,9 @@ function renderDetail(id) {
desc.textContent = s.description || ''; desc.textContent = s.description || '';
desc.hidden = !s.description; desc.hidden = !s.description;
// Aperçu enrichi (portefeuille, alertes…) // Aperçu enrichi (portefeuille, alertes…) + métriques live des pods
renderServiceData(id); renderServiceData(id);
renderMetrics(st);
// Composants // Composants
const comps = st.components || []; const comps = st.components || [];
@ -1636,6 +1677,51 @@ const svcDataLabel = document.getElementById('svcDataLabel');
const svcStats = document.getElementById('svcStats'); const svcStats = document.getElementById('svcStats');
const svcDataRows = document.getElementById('svcDataRows'); const svcDataRows = document.getElementById('svcDataRows');
const svcDataEmpty = document.getElementById('svcDataEmpty'); const svcDataEmpty = document.getElementById('svcDataEmpty');
const svcMetricsWrap = document.getElementById('svcMetricsWrap');
const svcMetrics = document.getElementById('svcMetrics');
/* Formatage d'une métrique selon son unité (poller → page détail) */
function fmtMetric(value, unit) {
if (value == null || isNaN(value)) return '—';
const v = Number(value);
if (unit === 'duration') return fmtDuration(v);
if (unit === 'bytes') return fmtBytes(v);
if (unit === 'cpu') return v < 1 ? Math.round(v * 1000) + ' mcœur' : v.toFixed(2).replace('.', ',') + ' cœur';
if (unit === 'percent') return Math.round(v) + ' %';
if (unit === 'count') return String(Math.round(v));
return Math.round(v).toLocaleString('fr-FR');
}
function fmtDuration(sec) {
sec = Math.max(0, Math.round(sec));
const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60);
if (d > 0) return `${d} j ${h} h`;
if (h > 0) return `${h} h ${m} min`;
if (m > 0) return `${m} min`;
return `${sec} s`;
}
function fmtBytes(b) {
if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace('.', ',') + ' Go';
if (b >= 1048576) return Math.round(b / 1048576) + ' Mo';
if (b >= 1024) return Math.round(b / 1024) + ' Ko';
return Math.round(b) + ' o';
}
/* Métriques live des pods (redémarrages, uptime, mémoire, CPU) */
function renderMetrics(st) {
const metrics = (st && Array.isArray(st.metrics)) ? st.metrics : [];
svcMetrics.innerHTML = '';
svcMetricsWrap.hidden = metrics.length === 0;
for (const m of metrics) {
const c = document.createElement('div');
c.className = 'svc-stat';
// redémarrages > 0 = avertissement léger ; sinon neutre
if (m.unit === 'count' && m.label && m.label.toLowerCase().includes('redémarr') && Number(m.value) > 0) c.dataset.tone = 'bad';
c.innerHTML = `<span class="svc-stat-label"></span><span class="svc-stat-value"></span>`;
c.querySelector('.svc-stat-label').textContent = m.label;
c.querySelector('.svc-stat-value').textContent = fmtMetric(m.value, m.unit);
svcMetrics.appendChild(c);
}
}
const fmtMoney = (v, cur) => { const fmtMoney = (v, cur) => {
if (v == null || isNaN(v)) return '—'; if (v == null || isNaN(v)) return '—';

View file

@ -143,8 +143,10 @@ class StatusPoller:
s.id: pool.submit(probe_http, s.probe_url, self.timeout) s.id: pool.submit(probe_http, s.probe_url, self.timeout)
for s in self.services for s in self.services
} }
# 2) requêtes Prometheus distinctes (déduplique les exprs identiques) # 2) requêtes Prometheus distinctes (déduplique les exprs identiques) —
# composants (1/0) ET métriques chiffrées de page détail.
exprs = {c.prom for s in self.services for c in s.components if c.prom} exprs = {c.prom for s in self.services for c in s.components if c.prom}
exprs |= {m.prom for s in self.services for m in s.metrics if m.prom}
prom_futs = { prom_futs = {
e: pool.submit(query_prometheus, self.prom_url, e, self.timeout) e: pool.submit(query_prometheus, self.prom_url, e, self.timeout)
for e in exprs for e in exprs
@ -163,6 +165,14 @@ class StatusPoller:
"state": _component_state(prom.get(c.prom)) if c.prom else "info"} "state": _component_state(prom.get(c.prom)) if c.prom else "info"}
for c in s.components for c in s.components
] ]
# Métriques chiffrées : 1re valeur de la série agrégée (None si absente).
metrics_out = []
for m in s.metrics:
vals = prom.get(m.prom)
value = vals[0] if vals else None
if value is None and m.hide_if_empty:
continue # ex. cAdvisor non scrappé → on n'affiche pas la carte
metrics_out.append({"label": m.label, "value": value, "unit": m.unit})
svc_alerts = [a for a in (alerts or []) if _alert_matches(a, s.alerts_match)] 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) degraded = probe["state"] == "up" and any(c["state"] == "down" for c in comps)
if probe["state"] == "down": if probe["state"] == "down":
@ -178,6 +188,7 @@ class StatusPoller:
"latency_ms": probe["latency_ms"], "latency_ms": probe["latency_ms"],
"health": health, "health": health,
"components": comps, "components": comps,
"metrics": metrics_out,
"alerts": svc_alerts, "alerts": svc_alerts,
} }

View file

@ -32,6 +32,23 @@ class Component:
prom: str = "" prom: str = ""
@dataclass(frozen=True)
class Metric:
"""Chiffre live affiché sur la page détail (redémarrages, uptime, mémoire…).
`prom` : requête PromQL qui DOIT s'agréger à une seule valeur (sum/max/…). Le
StatusPoller prend la première valeur retournée.
`unit` : formatage côté HUD "count" | "duration" (secondes) | "bytes" |
"cpu" (cœurs) | "percent" | "" (nombre brut).
`hide_if_empty` : carte masquée si la requête ne renvoie aucune série (ex. cAdvisor
absent) pas de carte « » disgracieuse.
"""
label: str
prom: str
unit: str = ""
hide_if_empty: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
class Service: class Service:
id: str id: str
@ -43,6 +60,7 @@ class Service:
description: str = "" description: str = ""
health_url: str = "" # endpoint sondé (défaut : url) health_url: str = "" # endpoint sondé (défaut : url)
components: tuple[Component, ...] = () components: tuple[Component, ...] = ()
metrics: tuple[Metric, ...] = () # chiffres live (page détail)
# Une alerte Alertmanager est « pertinente » si TOUS ces (label, regex) matchent. # Une alerte Alertmanager est « pertinente » si TOUS ces (label, regex) matchent.
alerts_match: tuple[tuple[str, str], ...] = () alerts_match: tuple[tuple[str, str], ...] = ()
@ -85,6 +103,15 @@ def load_services(cfg: dict) -> list[Service]:
for c in (raw.get("components") or []) for c in (raw.get("components") or [])
if isinstance(c, dict) and str(c.get("name") or "").strip() if isinstance(c, dict) and str(c.get("name") or "").strip()
) )
metrics = tuple(
Metric(label=str(m.get("label") or "").strip(),
prom=str(m.get("prom") or "").strip(),
unit=str(m.get("unit") or "").strip(),
hide_if_empty=bool(m.get("hide_if_empty")))
for m in (raw.get("metrics") or [])
if isinstance(m, dict) and str(m.get("label") or "").strip()
and str(m.get("prom") or "").strip()
)
alerts_match = tuple( alerts_match = tuple(
(str(k), str(v)) (str(k), str(v))
for k, v in (raw.get("alerts_match") or {}).items() for k, v in (raw.get("alerts_match") or {}).items()
@ -99,6 +126,7 @@ def load_services(cfg: dict) -> list[Service]:
description=str(raw.get("description") or "").strip(), description=str(raw.get("description") or "").strip(),
health_url=str(raw.get("health_url") or "").strip(), health_url=str(raw.get("health_url") or "").strip(),
components=components, components=components,
metrics=metrics,
alerts_match=alerts_match, alerts_match=alerts_match,
) )
) )