add: throughput sparkline and ETA-first status hero
Restructure the current-job card around the question the monitor exists to answer: time remaining is now the hero, with percent riding the bar. Add a client-side throughput sparkline (90 s rolling buffer of encode speed against a fixed 1.0x realtime reference) and a health-colored speed gauge so stalls and sub-realtime encoding are visible at a glance. Collapse the repetitive input queue into a count plus shared-prefix rows.
This commit is contained in:
+198
-108
@@ -20,6 +20,7 @@
|
|||||||
padding:clamp(1.25rem,4vw,2.5rem); max-width:920px; margin:0 auto;
|
padding:clamp(1.25rem,4vw,2.5rem); max-width:920px; margin:0 auto;
|
||||||
}
|
}
|
||||||
a { color:var(--cyan); }
|
a { color:var(--cyan); }
|
||||||
|
code { font-family:var(--mono); }
|
||||||
|
|
||||||
/* header */
|
/* header */
|
||||||
header { display:flex; align-items:center; gap:1rem; margin-bottom:1.75rem; }
|
header { display:flex; align-items:center; gap:1rem; margin-bottom:1.75rem; }
|
||||||
@@ -36,40 +37,72 @@
|
|||||||
.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:1.4rem 1.5rem; margin-bottom:1.25rem; }
|
.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:1.4rem 1.5rem; margin-bottom:1.25rem; }
|
||||||
.eyebrow { font-family:var(--mono); font-size:.7rem; letter-spacing:.16em; text-transform:uppercase; color:var(--dim); margin:0 0 .9rem; }
|
.eyebrow { font-family:var(--mono); font-size:.7rem; letter-spacing:.16em; text-transform:uppercase; color:var(--dim); margin:0 0 .9rem; }
|
||||||
|
|
||||||
/* current job */
|
/* ---- hero ---- */
|
||||||
.job-head { display:flex; align-items:baseline; gap:.75rem; flex-wrap:wrap; margin-bottom:.2rem; }
|
.job-head { display:flex; align-items:baseline; gap:.6rem; flex-wrap:wrap; margin-bottom:1rem; }
|
||||||
.job-file { font-size:1.15rem; font-weight:600; color:#e7edf4; word-break:break-word; }
|
.job-title { font-size:1.2rem; font-weight:600; color:#e7edf4; word-break:break-word; }
|
||||||
.badge { font-family:var(--mono); font-size:.66rem; letter-spacing:.08em; text-transform:uppercase; padding:.2rem .5rem; border-radius:5px; border:1px solid var(--line); color:var(--muted); }
|
.job-title .se { color:var(--amber); font-family:var(--mono); font-size:1rem; margin-right:.45rem; }
|
||||||
|
.job-title .ep { color:var(--muted); font-weight:400; }
|
||||||
|
.badge { font-family:var(--mono); font-size:.66rem; letter-spacing:.08em; text-transform:uppercase; padding:.2rem .5rem; border-radius:5px; border:1px solid var(--line); color:var(--muted); margin-left:auto; white-space:nowrap; }
|
||||||
.badge[data-phase="encoding"] { color:var(--amber); border-color:var(--amber); }
|
.badge[data-phase="encoding"] { color:var(--amber); border-color:var(--amber); }
|
||||||
.badge[data-phase="probing"], .badge[data-phase="audio"] { color:var(--cyan); border-color:var(--cyan); }
|
.badge[data-phase="probing"], .badge[data-phase="audio"] { color:var(--cyan); border-color:var(--cyan); }
|
||||||
.badge[data-phase="idle"] { color:var(--dim); }
|
|
||||||
|
|
||||||
.pct { font-family:var(--mono); font-size:2.6rem; font-weight:600; line-height:1; margin:.6rem 0 .5rem; color:var(--amber); }
|
/* ETA is the hero answer */
|
||||||
.pct.idle { color:var(--dim); font-size:1.4rem; }
|
.eta-line { display:flex; align-items:baseline; gap:.7rem; flex-wrap:wrap; margin-bottom:.9rem; }
|
||||||
|
.eta-big { font-family:var(--mono); font-size:2.6rem; font-weight:600; line-height:1; color:var(--amber-soft); }
|
||||||
|
.eta-big.dim { color:var(--dim); font-size:1.6rem; }
|
||||||
|
.eta-at { font-family:var(--mono); color:var(--muted); font-size:.95rem; }
|
||||||
|
.eta-at b { color:var(--text); font-weight:500; }
|
||||||
|
|
||||||
.bar { height:10px; border-radius:6px; background:var(--panel-2); overflow:hidden; border:1px solid var(--line); }
|
.bar { position:relative; height:12px; border-radius:6px; background:var(--panel-2); overflow:hidden; border:1px solid var(--line); }
|
||||||
.bar > i { display:block; height:100%; width:0; background:linear-gradient(90deg,var(--amber-soft),var(--amber)); border-radius:6px; transition:width .6s ease; }
|
.bar > i { display:block; height:100%; width:0; background:linear-gradient(90deg,var(--amber-soft),var(--amber)); border-radius:6px; transition:width .6s ease; }
|
||||||
.bar.indet > i { width:35%; background:linear-gradient(90deg,transparent,var(--cyan),transparent); animation:slide 1.3s linear infinite; transition:none; }
|
.bar.indet > i { width:35%; background:linear-gradient(90deg,transparent,var(--cyan),transparent); animation:slide 1.3s linear infinite; transition:none; }
|
||||||
@keyframes slide { 0%{transform:translateX(-120%);} 100%{transform:translateX(340%);} }
|
@keyframes slide { 0%{transform:translateX(-120%);} 100%{transform:translateX(340%);} }
|
||||||
@media (prefers-reduced-motion:reduce){ .dot.ok{animation:none;} .bar.indet>i{animation:none; width:100%; opacity:.4;} }
|
.pct { font-family:var(--mono); font-size:.8rem; color:var(--muted); margin-top:.45rem; }
|
||||||
|
.pct b { color:var(--text); }
|
||||||
.stats { display:flex; flex-wrap:wrap; gap:1.5rem; margin-top:1.1rem; }
|
|
||||||
.stat { font-family:var(--mono); }
|
|
||||||
.stat .v { font-size:1.15rem; color:var(--text); }
|
|
||||||
.stat .k { font-size:.66rem; letter-spacing:.1em; text-transform:uppercase; color:var(--dim); margin-top:.1rem; }
|
|
||||||
|
|
||||||
.idle-msg { color:var(--muted); }
|
.idle-msg { color:var(--muted); }
|
||||||
|
|
||||||
/* grid: queue + events */
|
/* ---- signature: throughput sparkline ---- */
|
||||||
|
.pulse { margin-top:1.3rem; padding-top:1.2rem; border-top:1px solid var(--line); display:grid; grid-template-columns:1fr auto; gap:1rem 1.5rem; align-items:center; }
|
||||||
|
.spark-wrap { grid-column:1; min-width:0; }
|
||||||
|
.spark-head { display:flex; justify-content:space-between; align-items:baseline; font-family:var(--mono); font-size:.66rem; letter-spacing:.1em; text-transform:uppercase; color:var(--dim); margin-bottom:.35rem; }
|
||||||
|
svg.spark { width:100%; height:54px; display:block; overflow:visible; }
|
||||||
|
.ref-label { font-family:var(--mono); font-size:.6rem; fill:var(--dim); }
|
||||||
|
|
||||||
|
.gauges { grid-column:2; display:flex; gap:1.6rem; }
|
||||||
|
.gauge { font-family:var(--mono); text-align:right; }
|
||||||
|
.gauge .v { font-size:1.5rem; line-height:1; }
|
||||||
|
.gauge .v.warn { color:var(--amber); }
|
||||||
|
.gauge .v.bad { color:var(--red); }
|
||||||
|
.gauge .v.good { color:var(--green); }
|
||||||
|
.gauge .k { font-size:.62rem; letter-spacing:.12em; text-transform:uppercase; color:var(--dim); margin-top:.35rem; }
|
||||||
|
.gauge .sub { font-size:.6rem; color:var(--dim); margin-top:.15rem; }
|
||||||
|
|
||||||
|
.substats { display:flex; flex-wrap:wrap; gap:1.4rem; margin-top:1.2rem; font-family:var(--mono); }
|
||||||
|
.substats .s { font-size:.8rem; color:var(--muted); }
|
||||||
|
.substats .s b { color:var(--text); font-weight:500; }
|
||||||
|
|
||||||
|
/* stream chips */
|
||||||
|
.streams { display:flex; flex-wrap:wrap; gap:.5rem; margin-top:1rem; }
|
||||||
|
.chip { font-family:var(--mono); font-size:.72rem; padding:.2rem .5rem; border-radius:5px; background:var(--panel-2); border:1px solid var(--line); color:var(--text); }
|
||||||
|
.chip .lab { color:var(--dim); }
|
||||||
|
.chip .ch { color:var(--amber); }
|
||||||
|
|
||||||
|
/* grid */
|
||||||
.grid { display:grid; grid-template-columns:1fr 1fr; gap:1.25rem; }
|
.grid { display:grid; grid-template-columns:1fr 1fr; gap:1.25rem; }
|
||||||
@media (max-width:680px){ .grid { grid-template-columns:1fr; } }
|
@media (max-width:680px){ .grid { grid-template-columns:1fr; } .pulse{ grid-template-columns:1fr; } .gauges{ grid-column:1; justify-content:flex-start; } .gauge{ text-align:left; } }
|
||||||
|
|
||||||
.count { font-family:var(--mono); color:var(--amber); }
|
/* queue — collapsed aggregate */
|
||||||
ul.list { list-style:none; margin:0; padding:0; font-family:var(--mono); font-size:.85rem; }
|
.qsum { display:flex; align-items:baseline; gap:.5rem; flex-wrap:wrap; margin-bottom:.8rem; }
|
||||||
ul.list li { padding:.4rem 0; border-bottom:1px solid var(--line); color:var(--text); word-break:break-word; }
|
.qsum .qn { font-family:var(--mono); font-size:1.4rem; color:var(--amber); line-height:1; }
|
||||||
|
.qsum .ql { color:var(--muted); font-size:.85rem; }
|
||||||
|
ul.list { list-style:none; margin:0; padding:0; font-family:var(--mono); font-size:.82rem; }
|
||||||
|
ul.list li { padding:.35rem 0; border-bottom:1px solid var(--line); color:var(--muted); word-break:break-word; }
|
||||||
ul.list li:last-child { border-bottom:0; }
|
ul.list li:last-child { border-bottom:0; }
|
||||||
ul.list li.empty { color:var(--dim); border:0; }
|
ul.list li .pre { color:var(--dim); }
|
||||||
|
ul.list li .key { color:var(--text); }
|
||||||
|
.qmore { font-family:var(--mono); font-size:.74rem; color:var(--dim); padding-top:.5rem; }
|
||||||
|
|
||||||
|
/* events */
|
||||||
.ev { display:flex; gap:.6rem; padding:.4rem 0; border-bottom:1px solid var(--line); font-size:.82rem; }
|
.ev { display:flex; gap:.6rem; padding:.4rem 0; border-bottom:1px solid var(--line); font-size:.82rem; }
|
||||||
.ev:last-child { border-bottom:0; }
|
.ev:last-child { border-bottom:0; }
|
||||||
.ev time { font-family:var(--mono); font-size:.7rem; color:var(--dim); white-space:nowrap; padding-top:.1rem; }
|
.ev time { font-family:var(--mono); font-size:.7rem; color:var(--dim); white-space:nowrap; padding-top:.1rem; }
|
||||||
@@ -78,20 +111,7 @@
|
|||||||
.day { text-align:center; font-family:var(--mono); font-size:.64rem; letter-spacing:.12em; text-transform:uppercase; color:var(--dim); margin:.8rem 0 .2rem; }
|
.day { text-align:center; font-family:var(--mono); font-size:.64rem; letter-spacing:.12em; text-transform:uppercase; color:var(--dim); margin:.8rem 0 .2rem; }
|
||||||
.day:first-child { margin-top:0; }
|
.day:first-child { margin-top:0; }
|
||||||
|
|
||||||
/* job metadata */
|
@media (prefers-reduced-motion:reduce){ .dot.ok{animation:none;} .bar.indet>i{animation:none; width:100%; opacity:.4;} }
|
||||||
.meta { margin-top:1.1rem; padding-top:1.1rem; border-top:1px solid var(--line); }
|
|
||||||
.meta .mtitle { font-size:1.1rem; color:#e7edf4; font-weight:600; word-break:break-word; }
|
|
||||||
.meta .msub { color:var(--amber-soft); font-size:.92rem; margin-top:.15rem; }
|
|
||||||
.meta .mrow { color:var(--muted); font-size:.85rem; margin-top:.3rem; }
|
|
||||||
.meta .mrow b { color:var(--text); font-weight:500; }
|
|
||||||
|
|
||||||
/* stream chips */
|
|
||||||
.streams { display:flex; flex-direction:column; gap:.5rem; margin-top:1rem; }
|
|
||||||
.srow { display:flex; align-items:baseline; gap:.5rem; flex-wrap:wrap; }
|
|
||||||
.srow .lab { font-family:var(--mono); font-size:.64rem; letter-spacing:.1em; text-transform:uppercase; color:var(--dim); width:4rem; flex:none; padding-top:.25rem; }
|
|
||||||
.chip { font-family:var(--mono); font-size:.74rem; padding:.2rem .5rem; border-radius:5px; background:var(--panel-2); border:1px solid var(--line); color:var(--text); }
|
|
||||||
.chip .ch { color:var(--amber); }
|
|
||||||
.chip .cd { color:var(--dim); }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -102,14 +122,13 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card" id="job">
|
<section class="card" id="job">
|
||||||
<p class="eyebrow">Current job</p>
|
|
||||||
<div id="job-body"></div>
|
<div id="job-body"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<p class="eyebrow">Queue <span class="count" id="qcount"></span></p>
|
<p class="eyebrow">Queue</p>
|
||||||
<ul class="list" id="queue"></ul>
|
<div id="queue"></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<p class="eyebrow">Recent events</p>
|
<p class="eyebrow">Recent events</p>
|
||||||
@@ -127,105 +146,175 @@
|
|||||||
({ "&":"&", "<":"<", ">":">", '"':""", "'":"'" }[c]));
|
({ "&":"&", "<":"<", ">":">", '"':""", "'":"'" }[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDur(sec) {
|
|
||||||
sec = Math.max(0, Math.floor(sec || 0));
|
|
||||||
if (sec <= 0) return "--";
|
|
||||||
const h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
|
|
||||||
if (h) return `${h}h${String(m).padStart(2,"0")}m`;
|
|
||||||
if (m) return `${m}m${String(s).padStart(2,"0")}s`;
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// "17:49 (3h52m)" — wall-clock finish time plus remaining duration.
|
|
||||||
function etaText(sec) {
|
|
||||||
if (!sec || sec <= 0) return "--";
|
|
||||||
const done = new Date(Date.now() + sec * 1000);
|
|
||||||
const p = n => String(n).padStart(2, "0");
|
|
||||||
return `${p(done.getHours())}:${p(done.getMinutes())} (${fmtDur(sec)})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function chLabel(n) { return ({1:"mono",2:"2.0",6:"5.1",8:"7.1"})[n] || (n ? n+"ch" : ""); }
|
|
||||||
function base(path) { return (path || "").split("/").pop(); }
|
function base(path) { return (path || "").split("/").pop(); }
|
||||||
function pad2(s) { return String(s || "").padStart(2, "0"); }
|
function pad2(s) { return String(s || "").padStart(2, "0"); }
|
||||||
|
|
||||||
function jobHeading(c) {
|
// "3h 53m" / "53m 20s" / "20s" — remaining time, spelled for the hero.
|
||||||
|
function fmtDurLong(sec) {
|
||||||
|
sec = Math.max(0, Math.floor(sec || 0));
|
||||||
|
if (sec <= 0) return "—";
|
||||||
|
const h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
|
||||||
|
if (h) return `${h}h ${pad2(m)}m`;
|
||||||
|
if (m) return `${m}m ${pad2(s)}s`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
// wall-clock finish time, "21:35"
|
||||||
|
function clockAt(sec) {
|
||||||
|
if (!sec || sec <= 0) return "—";
|
||||||
|
const d = new Date(Date.now() + sec * 1000), p = n => String(n).padStart(2, "0");
|
||||||
|
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
function chLabel(n) { return ({1:"mono",2:"2.0",6:"5.1",8:"7.1"})[n] || (n ? n+"ch" : ""); }
|
||||||
|
|
||||||
|
// ---- signature: throughput sparkline, fed by the 1 s poll ----
|
||||||
|
let speedHist = [], histFile = null;
|
||||||
|
function pushSpeed(c) {
|
||||||
|
if (!c || c.file !== histFile) { speedHist = []; histFile = c ? c.file : null; }
|
||||||
|
if (c && c.phase === "encoding" && c.speed > 0) {
|
||||||
|
speedHist.push(c.speed);
|
||||||
|
if (speedHist.length > 90) speedHist.shift(); // ~90 s window
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trace of recent speed against a fixed 1.0× realtime reference. The reference
|
||||||
|
// stays fixed on purpose — autoscaling would hide how far below realtime we run.
|
||||||
|
function sparkSVG(hist, ref) {
|
||||||
|
const W = 300, H = 54;
|
||||||
|
if (hist.length < 2)
|
||||||
|
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true"></svg>`;
|
||||||
|
const max = Math.max(ref * 1.3, ...hist);
|
||||||
|
const y = v => H - (v / max) * H;
|
||||||
|
const pts = hist.map((v, i) => `${(i/(hist.length-1)*W).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||||
|
const refY = y(ref).toFixed(1);
|
||||||
|
const below = hist[hist.length-1] < ref;
|
||||||
|
const col = below ? "var(--red)" : "var(--green)";
|
||||||
|
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<line x1="0" y1="${refY}" x2="${W}" y2="${refY}" stroke="var(--dim)" stroke-width="1" stroke-dasharray="3 3"/>
|
||||||
|
<text class="ref-label" x="2" y="${Math.max(8, refY-3)}">${ref.toFixed(1)}×</text>
|
||||||
|
<polyline fill="none" stroke="${col}" stroke-width="2" stroke-linejoin="round" points="${pts}"/>
|
||||||
|
<circle cx="${W}" cy="${y(hist[hist.length-1]).toFixed(1)}" r="2.5" fill="${col}"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
function trendLabel(hist) {
|
||||||
|
if (hist.length < 4) return ["—", "var(--dim)"];
|
||||||
|
const last = hist[hist.length-1], prev = hist[hist.length-3];
|
||||||
|
if (last < prev - 0.01) return ["slowing", "var(--red)"];
|
||||||
|
if (last > prev + 0.01) return ["recovering", "var(--green)"];
|
||||||
|
return ["holding", "var(--dim)"];
|
||||||
|
}
|
||||||
|
function speedClass(v) { return v <= 0 ? "" : v < 0.5 ? "bad" : v < 1 ? "warn" : "good"; }
|
||||||
|
|
||||||
|
function jobTitleHTML(c) {
|
||||||
const m = c.meta;
|
const m = c.meta;
|
||||||
if (m && m.is_series && m.collection) {
|
if (m && m.is_series && m.collection) {
|
||||||
const se = (m.season && m.episode) ? `S${pad2(m.season)}E${pad2(m.episode)}` : "";
|
const se = (m.season && m.episode) ? `S${pad2(m.season)}E${pad2(m.episode)}` : "";
|
||||||
return { title: m.collection, sub: [se, m.title].filter(Boolean).join(" · ") };
|
const ep = (m.title && m.title !== "Unknown") ? ` — “${esc(m.title)}”` : "";
|
||||||
|
return `${se ? `<span class="se">${se}</span>` : ""}${esc(m.collection)}<span class="ep">${ep}</span>`;
|
||||||
}
|
}
|
||||||
if (m && m.title && m.title !== "Unknown") return { title: m.title, sub: "" };
|
if (m && m.title && m.title !== "Unknown") return esc(m.title);
|
||||||
return { title: base(c.file), sub: "" };
|
return esc(base(c.file));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStreams(streams) {
|
function streamsHTML(streams) {
|
||||||
if (!streams || !streams.length) return "";
|
if (!streams || !streams.length) return "";
|
||||||
const audio = streams.filter(s => s.kind === "audio");
|
const chips = streams.filter(s => s.kind === "audio" || s.kind === "subtitle").map(s => {
|
||||||
const subs = streams.filter(s => s.kind === "subtitle");
|
const lab = s.kind === "audio" ? "audio" : "subs";
|
||||||
let html = "";
|
const ch = s.kind === "audio" ? chLabel(s.channels) : "";
|
||||||
if (audio.length) {
|
const codec = s.kind === "audio" ? esc(s.codec || "") : "";
|
||||||
html += `<div class="srow"><span class="lab">Audio</span>` + audio.map(s => {
|
return `<span class="chip"><span class="lab">${lab}</span> ${esc(s.language || "und")}` +
|
||||||
const ch = chLabel(s.channels), codec = esc(s.codec || "");
|
`${ch ? ` <span class="ch">${ch}</span>` : ""}${codec ? ` ${codec}` : ""}</span>`;
|
||||||
return `<span class="chip">${esc(s.language || "und")}` +
|
}).join("");
|
||||||
`${ch ? ` <span class="ch">${ch}</span>` : ""}` +
|
return chips ? `<div class="streams">${chips}</div>` : "";
|
||||||
`${codec ? ` <span class="cd">${codec}</span>` : ""}</span>`;
|
|
||||||
}).join("") + `</div>`;
|
|
||||||
}
|
|
||||||
if (subs.length) {
|
|
||||||
html += `<div class="srow"><span class="lab">Subs</span>` +
|
|
||||||
subs.map(s => `<span class="chip">${esc(s.language || "und")}</span>`).join("") + `</div>`;
|
|
||||||
}
|
|
||||||
return html ? `<div class="streams">${html}</div>` : "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMeta(c) {
|
function substatsHTML(c) {
|
||||||
const m = c.meta, h = jobHeading(c);
|
const m = c.meta, parts = [];
|
||||||
let rows = "";
|
if (c.elapsed_sec) parts.push(`elapsed <b>${fmtDurLong(c.elapsed_sec)}</b>`);
|
||||||
if (m) {
|
if (m && m.media_type) parts.push(`source <b>${esc(m.media_type)}</b>`);
|
||||||
if (m.date_released) rows += `<div class="mrow">${m.is_series ? "Aired" : "Released"} <b>${esc(m.date_released)}</b></div>`;
|
if (m && m.date_released) parts.push(`${m.is_series ? "aired" : "released"} <b>${esc(m.date_released)}</b>`);
|
||||||
if (m.media_type) rows += `<div class="mrow">Source <b>${esc(m.media_type)}</b></div>`;
|
return parts.length ? `<div class="substats">${parts.map(p => `<div class="s">${p}</div>`).join("")}</div>` : "";
|
||||||
}
|
|
||||||
return `<div class="meta">
|
|
||||||
<div class="mtitle">${esc(h.title)}</div>
|
|
||||||
${h.sub ? `<div class="msub">${esc(h.sub)}</div>` : ""}
|
|
||||||
${rows}
|
|
||||||
${renderStreams(c.streams)}
|
|
||||||
</div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderJob(c) {
|
function renderJob(c) {
|
||||||
const body = $("job-body");
|
const body = $("job-body");
|
||||||
if (!c || c.phase === "idle" || !c.file) {
|
if (!c || c.phase === "idle" || !c.file) {
|
||||||
body.innerHTML = `<div class="pct idle">Idle</div><p class="idle-msg">Waiting for files in <code>input/</code>.</p>`;
|
body.innerHTML = `<div class="eta-line"><span class="eta-big dim">Idle</span></div>
|
||||||
|
<p class="idle-msg">Waiting for files in <code>input/</code>.</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const encoding = c.phase === "encoding" && c.percent > 0;
|
const encoding = c.phase === "encoding" && c.percent > 0;
|
||||||
const pct = encoding ? c.percent.toFixed(1) + "%" : "preparing";
|
|
||||||
const barClass = encoding ? "bar" : "bar indet";
|
// Preparing phases (probing/audio) have no progress yet — indeterminate bar.
|
||||||
const barW = encoding ? c.percent : 0;
|
if (!encoding) {
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="job-head">
|
||||||
|
<span class="job-title">${jobTitleHTML(c)}</span>
|
||||||
|
<span class="badge" data-phase="${esc(c.phase)}">${esc(c.phase)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="eta-line"><span class="eta-big dim">preparing…</span></div>
|
||||||
|
<div class="bar indet" role="progressbar" aria-valuetext="preparing"><i></i></div>
|
||||||
|
${substatsHTML(c)}${streamsHTML(c.streams)}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sp = c.speed || 0, [trend, trendCol] = trendLabel(speedHist);
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="job-head">
|
<div class="job-head">
|
||||||
<span class="job-file">${esc(base(c.file))}</span>
|
<span class="job-title">${jobTitleHTML(c)}</span>
|
||||||
<span class="badge" data-phase="${esc(c.phase)}">${esc(c.phase)}</span>
|
<span class="badge" data-phase="encoding">encoding</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pct${encoding ? "" : " idle"}">${pct}</div>
|
|
||||||
<div class="${barClass}" role="progressbar" aria-valuenow="${encoding ? Math.round(c.percent) : 0}" aria-valuemin="0" aria-valuemax="100"><i style="width:${barW}%"></i></div>
|
<div class="eta-line">
|
||||||
<div class="stats">
|
<span class="eta-big">${fmtDurLong(c.eta_sec)}</span>
|
||||||
<div class="stat"><div class="v">${(c.fps||0).toFixed(1)}</div><div class="k">fps</div></div>
|
<span class="eta-at">left · done ~<b>${clockAt(c.eta_sec)}</b></span>
|
||||||
<div class="stat"><div class="v">${(c.speed||0).toFixed(2)}×</div><div class="k">speed</div></div>
|
|
||||||
<div class="stat"><div class="v">${etaText(c.eta_sec)}</div><div class="k">eta</div></div>
|
|
||||||
<div class="stat"><div class="v">${fmtDur(c.elapsed_sec)}</div><div class="k">elapsed</div></div>
|
|
||||||
</div>
|
</div>
|
||||||
${renderMeta(c)}`;
|
<div class="bar" role="progressbar" aria-valuenow="${Math.round(c.percent)}" aria-valuemin="0" aria-valuemax="100"><i style="width:${c.percent}%"></i></div>
|
||||||
|
<div class="pct"><b>${c.percent.toFixed(1)}%</b> encoded</div>
|
||||||
|
|
||||||
|
<div class="pulse">
|
||||||
|
<div class="spark-wrap">
|
||||||
|
<div class="spark-head"><span>throughput · last 90s</span><span style="color:${trendCol}">${trend}</span></div>
|
||||||
|
${sparkSVG(speedHist, 1.0)}
|
||||||
|
</div>
|
||||||
|
<div class="gauges">
|
||||||
|
<div class="gauge">
|
||||||
|
<div class="v ${speedClass(sp)}">${sp.toFixed(2)}×</div>
|
||||||
|
<div class="k">speed</div>
|
||||||
|
<div class="sub">${sp > 0 && sp < 1 ? "below realtime" : sp >= 1 ? "realtime+" : ""}</div>
|
||||||
|
</div>
|
||||||
|
<div class="gauge">
|
||||||
|
<div class="v">${(c.fps || 0).toFixed(1)}</div>
|
||||||
|
<div class="k">fps</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${substatsHTML(c)}${streamsHTML(c.streams)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Longest common prefix, so a queue of "Medium S05E02/03/04…" shows the
|
||||||
|
// shared part once and the differing tail per row.
|
||||||
|
function commonPrefix(arr) {
|
||||||
|
if (!arr.length) return "";
|
||||||
|
let p = arr[0];
|
||||||
|
for (const s of arr) { while (!s.startsWith(p)) p = p.slice(0, -1); if (!p) break; }
|
||||||
|
return p;
|
||||||
|
}
|
||||||
function renderQueue(queue, current) {
|
function renderQueue(queue, current) {
|
||||||
const cur = base(current && current.file);
|
const cur = base(current && current.file);
|
||||||
const pending = (queue || []).filter(f => f !== cur);
|
const pending = (queue || []).filter(f => f !== cur);
|
||||||
$("qcount").textContent = pending.length ? `(${pending.length})` : "";
|
const el = $("queue");
|
||||||
$("queue").innerHTML = pending.length
|
if (!pending.length) {
|
||||||
? pending.map(f => `<li>${esc(f)}</li>`).join("")
|
el.innerHTML = `<div class="qsum"><span class="ql">Nothing waiting</span></div>`;
|
||||||
: `<li class="empty">Nothing waiting</li>`;
|
return;
|
||||||
|
}
|
||||||
|
const pre = pending.length > 1 ? commonPrefix(pending) : "";
|
||||||
|
const head = `<div class="qsum"><span class="qn">${pending.length}</span><span class="ql">${pending.length === 1 ? "file" : "files"} waiting</span></div>`;
|
||||||
|
const rows = pending.slice(0, 3).map(f => {
|
||||||
|
const tail = pre ? f.slice(pre.length) : f;
|
||||||
|
return `<li>${pre ? `<span class="pre">${esc(pre)}</span>` : ""}<span class="key">${esc(tail)}</span></li>`;
|
||||||
|
}).join("");
|
||||||
|
const more = pending.length > 3 ? `<div class="qmore">+ ${pending.length - 3} more</div>` : "";
|
||||||
|
el.innerHTML = head + `<ul class="list">${rows}</ul>` + more;
|
||||||
}
|
}
|
||||||
|
|
||||||
// WhatsApp-style: a date divider whenever the day changes, time-only per row.
|
// WhatsApp-style: a date divider whenever the day changes, time-only per row.
|
||||||
@@ -269,6 +358,7 @@
|
|||||||
const r = await fetch("status", { cache: "no-store" });
|
const r = await fetch("status", { cache: "no-store" });
|
||||||
if (!r.ok) throw new Error(r.status);
|
if (!r.ok) throw new Error(r.status);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
|
pushSpeed(d.current);
|
||||||
renderJob(d.current);
|
renderJob(d.current);
|
||||||
renderQueue(d.queue, d.current);
|
renderQueue(d.queue, d.current);
|
||||||
renderEvents(d.recent);
|
renderEvents(d.recent);
|
||||||
|
|||||||
Reference in New Issue
Block a user