mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 14:24:42 +02:00
feat(stt-client): pages détail enrichies (aperçu portefeuille + alertes) (#59)
Les pages détail du portail HUD affichent désormais un aperçu live des données du service, pas seulement santé + lien : - Ghostfolio → portefeuille complet : valeur, performance globale, positions (allocation % + P/L par ligne, coloré). Nouvel endpoint serveur POST /v1/portfolio/details (jeton client, source unique réutilisée). - Alertmanager → TOUTES les alertes actives du homelab (total + critiques + liste avec sévérité), rendu côté HUD depuis `all_alerts` poussé par le StatusPoller (auto-rafraîchi, aucun appel supplémentaire). Mécanisme extensible (`DETAIL_PROVIDERS` : `server` = requête backend | `alerts` = liste locale). Cartes de stats + lignes de données génériques. Client 0.18.0. Vérifié via Playwright (rendu, tons, normalisation des %). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7bfedb0901
commit
f47bc36f29
8 changed files with 288 additions and 13 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue