feat: Sync/Clone-Modus, Rotation, --setup Deployment, Scheduler-Fix, Dashboard-Updates, README
This commit is contained in:
@@ -8,6 +8,12 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.run-action {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -34,6 +40,72 @@
|
||||
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>
|
||||
@@ -49,6 +121,7 @@
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Zeitplan</th>
|
||||
<th>Nächster Lauf</th>
|
||||
<th>Letzter Lauf</th>
|
||||
<th>Größe</th>
|
||||
<th>Aktionen</th>
|
||||
@@ -58,13 +131,28 @@
|
||||
{% for backup in backups %}
|
||||
<tr>
|
||||
<td><strong>{{ backup.name }}</strong></td>
|
||||
<td>{{ backup.schedule_mode }}</td>
|
||||
<td>{{ backup.last_run_at | fmt_dt }}</td>
|
||||
<td>{{ backup.last_size_bytes | fmt_bytes }}</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">
|
||||
<form method="post" action="{{ url_for('backup_run', backup_id=backup.backup_id) }}" style="display:inline;">
|
||||
<button class="icon-btn" type="submit" title="Manuell ausführen" aria-label="Manuell ausführen">▶</button>
|
||||
</form>
|
||||
<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>
|
||||
<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>
|
||||
@@ -79,4 +167,179 @@
|
||||
<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 ? "✖" : "▶";
|
||||
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 %}
|
||||
|
||||
Reference in New Issue
Block a user