Dashboard: refonte design « instrument 1-bit » (jauges graduées, polices vendorisées, glyphes météo)

- Identité noir & blanc pur (zéro gris, anti-ghosting e-ink) ; hachures pour conso/alarme
- Typo vendorisée : Archivo (mots) + JetBrains Mono (nombres tabulaires), @font-face base64
- Jauge signature : noir = restant, repère seuil 20 %, hachures sous le seuil
- Météo : glyphes 1-bit en silhouette (weather.kind) au lieu d'emoji couleur
- Layout rééquilibré (plus de débordement), états dégradés soignés
- dev/preview.py : aperçu hors-ligne du template
This commit is contained in:
jerem
2026-06-15 22:56:56 +02:00
parent 0f6286c154
commit 3782738d57
11 changed files with 357 additions and 161 deletions

37
backend/fonts.py Normal file
View File

@@ -0,0 +1,37 @@
"""Génère le bloc CSS @font-face avec les woff2 vendorisés embarqués en data-URI.
Playwright rend via page.set_content() (pas de base URL) : les chemins de police
relatifs ne se résolvent pas. On embarque donc les woff2 en base64 directement dans
le CSS. Résultat mémoïsé (les fichiers ne changent pas au runtime)."""
from __future__ import annotations
import base64
from functools import lru_cache
from pathlib import Path
FONTS_DIR = Path(__file__).parent / "static" / "fonts"
# (famille CSS, fichier, graisse)
_FACES = [
("Archivo", "archivo-700.woff2", 700),
("Archivo", "archivo-800.woff2", 800),
("JetBrains Mono", "jbmono-400.woff2", 400),
("JetBrains Mono", "jbmono-500.woff2", 500),
("JetBrains Mono", "jbmono-700.woff2", 700),
("JetBrains Mono", "jbmono-800.woff2", 800),
]
@lru_cache(maxsize=1)
def font_face_css() -> str:
"""CSS @font-face complet (data-URI) à injecter dans le <style> du template."""
blocks = []
for family, filename, weight in _FACES:
data = (FONTS_DIR / filename).read_bytes()
b64 = base64.b64encode(data).decode("ascii")
blocks.append(
"@font-face{font-family:'%s';font-style:normal;font-weight:%d;"
"font-display:block;src:url(data:font/woff2;base64,%s) format('woff2');}"
% (family, weight, b64)
)
return "\n".join(blocks)

View File

@@ -9,29 +9,30 @@ from config import config
API_URL = "https://api.open-meteo.com/v1/forecast" API_URL = "https://api.open-meteo.com/v1/forecast"
# Codes WMO -> (libellé court FR, emoji). Suffisant pour un dashboard e-ink. # Codes WMO -> (libellé court FR, kind). `kind` pilote le glyphe 1-bit dessiné côté
# template (clear/partly/cloudy/fog/rain/snow/storm) : pas d'emoji couleur sur e-ink.
WMO = { WMO = {
0: ("Dégagé", ""), 0: ("Dégagé", "clear"),
1: ("Peu nuageux", "🌤"), 1: ("Peu nuageux", "partly"),
2: ("Nuageux", ""), 2: ("Nuageux", "partly"),
3: ("Couvert", ""), 3: ("Couvert", "cloudy"),
45: ("Brouillard", "🌫"), 45: ("Brouillard", "fog"),
48: ("Brouillard givrant", "🌫"), 48: ("Brouillard givrant", "fog"),
51: ("Bruine légère", "🌦"), 51: ("Bruine légère", "rain"),
53: ("Bruine", "🌦"), 53: ("Bruine", "rain"),
55: ("Bruine forte", "🌦"), 55: ("Bruine forte", "rain"),
61: ("Pluie faible", "🌧"), 61: ("Pluie faible", "rain"),
63: ("Pluie", "🌧"), 63: ("Pluie", "rain"),
65: ("Pluie forte", "🌧"), 65: ("Pluie forte", "rain"),
71: ("Neige faible", "🌨"), 71: ("Neige faible", "snow"),
73: ("Neige", "🌨"), 73: ("Neige", "snow"),
75: ("Neige forte", "🌨"), 75: ("Neige forte", "snow"),
80: ("Averses", "🌦"), 80: ("Averses", "rain"),
81: ("Averses", "🌧"), 81: ("Averses", "rain"),
82: ("Fortes averses", ""), 82: ("Fortes averses", "storm"),
95: ("Orage", ""), 95: ("Orage", "storm"),
96: ("Orage + grêle", ""), 96: ("Orage + grêle", "storm"),
99: ("Orage + grêle", ""), 99: ("Orage + grêle", "storm"),
} }
@@ -42,7 +43,7 @@ class Weather:
temp: float | None = None temp: float | None = None
feels_like: float | None = None feels_like: float | None = None
label: str = "" label: str = ""
icon: str = "" kind: str = ""
temp_min: float | None = None temp_min: float | None = None
temp_max: float | None = None temp_max: float | None = None
precip_prob: int | None = None precip_prob: int | None = None
@@ -68,7 +69,7 @@ async def fetch_weather() -> Weather:
cur = data.get("current", {}) cur = data.get("current", {})
daily = data.get("daily", {}) daily = data.get("daily", {})
code = int(cur.get("weather_code", -1)) code = int(cur.get("weather_code", -1))
label, icon = WMO.get(code, ("", "·")) label, kind = WMO.get(code, ("", "cloudy"))
def _first(key: str): def _first(key: str):
vals = daily.get(key) or [] vals = daily.get(key) or []
@@ -79,7 +80,7 @@ async def fetch_weather() -> Weather:
temp=cur.get("temperature_2m"), temp=cur.get("temperature_2m"),
feels_like=cur.get("apparent_temperature"), feels_like=cur.get("apparent_temperature"),
label=label, label=label,
icon=icon, kind=kind,
temp_min=_first("temperature_2m_min"), temp_min=_first("temperature_2m_min"),
temp_max=_first("temperature_2m_max"), temp_max=_first("temperature_2m_max"),
precip_prob=_first("precipitation_probability_max"), precip_prob=_first("precipitation_probability_max"),

View File

@@ -13,6 +13,7 @@ from PIL import Image
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
from config import config from config import config
from fonts import font_face_css
from integrations import claude_usage, codex, homeassistant, kobo, nas, weather from integrations import claude_usage, codex, homeassistant, kobo, nas, weather
TEMPLATES = Path(__file__).parent / "templates" TEMPLATES = Path(__file__).parent / "templates"
@@ -66,6 +67,7 @@ async def build_context() -> dict:
return { return {
"width": config.width, "width": config.width,
"height": config.height, "height": config.height,
"fonts": font_face_css(),
"time": now.strftime("%H:%M"), "time": now.strftime("%H:%M"),
"dow": JOURS[now.weekday()], "dow": JOURS[now.weekday()],
"date": f"{now.day} {MOIS[now.month - 1]}", "date": f"{now.day} {MOIS[now.month - 1]}",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -3,180 +3,243 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<style> <style>
/* Dashboard e-ink PAYSAGE 1680x1264 — noir & blanc, fort contraste, sans couleur. /* ============================================================================
Le PNG est pivoté de 90° côté backend pour le panneau portrait de la Kobo. */ MONITORINK — instrument de mesure 1-bit. Canevas PAYSAGE 1680x1264, pivoté 90°
côté backend pour le panneau e-ink portrait de la Kobo.
Règles e-ink : NOIR & BLANC purs, aucun gris (le gris fantôme au refresh partiel).
La hiérarchie passe par taille/graisse ; le « consommé » par des hachures.
Mots = Archivo (grotesque lourd) · Nombres mesurés = JetBrains Mono (tabulaire).
========================================================================= */
{{ fonts | safe }}
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
:root { :root { --ink: #000; --paper: #fff; }
--ink: #000;
--paper: #fff;
--muted: #555;
--line: #000;
--gauge-bg: #d9d9d9;
}
html, body { html, body {
width: {{ width }}px; height: {{ height }}px; width: {{ width }}px; height: {{ height }}px;
background: var(--paper); color: var(--ink); background: var(--paper); color: var(--ink);
font-family: "DejaVu Sans", "Noto Sans", Arial, sans-serif; font-family: "Archivo", "DejaVu Sans", sans-serif;
-webkit-font-smoothing: none; -webkit-font-smoothing: none;
font-variant-numeric: tabular-nums;
} }
/* Deux colonnes + pied de page pleine largeur. */ .num { font-family: "JetBrains Mono", monospace; font-variant-numeric: tabular-nums; }
body { body {
padding: 44px 52px; padding: 46px 52px 0;
display: grid; display: grid;
grid-template-columns: 600px 1fr; grid-template-columns: 560px 1fr;
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
grid-template-areas: "left right" "footer footer"; grid-template-areas: "left right" "foot foot";
column-gap: 56px; column-gap: 52px;
row-gap: 28px;
} }
.col { .pane { grid-area: left; display: flex; flex-direction: column; min-width: 0; }
display: flex; flex-direction: column; min-width: 0; .deck { grid-area: right; display: flex; flex-direction: column; min-width: 0;
} padding-left: 52px; border-left: 4px solid var(--ink); }
.col-left { grid-area: left; }
.col-right { grid-area: right; padding-left: 56px; border-left: 5px solid var(--line); }
.section { margin-bottom: 40px; }
.section:last-child { margin-bottom: 0; }
.rule { border: 0; border-top: 4px solid var(--line); margin: 0 0 32px; }
/* Météo */ /* Étiquette de bloc : barre pleine + mot. Encode "section", pas de déco capitale flottante. */
.weather .top { display: flex; align-items: center; gap: 32px; } .label { display: flex; align-items: center; gap: 16px; margin-bottom: 26px; }
.weather .icon { font-size: 140px; line-height: 1; } .label::before { content: ""; width: 26px; height: 26px; background: var(--ink); flex: 0 0 auto; }
.weather .temp { font-size: 130px; font-weight: 800; line-height: 1; } .label .t { font-weight: 800; font-size: 33px; letter-spacing: 1px; text-transform: uppercase; }
.weather .meta { font-size: 40px; color: var(--muted); margin-top: 18px; } .label .meta { font-weight: 700; font-size: 24px; letter-spacing: 1px; text-transform: uppercase;
.weather .meta b { color: var(--ink); } margin-left: auto; padding: 4px 12px; border: 3px solid var(--ink); }
.label .alarm { background: var(--ink); color: var(--paper); }
/* Titre de section */ hr.div { border: 0; border-top: 4px solid var(--ink); margin: 30px 0; }
.title { font-size: 36px; font-weight: 800; text-transform: uppercase;
letter-spacing: 3px; margin-bottom: 24px; }
/* Jauges Claude */ /* ---- Météo : silhouette 1-bit + relevé mono ---- */
.gauge { margin-bottom: 36px; } .wx { display: flex; align-items: center; gap: 30px; }
.gauge .row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; } .wx svg { width: 150px; height: 150px; flex: 0 0 auto; }
.gauge .name { font-size: 42px; font-weight: 700; } .wx .temp { font-size: 144px; font-weight: 800; line-height: .82; }
.gauge .pct { font-size: 66px; font-weight: 800; } .wx .deg { font-size: 64px; vertical-align: top; }
.gauge .pct small { font-size: 32px; font-weight: 600; } .wx-meta { margin-top: 18px; font-size: 30px; line-height: 1.45; }
.bar { position: relative; height: 50px; background: var(--gauge-bg); .wx-meta .k { font-family: "Archivo", sans-serif; font-weight: 700;
border: 4px solid var(--ink); border-radius: 6px; overflow: hidden; } text-transform: uppercase; letter-spacing: 1px; font-size: 24px; }
.bar .fill { position: absolute; top: 0; left: 0; bottom: 0; background: var(--ink); }
.bar.low .fill { background: repeating-linear-gradient(45deg, #000 0 14px, #fff 14px 22px); }
.gauge .sub { font-size: 30px; color: var(--muted); margin-top: 10px; }
.err { font-size: 40px; font-weight: 700; padding: 24px; border: 4px dashed var(--ink); }
/* Liste NAS (valeurs larges -> une colonne pleine largeur, sans soulignements) */ /* ---- Lignes de données (NAS, Maison) ---- */
.nas-list { display: flex; flex-direction: column; gap: 18px; } .rows { display: flex; flex-direction: column; }
.nas-list .ha-item { border-bottom: 0; padding-bottom: 0; } .rows.grid2 { display: grid; grid-template-columns: 1fr 1fr; column-gap: 40px; }
.bad { font-weight: 800; } .row { display: flex; justify-content: space-between; align-items: baseline; gap: 16px;
padding: 13px 0; border-bottom: 2px solid var(--ink); }
.row .k { font-weight: 700; font-size: 31px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .v { font-size: 36px; font-weight: 700; white-space: nowrap; }
.rows.grid2 .k { font-size: 28px; } .rows.grid2 .v { font-size: 32px; }
.ko { display: inline-block; padding: 0 8px; background: var(--ink); color: var(--paper); }
/* Grille Home Assistant */ /* ============================ JAUGE (signature) ============================
.ha-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 44px; } Échelle graduée : noir plein = budget RESTANT (le poids visuel colle au "% restant").
.ha-item { display: flex; justify-content: space-between; align-items: baseline; Repère ▼ = seuil d'alerte 20 %, toujours lisible (au-dessus de la piste).
border-bottom: 3px solid var(--ink); padding-bottom: 12px; } Sous le seuil : zone consommée en hachures + état alarme. */
.ha-item .k { font-size: 38px; font-weight: 600; } .g { margin-bottom: 34px; }
.ha-item .v { font-size: 44px; font-weight: 800; } .g:last-child { margin-bottom: 0; }
.g .head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 14px; }
.g .name { font-weight: 700; font-size: 38px; }
.g .pct { font-size: 80px; font-weight: 800; line-height: .8; }
.g .pct .u { font-size: 24px; font-weight: 700; letter-spacing: .5px; }
footer { grid-area: footer; display: flex; justify-content: space-between; .meter { position: relative; padding-top: 26px; } /* place pour le repère de seuil */
font-size: 28px; color: var(--muted); padding-top: 20px; border-top: 3px solid var(--ink); } .meter .mark { position: absolute; top: 0; left: 20%; transform: translateX(-50%);
.stale { font-weight: 800; color: var(--ink); } width: 0; height: 0; border-left: 11px solid transparent;
border-right: 11px solid transparent; border-top: 14px solid var(--ink); }
.track { position: relative; height: 58px; border: 4px solid var(--ink); background: var(--paper);
overflow: hidden; }
.track .fill { position: absolute; top: 0; bottom: 0; left: 0; background: var(--ink); }
.track .tick { position: absolute; top: 0; width: 3px; height: 16px; background: var(--ink); }
.track .t25 { left: 25%; } .track .t50 { left: 50%; } .track .t75 { left: 75%; }
.g.low .track { background: repeating-linear-gradient(-45deg, #000 0 4px, #fff 4px 11px); }
.g.low .mark { border-top-width: 18px; border-left-width: 13px; border-right-width: 13px; }
.g .sub { font-size: 28px; margin-top: 12px; }
/* Variante compacte (Codex, secondaire). */
.g.mini { margin-bottom: 22px; }
.g.mini .name { font-size: 30px; }
.g.mini .pct { font-size: 52px; }
.g.mini .pct .u { font-size: 22px; }
.g.mini .meter { padding-top: 22px; }
.g.mini .track { height: 38px; }
.g.mini .sub { font-size: 24px; margin-top: 9px; }
.err { font-size: 36px; font-weight: 700; padding: 22px; border: 4px solid var(--ink);
background: repeating-linear-gradient(-45deg, #000 0 4px, #fff 4px 11px); }
.err span { background: var(--paper); padding: 6px 10px; }
/* ---- Pied de page ---- */
footer { grid-area: foot; display: flex; justify-content: space-between; align-items: center;
margin-top: 26px; padding: 18px 0 22px; border-top: 4px solid var(--ink);
font-size: 26px; }
footer .num { font-weight: 500; }
.stale { background: var(--ink); color: var(--paper); padding: 2px 10px; font-weight: 700; }
.space { flex: 1 1 auto; }
</style> </style>
</head> </head>
{# ---- Glyphes météo 1-bit (silhouettes pleines, nettes sur e-ink) ---- #}
{% macro sun(cx, cy, r) -%}
<circle cx="{{cx}}" cy="{{cy}}" r="{{r}}"/>
{% for a in [0,45,90,135,180,225,270,315] %}
<rect x="{{cx-2.5}}" y="{{cy-r-13}}" width="5" height="11" transform="rotate({{a}} {{cx}} {{cy}})"/>
{% endfor %}
{%- endmacro %}
{% macro cloud(ox, oy, s) -%}
<g transform="translate({{ox}},{{oy}}) scale({{s}})">
<circle cx="32" cy="42" r="20"/><circle cx="56" cy="34" r="26"/>
<circle cx="78" cy="44" r="18"/><rect x="30" y="42" width="50" height="22" rx="11"/>
</g>
{%- endmacro %}
{% macro wxicon(kind) -%}
<svg viewBox="0 0 100 100" fill="#000" stroke="#000" stroke-linecap="round">
{% if kind == 'clear' %}{{ sun(50, 50, 21) }}
{% elif kind == 'partly' %}{{ sun(34, 32, 15) }}{{ cloud(18, 36, 0.78) }}
{% elif kind == 'cloudy' %}{{ cloud(8, 22, 0.95) }}
{% elif kind == 'fog' %}{{ cloud(8, 12, 0.9) }}
{% for y in [76, 86, 96] %}<rect x="14" y="{{y}}" width="72" height="5" rx="2"/>{% endfor %}
{% elif kind == 'rain' %}{{ cloud(8, 8, 0.92) }}
{% for x in [30, 50, 70] %}<rect x="{{x}}" y="74" width="5" height="20" rx="2" transform="rotate(18 {{x}} 84)"/>{% endfor %}
{% elif kind == 'snow' %}{{ cloud(8, 8, 0.92) }}
{% for x in [30, 50, 70] %}<circle cx="{{x}}" cy="86" r="5"/>{% endfor %}
{% elif kind == 'storm' %}{{ cloud(8, 4, 0.92) }}
<polygon points="52,66 38,90 50,90 44,100 66,76 53,76 60,66" stroke="none"/>
{% else %}{{ cloud(8, 22, 0.95) }}{% endif %}
</svg>
{%- endmacro %}
{# ---- Jauge : la signature ---- #}
{% macro gauge(g, mini=False) -%}
<div class="g{% if g.remaining < 20 %} low{% endif %}{% if mini %} mini{% endif %}">
<div class="head">
<span class="name">{{ g.name }}</span>
<span class="pct num">{{ g.remaining | round | int }}<span class="u">% restant</span></span>
</div>
<div class="meter">
<span class="mark"></span>
<div class="track">
<span class="tick t25"></span><span class="tick t50"></span><span class="tick t75"></span>
<div class="fill" style="width: {{ g.remaining | round(1) }}%;"></div>
</div>
</div>
<div class="sub num">reset {{ g.resets_in }} · {{ (100 - g.remaining) | round | int }}% conso{% if g.extra %} · {{ g.extra }}{% endif %}</div>
</div>
{%- endmacro %}
<body> <body>
<!-- Colonne gauche : météo, NAS --> <!-- COLONNE GAUCHE : météo + infra maison -->
<div class="col col-left"> <div class="pane">
<div class="section weather"> <div class="wx">
{% if weather.ok %} {% if weather.ok %}
<div class="top"> {{ wxicon(weather.kind) }}
<div class="icon">{{ weather.icon }}</div> <div>
<div class="temp">{{ weather.temp | round | int }}°</div> <div class="temp num">{{ weather.temp | round | int }}<span class="deg">°C</span></div>
</div>
<div class="meta">
{{ weather.label }} · ressenti <b>{{ weather.feels_like | round | int }}°</b>
</div>
<div class="meta">
min <b>{{ weather.temp_min | round | int }}°</b> / max <b>{{ weather.temp_max | round | int }}°</b>{% if weather.precip_prob is not none %} · pluie <b>{{ weather.precip_prob }}%</b>{% endif %}
</div> </div>
{% else %} {% else %}
<div class="meta">Météo indisponible</div> <div class="wx-meta">Météo indisponible</div>
{% endif %} {% endif %}
</div> </div>
{% if weather.ok %}
<div class="wx-meta num">
{{ weather.label }} · ressenti <span class="k">{{ weather.feels_like | round | int }}°</span>
· {{ weather.temp_min | round | int }}° / {{ weather.temp_max | round | int }}°{% if weather.precip_prob is not none %} · pluie {{ weather.precip_prob }}%{% endif %}
</div>
{% endif %}
{% if nas.ok %} {% if nas.ok %}
<hr class="rule"> <hr class="div">
<div class="section"> <div class="label"><span class="t">NAS</span>
<div class="title">NAS</div> {% if nas.docker_unhealthy %}<span class="meta alarm">{{ nas.docker_unhealthy }} KO</span>{% endif %}</div>
<div class="nas-list"> <div class="rows">
{% for d in nas.disks %} {% for d in nas.disks %}
<div class="ha-item"><span class="k">{{ d.label }}</span><span class="v">{{ d.percent | round | int }}% · {{ d.free_human }} libre</span></div> <div class="row"><span class="k">{{ d.label }}</span><span class="v num">{{ d.percent | round | int }}% · {{ d.free_human }}</span></div>
{% endfor %}
<div class="ha-item"><span class="k">Docker</span><span class="v">{{ nas.docker_running }}/{{ nas.docker_total }}{% if nas.docker_unhealthy %} · <span class="bad">{{ nas.docker_unhealthy }} KO</span>{% else %} ✓{% endif %}</span></div>
<div class="ha-item"><span class="k">Port VPN</span><span class="v">{% if nas.vpn_ok %}OK{% if nas.vpn_port %} · {{ nas.vpn_port }}{% endif %}{% else %}<span class="bad">✗ désync</span>{% endif %}</span></div>
</div>
</div>
{% endif %}
</div>
<!-- Colonne droite : abonnement Claude + maison -->
<div class="col col-right">
<div class="section">
<div class="title">Claude{% if claude.ok %} · Max 5x{% endif %}</div>
{% if not claude.ok %}
<div class="err">⚠ {{ claude.error }}</div>
{% else %}
{% for g in gauges %}
<div class="gauge">
<div class="row">
<span class="name">{{ g.name }}</span>
<span class="pct">{{ g.remaining | round | int }}<small>% restant</small></span>
</div>
<div class="bar {% if g.remaining < 20 %}low{% endif %}">
<div class="fill" style="width: {{ (100 - g.remaining) | round(1) }}%;"></div>
</div>
<div class="sub">{{ (100 - g.remaining) | round | int }}% utilisé · reset dans {{ g.resets_in }}{% if g.extra %} · {{ g.extra }}{% endif %}</div>
</div>
{% endfor %}
{% if claude.extra %}
<div class="sub" style="font-size:32px;">{{ claude.extra.label }}</div>
{% endif %}
{% if claude.burn_rate %}
<div class="sub" style="font-size:32px;">Burn rate : {{ claude.burn_rate | round | int }} tok/min</div>
{% endif %}
{% endif %}
</div>
{% if codex.ok %}
<div class="section">
<div class="title">Codex{% if codex.plan_type %} · {{ codex.plan_type | capitalize }}{% endif %}{% if codex.limited %} · <span class="bad">⚠ limite atteinte</span>{% endif %}</div>
{% for g in codex.gauges %}
<div class="gauge">
<div class="row">
<span class="name">{{ g.name }}</span>
<span class="pct">{{ g.remaining | round | int }}<small>% restant</small></span>
</div>
<div class="bar {% if g.remaining < 20 %}low{% endif %}">
<div class="fill" style="width: {{ (100 - g.remaining) | round(1) }}%;"></div>
</div>
<div class="sub">{{ (100 - g.remaining) | round | int }}% utilisé · reset dans {{ g.resets_in }}</div>
</div>
{% endfor %} {% endfor %}
<div class="row"><span class="k">Docker</span><span class="v num">{{ nas.docker_running }}/{{ nas.docker_total }}{% if not nas.docker_unhealthy %} ✓{% endif %}</span></div>
<div class="row"><span class="k">VPN</span><span class="v num">{% if nas.vpn_ok %}OK{% if nas.vpn_port %} · {{ nas.vpn_port }}{% endif %}{% else %}<span class="ko">DÉSYNC</span>{% endif %}</span></div>
</div> </div>
{% endif %} {% endif %}
{% if ha_states %} {% if ha_states %}
<div class="section"> <hr class="div">
<div class="title">Maison</div> <div class="label"><span class="t">Maison</span></div>
<div class="ha-grid"> <div class="rows grid2">
{% for s in ha_states %} {% for s in ha_states %}
<div class="ha-item"><span class="k">{{ s.label }}</span><span class="v">{{ s.display }}</span></div> <div class="row"><span class="k">{{ s.label }}</span><span class="v num">{{ s.display }}</span></div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %}
<div class="space"></div>
</div>
<!-- COLONNE DROITE : budgets (le héros) -->
<div class="deck">
<div class="label"><span class="t">Claude</span>
{% if claude.ok %}<span class="meta">Max 5×</span>{% endif %}</div>
{% if not claude.ok %}
<div class="err"><span>⚠ {{ claude.error }}</span></div>
{% else %}
{% for g in gauges %}{{ gauge(g) }}{% endfor %}
{% if claude.extra or claude.burn_rate %}
<div class="sub num" style="font-size:26px; margin-top:6px;">
{% 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 %}
</div> </div>
{% endif %} {% endif %}
{% endif %}
{% if codex.ok %}
<hr class="div">
<div class="label"><span class="t">Codex</span>
{% if codex.plan_type %}<span class="meta">{{ codex.plan_type | capitalize }}</span>{% endif %}
{% if codex.limited %}<span class="meta alarm">Limite</span>{% endif %}</div>
{% for g in codex.gauges %}{{ gauge(g, mini=True) }}{% endfor %}
{% endif %}
<div class="space"></div>
</div> </div>
<footer> <footer>
<span>Monitorink · 3 appuis bouton page = redémarrer</span> <span class="num">monitorink · 3× bouton page reboot</span>
{% if kobo.ok %}<span class="{% if kobo.low %}stale{% endif %}">{% if kobo.charging %}⚡{% else %}🔋{% endif %} Kobo {{ kobo.percent }}%{% if kobo.stale %} ·&nbsp;?{% endif %}</span>{% endif %} <span class="space"></span>
<span class="{% if stale %}stale{% endif %}">maj {{ updated }}{% if stale %} · DONNÉE PÉRIMÉE{% endif %}</span> {% if kobo.ok %}<span class="num">{% if kobo.charging %}⚡{% else %}batt{% endif %} {{ kobo.percent }}%{% if kobo.stale %} ?{% endif %}</span>{% endif %}
<span class="num" style="margin-left:28px;">maj {{ updated }}{% if stale %} <span class="stale">PÉRIMÉ</span>{% endif %}</span>
</footer> </footer>
</body> </body>

93
dev/preview.py Normal file
View File

@@ -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())