diff --git a/admin/ia/stt.md b/admin/ia/stt.md
index c02b024..3dffa00 100644
--- a/admin/ia/stt.md
+++ b/admin/ia/stt.md
@@ -212,7 +212,8 @@ 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/` | `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 |
+| — 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) |
| — 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 653b575..830ea2a 100644
--- a/stt/client/pyproject.toml
+++ b/stt/client/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "stt"
-version = "0.17.0"
+version = "0.18.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/api.py b/stt/client/stt/api.py
index f5e3af5..bc82956 100644
--- a/stt/client/stt/api.py
+++ b/stt/client/stt/api.py
@@ -68,6 +68,27 @@ class ServerClient:
except requests.RequestException:
return "Je n'arrive pas à joindre le serveur pour Ghostfolio."
+ def portfolio_details(self) -> dict:
+ """Aperçu structuré du portefeuille (page détail du portail HUD).
+
+ {value, currency, performance_pct, holdings:[…]} ou {error: …}. Dégrade en
+ clair si le serveur est antérieur (pas d'endpoint) ou injoignable.
+ """
+ import requests
+
+ try:
+ r = requests.post(
+ f"{self.url}/v1/portfolio/details",
+ json={"token": self.secrets.get("ghostfolio_token")},
+ timeout=self.timeout,
+ )
+ if r.status_code == 404:
+ return {"error": "Aperçu indisponible (STT-server antérieur)."}
+ r.raise_for_status()
+ return r.json()
+ except requests.RequestException:
+ return {"error": "Je n'arrive pas à joindre le serveur pour Ghostfolio."}
+
def contexts(self) -> dict | None:
"""{'default':…, 'contexts':[{id,label,icon,description}]} ; None si indisponible.
diff --git a/stt/client/stt/hud/index.html b/stt/client/stt/hud/index.html
index 38c4c7c..f3f2128 100644
--- a/stt/client/stt/hud/index.html
+++ b/stt/client/stt/hud/index.html
@@ -751,6 +751,36 @@
.svc-info a { color: var(--accent-active); text-decoration: none; }
.svc-open-btn { margin-top: 18px; }
+ /* Aperçu enrichi (page détail) : cartes de stats + lignes de données */
+ .svc-stats { display: flex; flex-wrap: wrap; gap: 10px; }
+ .svc-stats:empty { display: none; }
+ .svc-stat {
+ flex: 1 1 120px; min-width: 0;
+ padding: 12px 14px; border-radius: 12px;
+ background: var(--bg-soft); border: 1px solid var(--line);
+ }
+ .svc-stat .svc-stat-label {
+ display: block; font-size: 11px; font-weight: 700; text-transform: uppercase;
+ letter-spacing: .04em; color: var(--ink-soft); margin-bottom: 6px;
+ }
+ .svc-stat .svc-stat-value { font-size: 20px; font-weight: 650; letter-spacing: -.01em; }
+ .svc-stat[data-tone="good"] .svc-stat-value { color: #2bb673; }
+ .svc-stat[data-tone="bad"] .svc-stat-value { color: #d23a52; }
+ .svc-data-rows { margin-top: 12px; }
+ .svc-data-rows:empty { display: none; }
+ .svc-data-row {
+ display: flex; align-items: baseline; gap: 10px;
+ padding: 8px 0; font-size: 13.5px; border-top: 1px solid var(--line);
+ }
+ .svc-data-row:first-child { border-top: none; }
+ .svc-data-row .svc-data-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .svc-data-row .svc-data-sub { font-size: 11.5px; color: var(--ink-soft); margin-left: 6px; }
+ .svc-data-row .svc-data-val { font-weight: 600; flex: none; }
+ .svc-data-row .svc-data-val[data-tone="good"] { color: #2bb673; }
+ .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; }
+
/* Badge alertes global (en tête de la liste) */
.svc-alert-badge {
display: flex; align-items: center; gap: 8px;
@@ -1090,6 +1120,15 @@
Composants
@@ -1288,6 +1327,7 @@ function handleEvent(ev) {
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 'service-detail': applyServiceDetail(ev); break; // aperçu enrichi (portefeuille…)
case 'contexts': applyContexts(ev); break; // contextes présélectionnables
case 'meta': setVersion(ev.version || '?'); break; // version installée
case 'context': renderContextViewer(ev.context); break; // contexte assemblé (visualiseur)
@@ -1429,7 +1469,14 @@ let toastTimer = null;
let servicesById = {}; // id → {id,name,url,icon,description}
let portalStatus = {}; // id → {state,health,latency_ms,components,alerts}
let alertsTotal = null;
+let allAlerts = []; // liste complète des alertes actives (page Alertmanager)
let detailId = null; // service affiché en détail, ou null (vue liste)
+let detailData = {}; // id → aperçu enrichi reçu du serveur (ex. portefeuille Ghostfolio)
+
+/* Services dont la page détail a un aperçu enrichi.
+ 'server' : données récupérées via le backend (requête WS). 'alerts' : rendu côté
+ HUD à partir de la liste `allAlerts` déjà poussée (auto-rafraîchie). */
+const DETAIL_PROVIDERS = { ghostfolio: 'server', alertmanager: 'alerts' };
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:'' };
@@ -1442,6 +1489,15 @@ const DEMO_SERVICES = [
{ id:'argocd', name:'ArgoCD', icon:'🚢', description:'Déploiement GitOps.' },
{ id:'n8n', name:'n8n', icon:'🔀', description:'Workflows.' },
];
+/* Aperçus enrichis simulés (mode DÉMO seulement) */
+const DEMO_DETAIL = {
+ ghostfolio: { service:'ghostfolio', kind:'portfolio', data:{
+ value:12480, currency:'EUR', performance_pct:3.2, holdings:[
+ { name:'Apple Inc.', symbol:'AAPL', value:3200, allocation_pct:25.6, performance_pct:12.4 },
+ { name:'Bitcoin', symbol:'BTC', value:2100, allocation_pct:16.8, performance_pct:-4.1 },
+ { name:'MSCI World', symbol:'IWDA', value:1800, allocation_pct:14.4, performance_pct:6.0 },
+ ] } },
+};
function renderServices(list) {
const items = Array.isArray(list) ? list : [];
@@ -1472,6 +1528,7 @@ function renderServices(list) {
function applyPortalStatus(msg) {
portalStatus = (msg && msg.services) || {};
alertsTotal = msg ? msg.alerts_total : null;
+ allAlerts = (msg && Array.isArray(msg.all_alerts)) ? msg.all_alerts : [];
updateTileDots();
updateAlertBadge();
if (detailId && servicesById[detailId]) renderDetail(detailId);
@@ -1526,6 +1583,9 @@ function renderDetail(id) {
desc.textContent = s.description || '';
desc.hidden = !s.description;
+ // Aperçu enrichi (portefeuille, alertes…)
+ renderServiceData(id);
+
// Composants
const comps = st.components || [];
document.getElementById('svcDetailCompWrap').hidden = comps.length === 0;
@@ -1570,6 +1630,101 @@ function renderDetail(id) {
document.getElementById('svcOpenBtn').onclick = () => openService(s);
}
+/* ----- Aperçu enrichi par service (portefeuille, alertes…) ----- */
+const svcDataWrap = document.getElementById('svcDataWrap');
+const svcDataLabel = document.getElementById('svcDataLabel');
+const svcStats = document.getElementById('svcStats');
+const svcDataRows = document.getElementById('svcDataRows');
+const svcDataEmpty = document.getElementById('svcDataEmpty');
+
+const fmtMoney = (v, cur) => {
+ if (v == null || isNaN(v)) return '—';
+ return Math.round(v).toLocaleString('fr-FR') + (cur ? ' ' + curSymbol(cur) : '');
+};
+const curSymbol = (c) => ({ EUR:'€', USD:'$', GBP:'£' })[c] || c;
+const fmtPct = (p) => (p == null || isNaN(p)) ? null
+ : (p >= 0 ? '+' : '') + p.toFixed(1).replace('.', ',') + ' %';
+const pctTone = (p) => (p == null || isNaN(p)) ? '' : (p >= 0 ? 'good' : 'bad');
+
+function clearData() { svcStats.innerHTML = ''; svcDataRows.innerHTML = ''; svcDataEmpty.hidden = true; }
+function addStat(label, value, tone) {
+ const c = document.createElement('div');
+ c.className = 'svc-stat';
+ if (tone) c.dataset.tone = tone;
+ c.innerHTML = `
`;
+ c.querySelector('.svc-stat-label').textContent = label;
+ c.querySelector('.svc-stat-value').textContent = value;
+ svcStats.appendChild(c);
+}
+function addDataRow(name, sub, value, tone) {
+ const r = document.createElement('div');
+ r.className = 'svc-data-row';
+ r.innerHTML = `
` +
+ (sub ? `
` : '') +
+ `
`;
+ r.querySelector('.svc-data-name').textContent = name;
+ if (sub) r.querySelector('.svc-data-sub').textContent = sub;
+ const v = r.querySelector('.svc-data-val');
+ v.textContent = value; if (tone) v.dataset.tone = tone;
+ svcDataRows.appendChild(r);
+}
+function dataEmpty(text) { svcDataEmpty.hidden = false; svcDataEmpty.textContent = text; }
+
+function renderServiceData(id) {
+ const kind = DETAIL_PROVIDERS[id];
+ if (!kind) { svcDataWrap.hidden = true; return; }
+ svcDataWrap.hidden = false;
+ clearData();
+ if (kind === 'alerts') { renderAlertsData(); return; }
+ if (kind === 'server') { // Ghostfolio
+ svcDataLabel.textContent = 'Portefeuille';
+ const d = detailData[id];
+ if (d) renderPortfolio(d);
+ else { dataEmpty('Chargement…'); requestServiceData(id); }
+ }
+}
+
+function renderAlertsData() {
+ svcDataLabel.textContent = 'Alertes actives du homelab';
+ const list = allAlerts || [];
+ const crit = list.filter(a => (a.severity || '').toLowerCase() === 'critical').length;
+ addStat('Total', String(list.length), list.length ? 'bad' : 'good');
+ if (crit) addStat('Critiques', String(crit), 'bad');
+ if (list.length === 0) { dataEmpty('Aucune alerte active ✓'); return; }
+ for (const a of list) {
+ const sev = (a.severity || 'none').toLowerCase();
+ const tone = sev === 'critical' ? 'bad' : (sev === 'warning' ? 'warn' : '');
+ addDataRow(a.name || 'alerte', a.summary || '', sev, tone);
+ }
+}
+
+function renderPortfolio(d) {
+ if (d.error) { dataEmpty(d.error); return; }
+ addStat('Valeur', fmtMoney(d.value, d.currency), '');
+ const perf = fmtPct(d.performance_pct);
+ if (perf) addStat('Performance', perf, pctTone(d.performance_pct));
+ const holdings = Array.isArray(d.holdings) ? d.holdings : [];
+ if (holdings.length === 0) { dataEmpty('Aucune position renvoyée.'); return; }
+ for (const h of holdings) {
+ const alloc = (h.allocation_pct != null && !isNaN(h.allocation_pct))
+ ? Math.round(h.allocation_pct) + ' %' : '';
+ addDataRow(h.name || h.symbol || '?', alloc,
+ fmtMoney(h.value, d.currency), pctTone(h.performance_pct));
+ }
+}
+
+function requestServiceData(id) {
+ if (DEMO) { applyServiceDetail(DEMO_DETAIL[id]); return; }
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ ws.send(JSON.stringify({ type: 'control', action: 'service-detail', service: id }));
+}
+
+function applyServiceDetail(ev) {
+ if (!ev || !ev.service) return;
+ detailData[ev.service] = ev.data || {};
+ if (detailId === ev.service) renderServiceData(ev.service); // page encore ouverte
+}
+
function openService(s) {
showToast(`${s.icon || '🔗'} Ouverture de ${s.name || s.id}`);
if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) {
diff --git a/stt/client/stt/portal/health.py b/stt/client/stt/portal/health.py
index 95f6181..ad09979 100644
--- a/stt/client/stt/portal/health.py
+++ b/stt/client/stt/portal/health.py
@@ -182,7 +182,14 @@ class StatusPoller:
}
total = None if alerts is None else len(alerts)
- return {"type": "portal-status", "services": services_out, "alerts_total": total}
+ # `all_alerts` : liste complète (déjà récupérée) → alimente la page détail
+ # d'Alertmanager (aperçu de TOUTES les alertes, pas seulement celles d'un service).
+ return {
+ "type": "portal-status",
+ "services": services_out,
+ "alerts_total": total,
+ "all_alerts": alerts or [],
+ }
# ── thread ───────────────────────────────────────────────────────────
def _run(self) -> None:
diff --git a/stt/client/stt/ui/app.py b/stt/client/stt/ui/app.py
index 2164678..2de842c 100644
--- a/stt/client/stt/ui/app.py
+++ b/stt/client/stt/ui/app.py
@@ -132,6 +132,17 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None:
stt_bin = _shutil.which("stt") or _sys.argv[0]
+ def fetch_service_detail(sid: str) -> None:
+ """Aperçu enrichi à la demande pour une page détail (HTTP bloquant → executor).
+
+ Seul Ghostfolio passe par le serveur (jeton). Alertmanager se rend côté HUD à
+ partir de `all_alerts` (déjà poussé par le poller) — pas d'appel ici.
+ """
+ if sid == "ghostfolio":
+ data = client.portfolio_details()
+ emit({"type": "service-detail", "service": sid,
+ "kind": "portfolio", "data": data})
+
def run_service_action(action: str) -> None:
"""Pilote le service depuis le HUD : restart / stop / update.
@@ -187,6 +198,11 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None:
elif mtype == "control" and msg.get("action") == "open-service":
# indépendant du moteur vocal → cliquable même sans micro
open_service(str(msg.get("service") or ""))
+ elif mtype == "control" and msg.get("action") == "service-detail":
+ # aperçu enrichi d'une page détail (Ghostfolio → serveur)
+ loop.run_in_executor(
+ None, fetch_service_detail, str(msg.get("service") or "")
+ )
elif mtype == "control" and msg.get("action") in (
"service-restart", "service-stop", "service-update"
):
diff --git a/stt/server/stt_server/app.py b/stt/server/stt_server/app.py
index 8b5892a..8c2db00 100644
--- a/stt/server/stt_server/app.py
+++ b/stt/server/stt_server/app.py
@@ -25,7 +25,7 @@ from stt_server.contexts import CONTEXTS, assemble, get_context
from stt_server.knowledge import Knowledge
from stt_server.longterm import LongTermMemory
from stt_server.memory import SessionStore
-from stt_server.sources import fetch_blocks, ghostfolio_phrase
+from stt_server.sources import fetch_blocks, ghostfolio_details, ghostfolio_phrase
log = logging.getLogger("stt_server")
# uvicorn ne configure que ses propres loggers : on attache notre handler en INFO
@@ -126,6 +126,16 @@ async def v1_portfolio(req: PortfolioRequest) -> dict:
return {"summary": summary}
+@app.post("/v1/portfolio/details")
+async def v1_portfolio_details(req: PortfolioRequest) -> dict:
+ """Aperçu structuré du portefeuille (page détail du portail HUD).
+
+ Même source de jeton que /v1/portfolio (client prioritaire, sinon env serveur).
+ """
+ async with httpx.AsyncClient() as c:
+ return await ghostfolio_details(c, req.token)
+
+
@app.post("/v1/reset")
async def v1_reset(req: AskRequest) -> dict:
if req.session_id:
diff --git a/stt/server/stt_server/sources.py b/stt/server/stt_server/sources.py
index 1dd3544..34991cc 100644
--- a/stt/server/stt_server/sources.py
+++ b/stt/server/stt_server/sources.py
@@ -38,10 +38,25 @@ def _money(v) -> str:
return f"{round(float(v)):,}".replace(",", " ")
-async def _ghostfolio_fetch(client: httpx.AsyncClient, token: str | None):
- """Récupère le portefeuille → (value, currency, holdings) ou une str d'erreur.
+def _pct(v) -> float | None:
+ """Normalise un pourcentage Ghostfolio → toujours en POURCENT.
+
+ Ghostfolio rend ses ratios tantôt en fraction (0.12 = 12 %), tantôt déjà en
+ pourcent (12) selon la version/le champ. Heuristique : |v| ≤ 1.5 ⇒ fraction.
+ """
+ try:
+ v = float(v)
+ except (TypeError, ValueError):
+ return None
+ return v * 100 if -1.5 <= v <= 1.5 else v
+
+
+async def _ghostfolio_data(client: httpx.AsyncClient, token: str | None):
+ """Auth + GET /portfolio/details → dict brut Ghostfolio, ou une str d'erreur.
`token` : jeton fourni par le client (prioritaire) ; à défaut, jeton serveur (env).
+ Source UNIQUE de l'appel Ghostfolio (réutilisée par le bloc contexte, la phrase
+ vocale et l'aperçu structuré du portail).
"""
base = settings.ghostfolio_url.rstrip("/")
token = token or settings.ghostfolio_token
@@ -62,19 +77,26 @@ async def _ghostfolio_fetch(client: httpx.AsyncClient, token: str | None):
headers={"Authorization": f"Bearer {jwt}"}, timeout=settings.sources_timeout,
)
r.raise_for_status()
- data = r.json() or {}
- summary = data.get("summary", {}) or {}
- val = (summary.get("currentValueInBaseCurrency")
- or summary.get("currentValue") or summary.get("netWorth"))
- cur = summary.get("baseCurrency") or "EUR"
- holdings = data.get("holdings", {}) or {}
- return (val, cur, holdings)
+ return r.json() or {}
except httpx.HTTPError:
return "Données indisponibles : Ghostfolio injoignable."
except (ValueError, KeyError):
return "Données indisponibles : réponse Ghostfolio inattendue."
+async def _ghostfolio_fetch(client: httpx.AsyncClient, token: str | None):
+ """Récupère le portefeuille → (value, currency, holdings) ou une str d'erreur."""
+ data = await _ghostfolio_data(client, token)
+ if isinstance(data, str):
+ return data
+ summary = data.get("summary", {}) or {}
+ val = (summary.get("currentValueInBaseCurrency")
+ or summary.get("currentValue") or summary.get("netWorth"))
+ cur = summary.get("baseCurrency") or "EUR"
+ holdings = data.get("holdings", {}) or {}
+ return (val, cur, holdings)
+
+
async def ghostfolio_block(client: httpx.AsyncClient, token: str | None = None) -> str:
"""Bloc de contexte (valeur + positions principales)."""
res = await _ghostfolio_fetch(client, token)
@@ -107,6 +129,49 @@ async def ghostfolio_phrase(client: httpx.AsyncClient, token: str | None = None)
return f"Ton portefeuille Ghostfolio vaut environ {_money(val)} {cur}."
+async def ghostfolio_details(client: httpx.AsyncClient, token: str | None = None) -> dict:
+ """Aperçu STRUCTURÉ du portefeuille (page détail du portail HUD).
+
+ Renvoie {value, currency, performance_pct, holdings:[{name, symbol, value,
+ allocation_pct, performance_pct}]} ou {error: …}. Lecture seule, best-effort.
+ """
+ data = await _ghostfolio_data(client, token)
+ if isinstance(data, str):
+ return {"error": data}
+ summary = data.get("summary", {}) or {}
+ val = (summary.get("currentValueInBaseCurrency")
+ or summary.get("currentValue") or summary.get("netWorth"))
+ cur = summary.get("baseCurrency") or "EUR"
+ perf = _pct(
+ summary.get("currentNetPerformancePercent")
+ or summary.get("netPerformancePercent")
+ or summary.get("currentNetPerformancePercentWithCurrencyEffect")
+ )
+ holdings = data.get("holdings", {}) or {}
+ rows = sorted(
+ (h for h in holdings.values() if isinstance(h, dict)),
+ key=lambda h: h.get("valueInBaseCurrency") or 0, reverse=True,
+ )
+ out = []
+ for h in rows[:12]:
+ hv = h.get("valueInBaseCurrency")
+ if not hv:
+ continue
+ out.append({
+ "name": h.get("name") or h.get("symbol") or "?",
+ "symbol": h.get("symbol") or "",
+ "value": round(float(hv)),
+ "allocation_pct": _pct(h.get("allocationInPercentage")),
+ "performance_pct": _pct(h.get("netPerformancePercent")),
+ })
+ return {
+ "value": round(float(val)) if val is not None else None,
+ "currency": cur,
+ "performance_pct": perf,
+ "holdings": out,
+ }
+
+
async def alerts_block(client: httpx.AsyncClient) -> str:
base = settings.alertmanager_url.rstrip("/")
try: