From ebb2eec184c7691b90f83e71e7fa5eff206b765d Mon Sep 17 00:00:00 2001 From: ALI YESILKAYA Date: Wed, 24 Jun 2026 00:18:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(stt-client):=20page=20d=C3=A9tail=20des=20?= =?UTF-8?q?services=20en=20pleine=20page=20+=20m=C3=A9triques=20live=20(#6?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin/ia/stt.md | 1 + stt/client/pyproject.toml | 2 +- stt/client/stt/config.py | 30 +++++++ stt/client/stt/hud/index.html | 134 ++++++++++++++++++++++++------ stt/client/stt/portal/health.py | 13 ++- stt/client/stt/portal/registry.py | 28 +++++++ 6 files changed, 182 insertions(+), 26 deletions(-) diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 3dffa00..aa26edc 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -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 | | — 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) | +| — 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 <service> », « é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 | | STT-server | `stt/server/` (conteneur) | FastAPI : `/healthz`, `/v1/ask` ; `brain.py` → LiteLLM | diff --git a/stt/client/pyproject.toml b/stt/client/pyproject.toml index 830ea2a..72dbe84 100644 --- a/stt/client/pyproject.toml +++ b/stt/client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stt" -version = "0.18.0" +version = "0.19.0" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" requires-python = ">=3.11" readme = "README.md" diff --git a/stt/client/stt/config.py b/stt/client/stt/config.py index 586624b..3584377 100644 --- a/stt/client/stt/config.py +++ b/stt/client/stt/config.py @@ -29,6 +29,29 @@ def resolve_model(name: str) -> str: """Nom court → alias LiteLLM (ou renvoie tel quel si déjà un alias).""" 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] = { "server": { # 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). " "Base PostgreSQL sur storage-01 + cache Redis in-cluster.", "health_url": "http://ghostfolio.lab.local/api/v1/health", + "metrics": _pod_metrics("ai", "ghostfolio-.+"), "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"}'}, @@ -123,6 +147,7 @@ DEFAULT_CONFIG: dict[str, Any] = { "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", + "metrics": _pod_metrics("monitoring", ".*grafana.*"), "components": [ {"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 " "depuis le dépôt Git (pattern App-of-Apps).", "health_url": "http://argocd.lab.local/healthz", + "metrics": _pod_metrics("argocd", ".+"), "components": [ {"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 " "doc Hermès). Base PostgreSQL sur storage-01.", "health_url": "http://n8n.lab.local/healthz", + "metrics": _pod_metrics("ai", "n8n-.+"), "components": [ {"name": "Application", "prom": 'kube_pod_status_ready{namespace="ai",pod=~"n8n-.+",condition="true"}'}, {"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 " "(backend LiteLLM sur storage-01).", "health_url": "http://openwebui.lab.local/health", + "metrics": _pod_metrics("ai", "open-webui-.+"), "components": [ {"name": "Application", "prom": 'kube_pod_status_ready{namespace="ai",pod=~"open-webui-.+",condition="true"}'}, {"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, " "nœuds k8s et services.", "health_url": "http://prometheus.lab.local/-/healthy", + "metrics": _pod_metrics("monitoring", "prometheus-.+"), "components": [ {"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 " "(→ webhook Hermès, n8n, e-mail).", "health_url": "http://alertmanager.lab.local/-/healthy", + "metrics": _pod_metrics("monitoring", "alertmanager-.+"), "components": [ {"name": "Pod", "prom": 'kube_pod_status_ready{namespace="monitoring",pod=~"alertmanager-.+",condition="true"}'}, ], diff --git a/stt/client/stt/hud/index.html b/stt/client/stt/hud/index.html index f3f2128..58211d6 100644 --- a/stt/client/stt/hud/index.html +++ b/stt/client/stt/hud/index.html @@ -780,6 +780,27 @@ .svc-data-row .svc-data-val[data-tone="bad"] { color: #d23a52; } .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-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) */ .svc-alert-badge { @@ -1120,31 +1141,44 @@

- - + +
+
+ + - + + +
-
- Alertes -
-
+
+ -
- Infos -
-
+
+ Alertes +
+
- +
+ Infos +
+
+ + +
+
@@ -1551,12 +1585,16 @@ function updateAlertBadge() { 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) { - if (!servicesById[id]) return; + const s = servicesById[id]; if (!s) return; detailId = id; svcHome.hidden = true; svcDetail.hidden = false; + servicesDrawerEl.classList.add('is-detail'); // drawer → pleine page + servicesTitleEl.textContent = s.name || id; // titre = nom du service svcDetail.parentElement.scrollTop = 0; renderDetail(id); } @@ -1564,6 +1602,8 @@ function showHome() { detailId = null; svcDetail.hidden = true; svcHome.hidden = false; + servicesDrawerEl.classList.remove('is-detail'); + servicesTitleEl.textContent = 'Services'; } function renderDetail(id) { @@ -1583,8 +1623,9 @@ function renderDetail(id) { desc.textContent = s.description || ''; desc.hidden = !s.description; - // Aperçu enrichi (portefeuille, alertes…) + // Aperçu enrichi (portefeuille, alertes…) + métriques live des pods renderServiceData(id); + renderMetrics(st); // Composants const comps = st.components || []; @@ -1636,6 +1677,51 @@ const svcDataLabel = document.getElementById('svcDataLabel'); const svcStats = document.getElementById('svcStats'); const svcDataRows = document.getElementById('svcDataRows'); 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 = ``; + 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) => { if (v == null || isNaN(v)) return '—'; diff --git a/stt/client/stt/portal/health.py b/stt/client/stt/portal/health.py index ad09979..b38da60 100644 --- a/stt/client/stt/portal/health.py +++ b/stt/client/stt/portal/health.py @@ -143,8 +143,10 @@ class StatusPoller: 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) + # 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 |= {m.prom for s in self.services for m in s.metrics if m.prom} prom_futs = { e: pool.submit(query_prometheus, self.prom_url, e, self.timeout) for e in exprs @@ -163,6 +165,14 @@ class StatusPoller: "state": _component_state(prom.get(c.prom)) if c.prom else "info"} 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)] degraded = probe["state"] == "up" and any(c["state"] == "down" for c in comps) if probe["state"] == "down": @@ -178,6 +188,7 @@ class StatusPoller: "latency_ms": probe["latency_ms"], "health": health, "components": comps, + "metrics": metrics_out, "alerts": svc_alerts, } diff --git a/stt/client/stt/portal/registry.py b/stt/client/stt/portal/registry.py index e7b86ad..192046f 100644 --- a/stt/client/stt/portal/registry.py +++ b/stt/client/stt/portal/registry.py @@ -32,6 +32,23 @@ class Component: 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) class Service: id: str @@ -43,6 +60,7 @@ class Service: description: str = "" health_url: str = "" # endpoint sondé (défaut : url) components: tuple[Component, ...] = () + metrics: tuple[Metric, ...] = () # chiffres live (page détail) # Une alerte Alertmanager est « pertinente » si TOUS ces (label, regex) matchent. alerts_match: tuple[tuple[str, str], ...] = () @@ -85,6 +103,15 @@ def load_services(cfg: dict) -> list[Service]: for c in (raw.get("components") or []) 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( (str(k), str(v)) 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(), health_url=str(raw.get("health_url") or "").strip(), components=components, + metrics=metrics, alerts_match=alerts_match, ) )