271 lines
9.3 KiB
HTML
271 lines
9.3 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; }
|
||
|
||
.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">🔄 Refresh</a>
|
||
<a href="/jobs/create" class="btn btn-primary btn-sm">➕ Create Job</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content">
|
||
{% if error %}
|
||
<div class="alert alert-danger">⚠ {{ 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">
|
||
<span>🔍</span>
|
||
<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')">🟢 On</button>
|
||
<button class="filter-chip" id="filter-poweredOff" onclick="setFilter('poweredOff')">🔴 Off</button>
|
||
<button class="filter-chip" id="filter-suspended" onclick="setFilter('suspended')">🟡 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-power="{{ vm.power_state }}">
|
||
|
||
<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">
|
||
📦 {{ vm.datastores|join(', ') }}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="vm-footer">
|
||
<a href="/jobs/create?vm={{ vm.name|urlencode }}"
|
||
class="btn btn-primary btn-sm">⚡ Backup Now</a>
|
||
<a href="/jobs/create?vm={{ vm.name|urlencode }}&schedule=1"
|
||
class="btn btn-secondary btn-sm">🕐 Schedule</a>
|
||
</div>
|
||
</div>
|
||
{% else %}
|
||
<div class="empty-state" style="grid-column:1/-1;">
|
||
<div class="empty-icon">🖥</div>
|
||
<p>No virtual machines found.</p>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
let activeFilter = 'all';
|
||
|
||
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';
|
||
});
|
||
}
|
||
</script>
|
||
{% endblock %}
|