Files
L'électron rare 5a23f3e47b feat(ui): transfers tab with autorefresh
Add panel-transfers + tab-transfers DOM, loadTransfers() with
setInterval(2s) while active != null, stopTransfersTimer() on
leave-tab and on idle, retryTransfers() for POST /api/transfers/retry.
All record fields escaped; null duration/rate guarded.
2026-06-21 09:51:49 +02:00

750 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Merlin Studio</title>
<style>
:root {
--bg: #fdf7ff;
--card: #ffffff;
--violet: #7c4dff;
--violet-d: #5e35b1;
--orange: #ff8a3d;
--orange-d: #f06c1d;
--ink: #3a2e4d;
--muted: #8a7ea0;
--ok: #2e9e5b;
--err: #d8324b;
--line: #ece3f6;
--radius: 18px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: "Comic Sans MS", "Trebuchet MS", system-ui, sans-serif;
font-size: 17px;
line-height: 1.4;
}
.wrap { max-width: 700px; margin: 0 auto; padding: 16px clamp(12px, 4vw, 24px) 60px; }
header { text-align: center; padding: 18px 0 8px; }
header h1 { margin: 0; font-size: 2rem; color: var(--violet-d); }
header .sub { color: var(--orange-d); font-size: 1.1rem; font-weight: bold; }
.tabs { display: flex; gap: 8px; margin: 18px 0; }
.tabs button {
flex: 1; padding: 14px 8px; font-size: 1.05rem; cursor: pointer;
border: none; border-radius: var(--radius); background: #efe7fb;
color: var(--violet-d); font-family: inherit; font-weight: bold;
transition: transform .08s ease;
}
.tabs button:active { transform: scale(0.97); }
.tabs button.active { background: var(--violet); color: #fff; box-shadow: 0 4px 12px rgba(124,77,255,.35); }
section.panel { display: none; }
section.panel.active { display: block; }
.card {
background: var(--card); border: 2px solid var(--line); border-radius: var(--radius);
padding: 16px; margin: 12px 0; box-shadow: 0 3px 10px rgba(124,77,255,.06);
}
.card h3 { margin: 0 0 10px; color: var(--violet-d); }
.row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.row.between { justify-content: space-between; }
.grow { flex: 1 1 auto; min-width: 0; }
label { display: block; margin: 8px 0 4px; color: var(--muted); font-size: .95rem; }
input[type=text], input[type=url], input[type=number], input[type=file] {
width: 100%; padding: 12px; font-size: 1rem; font-family: inherit;
border: 2px solid var(--line); border-radius: 12px; background: #fbf8ff; color: var(--ink);
}
input:focus { outline: none; border-color: var(--violet); }
button.btn {
padding: 13px 18px; font-size: 1rem; font-family: inherit; font-weight: bold;
border: none; border-radius: 14px; cursor: pointer; color: #fff; background: var(--violet);
transition: transform .08s ease, opacity .15s ease;
}
button.btn:active { transform: scale(0.97); }
button.btn.orange { background: var(--orange); }
button.btn.ghost { background: #efe7fb; color: var(--violet-d); }
button.btn:disabled { opacity: .45; cursor: not-allowed; }
button.icon {
border: none; background: transparent; cursor: pointer; font-size: 1.3rem;
padding: 6px 8px; border-radius: 10px; line-height: 1;
}
button.icon:active { background: #f3eafe; }
.feedname { font-weight: bold; font-size: 1.1rem; }
.badge {
display: inline-block; background: #e3f6e9; color: var(--ok);
border-radius: 10px; padding: 2px 8px; font-size: .8rem; font-weight: bold; margin-left: 6px;
}
ul.eps { list-style: none; margin: 12px 0 0; padding: 0; }
ul.eps li { display: flex; align-items: flex-start; gap: 10px; padding: 9px 0; border-top: 1px solid var(--line); }
ul.eps li label { margin: 0; color: var(--ink); flex: 1; cursor: pointer; }
ul.eps input[type=checkbox] { width: 22px; height: 22px; margin-top: 2px; accent-color: var(--violet); flex: none; }
.dur { color: var(--muted); font-size: .85rem; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px 6px; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-size: .85rem; }
td.num { text-align: right; white-space: nowrap; color: var(--muted); }
.fname { word-break: break-all; }
.stat { display: flex; justify-content: space-between; padding: 10px 4px; border-bottom: 1px solid var(--line); }
.stat .k { color: var(--muted); }
.stat .v { font-weight: bold; }
.spinner { display: inline-block; width: 22px; height: 22px; border: 3px solid var(--line);
border-top-color: var(--violet); border-radius: 50%; animation: spin .8s linear infinite; vertical-align: middle; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading { color: var(--muted); padding: 14px 0; }
#banner {
display: none; background: #ffe2e6; color: var(--err); border: 2px solid #f7b9c3;
border-radius: 14px; padding: 12px 14px; margin: 12px 0; font-weight: bold;
}
#banner.show { display: block; }
#toast {
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%) translateY(140%);
background: var(--violet-d); color: #fff; padding: 14px 22px; border-radius: 16px;
box-shadow: 0 8px 24px rgba(0,0,0,.25); font-weight: bold; max-width: 90vw; text-align: center;
transition: transform .3s ease; z-index: 50;
}
#toast.show { transform: translateX(-50%) translateY(0); }
#toast.err { background: var(--err); }
.empty { color: var(--muted); text-align: center; padding: 18px 0; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>🪄 Merlin Studio</h1>
<div class="sub">Lisael Box</div>
</header>
<div id="banner"></div>
<div class="tabs">
<button id="tab-podcasts" class="active" onclick="showTab('podcasts')">🎧 Podcasts</button>
<button id="tab-fichiers" onclick="showTab('fichiers')">📁 Fichiers</button>
<button id="tab-box" onclick="showTab('box')">📦 Box</button>
<button id="tab-routines" onclick="showTab('routines')">🗓️ Routines</button>
<button id="tab-transfers" onclick="showTab('transfers')">📤 Transferts</button>
</div>
<!-- ===== PODCASTS ===== -->
<section id="panel-podcasts" class="panel active">
<div id="feeds"><div class="loading"><span class="spinner"></span> chargement…</div></div>
<div class="card">
<h3>+ Ajouter un flux</h3>
<label for="f-name">Nom de l'émission</label>
<input type="text" id="f-name" placeholder="Une histoire et Oli">
<label for="f-rss">URL du flux RSS</label>
<input type="url" id="f-rss" placeholder="https://…/rss.xml">
<label for="f-n">Nombre d'épisodes récents</label>
<input type="number" id="f-n" value="2" min="1" max="20">
<div class="row" style="margin-top:12px">
<button class="btn" onclick="addFeed()">Ajouter</button>
</div>
</div>
</section>
<!-- ===== FICHIERS ===== -->
<section id="panel-fichiers" class="panel">
<div class="card">
<h3>Fichiers sur le staging</h3>
<div id="files"><div class="loading"><span class="spinner"></span> chargement…</div></div>
</div>
<div class="card">
<h3>⬆️ Ajouter un fichier audio</h3>
<label for="u-file">Fichier audio (MP3)</label>
<input type="file" id="u-file" accept="audio/*">
<label for="u-show">Émission</label>
<input type="text" id="u-show" value="Perso">
<label for="u-title">Titre</label>
<input type="text" id="u-title" placeholder="Mon histoire">
<div class="row" style="margin-top:12px">
<button id="u-btn" class="btn orange" onclick="uploadFile()">Envoyer</button>
</div>
</div>
</section>
<!-- ===== BOX ===== -->
<section id="panel-box" class="panel">
<div class="card">
<h3>État de la box</h3>
<div id="boxinfo"><div class="loading"><span class="spinner"></span> chargement…</div></div>
<div class="row" style="margin-top:14px">
<button id="push-btn" class="btn orange" onclick="pushNow()" disabled>📤 Pousser maintenant</button>
</div>
</div>
</section>
<!-- ===== ROUTINES ===== -->
<section id="panel-routines" class="panel">
<div id="routines"><div class="loading"><span class="spinner"></span> chargement…</div></div>
<div class="row" style="margin-top:14px">
<button id="routines-save" class="btn orange" onclick="saveRoutines()">💾 Enregistrer &amp; pousser</button>
</div>
</section>
<!-- ===== TRANSFERTS ===== -->
<section id="panel-transfers" class="panel">
<div class="card">
<h3>En cours</h3>
<div id="tr-active"><div class="empty">Aucun transfert en cours.</div></div>
</div>
<div class="card">
<div class="row between">
<h3 style="margin:0">Effectués</h3>
<button id="tr-retry" class="btn ghost" onclick="retryTransfers()" disabled>🔁 Relancer les échecs</button>
</div>
<div id="tr-history" style="margin-top:10px"><div class="empty">Aucun transfert.</div></div>
</div>
</section>
</div>
<div id="toast"></div>
<script>
"use strict";
// ---------- helpers ----------
function $(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function showBanner(msg) {
const b = $("banner");
b.textContent = "⚠️ " + msg;
b.classList.add("show");
}
function clearBanner() { $("banner").classList.remove("show"); }
let toastTimer = null;
function toast(msg, isErr) {
const t = $("toast");
t.textContent = msg;
t.classList.toggle("err", !!isErr);
t.classList.add("show");
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () { t.classList.remove("show"); }, 3200);
}
// All API calls go through here: same-origin credentials (browser handles Basic Auth),
// JSON parsing, and uniform error surfacing in the banner.
async function api(path, opts) {
opts = opts || {};
opts.credentials = "same-origin";
const res = await fetch(path, opts);
let data = null;
const ct = res.headers.get("content-type") || "";
if (ct.indexOf("application/json") !== -1) {
try { data = await res.json(); } catch (e) { data = null; }
}
if (!res.ok) {
const detail = (data && data.error) ? data.error : ("HTTP " + res.status);
const err = new Error(detail);
err.status = res.status;
throw err;
}
return data;
}
function humanSize(n) {
n = Number(n) || 0;
if (n >= 1048576) return (n / 1048576).toFixed(1) + " Mo";
if (n >= 1024) return (n / 1024).toFixed(0) + " Ko";
return n + " o";
}
// ---------- tabs ----------
const TABS = ["podcasts", "fichiers", "box", "routines", "transfers"];
function showTab(name) {
TABS.forEach(function (t) {
const panel = $("panel-" + t);
const tab = $("tab-" + t);
if (panel) panel.classList.toggle("active", t === name);
if (tab) tab.classList.toggle("active", t === name);
});
if (name === "fichiers") loadFiles();
if (name === "box") loadBox();
if (name === "routines") loadRoutines();
if (name === "transfers") loadTransfers(); else stopTransfersTimer();
}
// ---------- Podcasts ----------
async function loadFeeds() {
const box = $("feeds");
box.innerHTML = '<div class="loading"><span class="spinner"></span> chargement…</div>';
try {
clearBanner();
const feeds = await api("/api/feeds");
if (!feeds || !feeds.length) {
box.innerHTML = '<div class="card empty">Aucun flux pour le moment.</div>';
return;
}
box.innerHTML = "";
feeds.forEach(function (f) {
const card = document.createElement("div");
card.className = "card";
card.innerHTML =
'<div class="row between">' +
'<span class="feedname">' + esc(f.name) + '</span>' +
'<button class="icon" title="Supprimer ce flux">✕</button>' +
'</div>' +
'<div class="row" style="margin-top:10px">' +
'<button class="btn ghost">👀 Voir les épisodes</button>' +
'</div>' +
'<div class="eps-host"></div>';
const delBtn = card.querySelector(".icon");
const seeBtn = card.querySelector(".btn.ghost");
const host = card.querySelector(".eps-host");
delBtn.onclick = function () { removeFeed(f.key, f.name); };
seeBtn.onclick = function () { loadEpisodes(f.key, host, seeBtn); };
box.appendChild(card);
});
} catch (e) {
box.innerHTML = '<div class="card empty">Impossible de charger les flux.</div>';
showBanner("Flux: " + e.message);
}
}
async function addFeed() {
const name = $("f-name").value.trim();
const rss = $("f-rss").value.trim();
const n = parseInt($("f-n").value, 10) || 2;
if (!name || !rss) { toast("Nom et URL requis", true); return; }
// derive a stable key from the name (server keys feeds by this)
let key = name.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24);
if (!key) key = "flux-" + Date.now();
try {
await api("/api/feeds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: key, name: name, rss: rss, n: n }),
});
$("f-name").value = ""; $("f-rss").value = ""; $("f-n").value = "2";
toast("Flux ajouté ✓");
loadFeeds();
} catch (e) {
toast("Erreur: " + e.message, true);
showBanner("Ajout flux: " + e.message);
}
}
async function removeFeed(key, name) {
if (!confirm("Supprimer le flux « " + name + " » ?")) return;
try {
await api("/api/feeds/" + encodeURIComponent(key), { method: "DELETE" });
toast("Flux supprimé");
loadFeeds();
} catch (e) {
toast("Erreur: " + e.message, true);
showBanner("Suppression flux: " + e.message);
}
}
async function loadEpisodes(key, host, btn) {
host.innerHTML = '<div class="loading"><span class="spinner"></span> chargement des épisodes (1-2 s)…</div>';
btn.disabled = true;
try {
clearBanner();
const data = await api("/api/episodes?feed=" + encodeURIComponent(key));
const eps = (data && data.episodes) || [];
if (!eps.length) {
host.innerHTML = '<div class="empty">Aucun épisode trouvé.</div>';
return;
}
const ul = document.createElement("ul");
ul.className = "eps";
eps.forEach(function (ep, i) {
const li = document.createElement("li");
const cbId = "ep_" + key + "_" + i;
const dur = ep.duration ? '<span class="dur"> · ' + esc(ep.duration) + '</span>' : "";
const badge = ep.staged ? '<span class="badge">✓ déjà sur la box</span>' : "";
li.innerHTML =
'<input type="checkbox" id="' + cbId + '"' + (ep.staged ? " disabled" : "") + '>' +
'<label for="' + cbId + '">' + esc(ep.title) + dur + badge + '</label>';
const cb = li.querySelector("input");
cb.dataset.url = ep.url;
cb.dataset.title = ep.title;
cb.dataset.staged = ep.staged ? "1" : "";
ul.appendChild(li);
});
host.innerHTML = "";
host.appendChild(ul);
const action = document.createElement("div");
action.className = "row";
action.style.marginTop = "12px";
const send = document.createElement("button");
send.className = "btn orange";
send.textContent = "✨ Mettre sur la box";
send.onclick = function () { selectEpisodes(key, ul, send); };
action.appendChild(send);
host.appendChild(action);
} catch (e) {
host.innerHTML = '<div class="empty">Erreur de chargement.</div>';
showBanner("Épisodes: " + e.message);
} finally {
btn.disabled = false;
}
}
async function selectEpisodes(key, ul, btn) {
const chosen = [];
ul.querySelectorAll("input[type=checkbox]").forEach(function (cb) {
if (cb.checked && !cb.dataset.staged) {
chosen.push({ url: cb.dataset.url, title: cb.dataset.title });
}
});
if (!chosen.length) { toast("Coche au moins un épisode", true); return; }
btn.disabled = true;
try {
const r = await api("/api/select", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feed: key, episodes: chosen }),
});
const n = (r && r.queued != null) ? r.queued : chosen.length;
toast(n + " épisode(s) en cours de préparation…");
// transcode runs server-side; refresh the Fichiers list shortly
setTimeout(loadFiles, 3000);
} catch (e) {
toast("Erreur: " + e.message, true);
showBanner("Sélection: " + e.message);
} finally {
btn.disabled = false;
}
}
// ---------- Fichiers ----------
async function loadFiles() {
const box = $("files");
box.innerHTML = '<div class="loading"><span class="spinner"></span> chargement…</div>';
try {
clearBanner();
const files = await api("/api/files");
if (!files || !files.length) {
box.innerHTML = '<div class="empty">Aucun fichier sur le staging.</div>';
return;
}
let html = '<table><thead><tr><th>Nom</th><th>Taille</th><th>Type</th><th></th></tr></thead><tbody>';
files.forEach(function (f) {
html +=
'<tr>' +
'<td class="fname">' + esc(f.name) + '</td>' +
'<td class="num">' + humanSize(f.size) + '</td>' +
'<td>' + esc(f.type) + '</td>' +
'<td class="num"><button class="icon" data-name="' + esc(f.name) + '" title="Supprimer">🗑</button></td>' +
'</tr>';
});
html += '</tbody></table>';
box.innerHTML = html;
box.querySelectorAll("button.icon").forEach(function (b) {
b.onclick = function () { removeFile(b.dataset.name); };
});
} catch (e) {
box.innerHTML = '<div class="empty">Impossible de charger les fichiers.</div>';
showBanner("Fichiers: " + e.message);
}
}
async function removeFile(name) {
if (!confirm("Supprimer « " + name + " » ?")) return;
try {
await api("/api/files/" + encodeURIComponent(name), { method: "DELETE" });
toast("Fichier supprimé");
loadFiles();
} catch (e) {
toast("Erreur: " + e.message, true);
showBanner("Suppression fichier: " + e.message);
}
}
async function uploadFile() {
const input = $("u-file");
const file = input.files && input.files[0];
if (!file) { toast("Choisis un fichier audio", true); return; }
const show = $("u-show").value.trim() || "Perso";
const title = $("u-title").value.trim() || file.name.replace(/\.[^.]+$/, "");
const btn = $("u-btn");
btn.disabled = true;
const oldLabel = btn.textContent;
btn.textContent = "Envoi…";
try {
const buf = await file.arrayBuffer();
const qs = "?show=" + encodeURIComponent(show) + "&title=" + encodeURIComponent(title);
const r = await api("/api/upload" + qs, {
method: "POST",
headers: { "Content-Type": "audio/mpeg" },
body: buf,
});
toast("Envoyé ✓ " + ((r && r.file) ? r.file : ""));
input.value = "";
$("u-title").value = "";
loadFiles();
} catch (e) {
toast("Erreur: " + e.message, true);
showBanner("Upload: " + e.message);
} finally {
btn.disabled = false;
btn.textContent = oldLabel;
}
}
// ---------- Box ----------
async function loadBox() {
const box = $("boxinfo");
const pushBtn = $("push-btn");
box.innerHTML = '<div class="loading"><span class="spinner"></span> chargement…</div>';
pushBtn.disabled = true;
try {
clearBanner();
const data = await api("/api/box");
const b = (data && data.box) || {};
const delta = (data && data.delta) || [];
const have = (b.have || []);
const ip = b.ip || null;
box.innerHTML =
'<div class="stat"><span class="k">Adresse IP</span><span class="v">' + esc(ip || "jamais annoncée") + '</span></div>' +
'<div class="stat"><span class="k">Dernière annonce</span><span class="v">' + esc(b.last_seen || "—") + '</span></div>' +
'<div class="stat"><span class="k">Fichiers sur la box</span><span class="v">' + have.length + '</span></div>' +
'<div class="stat"><span class="k">En attente d\'envoi</span><span class="v">' + delta.length + '</span></div>';
pushBtn.disabled = !ip;
} catch (e) {
box.innerHTML = '<div class="empty">Impossible de lire l\'état de la box.</div>';
showBanner("Box: " + e.message);
}
}
async function pushNow() {
const btn = $("push-btn");
btn.disabled = true;
try {
const r = await api("/api/push", { method: "POST" });
if (r && r.started) {
toast("Envoi démarré (" + (r.delta || 0) + " fichier(s))");
} else {
toast("Rien à envoyer ou envoi déjà en cours");
}
setTimeout(loadBox, 1500);
} catch (e) {
if (e.status === 409) {
toast("La box ne s'est jamais annoncée", true);
} else {
toast("Erreur: " + e.message, true);
showBanner("Push: " + e.message);
}
btn.disabled = false;
}
}
// ---------- Routines ----------
const EMOJI_PALETTE = ["☀️","🌙","👕","🥣","🦷","🎒","🧸","🛁","👖","🧦","🪥","📖","🚽","🍽️","💤","🧼","👟","🧥"];
let ROUTINES = []; // editable in-memory copy of /api/routines
function routineCard(r, ri) {
const card = document.createElement("div");
card.className = "card";
card.innerHTML = '<h3>' + esc(r.emoji || "") + ' ' + esc(r.title || r.id) + '</h3>'
+ '<div class="steps-host"></div>'
+ '<div class="row" style="margin-top:10px"><button class="btn ghost add-step">+ Ajouter une étape</button></div>';
const host = card.querySelector(".steps-host");
(r.steps || []).forEach(function (st, si) { host.appendChild(stepRow(ri, si)); });
card.querySelector(".add-step").onclick = function () {
ROUTINES[ri].steps.push({ text: "", emoji: "🧸" });
loadRoutines(true);
};
return card;
}
function stepRow(ri, si) {
const st = ROUTINES[ri].steps[si];
const li = document.createElement("div");
li.className = "row";
li.style.borderTop = "1px solid var(--line)";
li.style.padding = "8px 0";
// emoji select
let opts = "";
const inPalette = EMOJI_PALETTE.indexOf(st.emoji) !== -1;
EMOJI_PALETTE.forEach(function (e) {
opts += '<option value="' + esc(e) + '"' + (e === st.emoji ? " selected" : "") + '>' + esc(e) + '</option>';
});
if (!inPalette && st.emoji) opts += '<option value="' + esc(st.emoji) + '" selected>' + esc(st.emoji) + '</option>';
li.innerHTML =
'<select class="st-emoji" style="flex:none;font-size:1.4rem;padding:8px;border-radius:12px;border:2px solid var(--line);background:#fbf8ff">' + opts + '</select>'
+ '<input type="text" class="st-text grow" value="' + esc(st.text) + '" placeholder="Texte de l\'étape">'
+ '<button class="icon st-up" title="Monter">⬆️</button>'
+ '<button class="icon st-down" title="Descendre">⬇️</button>'
+ '<button class="icon st-del" title="Supprimer">🗑</button>';
li.querySelector(".st-text").oninput = function (e) { ROUTINES[ri].steps[si].text = e.target.value; };
li.querySelector(".st-emoji").onchange = function (e) { ROUTINES[ri].steps[si].emoji = e.target.value; };
li.querySelector(".st-up").onclick = function () { moveStep(ri, si, -1); };
li.querySelector(".st-down").onclick = function () { moveStep(ri, si, 1); };
li.querySelector(".st-del").onclick = function () { ROUTINES[ri].steps.splice(si, 1); loadRoutines(true); };
return li;
}
function moveStep(ri, si, d) {
const s = ROUTINES[ri].steps;
const j = si + d;
if (j < 0 || j >= s.length) return;
const t = s[si]; s[si] = s[j]; s[j] = t;
loadRoutines(true);
}
async function loadRoutines(fromMemory) {
const box = $("routines");
if (!fromMemory) {
box.innerHTML = '<div class="loading"><span class="spinner"></span> chargement…</div>';
try {
clearBanner();
const data = await api("/api/routines");
ROUTINES = (data && data.routines) || [];
} catch (e) {
box.innerHTML = '<div class="empty">Impossible de charger les routines.</div>';
showBanner("Routines: " + e.message);
return;
}
}
box.innerHTML = "";
ROUTINES.forEach(function (r, ri) { box.appendChild(routineCard(r, ri)); });
}
async function saveRoutines() {
const btn = $("routines-save");
btn.disabled = true;
const old = btn.textContent;
btn.textContent = "Enregistrement…";
try {
await api("/api/routines", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ routines: ROUTINES }),
});
toast("Routines enregistrées — génération en cours…");
// give the server a moment to regenerate assets, then push
setTimeout(async function () {
try {
const r = await api("/api/routines/push", { method: "POST" });
if (r && r.started) toast("Envoi des routines démarré (" + (r.delta || 0) + " fichier(s))");
else toast("Routines prêtes (rien à pousser ou box absente)");
} catch (e) {
if (e.status === 409) toast("La box ne s'est jamais annoncée", true);
else { toast("Erreur push: " + e.message, true); showBanner("Push routines: " + e.message); }
}
}, 6000);
} catch (e) {
toast("Erreur: " + e.message, true);
showBanner("Enregistrer routines: " + e.message);
} finally {
btn.disabled = false;
btn.textContent = old;
}
}
// ---------- Transferts ----------
let trTimer = null;
function stopTransfersTimer() { if (trTimer) { clearInterval(trTimer); trTimer = null; } }
function fmtRate(bps) {
bps = Number(bps) || 0;
if (bps >= 1048576) return (bps / 1048576).toFixed(1) + " Mo/s";
if (bps >= 1024) return (bps / 1024).toFixed(0) + " Ko/s";
return bps + " o/s";
}
function fmtTime(iso) { return iso ? esc(String(iso).replace("T", " ").slice(0, 19)) : "—"; }
function renderActive(active) {
const host = $("tr-active");
if (!active) { host.innerHTML = '<div class="empty">Aucun transfert en cours.</div>'; return; }
const cur = (active.files || []).find(function (f) { return f.status === "sending"; });
const pct = active.total ? Math.round(100 * active.current / active.total) : 0;
host.innerHTML =
'<div class="stat"><span class="k">Vers</span><span class="v">' + esc(active.box_ip || "?") + '</span></div>' +
'<div class="stat"><span class="k">Avancement</span><span class="v">' + active.current + '/' + active.total + ' (' + pct + '%)</span></div>' +
'<div class="stat"><span class="k">Fichier en cours</span><span class="v fname">' + (cur ? esc(cur.name) : "—") + '</span></div>';
}
function renderHistory(history) {
const host = $("tr-history");
const rows = (history || []).slice().reverse(); // most recent first
if (!rows.length) { host.innerHTML = '<div class="empty">Aucun transfert.</div>'; $("tr-retry").disabled = true; return; }
let hasFailed = false;
let html = '<table><thead><tr><th>Fichier</th><th>Dossier</th><th>Taille</th><th>Durée</th><th>Débit</th><th>Statut</th><th>Heure</th></tr></thead><tbody>';
rows.forEach(function (h) {
const failed = h.status === "failed";
if (failed) hasFailed = true;
const stCell = failed
? '<span style="color:var(--err)">✗ ' + esc(h.error || "échec") + '</span>'
: '<span style="color:var(--ok)">✓</span>';
html +=
'<tr' + (failed ? ' style="background:#fff0f2"' : '') + '>' +
'<td class="fname">' + esc(h.name) + '</td>' +
'<td>' + esc(h.dir || "") + '</td>' +
'<td class="num">' + humanSize(h.size) + '</td>' +
'<td class="num">' + (h.duration_s != null ? h.duration_s + ' s' : '—') + '</td>' +
'<td class="num">' + (h.rate_bps != null ? fmtRate(h.rate_bps) : '—') + '</td>' +
'<td>' + stCell + '</td>' +
'<td class="num">' + fmtTime(h.ended || h.started) + '</td>' +
'</tr>';
});
html += '</tbody></table>';
host.innerHTML = html;
$("tr-retry").disabled = !hasFailed;
}
async function loadTransfers() {
try {
clearBanner();
const data = await api("/api/transfers");
const active = (data && data.active) || null;
renderActive(active);
renderHistory((data && data.history) || []);
if (active) {
if (!trTimer) trTimer = setInterval(loadTransfers, 2000);
} else {
stopTransfersTimer(); // one last refresh already froze the history
}
} catch (e) {
showBanner("Transferts: " + e.message);
stopTransfersTimer();
}
}
async function retryTransfers() {
const btn = $("tr-retry");
btn.disabled = true;
try {
const r = await api("/api/transfers/retry", { method: "POST" });
toast("Relance de " + (r.count || 0) + " fichier(s)…");
loadTransfers(); // restart the auto-refresh as a batch becomes active
} catch (e) {
if (e.status === 409) toast("Rien à relancer ou box absente", true);
else { toast("Erreur: " + e.message, true); showBanner("Retry: " + e.message); }
btn.disabled = false;
}
}
// ---------- init ----------
window.addEventListener("DOMContentLoaded", function () {
loadFeeds();
});
</script>
</body>
</html>