2026-07-29 17:36:22 +02:00
|
|
|
|
{% extends "base_admin.html" %}
|
|
|
|
|
|
|
|
|
|
|
|
{% block title %}StFV Backup - Dashboard{% endblock %}
|
|
|
|
|
|
|
|
|
|
|
|
{% block content %}
|
|
|
|
|
|
<style>
|
|
|
|
|
|
.actions {
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 22:45:58 +02:00
|
|
|
|
.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);
|
|
|
|
|
|
}
|
2026-08-11 22:45:58 +02:00
|
|
|
|
|
|
|
|
|
|
.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>
|
2026-08-11 22:45:58 +02:00
|
|
|
|
<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>
|
2026-08-11 22:45:58 +02:00
|
|
|
|
<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">
|
2026-08-11 22:45:58 +02:00
|
|
|
|
<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) }}"
|
|
|
|
|
|
>▶</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">✎</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">🗑</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 %}
|
2026-08-11 22:45:58 +02:00
|
|
|
|
|
|
|
|
|
|
<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 ? "✖" : "▶";
|
|
|
|
|
|
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 %}
|