Files
pvemanager/backend/app/templates/proxmox_vms.html
markmorado 91dbf0b81c feat: IPAM improvements - server/node subnet binding, theme color fixes
- IPAM link-allocations: filter networks by proxmox_server_id and proxmox_node
  to prevent cross-server/cross-node IP assignment
- IPAM link-allocations: add manual server+network selection modal before linking
- IPAM add-network forms: add Proxmox server and node selectors with dynamic
  cluster-info loading (ipam_dashboard.html, ipam_networks.html)
- models.py / schemas.py: add proxmox_node field to IPAMNetwork
- migrations: migration #16 adds proxmox_node column to ipam_networks table
- Theme fixes: replace all hardcoded rgba/color:#fff/color:white with CSS vars
  across base.html, virtual_machines.html, instance_detail.html,
  os_templates.html, proxmox_vms.html, ipam_network_detail.html,
  ipam_allocations.html
- i18n: add proxmox_node, node selector, link-allocations modal keys (ru/en)
- Version bump: 1.0.4 -> 1.0.5
2026-03-08 14:17:54 +01:00

2142 lines
102 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;" 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>
{% 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.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);
}
</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>
<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';
}
</script>
{% endblock %}