-
Codex{% if codex.plan_type %} · {{ codex.plan_type | capitalize }}{% endif %}{% if codex.limited %} · ⚠ limite atteinte{% endif %}
- {% for g in codex.gauges %}
-
-
- {{ g.name }}
- {{ g.remaining | round | int }}% restant
-
-
-
{{ (100 - g.remaining) | round | int }}% utilisé · reset dans {{ g.resets_in }}
-
+ {% if nas.ok %}
+
+
NAS
+ {% if nas.docker_unhealthy %}{{ nas.docker_unhealthy }} KO{% endif %}
+
+ {% for d in nas.disks %}
+
{{ d.label }}{{ d.percent | round | int }}% · {{ d.free_human }}
{% endfor %}
+
Docker{{ nas.docker_running }}/{{ nas.docker_total }}{% if not nas.docker_unhealthy %} ✓{% endif %}
+
VPN{% if nas.vpn_ok %}OK{% if nas.vpn_port %} · {{ nas.vpn_port }}{% endif %}{% else %}DÉSYNC{% endif %}
{% endif %}
{% if ha_states %}
-
-
Maison
-
- {% for s in ha_states %}
-
{{ s.label }}{{ s.display }}
- {% endfor %}
-
+
+
Maison
+
+ {% for s in ha_states %}
+
{{ s.label }}{{ s.display }}
+ {% endfor %}
{% endif %}
+
+
+
+
+
+
+
Claude
+ {% if claude.ok %}Max 5×{% endif %}
+ {% if not claude.ok %}
+
⚠ {{ claude.error }}
+ {% else %}
+ {% for g in gauges %}{{ gauge(g) }}{% endfor %}
+ {% if claude.extra or claude.burn_rate %}
+
+ {% if claude.extra %}{{ claude.extra.label }}{% endif %}{% if claude.extra and claude.burn_rate %} · {% endif %}{% if claude.burn_rate %}burn {{ claude.burn_rate | round | int }} tok/min{% endif %}
+
+ {% endif %}
+ {% endif %}
+
+ {% if codex.ok %}
+
+
Codex
+ {% if codex.plan_type %}{{ codex.plan_type | capitalize }}{% endif %}
+ {% if codex.limited %}Limite{% endif %}
+ {% for g in codex.gauges %}{{ gauge(g, mini=True) }}{% endfor %}
+ {% endif %}
+
+
diff --git a/dev/preview.py b/dev/preview.py
new file mode 100644
index 0000000..b3a13bd
--- /dev/null
+++ b/dev/preview.py
@@ -0,0 +1,93 @@
+"""Aperçu design hors-ligne : rend dashboard.html avec des données fictives en PNG
+paysage (1680x1264, niveaux de gris, sans rotation), pour itérer sur le design sans
+dépendre des intégrations réseau (Claude, météo, NAS, HA).
+
+Usage: .venv/bin/python ../dev/preview.py [sortie.png]
+"""
+from __future__ import annotations
+
+import asyncio
+import io
+import sys
+from pathlib import Path
+
+from jinja2 import Environment, FileSystemLoader, select_autoescape
+from PIL import Image
+from playwright.async_api import async_playwright
+
+BACKEND = Path(__file__).resolve().parent.parent / "backend"
+TEMPLATES = BACKEND / "templates"
+WIDTH, HEIGHT = 1680, 1264
+
+sys.path.insert(0, str(BACKEND))
+from fonts import font_face_css # noqa: E402
+
+env = Environment(
+ loader=FileSystemLoader(str(TEMPLATES)),
+ autoescape=select_autoescape(["html"]),
+)
+
+CTX = {
+ "width": WIDTH,
+ "height": HEIGHT,
+ "fonts": font_face_css(),
+ "time": "14:32",
+ "dow": "lundi",
+ "date": "15 juin",
+ "updated": "14:32",
+ "stale": False,
+ "weather": {
+ "ok": True, "kind": "partly", "temp": 21.4, "feels_like": 20.1,
+ "temp_min": 14.0, "temp_max": 24.0, "precip_prob": 20,
+ "label": "Peu nuageux",
+ },
+ "claude": {
+ "ok": True,
+ "extra": {"label": "Crédits API : 38 % restant"},
+ "burn_rate": 1820,
+ },
+ "gauges": [
+ {"name": "Session (5 h)", "remaining": 64, "resets_in": "2 h 10", "extra": ""},
+ {"name": "Hebdo (7 j)", "remaining": 12, "resets_in": "3 j", "extra": "Opus 41% rest."},
+ ],
+ "codex": {
+ "ok": True, "plan_type": "plus", "limited": False,
+ "gauges": [
+ {"name": "Session (5 h)", "remaining": 78, "resets_in": "1 h 40"},
+ {"name": "Hebdo (7 j)", "remaining": 33, "resets_in": "4 j"},
+ ],
+ },
+ "nas": {
+ "ok": True,
+ "disks": [
+ {"label": "Volume 1", "percent": 72, "free_human": "1,4 To"},
+ {"label": "Volume 2", "percent": 41, "free_human": "5,8 To"},
+ ],
+ "docker_running": 18, "docker_total": 19, "docker_unhealthy": 1,
+ "vpn_ok": True, "vpn_port": 51820,
+ },
+ "ha_states": [
+ {"label": "Salon", "display": "21°"},
+ {"label": "Chambre", "display": "19°"},
+ {"label": "Humidité", "display": "48 %"},
+ {"label": "Porte garage", "display": "Fermée"},
+ ],
+ "kobo": {"ok": True, "percent": 63, "charging": False, "low": False, "stale": False},
+}
+
+
+async def main() -> None:
+ out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).parent / "preview.png"
+ html = env.get_template("dashboard.html").render(**CTX)
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
+ page = await browser.new_page(viewport={"width": WIDTH, "height": HEIGHT}, device_scale_factor=1)
+ await page.set_content(html, wait_until="networkidle")
+ png = await page.screenshot(type="png", clip={"x": 0, "y": 0, "width": WIDTH, "height": HEIGHT})
+ await browser.close()
+ Image.open(io.BytesIO(png)).convert("L").save(out)
+ print(f"écrit {out}")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())