Files
ComicOPDS/app/templates/dashboard.html

443 lines
19 KiB
HTML

<!DOCTYPE html>
<html lang="en" data-bs-theme="auto">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>ComicOPDS — Library Dashboard</title>
<!-- Bootstrap 5 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
body { background: var(--bs-body-bg); }
.metric { display:flex; align-items:center; gap:.75rem; }
.metric .bi { font-size:1.6rem; opacity:.8; }
.metric .value { font-size:1.9rem; font-weight:700; line-height:1; }
.metric .label { color: var(--bs-secondary-color); font-size:.85rem; }
.chart-card canvas { width:100% !important; height:360px !important; }
.footer-note { color: var(--bs-secondary-color); }
.kpis .card { transition: transform .15s ease; }
.kpis .card:hover { transform: translateY(-2px); }
.cache-pill { font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom">
<div class="container">
<a class="navbar-brand fw-semibold" href="#"><i class="bi bi-book-half me-2"></i>ComicOPDS</a>
<div class="ms-auto d-flex align-items-center gap-2">
<span class="navbar-text small text-secondary me-2">
<span id="lastUpdated"></span> • Covers: <span id="covers"></span>
• Errors: <a id="errLink" href="#" class="link-danger text-decoration-none"><span id="errCount">0</span></a>
<!-- NEW: live page cache status -->
• Cache: <span id="cacheStatus" class="badge text-bg-light cache-pill"></span>
</span>
<button id="thumbsBtn" class="btn btn-sm btn-outline-primary">
<i class="bi bi-images me-1"></i> Pre-cache Thumbnails
</button>
<button id="reindexBtn" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-repeat me-1"></i> Reindex
</button>
<!-- NEW: Clean Page Cache -->
<button id="cleanCacheBtn" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash3 me-1"></i> Clean Page Cache
</button>
</div>
<div class="ms-auto small text-secondary">
<a class="text-decoration-none me-3" href="/opds"><i class="bi bi-rss me-1"></i>OPDS</a>
<a class="text-decoration-none" href="/search"><i class="bi bi-search me-1"></i>search</a>
</div>
</div>
</nav>
<main class="container my-4">
<!-- Indexing progress -->
<div id="indexProgress" class="alert alert-secondary d-none" role="alert">
<div class="d-flex justify-content-between">
<div>
<strong>Indexing your library…</strong>
<div class="small text-secondary">
<span id="idxPhase">indexing</span>
<span id="idxCounts" class="ms-2">(0 / 0)</span>
</div>
<div class="small text-secondary" id="idxCurrent"></div>
</div>
<div class="text-end">
<span class="badge text-bg-light" id="idxEta"></span>
</div>
</div>
<div class="progress mt-2">
<div id="idxBar" class="progress-bar progress-bar-striped progress-bar-animated" style="width:0%">0%</div>
</div>
</div>
<!-- Thumbnails pre-cache progress -->
<div id="thumbsProgress" class="alert alert-primary d-none" role="alert">
<div class="d-flex justify-content-between">
<div>
<strong>Generating thumbnails…</strong>
<div class="small text-secondary">
<span id="thCounts">(0 / 0)</span>
</div>
</div>
<div class="text-end">
<span class="badge text-bg-light" id="thEta"></span>
</div>
</div>
<div class="progress mt-2">
<div id="thBar" class="progress-bar progress-bar-striped progress-bar-animated bg-primary" style="width:0%">0%</div>
</div>
</div>
<!-- KPIs -->
<div class="row g-3 kpis">
<div class="col-12 col-md-6 col-xl-3">
<div class="card h-100"><div class="card-body">
<div class="metric"><i class="bi bi-collection"></i>
<div><div class="value" id="totalComics">0</div><div class="label">Total comics</div></div>
</div>
</div></div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card h-100"><div class="card-body">
<div class="metric"><i class="bi bi-layers"></i>
<div><div class="value" id="uniqueSeries">0</div><div class="label">Unique series</div></div>
</div>
</div></div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card h-100"><div class="card-body">
<div class="metric"><i class="bi bi-building"></i>
<div><div class="value" id="uniquePublishers">0</div><div class="label">Publishers</div></div>
</div>
</div></div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card h-100"><div class="card-body">
<div class="metric"><i class="bi bi-tags"></i>
<div><div class="value" id="formats"></div><div class="label">Formats (kinds)</div></div>
</div>
</div></div>
</div>
</div>
<!-- Charts -->
<div class="row g-3 mt-1">
<div class="col-12 col-lg-6">
<div class="card h-100 chart-card">
<div class="card-header fw-semibold">Publishers distribution</div>
<div class="card-body"><canvas id="publishersChart"></canvas></div>
<div class="card-footer small footer-note">Top publishers (others grouped).</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card h-100 chart-card">
<div class="card-header fw-semibold">Publication timeline</div>
<div class="card-body"><canvas id="timelineChart"></canvas></div>
<div class="card-footer small footer-note">Issues per year.</div>
</div>
</div>
<div class="col-12 col-lg-6" id="formatsCardCol">
<div class="card h-100 chart-card">
<div class="card-header fw-semibold">Formats breakdown</div>
<div class="card-body"><canvas id="formatsChart"></canvas></div>
<div class="card-footer small footer-note">Breakdown by <code>&lt;Format&gt;</code> in ComicInfo.xml (e.g., TPB, HC, Limited series).</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card h-100 chart-card">
<div class="card-header fw-semibold">Top writers</div>
<div class="card-body"><canvas id="writersChart"></canvas></div>
<div class="card-footer small footer-note">Top 15 writers across your library.</div>
</div>
</div>
</div>
</main>
<!-- Toast (bottom-right) for one-off messages like cache cleanup result) -->
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:1080">
<div id="toast" class="toast align-items-center text-bg-dark border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div id="toastBody" class="toast-body">Done.</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</div>
<footer class="container my-4 small footer-note">
<div class="d-flex justify-content-between">
<span>ComicOPDS Dashboard</span>
<span><a href="/opds" class="link-secondary text-decoration-none"><i class="bi bi-rss me-1"></i>OPDS feed</a></span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
const baseOptions = {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels:{usePointStyle:true, boxWidth:8} }, tooltip:{mode:'index', intersect:false} },
interaction:{ mode:'nearest', axis:'x', intersect:false },
scales:{ x:{ ticks:{ maxRotation:0, autoSkip:true } }, y:{ beginAtZero:true, ticks:{ precision:0 } } }
};
const charts = {};
function upsertChart(id, cfg){ const e=Chart.getChart(id)||charts[id]; if(e) e.destroy(); const el=document.getElementById(id); if(!el) return null; return charts[id]=new Chart(el, cfg); }
async function jget(u){ const r=await fetch(u,{credentials:'include'}); if(!r.ok) throw Error(r.status); return r.json(); }
function mapPublishers(d){ const a=Array.isArray(d.top_publishers)?d.top_publishers:[]; return {labels:a.map(x=>x.publisher??'(Unknown)'),values:a.map(x=>x.count??x.c??0)}; }
function mapTimeline(d){ const l=(d.timeline_by_year||[]).filter(x=>x.year!=null).sort((a,b)=>(+a.year)-(+b.year)); return {labels:l.map(x=>String(x.year)),values:l.map(x=>x.count??0)}; }
function mapWriters(d){ const a=Array.isArray(d.top_writers)?d.top_writers:[]; return {labels:a.map(x=>x.writer??'(Unknown)'),values:a.map(x=>x.count??0)}; }
// Formats from stats.formats_breakdown (array of {format, count})
function mapFormats(d){
const arr = Array.isArray(d.formats_breakdown) ? d.formats_breakdown : [];
const labels = arr.map(x => x.format || '(unknown)');
const values = arr.map(x => x.count || 0);
return { labels, values, kinds: arr.length };
}
function fmtBytes(n){
if(!Number.isFinite(n)) return "—";
const u=['B','KB','MB','GB','TB']; let i=0; let v=Number(n);
while(v>=1024 && i<u.length-1){ v/=1024; i++; }
return (v>=10? v.toFixed(0): v.toFixed(1))+" "+u[i];
}
async function load(){
const d = await jget("/stats.json");
document.getElementById("lastUpdated").textContent = d.last_updated? new Date(d.last_updated*1000).toLocaleString() : "—";
document.getElementById("covers").textContent = d.total_covers ?? "0";
document.getElementById("totalComics").textContent = d.total_comics ?? "0";
document.getElementById("uniqueSeries").textContent = d.unique_series ?? "0";
document.getElementById("uniquePublishers").textContent = d.publishers ?? "0";
// Formats KPI + chart
const fmt = mapFormats(d);
document.getElementById("formats").textContent = String(fmt.kinds || 0);
const hasFormats = (fmt.values.reduce((a,b)=>a+b,0) > 0);
document.getElementById("formatsCardCol").style.display = hasFormats ? "" : "none";
const pubs = mapPublishers(d);
const zipped = pubs.labels.map((l,i)=>({l, v: pubs.values[i]||0})).sort((a,b)=>b.v-a.v);
upsertChart("publishersChart",{ type:"doughnut", data:{labels:zipped.map(x=>x.l), datasets:[{data:zipped.map(x=>x.v)}]}, options:{...baseOptions, cutout:"60%", scales:{}} });
const tl = mapTimeline(d);
upsertChart("timelineChart",{ type:"line", data:{labels:tl.labels, datasets:[{ label:"Issues per year", data:tl.values, fill:true, tension:0.25, pointRadius:2 }]}, options:{...baseOptions} });
if (hasFormats){
upsertChart("formatsChart",{
type:"doughnut",
data:{ labels: fmt.labels, datasets:[{ data: fmt.values }]},
options:{ ...baseOptions, cutout:"60%", scales:{} }
});
} else {
const ex = Chart.getChart("formatsChart"); if (ex) ex.destroy();
}
const tw = mapWriters(d);
upsertChart("writersChart",{ type:"bar", data:{labels:tw.labels, datasets:[{ label:"Issues", data:tw.values }]}, options:{...baseOptions, indexAxis:'y'} });
}
// ----- Index progress -----
let lastIdx = null;
function showIndexUI(s){
const box = document.getElementById("indexProgress");
if (s.running || (!s.usable && s.phase!=='idle')){
box.classList.remove("d-none");
const done=s.done||0, total=Math.max(s.total||0,1), pct=Math.min(100, Math.floor(done*100/total));
document.getElementById("idxPhase").textContent = s.phase||"indexing";
document.getElementById("idxCounts").textContent = `(${done} / ${s.total||0})`;
document.getElementById("idxCurrent").textContent = s.current || "";
const bar = document.getElementById("idxBar");
bar.style.width = pct+"%"; bar.textContent = pct+"%";
let eta="—";
if (s.started_at && done>5 && (s.total>done)){
const elapsed=(Date.now()/1000 - s.started_at), per=elapsed/done, secs=Math.round(per*(s.total-done));
eta=`~${Math.floor(secs/60)}m ${secs%60}s left`;
}
document.getElementById("idxEta").textContent = eta;
} else {
box.classList.add("d-none");
}
}
function shouldReloadCharts(newS, oldS){
if (!newS) return false;
if (!oldS) return (!newS.running && newS.usable);
const finished = oldS.running && !newS.running;
const endedChanged = newS.ended_at && newS.ended_at !== oldS.ended_at;
const becameUsable = !oldS.usable && newS.usable;
return finished || endedChanged || becameUsable;
}
async function pollIndex(){
let delay=5000;
try{
const s = await jget("/index/status");
showIndexUI(s);
if (shouldReloadCharts(s, lastIdx)) await load();
delay = s.running ? 800 : 5000;
lastIdx = s;
}catch{ delay=5000; }
setTimeout(pollIndex, delay);
}
// ----- Thumbnails pre-cache progress -----
async function pollThumbs(){
let delay=5000;
try{
const t = await jget("/thumbs/status");
const box = document.getElementById("thumbsProgress");
if (t && t.running){
box.classList.remove("d-none");
const done=t.done||0, total=Math.max(t.total||0,1), pct=Math.min(100, Math.floor(done*100/total));
document.getElementById("thCounts").textContent = `(${done} / ${t.total||0})`;
const bar=document.getElementById("thBar");
bar.style.width = pct+"%"; bar.textContent = pct+"%";
let eta="—";
if (t.started_at && done>5 && (t.total>done)){
const elapsed=(Date.now()/1000 - t.started_at), per=elapsed/done, secs=Math.round(per*(t.total-done));
eta=`~${Math.floor(secs/60)}m ${secs%60}s left`;
}
document.getElementById("thEta").textContent = eta;
delay = 800;
} else {
box.classList.add("d-none");
delay = 5000;
}
}catch{ delay=5000; }
setTimeout(pollThumbs, delay);
}
// ----- Errors counter + download -----
async function downloadErrors() {
try {
const resp = await fetch("/thumbs/errors/log", { credentials: "include" });
if (!resp.ok) throw new Error("HTTP " + resp.status);
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "thumbs_errors.log";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
alert("Download failed: " + (e?.message || e));
}
}
document.getElementById("errLink").addEventListener("click", (ev) => {
ev.preventDefault();
downloadErrors();
});
// ----- Buttons -----
function showIndexPending() {
const box = document.getElementById("indexProgress");
box.classList.remove("d-none");
document.getElementById("idxPhase").textContent = "starting…";
document.getElementById("idxCounts").textContent = "(0 / ?)";
document.getElementById("idxCurrent").textContent = "";
const bar = document.getElementById("idxBar");
bar.style.width = "100%";
bar.textContent = "Starting…";
}
function showThumbsPending() {
const box = document.getElementById("thumbsProgress");
box.classList.remove("d-none");
document.getElementById("thCounts").textContent = "(0 / ?)";
const bar = document.getElementById("thBar");
bar.style.width = "100%";
bar.textContent = "Starting…";
}
async function triggerThumbs() {
const btn = document.getElementById("thumbsBtn");
const html = btn.innerHTML;
try {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> Starting…';
showThumbsPending();
await fetch("/admin/thumbs/precache", { method: "POST", credentials: "include" });
setTimeout(pollThumbs, 200);
} catch (e) {
alert("Failed to start thumbnails pre-cache: " + (e?.message || e));
} finally {
setTimeout(() => { btn.disabled = false; btn.innerHTML = html; }, 600);
}
}
document.getElementById("thumbsBtn").addEventListener("click", triggerThumbs);
document.getElementById("reindexBtn").addEventListener("click", async () => {
const btn = document.getElementById("reindexBtn");
const original = btn.innerHTML;
try {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> Reindexing…';
showIndexPending();
await fetch("/admin/reindex", { method: "POST", credentials: "include" });
setTimeout(pollIndex, 200);
} catch (e) {
alert("Reindex failed: " + (e?.message || e));
} finally {
setTimeout(() => { btn.disabled = false; btn.innerHTML = original; }, 600);
}
});
// NEW: Clean page cache
async function updateCacheStatus() {
try{
const s = await jget("/pages/cache/status");
const badge = document.getElementById("cacheStatus");
badge.textContent = `${s.dir_count ?? 0} dirs • ${fmtBytes(s.total_bytes ?? 0)}`;
} catch {
document.getElementById("cacheStatus").textContent = "—";
}
}
async function cleanCache() {
const btn = document.getElementById("cleanCacheBtn");
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> Cleaning…';
try {
const resp = await fetch("/admin/pages/cleanup", { method:"POST", credentials:"include" });
const data = await resp.json().catch(()=>({}));
// toast
const toastEl = document.getElementById('toast');
document.getElementById('toastBody').textContent =
`Cache cleaned: ${data.deleted_dirs ?? 0} dirs, ${fmtBytes(data.deleted_bytes ?? 0)} freed.`;
const t = new bootstrap.Toast(toastEl, { delay: 4000 });
t.show();
} catch (e) {
alert("Cache cleanup failed: " + (e?.message || e));
} finally {
await updateCacheStatus();
setTimeout(() => { btn.disabled = false; btn.innerHTML = original; }, 500);
}
}
document.getElementById("cleanCacheBtn").addEventListener("click", cleanCache);
// Initial load & polls
load();
pollIndex();
pollThumbs();
updateCacheStatus();
// refresh cache pill periodically
setInterval(updateCacheStatus, 120000); // every 2 min
// Errors counter
(function pollErrors(){
let delay=8000;
jget("/thumbs/errors/count").then(e => {
const n = e?.lines || 0;
const el = document.getElementById("errCount");
el.textContent = n;
el.parentElement.classList.toggle("link-danger", n>0);
delay = n>0 ? 5000 : 15000;
}).catch(()=>{ delay=15000; }).finally(()=>setTimeout(pollErrors, delay));
})();
</script>
</body>
</html>