960 lines
40 KiB
HTML
960 lines
40 KiB
HTML
{% extends "base.html" %}
|
|
{% block content %}
|
|
|
|
{# ═══════════════════════════════════════════════════════
|
|
DASHBOARD PAGE
|
|
═══════════════════════════════════════════════════════ #}
|
|
<div id="dashboard-page" class="page active">
|
|
|
|
<!-- System Metrics -->
|
|
<div class="metrics-row">
|
|
<div class="metric-card cpu">
|
|
<div class="metric-label">CPU USAGE</div>
|
|
<div class="metric-value" id="m-cpu">{{ system.cpu_pct }}<span>%</span></div>
|
|
<div class="gauge-bar"><div class="gauge-fill" id="g-cpu" style="width:{{ system.cpu_pct }}%"></div></div>
|
|
</div>
|
|
<div class="metric-card mem">
|
|
<div class="metric-label">MEMORY</div>
|
|
<div class="metric-value" id="m-mem" style="font-size:16px;">{{ system.memory }}</div>
|
|
<div class="gauge-bar"><div class="gauge-fill" id="g-mem" style="width:{{ system.mem_pct }}%"></div></div>
|
|
</div>
|
|
<div class="metric-card disk">
|
|
<div class="metric-label">DISK /</div>
|
|
<div class="metric-value" id="m-disk" style="font-size:16px;">{{ system.disk }}</div>
|
|
<div class="gauge-bar"><div class="gauge-fill" id="g-disk" style="width:{{ system.disk_pct }}%"></div></div>
|
|
</div>
|
|
<div class="metric-card load">
|
|
<div class="metric-label">LOAD AVG</div>
|
|
<div class="metric-value" id="m-load" style="font-size:16px;">{{ system.load }}</div>
|
|
<div class="gauge-bar"><div class="gauge-fill" id="g-load" style="width:10%"></div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Stats -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="card-title"><i class="fas fa-chart-line"></i> Overview</div>
|
|
<span class="card-meta" id="docker-version">Docker {{ system.docker_v }}</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="stat-total">{{ containers|length }}</div>
|
|
<div class="stat-label">App Containers</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="stat-running">{{ running_count }}</div>
|
|
<div class="stat-label">Running</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="stat-users">{{ users|length }}</div>
|
|
<div class="stat-label">Linux Users</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="stat-local-bk">{{ backups|length }}</div>
|
|
<div class="stat-label">Local Backups</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="stat-vm-bk">{{ vm_backups|length }}</div>
|
|
<div class="stat-label">VM Backups</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- App Containers with Live Stats -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="card-title"><i class="fas fa-cubes"></i> App Containers</div>
|
|
<span class="card-meta">Auto-refresh every 15s</span>
|
|
</div>
|
|
<div style="overflow-x:auto;">
|
|
<table class="ct-table" id="app-containers-table">
|
|
<thead>
|
|
<tr>
|
|
<th>NAME</th>
|
|
<th>STATUS</th>
|
|
<th>CPU</th>
|
|
<th>MEMORY</th>
|
|
<th>NET I/O</th>
|
|
<th>DISK I/O</th>
|
|
<th>IMAGE</th>
|
|
<th>PORTS</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="app-containers-body">
|
|
{% for c in containers %}
|
|
<tr>
|
|
<td class="ct-name">{{ c.name }}</td>
|
|
<td>
|
|
{% if 'Up' in c.status %}
|
|
<span class="badge badge-run">Running</span>
|
|
{% else %}
|
|
<span class="badge badge-stop">Stopped</span>
|
|
{% endif %}
|
|
</td>
|
|
<td><span class="stat-pct" data-ctr="{{ c.name }}" data-stat="cpu">—</span></td>
|
|
<td>
|
|
<div class="stat-bar-wrap">
|
|
<div class="stat-bar-bg">
|
|
<div class="stat-bar-fill" data-ctr="{{ c.name }}" data-stat="mem_bar" style="width:0%"></div>
|
|
</div>
|
|
<span class="stat-pct" data-ctr="{{ c.name }}" data-stat="mem_pct">—</span>
|
|
</div>
|
|
</td>
|
|
<td><span class="stat-pct" data-ctr="{{ c.name }}" data-stat="net" style="color:var(--cyan)">—</span></td>
|
|
<td><span class="stat-pct" data-ctr="{{ c.name }}" data-stat="block" style="color:var(--yellow)">—</span></td>
|
|
<td class="ct-image">{{ c.image }}</td>
|
|
<td class="ct-ports">{{ c.ports or '—' }}</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr><td colspan="8"><div class="empty-state"><i class="fas fa-inbox"></i>No containers found</div></td></tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ═══════════════════════════════════════════════════════
|
|
ALL CONTAINERS PAGE (root + all users)
|
|
═══════════════════════════════════════════════════════ #}
|
|
<div id="containers-page" class="page">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="card-title"><i class="fas fa-layer-group"></i> All Containers</div>
|
|
<div style="display:flex;gap:8px;">
|
|
<span class="card-meta" id="all-ctr-meta">Loading…</span>
|
|
<button class="btn btn-ghost btn-sm" onclick="loadAllContainers()"><i class="fas fa-sync-alt"></i> Refresh</button>
|
|
</div>
|
|
</div>
|
|
<div style="overflow-x:auto;">
|
|
<table class="ct-table">
|
|
<thead>
|
|
<tr>
|
|
<th>NAME</th>
|
|
<th>OWNER</th>
|
|
<th>STATUS</th>
|
|
<th>CPU</th>
|
|
<th>MEMORY</th>
|
|
<th>NET I/O</th>
|
|
<th>IMAGE</th>
|
|
<th>PORTS</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="all-containers-body">
|
|
<tr><td colspan="8"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ═══════════════════════════════════════════════════════
|
|
RESTORE PAGE
|
|
═══════════════════════════════════════════════════════ #}
|
|
<div id="restore-page" class="page">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="card-title"><i class="fas fa-rotate-right"></i> Restore Configuration</div>
|
|
</div>
|
|
|
|
<div class="restore-form">
|
|
|
|
<!-- Step 1: Source -->
|
|
<div class="form-section">
|
|
<div class="form-section-title">STEP 1 — SELECT BACKUP SOURCE</div>
|
|
<div class="radio-group">
|
|
<label class="radio-card">
|
|
<input type="radio" name="backup_source" value="local" checked onchange="updateBackupList()">
|
|
<div class="radio-body">
|
|
<span class="radio-icon">🖥️</span>
|
|
<div>
|
|
<div class="radio-label">Main Server</div>
|
|
<div class="radio-desc"><code>/root/backups/</code></div>
|
|
</div>
|
|
</div>
|
|
</label>
|
|
<label class="radio-card">
|
|
<input type="radio" name="backup_source" value="vm" onchange="updateBackupList()">
|
|
<div class="radio-body">
|
|
<span class="radio-icon">💾</span>
|
|
<div>
|
|
<div class="radio-label">VM Backup Server</div>
|
|
<div class="radio-desc"><code>/backups/main-server/</code></div>
|
|
</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="form-group" style="margin-top:14px; max-width:500px;">
|
|
<label class="form-label">BACKUP FILE</label>
|
|
<select id="backup-file-select" class="form-input">
|
|
<optgroup label="Main Server" id="local-options">
|
|
{% for b in backups %}
|
|
<option value="{{ b }}" data-source="local">{{ b }}</option>
|
|
{% else %}
|
|
<option disabled>No local backups</option>
|
|
{% endfor %}
|
|
</optgroup>
|
|
<optgroup label="VM Backups" id="vm-options" style="display:none;">
|
|
{% for b in vm_backups %}
|
|
<option value="{{ b }}" data-source="vm">{{ b }}</option>
|
|
{% else %}
|
|
<option disabled>No VM backups</option>
|
|
{% endfor %}
|
|
</optgroup>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: Target -->
|
|
<div class="form-section">
|
|
<div class="form-section-title">STEP 2 — SELECT RESTORE TARGET</div>
|
|
<div class="radio-group">
|
|
<label class="radio-card">
|
|
<input type="radio" name="restore_target" value="local" checked onchange="toggleRemoteFields()">
|
|
<div class="radio-body">
|
|
<span class="radio-icon">🎯</span>
|
|
<div>
|
|
<div class="radio-label">This Server</div>
|
|
<div class="radio-desc">{{ main_server }}</div>
|
|
</div>
|
|
</div>
|
|
</label>
|
|
<label class="radio-card">
|
|
<input type="radio" name="restore_target" value="remote" onchange="toggleRemoteFields()">
|
|
<div class="radio-body">
|
|
<span class="radio-icon">📡</span>
|
|
<div>
|
|
<div class="radio-label">Remote Machine</div>
|
|
<div class="radio-desc">via SSH</div>
|
|
</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div id="remote-fields" style="display:none; margin-top:16px; max-width:560px;">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label class="form-label">TARGET IP</label>
|
|
<input type="text" id="remote-ip" class="form-input" placeholder="192.168.1.x or localhost">
|
|
</div>
|
|
<div class="form-group" style="max-width:100px;">
|
|
<label class="form-label">SSH PORT</label>
|
|
<input type="text" id="remote-port" class="form-input" value="22" placeholder="22">
|
|
</div>
|
|
<div class="form-group" style="max-width:120px;">
|
|
<label class="form-label">SSH USER</label>
|
|
<input type="text" id="remote-user" class="form-input" value="root">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group" style="margin-top:8px;">
|
|
<label class="form-label">AUTHENTICATION</label>
|
|
<div class="radio-group" style="gap:8px;">
|
|
<label class="radio-card small">
|
|
<input type="radio" name="auth_method" value="key" checked onchange="toggleAuthFields()">
|
|
<div class="radio-body"><span>🔑</span><div class="radio-label">SSH Key</div></div>
|
|
</label>
|
|
<label class="radio-card small">
|
|
<input type="radio" name="auth_method" value="password" onchange="toggleAuthFields()">
|
|
<div class="radio-body"><span>🔒</span><div class="radio-label">Password</div></div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="key-field" class="form-group" style="margin-top:8px;">
|
|
<label class="form-label">SSH KEY PATH</label>
|
|
<input type="text" id="ssh-key-path" class="form-input" value="/root/.ssh/contabo-key">
|
|
</div>
|
|
<div id="password-field" class="form-group" style="display:none; margin-top:8px;">
|
|
<label class="form-label">SSH PASSWORD</label>
|
|
<input type="password" id="ssh-password" class="form-input" placeholder="SSH password">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Launch -->
|
|
<div class="form-section">
|
|
<button class="btn btn-danger btn-lg" onclick="launchRestore()" id="restore-btn">
|
|
<i class="fas fa-play"></i> Start Restore
|
|
</button>
|
|
<p style="color:var(--text3); font-size:12px; margin-top:8px;">
|
|
⚠ Healthy running containers will be skipped automatically.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Live Log -->
|
|
<div id="restore-log-wrapper" style="display:none; margin-top:20px;">
|
|
<div class="card-header" style="margin-bottom:10px;">
|
|
<div class="card-title"><i class="fas fa-terminal"></i> Restore Log</div>
|
|
<span class="badge" id="restore-status-badge" style="background:rgba(59,130,246,0.15);color:var(--accent2);">Running…</span>
|
|
</div>
|
|
<div id="restore-log" class="log-console"></div>
|
|
<div style="color:var(--text3); font-size:11px; margin-top:6px; font-family:var(--mono);" id="restore-elapsed"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ═══════════════════════════════════════════════════════
|
|
BACKUPS PAGE
|
|
═══════════════════════════════════════════════════════ #}
|
|
<div id="backups-page" class="page">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="card-title"><i class="fas fa-database"></i> Available Backups</div>
|
|
<button class="btn btn-ghost btn-sm" onclick="refreshBackupsList()"><i class="fas fa-sync-alt"></i> Refresh</button>
|
|
</div>
|
|
<div class="two-col">
|
|
<div>
|
|
<div class="section-header">
|
|
<div class="section-title">🖥️ MAIN SERVER</div>
|
|
<span class="card-meta">/root/backups/</span>
|
|
</div>
|
|
<div class="backup-list" id="local-backup-list">
|
|
{% for b in backups %}
|
|
<div class="backup-item">
|
|
<span class="backup-name">{{ b }}</span>
|
|
<button class="btn btn-ghost btn-sm" onclick="quickRestore('local','{{ b }}')">↩ Restore</button>
|
|
</div>
|
|
{% else %}
|
|
<div class="empty-state"><i class="fas fa-inbox"></i>No backups</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="section-header">
|
|
<div class="section-title">💾 VM SERVER</div>
|
|
<span class="card-meta">/backups/main-server/</span>
|
|
</div>
|
|
<div class="backup-list" id="vm-backup-list">
|
|
{% for b in vm_backups %}
|
|
<div class="backup-item">
|
|
<span class="backup-name">{{ b }}</span>
|
|
<button class="btn btn-ghost btn-sm" onclick="quickRestore('vm','{{ b }}')">↩ Restore</button>
|
|
</div>
|
|
{% else %}
|
|
<div class="empty-state"><i class="fas fa-inbox"></i>No VM backups</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ═══════════════════════════════════════════════════════
|
|
USERS PAGE
|
|
═══════════════════════════════════════════════════════ #}
|
|
<div id="users-page" class="page">
|
|
|
|
<!-- Create User -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="card-title"><i class="fas fa-user-plus"></i> Create New User</div>
|
|
</div>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label class="form-label">USERNAME</label>
|
|
<input type="text" id="new-username" class="form-input" placeholder="e.g. secuser2">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">PASSWORD (optional)</label>
|
|
<input type="password" id="new-password" class="form-input" placeholder="Leave blank for no password">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">DISK QUOTA (MB, optional)</label>
|
|
<input type="number" id="new-quota" class="form-input" placeholder="e.g. 10240 for 10GB">
|
|
</div>
|
|
<div class="form-group" style="justify-content:flex-end;">
|
|
<label class="form-check" style="margin-bottom:8px;">
|
|
<input type="checkbox" id="new-docker" checked>
|
|
<span>Setup rootless Docker</span>
|
|
</label>
|
|
<button class="btn btn-primary" onclick="createUser()">
|
|
<i class="fas fa-plus"></i> Create User
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="create-user-result" class="alert" style="margin-top:14px;"></div>
|
|
<div id="create-user-log" class="log-console" style="display:none; margin-top:12px; max-height:200px;"></div>
|
|
</div>
|
|
|
|
<!-- Users List -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="card-title"><i class="fas fa-users"></i> System Users</div>
|
|
<button class="btn btn-ghost btn-sm" onclick="loadUsers()"><i class="fas fa-sync-alt"></i> Refresh</button>
|
|
</div>
|
|
<div class="users-grid" id="users-grid">
|
|
{% for u in users %}
|
|
<div class="user-card" onclick="loadUserContainers('{{ u.name }}')">
|
|
<div class="user-card-top">
|
|
<div class="user-avatar" style="background: #667eea">
|
|
{{ u.name[0].upper() }}
|
|
</div>
|
|
<div>
|
|
<div class="user-name">{{ u.name }}</div>
|
|
<div class="user-uid">uid {{ u.uid }} · {{ u.home }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="user-tags">
|
|
{% if u.has_docker %}<span class="user-tag docker"><i class="fab fa-docker"></i> rootless docker</span>{% endif %}
|
|
{% if u.linger %}<span class="user-tag linger">linger on</span>{% endif %}
|
|
</div>
|
|
<div class="user-stats">
|
|
<div class="user-stat">Disk: <strong>{{ u.disk_used }}</strong></div>
|
|
<div class="user-stat">Containers: <strong>{{ u.container_count }}</strong></div>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="empty-state" style="grid-column:1/-1;">
|
|
<i class="fas fa-user-slash"></i>No non-system users found
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- User Detail Panel -->
|
|
<div class="card" id="user-detail-panel" style="display:none;">
|
|
<div class="card-header">
|
|
<div class="card-title"><i class="fas fa-cube"></i> <span id="user-detail-title">Containers</span></div>
|
|
<div style="display:flex;gap:8px;">
|
|
<button class="btn btn-ghost btn-sm" id="user-delete-btn" onclick="deleteUser()">
|
|
<i class="fas fa-trash"></i> Delete User
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('user-detail-panel').style.display='none'">
|
|
✕ Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div style="overflow-x:auto;">
|
|
<table class="ct-table">
|
|
<thead>
|
|
<tr><th>NAME</th><th>STATUS</th><th>IMAGE</th><th>PORTS</th></tr>
|
|
</thead>
|
|
<tbody id="user-containers-body">
|
|
<tr><td colspan="4"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i></div></td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div id="user-action-result" class="alert" style="margin-top:14px;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ═══════════════════════════════════════════════════════
|
|
SETTINGS PAGE
|
|
═══════════════════════════════════════════════════════ #}
|
|
<div id="settings-page" class="page">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="card-title"><i class="fas fa-sliders"></i> Platform Settings</div>
|
|
</div>
|
|
<div style="max-width:460px; display:flex; flex-direction:column; gap:14px;">
|
|
<div class="form-group">
|
|
<label class="form-label">MAIN SERVER IP</label>
|
|
<input class="form-input" value="{{ main_server }}" readonly>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">VM BACKUP PATH</label>
|
|
<input class="form-input" value="/backups/main-server/" readonly>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">PLATFORM UPTIME</label>
|
|
<input class="form-input" id="settings-uptime" value="{{ system.uptime }}" readonly>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">DOCKER VERSION</label>
|
|
<input class="form-input" value="{{ system.docker_v }}" readonly>
|
|
</div>
|
|
<button class="btn btn-ghost" onclick="refreshAll()"><i class="fas fa-sync-alt"></i> Refresh All Data</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ═══════════════════════════════════════════════════════
|
|
JAVASCRIPT
|
|
═══════════════════════════════════════════════════════ #}
|
|
<script>
|
|
// ── Page routing ────────────────────────────────────────
|
|
const PAGES = {
|
|
'dashboard': { title: 'Dashboard', sub: '{{ main_server }}' },
|
|
'containers': { title: 'All Containers', sub: 'root + all users' },
|
|
'restore': { title: 'Restore', sub: 'backup → target' },
|
|
'backups': { title: 'Backups', sub: 'local & VM' },
|
|
'users': { title: 'User Management',sub: 'linux + docker' },
|
|
'settings': { title: 'Settings', sub: 'platform config' },
|
|
};
|
|
|
|
function showPage(name) {
|
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
const page = document.getElementById(name + '-page');
|
|
if (page) page.classList.add('active');
|
|
const nav = document.querySelector(`[data-page="${name}"]`);
|
|
if (nav) nav.classList.add('active');
|
|
const info = PAGES[name] || {};
|
|
document.getElementById('page-title').textContent = info.title || name;
|
|
document.getElementById('page-subtitle').textContent = info.sub || '';
|
|
// Lazy load
|
|
if (name === 'containers') loadAllContainers();
|
|
if (name === 'backups') refreshBackupsList();
|
|
if (name === 'users') loadUsers();
|
|
}
|
|
|
|
document.querySelectorAll('.nav-item').forEach(item => {
|
|
item.addEventListener('click', e => {
|
|
e.preventDefault();
|
|
showPage(item.dataset.page);
|
|
});
|
|
});
|
|
|
|
// ── Server status ────────────────────────────────────────
|
|
async function checkServerStatus() {
|
|
try {
|
|
const r = await fetch('/server/status');
|
|
const d = await r.json();
|
|
const dot = document.getElementById('pulse-dot');
|
|
const text = document.getElementById('server-status-text');
|
|
if (d.status === 'online') {
|
|
dot.className = 'pulse-dot online';
|
|
text.textContent = 'Online';
|
|
} else {
|
|
dot.className = 'pulse-dot offline';
|
|
text.textContent = 'Offline';
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
// ── System metrics ───────────────────────────────────────
|
|
async function refreshSystemMetrics() {
|
|
try {
|
|
const r = await fetch('/api/system');
|
|
const d = await r.json();
|
|
document.getElementById('m-cpu').innerHTML = d.cpu_pct + '<span>%</span>';
|
|
document.getElementById('m-mem').textContent = d.memory;
|
|
document.getElementById('m-disk').textContent = d.disk;
|
|
document.getElementById('m-load').textContent = d.load;
|
|
document.getElementById('g-cpu').style.width = Math.min(parseFloat(d.cpu_pct)||0, 100) + '%';
|
|
document.getElementById('g-mem').style.width = Math.min(parseFloat(d.mem_pct)||0, 100) + '%';
|
|
document.getElementById('g-disk').style.width = Math.min(parseFloat(d.disk_pct)||0, 100) + '%';
|
|
document.getElementById('uptime-chip').textContent = d.uptime;
|
|
const settingsUptime = document.getElementById('settings-uptime');
|
|
if (settingsUptime) settingsUptime.value = d.uptime;
|
|
} catch { }
|
|
}
|
|
|
|
// ── Container stats (live) ───────────────────────────────
|
|
async function refreshContainerStats() {
|
|
try {
|
|
const r = await fetch('/api/stats');
|
|
const stats = await r.json();
|
|
|
|
document.querySelectorAll('[data-stat]').forEach(el => {
|
|
const name = el.dataset.ctr;
|
|
const stat = el.dataset.stat;
|
|
const s = stats[name];
|
|
if (!s) return;
|
|
|
|
if (stat === 'cpu') el.textContent = s.cpu || '—';
|
|
if (stat === 'net') el.textContent = s.net || '—';
|
|
if (stat === 'block') el.textContent = s.block || '—';
|
|
if (stat === 'mem_pct') el.textContent = s.mem_pct || '—';
|
|
if (stat === 'mem_bar') {
|
|
const pct = parseFloat(s.mem_pct) || 0;
|
|
el.style.width = Math.min(pct, 100) + '%';
|
|
el.className = 'stat-bar-fill' + (pct > 85 ? ' crit' : pct > 65 ? ' warn' : '');
|
|
}
|
|
});
|
|
} catch { }
|
|
}
|
|
|
|
// ── All containers (root + users) ───────────────────────
|
|
async function loadAllContainers() {
|
|
const body = document.getElementById('all-containers-body');
|
|
const meta = document.getElementById('all-ctr-meta');
|
|
body.innerHTML = '<tr><td colspan="8"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></td></tr>';
|
|
|
|
try {
|
|
const [ctrRes, statRes] = await Promise.all([
|
|
fetch('/api/containers/all'),
|
|
fetch('/api/stats')
|
|
]);
|
|
const { containers, running } = await ctrRes.json();
|
|
const stats = await statRes.json();
|
|
|
|
meta.textContent = `${containers.length} total · ${running} running`;
|
|
document.getElementById('nav-badge-containers').textContent = containers.length;
|
|
|
|
if (!containers.length) {
|
|
body.innerHTML = '<tr><td colspan="8"><div class="empty-state"><i class="fas fa-inbox"></i>No containers</div></td></tr>';
|
|
return;
|
|
}
|
|
body.innerHTML = containers.map(c => {
|
|
const up = c.status.includes('Up');
|
|
const s = stats[c.name] || {};
|
|
const pct = parseFloat(s.mem_pct) || 0;
|
|
const cls = pct > 85 ? 'crit' : pct > 65 ? 'warn' : '';
|
|
return `<tr>
|
|
<td class="ct-name">${c.name}</td>
|
|
<td class="ct-owner">${c.owner}</td>
|
|
<td>${up ? '<span class="badge badge-run">Running</span>' : '<span class="badge badge-stop">Stopped</span>'}</td>
|
|
<td><span class="stat-pct">${s.cpu||'—'}</span></td>
|
|
<td>
|
|
<div class="stat-bar-wrap">
|
|
<div class="stat-bar-bg"><div class="stat-bar-fill ${cls}" style="width:${Math.min(pct,100)}%"></div></div>
|
|
<span class="stat-pct">${s.mem_pct||'—'}</span>
|
|
</div>
|
|
</td>
|
|
<td><span class="stat-pct" style="color:var(--cyan)">${s.net||'—'}</span></td>
|
|
<td class="ct-image">${c.image}</td>
|
|
<td class="ct-ports">${c.ports||'—'}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
} catch (e) {
|
|
body.innerHTML = `<tr><td colspan="8"><div class="empty-state"><i class="fas fa-exclamation-triangle"></i>${e}</div></td></tr>`;
|
|
}
|
|
}
|
|
|
|
// ── Backups ──────────────────────────────────────────────
|
|
async function refreshBackupsList() {
|
|
try {
|
|
const r = await fetch('/api/backups');
|
|
const d = await r.json();
|
|
renderBackupList(d.local, 'local-backup-list', 'local');
|
|
renderBackupList(d.vm, 'vm-backup-list', 'vm');
|
|
document.getElementById('stat-local-bk').textContent = d.local.length;
|
|
document.getElementById('stat-vm-bk').textContent = d.vm.length;
|
|
|
|
// Refresh selects on restore page
|
|
const lo = document.getElementById('local-options');
|
|
const vo = document.getElementById('vm-options');
|
|
lo.innerHTML = d.local.length
|
|
? d.local.map(b => `<option value="${b}" data-source="local">${b}</option>`).join('')
|
|
: '<option disabled>No local backups</option>';
|
|
vo.innerHTML = d.vm.length
|
|
? d.vm.map(b => `<option value="${b}" data-source="vm">${b}</option>`).join('')
|
|
: '<option disabled>No VM backups</option>';
|
|
} catch { }
|
|
}
|
|
|
|
function renderBackupList(items, id, source) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
if (!items || !items.length) {
|
|
el.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i>No backups</div>';
|
|
return;
|
|
}
|
|
el.innerHTML = items.map(b => `
|
|
<div class="backup-item">
|
|
<span class="backup-name">${b}</span>
|
|
<button class="btn btn-ghost btn-sm" onclick="quickRestore('${source}','${b}')">↩ Restore</button>
|
|
</div>`).join('');
|
|
}
|
|
|
|
// ── Users ────────────────────────────────────────────────
|
|
let selectedUser = null;
|
|
|
|
async function loadUsers() {
|
|
try {
|
|
const r = await fetch('/api/users');
|
|
const users = await r.json();
|
|
document.getElementById('nav-badge-users').textContent = users.length;
|
|
document.getElementById('stat-users').textContent = users.length;
|
|
|
|
const colors = ['#3b82f6','#a78bfa','#22c55e','#f59e0b','#ef4444','#22d3ee'];
|
|
const grid = document.getElementById('users-grid');
|
|
if (!users.length) {
|
|
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><i class="fas fa-user-slash"></i>No users</div>';
|
|
return;
|
|
}
|
|
grid.innerHTML = users.map((u, i) => `
|
|
<div class="user-card" onclick="loadUserContainers('${u.name}')">
|
|
<div class="user-card-top">
|
|
<div class="user-avatar" style="background:${colors[i % colors.length]}">${u.name[0].toUpperCase()}</div>
|
|
<div>
|
|
<div class="user-name">${u.name}</div>
|
|
<div class="user-uid">uid ${u.uid} · ${u.home}</div>
|
|
</div>
|
|
</div>
|
|
<div class="user-tags">
|
|
${u.has_docker ? '<span class="user-tag docker"><i class="fab fa-docker"></i> rootless docker</span>' : ''}
|
|
${u.linger ? '<span class="user-tag linger">linger on</span>' : ''}
|
|
</div>
|
|
<div class="user-stats">
|
|
<div class="user-stat">Disk: <strong>${u.disk_used}</strong></div>
|
|
<div class="user-stat">Containers: <strong>${u.container_count}</strong></div>
|
|
</div>
|
|
</div>`).join('');
|
|
} catch { }
|
|
}
|
|
|
|
async function loadUserContainers(username) {
|
|
selectedUser = username;
|
|
const panel = document.getElementById('user-detail-panel');
|
|
panel.style.display = '';
|
|
document.getElementById('user-detail-title').textContent = `${username}'s Containers`;
|
|
document.getElementById('user-delete-btn').dataset.username = username;
|
|
document.getElementById('user-action-result').className = 'alert';
|
|
|
|
const body = document.getElementById('user-containers-body');
|
|
body.innerHTML = '<tr><td colspan="4"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i></div></td></tr>';
|
|
|
|
try {
|
|
const r = await fetch(`/api/users/${username}/containers`);
|
|
const ctrs = await r.json();
|
|
if (!ctrs.length) {
|
|
body.innerHTML = '<tr><td colspan="4"><div class="empty-state"><i class="fas fa-inbox"></i>No containers</div></td></tr>';
|
|
return;
|
|
}
|
|
body.innerHTML = ctrs.map(c => {
|
|
const up = c.status.includes('Up');
|
|
return `<tr>
|
|
<td class="ct-name">${c.name}</td>
|
|
<td>${up ? '<span class="badge badge-run">Running</span>' : '<span class="badge badge-stop">Stopped</span>'}</td>
|
|
<td class="ct-image">${c.image}</td>
|
|
<td class="ct-ports">${c.ports||'—'}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
panel.scrollIntoView({ behavior: 'smooth' });
|
|
} catch (e) {
|
|
body.innerHTML = `<tr><td colspan="4"><div class="empty-state">${e}</div></td></tr>`;
|
|
}
|
|
}
|
|
|
|
async function createUser() {
|
|
const username = document.getElementById('new-username').value.trim();
|
|
const password = document.getElementById('new-password').value.trim();
|
|
const quota = document.getElementById('new-quota').value.trim();
|
|
const docker = document.getElementById('new-docker').checked;
|
|
|
|
if (!username) { showAlert('create-user-result', 'error', 'Username is required'); return; }
|
|
|
|
const btn = event.target;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Creating…';
|
|
|
|
const logEl = document.getElementById('create-user-log');
|
|
logEl.style.display = '';
|
|
logEl.textContent = '⏳ Setting up user…\n';
|
|
|
|
try {
|
|
const r = await fetch('/api/users/create', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
username,
|
|
password: password || null,
|
|
setup_docker: docker,
|
|
disk_quota_mb: quota ? parseInt(quota) : null,
|
|
})
|
|
});
|
|
const d = await r.json();
|
|
logEl.textContent = d.message || '';
|
|
if (d.success) {
|
|
showAlert('create-user-result', 'success', `✅ User "${username}" created successfully`);
|
|
document.getElementById('new-username').value = '';
|
|
document.getElementById('new-password').value = '';
|
|
document.getElementById('new-quota').value = '';
|
|
loadUsers();
|
|
} else {
|
|
showAlert('create-user-result', 'error', `❌ ${d.message}`);
|
|
}
|
|
} catch (e) {
|
|
showAlert('create-user-result', 'error', `Request failed: ${e}`);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-plus"></i> Create User';
|
|
}
|
|
}
|
|
|
|
async function deleteUser() {
|
|
const username = selectedUser;
|
|
if (!username) return;
|
|
if (!confirm(`Delete user "${username}"? This cannot be undone.\nRemove home directory too?`)) return;
|
|
const removeHome = confirm(`Also delete /home/${username}?`);
|
|
|
|
try {
|
|
const r = await fetch('/api/users/delete', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, remove_home: removeHome })
|
|
});
|
|
const d = await r.json();
|
|
showAlert('user-action-result', d.success ? 'success' : 'error', d.message);
|
|
if (d.success) {
|
|
document.getElementById('user-detail-panel').style.display = 'none';
|
|
loadUsers();
|
|
}
|
|
} catch (e) {
|
|
showAlert('user-action-result', 'error', `Request failed: ${e}`);
|
|
}
|
|
}
|
|
|
|
function showAlert(id, type, msg) {
|
|
const el = document.getElementById(id);
|
|
el.className = `alert alert-${type} show`;
|
|
el.textContent = msg;
|
|
}
|
|
|
|
// ── Restore form ─────────────────────────────────────────
|
|
function updateBackupList() {
|
|
const src = document.querySelector('input[name="backup_source"]:checked').value;
|
|
document.getElementById('local-options').style.display = src === 'local' ? '' : 'none';
|
|
document.getElementById('vm-options').style.display = src === 'vm' ? '' : 'none';
|
|
const sel = document.getElementById('backup-file-select');
|
|
for (let opt of sel.options) {
|
|
if (!opt.disabled && opt.parentElement.style.display !== 'none') { opt.selected = true; break; }
|
|
}
|
|
}
|
|
|
|
function toggleRemoteFields() {
|
|
const t = document.querySelector('input[name="restore_target"]:checked').value;
|
|
document.getElementById('remote-fields').style.display = t === 'remote' ? '' : 'none';
|
|
}
|
|
|
|
function toggleAuthFields() {
|
|
const m = document.querySelector('input[name="auth_method"]:checked').value;
|
|
document.getElementById('key-field').style.display = m === 'key' ? '' : 'none';
|
|
document.getElementById('password-field').style.display = m === 'password' ? '' : 'none';
|
|
}
|
|
|
|
function quickRestore(source, filename) {
|
|
showPage('restore');
|
|
document.querySelector(`input[name="backup_source"][value="${source}"]`).checked = true;
|
|
updateBackupList();
|
|
const sel = document.getElementById('backup-file-select');
|
|
for (let opt of sel.options) { if (opt.value === filename) { opt.selected = true; break; } }
|
|
document.querySelector('input[name="restore_target"][value="local"]').checked = true;
|
|
toggleRemoteFields();
|
|
window.scrollTo(0, 0);
|
|
}
|
|
|
|
let currentJobId = null;
|
|
let pollInterval = null;
|
|
|
|
async function launchRestore() {
|
|
const src = document.querySelector('input[name="backup_source"]:checked').value;
|
|
const file = document.getElementById('backup-file-select').value;
|
|
const target = document.querySelector('input[name="restore_target"]:checked').value;
|
|
|
|
if (!file || file === 'undefined') { alert('Select a backup file first.'); return; }
|
|
|
|
const payload = { backup_source: src, backup_file: file, target };
|
|
|
|
if (target === 'remote') {
|
|
payload.remote_ip = document.getElementById('remote-ip').value.trim();
|
|
payload.remote_port = document.getElementById('remote-port').value.trim() || '22';
|
|
payload.remote_user = document.getElementById('remote-user').value.trim() || 'root';
|
|
payload.auth_method = document.querySelector('input[name="auth_method"]:checked').value;
|
|
if (payload.auth_method === 'key')
|
|
payload.ssh_key_path = document.getElementById('ssh-key-path').value.trim();
|
|
else
|
|
payload.ssh_password = document.getElementById('ssh-password').value;
|
|
if (!payload.remote_ip) { alert('Enter a target IP.'); return; }
|
|
}
|
|
|
|
if (!confirm(`Restore "${file}" → ${target === 'local' ? 'this server' : payload.remote_ip + ':' + payload.remote_port}?`)) return;
|
|
|
|
const btn = document.getElementById('restore-btn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Starting…';
|
|
|
|
const wrapper = document.getElementById('restore-log-wrapper');
|
|
wrapper.style.display = '';
|
|
document.getElementById('restore-log').innerHTML = '';
|
|
const badge = document.getElementById('restore-status-badge');
|
|
badge.style = 'background:rgba(59,130,246,0.15);color:var(--accent2)';
|
|
badge.textContent = 'Running…';
|
|
|
|
try {
|
|
const res = await fetch('/restore/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const data = await res.json();
|
|
if (data.error) {
|
|
appendLog('❌ ' + data.error, 'error');
|
|
btn.disabled = false; btn.innerHTML = '<i class="fas fa-play"></i> Start Restore';
|
|
return;
|
|
}
|
|
currentJobId = data.job_id;
|
|
pollRestore();
|
|
} catch (e) {
|
|
appendLog('❌ ' + e, 'error');
|
|
btn.disabled = false; btn.innerHTML = '<i class="fas fa-play"></i> Start Restore';
|
|
}
|
|
}
|
|
|
|
function pollRestore() {
|
|
if (pollInterval) clearInterval(pollInterval);
|
|
let lastLine = 0;
|
|
pollInterval = setInterval(async () => {
|
|
if (!currentJobId) return;
|
|
try {
|
|
const r = await fetch(`/restore/status/${currentJobId}`);
|
|
const d = await r.json();
|
|
d.log.slice(lastLine).forEach(l => appendLog(l));
|
|
lastLine = d.log.length;
|
|
document.getElementById('restore-elapsed').textContent = `⏱ ${d.elapsed}s elapsed`;
|
|
|
|
if (d.status !== 'running') {
|
|
clearInterval(pollInterval);
|
|
const badge = document.getElementById('restore-status-badge');
|
|
if (d.status === 'done') {
|
|
badge.style = 'background:rgba(34,197,94,0.15);color:var(--green)';
|
|
badge.textContent = '✅ Done';
|
|
} else {
|
|
badge.style = 'background:rgba(239,68,68,0.15);color:var(--red)';
|
|
badge.textContent = '❌ Error';
|
|
}
|
|
const btn = document.getElementById('restore-btn');
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-play"></i> Start Restore';
|
|
}
|
|
} catch { }
|
|
}, 1500);
|
|
}
|
|
|
|
function appendLog(line, type) {
|
|
const el = document.getElementById('restore-log');
|
|
const div = document.createElement('div');
|
|
div.className = 'log-line';
|
|
if (line.includes('✅') || type === 'ok') div.style.color = 'var(--green)';
|
|
else if (line.includes('❌') || type === 'error') div.style.color = 'var(--red)';
|
|
else if (line.includes('⚠️')) div.style.color = 'var(--yellow)';
|
|
else if (line.includes('📌') || line.includes('STEP')) div.style.color = 'var(--accent2)';
|
|
else if (line.includes('📤') || line.includes('🚀')) div.style.color = 'var(--cyan)';
|
|
div.textContent = line;
|
|
el.appendChild(div);
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
|
|
// ── Global refresh ───────────────────────────────────────
|
|
function refreshAll() {
|
|
const icon = document.getElementById('refresh-icon');
|
|
const btn = icon.closest('.icon-btn');
|
|
btn.classList.add('spinning');
|
|
Promise.all([
|
|
checkServerStatus(),
|
|
refreshSystemMetrics(),
|
|
refreshContainerStats(),
|
|
]).finally(() => btn.classList.remove('spinning'));
|
|
}
|
|
|
|
// ── Boot ─────────────────────────────────────────────────
|
|
checkServerStatus();
|
|
refreshSystemMetrics();
|
|
refreshContainerStats();
|
|
document.getElementById('nav-badge-users').textContent = '{{ users|length }}';
|
|
document.getElementById('nav-badge-containers').textContent = '{{ containers|length }}';
|
|
|
|
// Auto-refresh every 15s
|
|
setInterval(() => {
|
|
refreshSystemMetrics();
|
|
refreshContainerStats();
|
|
}, 15000);
|
|
setInterval(checkServerStatus, 30000);
|
|
</script>
|
|
{% endblock %}
|