feat(picobrew): panel embarque l'UI complète picobrew_pico (iframe) + bandeau de contrôle
Some checks are pending
License Headers / check (push) Waiting to run

Webui admin la plus complète possible sans dev : bandeau de contrôle SecuBox
(état + start/stop/rafraîchir + plein écran) au-dessus d'un iframe de l'UI
complète de picobrew_pico, servie via son sous-domaine LAN-confiné
picobrew.gk2.secubox.in (même domaine parent → cert wildcard valide, pas de
mixed-content). L'iframe n'est chargée que conteneur en marche.

Réf. montage vhost (live gk2, patron lyrion) : sous-domaine LAN-only
(snippet exposure allow-privé/deny-all) → nginx:9080 → LXC:80, TLS terminée
par HAProxy, route dans /etc/secubox/waf/haproxy-routes.json, upgrade WebSocket
(Upgrade/Connection comme spiderfoot). CAVEAT: un wss:// via wg-toolbox échoue
tant que sbxmitm ne pipe pas l'upgrade WebSocket dans un flux MITM'd (général,
pas spécifique picobrew).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-24 07:02:36 +02:00
parent 1208374a9a
commit e287389138

View File

@ -12,38 +12,29 @@
: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;
--cyan:#00d4ff; --red:#ff4466; --green:#00dd44;
}
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}
.main{display:flex;flex-direction:column;height:100vh;box-sizing:border-box}
.bar{display:flex;justify-content:space-between;align-items:center;gap:.6rem;flex-wrap:wrap;
padding:.55rem .8rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;margin-bottom:.6rem}
.bar h1{font-size:1.05rem;color:var(--cyan);text-shadow:0 0 8px var(--cyan);margin:0}
.bar .st{font-size:.72rem;color:var(--p31-dim);margin-left:.6rem}
.dot{display:inline-block;width:.6rem;height:.6rem;border-radius:50%;background:var(--p31-dim);margin-right:.3rem;vertical-align:middle}
.dot.on{background:var(--green);box-shadow:0 0 6px var(--green)} .dot.off{background:var(--red)}
.btns{display:flex;flex-wrap:wrap;gap:.4rem}
.btn{font-family:inherit;font-size:.7rem;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:.4rem .75rem;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}
.frame-wrap{flex:1;position:relative;border:1px solid var(--border);border-radius:8px;overflow:hidden;background:#fff}
iframe{width:100%;height:100%;border:0;display:block}
.placeholder{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
text-align:center;color:var(--p31-dim);font-size:.9rem;background:var(--bg-dark);padding:2rem}
.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>
@ -53,100 +44,73 @@
<script src="/shared/sidebar.js"></script>
<main class="main">
<header class="header">
<div class="bar">
<div>
<h1>🍺 PicoBrew</h1>
<div class="sub" id="sub">nœud de brassage · chargement…</div>
<h1 style="display:inline">🍺 PicoBrew</h1>
<span class="st" id="st"><span class="dot" id="dot"></span>chargement…</span>
</div>
<div class="btn-group">
<button class="btn" onclick="load()">↻ Rafraîchir</button>
<div class="btns">
<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>
<button class="btn" onclick="load()">↻ Rafraîchir</button>
<a class="btn primary" id="a-open" href="https://picobrew.gk2.secubox.in/" target="_blank" rel="noopener">↗ Plein écran</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 class="frame-wrap">
<iframe id="app" title="Interface PicoBrew" referrerpolicy="no-referrer"></iframe>
<div class="placeholder" id="ph">Conteneur à l'arrêt — démarre-le pour afficher l'interface PicoBrew.</div>
</div>
</main>
<div class="toast" id="toast"></div>
<script>
// L'interface complète de picobrew_pico est embarquée via son sous-domaine
// LAN-confiné (même domaine parent → cert wildcard valide, pas de mixed-content).
const APP_URL = 'https://picobrew.gk2.secubox.in/';
const API = '/api/v1/picobrew';
const $ = (id) => document.getElementById(id);
function toast(msg, isErr) {
const t = $('toast');
t.textContent = msg;
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() {
try {
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;
const running = !!d.running;
$('dot').className = 'dot ' + (running ? 'on' : 'off');
const sha = (d.pinned_sha && d.pinned_sha !== 'none') ? d.pinned_sha.slice(0,8) : '—';
$('st').innerHTML = '<span class="dot ' + (running?'on':'off') + '"></span>' +
(d.error ? d.error : (running ? 'en marche' : 'à l\'arrêt')) +
' · ' + (d.ip || '—') + ' · v' + sha;
$('b-start').disabled = running; $('b-stop').disabled = !running;
const app = $('app'), ph = $('ph');
if (running) {
if (app.getAttribute('src') !== APP_URL) app.setAttribute('src', APP_URL);
ph.style.display = 'none';
} else {
app.removeAttribute('src'); ph.style.display = 'flex';
}
} catch (e) {
$('sub').textContent = 'panel injoignable (' + e.message + ')';
$('st').innerHTML = '<span class="dot off"></span>panel injoignable (' + e.message + ')';
toast('Statut indisponible : ' + e.message, true);
}
}
async function act(verb, label) {
try {
const d = await J('/' + verb, {method:'POST'});
toast(d && d.ok === false ? (label + ' a échoué') : (label + ' demandé'), d && d.ok === false);
} catch (e) {
toast(label + ' impossible : ' + e.message, true);
} catch (e) { toast(label + ' impossible : ' + e.message, true); }
setTimeout(load, 1500);
}
setTimeout(load, 1300);
}
load();
</script>
</body>