vSphere-Backup-Manager/templates/base.html

731 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}vSphere Backup Manager{% endblock %}</title>
<meta name="description" content="Enterprise vSphere VM backup management — schedule, monitor and manage VM backups." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root {
--bg-base: #080a10;
--bg-surface: #0e111a;
--bg-card: rgba(18, 22, 35, 0.6);
--bg-card-hover: rgba(24, 29, 45, 0.85);
--border: rgba(255, 255, 255, 0.05);
--border-bright: rgba(124, 107, 255, 0.25);
--accent: #6366f1; /* Indigo */
--accent-2: #06b6d4; /* Cyan */
--accent-gradient: linear-gradient(135deg, #6366f1 0%, #06b6d4 100%);
--accent-glow: rgba(99, 102, 241, 0.25);
--success: #10b981; /* Emerald */
--warning: #f59e0b; /* Amber */
--danger: #ef4444; /* Red */
--text-primary: #f8fafc;
--text-secondary:#94a3b8;
--text-muted: #64748b;
--sidebar-w: 260px;
--radius: 16px;
--radius-sm: 10px;
--shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.5), 0 1px 1px rgba(255, 255, 255, 0.05) inset;
--shadow-hover: 0 20px 40px -15px rgba(99, 102, 241, 0.3), 0 1px 2px rgba(255, 255, 255, 0.1) inset;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: radial-gradient(circle at 50% 0%, #151926 0%, var(--bg-base) 80%);
color: var(--text-primary);
display: flex;
min-height: 100vh;
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* ── Sidebar ── */
.sidebar {
width: var(--sidebar-w);
min-height: 100vh;
background: rgba(14, 17, 26, 0.85);
backdrop-filter: blur(20px);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 0;
position: fixed;
left: 0; top: 0; bottom: 0;
z-index: 100;
transition: all 0.3s ease;
}
.sidebar-logo {
padding: 28px 24px;
border-bottom: 1px solid var(--border);
}
.sidebar-logo .logo-mark {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 40px; height: 40px;
background: var(--accent-gradient);
border-radius: var(--radius-sm);
display: flex; align-items: center; justify-content: center;
font-size: 20px;
box-shadow: 0 0 20px var(--accent-glow);
transition: transform 0.3s ease;
}
.sidebar:hover .logo-icon {
transform: rotate(5deg) scale(1.05);
}
.logo-text { font-size: 16px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.02em; }
.logo-sub { font-size: 11px; color: var(--text-secondary); margin-top: 1px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
.sidebar-nav { flex: 1; padding: 24px 16px; }
.nav-section-label {
font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
color: var(--text-muted); text-transform: uppercase;
padding: 0 12px; margin: 16px 0 8px;
}
.nav-link {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px; border-radius: var(--radius-sm);
color: var(--text-secondary); text-decoration: none;
font-size: 14px; font-weight: 500;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
margin-bottom: 4px;
border: 1px solid transparent;
}
.nav-link:hover {
background: rgba(255, 255, 255, 0.02);
color: var(--text-primary);
border-color: rgba(255, 255, 255, 0.03);
transform: translateX(4px);
}
.nav-link.active {
background: rgba(99, 102, 241, 0.1);
color: #a5b4fc;
border-color: rgba(99, 102, 241, 0.2);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.05);
font-weight: 600;
}
.nav-link .icon { width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; }
.sidebar-footer {
padding: 20px 24px;
border-top: 1px solid var(--border);
background: rgba(8, 10, 16, 0.4);
}
.server-badge {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
font-size: 12px;
margin-bottom: 12px;
}
.server-badge .server-host { font-weight: 600; color: var(--accent-2); word-break: break-all; margin-bottom: 2px; }
.server-badge .server-user { color: var(--text-muted); }
/* ── Main content ── */
.main {
margin-left: var(--sidebar-w);
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
transition: margin-left 0.3s ease;
}
.topbar {
padding: 24px 40px;
border-bottom: 1px solid var(--border);
background: rgba(8, 10, 16, 0.7);
backdrop-filter: blur(16px);
display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 50;
}
.topbar-title { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
.topbar-subtitle { font-size: 13px; color: var(--text-secondary); margin-top: 4px; }
.topbar-actions { display: flex; align-items: center; gap: 12px; }
.content { padding: 32px 40px; flex: 1; max-width: 1400px; width: 100%; margin: 0 auto; }
/* ── Buttons ── */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
padding: 10px 20px; border-radius: var(--radius-sm);
font-size: 13.5px; font-weight: 600;
cursor: pointer; border: 1px solid transparent; text-decoration: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
line-height: 1.2;
white-space: nowrap;
}
.btn-primary {
background: var(--accent-gradient);
color: #ffffff;
box-shadow: 0 4px 14px var(--accent-glow);
}
.btn-primary:hover {
transform: translateY(-1.5px);
box-shadow: 0 6px 20px var(--accent-glow);
filter: brightness(1.05);
}
.btn-primary:active { transform: translateY(0); }
.btn-secondary {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-1px);
}
.btn-danger {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.btn-danger:hover {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.35);
color: #ffffff;
transform: translateY(-1px);
}
.btn-sm { padding: 8px 14px; font-size: 12.5px; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text-secondary); }
.btn-ghost:hover { border-color: var(--border-bright); color: var(--text-primary); background: rgba(255,255,255,0.01); }
/* ── Cards ── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
transition: all 0.25s ease;
}
.card:hover {
border-color: var(--border-bright);
box-shadow: var(--shadow-hover);
}
.card-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
}
.card-title { font-size: 16px; font-weight: 600; }
.card-body { padding: 24px; }
/* ── Badges / Status chips ── */
.badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 12px; border-radius: 100px;
font-size: 11px; font-weight: 600; letter-spacing: 0.03em;
border: 1px solid transparent;
text-transform: uppercase;
}
.badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.badge-green { background: rgba(16, 185, 129, 0.08); border-color: rgba(16, 185, 129, 0.2); color: #34d399; }
.badge-green::before { background: #10b981; box-shadow: 0 0 8px #10b981; }
.badge-red { background: rgba(239, 68, 68, 0.08); border-color: rgba(239, 68, 68, 0.2); color: #f87171; }
.badge-red::before { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
.badge-yellow { background: rgba(245, 158, 11, 0.08); border-color: rgba(245, 158, 11, 0.2); color: #fbbf24; }
.badge-yellow::before { background: #f59e0b; box-shadow: 0 0 8px #f59e0b; }
.badge-gray { background: rgba(148, 163, 184, 0.08); border-color: rgba(148, 163, 184, 0.2); color: #cbd5e1; }
.badge-gray::before { background: #94a3b8; }
.badge-purple { background: rgba(99, 102, 241, 0.08); border-color: rgba(99, 102, 241, 0.25); color: #c7d2fe; }
.badge-purple::before { background: #6366f1; box-shadow: 0 0 8px #6366f1; }
/* ── Form elements ── */
.form-group { margin-bottom: 20px; }
.form-label {
display: block; font-size: 13px; font-weight: 600;
color: var(--text-secondary); margin-bottom: 8px;
letter-spacing: 0.01em;
}
.form-control {
width: 100%;
background: rgba(8, 10, 16, 0.5);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: 12px 16px;
font-size: 14px;
font-family: inherit;
transition: all 0.2s ease;
outline: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1) inset;
}
.form-control:hover {
border-color: rgba(255,255,255,0.1);
}
.form-control:focus {
border-color: var(--accent);
background: rgba(8, 10, 16, 0.8);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15), 0 2px 4px rgba(0,0,0,0.15) inset;
}
.form-control::placeholder { color: var(--text-muted); }
select.form-control { cursor: pointer; }
select.form-control option { background: var(--bg-surface); color: var(--text-primary); }
.form-check { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.form-check input[type=checkbox] {
accent-color: var(--accent);
width: 18px; height: 18px;
cursor: pointer;
border-radius: 4px;
}
.form-check label { font-size: 13.5px; color: var(--text-secondary); cursor: pointer; user-select: none; }
/* ── Alert / Flash ── */
.alert {
padding: 14px 20px; border-radius: var(--radius-sm);
margin-bottom: 24px; font-size: 14px;
display: flex; align-items: center; gap: 12px;
border: 1px solid transparent;
box-shadow: 0 4px 15px rgba(0,0,0,0.15);
backdrop-filter: blur(8px);
}
.alert-danger { background: rgba(239, 68, 68, 0.08); border-color: rgba(239, 68, 68, 0.25); color: #fca5a5; }
.alert-success { background: rgba(16, 185, 129, 0.08); border-color: rgba(16, 185, 129, 0.25); color: #a7f3d0; }
.alert-info { background: rgba(6, 182, 212, 0.08); border-color: rgba(6, 182, 212, 0.2); color: #c5f2f7; }
/* ── Table ── */
table { width: 100%; border-collapse: collapse; }
th {
text-align: left; padding: 14px 20px;
font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--text-muted); border-bottom: 1px solid var(--border);
}
td { padding: 16px 20px; border-bottom: 1px solid var(--border); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr { transition: background 0.15s; }
tr:hover td { background: rgba(255, 255, 255, 0.015); }
/* ── Utilities ── */
.text-muted { color: var(--text-muted); }
.text-secondary { color: var(--text-secondary); }
.text-small { font-size: 12.5px; }
.mono { font-family: 'JetBrains Mono', monospace; font-size: 13px; }
.flex { display: flex; }
.flex-center { display: flex; align-items: center; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.mt-1 { margin-top: 6px; }
.mt-2 { margin-top: 12px; }
.mt-3 { margin-top: 20px; }
.mb-1 { margin-bottom: 6px; }
.mb-2 { margin-bottom: 12px; }
/* ── Spinner ── */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
width: 18px; height: 18px;
border: 2.5px solid rgba(255,255,255,.15);
border-top-color: var(--accent-2);
border-radius: 50%;
animation: spin .7s linear infinite;
display: inline-block;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); }
/* ── Toast Notifications ── */
.toast-container {
position: fixed; bottom: 24px; right: 24px; z-index: 10000;
display: flex; flex-direction: column-reverse; gap: 10px;
pointer-events: none;
}
.toast {
pointer-events: auto;
background: rgba(18, 22, 35, 0.95);
backdrop-filter: blur(24px);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px 20px;
min-width: 300px; max-width: 420px;
display: flex; align-items: center; gap: 12px;
font-size: 13.5px; font-weight: 500;
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
transform: translateX(120%);
animation: toast-in .35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.toast.removing { animation: toast-out .25s ease-in forwards; }
@keyframes toast-in { to { transform: translateX(0); } }
@keyframes toast-out { to { transform: translateX(120%); opacity: 0; } }
.toast-icon { font-size: 18px; flex-shrink: 0; }
.toast-body { flex: 1; }
.toast-title { font-weight: 700; font-size: 13px; margin-bottom: 2px; }
.toast-msg { color: var(--text-secondary); font-size: 12.5px; }
.toast-close {
background: none; border: none; color: var(--text-muted); cursor: pointer;
padding: 4px; border-radius: 4px; line-height: 1; flex-shrink: 0;
}
.toast-close:hover { background: rgba(255,255,255,0.06); color: var(--text-primary); }
.toast-success { border-left: 3px solid var(--success); }
.toast-error { border-left: 3px solid var(--danger); }
.toast-info { border-left: 3px solid var(--accent-2); }
.toast-warning { border-left: 3px solid var(--warning); }
/* ── Command Palette ── */
.cmd-overlay {
position: fixed; inset: 0; z-index: 20000;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(6px);
display: none; align-items: flex-start; justify-content: center;
padding-top: min(20vh, 180px);
}
.cmd-overlay.open { display: flex; }
.cmd-palette {
width: 560px; max-width: 90vw;
background: rgba(18, 22, 35, 0.98);
border: 1px solid var(--border-bright);
border-radius: var(--radius);
box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(99,102,241,0.1);
overflow: hidden;
animation: cmd-in .2s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes cmd-in { from { opacity:0; transform:scale(0.96) translateY(-10px); } to { opacity:1; transform:scale(1) translateY(0); } }
.cmd-input-wrap {
display: flex; align-items: center; gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.cmd-input-wrap svg { flex-shrink: 0; color: var(--text-muted); }
.cmd-input {
flex: 1; background: none; border: none; outline: none;
color: var(--text-primary); font-size: 15px; font-family: inherit;
}
.cmd-input::placeholder { color: var(--text-muted); }
.cmd-hint {
font-size: 11px; color: var(--text-muted); padding: 8px 20px 4px;
font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em;
}
.cmd-list {
max-height: 340px; overflow-y: auto;
padding: 8px 8px;
}
.cmd-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px; border-radius: var(--radius-sm);
cursor: pointer; color: var(--text-secondary);
font-size: 13.5px; font-weight: 500;
transition: all .12s ease;
}
.cmd-item:hover, .cmd-item.active {
background: rgba(99, 102, 241, 0.1);
color: var(--text-primary);
}
.cmd-item.active { border: 1px solid rgba(99, 102, 241, 0.2); }
.cmd-item-icon { width: 20px; text-align: center; font-size: 15px; flex-shrink: 0; }
.cmd-item-label { flex: 1; }
.cmd-item-kbd {
font-size: 11px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace;
background: rgba(255,255,255,0.04); padding: 2px 6px; border-radius: 4px;
border: 1px solid rgba(255,255,255,0.06);
}
.cmd-footer {
padding: 10px 20px; border-top: 1px solid var(--border);
font-size: 11px; color: var(--text-muted); display: flex; gap: 16px;
}
.cmd-footer kbd {
font-family: 'JetBrains Mono', monospace; font-size: 10px;
background: rgba(255,255,255,0.04); padding: 2px 5px; border-radius: 3px;
border: 1px solid rgba(255,255,255,0.08); margin: 0 2px;
}
/* ── Copy Tooltip ── */
.copy-feedback {
position: fixed; z-index: 15000;
background: var(--success); color: #fff;
font-size: 12px; font-weight: 600;
padding: 4px 10px; border-radius: 6px;
pointer-events: none;
animation: copy-pop .4s ease forwards;
}
@keyframes copy-pop {
0% { opacity: 0; transform: translateY(4px) scale(0.9); }
30% { opacity: 1; transform: translateY(-6px) scale(1); }
100% { opacity: 0; transform: translateY(-18px) scale(0.95); }
}
/* ── Skeleton Loading ── */
.skeleton {
background: linear-gradient(90deg, rgba(255,255,255,0.03) 25%, rgba(255,255,255,0.06) 50%, rgba(255,255,255,0.03) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-sm);
}
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.skeleton-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow);
}
.skeleton-line { height: 14px; margin-bottom: 10px; border-radius: 6px; }
.skeleton-line.w-60 { width: 60%; }
.skeleton-line.w-40 { width: 40%; }
.skeleton-line.w-80 { width: 80%; }
.skeleton-line.h-20 { height: 20px; }
.skeleton-line.h-8 { height: 8px; margin-bottom: 14px; }
.skeleton-block { height: 48px; margin-bottom: 12px; border-radius: var(--radius-sm); }
/* ── Keyboard Shortcut Hints ── */
.kbd-hint {
display: inline-block;
font-family: 'JetBrains Mono', monospace;
font-size: 10px; font-weight: 600;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
padding: 1px 5px; border-radius: 3px;
color: var(--text-muted);
margin-left: 6px; vertical-align: middle;
}
</style>
{% block head %}{% endblock %}
</head>
<body>
{% if session.get('host') %}
<aside class="sidebar">
<div class="sidebar-logo">
<div class="logo-mark">
<div class="logo-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color: #ffffff; display: block;">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<path d="M8 11a4 4 0 0 1 6-3.46M16 13a4 4 0 0 1-6 3.46"/>
<path d="M12 6h2v2"/>
<path d="M12 18H10v-2"/>
</svg>
</div>
<div>
<div class="logo-text">vSphere Backup</div>
<div class="logo-sub">Manager</div>
</div>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section-label">Navigation</div>
<a href="/vms" class="nav-link {% if active_page == 'vms' %}active{% endif %}">
<span class="icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
</span>
Virtual Machines
</a>
<a href="/jobs" class="nav-link {% if active_page == 'jobs' %}active{% endif %}">
<span class="icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
</span>
Backup Jobs
</a>
<a href="/jobs/create" class="nav-link {% if active_page == 'create_job' %}active{% endif %}">
<span class="icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
</span>
Create Job
</a>
<a href="/nfs" class="nav-link {% if active_page == 'nfs' %}active{% endif %}">
<span class="icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</span>
NFS Manager
</a>
</nav>
<div class="sidebar-footer">
<div class="server-badge">
<div class="server-host">{{ session.get('host', '—') }}</div>
<div class="server-user text-small">{{ session.get('user', '') }}</div>
</div>
<a href="/logout" class="btn btn-ghost btn-sm" style="width:100%;justify-content:center;margin-top:10px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px;"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Logout
</a>
</div>
</aside>
{% endif %}
<main class="main" {% if not session.get('host') %}style="margin-left:0; align-items:center; justify-content:center;"{% else %}style="min-height:100vh;"{% endif %}>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div id="flashData" style="display:none;">{{ messages|tojson }}</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<!-- Command Palette -->
<div class="cmd-overlay" id="cmdOverlay">
<div class="cmd-palette">
<div class="cmd-input-wrap">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input class="cmd-input" id="cmdInput" type="text" placeholder="Type a command or search…" autocomplete="off" />
</div>
<div class="cmd-hint">Commands</div>
<div class="cmd-list" id="cmdList"></div>
<div class="cmd-footer">
<span><kbd>↑↓</kbd> Navigate</span>
<span><kbd></kbd> Select</span>
<span><kbd>Esc</kbd> Close</span>
</div>
</div>
</div>
{% block scripts %}{% endblock %}
<!-- Global Scripts -->
<script>
(function() {
// ── Toast System ─────────────────────────────────────────────────────────
const toastIcons = { success: '✓', error: '✕', info: '', warning: '⚠' };
window.showToast = function(type, title, msg, duration) {
duration = duration || 4000;
const c = document.getElementById('toastContainer');
const el = document.createElement('div');
el.className = 'toast toast-' + type;
el.innerHTML = '<span class="toast-icon">' + (toastIcons[type] || '') + '</span>'
+ '<div class="toast-body"><div class="toast-title">' + title + '</div>'
+ (msg ? '<div class="toast-msg">' + msg + '</div>' : '') + '</div>'
+ '<button class="toast-close" onclick="this.closest(\'.toast\').remove()">✕</button>';
c.appendChild(el);
setTimeout(function() { el.classList.add('removing'); setTimeout(function() { el.remove(); }, 300); }, duration);
};
// Convert Flask flash messages to toasts
var flashEl = document.getElementById('flashData');
if (flashEl) {
try {
var msgs = JSON.parse(flashEl.textContent);
msgs.forEach(function(m) {
var type = m[0] === 'danger' ? 'error' : (m[0] || 'info');
showToast(type, type.charAt(0).toUpperCase() + type.slice(1), m[1]);
});
} catch(e) {}
flashEl.remove();
}
// ── Copy to Clipboard ───────────────────────────────────────────────────
document.addEventListener('click', function(e) {
var el = e.target.closest('[data-copy]');
if (!el) return;
var text = el.getAttribute('data-copy') || el.textContent;
navigator.clipboard.writeText(text).then(function() {
var fb = document.createElement('div');
fb.className = 'copy-feedback';
fb.textContent = '✓ Copied';
var r = el.getBoundingClientRect();
fb.style.left = r.left + r.width / 2 - 24 + 'px';
fb.style.top = r.top - 8 + 'px';
document.body.appendChild(fb);
setTimeout(function() { fb.remove(); }, 600);
});
});
// ── Relative Time Tooltips ──────────────────────────────────────────────
window.renderRelativeTimes = function() {
document.querySelectorAll('[data-ts]').forEach(function(el) {
var ts = parseInt(el.getAttribute('data-ts'), 10);
if (!ts) return;
var d = new Date(ts * 1000);
el.setAttribute('title', d.toLocaleString());
// If text is a raw date, replace with relative
var now = Date.now() / 1000;
var diff = now - ts;
var txt;
if (diff < 60) txt = 'just now';
else if (diff < 3600) txt = Math.floor(diff / 60) + 'm ago';
else if (diff < 86400) txt = Math.floor(diff / 3600) + 'h ago';
else if (diff < 604800) txt = Math.floor(diff / 86400) + 'd ago';
else txt = d.toLocaleDateString();
if (el.hasAttribute('data-relative')) el.textContent = txt;
});
};
renderRelativeTimes();
// ── Command Palette ─────────────────────────────────────────────────────
var cmdOverlay = document.getElementById('cmdOverlay');
var cmdInput = document.getElementById('cmdInput');
var cmdList = document.getElementById('cmdList');
var cmdActive = 0;
var cmdItems = [];
var commands = [
{ icon: '🖥', label: 'Virtual Machines', kbd: '', action: function() { window.location.href = '/vms'; } },
{ icon: '📋', label: 'Backup Jobs', kbd: '', action: function() { window.location.href = '/jobs'; } },
{ icon: '', label: 'Create New Job', kbd: 'N', action: function() { window.location.href = '/jobs/create'; } },
{ icon: '💾', label: 'NFS Manager', kbd: '', action: function() { window.location.href = '/nfs'; } },
{ icon: '🔄', label: 'Refresh Page', kbd: 'R', action: function() { location.reload(); } },
{ icon: '🚪', label: 'Logout', kbd: '', action: function() { window.location.href = '/logout'; } },
];
function renderCommands(filter) {
filter = (filter || '').toLowerCase();
cmdItems = commands.filter(function(c) { return !filter || c.label.toLowerCase().indexOf(filter) >= 0; });
cmdActive = 0;
var html = '';
cmdItems.forEach(function(c, i) {
html += '<div class="cmd-item' + (i === 0 ? ' active' : '') + '" data-idx="' + i + '">'
+ '<span class="cmd-item-icon">' + c.icon + '</span>'
+ '<span class="cmd-item-label">' + c.label + '</span>'
+ (c.kbd ? '<span class="cmd-item-kbd">' + c.kbd + '</span>' : '')
+ '</div>';
});
cmdList.innerHTML = html || '<div style="padding:16px;color:var(--text-muted);text-align:center;">No matching commands</div>';
}
function openCmd() {
cmdOverlay.classList.add('open');
cmdInput.value = '';
renderCommands('');
setTimeout(function() { cmdInput.focus(); }, 50);
}
function closeCmd() { cmdOverlay.classList.remove('open'); }
cmdInput.addEventListener('input', function() { renderCommands(this.value); });
cmdOverlay.addEventListener('click', function(e) { if (e.target === cmdOverlay) closeCmd(); });
cmdInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { closeCmd(); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); cmdActive = Math.min(cmdActive + 1, cmdItems.length - 1); updateCmdActive(); }
if (e.key === 'ArrowUp') { e.preventDefault(); cmdActive = Math.max(cmdActive - 1, 0); updateCmdActive(); }
if (e.key === 'Enter' && cmdItems[cmdActive]) { closeCmd(); cmdItems[cmdActive].action(); }
});
cmdList.addEventListener('click', function(e) {
var item = e.target.closest('.cmd-item');
if (item) { closeCmd(); cmdItems[parseInt(item.dataset.idx)].action(); }
});
function updateCmdActive() {
cmdList.querySelectorAll('.cmd-item').forEach(function(el, i) { el.classList.toggle('active', i === cmdActive); });
var active = cmdList.querySelector('.cmd-item.active');
if (active) active.scrollIntoView({ block: 'nearest' });
}
// ── Global Keyboard Shortcuts ───────────────────────────────────────────
document.addEventListener('keydown', function(e) {
// Don't fire in inputs
var tag = (e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || e.target.isContentEditable) return;
if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); openCmd(); return; }
if (e.key === 'Escape' && cmdOverlay.classList.contains('open')) { closeCmd(); return; }
if (e.key === 'n' || e.key === 'N') { window.location.href = '/jobs/create'; }
if (e.key === 'r' || e.key === 'R') { location.reload(); }
if (e.key === '/') { e.preventDefault(); var s = document.querySelector('.search-bar input, #vmSearch'); if (s) s.focus(); }
});
})();
</script>
</body>
</html>