Files
BackItUp/app/templates/dashboard.html
T
2026-08-11 23:33:40 +02:00

346 lines
10 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base_admin.html" %}
{% block title %}BackItUp - Dashboard{% endblock %}
{% block content %}
<style>
.actions {
white-space: nowrap;
}
.run-action {
position: relative;
display: inline-flex;
margin-right: 5px;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 30px;
border: 1px solid rgba(30, 42, 59, 0.18);
border-radius: 8px;
background: #fff;
color: #1e2a3b;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
margin-right: 5px;
text-decoration: none;
}
.icon-btn:hover {
background: #f3f8ff;
}
.icon-btn-danger {
color: #b9382f;
border-color: rgba(185, 56, 47, 0.3);
}
.icon-btn-stop {
color: #b9382f;
border-color: rgba(185, 56, 47, 0.38);
background: #fff2f1;
}
.run-tooltip {
position: absolute;
right: 0;
bottom: 100%;
/* extra bottom-padding bridges the gap so CSS :hover stays active while moving mouse to tooltip */
padding: 9px 10px 18px;
transform: translateY(4px);
width: 280px;
border: 1px solid rgba(30, 42, 59, 0.2);
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 28px rgba(30, 42, 59, 0.2);
opacity: 0;
visibility: hidden;
transition: opacity 150ms ease, transform 150ms ease;
pointer-events: auto;
user-select: text;
cursor: text;
z-index: 30;
}
.run-action:hover .run-tooltip,
.run-action:focus-within .run-tooltip {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.run-tooltip-title {
margin: 0 0 6px;
font-size: 12px;
font-weight: 700;
color: #1e2a3b;
}
.run-progress {
width: 100%;
height: 7px;
border-radius: 999px;
background: #ecf2fa;
overflow: hidden;
margin-bottom: 6px;
}
.run-progress > span {
display: block;
width: 0;
height: 100%;
background: linear-gradient(90deg, #0a7f68, #02a883);
transition: width 220ms ease;
}
.run-tooltip-text {
margin: 0;
font-size: 12px;
color: #5a6e87;
line-height: 1.4;
word-break: break-word;
}
</style>
<h2>Eingerichtete Backups</h2>
<p>Hier siehst du alle Backup-Jobs mit Zeitplan, letztem Lauf und Größe.</p>
<div class="card">
<a class="btn" href="{{ url_for('backup_new') }}">Neues Backup einrichten</a>
</div>
{% if backups %}
<table class="users-table">
<thead>
<tr>
<th>Name</th>
<th>Zeitplan</th>
<th>Nächster Lauf</th>
<th>Letzter Lauf</th>
<th>Größe</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for backup in backups %}
<tr>
<td><strong>{{ backup.name }}</strong></td>
<td>{{ backup.schedule_cron or '–' }}</td>
<td data-next-run>{% if backup.schedule_cron %}{{ backup.next_run_at | fmt_dt }}{% else %}Kein automatisches Backup{% endif %}</td>
<td data-last-run>{{ backup.last_run_at | fmt_dt }}</td>
<td data-last-size>{{ backup.last_size_bytes | fmt_bytes }}</td>
<td class="actions">
<span class="run-action" data-run-action data-backup-id="{{ backup.backup_id }}">
<button
class="icon-btn"
type="button"
title="Manuell ausführen"
aria-label="Manuell ausführen"
data-run-toggle
data-run-url="{{ url_for('backup_run_api', backup_id=backup.backup_id) }}"
data-stop-url="{{ url_for('backup_stop_api', backup_id=backup.backup_id) }}"
data-status-url="{{ url_for('backup_status_api', backup_id=backup.backup_id) }}"
>&#9654;</button>
<div class="run-tooltip" role="status" aria-live="polite">
<p class="run-tooltip-title" data-run-title>Bereit</p>
<div class="run-progress"><span data-run-bar></span></div>
<p class="run-tooltip-text" data-run-text>Kein Lauf aktiv</p>
</div>
</span>
<a class="icon-btn" href="{{ url_for('backup_edit', backup_id=backup.backup_id) }}" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</a>
<form method="post" action="{{ url_for('backup_delete', backup_id=backup.backup_id) }}" style="display:inline;" onsubmit="return confirm('Backup wirklich löschen?');">
<button class="icon-btn icon-btn-danger" type="submit" title="Löschen" aria-label="Löschen">&#128465;</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card">
<p>Noch keine Backups eingerichtet. Lege jetzt den ersten Job an.</p>
</div>
{% endif %}
<script>
(() => {
const fastPollMs = 1200;
const idlePollMs = 10000;
const rows = Array.from(document.querySelectorAll("[data-run-action]"));
if (!rows.length) {
return;
}
let pollTimer = null;
let pollInFlight = false;
const clampPercent = (value) => {
const num = Number(value);
if (!Number.isFinite(num)) return 0;
return Math.max(0, Math.min(100, Math.round(num)));
};
const formatBytes = (value) => {
if (value === null || value === undefined || value === "") {
return "Noch kein Lauf";
}
const num = Number(value);
if (!Number.isFinite(num)) {
return "Unbekannt";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let size = num;
let unitIdx = 0;
while (size >= 1024 && unitIdx < units.length - 1) {
size /= 1024;
unitIdx += 1;
}
return `${size.toFixed(2)} ${units[unitIdx]}`;
};
const updateRuntimeCells = (container, runtime) => {
if (!runtime) {
return;
}
const row = container.closest("tr");
if (!row) {
return;
}
const lastRunCell = row.querySelector("[data-last-run]");
const lastSizeCell = row.querySelector("[data-last-size]");
const nextRunCell = row.querySelector("[data-next-run]");
if (lastRunCell) {
lastRunCell.textContent = runtime.last_run_at ? String(runtime.last_run_at) : "Noch kein Lauf";
}
if (lastSizeCell) {
lastSizeCell.textContent = formatBytes(runtime.last_size_bytes);
}
if (nextRunCell && runtime.next_run_at !== undefined) {
nextRunCell.textContent = runtime.next_run_at || "Kein automatisches Backup";
}
};
const renderStatus = (container, status) => {
const button = container.querySelector("[data-run-toggle]");
const title = container.querySelector("[data-run-title]");
const text = container.querySelector("[data-run-text]");
const bar = container.querySelector("[data-run-bar]");
const isRunning = !!status?.is_running;
const percent = clampPercent(status?.progress_percent ?? 0);
const progressText = String(status?.progress_text || (isRunning ? "In Progress" : "Bereit"));
const statusMessage = String(status?.status_message || "");
button.dataset.isRunning = isRunning ? "1" : "0";
button.innerHTML = isRunning ? "&#10006;" : "&#9654;";
button.classList.toggle("icon-btn-stop", isRunning);
button.title = isRunning ? "Abbrechen" : "Manuell ausführen";
button.setAttribute("aria-label", isRunning ? "Backup-Lauf abbrechen" : "Manuell ausführen");
title.textContent = `${progressText} (${percent}%)`;
text.textContent = statusMessage || (isRunning ? "Backup läuft..." : "Kein Lauf aktiv");
bar.style.width = `${percent}%`;
};
const fetchStatus = async (container) => {
const button = container.querySelector("[data-run-toggle]");
try {
const response = await fetch(button.dataset.statusUrl, {
method: "GET",
headers: { "Accept": "application/json" },
credentials: "same-origin"
});
if (!response.ok) {
return button.dataset.isRunning === "1";
}
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if (!contentType.includes("application/json")) {
return button.dataset.isRunning === "1";
}
const data = await response.json().catch(() => null);
if (!data || !data.status) {
return button.dataset.isRunning === "1";
}
renderStatus(container, data.status || {});
updateRuntimeCells(container, data.runtime || null);
return !!data.status?.is_running;
} catch (error) {
console.warn("Status-Polling fehlgeschlagen", error);
return button.dataset.isRunning === "1";
}
};
const schedulePoll = (delayMs) => {
if (pollTimer) {
window.clearTimeout(pollTimer);
}
pollTimer = window.setTimeout(() => {
void pollStatuses();
}, delayMs);
};
const pollStatuses = async () => {
if (pollInFlight) {
schedulePoll(fastPollMs);
return;
}
pollInFlight = true;
try {
const runningStates = await Promise.all(rows.map((container) => fetchStatus(container)));
const anyRunning = runningStates.some((isRunning) => isRunning === true);
schedulePoll(anyRunning ? fastPollMs : idlePollMs);
} finally {
pollInFlight = false;
}
};
const toggleRun = async (container) => {
const button = container.querySelector("[data-run-toggle]");
const isRunning = button.dataset.isRunning === "1";
const endpoint = isRunning ? button.dataset.stopUrl : button.dataset.runUrl;
button.disabled = true;
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Accept": "application/json",
"X-Requested-With": "fetch"
},
credentials: "same-origin"
});
const data = await response.json().catch(() => ({}));
if (data.status) {
renderStatus(container, data.status);
}
updateRuntimeCells(container, data.runtime || null);
schedulePoll(fastPollMs);
if (!response.ok && data.message) {
console.warn(data.message);
}
} catch (error) {
console.warn("Run-Aktion fehlgeschlagen", error);
} finally {
button.disabled = false;
}
};
rows.forEach((container) => {
const button = container.querySelector("[data-run-toggle]");
button.addEventListener("click", () => {
void toggleRun(container);
});
void fetchStatus(container);
});
void pollStatuses();
})();
</script>
{% endblock %}