443 lines
19 KiB
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><Format></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>
|