456 lines
18 KiB
HTML
456 lines
18 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: hidden;
|
|
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; }
|
|
</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 %}
|
|
·
|
|
<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>
|
|
</div>
|
|
|
|
<!-- VM Cards grid -->
|
|
<div class="vms-grid" id="vmGrid">
|
|
{% for vm in vms %}
|
|
<div class="vm-card"
|
|
data-name="{{ vm.name|lower }}"
|
|
data-vmname="{{ vm.name }}"
|
|
data-power="{{ vm.power_state }}"
|
|
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>
|
|
|
|
<div class="vm-card-header">
|
|
<div>
|
|
<div class="vm-name">{{ vm.name }}</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 => {
|
|
const matchFilter = activeFilter === 'all' || 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();
|
|
}
|
|
</script>
|
|
{% endblock %}
|