Files
pvemanager/backend/app/templates/proxmox_vms.html
markmorado b3db346cfb feat: v1.1.0 — backup module, IPAM node binding, HA, task queue, theme CSS
- Backup module: full CRUD for storages, backup files, scheduled jobs (APScheduler)
- Proxmox client: storage/backup/restore/HA methods
- IPAM: proxmox_node field, server+node binding for networks and allocations
- Task queue service: improvements and new task types
- Monitoring worker: extended metrics and alerts
- HA support: add/remove VMs from Proxmox HA groups
- tasks.html: new task tracking page
- proxmox_vms.html: major UI update (bulk ops, HA, snapshots)
- backups.html: full backup management UI (3 tabs)
- base.html: sidebar backup link, websocket manager
- theme.css: full CSS variables theme, 395 lines added
- migrations: migration #15 (BackupJob), #16 (proxmox_node)
- RBAC: backup resource permissions
- websocket_manager.py: new module for WS connections
2026-03-08 18:29:29 +01:00

2906 lines
145 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.
{% extends "base.html" %}
{% set page_title = t('nav_proxmox') %}
{% block content %}
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3><i class="fa-solid fa-computer"></i> {{ t('proxmox_servers') }}</h3>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<div data-permission="proxmox.cluster.manage" style="position: relative;">
<button onclick="toggleClusterDropdown(event)" id="clusterMenuBtn" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">
<i class="fa-solid fa-network-wired"></i> Кластер <i class="fa-solid fa-caret-down" style="font-size: 0.75rem; margin-left: 2px;"></i>
</button>
<div id="clusterDropdown" class="cluster-dropdown" style="display: none;">
<button onclick="showCreateClusterModal(); closeClusterDropdown();" class="cluster-dropdown-item">
<i class="fa-solid fa-circle-plus" style="color: var(--success);"></i> Создать кластер
</button>
<button onclick="showJoinClusterModal(); closeClusterDropdown();" class="cluster-dropdown-item">
<i class="fa-solid fa-link" style="color: var(--info);"></i> Присоединить к кластеру
</button>
<button onclick="showEjectNodeModal(); closeClusterDropdown();" class="cluster-dropdown-item">
<i class="fa-solid fa-right-from-bracket" style="color: var(--danger);"></i> Исключить ноду
</button>
</div>
</div>
<div data-permission="proxmox.servers.add">
<button onclick="showAddServerModal()" class="btn btn-primary"><i class="fa-solid fa-plus"></i> {{ t('add_server') }}</button>
</div>
</div>
</div>
{% if proxmox_servers %}
<div class="table-container">
<table class="data-table" id="serversTable">
<thead>
<tr>
<th class="sortable" data-sort="name" onclick="sortTable('name')">
{{ t('server_name') }} <i class="fa-solid fa-sort sort-icon"></i>
</th>
<th class="sortable" data-sort="ip" onclick="sortTable('ip')">
{{ t('ip_address') }} <i class="fa-solid fa-sort sort-icon"></i>
</th>
<th class="sortable" data-sort="node" onclick="sortTable('node')">
{{ t('node') or 'Node' }} <i class="fa-solid fa-sort sort-icon"></i>
</th>
<th class="sortable" data-sort="vms" onclick="sortTable('vms')">
VM <i class="fa-solid fa-sort sort-icon"></i>
</th>
<th class="sortable" data-sort="lxc" onclick="sortTable('lxc')">
LXC <i class="fa-solid fa-sort sort-icon"></i>
</th>
<th class="sortable" data-sort="status" onclick="sortTable('status')">
{{ t('status') }} <i class="fa-solid fa-sort sort-icon"></i>
</th>
<th style="text-align: right;">{{ t('actions') }}</th>
</tr>
</thead>
<tbody id="serversTableBody">
{% for server in proxmox_servers|sort(attribute='name') %}
<tr id="server-row-{{ server.id }}" data-server-id="{{ server.id }}" data-name="{{ server.name|lower }}" data-ip="{{ server.ip_address }}" data-node="{{ server.hostname or '' }}" data-vms="0" data-lxc="0" data-status="loading">
<td>
<div style="font-weight: 600; color: var(--text-primary);">{{ server.name }}</div>
{% if server.cluster_name %}
<span class="cluster-badge"><i class="fa-solid fa-network-wired"></i> {{ server.cluster_name }}</span>
{% endif %}
{% if server.description %}
<div style="font-size: 0.75rem; color: var(--text-muted);">{{ server.description }}</div>
{% endif %}
</td>
<td>
<div style="font-family: monospace; font-size: 0.875rem;">{{ server.ip_address }}:{{ server.port }}</div>
<div style="font-size: 0.75rem; color: var(--text-muted);"><i class="fa-solid fa-user"></i> {{ server.api_user }}</div>
</td>
<td>
{% if server.hostname and server.hostname != server.ip_address %}
<span style="font-size: 0.875rem; color: var(--info);">{{ server.hostname }}</span>
{% else %}
<span style="color: var(--text-muted);">—</span>
{% endif %}
</td>
<td id="server-vms-{{ server.id }}" style="text-align: center;">
<span style="color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i></span>
</td>
<td id="server-lxc-{{ server.id }}" style="text-align: center;">
<span style="color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i></span>
</td>
<td id="server-status-{{ server.id }}" style="text-align: center;">
<span class="status-badge status-loading"><i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}</span>
</td>
<td style="text-align: right;">
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button onclick="testConnection({{ server.id }})" class="btn btn-sm" style="background: var(--success); color: var(--text-on-success);" title="{{ t('test') }}">
<i class="fa-solid fa-plug"></i> {{ t('test') }}
</button>
<button onclick="window.location.href='/proxmox/server/{{ server.id }}'" class="btn btn-sm" style="background: var(--info); color: var(--text-on-info);" title="{{ t('details') }}">
<i class="fa-solid fa-chart-simple"></i> {{ t('details') }}
</button>
<button onclick="editServer({{ server.id }}, '{{ server.name }}')" class="btn btn-sm" style="background: var(--warning); color: var(--text-on-warning);" title="{{ t('edit') }}" data-permission="proxmox.servers.edit">
<i class="fa-solid fa-pen"></i>
</button>
<button onclick="deleteServer({{ server.id }}, '{{ server.name }}')" class="btn btn-sm btn-delete" title="{{ t('delete') }}" data-permission="proxmox.servers.delete">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p style="color: var(--text-secondary);">
{{ t('no_proxmox_servers') }} <a href="#" onclick="showAddServerModal(); return false;" style="color: var(--success);">{{ t('add_first_server') }}</a>.
</p>
{% endif %}
</div>
<style>
.table-container {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.data-table th {
background: var(--bg-secondary);
font-weight: 600;
color: var(--text-secondary);
font-size: 0.875rem;
white-space: nowrap;
user-select: none;
}
.data-table th.sortable {
cursor: pointer;
transition: background 0.2s;
}
.data-table th.sortable:hover {
background: var(--bg-hover);
}
.data-table th.sorted-asc .sort-icon,
.data-table th.sorted-desc .sort-icon {
color: var(--primary);
}
.data-table th.sorted-asc .sort-icon::before {
content: "\f0de"; /* fa-sort-up */
}
.data-table th.sorted-desc .sort-icon::before {
content: "\f0dd"; /* fa-sort-down */
}
.data-table tbody tr {
transition: background 0.2s;
}
.data-table tbody tr:hover {
background: var(--bg-hover);
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.status-badge.status-online {
background: var(--success-light);
color: var(--success);
}
.status-badge.status-offline {
background: var(--danger-light);
color: var(--danger);
}
.status-badge.status-loading {
background: var(--bg-surface-hover);
color: var(--text-muted);
}
.cluster-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: rgba(56, 189, 248, 0.1);
color: var(--info);
border: 1px solid rgba(56, 189, 248, 0.4);
border-radius: 0.375rem;
font-size: 0.7rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
margin-top: 0.25rem;
}
.cluster-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
min-width: 215px;
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
z-index: 200;
overflow: hidden;
}
.cluster-dropdown-item {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.7rem 1rem;
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 0.875rem;
transition: background 0.15s;
}
.cluster-dropdown-item:hover {
background: var(--bg-hover);
}
</style>
<!-- Modal для добавления сервера -->
<div id="addServerModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 550px; width: 90%; max-height: 90vh; overflow-y: auto; margin: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3><i class="fa-solid fa-plus"></i> {{ t('add_proxmox_server') }}</h3>
<button onclick="closeAddServerModal()" style="background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<div style="background: var(--info-dark); border: 1px solid var(--info); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1.5rem;">
<div style="display: flex; align-items: start; gap: 0.75rem;">
<span style="font-size: 1.25rem;">💡</span>
<div style="font-size: 0.875rem; color: var(--info-text-light);">
<strong>{{ t('auto_api_token_title') }}</strong><br>
{{ t('add_server_info') }}
</div>
</div>
</div>
<form id="addServerForm" onsubmit="submitAddServer(event)">
<div class="form-row">
<div>
<label>{{ t('server_name') }} *</label>
<input type="text" name="name" required placeholder="PVE Main">
</div>
<div>
<label>{{ t('hostname_fqdn') }} *</label>
<input type="text" name="hostname" required placeholder="pve.example.com">
</div>
</div>
<div class="form-row">
<div>
<label>{{ t('ip_address') }} *</label>
<input type="text" name="ip_address" required placeholder="192.168.1.100">
</div>
<div>
<label>{{ t('port') }}</label>
<input type="number" name="port" value="8006" min="1" max="65535">
</div>
</div>
<div style="background: var(--bg-primary); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<h4 style="margin: 0 0 1rem 0; color: var(--text-primary); font-size: 0.9rem;"><i class="fa-solid fa-lock"></i> {{ t('proxmox_auth') }}</h4>
<div class="form-row">
<div>
<label>{{ t('api_user') }} *</label>
<input type="text" name="api_user" value="root@pam" required placeholder="root@pam">
</div>
<div>
<label>{{ t('password') }} *</label>
<input type="password" name="password" id="addPassword" required placeholder="{{ t('root_password') }}">
</div>
</div>
</div>
<div style="margin: 1rem 0;">
<div class="checkbox-row">
<label style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" name="verify_ssl">
<span>{{ t('verify_ssl') }}</span>
</label>
<small style="color: var(--text-muted); margin-left: 1.5rem;">{{ t('verify_ssl_hint') }}</small>
</div>
</div>
<div style="margin-bottom: 1rem;">
<label>{{ t('server_description') }}</label>
<input type="text" name="description" placeholder="{{ t('server_description_placeholder') }}">
</div>
<div id="addServerStatus" style="display: none; margin-bottom: 1rem; padding: 1rem; border-radius: 0.5rem;"></div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button" onclick="closeAddServerModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">{{ t('cancel') }}</button>
<button type="submit" id="addServerBtn" class="btn btn-primary">🔗 {{ t('connect') }}</button>
</div>
</form>
</div>
</div>
<!-- Modal для редактирования сервера -->
<div id="editServerModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 600px; width: 90%; max-height: 90vh; overflow-y: auto; margin: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3><i class="fa-solid fa-pen"></i> {{ t('edit_server') }}</h3>
<button onclick="closeEditServerModal()" style="background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<form id="editServerForm" onsubmit="submitEditServer(event)">
<input type="hidden" id="editServerId" name="id">
<div class="form-row">
<div>
<label title="{{ t('server_name') }}">{{ t('server_name') }} * <span style="color: var(--text-muted); cursor: help;"><i class="fa-solid fa-circle-info"></i></span></label>
<input type="text" id="editName" name="name" required placeholder="PVE Main">
</div>
<div>
<label title="{{ t('hostname_fqdn') }}">{{ t('hostname_fqdn') }} * <span style="color: var(--text-muted); cursor: help;"><i class="fa-solid fa-circle-info"></i></span></label>
<input type="text" id="editHostname" name="hostname" required placeholder="pve.example.com">
</div>
</div>
<div class="form-row">
<div>
<label title="{{ t('ip_address') }}">{{ t('ip_address') }} * <span style="color: var(--text-muted); cursor: help;"><i class="fa-solid fa-circle-info"></i></span></label>
<input type="text" id="editIpAddress" name="ip_address" required placeholder="192.168.1.100">
</div>
<div>
<label title="{{ t('port') }}">{{ t('port') }} <span style="color: var(--text-muted); cursor: help;"><i class="fa-solid fa-circle-info"></i></span></label>
<input type="number" id="editPort" name="port" value="8006" min="1" max="65535">
</div>
</div>
<div style="margin-bottom: 1rem;">
<label title="{{ t('api_user') }}">{{ t('api_user') }} <span style="color: var(--text-muted); cursor: help;"><i class="fa-solid fa-circle-info"></i></span></label>
<input type="text" id="editApiUser" name="api_user" value="root@pam" placeholder="root@pam">
</div>
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bg-secondary); border-radius: 0.5rem;">
<div class="checkbox-row">
<label style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="editUsePassword" onchange="toggleEditAuthMethod()">
<span>{{ t('use_password_less_secure') }}</span>
</label>
</div>
</div>
<div id="editTokenFields">
<div class="form-row">
<div>
<label title="TokenID из Proxmox">TokenID * <span style="color: var(--text-secondary); cursor: help;"><i class="fa-solid fa-circle-info"></i></span></label>
<input type="text" id="editTokenName" name="api_token_name" placeholder="mytoken">
</div>
<div>
<label title="Secret из Proxmox">Secret * <span style="color: var(--text-secondary); cursor: help;"><i class="fa-solid fa-circle-info"></i></span></label>
<input type="text" id="editTokenValue" name="api_token_value" placeholder="xxxxxxxx-xxxx-xxxx">
</div>
</div>
</div>
<div id="editPasswordField" style="display: none;">
<label>{{ t('password') }} *</label>
<input type="password" id="editPassword" name="password" placeholder="{{ t('password') }}">
</div>
<div style="margin: 1rem 0;">
<div class="checkbox-row">
<label style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="editVerifySsl" name="verify_ssl">
<span>{{ t('verify_ssl') }}</span>
</label>
</div>
</div>
<div style="margin-bottom: 1rem;">
<label>{{ t('server_description') }}</label>
<input type="text" id="editDescription" name="description" placeholder="{{ t('server_description_placeholder') }}">
</div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button" onclick="closeEditServerModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">{{ t('cancel') }}</button>
<button type="submit" class="btn btn-primary">{{ t('save') }}</button>
</div>
</form>
</div>
</div>
<!-- ==================== Cluster: Create Cluster Modal ==================== -->
<div id="createClusterModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 520px; width: 90%; max-height: 90vh; overflow-y: auto; margin: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3><i class="fa-solid fa-network-wired" style="color: var(--success);"></i> Создать кластер Proxmox</h3>
<button onclick="closeCreateClusterModal()" style="background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<form id="createClusterForm" onsubmit="submitCreateCluster(event)">
<div style="margin-bottom: 1rem;">
<label>Сервер (нода для создания кластера) *</label>
<select id="createClusterServerId" name="server_id" required style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem;">
<option value="">-- Выберите сервер --</option>
{% for s in proxmox_servers %}{% if not s.cluster_name %}
<option value="{{ s.id }}">{{ s.name }} ({{ s.ip_address }})</option>
{% endif %}{% endfor %}
</select>
</div>
<div style="margin-bottom: 1rem;">
<label>Имя кластера *</label>
<input type="text" id="createClusterName" name="cluster_name" required
placeholder="mycluster" pattern="[a-zA-Z][a-zA-Z0-9\-]{0,14}"
title="Начинается с буквы, буквы/цифры/дефис, макс 15 символов"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
<small style="color: var(--text-muted);">Начинается с буквы, только буквы/цифры/дефис, макс. 15 символов</small>
</div>
<div style="margin-bottom: 1rem;">
<label>Corosync link0 IP <span style="color: var(--text-muted);">(опционально)</span></label>
<input type="text" id="createClusterLink0" name="link0_ip"
placeholder="192.168.1.101 (по умолчанию — IP сервера)"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
</div>
<div style="margin-bottom: 1.5rem;">
<label>Пароль root@pam <span style="color: var(--text-muted);">(если не сохранён в панели)</span></label>
<input type="password" id="createClusterRootpw" name="rootpw"
placeholder="Пароль для кластерных операций"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
</div>
{% if proxmox_servers | length <= 2 %}
<div style="background: rgba(234, 179, 8, 0.1); border: 1px solid var(--warning); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1.5rem;">
<div style="display: flex; align-items: start; gap: 0.75rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--warning); margin-top: 2px;"></i>
<div style="font-size: 0.875rem; color: var(--text-primary);">
<strong>Кластер из 2 нод</strong><br>
В 2-нодовых кластерах нет чёткого кворума — при отказе одной ноды оставшаяся не сможет запускать VM.
Рекомендуется добавить 3-ю ноду или настроить <strong>QDevice</strong> (<code>pvecm qdevice setup</code>).
Временное восстановление: <code>pvecm expected 1</code>.
</div>
</div>
</div>
{% endif %}
<div id="createClusterStatus" style="display: none; margin-bottom: 1rem; padding: 1rem; border-radius: 0.5rem;"></div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button" onclick="closeCreateClusterModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">Отмена</button>
<button type="submit" id="createClusterBtn" class="btn" style="background: var(--success); color: var(--text-on-success);">
<i class="fa-solid fa-circle-plus"></i> Создать кластер
</button>
</div>
</form>
</div>
</div>
<!-- ==================== Cluster: Join Cluster Modal (4-step) ==================== -->
<div id="joinClusterModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 580px; width: 90%; max-height: 90vh; overflow-y: auto; margin: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h3 style="margin: 0;"><i class="fa-solid fa-link" style="color: var(--info);"></i> Присоединить к кластеру</h3>
<small id="joinStepTitle" style="color: var(--text-muted);">Шаг 1 из 4: Выбор нод</small>
</div>
<button onclick="closeJoinClusterModal()" style="background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<!-- Warning: no cluster servers -->
<div id="joinNoClusterWarning" style="display: none; background: rgba(234, 179, 8, 0.1); border: 1px solid var(--warning); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1rem; font-size: 0.875rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--warning);"></i>
Нет серверов с активным кластером. Сначала <strong>создайте кластер</strong> на одном из серверов.
</div>
<!-- Step 1: Select nodes -->
<div id="joinStep1">
<div style="background: rgba(56, 189, 248, 0.1); border: 1px solid var(--info); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1.2rem; font-size: 0.875rem;">
<i class="fa-solid fa-circle-info" style="color: var(--info);"></i>
Выберите ноду для добавления в кластер и любую существующую ноду кластера.
</div>
<form id="joinStep1Form" onsubmit="submitJoinStep1(event)">
<div style="margin-bottom: 1rem;">
<label>Добавляемая нода (standalone) *</label>
<select id="joinNodeServerId" required style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem;">
<option value="">-- Выберите ноду для добавления --</option>
{% for s in proxmox_servers %}{% if not s.cluster_name %}
<option value="{{ s.id }}">{{ s.name }} ({{ s.ip_address }})</option>
{% endif %}{% endfor %}
</select>
</div>
<div style="margin-bottom: 1.5rem;">
<label>Кластерная нода (источник join-информации) *</label>
<select id="joinClusterServerId" required style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem;">
<option value="">-- Выберите ноду кластера --</option>
{% for s in proxmox_servers %}{% if s.cluster_name %}
<option value="{{ s.id }}" data-cluster="{{ s.cluster_name }}">{{ s.name }} (кластер: {{ s.cluster_name }})</option>
{% endif %}{% endfor %}
</select>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button" onclick="closeJoinClusterModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">Отмена</button>
<button type="submit" id="joinStep1Btn" class="btn btn-primary">Далее <i class="fa-solid fa-arrow-right"></i></button>
</div>
</form>
</div>
<!-- Step 2: Pre-check results -->
<div id="joinStep2" style="display: none;">
<div id="joinPreCheckResult"></div>
<div id="joinPrepareSection" style="display: none; margin-top: 0.5rem;">
<div style="margin-bottom: 1rem;">
<label>Хранилище для резервных копий *</label>
<select id="joinBackupStorage" style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem;">
<option value="">-- Загрузка хранилищ... --</option>
</select>
<small style="color: var(--text-muted);">Рекомендуется общее хранилище (NFS, Ceph) — доступное в будущем из кластера.</small>
</div>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem;">
<button onclick="showJoinStep(1)" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">
<i class="fa-solid fa-arrow-left"></i> Назад
</button>
<button id="joinSkipToStep4Btn" onclick="joinSkipToFinalStep()" class="btn btn-primary" style="display: none;">
Продолжить <i class="fa-solid fa-arrow-right"></i>
</button>
<button id="joinStep2PrepBtn" onclick="submitJoinPrepare()" class="btn" style="display: none; background: var(--warning); color: var(--bg-primary);">
<i class="fa-solid fa-hard-drive"></i> Бэкап и очистка ноды
</button>
</div>
</div>
<!-- Step 3: Backup progress -->
<div id="joinStep3" style="display: none;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; color: var(--text-secondary); font-size: 0.875rem;">
<i class="fa-solid fa-hard-drive"></i>
Резервное копирование и очистка гестов перед присоединением к кластеру
</div>
<div id="joinPrepResult"></div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem;">
<button id="joinPrepContinueBtn" onclick="showJoinStep(4)" class="btn btn-primary" style="display: none;">
Продолжить <i class="fa-solid fa-arrow-right"></i>
</button>
</div>
</div>
<!-- Step 4: Final join -->
<div id="joinStep4" style="display: none;">
<div style="background: rgba(234, 179, 8, 0.1); border: 1px solid var(--warning); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1.2rem; font-size: 0.875rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--warning);"></i>
После присоединения нода синхронизирует конфигурацию с кластером. Операция необратима.
</div>
<form onsubmit="submitJoinFinal(event)">
<div style="margin-bottom: 1rem;">
<label>Пароль root@pam присоединяемой ноды *</label>
<input type="password" id="joinRootpw" required placeholder="root-пароль"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
</div>
<div style="margin-bottom: 1.5rem;">
<label>Corosync link0 IP <span style="color: var(--text-muted);">(опционально)</span></label>
<input type="text" id="joinLink0" placeholder="IP присоединяемой ноды для corosync"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
</div>
<div id="joinFinalStatus" style="display: none; margin-bottom: 1rem; padding: 1rem; border-radius: 0.5rem;"></div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button"
onclick="showJoinStep(joinState.preCheck && joinState.preCheck.guests_count > 0 ? 3 : 2)"
class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">
<i class="fa-solid fa-arrow-left"></i> Назад
</button>
<button type="submit" id="joinFinalBtn" class="btn" style="background: var(--success); color: var(--text-on-success);">
<i class="fa-solid fa-link"></i> Присоединить к кластеру
</button>
</div>
</form>
</div>
</div>
</div>
<!-- ==================== Cluster: Eject Node Modal ==================== -->
<div id="ejectNodeModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 500px; width: 90%; max-height: 90vh; overflow-y: auto; margin: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3><i class="fa-solid fa-right-from-bracket" style="color: var(--danger);"></i> Исключить ноду из кластера</h3>
<button onclick="closeEjectNodeModal()" style="background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1.5rem; font-size: 0.875rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--danger);"></i>
<strong> Внимание!</strong> Исключаемая нода должна быть <strong>полностью выключена</strong> до запуска операции.
После исключения на ней необходимо вручную сбросить конфигурацию кластера.
</div>
<form id="ejectNodeForm" onsubmit="submitEjectNode(event)">
<div style="margin-bottom: 1rem;">
<label>Управляющий сервер (остаётся в кластере) *</label>
<select id="ejectFromServerId" required style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem;">
<option value="">-- Выберите кластерный сервер --</option>
{% for s in proxmox_servers %}{% if s.cluster_name %}
<option value="{{ s.id }}" data-cluster="{{ s.cluster_name }}">{{ s.name }} (кластер: {{ s.cluster_name }})</option>
{% endif %}{% endfor %}
</select>
</div>
<div style="margin-bottom: 1rem;">
<label>Hostname ноды для исключения *</label>
<input type="text" id="ejectNodeName" required placeholder="pve2 (hostname как в pvecm status)"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
<small style="color: var(--text-muted);">Hostname как в <code>pvecm status</code>, не IP-адрес</small>
</div>
<div style="margin-bottom: 1.5rem;">
<label>Пароль root@pam <span style="color: var(--text-muted);">(для SSH fallback)</span></label>
<input type="password" id="ejectRootpw" placeholder="Используется при ошибке REST API"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
</div>
<div id="ejectNodeStatus" style="display: none; margin-bottom: 1rem; padding: 1rem; border-radius: 0.5rem;"></div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button" onclick="closeEjectNodeModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">Отмена</button>
<button type="submit" id="ejectNodeBtn" class="btn" style="background: var(--danger); color: white;">
<i class="fa-solid fa-right-from-bracket"></i> Исключить ноду
</button>
</div>
</form>
</div>
</div>
<div id="stats-section" style="display: none;">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">{{ t('total_vms') }}</div>
<div class="stat-value" id="total-vms">0</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('total_lxc') }}</div>
<div class="stat-value" id="total-containers" style="color: var(--info);">0</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('running') }}</div>
<div class="stat-value" id="running-count" style="color: var(--success);">0</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('stopped') }}</div>
<div class="stat-value" id="stopped-count" style="color: var(--danger);">0</div>
</div>
</div>
</div>
<div id="resources-container"></div>
<div id="loading" style="display: none; text-align: center; padding: 2rem;">
<div style="color: var(--text-muted);">{{ t('loading') }}</div>
</div>
<script>
let currentData = null;
function showAddServerModal() {
document.getElementById('addServerModal').style.display = 'flex';
document.getElementById('addServerStatus').style.display = 'none';
}
function closeAddServerModal() {
document.getElementById('addServerModal').style.display = 'none';
document.getElementById('addServerForm').reset();
document.getElementById('addServerStatus').style.display = 'none';
}
function showAddStatus(message, isError = false) {
const status = document.getElementById('addServerStatus');
status.style.display = 'block';
status.style.background = isError ? 'var(--danger-dark)' : 'var(--success-dark)';
status.style.border = `1px solid ${isError ? 'var(--danger)' : 'var(--success)'}`;
status.innerHTML = `<div style="color: ${isError ? 'var(--danger-text-light)' : 'var(--success-text-light)'};">${message}</div>`;
}
async function submitAddServer(event) {
event.preventDefault();
const btn = document.getElementById('addServerBtn');
const originalText = btn.innerHTML;
btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> ${t('connecting')}`;
btn.disabled = true;
const formData = new FormData(event.target);
const data = {
name: formData.get('name'),
hostname: formData.get('hostname'),
ip_address: formData.get('ip_address'),
port: parseInt(formData.get('port')) || 8006,
api_user: formData.get('api_user') || 'root@pam',
password: formData.get('password'),
verify_ssl: formData.get('verify_ssl') === 'on',
description: formData.get('description') || null
};
try {
showAddStatus('<i class="fa-solid fa-rotate"></i> Проверка подключения к Proxmox...');
const response = await fetchWithAuth('/proxmox/api/servers/auto-setup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
if (result.cluster) {
// Кластер - добавлено несколько нод
const nodesList = result.servers.map(s => `• ${s.name} (${s.ip}) - ${s.status}`).join('<br>');
showAddStatus(`<i class="fa-solid fa-circle-check"></i> Обнаружен кластер! Добавлено ${result.nodes_count} нод:<br>${nodesList}`);
} else {
// Одиночный сервер
showAddStatus(`<i class="fa-solid fa-circle-check"></i> Сервер "${result.name}" успешно добавлен!<br><i class="fa-solid fa-key"></i> API Token создан: ${result.api_token_name}`);
}
setTimeout(() => {
closeAddServerModal();
window.location.reload();
}, 2000);
} else {
showAddStatus(`<i class="fa-solid fa-circle-xmark"></i> ${result.detail || 'Не удалось добавить сервер'}`, true);
}
} catch (error) {
console.error('Error adding server:', error);
showAddStatus('<i class="fa-solid fa-circle-xmark"></i> Ошибка соединения с сервером', true);
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
function showEditServerModal() {
document.getElementById('editServerModal').style.display = 'flex';
}
function closeEditServerModal() {
document.getElementById('editServerModal').style.display = 'none';
document.getElementById('editServerForm').reset();
document.getElementById('editTokenFields').style.display = 'block';
document.getElementById('editPasswordField').style.display = 'none';
document.getElementById('editUsePassword').checked = false;
}
function toggleEditAuthMethod() {
const usePassword = document.getElementById('editUsePassword').checked;
const tokenFields = document.getElementById('editTokenFields');
const passwordField = document.getElementById('editPasswordField');
const tokenName = document.getElementById('editTokenName');
const tokenValue = document.getElementById('editTokenValue');
const password = document.getElementById('editPassword');
if (usePassword) {
tokenFields.style.display = 'none';
passwordField.style.display = 'block';
tokenName.removeAttribute('required');
tokenValue.removeAttribute('required');
password.setAttribute('required', '');
} else {
tokenFields.style.display = 'block';
passwordField.style.display = 'none';
tokenName.setAttribute('required', '');
tokenValue.setAttribute('required', '');
password.removeAttribute('required');
}
}
async function editServer(serverId) {
try {
const response = await fetchWithAuth(`/proxmox/api/servers/${serverId}`);
if (response.ok) {
const server = await response.json();
document.getElementById('editServerId').value = server.id;
document.getElementById('editName').value = server.name;
document.getElementById('editHostname').value = server.hostname;
document.getElementById('editIpAddress').value = server.ip_address;
document.getElementById('editPort').value = server.port;
document.getElementById('editApiUser').value = server.api_user;
document.getElementById('editVerifySsl').checked = server.verify_ssl;
document.getElementById('editDescription').value = server.description || '';
// Note: We don't populate token/password fields for security
document.getElementById('editUsePassword').checked = false;
toggleEditAuthMethod();
showEditServerModal();
} else {
showToast('error', t('error'), t('failed_to_load_server_data') || 'Ошибка загрузки данных сервера');
}
} catch (error) {
console.error('Error loading server:', error);
showToast('error', t('error'), t('connection_error'));
}
}
async function submitEditServer(event) {
event.preventDefault();
const formData = new FormData(event.target);
const serverId = document.getElementById('editServerId').value;
const usePassword = document.getElementById('editUsePassword').checked;
const data = {
name: formData.get('name'),
hostname: formData.get('hostname'),
ip_address: formData.get('ip_address'),
port: parseInt(formData.get('port')),
api_user: formData.get('api_user'),
verify_ssl: formData.get('verify_ssl') === 'on'
};
// Only update auth credentials if provided
if (usePassword) {
const password = formData.get('password');
if (password) {
data.use_password = true;
data.password = password;
}
} else {
const tokenName = formData.get('api_token_name');
const tokenValue = formData.get('api_token_value');
if (tokenName && tokenValue) {
data.use_password = false;
data.api_token_name = tokenName;
data.api_token_value = tokenValue;
}
}
const description = formData.get('description');
if (description) {
data.description = description;
}
try {
const response = await fetchWithAuth(`/proxmox/api/servers/${serverId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (response.ok) {
const result = await response.json();
showToast('success', t('success'), `${t('server_updated_successfully')} "${result.name}"` || `Сервер "${result.name}" успешно обновлён!`);
closeEditServerModal();
window.location.reload();
} else {
const error = await response.json();
showToast('error', t('error'), error.detail || t('failed_to_update_server'));
}
} catch (error) {
console.error('Error updating server:', error);
showToast('error', t('error'), t('connection_error'));
}
}
async function deleteServer(serverId, serverName) {
const confirmed = await showDeleteConfirm(
`${t('confirm_delete_server_msg') || 'Are you sure you want to delete server'} "${serverName}"?\n\n${t('action_irreversible') || 'This action cannot be undone.'}`,
`${t('delete_server') || 'Delete Server'}`
);
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/proxmox/api/servers/${serverId}`, {
method: 'DELETE'
});
if (response.ok) {
showToast('success', t('success'), `${t('server_deleted') || 'Server deleted'}: "${serverName}"`);
window.location.reload();
} else {
const error = await response.json();
showToast('error', t('error'), error.detail || t('failed_to_delete_server') || 'Failed to delete server');
}
} catch (error) {
console.error('Error deleting server:', error);
showToast('error', t('error'), t('connection_error') || 'Connection error');
}
}
async function testConnection(serverId) {
const statusCell = document.getElementById(`server-status-${serverId}`);
statusCell.innerHTML = `<span class="status-badge status-loading"><i class="fa-solid fa-spinner fa-spin"></i> ${t('checking_connection')}</span>`;
try {
const response = await fetchWithAuth(`/proxmox/api/servers/${serverId}/test`, {
method: 'POST'
});
if (response.ok) {
statusCell.innerHTML = `<span class="status-badge status-online"><i class="fa-solid fa-circle-check"></i> ${t('connection_successful')}</span>`;
await loadServerStatus(serverId);
} else {
const error = await response.json();
statusCell.innerHTML = `<span class="status-badge status-offline"><i class="fa-solid fa-circle-xmark"></i> ${error.detail}</span>`;
}
} catch (error) {
console.error('Error testing connection:', error);
statusCell.innerHTML = `<span class="status-badge status-offline"><i class="fa-solid fa-circle-xmark"></i> ${t('connection_error')}</span>`;
}
}
async function loadServerStatus(serverId) {
const statusCell = document.getElementById(`server-status-${serverId}`);
const vmsCell = document.getElementById(`server-vms-${serverId}`);
const lxcCell = document.getElementById(`server-lxc-${serverId}`);
const row = document.getElementById(`server-row-${serverId}`);
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/resources`);
if (response.ok) {
const data = await response.json();
const vmsCount = data.vms ? data.vms.length : 0;
const containersCount = data.containers ? data.containers.length : 0;
const runningVMs = data.vms ? data.vms.filter(v => v.status === 'running').length : 0;
const runningContainers = data.containers ? data.containers.filter(c => c.status === 'running').length : 0;
// Update VM count
vmsCell.innerHTML = `
<span style="color: var(--purple); font-weight: 600;">${vmsCount}</span>
<span style="color: var(--text-muted); font-size: 0.75rem;">(${runningVMs} <i class="fa-solid fa-play" style="color: var(--success);"></i>)</span>
`;
// Update LXC count
lxcCell.innerHTML = `
<span style="color: var(--info); font-weight: 600;">${containersCount}</span>
<span style="color: var(--text-muted); font-size: 0.75rem;">(${runningContainers} <i class="fa-solid fa-play" style="color: var(--success);"></i>)</span>
`;
// Update status
statusCell.innerHTML = `<span class="status-badge status-online"><i class="fa-solid fa-circle"></i> Online</span>`;
// Update data attributes for sorting
if (row) {
row.dataset.vms = vmsCount;
row.dataset.lxc = containersCount;
row.dataset.status = 'online';
}
} else if (response.status === 403) {
// Auth error - wait and retry
setTimeout(() => loadServerStatus(serverId), 1000);
} else {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
vmsCell.innerHTML = `<span style="color: var(--text-muted);">—</span>`;
lxcCell.innerHTML = `<span style="color: var(--text-muted);">—</span>`;
statusCell.innerHTML = `<span class="status-badge status-offline"><i class="fa-solid fa-circle-xmark"></i> Offline</span>`;
if (row) row.dataset.status = 'offline';
}
} catch (error) {
console.error(`Error loading status for server ${serverId}:`, error);
statusCell.innerHTML = `<span class="status-badge status-loading"><i class="fa-solid fa-spinner fa-spin"></i> ${t('checking')}</span>`;
// Retry after delay
setTimeout(() => loadServerStatus(serverId), 2000);
}
}
// Sorting functionality
let currentSort = { column: 'name', direction: 'asc' };
function sortTable(column) {
const tbody = document.getElementById('serversTableBody');
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr'));
// Toggle direction if same column
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'asc';
}
// Update header icons
document.querySelectorAll('.data-table th.sortable').forEach(th => {
th.classList.remove('sorted-asc', 'sorted-desc');
});
const activeHeader = document.querySelector(`.data-table th[data-sort="${column}"]`);
if (activeHeader) {
activeHeader.classList.add(currentSort.direction === 'asc' ? 'sorted-asc' : 'sorted-desc');
}
// Sort rows
rows.sort((a, b) => {
let aVal, bVal;
switch(column) {
case 'name':
aVal = a.dataset.name || '';
bVal = b.dataset.name || '';
break;
case 'ip':
aVal = a.dataset.ip || '';
bVal = b.dataset.ip || '';
break;
case 'node':
aVal = a.dataset.node || '';
bVal = b.dataset.node || '';
break;
case 'vms':
aVal = parseInt(a.dataset.vms) || 0;
bVal = parseInt(b.dataset.vms) || 0;
return currentSort.direction === 'asc' ? aVal - bVal : bVal - aVal;
case 'lxc':
aVal = parseInt(a.dataset.lxc) || 0;
bVal = parseInt(b.dataset.lxc) || 0;
return currentSort.direction === 'asc' ? aVal - bVal : bVal - aVal;
case 'status':
aVal = a.dataset.status || '';
bVal = b.dataset.status || '';
// Online first
if (aVal === 'online' && bVal !== 'online') return currentSort.direction === 'asc' ? -1 : 1;
if (aVal !== 'online' && bVal === 'online') return currentSort.direction === 'asc' ? 1 : -1;
break;
default:
aVal = '';
bVal = '';
}
if (currentSort.direction === 'asc') {
return aVal.localeCompare(bVal);
} else {
return bVal.localeCompare(aVal);
}
});
// Reorder DOM
rows.forEach(row => tbody.appendChild(row));
}
async function loadAllStatuses() {
const rows = document.querySelectorAll('[id^="server-row-"]');
for (const row of rows) {
const serverId = row.dataset.serverId;
if (serverId) {
await loadServerStatus(serverId);
}
}
}
async function loadAllResources() {
const container = document.getElementById('resources-container');
const loading = document.getElementById('loading');
const statsSection = document.getElementById('stats-section');
container.innerHTML = '';
loading.style.display = 'block';
statsSection.style.display = 'none';
try {
const response = await fetchWithAuth('/proxmox/api/resources/all');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Проверка данных
if (!data.servers || !Array.isArray(data.servers)) {
throw new Error('Invalid data format');
}
currentData = data;
displayStats(data);
displayResources(data);
statsSection.style.display = 'block';
} catch (error) {
console.error('Error loading all resources:', error);
container.innerHTML = `<div class="card"><p style="color: var(--danger);">Ошибка загрузки: ${error.message || error}</p></div>`;
} finally {
loading.style.display = 'none';
}
}
async function loadServerResources(serverId) {
const container = document.getElementById('resources-container');
const loading = document.getElementById('loading');
const statsSection = document.getElementById('stats-section');
container.innerHTML = '';
loading.style.display = 'block';
statsSection.style.display = 'none';
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/resources`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Проверка и обработка данных
const vms = data.vms || [];
const containers = data.containers || [];
// Преобразуем в формат для отображения
const formattedData = {
servers: [{
id: data.server_id,
name: data.server_name,
vms: vms,
containers: containers,
vms_count: vms.length,
containers_count: containers.length
}],
total_vms: vms.length,
total_containers: containers.length
};
currentData = formattedData;
displayStats(formattedData);
displayResources(formattedData);
statsSection.style.display = 'block';
} catch (error) {
console.error('Error loading server resources:', error);
container.innerHTML = `<div class="card"><p style="color: var(--danger);">Ошибка загрузки: ${error.message || error}</p></div>`;
} finally {
loading.style.display = 'none';
}
}
function displayStats(data) {
if (!data || !data.servers) return;
document.getElementById('total-vms').textContent = data.total_vms || 0;
document.getElementById('total-containers').textContent = data.total_containers || 0;
let running = 0;
let stopped = 0;
data.servers.forEach(server => {
const vms = server.vms || [];
const containers = server.containers || [];
[...vms, ...containers].forEach(resource => {
if (resource.status === 'running') running++;
else stopped++;
});
});
document.getElementById('running-count').textContent = running;
document.getElementById('stopped-count').textContent = stopped;
}
function displayResources(data) {
const container = document.getElementById('resources-container');
if (!data || !data.servers || !Array.isArray(data.servers)) {
container.innerHTML = '<div class="card"><p style="color: var(--text-secondary);">Нет данных для отображения</p></div>';
return;
}
data.servers.forEach(server => {
if (server.error) {
container.innerHTML += `
<div class="card">
<h3>${server.name}</h3>
<p style="color: var(--danger);">Ошибка: ${server.error}</p>
</div>
`;
return;
}
const vms = server.vms || [];
const containers = server.containers || [];
// VM section
if (vms.length > 0) {
container.innerHTML += `
<div class="card">
<h3><i class="fa-solid fa-desktop"></i> ${server.name} - Виртуальные машины (${vms.length})</h3>
<div class="server-cards" style="margin-top: 1rem;">
${vms.map(vm => createResourceCard(vm, server.id, 'vm')).join('')}
</div>
</div>
`;
}
// Containers section
if (containers.length > 0) {
container.innerHTML += `
<div class="card">
<h3><i class="fa-solid fa-cube"></i> ${server.name} - LXC Контейнеры (${containers.length})</h3>
<div class="server-cards" style="margin-top: 1rem;">
${containers.map(ct => createResourceCard(ct, server.id, 'container')).join('')}
</div>
</div>
`;
}
if (vms.length === 0 && containers.length === 0) {
container.innerHTML += `
<div class="card">
<h3>${server.name}</h3>
<p style="color: var(--text-secondary);">Нет VM или контейнеров</p>
</div>
`;
}
});
if (container.innerHTML === '') {
container.innerHTML = '<div class="card"><p style="color: var(--text-secondary);">Нет VM или контейнеров</p></div>';
}
}
function createResourceCard(resource, serverId, type) {
const statusClass = resource.status === 'running' ? 'status-online' : 'status-offline';
const statusText = resource.status === 'running' ? 'Запущен' : 'Остановлен';
const cpuPercent = resource.cpu ? (resource.cpu * 100).toFixed(1) : '0';
const memPercent = resource.mem && resource.maxmem ? ((resource.mem / resource.maxmem) * 100).toFixed(1) : '0';
return `
<div class="server-card">
<div class="server-card-header">
<h3 class="server-card-title">${resource.name || 'VM ' + resource.vmid}</h3>
<span class="server-card-badge" style="background: ${type === 'vm' ? 'var(--purple)' : 'var(--info)'};">
${type === 'vm' ? 'QEMU' : 'LXC'}
</span>
</div>
<div class="server-status">
<span class="status-indicator ${statusClass}"></span>
<span>${statusText}</span>
</div>
<div class="server-card-info">
<div><label>ID</label><span>${resource.vmid}</span></div>
<div><label>Node</label><span>${resource.node}</span></div>
<div><label>CPU</label><span>${cpuPercent}%</span></div>
<div><label>Memory</label><span>${memPercent}%</span></div>
</div>
<div class="actions">
${resource.status !== 'running' ?
`<button onclick="controlResource(${serverId}, '${resource.node}', ${resource.vmid}, 'start', '${type}')"
class="btn btn-sm" style="background: var(--success); color: var(--text-on-success);"><i class="fa-solid fa-play"></i> Start</button>` :
`<button onclick="controlResource(${serverId}, '${resource.node}', ${resource.vmid}, 'stop', '${type}')"
class="btn btn-sm btn-delete"><i class="fa-solid fa-stop"></i> Stop</button>`
}
<button onclick="controlResource(${serverId}, '${resource.node}', ${resource.vmid}, 'restart', '${type}')"
class="btn btn-sm" style="background: var(--warning); color: var(--text-on-warning);"><i class="fa-solid fa-rotate"></i> Restart</button>
<button onclick="showMonitorModal(${serverId}, '${resource.node}', ${resource.vmid}, '${type}', '${resource.name || resource.vmid}')"
class="btn btn-sm" style="background: var(--accent); color: var(--bg-primary);"><i class="fa-solid fa-chart-simple"></i> Монитор</button>
<button onclick="showVNCModal(${serverId}, '${resource.node}', ${resource.vmid}, '${type}', '${resource.name || resource.vmid}')"
class="btn btn-sm" style="background: var(--info); color: var(--text-on-info);"><i class="fa-solid fa-desktop"></i> VNC</button>
</div>
</div>
`;
}
// Модальное окно мониторинга
function showMonitorModal(serverId, node, vmid, type, name) {
const modal = document.getElementById('monitorModal');
document.getElementById('monitorTitle').innerHTML = `
<i class="fa-solid fa-chart-simple"></i> Мониторинг: ${name} (${vmid})
<span id="updateIndicator" style="display: inline-block; margin-left: 0.5rem; width: 8px; height: 8px; background: var(--accent); border-radius: 50%; opacity: 0; transition: opacity 0.3s ease;"></span>
`;
document.getElementById('monitorContent').innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> Загрузка данных...</div>';
modal.style.display = 'flex';
// Загрузить данные мониторинга
loadMonitorData(serverId, node, vmid, type);
// Обновлять каждые 5 секунд
if (window.monitorInterval) clearInterval(window.monitorInterval);
window.monitorInterval = setInterval(() => {
if (modal.style.display === 'flex') {
loadMonitorData(serverId, node, vmid, type);
}
}, 5000);
}
function closeMonitorModal() {
const modal = document.getElementById('monitorModal');
const monitorContent = document.getElementById('monitorContent');
modal.style.display = 'none';
// Очистить флаг инициализации для следующего открытия
if (monitorContent) {
delete monitorContent.dataset.initialized;
}
if (window.monitorInterval) {
clearInterval(window.monitorInterval);
window.monitorInterval = null;
}
}
async function loadMonitorData(serverId, node, vmid, type) {
const monitorContent = document.getElementById('monitorContent');
const isFirstLoad = !monitorContent.dataset.initialized;
try {
// Показать индикатор обновления
const updateIndicator = document.getElementById('updateIndicator');
if (updateIndicator) {
updateIndicator.style.opacity = '1';
}
const [statusRes, rrdRes] = await Promise.all([
fetchWithAuth(`/proxmox/api/${serverId}/${type}/${vmid}/status?node=${node}`),
fetchWithAuth(`/proxmox/api/${serverId}/${type}/${vmid}/rrddata?node=${node}&timeframe=hour`)
]);
if (!statusRes.ok || !rrdRes.ok) {
throw new Error(`HTTP error: ${statusRes.status} / ${rrdRes.status}`);
}
const status = await statusRes.json();
const rrdData = await rrdRes.json();
// Первая загрузка - рендерим всё, последующие - только обновляем данные
if (isFirstLoad) {
renderMonitorData(status, rrdData, type);
monitorContent.dataset.initialized = 'true';
} else {
updateMonitorData(status, rrdData);
}
// Скрыть индикатор
if (updateIndicator) {
setTimeout(() => {
updateIndicator.style.opacity = '0';
}, 300);
}
} catch (error) {
console.error('Error loading monitor data:', error);
document.getElementById('monitorContent').innerHTML = `
<div style="padding: 2rem; text-align: center;">
<div style="background: var(--bg-tertiary); padding: 2rem; border-radius: 8px;">
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-triangle-exclamation"></i></div>
<h3 style="color: var(--text-primary); margin-bottom: 1rem;">Ошибка загрузки данных</h3>
<p style="color: var(--text-secondary);">${error.message}</p>
<button onclick="closeMonitorModal()" class="btn" style="margin-top: 1rem;">{{ t('close') }}</button>
</div>
</div>
`;
}
}
function updateMonitorData(status, rrdData) {
// Обновить метрики без перерисовки
const cpuPercent = ((status.cpu || 0) * 100).toFixed(1);
const memPercent = (((status.mem || 0) / (status.maxmem || 1)) * 100).toFixed(1);
const diskPercent = (((status.disk || 0) / (status.maxdisk || 1)) * 100).toFixed(1);
// Uptime
const totalSeconds = status.uptime || 0;
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = Math.floor(totalSeconds % 60);
let uptimeStr = days > 0
? `${days} ${days === 1 ? 'day' : 'days'}, ${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
: `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
// Обновить значения в DOM с плавной анимацией
const updates = [
{ selector: '[data-metric="status"]', value: status.status === 'running' ? '● Запущен' : '○ Остановлен' },
{ selector: '[data-metric="uptime"]', value: uptimeStr },
{ selector: '[data-metric="cpu-value"]', value: `${cpuPercent}%` },
{ selector: '[data-metric="cpu-progress"]', style: { width: `${cpuPercent}%` } },
{ selector: '[data-metric="mem-value"]', value: `${memPercent}%` },
{ selector: '[data-metric="mem-size"]', value: `${formatBytes(status.mem)} / ${formatBytes(status.maxmem)}` },
{ selector: '[data-metric="mem-progress"]', style: { width: `${memPercent}%` } },
{ selector: '[data-metric="disk-value"]', value: `${diskPercent}%` },
{ selector: '[data-metric="disk-size"]', value: `${formatBytes(status.disk)} / ${formatBytes(status.maxdisk)}` },
{ selector: '[data-metric="disk-progress"]', style: { width: `${diskPercent}%` } },
{ selector: '[data-metric="netin"]', value: formatBytes(status.netin || 0) },
{ selector: '[data-metric="netout"]', value: formatBytes(status.netout || 0) },
{ selector: '[data-metric="diskread"]', value: formatBytes(status.diskread || 0) },
{ selector: '[data-metric="diskwrite"]', value: formatBytes(status.diskwrite || 0) }
];
updates.forEach(update => {
const element = document.querySelector(update.selector);
if (element) {
if (update.value !== undefined) {
element.textContent = update.value;
}
if (update.style) {
Object.assign(element.style, update.style);
}
}
});
// Обновить графики (добавить новые точки вместо перерисовки)
updateCharts(status, rrdData);
}
function renderMonitorData(status, rrdData, type) {
const monitorContent = document.getElementById('monitorContent');
if (!monitorContent.style.transition) {
monitorContent.style.transition = 'opacity 0.3s ease-in-out';
}
const cpuPercent = ((status.cpu || 0) * 100).toFixed(1);
const memPercent = (((status.mem || 0) / (status.maxmem || 1)) * 100).toFixed(1);
const diskPercent = (((status.disk || 0) / (status.maxdisk || 1)) * 100).toFixed(1);
// Форматирование uptime как в Proxmox: "9 days, 12:30:25"
const totalSeconds = status.uptime || 0;
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = Math.floor(totalSeconds % 60);
let uptimeStr = '';
if (days > 0) {
uptimeStr = `${days} ${days === 1 ? 'day' : 'days'}, ${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
} else {
uptimeStr = `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
let html = `
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
<div class="stat-card">
<div class="stat-label">{{ t('status') }}</div>
<div data-metric="status" style="font-size: 1.25rem; color: ${status.status === 'running' ? 'var(--accent)' : 'var(--text-muted)'}; transition: color 0.3s ease;">
${status.status === 'running' ? '● ' + t('running_status') : '○ ' + t('stopped_status')}
</div>
</div>
<div class="stat-card">
<div class="stat-label">${t('uptime')}</div>
<div data-metric="uptime" style="font-size: 1.25rem; color: var(--text-primary);">${uptimeStr}</div>
</div>
<div class="stat-card">
<div class="stat-label">CPU</div>
<div data-metric="cpu-value" style="font-size: 1.25rem; color: var(--accent);">${cpuPercent}%</div>
<div class="progress-bar" style="margin-top: 0.5rem;">
<div data-metric="cpu-progress" class="progress-fill ${cpuPercent > 80 ? 'danger' : cpuPercent > 60 ? 'warning' : ''}" style="width: ${cpuPercent}%; transition: width 0.5s ease;"></div>
</div>
</div>
<div class="stat-card">
<div class="stat-label">${t('memory_usage')}</div>
<div data-metric="mem-value" style="font-size: 1.25rem; color: var(--accent);">${memPercent}%</div>
<div data-metric="mem-size" style="font-size: 0.75rem; color: var(--text-muted);">${formatBytes(status.mem)} / ${formatBytes(status.maxmem)}</div>
<div class="progress-bar" style="margin-top: 0.5rem;">
<div data-metric="mem-progress" class="progress-fill ${memPercent > 80 ? 'danger' : memPercent > 60 ? 'warning' : ''}" style="width: ${memPercent}%; transition: width 0.5s ease;"></div>
</div>
</div>
<div class="stat-card">
<div class="stat-label">${t('disk')}</div>
<div data-metric="disk-value" style="font-size: 1.25rem; color: var(--accent);">${diskPercent}%</div>
<div data-metric="disk-size" style="font-size: 0.75rem; color: var(--text-muted);">${formatBytes(status.disk)} / ${formatBytes(status.maxdisk)}</div>
<div class="progress-bar" style="margin-top: 0.5rem;">
<div data-metric="disk-progress" class="progress-fill ${diskPercent > 80 ? 'danger' : diskPercent > 60 ? 'warning' : ''}" style="width: ${diskPercent}%; transition: width 0.5s ease;"></div>
</div>
</div>
<div class="stat-card">
<div class="stat-label">${t('network_in')}</div>
<div data-metric="netin" style="font-size: 1.25rem; color: var(--accent);">${formatBytes(status.netin || 0)}</div>
</div>
<div class="stat-card">
<div class="stat-label">${t('network_out')}</div>
<div data-metric="netout" style="font-size: 1.25rem; color: var(--accent);">${formatBytes(status.netout || 0)}</div>
</div>
<div class="stat-card">
<div class="stat-label">${t('disk_read')}</div>
<div data-metric="diskread" style="font-size: 1.25rem; color: var(--accent);">${formatBytes(status.diskread || 0)}</div>
</div>
<div class="stat-card">
<div class="stat-label">${t('disk_write')}</div>
<div data-metric="diskwrite" style="font-size: 1.25rem; color: var(--accent);">${formatBytes(status.diskwrite || 0)}</div>
</div>
</div>
<div style="margin-top: 1.5rem;">
<h4 style="margin-bottom: 1rem; color: var(--text-primary);"><i class="fa-solid fa-chart-line"></i> ${t('vm_graphs')}</h4>
<div id="chartsContainer" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem;">
${renderCharts(rrdData)}
</div>
</div>
`;
document.getElementById('monitorContent').innerHTML = html;
}
function renderCharts(rrdResponse) {
// Извлечь массив данных из ответа API {data: [...], timeframe: 'hour'}
const data = Array.isArray(rrdResponse) ? rrdResponse : (rrdResponse?.data || []);
if (!data || data.length === 0) {
return `<div style="color: var(--text-muted); text-align: center;">${t('no_chart_data')}</div>`;
}
// Подготовить данные для графиков
const cpuData = data.map(d => ({time: d.time, value: (d.cpu || 0) * 100}));
const memData = data.map(d => ({time: d.time, value: (d.mem || 0) / (1024*1024*1024)})); // GB
const netData = data.map(d => ({time: d.time, in: (d.netin || 0) / (1024*1024), out: (d.netout || 0) / (1024*1024)})); // MB
const diskData = data.map(d => ({time: d.time, read: (d.diskread || 0) / (1024*1024), write: (d.diskwrite || 0) / (1024*1024)})); // MB/s
const html = `
<div class="card" style="padding: 1rem;">
<div style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-secondary);">${t('cpu_usage_percent')}</div>
<canvas id="cpuChart" style="max-height: 200px;"></canvas>
</div>
<div class="card" style="padding: 1rem;">
<div style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-secondary);">${t('memory_gb')}</div>
<canvas id="memChart" style="max-height: 200px;"></canvas>
</div>
<div class="card" style="padding: 1rem;">
<div style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-secondary);">${t('network_mb_s')}</div>
<canvas id="netChart" style="max-height: 200px;"></canvas>
</div>
<div class="card" style="padding: 1rem;">
<div style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-secondary);">${t('disk_io')}</div>
<canvas id="diskChart" style="max-height: 200px;"></canvas>
</div>
`;
// Отрисовать графики после добавления в DOM
setTimeout(() => {
drawCharts(cpuData, memData, netData, diskData);
}, 100);
return html;
}
function updateCharts(status, rrdResponse) {
// Если графики ещё не созданы, ничего не делаем (будут созданы при первом рендере)
if (!window.cpuChart || !window.memChart || !window.netChart || !window.diskChart) {
return;
}
// Обновить данные в существующих графиках используя Chart.js update
const data = Array.isArray(rrdResponse) ? rrdResponse : (rrdResponse?.data || []);
if (data.length === 0) return;
const formatTime = (timestamp) => {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
};
// Обновить CPU график
if (window.cpuChart) {
window.cpuChart.data.labels = data.map(d => formatTime(d.time));
window.cpuChart.data.datasets[0].data = data.map(d => ((d.cpu || 0) * 100).toFixed(2));
window.cpuChart.update('none'); // 'none' = без анимации для плавности
}
// Обновить Memory график
if (window.memChart) {
window.memChart.data.labels = data.map(d => formatTime(d.time));
window.memChart.data.datasets[0].data = data.map(d => ((d.mem || 0) / (1024*1024*1024)).toFixed(2));
window.memChart.update('none');
}
// Обновить Network график
if (window.netChart) {
window.netChart.data.labels = data.map(d => formatTime(d.time));
window.netChart.data.datasets[0].data = data.map(d => ((d.netin || 0) / (1024*1024)).toFixed(2));
window.netChart.data.datasets[1].data = data.map(d => ((d.netout || 0) / (1024*1024)).toFixed(2));
window.netChart.update('none');
}
// Обновить Disk I/O график
if (window.diskChart) {
window.diskChart.data.labels = data.map(d => formatTime(d.time));
window.diskChart.data.datasets[0].data = data.map(d => ((d.diskread || 0) / (1024*1024)).toFixed(2));
window.diskChart.data.datasets[1].data = data.map(d => ((d.diskwrite || 0) / (1024*1024)).toFixed(2));
window.diskChart.update('none');
}
}
function drawCharts(cpuData, memData, netData, diskData) {
// Проверка наличия Chart.js
if (typeof Chart === 'undefined') {
console.error('Chart.js not loaded!');
return;
}
// Очистить старые графики (только если они существуют и это экземпляры Chart)
if (window.cpuChart && typeof window.cpuChart.destroy === 'function') window.cpuChart.destroy();
if (window.memChart && typeof window.memChart.destroy === 'function') window.memChart.destroy();
if (window.netChart && typeof window.netChart.destroy === 'function') window.netChart.destroy();
if (window.diskChart && typeof window.diskChart.destroy === 'function') window.diskChart.destroy();
const chartConfig = {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
tooltip: {
enabled: true,
mode: 'index',
intersect: false
}
},
scales: {
x: {
type: 'category',
grid: { color: 'rgba(255,255,255,0.1)' },
ticks: {
color: '#b0b0b0',
maxTicksLimit: 10
}
},
y: {
beginAtZero: true,
grid: { color: 'rgba(255,255,255,0.1)' },
ticks: { color: '#b0b0b0' }
}
}
};
// Форматировать метки времени
const formatTime = (timestamp) => {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
};
// CPU Chart
const cpuCtx = document.getElementById('cpuChart');
if (cpuCtx && cpuData.length > 0) {
window.cpuChart = new Chart(cpuCtx, {
type: 'line',
data: {
labels: cpuData.map(d => formatTime(d.time)),
datasets: [{
label: t('cpu_usage_percent'),
data: cpuData.map(d => d.value.toFixed(2)),
borderColor: '#ffffff',
backgroundColor: 'rgba(255,255,255,0.1)',
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 0
}]
},
options: chartConfig
});
}
// Memory Chart
const memCtx = document.getElementById('memChart');
if (memCtx && memData.length > 0) {
window.memChart = new Chart(memCtx, {
type: 'line',
data: {
labels: memData.map(d => formatTime(d.time)),
datasets: [{
label: t('memory_gb'),
data: memData.map(d => d.value.toFixed(2)),
borderColor: '#ffffff',
backgroundColor: 'rgba(255,255,255,0.1)',
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 0
}]
},
options: chartConfig
});
}
// Network Chart
const netCtx = document.getElementById('netChart');
if (netCtx && netData.length > 0) {
window.netChart = new Chart(netCtx, {
type: 'line',
data: {
labels: netData.map(d => formatTime(d.time)),
datasets: [
{
label: t('in_mb_s'),
data: netData.map(d => d.in.toFixed(2)),
borderColor: '#4ade80',
backgroundColor: 'rgba(74,222,128,0.1)',
borderWidth: 2,
tension: 0.4,
pointRadius: 0
},
{
label: t('out_mb_s'),
data: netData.map(d => d.out.toFixed(2)),
borderColor: '#f87171',
backgroundColor: 'rgba(248,113,113,0.1)',
borderWidth: 2,
tension: 0.4,
pointRadius: 0
}
]
},
options: {
...chartConfig,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#b0b0b0',
usePointStyle: true,
padding: 10
}
},
tooltip: {
enabled: true,
mode: 'index',
intersect: false
}
}
}
});
}
// Disk I/O Chart
const diskCtx = document.getElementById('diskChart');
if (diskCtx && diskData.length > 0) {
window.diskChart = new Chart(diskCtx, {
type: 'line',
data: {
labels: diskData.map(d => formatTime(d.time)),
datasets: [
{
label: t('disk_read'),
data: diskData.map(d => d.read.toFixed(2)),
borderColor: '#60a5fa',
backgroundColor: 'rgba(96,165,250,0.1)',
borderWidth: 2,
tension: 0.4,
pointRadius: 0
},
{
label: t('disk_write'),
data: diskData.map(d => d.write.toFixed(2)),
borderColor: '#f59e0b',
backgroundColor: 'rgba(245,158,11,0.1)',
borderWidth: 2,
tension: 0.4,
pointRadius: 0
}
]
},
options: {
...chartConfig,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#b0b0b0',
usePointStyle: true,
padding: 10
}
},
tooltip: {
enabled: true,
mode: 'index',
intersect: false
}
}
}
});
}
}
// VNC Modal
function showVNCModal(serverId, node, vmid, type, name) {
const modal = document.getElementById('vncModal');
document.getElementById('vncTitle').innerHTML = `<i class="fa-solid fa-desktop"></i> VNC Console: ${name} (${vmid})`;
document.getElementById('vncContent').innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> ${t('connecting')}</div>`;
modal.style.display = 'flex';
// Load VNC
loadVNC(serverId, node, vmid, type);
}
function closeVNCModal() {
document.getElementById('vncModal').style.display = 'none';
document.getElementById('vncContent').innerHTML = '';
}
async function loadVNC(serverId, node, vmid, type) {
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/${type}/${vmid}/vnc?node=${node}`);
const vncData = await response.json();
if (vncData.ticket && vncData.port) {
const vncPath = type === 'vm' ? 'qemu' : 'lxc';
// Формируем URL для Proxmox VNC console
const baseUrl = `https://${vncData.host}:8006`;
const consoleUrl = `${baseUrl}/?console=${vncPath}&novnc=1&vmid=${vmid}&node=${node}&resize=off`;
document.getElementById('vncContent').innerHTML = `
<div style="text-align: center; padding: 2rem;">
<div style="background: var(--bg-tertiary); padding: 2rem; border-radius: 8px; margin-bottom: 1.5rem;">
<h3 style="margin: 0 0 1rem 0; color: var(--text-primary);"><i class="fa-solid fa-desktop"></i> VNC Console</h3>
<p style="color: var(--text-secondary); margin-bottom: 1.5rem; font-size: 0.9375rem;">
<strong>${type === 'vm' ? 'VM' : 'LXC'}:</strong> ${vmid} на ноде <strong>${node}</strong>
</p>
<div style="background: var(--bg-secondary); padding: 1.5rem; border-radius: 6px; margin-bottom: 1.5rem; text-align: left;">
<p style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1rem;">
<i class="fa-solid fa-triangle-exclamation"></i> Из-за ограничений безопасности браузера (CORS), VNC консоль необходимо открыть в отдельной вкладке Proxmox.
</p>
<ol style="color: var(--text-secondary); font-size: 0.875rem; padding-left: 1.5rem; margin: 0;">
<li style="margin-bottom: 0.5rem;">Нажмите кнопку ниже для открытия консоли</li>
<li style="margin-bottom: 0.5rem;">При необходимости примите SSL сертификат Proxmox</li>
<li>Войдите в Proxmox (если требуется)</li>
</ol>
</div>
<button
onclick="window.open('${consoleUrl}', '_blank', 'width=1024,height=768')"
class="btn btn-primary"
style="padding: 0.875rem 2rem; font-size: 1rem; margin-bottom: 1rem;">
<i class="fa-solid fa-rocket"></i> Открыть VNC Console в новой вкладке
</button>
<div style="margin-top: 1rem;">
<a href="${baseUrl}" target="_blank" style="color: var(--accent); font-size: 0.875rem; text-decoration: underline;">
Или открыть главную страницу Proxmox
</a>
</div>
</div>
<div style="background: var(--bg-tertiary); padding: 1.5rem; border-radius: 6px; text-align: left;">
<h4 style="margin: 0 0 1rem 0; color: var(--text-primary); font-size: 0.9375rem;">💡 Альтернативный способ:</h4>
<ol style="color: var(--text-secondary); font-size: 0.875rem; padding-left: 1.5rem; margin: 0; line-height: 1.6;">
<li>Откройте Proxmox напрямую: <code style="background: var(--bg-secondary); padding: 0.25rem 0.5rem; border-radius: 4px; color: var(--accent);">${baseUrl}</code></li>
<li>Найдите ${type === 'vm' ? 'виртуальную машину' : 'контейнер'} <strong>${vmid}</strong></li>
<li>Нажмите "Console" → "noVNC"</li>
</ol>
</div>
<div style="margin-top: 1.5rem; padding: 1rem; background: rgba(255,193,7,0.1); border: 1px solid rgba(255,193,7,0.3); border-radius: 6px;">
<p style="color: var(--warning); font-size: 0.875rem; margin: 0;">
<strong><i class="fa-solid fa-triangle-exclamation"></i> Примечание:</strong> Встроенный VNC в iframe невозможен из-за политики безопасности браузера и Proxmox CORS.
</p>
</div>
</div>
`;
} else {
throw new Error('Не удалось получить данные VNC');
}
} catch (error) {
console.error('Error loading VNC:', error);
let errorMessage = error.message || t('unknown_error');
if (error.message && error.message.includes('Failed to fetch')) {
errorMessage = t('network_error_check_proxmox');
}
document.getElementById('vncContent').innerHTML = `
<div style="padding: 2rem; text-align: center;">
<div style="background: var(--bg-tertiary); padding: 2rem; border-radius: 8px;">
<div style="font-size: 3rem; margin-bottom: 1rem;"><i class="fa-solid fa-circle-xmark"></i></div>
<h3 style="color: var(--text-primary); margin-bottom: 1rem;">${t('vnc_connection_error')}</h3>
<p style="color: var(--text-secondary); margin-bottom: 1.5rem;">${errorMessage}</p>
<div style="background: var(--bg-secondary); padding: 1.5rem; border-radius: 6px; text-align: left; margin-bottom: 1.5rem;">
<h4 style="color: var(--text-primary); margin: 0 0 1rem 0; font-size: 0.9375rem;"><i class="fa-solid fa-magnifying-glass"></i> ${t('possible_causes')}:</h4>
<ul style="margin: 0; padding-left: 1.5rem; color: var(--text-secondary); font-size: 0.875rem; line-height: 1.6;">
<li>${t('vm_stopped_start_first')}</li>
<li>${t('api_token_insufficient_rights')} - <code style="background: var(--bg-primary); padding: 0.25rem 0.5rem; border-radius: 4px;">VM.Console</code></li>
<li>${t('proxmox_server_unreachable')}</li>
<li>${t('network_connection_issues')}</li>
</ul>
</div>
<button onclick="closeVNCModal()" class="btn">{{ t('close') }}</button>
</div>
</div>
`;
}
}
function formatBytes(bytes) {
if (!bytes) return '0 B';
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
async function controlResource(serverId, node, vmid, action, type) {
const actionNames = {
start: t('start') || 'start',
stop: t('stop') || 'stop',
restart: t('restart') || 'restart'
};
const confirmed = await showConfirm(
t('confirm_action') || 'Confirm Action',
`${t('are_you_sure_to')} ${actionNames[action]} ${type} ${vmid}?`,
{ type: action === 'stop' ? 'warning' : 'info', confirmText: actionNames[action] }
);
if (!confirmed) return;
try {
const response = await fetchWithAuth(
`/proxmox/api/${serverId}/${type}/${vmid}/${action}?node=${node}`,
{ method: 'POST' }
);
if (response.ok) {
showToast('success', t('success'), `${t('action_executed_successfully')} ${action}`);
// Обновить данные через 2 секунды
setTimeout(() => {
if (document.querySelector('[data-server-id]')) {
loadServerResources(serverId);
} else {
loadAllResources();
}
}, 2000);
} else {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
showToast('error', t('error'), error.detail);
}
} catch (error) {
console.error('Error controlling resource:', error);
showToast('error', t('error'), error.message || t('unknown_error'));
}
}
// Загрузить данные при открытии страницы
{% if proxmox_servers %}
// Wait for auth token to be validated
setTimeout(() => {
loadAllStatuses();
}, 500);
{% endif %}
</script>
<!-- Модальное окно мониторинга -->
<div id="monitorModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 1200px; width: 95%; max-height: 90vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; position: sticky; top: 0; background: var(--bg-card); padding-bottom: 1rem; border-bottom: 1px solid var(--border-color);">
<h3 id="monitorTitle" style="margin: 0;"><i class="fa-solid fa-chart-simple"></i> Мониторинг</h3>
<button onclick="closeMonitorModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: var(--transition);" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='none'">&times;</button>
</div>
<div id="monitorContent"></div>
</div>
</div>
<!-- Модальное окно VNC -->
<div id="vncModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.9); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 1400px; width: 95%; max-height: 90vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; position: sticky; top: 0; background: var(--bg-card); padding-bottom: 1rem; border-bottom: 1px solid var(--border-color);">
<h3 id="vncTitle" style="margin: 0;"><i class="fa-solid fa-desktop"></i> VNC Console</h3>
<button onclick="closeVNCModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: var(--transition);" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='none'">&times;</button>
</div>
<div id="vncContent"></div>
</div>
</div>
<!-- Modal создания VM -->
<div id="createVMModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 700px; width: 95%; max-height: 95vh; overflow-y: auto; position: relative;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color);">
<h3 style="margin: 0;"><i class="fa-solid fa-plus"></i> {{ t('create_vm_from_template') }}</h3>
<button onclick="closeCreateVMModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: var(--transition);" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='none'">&times;</button>
</div>
<div id="createVMContent">
<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> Загрузка...</div>
</div>
</div>
</div>
<script>
// ==================== Create VM Functions ====================
let selectedServerId = null;
async function showCreateVMModal() {
const modal = document.getElementById('createVMModal');
const content = document.getElementById('createVMContent');
content.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> Загрузка серверов и шаблонов...</div>';
modal.style.display = 'flex';
try {
// Загружаем серверы
const serversResponse = await fetchWithAuth('/proxmox/api/servers');
if (!serversResponse.ok) throw new Error('Не удалось загрузить серверы');
const servers = await serversResponse.json();
if (servers.length === 0) {
content.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<div style="font-size: 3rem; margin-bottom: 1rem;"><i class="fa-solid fa-cube"></i></div>
<h3 style="color: var(--text-primary); margin-bottom: 1rem;">Нет Proxmox серверов</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">Сначала добавьте Proxmox сервер.</p>
<button onclick="closeCreateVMModal(); showAddServerModal();" class="btn btn-primary"><i class="fa-solid fa-plus"></i> Добавить сервер</button>
</div>
`;
return;
}
// Загружаем группы и шаблоны
const [groupsResponse, templatesResponse] = await Promise.all([
fetchWithAuth('/templates/api/groups'),
fetchWithAuth('/templates/api/templates')
]);
const groups = groupsResponse.ok ? await groupsResponse.json() : [];
const templates = templatesResponse.ok ? await templatesResponse.json() : [];
if (templates.length === 0) {
content.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<div style="font-size: 3rem; margin-bottom: 1rem;"><i class="fa-solid fa-cube"></i></div>
<h3 style="color: var(--text-primary); margin-bottom: 1rem;">Шаблоны не найдены</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">{{ t('add_templates_instruction') }}</p>
<a href="/templates" class="btn btn-primary"><i class="fa-solid fa-cube"></i> Перейти к шаблонам</a>
</div>
`;
return;
}
renderCreateVMForm(servers, groups, templates);
// Загружаем IPAM сети
await loadIPAMNetworksForCreateVM();
} catch (error) {
console.error('Error loading create VM data:', error);
content.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<div style="font-size: 3rem; margin-bottom: 1rem;"><i class="fa-solid fa-circle-xmark"></i></div>
<h3 style="color: var(--danger);">Ошибка загрузки</h3>
<p style="color: var(--text-secondary);">${error.message}</p>
<button onclick="closeCreateVMModal()" class="btn" style="margin-top: 1rem;">{{ t('close') }}</button>
</div>
`;
}
}
function renderCreateVMForm(servers, groups, templates) {
const content = document.getElementById('createVMContent');
// Group templates by servers
const templatesByServer = {};
templates.forEach(tpl => {
if (!templatesByServer[tpl.server_id]) {
templatesByServer[tpl.server_id] = [];
}
templatesByServer[tpl.server_id].push(tpl);
});
// Generate server options (only with templates)
let serverOptions = `<option value="">${t('select_server')}</option>`;
servers.forEach(server => {
const serverTemplates = templatesByServer[server.id] || [];
if (serverTemplates.length > 0) {
serverOptions += `<option value="${server.id}" data-templates='${JSON.stringify(serverTemplates)}'>${server.name} (${serverTemplates.length} ${t('templates_count')})</option>`;
}
});
// If no servers with templates
if (serverOptions === `<option value="">${t('select_server')}</option>`) {
content.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<div style="font-size: 3rem; margin-bottom: 1rem;"><i class="fa-solid fa-cube"></i></div>
<h3 style="color: var(--text-primary); margin-bottom: 1rem;">${t('no_templates_on_servers')}</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">${t('add_templates_for_servers')}</p>
<a href="/templates" class="btn btn-primary"><i class="fa-solid fa-cube"></i> ${t('go_to_templates')}</a>
</div>
`;
return;
}
// Сохраняем группы для использования при выборе сервера
window.templateGroups = groups;
content.innerHTML = `
<form id="createVMForm" onsubmit="executeCreateVM(event)">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group" style="grid-column: 1 / -1;">
<label style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem; display: block;">Proxmox {{ t('server') }} *</label>
<select id="createVMServer" required onchange="onServerChange()" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
${serverOptions}
</select>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem; display: block;">{{ t('os_template') }} *</label>
<select id="createVMTemplate" required onchange="onTemplateChange()" disabled style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
<option value="">-- {{ t('select_server_first') }} --</option>
</select>
</div>
<div class="form-group">
<label style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem; display: block;">{{ t('vm_name') }} *</label>
<input type="text" id="createVMName" required placeholder="my-server" pattern="[a-zA-Z0-9\\-_]+" title="Только буквы, цифры, дефис и подчеркивание" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem; display: block;">{{ t('cpu_cores') }}</label>
<input type="number" id="createVMCores" min="1" max="128" value="1" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem; display: block;">{{ t('memory_mb') }}</label>
<input type="number" id="createVMMemory" min="128" step="128" value="1024" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem; display: block;">{{ t('disk_gb') }}</label>
<input type="number" id="createVMDisk" min="1" value="10" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
</div>
<div style="background: var(--bg-tertiary); border-radius: 8px; padding: 1rem; margin-top: 1.5rem;">
<h4 style="color: var(--text-primary); font-size: 0.9rem; margin-bottom: 1rem;"><i class="fa-solid fa-globe"></i> {{ t('network') }}</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group" style="grid-column: 1 / -1;">
<label style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem; display: block;">{{ t('ipam_network') }}</label>
<select id="createVMIPAMNetwork" onchange="onIPAMNetworkChange()" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary);">
<option value="">{{ t('no_ipam') }}</option>
</select>
<small style="color: var(--text-muted); font-size: 0.75rem;">{{ t('select_network_for_auto_ip') }}</small>
</div>
<div class="form-group">
<label style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem; display: block;">{{ t('ip_address') }}</label>
<input type="text" id="createVMIP" placeholder="{{ t('auto_from_ipam') }}" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem; display: block;">{{ t('gateway') }}</label>
<input type="text" id="createVMGateway" placeholder="{{ t('auto_from_ipam_network') }}" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary);">
</div>
</div>
</div>
<div style="background: var(--bg-tertiary); border-radius: 8px; padding: 1rem; margin-top: 1rem;">
<h4 style="color: var(--text-primary); font-size: 0.9rem; margin-bottom: 1rem;"><i class="fa-solid fa-lock"></i> {{ t('cloud_init_optional') }}</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem; display: block;">{{ t('username') }}</label>
<input type="text" id="createVMUser" placeholder="admin" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem; display: block;">{{ t('password') }}</label>
<input type="password" id="createVMPassword" placeholder="••••••••" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary);">
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem; display: block;">{{ t('ssh_keys') }}</label>
<textarea id="createVMSSHKeys" rows="2" placeholder="ssh-rsa AAAA... user@host" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); font-family: monospace; font-size: 0.8rem; resize: vertical;"></textarea>
</div>
</div>
</div>
<div style="margin-top: 1.5rem;">
<label style="color: var(--text-secondary); font-size: 0.875rem; display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="createVMStart" checked style="width: auto;">
{{ t('start_vm_after_creation') }}
</label>
</div>
<div id="createVMStatus" style="display: none; margin-top: 1rem; padding: 1rem; border-radius: 6px;"></div>
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
<button type="button" onclick="closeCreateVMModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">{{ t('cancel') }}</button>
<button type="submit" id="createVMBtn" class="btn btn-success"><i class="fa-solid fa-rocket"></i> {{ t('create_vm') }}</button>
</div>
</form>
`;
}
async function loadIPAMNetworksForCreateVM() {
try {
const response = await fetchWithAuth('/ipam/api/networks');
if (response.ok) {
const networks = await response.json();
const select = document.getElementById('createVMIPAMNetwork');
if (select) {
select.innerHTML = `<option value="">${t('do_not_use_ipam')}</option>`;
networks.forEach(network => {
const option = document.createElement('option');
option.value = network.id;
option.dataset.gateway = network.gateway || '';
option.dataset.cidr = network.network;
option.textContent = `${network.name} (${network.network})`;
select.appendChild(option);
});
}
}
} catch (error) {
console.error('Error loading IPAM networks:', error);
}
}
function onServerChange() {
const serverSelect = document.getElementById('createVMServer');
const templateSelect = document.getElementById('createVMTemplate');
const selectedOption = serverSelect.options[serverSelect.selectedIndex];
if (!selectedOption.value) {
templateSelect.innerHTML = `<option value="">${t('first_select_server')}</option>`;
templateSelect.disabled = true;
return;
}
selectedServerId = parseInt(selectedOption.value);
const templates = JSON.parse(selectedOption.dataset.templates || '[]');
const groups = window.templateGroups || [];
// Group templates by groups
const groupedTemplates = {};
templates.forEach(tpl => {
const groupId = tpl.group_id || 'other';
if (!groupedTemplates[groupId]) {
groupedTemplates[groupId] = [];
}
groupedTemplates[groupId].push(tpl);
});
let templateOptions = `<option value="">${t('select_template')}</option>`;
groups.forEach(group => {
const groupTemplates = groupedTemplates[group.id] || [];
if (groupTemplates.length > 0) {
templateOptions += `<optgroup label="${group.icon || ''} ${group.name}">`;
groupTemplates.forEach(template => {
templateOptions += `
<option value="${template.id}"
data-cores="${template.default_cores}"
data-memory="${template.default_memory}"
data-disk="${template.default_disk}"
data-min-cores="${template.min_cores}"
data-min-memory="${template.min_memory}"
data-min-disk="${template.min_disk}">
${template.name}
</option>`;
});
templateOptions += '</optgroup>';
}
});
// Шаблоны без группы
if (groupedTemplates['other']) {
templateOptions += '<optgroup label="' + t('other') + '">';
groupedTemplates['other'].forEach(template => {
templateOptions += `
<option value="${template.id}"
data-cores="${template.default_cores}"
data-memory="${template.default_memory}"
data-disk="${template.default_disk}"
data-min-cores="${template.min_cores}"
data-min-memory="${template.min_memory}"
data-min-disk="${template.min_disk}">
${template.name}
</option>`;
});
templateOptions += '</optgroup>';
}
templateSelect.innerHTML = templateOptions;
templateSelect.disabled = false;
}
function onTemplateChange() {
const select = document.getElementById('createVMTemplate');
const selectedOption = select.options[select.selectedIndex];
if (selectedOption.value) {
document.getElementById('createVMCores').value = selectedOption.dataset.cores || 1;
document.getElementById('createVMMemory').value = selectedOption.dataset.memory || 1024;
document.getElementById('createVMDisk').value = selectedOption.dataset.disk || 10;
document.getElementById('createVMCores').min = selectedOption.dataset.minCores || 1;
document.getElementById('createVMMemory').min = selectedOption.dataset.minMemory || 128;
document.getElementById('createVMDisk').min = selectedOption.dataset.minDisk || 1;
}
}
function onIPAMNetworkChange() {
const select = document.getElementById('createVMIPAMNetwork');
const selectedOption = select.options[select.selectedIndex];
const ipField = document.getElementById('createVMIP');
const gatewayField = document.getElementById('createVMGateway');
if (selectedOption.value) {
ipField.placeholder = 'Авто из IPAM (оставьте пустым)';
ipField.value = '';
if (selectedOption.dataset.gateway) {
gatewayField.value = selectedOption.dataset.gateway;
}
} else {
ipField.placeholder = 'Авто из IPAM или вручную';
gatewayField.placeholder = 'Авто из сети IPAM';
}
}
function showCreateVMStatus(message, isError = false) {
const status = document.getElementById('createVMStatus');
status.style.display = 'block';
status.style.background = isError ? 'var(--danger-dark)' : 'var(--success-dark)';
status.style.border = `1px solid ${isError ? 'var(--danger)' : 'var(--success)'}`;
status.innerHTML = `<div style="color: ${isError ? 'var(--danger-text-light)' : 'var(--success-text-light)'};">${message}</div>`;
}
async function executeCreateVM(event) {
event.preventDefault();
const serverId = document.getElementById('createVMServer').value;
const templateId = document.getElementById('createVMTemplate').value;
const name = document.getElementById('createVMName').value.trim();
if (!serverId) {
showToast('warning', t('warning'), t('select_proxmox_server'));
return;
}
if (!templateId) {
showToast('warning', t('warning'), t('select_os_template'));
return;
}
if (!name) {
showToast('warning', t('warning'), t('enter_vm_name'));
return;
}
const btn = document.getElementById('createVMBtn');
const originalText = btn.innerHTML;
btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> ${t('creating')}`;
btn.disabled = true;
const deployData = {
template_id: parseInt(templateId),
name: name,
cores: parseInt(document.getElementById('createVMCores').value) || null,
memory: parseInt(document.getElementById('createVMMemory').value) || null,
disk: parseInt(document.getElementById('createVMDisk').value) || null,
start_after_create: document.getElementById('createVMStart').checked
};
// IPAM - автовыделение IP из сети
const ipamNetworkId = document.getElementById('createVMIPAMNetwork').value;
if (ipamNetworkId) {
deployData.ipam_network_id = parseInt(ipamNetworkId);
}
// Добавляем IP если указан вручную
const ip = document.getElementById('createVMIP').value.trim();
if (ip && ip.toLowerCase() !== 'dhcp') {
deployData.ip_address = ip;
}
const gateway = document.getElementById('createVMGateway').value.trim();
if (gateway) {
deployData.gateway = gateway;
}
// Cloud-init
const user = document.getElementById('createVMUser').value.trim();
if (user) {
deployData.cloud_init_user = user;
}
const password = document.getElementById('createVMPassword').value;
if (password) {
deployData.cloud_init_password = password;
}
const sshKeys = document.getElementById('createVMSSHKeys').value.trim();
if (sshKeys) {
deployData.ssh_keys = sshKeys;
}
try {
showCreateVMStatus('<i class="fa-solid fa-rotate"></i> Клонирование шаблона...');
const response = await fetchWithAuth('/templates/api/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(deployData)
});
const result = await response.json();
if (response.ok) {
showCreateVMStatus(`<i class="fa-solid fa-circle-check"></i> VM "${result.name}" успешно создана!<br>VMID: ${result.vmid} | Node: ${result.node}`);
setTimeout(() => {
closeCreateVMModal();
// Обновить статус сервера
loadServerStatus(parseInt(serverId));
}, 2000);
} else {
showCreateVMStatus(`<i class="fa-solid fa-circle-xmark"></i> ${result.detail || 'Ошибка создания VM'}`, true);
}
} catch (error) {
console.error('Create VM error:', error);
showCreateVMStatus(`<i class="fa-solid fa-circle-xmark"></i> Ошибка: ${error.message}`, true);
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
function closeCreateVMModal() {
document.getElementById('createVMModal').style.display = 'none';
const status = document.getElementById('createVMStatus');
if (status) status.style.display = 'none';
}
// ==================== Cluster Dropdown ====================
function toggleClusterDropdown(event) {
event.stopPropagation();
const dropdown = document.getElementById('clusterDropdown');
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
}
function closeClusterDropdown() {
const dropdown = document.getElementById('clusterDropdown');
if (dropdown) dropdown.style.display = 'none';
}
document.addEventListener('click', function(e) {
const btn = document.getElementById('clusterMenuBtn');
const dropdown = document.getElementById('clusterDropdown');
if (dropdown && btn && !btn.contains(e.target) && !dropdown.contains(e.target)) {
dropdown.style.display = 'none';
}
});
// ==================== Create Cluster ====================
function showCreateClusterModal() {
document.getElementById('createClusterModal').style.display = 'flex';
document.getElementById('createClusterStatus').style.display = 'none';
}
function closeCreateClusterModal() {
document.getElementById('createClusterModal').style.display = 'none';
document.getElementById('createClusterForm').reset();
document.getElementById('createClusterStatus').style.display = 'none';
}
async function submitCreateCluster(event) {
event.preventDefault();
const btn = document.getElementById('createClusterBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Создание...';
btn.disabled = true;
const data = {
server_id: parseInt(document.getElementById('createClusterServerId').value),
cluster_name: document.getElementById('createClusterName').value.trim(),
link0_ip: document.getElementById('createClusterLink0').value.trim() || null,
rootpw: document.getElementById('createClusterRootpw').value || null,
};
const statusEl = document.getElementById('createClusterStatus');
statusEl.style.display = 'block';
statusEl.style.background = 'var(--bg-tertiary)';
statusEl.style.border = '1px solid var(--border-color)';
statusEl.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Создание кластера...';
try {
const response = await fetchWithAuth('/proxmox/api/cluster/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await response.json();
if (response.ok) {
statusEl.style.background = 'var(--success-dark)';
statusEl.style.border = '1px solid var(--success)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-check" style="color: var(--success);"></i>
<strong>Кластер "${result.cluster_name}" создаётся!</strong><br>
<span style="font-size: 0.875rem; color: var(--text-secondary);">Сервер: ${result.server_name}</span>
${result.upid ? `<br><small style="color: var(--text-muted);">UPID: <code>${result.upid}</code></small>` : ''}`;
setTimeout(() => { closeCreateClusterModal(); window.location.reload(); }, 2500);
} else {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i>
${typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail)}`;
}
} catch (error) {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i> Ошибка: ${error.message}`;
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
// ==================== Join Cluster (4-step wizard) ====================
let joinState = {};
function showJoinClusterModal() {
joinState = {};
document.getElementById('joinClusterModal').style.display = 'flex';
document.getElementById('joinPreCheckResult').innerHTML = '';
document.getElementById('joinPrepResult').innerHTML = '';
document.getElementById('joinFinalStatus').style.display = 'none';
document.getElementById('joinStep1Form').reset();
showJoinStep(1);
// Warn if no cluster servers available
const clusterSelect = document.getElementById('joinClusterServerId');
const hasCluster = clusterSelect.querySelectorAll('option[value]:not([value=""])').length > 0;
document.getElementById('joinNoClusterWarning').style.display = hasCluster ? 'none' : 'block';
}
function closeJoinClusterModal() {
document.getElementById('joinClusterModal').style.display = 'none';
joinState = {};
}
function showJoinStep(step) {
[1, 2, 3, 4].forEach(s => {
const el = document.getElementById(`joinStep${s}`);
if (el) el.style.display = s === step ? 'block' : 'none';
});
const titles = ['', 'Шаг 1 из 4: Выбор нод', 'Шаг 2 из 4: Проверка ноды', 'Шаг 3 из 4: Подготовка', 'Шаг 4 из 4: Присоединение'];
document.getElementById('joinStepTitle').textContent = titles[step] || `Шаг ${step} из 4`;
}
async function submitJoinStep1(event) {
event.preventDefault();
const nodeServerId = parseInt(document.getElementById('joinNodeServerId').value);
const clusterServerId = parseInt(document.getElementById('joinClusterServerId').value);
if (!nodeServerId || !clusterServerId) {
showToast('error', 'Ошибка', 'Выберите оба сервера'); return;
}
if (nodeServerId === clusterServerId) {
showToast('error', 'Ошибка', 'Нда для добавления и кластерная нода должны быть разными'); return;
}
joinState.nodeServerId = nodeServerId;
joinState.clusterServerId = clusterServerId;
const btn = document.getElementById('joinStep1Btn');
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Проверка...';
btn.disabled = true;
try {
const response = await fetchWithAuth(`/proxmox/api/cluster/${nodeServerId}/pre-join-check`);
const data = await response.json();
if (!response.ok) {
showToast('error', 'Ошибка', typeof data.detail === 'string' ? data.detail : 'Ошибка проверки ноды');
return;
}
joinState.preCheck = data;
renderPreCheckResult(data);
showJoinStep(2);
} catch (error) {
showToast('error', 'Ошибка', error.message);
} finally {
btn.innerHTML = 'Далее <i class="fa-solid fa-arrow-right"></i>';
btn.disabled = false;
}
}
function renderPreCheckResult(data) {
const container = document.getElementById('joinPreCheckResult');
const storageOptions = (data.backup_storages || []).map(s =>
`<option value="${s.storage}">${s.storage} (${s.type}${s.avail ? ', свободно: ' + formatBytes(s.avail) : ''})</option>`
).join('');
if (data.guests_count === 0) {
container.innerHTML = `
<div style="background: rgba(34, 197, 94, 0.1); border: 1px solid var(--success); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<i class="fa-solid fa-circle-check" style="color: var(--success);"></i>
<strong> Нода чистая!</strong> VM и LXC не обнаружены. Можно сразу присоединяться к кластеру.
</div>
`;
document.getElementById('joinPrepareSection').style.display = 'none';
document.getElementById('joinSkipToStep4Btn').style.display = 'inline-flex';
document.getElementById('joinStep2PrepBtn').style.display = 'none';
} else {
const guestRows = data.guests.map(g => `
<tr>
<td style="padding: 0.4rem 0.75rem;">${g.vmid}</td>
<td style="padding: 0.4rem 0.75rem;">${g.name}</td>
<td style="padding: 0.4rem 0.75rem;">
<span style="background: ${g.type === 'qemu' ? 'var(--purple)' : 'var(--info)'}; color: white;
padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem;">
${g.type === 'qemu' ? 'VM' : 'LXC'}
</span>
</td>
<td style="padding: 0.4rem 0.75rem;">
<span class="status-badge ${g.status === 'running' ? 'status-online' : 'status-offline'}"
style="font-size: 0.7rem; padding: 0.1rem 0.5rem;">${g.status}</span>
</td>
</tr>
`).join('');
container.innerHTML = `
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--danger);"></i>
<strong> Обнаружено гестов: ${data.guests_count}</strong><br>
<span style="font-size: 0.875rem; color: var(--text-secondary);">
При присоединении к кластеру /etc/pve перезапишется и конфиги гестов потеряются.
Необходимо создать бэкапы и удалить всех гестов перед join.
</span>
</div>
<div style="overflow-x: auto; margin-bottom: 1rem;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.875rem;">
<thead>
<tr style="background: var(--bg-secondary);">
<th style="padding: 0.4rem 0.75rem; text-align: left;">VMID</th>
<th style="padding: 0.4rem 0.75rem; text-align: left;">Имя</th>
<th style="padding: 0.4rem 0.75rem; text-align: left;">Тип</th>
<th style="padding: 0.4rem 0.75rem; text-align: left;">Статус</th>
</tr>
</thead>
<tbody>${guestRows}</tbody>
</table>
</div>
`;
document.getElementById('joinPrepareSection').style.display = 'block';
if (storageOptions) {
document.getElementById('joinBackupStorage').innerHTML = storageOptions;
} else {
document.getElementById('joinBackupStorage').innerHTML = '<option value="">Нет доступных хранилищ backup</option>';
container.innerHTML += `
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1rem; font-size: 0.875rem;">
<i class="fa-solid fa-exclamation-circle" style="color: var(--danger);"></i>
<strong> Нет хранилищ для бэкапов.</strong> Добавьте NFS/CIFS/Ceph хранилище с типом backup.
</div>
`;
}
document.getElementById('joinSkipToStep4Btn').style.display = 'none';
document.getElementById('joinStep2PrepBtn').style.display = 'inline-flex';
}
}
function joinSkipToFinalStep() {
showJoinStep(4);
}
async function submitJoinPrepare() {
const storage = document.getElementById('joinBackupStorage').value;
if (!storage) {
showToast('error', 'Ошибка', 'Выберите хранилище для бэкапов');
return;
}
joinState.backupStorage = storage;
const btn = document.getElementById('joinStep2PrepBtn');
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Создание бэкапов...';
btn.disabled = true;
showJoinStep(3);
document.getElementById('joinPrepResult').innerHTML = `
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
<i class="fa-solid fa-spinner fa-spin" style="font-size: 2rem; margin-bottom: 1rem; display: block;"></i>
<div>Создание резервных копий и очистка ноды...</div>
<div style="font-size: 0.875rem; margin-top: 0.5rem;">Процесс может занять несколько минут</div>
</div>
`;
document.getElementById('joinPrepContinueBtn').style.display = 'none';
try {
const response = await fetchWithAuth('/proxmox/api/cluster/prepare-join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
node_server_id: joinState.nodeServerId,
backup_storage: storage,
compress: 'zstd',
}),
});
const result = await response.json();
const container = document.getElementById('joinPrepResult');
if (!response.ok) {
container.innerHTML = `
<div style="background: var(--danger-dark); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem;">
<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i>
<strong> Ошибка:</strong> ${typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail)}
</div>
`;
return;
}
const statusColor = result.ready_for_join ? 'var(--success)' : 'var(--warning)';
let errorsHtml = '';
if (result.errors && result.errors.length > 0) {
const errList = result.errors.map(e => `<li>VMID ${e.vmid} (${e.phase}): ${e.error}</li>`).join('');
errorsHtml = `<ul style="margin-top: 0.5rem; color: var(--danger); font-size: 0.875rem;">${errList}</ul>`;
}
container.innerHTML = `
<div style="background: ${result.ready_for_join ? 'rgba(34, 197, 94, 0.1)' : 'rgba(234, 179, 8, 0.1)'};
border: 1px solid ${statusColor}; border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<i class="fa-solid fa-${result.ready_for_join ? 'circle-check' : 'triangle-exclamation'}" style="color: ${statusColor};"></i>
<strong> ${result.message}</strong><br>
<span style="font-size: 0.875rem; color: var(--text-secondary);">
Забэкаплено: ${result.backed_up.length} | Удалено: ${result.deleted.length} | Ошибки: ${result.errors.length}
</span>
${errorsHtml}
</div>
`;
joinState.prepareResult = result;
if (result.ready_for_join) {
document.getElementById('joinPrepContinueBtn').style.display = 'inline-flex';
}
} catch (error) {
document.getElementById('joinPrepResult').innerHTML = `
<div style="background: var(--danger-dark); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem;">
<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i> Ошибка: ${error.message}
</div>
`;
} finally {
btn.innerHTML = '<i class="fa-solid fa-hard-drive"></i> Бэкап и очистка';
btn.disabled = false;
}
}
async function submitJoinFinal(event) {
event.preventDefault();
const rootpw = document.getElementById('joinRootpw').value.trim();
if (!rootpw) {
showToast('error', 'Ошибка', 'Введите пароль root@pam для присоединяемой ноды');
return;
}
const link0 = document.getElementById('joinLink0').value.trim() || null;
const btn = document.getElementById('joinFinalBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Присоединение...';
btn.disabled = true;
const statusEl = document.getElementById('joinFinalStatus');
statusEl.style.display = 'block';
statusEl.style.background = 'var(--bg-tertiary)';
statusEl.style.border = '1px solid var(--border-color)';
statusEl.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Выполняется join...';
try {
const response = await fetchWithAuth('/proxmox/api/cluster/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
node_server_id: joinState.nodeServerId,
cluster_server_id: joinState.clusterServerId,
rootpw: rootpw,
link0: link0,
}),
});
const result = await response.json();
if (response.ok) {
statusEl.style.background = 'var(--success-dark)';
statusEl.style.border = '1px solid var(--success)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-check" style="color: var(--success);"></i>
<strong> ${result.message}</strong>${result.upid ? `<br><small style="color: var(--text-muted);">UPID: <code>${result.upid}</code></small>` : ''}`;
setTimeout(() => { closeJoinClusterModal(); window.location.reload(); }, 3000);
} else if (response.status === 409 && result.detail && result.detail.code === 'GUESTS_FOUND') {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i> ${result.detail.message}`;
} else {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i>
${typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail)}`;
}
} catch (error) {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i> Ошибка: ${error.message}`;
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
// ==================== Eject Node ====================
function showEjectNodeModal() {
document.getElementById('ejectNodeModal').style.display = 'flex';
document.getElementById('ejectNodeStatus').style.display = 'none';
document.getElementById('ejectNodeForm').reset();
}
function closeEjectNodeModal() {
document.getElementById('ejectNodeModal').style.display = 'none';
}
async function submitEjectNode(event) {
event.preventDefault();
const serverId = document.getElementById('ejectFromServerId').value;
const nodeName = document.getElementById('ejectNodeName').value.trim();
const rootpw = document.getElementById('ejectRootpw').value.trim() || null;
if (!serverId || !nodeName) {
showToast('error', 'Ошибка', 'Выберите сервер и введите имя ноды');
return;
}
const confirmed = await showDeleteConfirm(
`Вы уверены, что хотите исключить ноду "${nodeName}" из кластера?\n\nНода должна быть ВЫКЛЮЧЕНА перед выполнением этой операции.`,
'Исключить ноду'
);
if (!confirmed) return;
const btn = document.getElementById('ejectNodeBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Исключение...';
btn.disabled = true;
const statusEl = document.getElementById('ejectNodeStatus');
statusEl.style.display = 'block';
statusEl.style.background = 'var(--bg-tertiary)';
statusEl.style.border = '1px solid var(--border-color)';
statusEl.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Удаление ноды из кластера...';
try {
const response = await fetchWithAuth(`/proxmox/api/cluster/${serverId}/eject-node`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ node_name: nodeName, rootpw: rootpw, clear_panel_entry: true }),
});
const result = await response.json();
if (response.ok) {
statusEl.style.background = 'var(--success-dark)';
statusEl.style.border = '1px solid var(--success)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-check" style="color: var(--success);"></i>
<strong> ${result.message}</strong>
<br><span style="font-size: 0.875rem; color: var(--text-muted);">Метод: ${result.method}</span>`;
setTimeout(() => { closeEjectNodeModal(); window.location.reload(); }, 2500);
} else {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i>
${typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail)}`;
}
} catch (error) {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i> Ошибка: ${error.message}`;
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
</script>
{% endblock %}