- 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
2142 lines
102 KiB
HTML
2142 lines
102 KiB
HTML
{% 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;">×</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;">×</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'">×</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'">×</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'">×</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 %}
|