feat: add services page and API endpoints for service cost breakdown
- Implemented new API endpoints for fetching global services and service tenants details. - Created a new services.html page with a sidebar for navigation and a chart for service costs. - Updated frontend JavaScript to handle service data loading and rendering. - Enhanced the dashboard with provider filtering options for AWS and Azure. - Added a segmented toggle for selecting the date range for service costs. - Improved styling for the sidebar and data tables. - Refactored common utility functions for better reusability across pages.
This commit is contained in:
1 parent
9e78dfa4f2
commit
c39364eda4
12 files changed
+804
-84
No files matched your search
@@ -9,3 +9,4 @@ data/
|
|||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.claude/
|
.claude/
|
||||||
|
TODO.md
|
||||||
@@ -252,39 +252,3 @@ simple-cost-dashboard/
|
|||||||
│ └── tenant.js # logique page détail + graphes + drawer ressources
|
│ └── tenant.js # logique page détail + graphes + drawer ressources
|
||||||
└── deploy/ # à venir : manifestes Kubernetes (voir TODO)
|
└── deploy/ # à venir : manifestes Kubernetes (voir TODO)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 9. À faire
|
|
||||||
|
|
||||||
**Déploiement Kubernetes** (`deploy/`, pas encore créé) :
|
|
||||||
- [ ] Manifeste `Deployment` pour le serveur web (readiness/liveness sur `/health`)
|
|
||||||
- [ ] Manifeste `Service` + `Ingress`
|
|
||||||
- [ ] Manifeste `CronJob` pour la collecte quotidienne, même image, commande surchargée
|
|
||||||
- [ ] `PersistentVolumeClaim` pour `/data` (la base SQLite doit survivre aux redéploiements)
|
|
||||||
- [ ] `Secret` Kubernetes pour `config.json` (credentials Azure notamment) plutôt qu'un ConfigMap en clair
|
|
||||||
|
|
||||||
**Authentification** :
|
|
||||||
- [ ] Basic Auth via l'Ingress (annotation nginx `auth-basic` + `Secret` `htpasswd`) — solution de départ validée
|
|
||||||
- [ ] Bascule vers SSO Entra ID (app d'entreprise Azure + groupe) une fois le besoin confirmé — probablement via `oauth2-proxy` en sidecar/Ingress plutôt que dans le code de l'appli, pour ne pas coupler l'auth à FastAPI
|
|
||||||
|
|
||||||
**Credentials AWS/Azure en environnement conteneurisé** :
|
|
||||||
- [x] AWS : tranché pour des clés IAM dédiées par tenant (`access_key_id`/
|
|
||||||
`secret_access_key` dans `config.json`, droit `ce:GetCostAndUsage`
|
|
||||||
uniquement) plutôt que des profils `~/.aws` — les profils SSO ne
|
|
||||||
fonctionnent pas en conteneur (le refresh du token demande un
|
|
||||||
navigateur). Voir §2. Pas encore fait : créer ces clés IAM dédiées côté
|
|
||||||
AWS pour chaque tenant (elles n'existent pas encore, seuls des profils
|
|
||||||
SSO existent aujourd'hui) — et envisager `sts:AssumeRole` depuis une
|
|
||||||
identité unique si la rotation de 28+ clés IAM statiques devient trop
|
|
||||||
lourde à gérer.
|
|
||||||
- [ ] `config.json` (les clés AWS et les `client_secret` Azure) doit passer
|
|
||||||
en `Secret` Kubernetes monté en volume une fois `deploy/` créé, pas en
|
|
||||||
ConfigMap.
|
|
||||||
|
|
||||||
**Fiabilité / observabilité** :
|
|
||||||
- [ ] Alerte (Slack/email) si un tenant est en échec de collecte plusieurs jours de suite — actuellement visible seulement en se rendant sur le dashboard
|
|
||||||
- [ ] Sauvegarde régulière de la base SQLite (le PVC seul ne protège pas d'une corruption)
|
|
||||||
- [ ] Limite de rétention ou archivage si le volume de la base grossit significativement à long terme (non urgent : le volume attendu reste faible)
|
|
||||||
|
|
||||||
**Fonctionnel** :
|
|
||||||
- [ ] Vérifier le rendu du dashboard avec de vraies données (actuellement testé uniquement sur la structure du code, pas sur un run réel bout-en-bout)
|
|
||||||
- [ ] Décider si la page globale doit aussi permettre de filtrer/rechercher un tenant si leur nombre grossit beaucoup
|
|
||||||
+39
-2
@@ -52,6 +52,11 @@ def tenant_page(provider: str, name: str):
|
|||||||
return _read_page("tenant.html")
|
return _read_page("tenant.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/services", response_class=HTMLResponse)
|
||||||
|
def services_page():
|
||||||
|
return _read_page("services.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
try:
|
try:
|
||||||
@@ -74,11 +79,43 @@ def api_overview(months: int = Query(12, ge=1, le=24)):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
VALID_RANGES = (1, 7, 30, 365)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_provider_filter(provider: str | None) -> None:
|
||||||
|
if provider is not None and provider not in PROVIDERS:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Provider inconnu : {provider}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/services")
|
||||||
|
def api_services(range: int = Query(30, alias="range"), provider: str | None = Query(None)):
|
||||||
|
if range not in VALID_RANGES:
|
||||||
|
raise HTTPException(status_code=400, detail="range doit être 1, 7, 30 ou 365")
|
||||||
|
_validate_provider_filter(provider)
|
||||||
|
conn = queries.get_connection()
|
||||||
|
try:
|
||||||
|
return queries.global_services(conn, range_days=range, provider=provider)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/services/{service_name}/tenants")
|
||||||
|
def api_service_tenants(service_name: str, range: int = Query(30, alias="range"), provider: str | None = Query(None)):
|
||||||
|
if range not in VALID_RANGES:
|
||||||
|
raise HTTPException(status_code=400, detail="range doit être 1, 7, 30 ou 365")
|
||||||
|
_validate_provider_filter(provider)
|
||||||
|
conn = queries.get_connection()
|
||||||
|
try:
|
||||||
|
return queries.service_tenants_detail(conn, service_name, range_days=range, provider=provider)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/tenant/{provider}/{name}")
|
@app.get("/api/tenant/{provider}/{name}")
|
||||||
def api_tenant_detail(provider: str, name: str, range: int = Query(30, alias="range")):
|
def api_tenant_detail(provider: str, name: str, range: int = Query(30, alias="range")):
|
||||||
_require_provider(provider)
|
_require_provider(provider)
|
||||||
if range not in (7, 30, 365):
|
if range not in VALID_RANGES:
|
||||||
raise HTTPException(status_code=400, detail="range doit être 7, 30 ou 365")
|
raise HTTPException(status_code=400, detail="range doit être 1, 7, 30 ou 365")
|
||||||
conn = queries.get_connection()
|
conn = queries.get_connection()
|
||||||
try:
|
try:
|
||||||
return queries.tenant_detail(conn, provider, name, range_days=range)
|
return queries.tenant_detail(conn, provider, name, range_days=range)
|
||||||
|
|||||||
+233
@@ -365,6 +365,239 @@ def services_for_months(conn: sqlite3.Connection, provider: str, name: str, mont
|
|||||||
return dict(sorted(((k, round(v, 2)) for k, v in totals.items()), key=lambda kv: -kv[1]))
|
return dict(sorted(((k, round(v, 2)) for k, v in totals.items()), key=lambda kv: -kv[1]))
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Vue globale par service (page "Services")
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _last_n_daily_dates(conn: sqlite3.Connection, n: int, provider: str | None = None) -> list[str]:
|
||||||
|
"""Les n dates de collecte journalière les plus récentes, tous tenants
|
||||||
|
confondus (ou un seul provider si filtré) — aligne la fenêtre sur les
|
||||||
|
jours réellement collectés plutôt que sur un simple `today - n` (qui
|
||||||
|
pourrait déborder sur des jours pas encore collectés)."""
|
||||||
|
where = ["granularity = 'daily'", "status = 'ok'"]
|
||||||
|
params: list = []
|
||||||
|
if provider:
|
||||||
|
where.append("provider = ?")
|
||||||
|
params.append(provider)
|
||||||
|
params.append(n)
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT DISTINCT period_start FROM collections
|
||||||
|
WHERE {' AND '.join(where)}
|
||||||
|
ORDER BY period_start DESC LIMIT ?
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
return [r["period_start"] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _services_breakdown(totals: dict[str, float], total: float) -> list[dict]:
|
||||||
|
return sorted(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"service_name": name,
|
||||||
|
"amount": round(amount, 2),
|
||||||
|
"pct": round(amount / total * 100, 1) if total else 0.0,
|
||||||
|
}
|
||||||
|
for name, amount in totals.items()
|
||||||
|
),
|
||||||
|
key=lambda s: -s["amount"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def global_services(conn: sqlite3.Connection, range_days: int = 30, provider: str | None = None) -> dict:
|
||||||
|
"""Répartition des coûts par service, tous tenants/providers confondus
|
||||||
|
(ou un seul provider si `provider` est fourni).
|
||||||
|
|
||||||
|
range_days=1, 7 ou 30 -> derniers jours réellement collectés (granularité
|
||||||
|
'daily'), la plus précise.
|
||||||
|
range_days=365 -> 12 derniers mois glissants ; même règle de
|
||||||
|
priorité 'daily' > 'monthly' par (tenant, mois)
|
||||||
|
que `_merged_monthly`, pour ne pas compter deux
|
||||||
|
fois un mois couvert par les deux granularités."""
|
||||||
|
if range_days in (1, 7, 30):
|
||||||
|
days = _last_n_daily_dates(conn, range_days, provider=provider)
|
||||||
|
if not days:
|
||||||
|
return {
|
||||||
|
"mode": "daily", "range_days": range_days, "provider": provider,
|
||||||
|
"period_start": None, "period_end": None,
|
||||||
|
"total": 0.0, "services": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholders = ",".join("?" * len(days))
|
||||||
|
where = [f"c.period_start IN ({placeholders})"]
|
||||||
|
params: list = list(days)
|
||||||
|
if provider:
|
||||||
|
where.append("c.provider = ?")
|
||||||
|
params.append(provider)
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT sc.service_name AS service_name, SUM(sc.amount) AS amount
|
||||||
|
FROM service_costs sc
|
||||||
|
JOIN collections c ON c.id = sc.collection_id
|
||||||
|
WHERE c.status = 'ok' AND c.granularity = 'daily' AND {' AND '.join(where)}
|
||||||
|
GROUP BY sc.service_name
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
totals = {r["service_name"]: r["amount"] for r in rows}
|
||||||
|
total = sum(totals.values())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": "daily",
|
||||||
|
"range_days": range_days,
|
||||||
|
"provider": provider,
|
||||||
|
"period_start": min(days),
|
||||||
|
"period_end": max(days),
|
||||||
|
"total": round(total, 2),
|
||||||
|
"services": _services_breakdown(totals, total),
|
||||||
|
}
|
||||||
|
|
||||||
|
month_keys = _last_n_months(12)
|
||||||
|
where = ["substr(c.period_start, 1, 7) >= ?"]
|
||||||
|
params = [month_keys[0]]
|
||||||
|
if provider:
|
||||||
|
where.append("c.provider = ?")
|
||||||
|
params.append(provider)
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT c.provider, c.tenant_name, substr(c.period_start, 1, 7) AS month,
|
||||||
|
c.granularity, sc.service_name AS service_name, SUM(sc.amount) AS amount
|
||||||
|
FROM service_costs sc
|
||||||
|
JOIN collections c ON c.id = sc.collection_id
|
||||||
|
WHERE c.status = 'ok' AND {' AND '.join(where)}
|
||||||
|
GROUP BY c.provider, c.tenant_name, month, c.granularity, sc.service_name
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
daily_keys = {(r["provider"], r["tenant_name"], r["month"]) for r in rows if r["granularity"] == "daily"}
|
||||||
|
totals: dict[str, float] = {}
|
||||||
|
for r in rows:
|
||||||
|
if r["granularity"] == "monthly" and (r["provider"], r["tenant_name"], r["month"]) in daily_keys:
|
||||||
|
continue
|
||||||
|
totals[r["service_name"]] = totals.get(r["service_name"], 0.0) + r["amount"]
|
||||||
|
|
||||||
|
total = sum(totals.values())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": "monthly",
|
||||||
|
"range_days": None,
|
||||||
|
"provider": provider,
|
||||||
|
"months": month_keys,
|
||||||
|
"total": round(total, 2),
|
||||||
|
"services": _services_breakdown(totals, total),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def service_tenants_detail(
|
||||||
|
conn: sqlite3.Connection, service_name: str, range_days: int = 30, provider: str | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""Répartition par tenant pour un service donné (clic sur un service de
|
||||||
|
la page Services), sur la même période/règle daily-vs-monthly que
|
||||||
|
`global_services`. `provider` restreint aux tenants d'un seul CSP."""
|
||||||
|
if range_days in (1, 7, 30):
|
||||||
|
days = _last_n_daily_dates(conn, range_days, provider=provider)
|
||||||
|
if not days:
|
||||||
|
return {
|
||||||
|
"mode": "daily", "range_days": range_days, "provider": provider,
|
||||||
|
"period_start": None, "period_end": None,
|
||||||
|
"service_name": service_name, "total": 0.0, "tenants": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholders = ",".join("?" * len(days))
|
||||||
|
where = [f"c.period_start IN ({placeholders})", "sc.service_name = ?"]
|
||||||
|
params: list = [*days, service_name]
|
||||||
|
if provider:
|
||||||
|
where.append("c.provider = ?")
|
||||||
|
params.append(provider)
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT c.provider, c.tenant_name, SUM(sc.amount) AS amount
|
||||||
|
FROM service_costs sc
|
||||||
|
JOIN collections c ON c.id = sc.collection_id
|
||||||
|
WHERE c.status = 'ok' AND c.granularity = 'daily' AND {' AND '.join(where)}
|
||||||
|
GROUP BY c.provider, c.tenant_name
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
total = sum(r["amount"] for r in rows)
|
||||||
|
tenants = sorted(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"provider": r["provider"], "tenant_name": r["tenant_name"],
|
||||||
|
"amount": round(r["amount"], 2),
|
||||||
|
"pct": round(r["amount"] / total * 100, 1) if total else 0.0,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
),
|
||||||
|
key=lambda t: -t["amount"],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"mode": "daily",
|
||||||
|
"range_days": range_days,
|
||||||
|
"provider": provider,
|
||||||
|
"period_start": min(days),
|
||||||
|
"period_end": max(days),
|
||||||
|
"service_name": service_name,
|
||||||
|
"total": round(total, 2),
|
||||||
|
"tenants": tenants,
|
||||||
|
}
|
||||||
|
|
||||||
|
month_keys = _last_n_months(12)
|
||||||
|
where = ["substr(c.period_start, 1, 7) >= ?", "sc.service_name = ?"]
|
||||||
|
params = [month_keys[0], service_name]
|
||||||
|
if provider:
|
||||||
|
where.append("c.provider = ?")
|
||||||
|
params.append(provider)
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT c.provider, c.tenant_name, substr(c.period_start, 1, 7) AS month,
|
||||||
|
c.granularity, SUM(sc.amount) AS amount
|
||||||
|
FROM service_costs sc
|
||||||
|
JOIN collections c ON c.id = sc.collection_id
|
||||||
|
WHERE c.status = 'ok' AND {' AND '.join(where)}
|
||||||
|
GROUP BY c.provider, c.tenant_name, month, c.granularity
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
daily_keys = {(r["provider"], r["tenant_name"], r["month"]) for r in rows if r["granularity"] == "daily"}
|
||||||
|
totals: dict[tuple[str, str], float] = {}
|
||||||
|
for r in rows:
|
||||||
|
if r["granularity"] == "monthly" and (r["provider"], r["tenant_name"], r["month"]) in daily_keys:
|
||||||
|
continue
|
||||||
|
key = (r["provider"], r["tenant_name"])
|
||||||
|
totals[key] = totals.get(key, 0.0) + r["amount"]
|
||||||
|
|
||||||
|
total = sum(totals.values())
|
||||||
|
tenants = sorted(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"provider": tenant_provider, "tenant_name": tenant_name,
|
||||||
|
"amount": round(amount, 2),
|
||||||
|
"pct": round(amount / total * 100, 1) if total else 0.0,
|
||||||
|
}
|
||||||
|
for (tenant_provider, tenant_name), amount in totals.items()
|
||||||
|
),
|
||||||
|
key=lambda t: -t["amount"],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"mode": "monthly",
|
||||||
|
"range_days": None,
|
||||||
|
"provider": provider,
|
||||||
|
"months": month_keys,
|
||||||
|
"service_name": service_name,
|
||||||
|
"total": round(total, 2),
|
||||||
|
"tenants": tenants,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Détail par ressource (drawer sur un service)
|
# Détail par ressource (drawer sur un service)
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
|||||||
+20
-2
@@ -9,6 +9,16 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<p class="sidebar-brand">simple-cost-dashboard</p>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<a href="/" class="sidebar-link active">Compte</a>
|
||||||
|
<a href="/services" class="sidebar-link">Services</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="topbar-title">
|
<div class="topbar-title">
|
||||||
<h1>simple-cost-dashboard</h1>
|
<h1>simple-cost-dashboard</h1>
|
||||||
@@ -46,13 +56,18 @@
|
|||||||
<h2>Coûts par mois</h2>
|
<h2>Coûts par mois</h2>
|
||||||
<p class="chart-card-sub">Total AWS + Azure, EUR</p>
|
<p class="chart-card-sub">Total AWS + Azure, EUR</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="segmented-toggle" id="provider-toggle">
|
||||||
|
<button data-provider="all" class="active">Tous</button>
|
||||||
|
<button data-provider="aws">AWS</button>
|
||||||
|
<button data-provider="azure">Azure</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-canvas-wrap">
|
<div class="chart-canvas-wrap">
|
||||||
<canvas id="monthly-chart"></canvas>
|
<canvas id="monthly-chart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend">
|
<div class="legend">
|
||||||
<span class="legend-item"><span class="legend-dot" style="background:var(--provider-aws)"></span>AWS</span>
|
<span class="legend-item" id="legend-aws"><span class="legend-dot" style="background:var(--provider-aws)"></span>AWS</span>
|
||||||
<span class="legend-item"><span class="legend-dot" style="background:var(--provider-azure)"></span>Azure</span>
|
<span class="legend-item" id="legend-azure"><span class="legend-dot" style="background:var(--provider-azure)"></span>Azure</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,6 +85,9 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/common.js"></script>
|
<script src="/static/common.js"></script>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Services — simple-cost-dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<p class="sidebar-brand">simple-cost-dashboard</p>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<a href="/" class="sidebar-link">Compte</a>
|
||||||
|
<a href="/services" class="sidebar-link active">Services</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-title">
|
||||||
|
<h1>Services</h1>
|
||||||
|
<span class="breadcrumb">coûts par service, tous tenants confondus</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
|
||||||
|
<div id="error-banner"></div>
|
||||||
|
|
||||||
|
<p class="section-title">Répartition par service</p>
|
||||||
|
<div class="card chart-card">
|
||||||
|
<div class="chart-card-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="services-period-title">30 derniers jours</h2>
|
||||||
|
<p class="chart-card-sub">EUR, converti au taux du jour de collecte</p>
|
||||||
|
</div>
|
||||||
|
<div class="segmented-toggle" id="range-toggle">
|
||||||
|
<button data-range="1">Aujourd'hui</button>
|
||||||
|
<button data-range="7">7 jours</button>
|
||||||
|
<button data-range="30" class="active">30 jours</button>
|
||||||
|
<button data-range="365">1 an</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; margin: -4px 0 12px;">
|
||||||
|
<p class="chart-card-sub" style="margin:0;">Clique un service pour voir le détail par tenant.</p>
|
||||||
|
<div class="segmented-toggle" id="provider-toggle">
|
||||||
|
<button data-provider="all" class="active">Tous</button>
|
||||||
|
<button data-provider="aws">AWS</button>
|
||||||
|
<button data-provider="azure">Azure</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="services-table-wrap">
|
||||||
|
<div class="skeleton">Chargement…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-backdrop" id="drawer-backdrop"></div>
|
||||||
|
<aside class="drawer" id="tenant-drawer" aria-hidden="true">
|
||||||
|
<div class="drawer-header">
|
||||||
|
<div>
|
||||||
|
<h3 id="drawer-title">Service</h3>
|
||||||
|
<p id="drawer-sub"> </p>
|
||||||
|
</div>
|
||||||
|
<button class="drawer-close" id="drawer-close" aria-label="Fermer">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-body" id="drawer-body"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<script src="/static/common.js"></script>
|
||||||
|
<script src="/static/services.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+40
-15
@@ -5,6 +5,7 @@ let allTenants = [];
|
|||||||
let currentMonths = [];
|
let currentMonths = [];
|
||||||
let currentProvidersTotals = { aws: [], azure: [] };
|
let currentProvidersTotals = { aws: [], azure: [] };
|
||||||
let monthlyChart = null;
|
let monthlyChart = null;
|
||||||
|
let currentProvider = "all";
|
||||||
const selectedMonths = new Set();
|
const selectedMonths = new Set();
|
||||||
|
|
||||||
async function loadOverview() {
|
async function loadOverview() {
|
||||||
@@ -60,13 +61,9 @@ function renderMonthlyChart() {
|
|||||||
monthlyChart = null;
|
monthlyChart = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = document.getElementById("monthly-chart").getContext("2d");
|
const datasets = [];
|
||||||
monthlyChart = new Chart(ctx, {
|
if (currentProvider === "all" || currentProvider === "aws") {
|
||||||
type: "bar",
|
datasets.push({
|
||||||
data: {
|
|
||||||
labels,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: "AWS",
|
label: "AWS",
|
||||||
data: currentProvidersTotals.aws,
|
data: currentProvidersTotals.aws,
|
||||||
backgroundColor: colorFor(awsColor),
|
backgroundColor: colorFor(awsColor),
|
||||||
@@ -76,19 +73,32 @@ function renderMonthlyChart() {
|
|||||||
borderRadius: { topLeft: 4, topRight: 4 },
|
borderRadius: { topLeft: 4, topRight: 4 },
|
||||||
stack: "total",
|
stack: "total",
|
||||||
maxBarThickness: 24,
|
maxBarThickness: 24,
|
||||||
},
|
});
|
||||||
{
|
}
|
||||||
|
if (currentProvider === "all" || currentProvider === "azure") {
|
||||||
|
datasets.push({
|
||||||
label: "Azure",
|
label: "Azure",
|
||||||
data: currentProvidersTotals.azure,
|
data: currentProvidersTotals.azure,
|
||||||
backgroundColor: colorFor(azureColor),
|
backgroundColor: colorFor(azureColor),
|
||||||
borderColor: surface,
|
borderColor: surface,
|
||||||
borderWidth: { top: 2, right: 0, bottom: 0, left: 0 },
|
borderWidth: { top: 2, right: 0, bottom: 0, left: 0 },
|
||||||
borderSkipped: false,
|
borderSkipped: false,
|
||||||
|
// Arrondi uniquement quand Azure est seul affiché (filtre "Azure") —
|
||||||
|
// en vue empilée "Tous", il est au-dessus d'AWS et déjà arrondi par
|
||||||
|
// le borderRadius du dataset AWS en dessous.
|
||||||
|
borderRadius: currentProvider === "azure" ? { topLeft: 4, topRight: 4 } : undefined,
|
||||||
stack: "total",
|
stack: "total",
|
||||||
maxBarThickness: 24,
|
maxBarThickness: 24,
|
||||||
},
|
});
|
||||||
],
|
}
|
||||||
},
|
|
||||||
|
document.getElementById("legend-aws").style.display = currentProvider === "azure" ? "none" : "";
|
||||||
|
document.getElementById("legend-azure").style.display = currentProvider === "aws" ? "none" : "";
|
||||||
|
|
||||||
|
const ctx = document.getElementById("monthly-chart").getContext("2d");
|
||||||
|
monthlyChart = new Chart(ctx, {
|
||||||
|
type: "bar",
|
||||||
|
data: { labels, datasets },
|
||||||
options: {
|
options: {
|
||||||
...chartBaseOptions(),
|
...chartBaseOptions(),
|
||||||
onHover: (evt, elements) => {
|
onHover: (evt, elements) => {
|
||||||
@@ -240,8 +250,9 @@ function renderTenantsTable(tenants) {
|
|||||||
|
|
||||||
function filteredTenants() {
|
function filteredTenants() {
|
||||||
const q = document.getElementById("tenant-search").value.trim().toLowerCase();
|
const q = document.getElementById("tenant-search").value.trim().toLowerCase();
|
||||||
if (!q) return allTenants;
|
return allTenants.filter(
|
||||||
return allTenants.filter((t) => t.name.toLowerCase().includes(q));
|
(t) => (currentProvider === "all" || t.provider === currentProvider) && (!q || t.name.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupSearch() {
|
function setupSearch() {
|
||||||
@@ -250,6 +261,19 @@ function setupSearch() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupProviderToggle() {
|
||||||
|
const buttons = document.querySelectorAll("#provider-toggle button");
|
||||||
|
buttons.forEach((btn) => {
|
||||||
|
btn.classList.toggle("active", btn.dataset.provider === currentProvider);
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
currentProvider = btn.dataset.provider;
|
||||||
|
buttons.forEach((b) => b.classList.toggle("active", b === btn));
|
||||||
|
renderMonthlyChart();
|
||||||
|
renderTenantsTable(filteredTenants());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
const data = await loadOverview();
|
const data = await loadOverview();
|
||||||
@@ -258,8 +282,9 @@ async function init() {
|
|||||||
currentProvidersTotals = data.providers_totals;
|
currentProvidersTotals = data.providers_totals;
|
||||||
renderKpis(data);
|
renderKpis(data);
|
||||||
renderMonthlyChart();
|
renderMonthlyChart();
|
||||||
renderTenantsTable(allTenants);
|
renderTenantsTable(filteredTenants());
|
||||||
setupSearch();
|
setupSearch();
|
||||||
|
setupProviderToggle();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById("error-banner").innerHTML = `
|
document.getElementById("error-banner").innerHTML = `
|
||||||
<div class="banner error"><strong>Erreur de chargement du dashboard</strong> : ${escapeHtml(e.message)}</div>`;
|
<div class="banner error"><strong>Erreur de chargement du dashboard</strong> : ${escapeHtml(e.message)}</div>`;
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ function dayLabel(isoDate) {
|
|||||||
return new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", timeZone: "UTC" }).format(d);
|
return new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", timeZone: "UTC" }).format(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rangeLabel(days) {
|
||||||
|
return days === 1 ? "aujourd'hui" : `${days} derniers jours`;
|
||||||
|
}
|
||||||
|
|
||||||
function providerLabel(provider) {
|
function providerLabel(provider) {
|
||||||
return provider === "aws" ? "AWS" : provider === "azure" ? "Azure" : provider;
|
return provider === "aws" ? "AWS" : provider === "azure" ? "Azure" : provider;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// services.js — page "Services" : répartition des coûts par service, tous
|
||||||
|
// tenants/providers confondus, avec sélecteur de période (7j/30j/1 an) et
|
||||||
|
// détail par tenant au clic sur un service.
|
||||||
|
|
||||||
|
let currentRange = 30;
|
||||||
|
let currentProvider = "all";
|
||||||
|
let currentServices = [];
|
||||||
|
let currentTotal = 0;
|
||||||
|
|
||||||
|
const SORT_COLUMNS = [
|
||||||
|
{ key: "service_name", label: "Service", numeric: false },
|
||||||
|
{ key: "amount", label: "Montant", numeric: true },
|
||||||
|
{ key: "pct", label: "Part", numeric: true },
|
||||||
|
];
|
||||||
|
let sortColumn = "amount";
|
||||||
|
let sortDirection = "desc";
|
||||||
|
|
||||||
|
async function loadServices(range, provider) {
|
||||||
|
const params = new URLSearchParams({ range });
|
||||||
|
if (provider !== "all") params.set("provider", provider);
|
||||||
|
const res = await fetch(`/api/services?${params}`);
|
||||||
|
if (!res.ok) throw new Error(`API services: HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodTitle(data) {
|
||||||
|
if (data.mode === "monthly") return "12 derniers mois";
|
||||||
|
return rangeLabel(data.range_days);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedServices() {
|
||||||
|
const sorted = [...currentServices];
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
const cmp =
|
||||||
|
sortColumn === "service_name"
|
||||||
|
? a.service_name.localeCompare(b.service_name, "fr", { sensitivity: "base" })
|
||||||
|
: a[sortColumn] - b[sortColumn];
|
||||||
|
return sortDirection === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortIndicator(column) {
|
||||||
|
if (sortColumn !== column) return "";
|
||||||
|
return sortDirection === "asc" ? " ↑" : " ↓";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServicesTable(data) {
|
||||||
|
document.getElementById("services-period-title").textContent = periodTitle(data);
|
||||||
|
currentServices = data.services;
|
||||||
|
currentTotal = data.total;
|
||||||
|
|
||||||
|
const wrap = document.getElementById("services-table-wrap");
|
||||||
|
|
||||||
|
if (currentServices.length === 0) {
|
||||||
|
wrap.innerHTML = `<div class="empty-state">Aucune donnée de coût sur cette période. Lance <code>collect.py</code> ou <code>import_historical.py</code> pour peupler le dashboard.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderServicesTableRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServicesTableRows() {
|
||||||
|
const wrap = document.getElementById("services-table-wrap");
|
||||||
|
const sorted = sortedServices();
|
||||||
|
|
||||||
|
const headerCells = SORT_COLUMNS.map((col) => {
|
||||||
|
const classes = ["sortable"];
|
||||||
|
if (col.numeric) classes.push("num");
|
||||||
|
if (sortColumn === col.key) classes.push(`sorted-${sortDirection}`);
|
||||||
|
return `<th class="${classes.join(" ")}" data-sort="${col.key}">${col.label}${sortIndicator(col.key)}</th>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const rows = sorted
|
||||||
|
.map(
|
||||||
|
(s) => `
|
||||||
|
<tr class="clickable" data-service="${escapeHtml(s.service_name)}" title="Voir le détail par tenant">
|
||||||
|
<td>${escapeHtml(s.service_name)}</td>
|
||||||
|
<td class="num">${formatEUR(s.amount)}</td>
|
||||||
|
<td class="num">${s.pct.toFixed(1)} %</td>
|
||||||
|
</tr>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr>${headerCells}</tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Total</strong></td>
|
||||||
|
<td class="num"><strong>${formatEUR(currentTotal)}</strong></td>
|
||||||
|
<td class="num"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
wrap.querySelectorAll("tr.clickable").forEach((tr) => {
|
||||||
|
tr.addEventListener("click", () => openTenantDrawer(tr.dataset.service));
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.querySelectorAll("th.sortable").forEach((th) => {
|
||||||
|
th.addEventListener("click", () => {
|
||||||
|
const column = th.dataset.sort;
|
||||||
|
if (sortColumn === column) {
|
||||||
|
sortDirection = sortDirection === "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
sortColumn = column;
|
||||||
|
sortDirection = column === "service_name" ? "asc" : "desc";
|
||||||
|
}
|
||||||
|
renderServicesTableRows();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadServices() {
|
||||||
|
try {
|
||||||
|
const data = await loadServices(currentRange, currentProvider);
|
||||||
|
renderServicesTable(data);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("error-banner").innerHTML = `
|
||||||
|
<div class="banner error"><strong>Erreur de chargement</strong> : ${escapeHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupRangeToggle() {
|
||||||
|
const buttons = document.querySelectorAll("#range-toggle button");
|
||||||
|
buttons.forEach((btn) => {
|
||||||
|
btn.classList.toggle("active", Number(btn.dataset.range) === currentRange);
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
currentRange = Number(btn.dataset.range);
|
||||||
|
buttons.forEach((b) => b.classList.toggle("active", b === btn));
|
||||||
|
reloadServices();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupProviderToggle() {
|
||||||
|
const buttons = document.querySelectorAll("#provider-toggle button");
|
||||||
|
buttons.forEach((btn) => {
|
||||||
|
btn.classList.toggle("active", btn.dataset.provider === currentProvider);
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
currentProvider = btn.dataset.provider;
|
||||||
|
buttons.forEach((b) => b.classList.toggle("active", b === btn));
|
||||||
|
reloadServices();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Drawer : détail par tenant d'un service ----------------------------
|
||||||
|
|
||||||
|
function openTenantDrawer(serviceName) {
|
||||||
|
document.getElementById("drawer-title").textContent = serviceName;
|
||||||
|
document.getElementById("drawer-sub").textContent = "Chargement…";
|
||||||
|
document.getElementById("drawer-body").innerHTML = `<div class="skeleton">Chargement…</div>`;
|
||||||
|
document.getElementById("tenant-drawer").classList.add("open");
|
||||||
|
document.getElementById("tenant-drawer").setAttribute("aria-hidden", "false");
|
||||||
|
document.getElementById("drawer-backdrop").classList.add("open");
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ range: currentRange });
|
||||||
|
if (currentProvider !== "all") params.set("provider", currentProvider);
|
||||||
|
|
||||||
|
fetch(`/api/services/${encodeURIComponent(serviceName)}/tenants?${params}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => renderDrawerContent(data))
|
||||||
|
.catch((e) => {
|
||||||
|
document.getElementById("drawer-sub").textContent = "";
|
||||||
|
document.getElementById("drawer-body").innerHTML =
|
||||||
|
`<div class="banner error"><strong>Erreur de chargement</strong> : ${escapeHtml(e.message)}</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDrawerContent(data) {
|
||||||
|
const subEl = document.getElementById("drawer-sub");
|
||||||
|
const bodyEl = document.getElementById("drawer-body");
|
||||||
|
|
||||||
|
subEl.textContent = data.mode === "monthly" ? "12 derniers mois" : rangeLabel(data.range_days);
|
||||||
|
|
||||||
|
if (!data.tenants.length) {
|
||||||
|
bodyEl.innerHTML = `<div class="empty-state">Aucun tenant n'a de coût sur ce service pour cette période.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyEl.innerHTML = data.tenants
|
||||||
|
.map(
|
||||||
|
(t) => `
|
||||||
|
<div class="resource-row clickable" data-provider="${t.provider}" data-name="${escapeHtml(t.tenant_name)}">
|
||||||
|
<div>
|
||||||
|
<div class="resource-name">
|
||||||
|
<span class="provider-badge ${t.provider}">${providerLabel(t.provider)}</span>
|
||||||
|
${escapeHtml(t.tenant_name)}
|
||||||
|
</div>
|
||||||
|
<div class="resource-pct">${t.pct.toFixed(1)} %</div>
|
||||||
|
</div>
|
||||||
|
<div class="resource-amount">${formatEUR(t.amount)}</div>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
bodyEl.querySelectorAll(".resource-row.clickable").forEach((row) => {
|
||||||
|
row.addEventListener("click", () => {
|
||||||
|
window.location.href = `/tenant/${row.dataset.provider}/${encodeURIComponent(row.dataset.name)}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTenantDrawer() {
|
||||||
|
document.getElementById("tenant-drawer").classList.remove("open");
|
||||||
|
document.getElementById("tenant-drawer").setAttribute("aria-hidden", "true");
|
||||||
|
document.getElementById("drawer-backdrop").classList.remove("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDrawer() {
|
||||||
|
document.getElementById("drawer-close").addEventListener("click", closeTenantDrawer);
|
||||||
|
document.getElementById("drawer-backdrop").addEventListener("click", closeTenantDrawer);
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") closeTenantDrawer();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const data = await loadServices(currentRange, currentProvider);
|
||||||
|
renderServicesTable(data);
|
||||||
|
setupRangeToggle();
|
||||||
|
setupProviderToggle();
|
||||||
|
setupDrawer();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("error-banner").innerHTML = `
|
||||||
|
<div class="banner error"><strong>Erreur de chargement</strong> : ${escapeHtml(e.message)}</div>`;
|
||||||
|
document.getElementById("services-table-wrap").innerHTML = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
+128
-22
@@ -132,11 +132,103 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1240px;
|
/* width: 100% est nécessaire ici : .page (son parent) est en
|
||||||
|
display:flex, et des marges auto sur l'axe transversal désactivent
|
||||||
|
l'étirement flex par défaut — sans ce width, .container retombe sur
|
||||||
|
un calcul "taille selon son contenu" au lieu de remplir l'espace
|
||||||
|
disponible jusqu'à max-width. */
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1520px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 28px 32px 64px;
|
padding: 28px 32px 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Sidebar de navigation */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
|
flex: none;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 20px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
padding: 0 20px 16px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link {
|
||||||
|
display: block;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link:hover {
|
||||||
|
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link.active {
|
||||||
|
background: color-mix(in srgb, var(--series-1) 14%, transparent);
|
||||||
|
color: var(--series-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.app-shell {
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: auto;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -207,23 +299,6 @@ a {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* Grille de graphiques */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
.chart-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1.4fr 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.chart-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-card {
|
.chart-card {
|
||||||
padding: 20px 20px 12px;
|
padding: 20px 20px 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -299,14 +374,14 @@ a {
|
|||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-toggle {
|
.segmented-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-toggle button {
|
.segmented-toggle button {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -316,11 +391,11 @@ a {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-toggle button + button {
|
.segmented-toggle button + button {
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-toggle button.active {
|
.segmented-toggle button.active {
|
||||||
background: var(--series-1);
|
background: var(--series-1);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -369,6 +444,21 @@ table.data-table {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-table thead th.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead th.sortable:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead th.sorted-asc,
|
||||||
|
.data-table thead th.sorted-desc {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.data-table td {
|
.data-table td {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-bottom: 1px solid var(--gridline);
|
border-bottom: 1px solid var(--gridline);
|
||||||
@@ -379,6 +469,11 @@ table.data-table {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-table tfoot td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--gridline);
|
||||||
|
}
|
||||||
|
|
||||||
.data-table tbody tr.clickable {
|
.data-table tbody tr.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -578,6 +673,17 @@ table.data-table {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-row.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 -8px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-row.clickable:hover {
|
||||||
|
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.resource-name {
|
.resource-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
|||||||
@@ -68,12 +68,13 @@ function renderKpis(data) {
|
|||||||
if (isMonthly) {
|
if (isMonthly) {
|
||||||
document.getElementById("kpi-current-label").textContent = `Total — ${p.months.length} derniers mois`;
|
document.getElementById("kpi-current-label").textContent = `Total — ${p.months.length} derniers mois`;
|
||||||
} else {
|
} else {
|
||||||
document.getElementById("kpi-current-label").textContent = `Total — ${p.range_days} derniers jours`;
|
document.getElementById("kpi-current-label").textContent = `Total — ${rangeLabel(p.range_days)}`;
|
||||||
}
|
}
|
||||||
document.getElementById("kpi-current").textContent = formatEUR(p.current_total);
|
document.getElementById("kpi-current").textContent = formatEUR(p.current_total);
|
||||||
|
|
||||||
if (!isMonthly) {
|
if (!isMonthly) {
|
||||||
document.getElementById("kpi-previous-label").textContent = `Total — ${p.range_days} jours précédents`;
|
document.getElementById("kpi-previous-label").textContent =
|
||||||
|
p.range_days === 1 ? "Total — veille" : `Total — ${p.range_days} jours précédents`;
|
||||||
document.getElementById("kpi-previous").textContent = p.has_previous ? formatEUR(p.previous_total) : "—";
|
document.getElementById("kpi-previous").textContent = p.has_previous ? formatEUR(p.previous_total) : "—";
|
||||||
|
|
||||||
const deltaEl = document.getElementById("kpi-delta");
|
const deltaEl = document.getElementById("kpi-delta");
|
||||||
@@ -99,7 +100,7 @@ function renderDailyChart(data) {
|
|||||||
|
|
||||||
document.getElementById("daily-chart-title").textContent = isMonthly
|
document.getElementById("daily-chart-title").textContent = isMonthly
|
||||||
? `Coûts mensuels — ${p.months.length} derniers mois`
|
? `Coûts mensuels — ${p.months.length} derniers mois`
|
||||||
: `Coûts journaliers — ${p.range_days} derniers jours`;
|
: `Coûts journaliers — ${rangeLabel(p.range_days)}`;
|
||||||
|
|
||||||
if (dailyChart) {
|
if (dailyChart) {
|
||||||
dailyChart.destroy();
|
dailyChart.destroy();
|
||||||
|
|||||||
+18
-4
@@ -9,6 +9,16 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<p class="sidebar-brand">simple-cost-dashboard</p>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<a href="/" class="sidebar-link active">Compte</a>
|
||||||
|
<a href="/services" class="sidebar-link">Services</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="topbar-title">
|
<div class="topbar-title">
|
||||||
<h1 id="tenant-title">Chargement…</h1>
|
<h1 id="tenant-title">Chargement…</h1>
|
||||||
@@ -46,7 +56,8 @@
|
|||||||
<h2 id="daily-chart-title">Coûts journaliers</h2>
|
<h2 id="daily-chart-title">Coûts journaliers</h2>
|
||||||
<p class="chart-card-sub">EUR, converti au taux du jour de collecte</p>
|
<p class="chart-card-sub">EUR, converti au taux du jour de collecte</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="range-toggle" id="range-toggle">
|
<div class="segmented-toggle" id="range-toggle">
|
||||||
|
<button data-range="1">Aujourd'hui</button>
|
||||||
<button data-range="7">7 jours</button>
|
<button data-range="7">7 jours</button>
|
||||||
<button data-range="30">30 jours</button>
|
<button data-range="30">30 jours</button>
|
||||||
<button data-range="365">1 an</button>
|
<button data-range="365">1 an</button>
|
||||||
@@ -60,8 +71,8 @@
|
|||||||
|
|
||||||
<p class="section-title" id="services-section-title">Répartition par service (30 derniers jours)</p>
|
<p class="section-title" id="services-section-title">Répartition par service (30 derniers jours)</p>
|
||||||
<div id="services-selection-note" class="selection-note" style="margin: -8px 0 14px;"></div>
|
<div id="services-selection-note" class="selection-note" style="margin: -8px 0 14px;"></div>
|
||||||
<div class="chart-grid">
|
|
||||||
<div class="card chart-card">
|
<div class="card chart-card" style="margin-bottom: 16px;">
|
||||||
<div class="chart-card-header">
|
<div class="chart-card-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Top services</h2>
|
<h2>Top services</h2>
|
||||||
@@ -72,6 +83,7 @@
|
|||||||
<canvas id="services-chart"></canvas>
|
<canvas id="services-chart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card chart-card">
|
<div class="card chart-card">
|
||||||
<div class="chart-card-header">
|
<div class="chart-card-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -82,7 +94,6 @@
|
|||||||
<div class="skeleton">Chargement…</div>
|
<div class="skeleton">Chargement…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -98,6 +109,9 @@
|
|||||||
<div class="drawer-body" id="drawer-body"></div>
|
<div class="drawer-body" id="drawer-body"></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/common.js"></script>
|
<script src="/static/common.js"></script>
|
||||||
<script src="/static/tenant.js"></script>
|
<script src="/static/tenant.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in new issue
Block a user