feat(picobrew): panel hybrid-dark avec navbar + toasts + lien appliance

Réécrit le panneau selon le look management par défaut du dépôt
(hybrid-dark, Courier Prime, sidebar via /shared/sidebar.js), au lieu du
squelette minimal C3BOX. Ajoute des toasts d'action (échec start/stop plus
silencieux), vérifie response.ok, et un lien vers l'interface réelle de
l'appareil (https://<ip>/) affiché quand le conteneur tourne.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-23 21:41:31 +02:00
parent 1dfcb66fa5
commit 9b45487da7

View File

@ -1,72 +1,153 @@
<!DOCTYPE html>
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
<html lang="fr" class="hybrid-dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SecuBox — PicoBrew</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared/hybrid-dark.css">
<style>
body { background:#0a0a0f; color:#e8e6d9; font-family:system-ui,sans-serif; margin:0; padding:1.5rem; }
h1 { color:#c9a84c; font-size:1.3rem; margin:0 0 1rem; }
.kv { display:flex; gap:.6rem; padding:.35rem 0; border-bottom:1px solid #1e1e2a; }
.k { color:#6b6b7a; min-width:11rem; } .v { color:#e8e6d9; }
.on { color:#00ff41; } .off { color:#e63946; }
button { background:#1e1e2a; color:#e8e6d9; border:1px solid #6e40c9; border-radius:4px;
padding:.45rem .9rem; margin-right:.5rem; cursor:pointer; }
button:hover { border-color:#00d4ff; }
.warn { color:#c9a84c; font-size:.82rem; margin-top:1rem; }
.err { color:#e63946; font-size:.82rem; margin-top:.6rem; }
a.device-link { display:inline-block; margin-top:1rem; color:#00d4ff; }
:root{
--p31-dim:#6b7b8b; --bg-dark:#0d1117; --bg-card:rgba(30,40,55,.8);
--bg-row:rgba(20,30,45,.6); --border:rgba(100,150,200,.2); --text:#e8e6d9;
--cyan:#00d4ff; --red:#ff4466; --orange:#ff9944; --yellow:#ffcc00;
--green:#00dd44; --blue:#4488ff; --purple:#a371f7;
}
body{font-family:'Courier Prime',monospace;color:var(--text);background:var(--bg-dark)}
.header{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;flex-wrap:wrap;gap:.5rem}
.header h1{font-size:1.4rem;color:var(--cyan);text-shadow:0 0 10px var(--cyan)}
.header .sub{font-size:.72rem;color:var(--p31-dim);letter-spacing:.05em;margin-top:.25rem}
.btn-group{display:flex;flex-wrap:wrap;gap:.4rem}
.btn{font-family:inherit;font-size:.72rem;text-transform:uppercase;letter-spacing:.05em;
text-decoration:none;color:var(--text);background:var(--bg-row);border:1px solid var(--border);
border-radius:6px;padding:.45rem .8rem;cursor:pointer;transition:border-color .15s,box-shadow .15s}
.btn:hover{border-color:var(--cyan);box-shadow:0 0 8px rgba(0,212,255,.4)}
.btn.primary{border-color:var(--cyan);color:var(--cyan)}
.btn[disabled]{opacity:.4;cursor:not-allowed}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:.75rem;margin-bottom:1rem}
.stat-card{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;
padding:.7rem;text-align:center;backdrop-filter:blur(10px)}
.stat-card .value{font-size:1.3rem;font-weight:bold;text-shadow:0 0 10px currentColor;color:var(--text)}
.stat-card .label{font-size:.6rem;color:var(--p31-dim);letter-spacing:.1em;margin-top:.2rem;text-transform:uppercase}
.stat-card.ok .value{color:var(--green)} .stat-card.off .value{color:var(--red)}
.stat-card.cyan .value{color:var(--cyan)} .stat-card.yellow .value{color:var(--yellow)}
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;
padding:1rem;margin-bottom:1rem;backdrop-filter:blur(10px)}
.card h3{font-size:.8rem;color:var(--cyan);text-transform:uppercase;letter-spacing:.08em;margin-bottom:.6rem}
.card p{font-size:.8rem;color:var(--p31-dim);line-height:1.5}
.kv{display:flex;justify-content:space-between;gap:1rem;padding:.35rem 0;border-bottom:1px solid var(--border);font-size:.82rem}
.kv:last-child{border-bottom:0}
.kv .k{color:var(--p31-dim)} .kv .v{font-variant-numeric:tabular-nums}
code{color:var(--cyan);font-size:.78rem}
.toast{position:fixed;bottom:1rem;right:1rem;background:var(--cyan);color:#07121a;
padding:.6rem 1rem;border-radius:6px;font-size:.8rem;opacity:0;pointer-events:none;
transition:opacity .2s;z-index:50}
.toast.show{opacity:1} .toast.error{background:var(--red);color:#fff}
@media(max-width:768px){.main{margin-left:0}}
</style>
<h1>🍺 PicoBrew</h1>
<div id="state"><div class="kv"><span class="k">chargement…</span></div></div>
<div id="device-link"></div>
<div style="margin-top:1rem">
<button onclick="act('start')">Démarrer</button>
<button onclick="act('stop')">Arrêter</button>
<button onclick="load()">Rafraîchir</button>
</div>
<div class="warn" id="warn"></div>
<div class="err" id="err"></div>
</head>
<body class="hybrid-dark">
<nav class="sidebar" id="sidebar"></nav>
<script src="/shared/sidebar.js"></script>
<main class="main">
<header class="header">
<div>
<h1>🍺 PicoBrew</h1>
<div class="sub" id="sub">nœud de brassage · chargement…</div>
</div>
<div class="btn-group">
<button class="btn" onclick="load()">↻ Rafraîchir</button>
<button class="btn" id="b-start" onclick="act('start','Démarrage')">▶ Démarrer</button>
<button class="btn" id="b-stop" onclick="act('stop','Arrêt')">⏹ Arrêter</button>
<a class="btn primary" id="a-open" target="_blank" rel="noopener" style="display:none">🍺 Interface PicoBrew</a>
</div>
</header>
<div class="grid" id="cards">
<div class="stat-card" id="c-installed"><div class="value"></div><div class="label">📦 Conteneur</div></div>
<div class="stat-card" id="c-running"><div class="value"></div><div class="label">⚙️ État</div></div>
<div class="stat-card cyan"><div class="value" id="c-sha" style="font-size:.9rem"></div><div class="label">🔖 Version figée</div></div>
<div class="stat-card" id="c-session"><div class="value"></div><div class="label">🔥 Session</div></div>
</div>
<div class="card">
<h3>Détails</h3>
<div class="kv"><span class="k">Adresse du conteneur</span><span class="v" id="d-ip"></span></div>
<div class="kv"><span class="k">Version upstream (SHA figé)</span><span class="v"><code id="d-sha"></code></span></div>
<div class="kv"><span class="k">Session de brassage active</span><span class="v" id="d-session"></span></div>
</div>
<div class="card">
<h3>À propos</h3>
<p>Ce module redonne vie à un appareil PicoBrew dont le cloud constructeur est éteint depuis 2020,
via un conteneur qui parle son protocole. L'appareil est redirigé localement (DNS) vers ce nœud.
La version du serveur est <b>figée</b> : aucune mise à jour ne survient pendant un brassage.</p>
</div>
</main>
<div class="toast" id="toast"></div>
<script>
const API = '/api/v1/picobrew';
const $ = (id) => document.getElementById(id);
function toast(msg, isErr) {
const t = $('toast');
t.textContent = msg;
t.className = 'toast show' + (isErr ? ' error' : '');
setTimeout(() => { t.className = 'toast'; }, 2600);
}
async function J(path, opts) {
const r = await fetch(API + path, Object.assign({credentials:'same-origin'}, opts||{}));
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
function badge(el, on, onText, offText) {
el.className = 'stat-card ' + (on ? 'ok' : 'off');
el.querySelector('.value').textContent = on ? (onText||'oui') : (offText||'non');
}
async function load() {
const el = document.getElementById('state');
const linkEl = document.getElementById('device-link');
const errEl = document.getElementById('err');
try {
const resp = await fetch(API + '/status', {credentials:'same-origin'});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const d = await resp.json();
const b = (x) => x ? '<span class="on">oui</span>' : '<span class="off">non</span>';
el.innerHTML =
`<div class="kv"><span class="k">Conteneur installé</span><span class="v">${b(d.installed)}</span></div>` +
`<div class="kv"><span class="k">En marche</span><span class="v">${b(d.running)}</span></div>` +
`<div class="kv"><span class="k">Adresse</span><span class="v">${d.ip || '—'}</span></div>` +
`<div class="kv"><span class="k">Version figée (SHA)</span><span class="v"><code>${(d.pinned_sha||'none').slice(0,12)}</code></span></div>` +
`<div class="kv"><span class="k">Session de brassage</span><span class="v">${b(d.session_active)}</span></div>`;
document.getElementById('warn').textContent = d.session_active
? 'Session active : les mises à jour sont refusées tant qu\'elle dure.'
: (d.error || '');
// Le lien vers l'interface PicoBrew elle-même n'a de sens que si le
// conteneur tourne réellement — sinon il ne mènerait nulle part.
linkEl.innerHTML = (d.running && d.ip)
? `<a class="device-link" href="https://${d.ip}/" target="_blank" rel="noopener">→ Ouvrir l'interface PicoBrew (${d.ip})</a>`
: '';
errEl.textContent = '';
const d = await J('/status');
$('sub').textContent = d.error ? ('nœud de brassage · ' + d.error)
: 'nœud de brassage · ' + (d.running ? 'en marche' : 'à l\'arrêt');
badge($('c-installed'), !!d.installed, 'installé', 'absent');
badge($('c-running'), !!d.running, 'en marche', 'arrêté');
badge($('c-session'), !!d.session_active, '🔥 oui', 'non');
const sha = (d.pinned_sha && d.pinned_sha !== 'none') ? d.pinned_sha : null;
$('c-sha').textContent = sha ? sha.slice(0,10) : '—';
$('d-sha').textContent = sha || '—';
$('d-ip').textContent = d.ip || '—';
$('d-session').textContent = d.session_active ? '🔥 oui (mise à jour verrouillée)' : 'non';
// Lien vers l'interface réelle de l'appareil — seulement quand le conteneur tourne (I7).
const open = $('a-open');
if (d.running && d.ip) { open.href = 'https://' + d.ip + '/'; open.style.display = ''; }
else { open.style.display = 'none'; }
$('b-start').disabled = !!d.running;
$('b-stop').disabled = !d.running;
} catch (e) {
el.innerHTML = '<div class="kv"><span class="k off">panel injoignable</span></div>';
linkEl.innerHTML = '';
errEl.textContent = 'Erreur de chargement : ' + e.message;
$('sub').textContent = 'panel injoignable (' + e.message + ')';
toast('Statut indisponible : ' + e.message, true);
}
}
async function act(a) {
const errEl = document.getElementById('err');
async function act(verb, label) {
try {
const resp = await fetch(API + '/' + a, {method:'POST', credentials:'same-origin'});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const d = await resp.json();
errEl.textContent = d.ok === false ? `Action "${a}" refusée par picobrewctl.` : '';
const d = await J('/' + verb, {method:'POST'});
toast(d && d.ok === false ? (label + ' a échoué') : (label + ' demandé'), d && d.ok === false);
} catch (e) {
errEl.textContent = `Action "${a}" échouée : ` + e.message;
toast(label + ' impossible : ' + e.message, true);
}
setTimeout(load, 1200);
setTimeout(load, 1300);
}
load();
</script>
</body>
</html>