vSphere-Backup-Manager/templates/jobs.html

424 lines
18 KiB
HTML

{% extends "base.html" %}
{% set active_page = 'jobs' %}
{% block title %}Backup Jobs — vSphere Backup Manager{% endblock %}
{% block head %}
<style>
.jobs-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 24px;
}
.jobs-summary {
display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 24px;
}
.jobs-stat {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px 20px;
text-align: center;
min-width: 100px;
box-shadow: var(--shadow);
backdrop-filter: blur(8px);
}
.jobs-stat-val { font-size: 24px; font-weight: 800; letter-spacing: -0.02em; }
.jobs-stat-lbl { font-size: 11px; color: var(--text-muted); margin-top: 3px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
.jobs-table-wrap {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
backdrop-filter: blur(8px);
}
.status-icon { font-size: 16px; }
.job-actions { display: flex; gap: 8px; }
.empty-state {
text-align: center; padding: 64px; color: var(--text-secondary);
}
.empty-icon { font-size: 52px; margin-bottom: 16px; opacity: .4; }
.running-pulse {
animation: pulse 1.5s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .6; transform: scale(0.98); }
}
.schedule-tag {
display: inline-flex; align-items: center; gap: 6px;
background: rgba(6, 182, 212, 0.08);
border: 1px solid rgba(6, 182, 212, 0.2);
color: var(--accent-2);
font-size: 11px; padding: 3px 10px; border-radius: 100px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.jobs-stat {
cursor: pointer;
transition: transform 0.15s ease, border-color 0.2s ease, box-shadow 0.2s ease;
user-select: none;
}
.jobs-stat:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.jobs-stat.active {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow), var(--shadow);
}
.jobs-stat.active .jobs-stat-lbl {
color: var(--accent);
}
.text-right { text-align: right !important; }
/* Actions Dropdown */
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-menu {
display: none;
position: absolute;
right: 0;
top: 100%;
margin-top: 6px;
background: #111422;
border: 1px solid var(--border-bright);
border-radius: var(--radius-sm);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
z-index: 1000;
min-width: 175px;
overflow: hidden;
}
.dropdown-menu.show {
display: block;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
color: var(--text-secondary);
text-decoration: none;
font-size: 13px;
font-weight: 500;
background: none;
border: none;
width: 100%;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
}
.dropdown-item:hover {
background: rgba(99, 102, 241, 0.15);
color: var(--text-primary);
}
.dropdown-item.text-danger {
color: #fca5a5;
}
.dropdown-item.text-danger:hover {
background: rgba(239, 68, 68, 0.15);
color: #ffffff;
}
</style>
{% endblock %}
{% block content %}
<div class="topbar">
<div>
<div class="topbar-title">Backup Jobs</div>
<div class="topbar-subtitle">All scheduled and completed backup jobs</div>
</div>
<div class="topbar-actions">
<a href="/jobs/create" class="btn btn-primary btn-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px;"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create Job
</a>
</div>
</div>
<div class="content">
<!-- Summary chips -->
<div class="jobs-summary">
<div class="jobs-stat active" data-filter="all">
<div class="jobs-stat-val">{{ jobs|length }}</div>
<div class="jobs-stat-lbl">Total</div>
</div>
<div class="jobs-stat" data-filter="running">
<div class="jobs-stat-val" style="color:var(--accent)">{{ jobs|selectattr('status','equalto','running')|list|length }}</div>
<div class="jobs-stat-lbl">Running</div>
</div>
<div class="jobs-stat" data-filter="finished">
<div class="jobs-stat-val" style="color:var(--success)">{{ jobs|selectattr('status','equalto','finished')|list|length }}</div>
<div class="jobs-stat-lbl">Finished</div>
</div>
<div class="jobs-stat" data-filter="queued">
<div class="jobs-stat-val" style="color:var(--warning)">{{ jobs|selectattr('status','equalto','queued')|list|length }}</div>
<div class="jobs-stat-lbl">Queued</div>
</div>
<div class="jobs-stat" data-filter="failed">
<div class="jobs-stat-val" style="color:var(--danger)">{% set fcnt = namespace(n=0) %}{% for j in jobs %}{% if j.status.startswith('failed') %}{% set fcnt.n = fcnt.n + 1 %}{% endif %}{% endfor %}{{ fcnt.n }}</div>
<div class="jobs-stat-lbl">Failed</div>
</div>
<div class="jobs-stat" data-filter="scheduled">
<div class="jobs-stat-val" style="color:var(--accent-2)">{{ scheduled_count }}</div>
<div class="jobs-stat-lbl">Scheduled</div>
</div>
</div>
{% if jobs %}
<div class="jobs-table-wrap">
<table>
<thead>
<tr>
<th>Job</th>
<th>VM</th>
<th>Status</th>
<th>Schedule</th>
<th>Started</th>
<th class="text-right" style="width: 140px; padding-right: 20px;">Actions</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr data-status="{{ job.status }}" data-scheduled="{{ '1' if job.schedule_id else '0' }}">
<td>
<div style="font-weight:600;font-size:13px;">
{{ job.label or ('Job #' + job.id[:8]) }}
</div>
<div class="text-small text-muted mono" data-copy="{{ job.id }}" style="cursor:pointer;" title="Click to copy">{{ job.id[:12] }}…</div>
</td>
<td>
<span style="font-weight:500;">{{ job.vm_name }}</span>
</td>
<td>
{% if job.status == 'running' %}
<span class="badge badge-purple running-pulse">Running</span>
{% elif job.status == 'finished' %}
<span class="badge badge-green">Finished</span>
{% elif 'finished with errors' in job.status %}
<span class="badge badge-yellow" title="{{ job.status }}" style="cursor: help;">Finished *</span>
{% elif job.status == 'queued' %}
<span class="badge badge-yellow">Queued</span>
{% elif job.status.startswith('failed') %}
<span class="badge badge-red" title="{{ job.status }}">Failed</span>
{% else %}
<span class="badge badge-gray" title="{{ job.status }}">{{ job.status }}</span>
{% endif %}
</td>
<td>
{% if job.schedule_type and job.schedule_type != 'now' %}
<span class="schedule-tag" style="margin-bottom: 4px; display: inline-flex; align-items: center;">
<span style="margin-right: 4px; font-size: 11px; line-height: 1;">&#x1F4C5;&#xFE0E;</span>
{% if job.schedule_type == '3_monthly' %}
3 Monthly
{% elif job.schedule_type == '6_monthly' %}
6 Monthly
{% elif job.schedule_type == 'yearly' %}
Yearly
{% else %}
{{ job.schedule_type|capitalize }}
{% endif %}
</span>
{% if job.schedule_id %}
<span class="badge badge-green" style="font-size: 10px; padding: 2px 6px; display: inline-block;">Active</span>
{% if job.next_run %}
<div style="font-size: 11px; color: var(--accent-2); margin-top: 4px; font-weight: 600; display: flex; align-items: center; gap: 4px;">
<span style="font-size: 11px; line-height: 1;">&#x23F1;&#xFE0E;</span>
Next: {{ job.next_run }}
</div>
{% endif %}
{% else %}
<span class="badge badge-red" style="font-size: 10px; padding: 2px 6px; display: inline-block;">Cancelled</span>
{% endif %}
{% else %}
<span class="text-muted text-small" style="display: inline-flex; align-items: center; gap: 4px; margin-bottom: 4px;">
<span style="font-size: 11px; line-height: 1;">&#x26A1;&#xFE0E;</span>
One-time
</span>
{% endif %}
<div class="text-muted" style="font-size:11px; margin-top: 2px;">
{% if job.retention_type == 'keep_count' %}
Keep: {{ job.retention_value }} backups
{% elif job.retention_type == 'keep_days' %}
Keep: {{ job.retention_value }} days
{% else %}
Keep: All
{% endif %}
</div>
</td>
<td class="text-small text-muted">
{{ job.started_fmt }}
</td>
<td class="text-right" style="padding-right: 20px;">
<div style="display: flex; gap: 6px; align-items: center; justify-content: flex-end;">
<!-- View Button -->
<a href="/job/{{ job.id }}" class="btn btn-ghost btn-sm" style="padding: 6px 12px; font-size: 12.5px;" title="View Details">
<span style="font-size: 12px; line-height: 1;">&#x1F441;&#xFE0E;</span> View
</a>
<!-- Quick Run/Stop Button -->
{% if job.status == 'running' or job.status == 'queued' %}
<form method="post" action="/job/{{ job.id }}/stop" style="margin: 0;" onsubmit="return confirm('Are you sure you want to stop this running backup?')">
<button class="btn btn-danger btn-sm" type="submit" style="width: 28px; height: 28px; padding: 0; min-width: 28px;" title="Force Stop">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="display:block; margin:auto;"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg>
</button>
</form>
{% else %}
<form method="post" action="/job/{{ job.id }}/run" style="margin: 0;">
<button class="btn btn-primary btn-sm" type="submit" style="width: 28px; height: 28px; padding: 0; min-width: 28px;" title="Run Backup Now">
<span style="font-size: 10px; line-height: 1; display:block; margin:auto;">&#x25B6;&#xFE0E;</span>
</button>
</form>
{% endif %}
<!-- Actions Dropdown -->
<div class="dropdown">
<button class="btn btn-secondary btn-sm" onclick="toggleDropdown(event, 'drop-{{ job.id }}')" style="width: 28px; height: 28px; padding: 0; min-width: 28px; display: flex; align-items: center; justify-content: center;" title="More Actions">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="display:block; margin:auto;"><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
<div class="dropdown-menu" id="drop-{{ job.id }}">
<a href="/job/{{ job.id }}/edit" class="dropdown-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="margin-right: 4px;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edit Config
</a>
{% if job.schedule_id %}
<form method="post" action="/job/{{ job.id }}/cancel-schedule" style="margin: 0;">
<button class="dropdown-item text-danger" type="submit" onclick="return confirm('Cancel this schedule?')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="margin-right: 4px; color: var(--danger);"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/></svg>
Cancel Schedule
</button>
</form>
{% elif job.schedule_type and job.schedule_type != 'now' %}
<form method="post" action="/job/{{ job.id }}/reactivate-schedule" style="margin: 0;">
<button class="dropdown-item" type="submit" onclick="return confirm('Reactivate this schedule?')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="margin-right: 4px; color: var(--success);"><circle cx="12" cy="12" r="10"/><polyline points="12 8 12 12 14 14"/></svg>
Reactivate Schedule
</button>
</form>
{% endif %}
<form method="post" action="/job/{{ job.id }}/delete" style="margin: 0;">
<button class="dropdown-item text-danger" type="submit" onclick="return confirm('Are you sure you want to delete this job? This will cancel any active schedule and delete the job logs.')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="margin-right: 4px; color: var(--danger);"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Delete Job
</button>
</form>
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon" style="display: flex; justify-content: center; margin-bottom: 16px;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color: var(--text-muted);"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>
</div>
<p>No backup jobs yet.</p>
<a href="/jobs/create" class="btn btn-primary" style="margin-top:16px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px;"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create your first job
</a>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
// Dropdown Manager
document.addEventListener('click', function(e) {
if (!e.target.closest('.dropdown')) {
document.querySelectorAll('.dropdown-menu.show').forEach(function(menu) {
menu.classList.remove('show');
});
}
});
window.toggleDropdown = function(e, menuId) {
e.stopPropagation();
const menu = document.getElementById(menuId);
const wasShown = menu.classList.contains('show');
document.querySelectorAll('.dropdown-menu.show').forEach(function(m) {
m.classList.remove('show');
});
if (!wasShown) {
menu.classList.add('show');
}
};
// Auto-refresh jobs page every 8 seconds if any jobs are running
const hasRunning = {{ 'true' if jobs|selectattr('status','equalto','running')|list|length > 0 else 'false' }};
if (hasRunning) {
setTimeout(() => location.reload(), 8000);
}
// Status filter
const statCards = document.querySelectorAll('.jobs-stat[data-filter]');
const tableRows = document.querySelectorAll('tbody tr[data-status]');
let activeFilter = localStorage.getItem('jobsFilter') || 'all';
function applyFilter(filter) {
activeFilter = filter;
localStorage.setItem('jobsFilter', filter);
statCards.forEach(card => {
card.classList.toggle('active', card.dataset.filter === filter);
});
let visibleCount = 0;
tableRows.forEach(row => {
let show = false;
if (filter === 'all') {
show = true;
} else if (filter === 'failed') {
show = row.dataset.status.startsWith('failed');
} else if (filter === 'scheduled') {
show = row.dataset.scheduled === '1';
} else {
show = row.dataset.status === filter;
}
row.style.display = show ? '' : 'none';
if (show) visibleCount++;
});
// Show "no matches" message if filter yields zero results
let noMatch = document.getElementById('no-filter-match');
if (!noMatch && visibleCount === 0) {
const tbody = document.querySelector('tbody');
noMatch = document.createElement('tr');
noMatch.id = 'no-filter-match';
noMatch.innerHTML = '<td colspan="6" style="text-align:center;padding:32px;color:var(--text-muted);">No jobs match this filter.</td>';
tbody.appendChild(noMatch);
} else if (noMatch && visibleCount > 0) {
noMatch.remove();
}
}
statCards.forEach(card => {
card.addEventListener('click', () => {
const filter = card.dataset.filter;
// Toggle off if clicking the already-active filter
applyFilter(filter === activeFilter ? 'all' : filter);
});
});
// Apply persisted filter on load
if (activeFilter !== 'all') {
applyFilter(activeFilter);
}
</script>
{% endblock %}