Files

346 lines
10 KiB
HTML
Raw Permalink Normal View History

2026-07-29 17:36:22 +02:00
{% extends "base_admin.html" %}
2026-08-11 23:33:40 +02:00
{% block title %}BackItUp - Dashboard{% endblock %}
2026-07-29 17:36:22 +02:00
{% block content %}
<style>
.actions {
white-space: nowrap;
}
.run-action {
position: relative;
display: inline-flex;
margin-right: 5px;
}
2026-07-29 17:36:22 +02:00
.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;
}
2026-07-29 17:36:22 +02:00
</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>
2026-07-29 17:36:22 +02:00
<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>
2026-07-29 17:36:22 +02:00
<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>
2026-07-29 17:36:22 +02:00
<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>
2026-07-29 17:36:22 +02:00
{% endblock %}