mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 04:34:41 +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
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</div>
|
||||
<p class="svc-detail-desc hint" id="svcDetailDesc"></p>
|
||||
|
||||
<!-- Aperçu enrichi spécifique au service (portefeuille Ghostfolio, toutes les
|
||||
alertes Alertmanager…). Cartes de stats + lignes de données. -->
|
||||
<div class="svc-section" id="svcDataWrap" hidden>
|
||||
<span class="flabel" id="svcDataLabel">Aperçu</span>
|
||||
<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>
|
||||
<span class="flabel">Composants</span>
|
||||
<div id="svcCompList"></div>
|
||||
|
|
@ -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 = `<span class="svc-stat-label"></span><span class="svc-stat-value"></span>`;
|
||||
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 = `<span class="svc-data-name"></span>` +
|
||||
(sub ? `<span class="svc-data-sub"></span>` : '') +
|
||||
`<span class="svc-data-val"></span>`;
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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