2025-06-03 22:56:36 -05:00
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
2025-06-03 23:19:44 -05:00
|
|
|
<head>
|
|
|
|
|
<title>qBittorrent Overview</title>
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
|
|
|
<style>
|
|
|
|
|
body {
|
|
|
|
|
background: #121212;
|
|
|
|
|
color: #e0e0e0;
|
|
|
|
|
font-family: "Segoe UI", sans-serif;
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 2rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
nav {
|
|
|
|
|
margin-bottom: 2rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
nav a {
|
|
|
|
|
color: #80dfff;
|
|
|
|
|
margin-right: 1.5rem;
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h1,
|
|
|
|
|
h2 {
|
|
|
|
|
margin-top: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
margin-bottom: 2rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.card {
|
|
|
|
|
background: #1e1e1e;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.card h3 {
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-size: 1.8rem;
|
|
|
|
|
color: #4bc0c0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.card p {
|
|
|
|
|
margin: 0.2rem;
|
|
|
|
|
color: #aaa;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.flex {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
gap: 2rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.chart-container,
|
|
|
|
|
.news-feed {
|
|
|
|
|
background: #1e1e1e;
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-width: 380px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.news-feed {
|
|
|
|
|
max-height: 500px;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.event {
|
|
|
|
|
margin-bottom: 0.75rem;
|
|
|
|
|
padding-left: 0.5rem;
|
|
|
|
|
border-left: 4px solid #444;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.event .time {
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
color: #999;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.event .state {
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
color: #00e676;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.toggle {
|
|
|
|
|
background: #222;
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
margin-bottom: 2rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.toggle label {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.toggle input {
|
|
|
|
|
transform: scale(1.3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.warning {
|
|
|
|
|
background: #2a1b1b;
|
|
|
|
|
color: #ff6f61;
|
|
|
|
|
padding: 0.75rem 1rem;
|
|
|
|
|
margin-top: 1rem;
|
|
|
|
|
border-left: 4px solid #ff6f61;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.warning strong {
|
|
|
|
|
color: #ffab91;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<nav>
|
|
|
|
|
<a href="/">Home</a>
|
|
|
|
|
<a href="/dashboard">Dashboard</a>
|
|
|
|
|
<a href="/news">News</a>
|
|
|
|
|
<a href="/settings">Settings</a>
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
{% if outdated %}
|
|
|
|
|
<div class="warning">
|
|
|
|
|
<strong>Outdated:</strong> You are running version
|
|
|
|
|
<b>{{ current_version }}</b>. The latest is <b>{{ latest_version }}</b>.
|
2025-06-03 22:56:36 -05:00
|
|
|
</div>
|
2025-06-03 23:19:44 -05:00
|
|
|
{% endif %}
|
|
|
|
|
|
|
|
|
|
<h1>Overview</h1>
|
|
|
|
|
|
|
|
|
|
<div class="toggle">
|
|
|
|
|
<label>
|
|
|
|
|
<input type="checkbox" id="resume-toggle" />
|
|
|
|
|
Auto-resume errored torrents
|
|
|
|
|
</label>
|
2025-06-03 22:56:36 -05:00
|
|
|
</div>
|
2025-06-03 23:19:44 -05:00
|
|
|
|
|
|
|
|
<div class="grid" id="metrics"></div>
|
|
|
|
|
|
|
|
|
|
<div class="flex">
|
|
|
|
|
<div class="chart-container">
|
|
|
|
|
<h2>Torrent State Distribution</h2>
|
|
|
|
|
<canvas id="stateChart"></canvas>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="chart-container">
|
|
|
|
|
<h2>Bandwidth Usage</h2>
|
|
|
|
|
<canvas id="bandwidthChart"></canvas>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="news-feed" id="news-feed">
|
|
|
|
|
<h2>Status Feed</h2>
|
|
|
|
|
</div>
|
2025-06-03 22:56:36 -05:00
|
|
|
</div>
|
|
|
|
|
|
2025-06-03 23:19:44 -05:00
|
|
|
<script>
|
|
|
|
|
let stateChart, bandwidthChart;
|
2025-06-03 22:56:36 -05:00
|
|
|
|
2025-06-03 23:19:44 -05:00
|
|
|
async function loadStats() {
|
|
|
|
|
const res = await fetch("/api/stats");
|
|
|
|
|
const stats = await res.json();
|
|
|
|
|
const grid = document.getElementById("metrics");
|
|
|
|
|
grid.innerHTML = "";
|
2025-06-03 22:56:36 -05:00
|
|
|
|
2025-06-03 23:19:44 -05:00
|
|
|
const stateCounts = stats.states || {};
|
|
|
|
|
const avg = stats.average_progress || 0;
|
2025-06-03 22:56:36 -05:00
|
|
|
|
2025-06-03 23:19:44 -05:00
|
|
|
for (const [state, count] of Object.entries(stateCounts)) {
|
|
|
|
|
const div = document.createElement("div");
|
|
|
|
|
div.className = "card";
|
|
|
|
|
div.innerHTML = `<h3>${count}</h3><p>${state}</p>`;
|
|
|
|
|
grid.appendChild(div);
|
|
|
|
|
}
|
2025-06-03 22:56:36 -05:00
|
|
|
|
2025-06-03 23:19:44 -05:00
|
|
|
const avgCard = document.createElement("div");
|
|
|
|
|
avgCard.className = "card";
|
|
|
|
|
avgCard.innerHTML = `<h3>${avg}%</h3><p>Overall Progress</p>`;
|
|
|
|
|
grid.appendChild(avgCard);
|
2025-06-03 22:56:36 -05:00
|
|
|
|
2025-06-03 23:19:44 -05:00
|
|
|
updateStateChart(stateCounts);
|
2025-06-03 22:56:36 -05:00
|
|
|
}
|
2025-06-03 23:19:44 -05:00
|
|
|
|
|
|
|
|
function updateStateChart(stateData) {
|
|
|
|
|
const labels = Object.keys(stateData);
|
|
|
|
|
const values = Object.values(stateData);
|
|
|
|
|
const colors = [
|
|
|
|
|
"#4bc0c0",
|
|
|
|
|
"#36a2eb",
|
|
|
|
|
"#9966ff",
|
|
|
|
|
"#ff6384",
|
|
|
|
|
"#ffcd56",
|
|
|
|
|
"#7fdb7f",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (!stateChart) {
|
|
|
|
|
stateChart = new Chart(document.getElementById("stateChart"), {
|
|
|
|
|
type: "pie",
|
|
|
|
|
data: {
|
|
|
|
|
labels: labels,
|
|
|
|
|
datasets: [{ data: values, backgroundColor: colors }],
|
2025-06-03 22:56:36 -05:00
|
|
|
},
|
2025-06-03 23:19:44 -05:00
|
|
|
options: { plugins: { legend: { labels: { color: "#eee" } } } },
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
stateChart.data.labels = labels;
|
|
|
|
|
stateChart.data.datasets[0].data = values;
|
|
|
|
|
stateChart.update();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadBandwidth() {
|
|
|
|
|
const res = await fetch("/api/bandwidth");
|
|
|
|
|
const history = await res.json();
|
|
|
|
|
const labels = history.map((h) => h.time);
|
|
|
|
|
const down = history.map((h) => h.download);
|
|
|
|
|
const up = history.map((h) => h.upload);
|
|
|
|
|
|
|
|
|
|
if (!bandwidthChart) {
|
|
|
|
|
bandwidthChart = new Chart(
|
|
|
|
|
document.getElementById("bandwidthChart"),
|
|
|
|
|
{
|
|
|
|
|
type: "line",
|
|
|
|
|
data: {
|
|
|
|
|
labels,
|
|
|
|
|
datasets: [
|
|
|
|
|
{
|
|
|
|
|
label: "Download KB/s",
|
|
|
|
|
data: down,
|
|
|
|
|
borderColor: "#4bc0c0",
|
|
|
|
|
tension: 0.3,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: "Upload KB/s",
|
|
|
|
|
data: up,
|
|
|
|
|
borderColor: "#ff6384",
|
|
|
|
|
tension: 0.3,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
options: {
|
|
|
|
|
scales: {
|
|
|
|
|
x: { ticks: { color: "#aaa" } },
|
|
|
|
|
y: { ticks: { color: "#aaa" } },
|
|
|
|
|
},
|
|
|
|
|
plugins: { legend: { labels: { color: "#eee" } } },
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
bandwidthChart.data.labels = labels;
|
|
|
|
|
bandwidthChart.data.datasets[0].data = down;
|
|
|
|
|
bandwidthChart.data.datasets[1].data = up;
|
|
|
|
|
bandwidthChart.update();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadNews() {
|
|
|
|
|
const res = await fetch("/api/news");
|
|
|
|
|
const news = await res.json();
|
|
|
|
|
const feed = document.getElementById("news-feed");
|
|
|
|
|
feed.innerHTML = "<h2>Status Feed</h2>";
|
|
|
|
|
for (const event of news.slice(0, 15)) {
|
|
|
|
|
const div = document.createElement("div");
|
|
|
|
|
div.className = "event";
|
|
|
|
|
div.innerHTML = `<div><span class="state">${event.state}</span> — <strong>${event.name}</strong></div><div class="time">${event.time}</div>`;
|
|
|
|
|
feed.appendChild(div);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadResumeSetting() {
|
|
|
|
|
const res = await fetch("/api/auto_resume");
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
document.getElementById("resume-toggle").checked = json.enabled;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function setResumeSetting(value) {
|
|
|
|
|
await fetch("/api/auto_resume", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ enabled: value }),
|
2025-06-03 22:56:36 -05:00
|
|
|
});
|
|
|
|
|
}
|
2025-06-03 23:19:44 -05:00
|
|
|
|
|
|
|
|
document
|
|
|
|
|
.getElementById("resume-toggle")
|
|
|
|
|
.addEventListener("change", (e) => {
|
|
|
|
|
setResumeSetting(e.target.checked);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function refresh() {
|
|
|
|
|
await Promise.all([loadStats(), loadBandwidth(), loadNews()]);
|
2025-06-03 22:56:36 -05:00
|
|
|
}
|
2025-06-03 23:19:44 -05:00
|
|
|
|
|
|
|
|
setInterval(refresh, 1000);
|
|
|
|
|
loadResumeSetting();
|
|
|
|
|
refresh();
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
2025-06-03 22:56:36 -05:00
|
|
|
</html>
|