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

View File

@@ -3,180 +3,243 @@
<head>
<meta charset="utf-8">
<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; }
:root {
--ink: #000;
--paper: #fff;
--muted: #555;
--line: #000;
--gauge-bg: #d9d9d9;
}
:root { --ink: #000; --paper: #fff; }
html, body {
width: {{ width }}px; height: {{ height }}px;
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;
font-variant-numeric: tabular-nums;
}
/* Deux colonnes + pied de page pleine largeur. */
.num { font-family: "JetBrains Mono", monospace; font-variant-numeric: tabular-nums; }
body {
padding: 44px 52px;
padding: 46px 52px 0;
display: grid;
grid-template-columns: 600px 1fr;
grid-template-columns: 560px 1fr;
grid-template-rows: 1fr auto;
grid-template-areas: "left right" "footer footer";
column-gap: 56px;
row-gap: 28px;
grid-template-areas: "left right" "foot foot";
column-gap: 52px;
}
.col {
display: flex; flex-direction: column; min-width: 0;
}
.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; }
.pane { grid-area: left; 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); }
/* Météo */
.weather .top { display: flex; align-items: center; gap: 32px; }
.weather .icon { font-size: 140px; line-height: 1; }
.weather .temp { font-size: 130px; font-weight: 800; line-height: 1; }
.weather .meta { font-size: 40px; color: var(--muted); margin-top: 18px; }
.weather .meta b { color: var(--ink); }
/* Étiquette de bloc : barre pleine + mot. Encode "section", pas de déco capitale flottante. */
.label { display: flex; align-items: center; gap: 16px; margin-bottom: 26px; }
.label::before { content: ""; width: 26px; height: 26px; background: var(--ink); flex: 0 0 auto; }
.label .t { font-weight: 800; font-size: 33px; letter-spacing: 1px; text-transform: uppercase; }
.label .meta { font-weight: 700; font-size: 24px; letter-spacing: 1px; text-transform: uppercase;
margin-left: auto; padding: 4px 12px; border: 3px solid var(--ink); }
.label .alarm { background: var(--ink); color: var(--paper); }
/* Titre de section */
.title { font-size: 36px; font-weight: 800; text-transform: uppercase;
letter-spacing: 3px; margin-bottom: 24px; }
hr.div { border: 0; border-top: 4px solid var(--ink); margin: 30px 0; }
/* Jauges Claude */
.gauge { margin-bottom: 36px; }
.gauge .row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
.gauge .name { font-size: 42px; font-weight: 700; }
.gauge .pct { font-size: 66px; font-weight: 800; }
.gauge .pct small { font-size: 32px; font-weight: 600; }
.bar { position: relative; height: 50px; background: var(--gauge-bg);
border: 4px solid var(--ink); border-radius: 6px; overflow: hidden; }
.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); }
/* ---- Météo : silhouette 1-bit + relevé mono ---- */
.wx { display: flex; align-items: center; gap: 30px; }
.wx svg { width: 150px; height: 150px; flex: 0 0 auto; }
.wx .temp { font-size: 144px; font-weight: 800; line-height: .82; }
.wx .deg { font-size: 64px; vertical-align: top; }
.wx-meta { margin-top: 18px; font-size: 30px; line-height: 1.45; }
.wx-meta .k { font-family: "Archivo", sans-serif; font-weight: 700;
text-transform: uppercase; letter-spacing: 1px; font-size: 24px; }
/* Liste NAS (valeurs larges -> une colonne pleine largeur, sans soulignements) */
.nas-list { display: flex; flex-direction: column; gap: 18px; }
.nas-list .ha-item { border-bottom: 0; padding-bottom: 0; }
.bad { font-weight: 800; }
/* ---- Lignes de données (NAS, Maison) ---- */
.rows { display: flex; flex-direction: column; }
.rows.grid2 { display: grid; grid-template-columns: 1fr 1fr; column-gap: 40px; }
.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 */
.ha-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 44px; }
.ha-item { display: flex; justify-content: space-between; align-items: baseline;
border-bottom: 3px solid var(--ink); padding-bottom: 12px; }
.ha-item .k { font-size: 38px; font-weight: 600; }
.ha-item .v { font-size: 44px; font-weight: 800; }
/* ============================ JAUGE (signature) ============================
Échelle graduée : noir plein = budget RESTANT (le poids visuel colle au "% restant").
Repère ▼ = seuil d'alerte 20 %, toujours lisible (au-dessus de la piste).
Sous le seuil : zone consommée en hachures + état alarme. */
.g { margin-bottom: 34px; }
.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;
font-size: 28px; color: var(--muted); padding-top: 20px; border-top: 3px solid var(--ink); }
.stale { font-weight: 800; color: var(--ink); }
.meter { position: relative; padding-top: 26px; } /* place pour le repère de seuil */
.meter .mark { position: absolute; top: 0; left: 20%; transform: translateX(-50%);
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>
</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>
<!-- Colonne gauche : météo, NAS -->
<div class="col col-left">
<div class="section weather">
<!-- COLONNE GAUCHE : météo + infra maison -->
<div class="pane">
<div class="wx">
{% if weather.ok %}
<div class="top">
<div class="icon">{{ weather.icon }}</div>
<div class="temp">{{ weather.temp | round | int }}°</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 %}
{{ wxicon(weather.kind) }}
<div>
<div class="temp num">{{ weather.temp | round | int }}<span class="deg">°C</span></div>
</div>
{% else %}
<div class="meta">Météo indisponible</div>
<div class="wx-meta">Météo indisponible</div>
{% endif %}
</div>
{% if nas.ok %}
<hr class="rule">
<div class="section">
<div class="title">NAS</div>
<div class="nas-list">
{% 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>
{% 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>
{% 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 %}
</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>
{% if nas.ok %}
<hr class="div">
<div class="label"><span class="t">NAS</span>
{% if nas.docker_unhealthy %}<span class="meta alarm">{{ nas.docker_unhealthy }} KO</span>{% endif %}</div>
<div class="rows">
{% for d in nas.disks %}
<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="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>
{% endif %}
{% if ha_states %}
<div class="section">
<div class="title">Maison</div>
<div class="ha-grid">
{% for s in ha_states %}
<div class="ha-item"><span class="k">{{ s.label }}</span><span class="v">{{ s.display }}</span></div>
{% endfor %}
</div>
<hr class="div">
<div class="label"><span class="t">Maison</span></div>
<div class="rows grid2">
{% for s in ha_states %}
<div class="row"><span class="k">{{ s.label }}</span><span class="v num">{{ s.display }}</span></div>
{% endfor %}
</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>
{% 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>
<footer>
<span>Monitorink · 3 appuis bouton page = redémarrer</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="{% if stale %}stale{% endif %}">maj {{ updated }}{% if stale %} · DONNÉE PÉRIMÉE{% endif %}</span>
<span class="num">monitorink · 3× bouton page reboot</span>
<span class="space"></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>
</body>