vSphere-Backup-Manager/templates/vms.html

573 lines
23 KiB
HTML

{% extends "base.html" %}
{% set active_page = 'vms' %}
{% block title %}Virtual Machines — vSphere Backup Manager{% endblock %}
{% block head %}
<style>
.vms-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
margin-top: 28px;
}
.vm-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: default;
position: relative;
overflow: visible;
box-shadow: var(--shadow);
backdrop-filter: blur(8px);
}
.vm-card::before {
content: '';
position: absolute; top: 0; left: 0; right: 0; height: 3px;
background: var(--accent-gradient);
opacity: 0;
transition: opacity 0.3s;
}
.vm-card:hover {
border-color: var(--border-bright);
transform: translateY(-4px) scale(1.01);
box-shadow: var(--shadow-hover);
}
.vm-card:hover::before { opacity: 1; }
/* ── Batch selection mode ── */
.vm-card.selectable { cursor: pointer; user-select: none; }
.vm-card.selected {
border-color: var(--accent) !important;
background: rgba(99,102,241,0.07);
transform: translateY(-2px) scale(1.005) !important;
box-shadow: 0 0 0 2px rgba(99,102,241,0.2), var(--shadow) !important;
}
.vm-card.selected::before { opacity: 1; }
.vm-check {
display: none;
position: absolute; top: 14px; right: 14px;
width: 22px; height: 22px;
border: 2px solid rgba(255,255,255,0.15);
border-radius: 6px;
background: rgba(8,10,16,0.6);
align-items: center; justify-content: center;
transition: all 0.15s ease;
z-index: 10;
flex-shrink: 0;
}
.vm-card.selectable .vm-check { display: flex; }
.vm-card.selected .vm-check {
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 8px rgba(99,102,241,0.4);
}
.vm-check svg { opacity: 0; transition: opacity 0.15s; }
.vm-card.selected .vm-check svg { opacity: 1; }
/* ── Floating batch bar ── */
.batch-bar {
position: fixed; bottom: 0; left: var(--sidebar-w); right: 0;
background: rgba(10, 12, 20, 0.96);
backdrop-filter: blur(24px);
border-top: 1px solid rgba(99,102,241,0.25);
padding: 14px 40px;
display: flex; align-items: center; justify-content: space-between;
gap: 16px;
transform: translateY(100%);
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 200;
box-shadow: 0 -12px 40px rgba(0,0,0,0.5);
}
.batch-bar.visible { transform: translateY(0); }
.batch-bar-info { display: flex; align-items: center; gap: 14px; }
.batch-count {
font-size: 24px; font-weight: 800; color: var(--accent);
letter-spacing: -0.03em; min-width: 2ch; text-align: right;
}
.batch-divider { width: 1px; height: 32px; background: var(--border); }
.batch-label { font-size: 14px; color: var(--text-secondary); font-weight: 500; }
.batch-vm-list {
font-size: 12px; color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
max-width: 400px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.batch-actions { display: flex; align-items: center; gap: 10px; }
.vm-card-header {
display: flex; align-items: flex-start; justify-content: space-between;
margin-bottom: 18px;
}
.vm-name {
font-size: 16px; font-weight: 700; word-break: break-word;
flex: 1; padding-right: 10px;
letter-spacing: -0.01em;
}
.vm-os { font-size: 12px; color: var(--text-muted); margin-top: 3px; font-weight: 500; }
.vm-stats {
display: grid; grid-template-columns: 1fr 1fr;
gap: 12px; margin: 18px 0;
}
.stat {
background: rgba(255,255,255,0.01);
border: 1px solid rgba(255,255,255,0.03);
border-radius: var(--radius-sm);
padding: 10px 14px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.stat-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; font-weight: 700; }
.stat-value { font-size: 14px; font-weight: 600; margin-top: 3px; }
.vm-footer {
display: flex; gap: 10px; margin-top: 20px;
flex-wrap: wrap;
}
.vm-footer .btn { flex: 1; min-width: 110px; justify-content: center; }
.search-bar {
display: flex; align-items: center; gap: 12px;
background: rgba(8, 10, 16, 0.4);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 16px;
max-width: 340px;
transition: all 0.2s ease;
}
.search-bar:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
background: rgba(8, 10, 16, 0.6);
}
.search-bar input {
background: none; border: none; outline: none;
color: var(--text-primary); font-size: 14px; width: 100%;
}
.search-bar input::placeholder { color: var(--text-muted); }
.filter-row {
display: flex; align-items: center; gap: 12px;
margin-bottom: 4px; flex-wrap: wrap;
}
.filter-chip {
padding: 6px 16px; border-radius: 100px;
font-size: 13px; font-weight: 600; cursor: pointer;
border: 1px solid var(--border); background: rgba(255,255,255,0.01);
color: var(--text-secondary); transition: all .2s cubic-bezier(0.4, 0, 0.2, 1);
}
.filter-chip:hover { border-color: var(--border-bright); color: var(--text-primary); transform: translateY(-1px); }
.filter-chip.active { background: rgba(99, 102, 241, .15); border-color: var(--accent); color: #a5b4fc; box-shadow: 0 4px 12px rgba(99,102,241,0.1); }
.stat-chips {
display: flex; gap: 12px; align-items: center;
flex-wrap: wrap; margin-bottom: 24px;
}
.stat-chip {
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);
}
.stat-chip-value { font-size: 24px; font-weight: 800; letter-spacing: -0.02em; }
.stat-chip-label { font-size: 11px; color: var(--text-muted); margin-top: 2px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
.empty-state {
text-align: center; padding: 64px 20px;
color: var(--text-secondary);
}
.empty-state .empty-icon { font-size: 52px; margin-bottom: 16px; opacity: 0.4; }
/* ── VM Quick-Action Menu ── */
.vm-actions-btn {
position: absolute; top: 14px; right: 14px;
width: 30px; height: 30px;
display: flex; align-items: center; justify-content: center;
background: rgba(8,10,16,0.5);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: all .15s ease;
z-index: 10;
font-size: 16px;
}
.vm-card:hover .vm-actions-btn { opacity: 1; }
.vm-actions-btn:hover {
background: rgba(99,102,241,0.15);
border-color: var(--accent);
color: var(--text-primary);
}
.vm-context-menu {
position: absolute; top: 50px; right: 14px;
background: rgba(18, 22, 35, 0.98);
backdrop-filter: blur(24px);
border: 1px solid var(--border-bright);
border-radius: var(--radius-sm);
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
min-width: 200px;
z-index: 100;
display: none;
overflow: hidden;
animation: menu-in .15s ease;
}
@keyframes menu-in { from { opacity:0; transform:scale(0.95) translateY(-4px); } to { opacity:1; transform:scale(1) translateY(0); } }
.vm-context-menu.open { display: block; }
.ctx-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px;
font-size: 13px; font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all .1s ease;
text-decoration: none;
border: none; background: none; width: 100%;
text-align: left;
}
.ctx-item:hover { background: rgba(99,102,241,0.1); color: var(--text-primary); }
.ctx-item.danger:hover { background: rgba(239,68,68,0.1); color: #fca5a5; }
.ctx-item-icon { width: 18px; text-align: center; font-size: 14px; flex-shrink: 0; }
.ctx-divider { height: 1px; background: var(--border); margin: 4px 0; }
.ctx-item .ctx-kbd { margin-left: auto; font-size: 10px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
</style>
{% endblock %}
{% block content %}
<div class="topbar">
<div>
<div class="topbar-title">Virtual Machines</div>
<div class="topbar-subtitle">
{{ vms|length }} VM{% if vms|length != 1 %}s{% endif %} on <strong>{{ session.get('host') }}</strong>
{% if cache_age is not none %}
&nbsp;·&nbsp;
<span class="text-muted" style="font-size:12px;">
{% if cache_age < 5 %}just refreshed{% else %}data from {{ cache_age }}s ago{% endif %}
</span>
{% endif %}
</div>
</div>
<div class="topbar-actions">
<a href="/vms?refresh=1" class="btn btn-ghost 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;"><path d="M23 4v6h-6"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Refresh
</a>
<button id="selectModeBtn" class="btn btn-secondary btn-sm" onclick="toggleSelectMode()">
<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;"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
Select VMs
</button>
<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">
{% if error %}
<div class="alert alert-danger">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px;"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
{{ error }}
</div>
{% endif %}
<!-- Summary chips -->
<div class="stat-chips">
<div class="stat-chip">
<div class="stat-chip-value" style="color:var(--text-primary)">{{ vms|length }}</div>
<div class="stat-chip-label">Total VMs</div>
</div>
<div class="stat-chip">
<div class="stat-chip-value" style="color:var(--success)">{{ vms|selectattr('power_state','equalto','poweredOn')|list|length }}</div>
<div class="stat-chip-label">Powered On</div>
</div>
<div class="stat-chip">
<div class="stat-chip-value" style="color:var(--danger)">{{ vms|selectattr('power_state','equalto','poweredOff')|list|length }}</div>
<div class="stat-chip-label">Powered Off</div>
</div>
<div class="stat-chip">
<div class="stat-chip-value" style="color:var(--warning)">{{ vms|selectattr('power_state','equalto','suspended')|list|length }}</div>
<div class="stat-chip-label">Suspended</div>
</div>
</div>
<!-- Filter / Search row -->
<div class="filter-row" style="margin-bottom:16px;">
<div class="search-bar">
<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="color: var(--text-muted);"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="vmSearch" placeholder="Search VMs…" oninput="filterVMs()" />
</div>
<button class="filter-chip active" id="filter-all" onclick="setFilter('all')">All</button>
<button class="filter-chip" id="filter-poweredOn" onclick="setFilter('poweredOn')">
<span style="display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--success); margin-right:6px; vertical-align:middle;"></span>On
</button>
<button class="filter-chip" id="filter-poweredOff" onclick="setFilter('poweredOff')">
<span style="display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--danger); margin-right:6px; vertical-align:middle;"></span>Off
</button>
<button class="filter-chip" id="filter-suspended" onclick="setFilter('suspended')">
<span style="display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--warning); margin-right:6px; vertical-align:middle;"></span>Suspended
</button>
<button class="filter-chip" id="filter-unscheduled" onclick="setFilter('unscheduled')">
<span style="display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--accent-2); margin-right:6px; vertical-align:middle;"></span>Unscheduled
</button>
</div>
<!-- VM Cards grid -->
<div class="vms-grid" id="vmGrid">
{% for vm in vms %}
{% set is_scheduled = vm.name in scheduled_vms %}
<div class="vm-card"
data-name="{{ vm.name|lower }}"
data-vmname="{{ vm.name }}"
data-power="{{ vm.power_state }}"
data-scheduled="{{ '1' if is_scheduled else '0' }}"
onclick="handleCardClick(this, event)">
<!-- Selection checkbox overlay -->
<div class="vm-check">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<!-- Quick-action menu button -->
<button class="vm-actions-btn" onclick="event.stopPropagation(); toggleVmMenu(this)" title="Actions">
</button>
<div class="vm-context-menu" onclick="event.stopPropagation()">
<a class="ctx-item" href="/jobs/create?vm={{ vm.name|urlencode }}">
<span class="ctx-item-icon"></span> Backup Now
</a>
<a class="ctx-item" href="/jobs/create?vm={{ vm.name|urlencode }}&schedule=1">
<span class="ctx-item-icon">📅</span> Schedule Backup
</a>
<div class="ctx-divider"></div>
<button class="ctx-item" data-copy="{{ vm.name }}">
<span class="ctx-item-icon">📋</span> Copy VM Name
</button>
{% if vm.ip_address %}
<button class="ctx-item" data-copy="{{ vm.ip_address }}">
<span class="ctx-item-icon">🌐</span> Copy IP Address
</button>
{% endif %}
</div>
<div class="vm-card-header">
<div>
<div class="vm-name">
{{ vm.name }}
{% if is_scheduled %}
<span style="display:inline-block; font-size: 11px; margin-left: 6px; color: var(--accent-2); vertical-align: middle;" title="Has active schedule">&#x1F4C5;&#xFE0E;</span>
{% endif %}
</div>
<div class="vm-os">{{ vm.guest_os or 'Unknown OS' }}</div>
</div>
{% if vm.power_state == 'poweredOn' %}
<span class="badge badge-green">On</span>
{% elif vm.power_state == 'poweredOff' %}
<span class="badge badge-red">Off</span>
{% elif vm.power_state == 'suspended' %}
<span class="badge badge-yellow">Suspended</span>
{% else %}
<span class="badge badge-gray">{{ vm.power_state }}</span>
{% endif %}
</div>
<div class="vm-stats">
<div class="stat">
<div class="stat-label">CPUs</div>
<div class="stat-value">{{ vm.num_cpu or '—' }}</div>
</div>
<div class="stat">
<div class="stat-label">Memory</div>
<div class="stat-value">{% if vm.memory_mb %}{{ (vm.memory_mb / 1024)|round(1) }} GB{% else %}—{% endif %}</div>
</div>
<div class="stat">
<div class="stat-label">Disk Used</div>
<div class="stat-value">{{ vm.committed_gb or 0 }} GB</div>
</div>
<div class="stat">
<div class="stat-label">IP Address</div>
<div class="stat-value mono" style="font-size:12px;">{{ vm.ip_address or '—' }}</div>
</div>
</div>
{% if vm.datastores %}
<div class="text-small text-muted mb-2" style="display:flex; align-items:center; gap:6px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--text-muted);"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/></svg>
{{ vm.datastores|join(', ') }}
</div>
{% endif %}
<div class="vm-footer" id="footer-{{ loop.index }}">
<a href="/jobs/create?vm={{ vm.name|urlencode }}"
class="btn btn-primary btn-sm" onclick="event.stopPropagation()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
Backup Now
</a>
<a href="/jobs/create?vm={{ vm.name|urlencode }}&schedule=1"
class="btn btn-secondary btn-sm" onclick="event.stopPropagation()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Schedule
</a>
</div>
</div>
{% else %}
<div class="empty-state" style="grid-column:1/-1;">
<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);"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<p>No virtual machines found.</p>
</div>
{% endfor %}
</div>
</div>
<!-- ── Floating Batch Action Bar ── -->
<div class="batch-bar" id="batchBar">
<div class="batch-bar-info">
<div class="batch-count" id="batchCount">0</div>
<div class="batch-divider"></div>
<div>
<div class="batch-label">VMs selected</div>
<div class="batch-vm-list" id="batchVmList"></div>
</div>
</div>
<div class="batch-actions">
<button class="btn btn-ghost btn-sm" onclick="selectAllVisible()">Select All</button>
<button class="btn btn-ghost btn-sm" onclick="clearSelection()">Clear</button>
<button class="btn btn-primary" onclick="batchBackup()">
<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;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
Backup <span id="batchCountBtn">0</span> VMs
</button>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let activeFilter = 'all';
let selectMode = false;
let selected = new Set();
function setFilter(filter) {
activeFilter = filter;
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
document.getElementById('filter-' + filter).classList.add('active');
filterVMs();
}
function filterVMs() {
const q = document.getElementById('vmSearch').value.toLowerCase();
document.querySelectorAll('.vm-card').forEach(card => {
let matchFilter = false;
if (activeFilter === 'all') {
matchFilter = true;
} else if (activeFilter === 'unscheduled') {
matchFilter = card.dataset.scheduled === '0';
} else {
matchFilter = card.dataset.power === activeFilter;
}
const matchSearch = card.dataset.name.includes(q);
card.style.display = (matchFilter && matchSearch) ? '' : 'none';
});
}
// ── Batch selection ──────────────────────────────────────────────────────────
function toggleSelectMode() {
selectMode = !selectMode;
const btn = document.getElementById('selectModeBtn');
if (selectMode) {
btn.style.background = 'rgba(99,102,241,0.15)';
btn.style.borderColor = 'var(--accent)';
btn.style.color = '#a5b4fc';
} else {
btn.style.background = '';
btn.style.borderColor = '';
btn.style.color = '';
clearSelection();
}
document.querySelectorAll('.vm-card').forEach(card => {
card.classList.toggle('selectable', selectMode);
});
}
function handleCardClick(card, e) {
if (!selectMode) return;
const vmName = card.dataset.vmname;
if (selected.has(vmName)) {
selected.delete(vmName);
card.classList.remove('selected');
} else {
selected.add(vmName);
card.classList.add('selected');
}
updateBatchBar();
}
function selectAllVisible() {
document.querySelectorAll('.vm-card').forEach(card => {
if (card.style.display === 'none') return;
const vmName = card.dataset.vmname;
selected.add(vmName);
card.classList.add('selected');
});
updateBatchBar();
}
function clearSelection() {
selected.clear();
document.querySelectorAll('.vm-card').forEach(c => c.classList.remove('selected'));
updateBatchBar();
}
function updateBatchBar() {
const n = selected.size;
document.getElementById('batchCount').textContent = n;
document.getElementById('batchCountBtn').textContent = n;
const names = [...selected].join(', ');
document.getElementById('batchVmList').textContent = names;
document.getElementById('batchBar').classList.toggle('visible', n > 0);
}
function batchBackup() {
if (!selected.size) return;
const params = new URLSearchParams();
selected.forEach(vm => params.append('vms', vm));
window.location.href = '/jobs/batch?' + params.toString();
}
// ── VM Context Menu ───────────────────────────────────────────────────────
let openMenu = null;
function toggleVmMenu(btn) {
closeAllMenus();
const menu = btn.nextElementSibling;
menu.classList.add('open');
openMenu = menu;
}
function closeAllMenus() {
document.querySelectorAll('.vm-context-menu.open').forEach(m => m.classList.remove('open'));
openMenu = null;
}
document.addEventListener('click', function(e) {
if (openMenu && !openMenu.contains(e.target)) closeAllMenus();
});
// Hide action button when in select mode
const origToggle = toggleSelectMode;
toggleSelectMode = function() {
origToggle();
document.querySelectorAll('.vm-actions-btn').forEach(b => {
b.style.display = selectMode ? 'none' : '';
});
if (selectMode) closeAllMenus();
};
</script>
{% endblock %}