Files
pvemanager/backend/app/templates/instance_detail.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

2155 lines
100 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 = ('VM' if type == 'qemu' else 'LXC') + ' ' + vmid|string %}
{% block content %}
<style>
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
</style>
<div id="loading" style="text-align: center; padding: 3rem; color: var(--text-muted);">
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading_instance') }}
</div>
<div id="instance-content" style="display: none;">
<!-- Заголовок -->
<div class="card" style="margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
<div style="display: flex; align-items: center; gap: 1rem;">
<a href="/virtual-machines" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary); padding: 0.5rem 1rem;">
← {{ t('back') }}
</a>
<div style="display: flex; align-items: center; gap: 0.75rem;">
<span id="instance-icon" style="font-size: 2.5rem; color: var(--text-primary);"><i class="fa-solid fa-computer"></i></span>
<div>
<h2 id="instance-name" style="margin: 0; color: var(--text-primary);">{{ t('loading') }}</h2>
<div style="display: flex; align-items: center; gap: 0.75rem; margin-top: 0.5rem;">
<span id="instance-type-badge" style="padding: 0.25rem 0.75rem; border-radius: 6px; background: var(--primary); color: var(--text-on-primary); font-size: 0.8rem; font-weight: 700; letter-spacing: 0.5px;">QEMU</span>
</div>
<div style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.35rem;">ID: {{ vmid }} | Node: {{ node }} | {{ server.name }}</div>
</div>
</div>
</div>
<div id="instance-status" style="display: flex; align-items: center; gap: 0.5rem; font-size: 1.125rem; font-weight: 600;">
<span class="status-dot" style="width: 12px; height: 12px; border-radius: 50%; background: var(--text-muted);"></span>
<span class="status-text">--</span>
</div>
</div>
</div>
<!-- Кнопки управления -->
<div class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin: 0 0 1rem 0; color: var(--text-primary);">
<i class="fas fa-gamepad"></i> {{ t('control') }}
</h3>
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
<button id="btn-start" onclick="controlInstance('start')" class="btn btn-success" style="display: none;" data-permission="vms.start">
<i class="fa-solid fa-play"></i> {{ t('start') }}
</button>
<button id="btn-stop" onclick="controlInstance('stop')" class="btn btn-danger" style="display: none;" data-permission="vms.stop">
<i class="fa-solid fa-stop"></i> {{ t('stop') }}
</button>
<button id="btn-kill" onclick="controlInstance('kill')" class="btn btn-danger" style="display: none;" data-permission="vms.stop">
<i class="fa-solid fa-skull"></i> {{ t('kill') }}
</button>
<button id="btn-restart" onclick="controlInstance('restart')" class="btn btn-warning" style="display: none;" data-permission="vms.restart">
<i class="fa-solid fa-rotate"></i> {{ t('restart') }}
</button>
{% if type == 'qemu' %}
<!-- QEMU: VNC Console (noVNC) -->
<button type="button" onclick="openVNC()" class="btn btn-primary" title="{{ t('vnc_console') or 'VNC Console' }}" data-permission="vms.console">
<i class="fa-solid fa-desktop"></i> {{ t('vnc_console') or 'VNC Console' }}
</button>
{% else %}
<!-- LXC: Terminal (xterm.js) -->
<button type="button" onclick="openTerminal()" class="btn btn-primary" title="{{ t('terminal_console') or 'Terminal (xterm.js)' }}" data-permission="vms.console">
<i class="fa-solid fa-terminal"></i> {{ t('terminal') or 'Terminal' }}
</button>
{% endif %}
<button type="button" onclick="showEditModal()" class="btn btn-secondary" data-permission="vms.edit">
<i class="fa-solid fa-gear"></i> {{ t('settings') }}
</button>
<button onclick="showReinstallModal()" class="btn btn-warning" data-permission="vms.reinstall">
<i class="fa-solid fa-compact-disc"></i> {{ t('reinstall') }}
</button>
<!-- HA Button -->
<button id="btn-ha" onclick="toggleHA()" class="btn" style="background: var(--purple); color: var(--text-on-primary); display: none;" title="{{ t('high_availability') or 'High Availability' }}" data-permission="vms.ha">
<span id="btn-ha-icon"><i class="fa-solid fa-shield"></i></span> <span id="btn-ha-text">HA</span>
</button>
<button onclick="deleteInstance()" class="btn btn-danger" data-permission="vms.delete">
<i class="fa-solid fa-trash"></i> {{ t('delete') }}
</button>
</div>
</div>
<!-- IP Адреса -->
<div id="network-interfaces-card" class="card" style="margin-bottom: 1.5rem; display: none;">
<h3 style="margin: 0 0 1rem 0; color: var(--text-primary);"><i class="fa-solid fa-globe"></i> {{ t('network_interfaces') }}</h3>
<div id="network-interfaces-list" style="display: grid; gap: 0.75rem;">
<!-- Интерфейсы будут загружены динамически -->
</div>
<div id="network-interfaces-empty" style="color: var(--text-muted); padding: 1rem; text-align: center; display: none;">
<i class="fa-solid fa-circle-info"></i> {{ t('ip_unavailable_guest_agent') }}
</div>
</div>
<!-- Выполнение команд (только для VM с guest agent) -->
<div id="command-execution-card" class="card" style="margin-bottom: 1.5rem; display: none;">
<h3 style="margin: 0 0 1rem 0; color: var(--text-primary);">
<i class="fas fa-terminal"></i> {{ t('command_execution') }}
</h3>
<!-- Быстрые команды -->
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary); font-size: 0.875rem;">
{{ t('quick_commands') }}:
</label>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button class="btn btn-secondary" onclick="executeQuickCommand('df -h')" style="font-size: 0.875rem;">
<i class="fa-solid fa-floppy-disk"></i> {{ t('disks') }}
</button>
<button class="btn btn-secondary" onclick="executeQuickCommand('free -h')" style="font-size: 0.875rem;">
<i class="fas fa-memory"></i> {{ t('memory') }}
</button>
<button class="btn btn-secondary" onclick="executeQuickCommand('uptime')" style="font-size: 0.875rem;">
<i class="fa-solid fa-clock"></i> Uptime
</button>
<button class="btn btn-secondary" onclick="executeQuickCommand('ps -ef | head -n 20')" style="font-size: 0.875rem;">
<i class="fa-solid fa-chart-simple"></i> {{ t('processes') }}
</button>
<button class="btn btn-secondary" onclick="executeQuickCommand('command -v systemctl >/dev/null 2>&1 &amp;&amp; systemctl status || service --status-all 2>/dev/null || rc-status 2>/dev/null || echo &quot;Service management not available&quot;')" style="font-size: 0.875rem;">
<i class="fa-solid fa-wrench"></i> {{ t('services') }}
</button>
</div>
</div>
<!-- Произвольная команда -->
<div style="margin-bottom: 1rem;">
<label for="custom-command" style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary); font-size: 0.875rem;">
{{ t('custom_command') }}:
</label>
<div style="display: flex; gap: 0.5rem;">
<input
type="text"
id="custom-command"
placeholder="Например: ls -la /var/log"
onkeypress="if(event.key === 'Enter') executeCustomCommand()"
style="flex: 1; padding: 0.5rem; border: 1px solid var(--border); border-radius: 0.375rem; background: var(--bg-secondary); color: var(--text-primary);"
/>
<button class="btn btn-primary" onclick="executeCustomCommand()">
<i class="fa-solid fa-play"></i> {{ t('run') }}
</button>
</div>
</div>
<!-- Bash скрипт -->
<div style="margin-bottom: 1rem;">
<label for="bash-script" style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary); font-size: 0.875rem;">
{{ t('bash_script') }}:
</label>
<textarea
id="bash-script"
rows="6"
placeholder="#!/bin/bash&#10;echo 'Hello from VM'&#10;# Ваш скрипт здесь..."
style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 0.375rem; background: var(--bg-secondary); color: var(--text-primary); font-family: 'Courier New', monospace; font-size: 0.875rem;"
></textarea>
<button class="btn btn-primary" onclick="executeBashScript()" style="margin-top: 0.5rem;">
<i class="fa-solid fa-rocket"></i> {{ t('run_script') }}
</button>
</div>
<!-- Результат выполнения -->
<div id="command-output" style="display: none; margin-top: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary); font-size: 0.875rem;">
{{ t('result') }}:
</label>
<pre id="command-output-content" style="background: var(--bg-surface-hover); color: var(--text-primary); padding: 1rem; border-radius: 0.375rem; overflow-x: auto; font-size: 0.875rem; max-height: 400px; overflow-y: auto; border: 1px solid var(--border-default);"></pre>
</div>
</div>
<!-- Метрики в реальном времени -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
<div class="card" style="text-align: center;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;"><i class="fa-solid fa-computer"></i> {{ t('cpu') }}</div>
<div id="metric-cpu" style="font-size: 2rem; font-weight: bold; color: var(--accent);">--%</div>
<div id="metric-cpu-detail" style="font-size: 0.75rem; color: var(--text-muted);">-- {{ t('cores') }}</div>
<div class="progress-bar" style="margin-top: 0.5rem;">
<div id="metric-cpu-bar" class="progress-fill" style="width: 0%;"></div>
</div>
</div>
<div class="card" style="text-align: center;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;"><i class="fa-solid fa-memory"></i> {{ t('memory') }}</div>
<div id="metric-memory" style="font-size: 2rem; font-weight: bold; color: var(--accent);">--%</div>
<div id="metric-memory-detail" style="font-size: 0.75rem; color: var(--text-muted);">-- / --</div>
<div class="progress-bar" style="margin-top: 0.5rem;">
<div id="metric-memory-bar" class="progress-fill" style="width: 0%;"></div>
</div>
</div>
<div class="card" id="disk-metric-card" style="text-align: center;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;"><i class="fa-solid fa-floppy-disk"></i> {{ t('disk') }}</div>
<div id="metric-disk" style="font-size: 2rem; font-weight: bold; color: var(--accent);">--%</div>
<div id="metric-disk-detail" style="font-size: 0.75rem; color: var(--text-muted);">-- / --</div>
<div class="progress-bar" style="margin-top: 0.5rem;">
<div id="metric-disk-bar" class="progress-fill" style="width: 0%;"></div>
</div>
</div>
<div class="card" style="text-align: center;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;"><i class="fa-solid fa-clock"></i> {{ t('uptime') }}</div>
<div id="metric-uptime" style="font-size: 1.5rem; font-weight: bold; color: var(--text-primary);">--</div>
</div>
</div>
<!-- Сетевой трафик -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
<div class="card" style="text-align: center;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;"><i class="fa-solid fa-download"></i> {{ t('network_in') }}</div>
<div id="metric-netin" style="font-size: 1.5rem; font-weight: bold; color: var(--success);">--</div>
</div>
<div class="card" style="text-align: center;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;"><i class="fa-solid fa-upload"></i> {{ t('network_out') }}</div>
<div id="metric-netout" style="font-size: 1.5rem; font-weight: bold; color: var(--danger);">--</div>
</div>
<div class="card" style="text-align: center;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;"><i class="fa-solid fa-hard-drive"></i> {{ t('disk_read') }}</div>
<div id="metric-diskread" style="font-size: 1.5rem; font-weight: bold; color: var(--info);">--</div>
</div>
<div class="card" style="text-align: center;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;"><i class="fa-solid fa-file-lines"></i> {{ t('disk_write') }}</div>
<div id="metric-diskwrite" style="font-size: 1.5rem; font-weight: bold; color: var(--warning);">--</div>
</div>
</div>
<!-- Графики -->
<div class="card" style="margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0; color: var(--text-primary);"><i class="fa-solid fa-chart-line"></i> {{ t('monitoring_charts') }}</h3>
<div style="display: flex; gap: 0.5rem;">
<button onclick="changeTimeframe('hour')" class="btn" id="btn-hour" style="padding: 0.5rem 1rem; font-size: 0.875rem; background: var(--bg-tertiary); color: var(--text-primary); border: 2px solid var(--text-primary);">{{ t('hour') }}</button>
<button onclick="changeTimeframe('day')" class="btn" id="btn-day" style="padding: 0.5rem 1rem; font-size: 0.875rem; background: var(--bg-tertiary); color: var(--text-primary); border: 2px solid transparent;">{{ t('day') }}</button>
<button onclick="changeTimeframe('week')" class="btn" id="btn-week" style="padding: 0.5rem 1rem; font-size: 0.875rem; background: var(--bg-tertiary); color: var(--text-primary); border: 2px solid transparent;">{{ t('week') }}</button>
<button onclick="changeTimeframe('month')" class="btn" id="btn-month" style="padding: 0.5rem 1rem; font-size: 0.875rem; background: var(--bg-tertiary); color: var(--text-primary); border: 2px solid transparent;">{{ t('month') }}</button>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 1rem;">
<div style="background: var(--bg-tertiary); padding: 1rem; border-radius: 8px;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;">{{ t('cpu') }} %</div>
<canvas id="chart-cpu" style="max-height: 200px;"></canvas>
</div>
<div style="background: var(--bg-tertiary); padding: 1rem; border-radius: 8px;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;">{{ t('memory') }} ({{ t('gb') }})</div>
<canvas id="chart-memory" style="max-height: 200px;"></canvas>
</div>
<div style="background: var(--bg-tertiary); padding: 1rem; border-radius: 8px;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;">{{ t('network') }} (MB/s)</div>
<canvas id="chart-network" style="max-height: 200px;"></canvas>
</div>
<div style="background: var(--bg-tertiary); padding: 1rem; border-radius: 8px;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;">{{ t('disk') }} I/O (MB/s)</div>
<canvas id="chart-disk" style="max-height: 200px;"></canvas>
</div>
</div>
</div>
<!-- Конфигурация -->
<div class="card">
<h3 style="margin: 0 0 1rem 0; color: var(--text-primary);"><i class="fa-solid fa-list"></i> {{ t('configuration') }}</h3>
<div id="config-content" style="background: var(--bg-tertiary); padding: 1rem; border-radius: 8px; max-height: 400px; overflow-y: auto;">
<pre id="config-json" style="font-size: 0.8rem; color: var(--text-muted); margin: 0; white-space: pre-wrap;">{{ t('loading') }}</pre>
</div>
</div>
</div>
<!-- Modal VNC -->
<div id="vncModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.95); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 1400px; width: 95%; max-height: 95vh; display: flex; flex-direction: column;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color);">
<h3 id="vncTitle" style="margin: 0;"><i class="fa-solid fa-desktop"></i> {{ t('vnc_console') }}</h3>
<button onclick="closeVNCModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer; padding: 0.5rem;">&times;</button>
</div>
<div id="vncContent" style="flex: 1; min-height: 600px; background: #000; border-radius: 4px; overflow: hidden;"></div>
</div>
</div>
<!-- Modal xterm.js Terminal -->
<div id="terminalModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.95); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 1200px; width: 95%; max-height: 90vh; display: flex; flex-direction: column;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border-color);">
<h3 id="terminalTitle" style="margin: 0;"><i class="fa-solid fa-terminal"></i> {{ t('terminal_console') or 'Terminal' }}</h3>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<span id="terminalStatus" style="font-size: 0.75rem; color: var(--text-muted);"></span>
<button onclick="closeTerminalModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer; padding: 0.5rem;">&times;</button>
</div>
</div>
<div id="terminalContent" style="flex: 1; min-height: 500px; background: #000; border-radius: 4px; overflow: hidden;"></div>
</div>
</div>
<!-- Modal редактирования -->
<div id="editModal" 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: 800px; width: 95%; max-height: 95vh; overflow-y: auto;">
<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-gear"></i> {{ t('settings') }}</h3>
<button onclick="closeEditModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<div id="editContent">{{ t('loading') }}</div>
</div>
</div>
<!-- Modal переустановки -->
<div id="reinstallModal" 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: 600px; width: 95%; max-height: 95vh; overflow-y: auto;">
<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-compact-disc"></i> {{ t('reinstall') }}</h3>
<button onclick="closeReinstallModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<div id="reinstallContent">{{ t('loading') }}</div>
</div>
</div>
<!-- Modal подтверждения -->
<div id="confirmModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2100; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 450px; width: 95%;">
<div style="text-align: center; padding: 1rem;">
<div id="confirmIcon" style="font-size: 3rem; margin-bottom: 1rem;"><i class="fa-solid fa-triangle-exclamation"></i></div>
<h3 id="confirmTitle" style="margin: 0 0 1rem 0; color: var(--text-primary);">{{ t('confirm') }}</h3>
<p id="confirmMessage" style="color: var(--text-secondary); margin-bottom: 1.5rem;"></p>
<div style="display: flex; gap: 1rem; justify-content: center;">
<button onclick="closeConfirmModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary); min-width: 100px;">{{ t('cancel') }}</button>
<button id="confirmBtn" onclick="executeConfirmedAction()" class="btn" style="min-width: 100px;">{{ t('yes') }}</button>
</div>
</div>
</div>
</div>
<script>
const serverId = {{ server.id }};
const vmid = {{ vmid }};
const instanceType = '{{ type }}';
const apiType = instanceType === 'qemu' ? 'vm' : 'container';
let instanceNode = '{{ node }}';
let instanceName = '';
let instanceStatus = '';
let instanceConfig = null;
let vncConnection = null;
let pendingAction = null;
let currentTimeframe = 'hour';
// Charts
let cpuChart = null;
let memoryChart = null;
let networkChart = null;
let diskChart = null;
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
}
function formatUptime(seconds) {
if (!seconds) return '--';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h ${mins}m`;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
async function loadInstanceData() {
try {
// Если нода указана в URL, используем её напрямую
// Это нужно для кластерных окружений где /resources может не вернуть все ноды
if (instanceNode) {
// Напрямую загружаем статус инстанса по известной ноде
const statusEndpoint = instanceType === 'qemu'
? `/proxmox/api/${serverId}/vm/${vmid}/status?node=${instanceNode}`
: `/proxmox/api/${serverId}/container/${vmid}/status?node=${instanceNode}`;
const statusRes = await fetchWithAuth(statusEndpoint);
if (statusRes && statusRes.ok) {
const statusData = await statusRes.json();
instanceName = statusData.name || `${instanceType === 'qemu' ? 'VM' : 'LXC'}-${vmid}`;
instanceStatus = statusData.status || 'unknown';
// Update UI
document.getElementById('instance-name').textContent = instanceName;
document.getElementById('instance-icon').innerHTML = instanceType === 'qemu' ? '<i class="fa-solid fa-computer"></i>' : '<i class="fa-solid fa-cube"></i>';
const typeBadge = document.getElementById('instance-type-badge');
typeBadge.textContent = instanceType === 'qemu' ? 'QEMU' : 'LXC';
typeBadge.style.background = instanceType === 'qemu' ? 'var(--info)' : 'var(--purple)';
updateStatus(instanceStatus);
updateButtons(instanceStatus);
// Загружаем конфигурацию и графики
await Promise.all([
loadConfig(),
loadRRDData()
]);
document.getElementById('loading').style.display = 'none';
document.getElementById('instance-content').style.display = 'block';
return;
}
// Если запрос статуса не удался, пробуем через /resources
}
// Fallback: Get resources list to find node and status
const resourcesRes = await fetchWithAuth(`/proxmox/api/${serverId}/resources`);
if (!resourcesRes || !resourcesRes.ok) {
const errorText = resourcesRes ? await resourcesRes.text() : 'No response';
throw new Error(t('failed_to_load_resources') + ': ' + errorText);
}
const resources = await resourcesRes.json();
const allResources = [...(resources.vms || []), ...(resources.containers || [])];
const instance = allResources.find(r => r.vmid === vmid);
if (!instance) {
throw new Error(t('instance_not_found') + ': VMID ' + vmid);
}
instanceNode = instance.node;
instanceName = instance.name || `VM-${vmid}`;
instanceStatus = instance.status;
// Update UI
document.getElementById('instance-name').textContent = instanceName;
document.getElementById('instance-icon').innerHTML = instanceType === 'qemu' ? '<i class="fa-solid fa-computer"></i>' : '<i class="fa-solid fa-cube"></i>';
const typeBadge = document.getElementById('instance-type-badge');
typeBadge.textContent = instanceType === 'qemu' ? 'QEMU' : 'LXC';
typeBadge.style.background = instanceType === 'qemu' ? 'var(--info)' : 'var(--purple)';
updateStatus(instanceStatus);
updateButtons(instanceStatus);
// Загружаем статус и конфигурацию
await Promise.all([
loadStatus(),
loadConfig(),
loadRRDData()
]);
document.getElementById('loading').style.display = 'none';
document.getElementById('instance-content').style.display = 'block';
} catch (error) {
console.error('Error loading instance:', error);
document.getElementById('loading').innerHTML = `
<div style="color: var(--danger);">
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-circle-xmark"></i></div>
<p>${error.message}</p>
<a href="/virtual-machines" class="btn" style="margin-top: 1rem;">← ${t('back_to_vms')}</a>
</div>
`;
}
}
function updateStatus(status) {
const statusEl = document.getElementById('instance-status');
const dot = statusEl.querySelector('.status-dot');
const text = statusEl.querySelector('.status-text');
if (status === 'running') {
dot.style.background = 'var(--success)';
text.textContent = 'Running';
text.style.color = 'var(--success)';
} else {
dot.style.background = 'var(--danger)';
text.textContent = 'Stopped';
text.style.color = 'var(--danger)';
}
}
function updateButtons(status) {
// Check permissions before showing buttons
const canStart = window.hasPermission('vms.start');
const canStop = window.hasPermission('vms.stop');
const canRestart = window.hasPermission('vms.restart');
document.getElementById('btn-start').style.display = (status !== 'running' && canStart) ? 'inline-flex' : 'none';
document.getElementById('btn-stop').style.display = (status === 'running' && canStop) ? 'inline-flex' : 'none';
document.getElementById('btn-kill').style.display = (status === 'running' && canStop) ? 'inline-flex' : 'none';
document.getElementById('btn-restart').style.display = (status === 'running' && canRestart) ? 'inline-flex' : 'none';
}
async function loadStatus() {
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/status?node=${instanceNode}`);
if (!response.ok) return;
const status = await response.json();
if (!status) return;
// CPU
const cpuPercent = ((status.cpu || 0) * 100).toFixed(1);
document.getElementById('metric-cpu').textContent = `${cpuPercent}%`;
const cpuBar = document.getElementById('metric-cpu-bar');
cpuBar.style.width = `${cpuPercent}%`;
cpuBar.className = `progress-fill ${cpuPercent > 80 ? 'danger' : cpuPercent > 60 ? 'warning' : ''}`;
// Memory
const memUsed = status.mem || 0;
const memMax = status.maxmem || 1;
const memPercent = ((memUsed / memMax) * 100).toFixed(1);
document.getElementById('metric-memory').textContent = `${memPercent}%`;
document.getElementById('metric-memory-detail').textContent = `${formatBytes(memUsed)} / ${formatBytes(memMax)}`;
const memBar = document.getElementById('metric-memory-bar');
memBar.style.width = `${memPercent}%`;
memBar.className = `progress-fill ${memPercent > 80 ? 'danger' : memPercent > 60 ? 'warning' : ''}`;
// Disk - для QEMU виртуальных машин Proxmox не возвращает disk usage
// Показываем только если данные есть (для LXC контейнеров)
const diskUsed = status.disk || 0;
const diskMax = status.maxdisk || 0;
const diskCard = document.getElementById('disk-metric-card');
if (diskMax > 0 && diskUsed > 0) {
const diskPercent = ((diskUsed / diskMax) * 100).toFixed(1);
document.getElementById('metric-disk').textContent = `${diskPercent}%`;
document.getElementById('metric-disk-detail').textContent = `${formatBytes(diskUsed)} / ${formatBytes(diskMax)}`;
const diskBar = document.getElementById('metric-disk-bar');
diskBar.style.width = `${diskPercent}%`;
diskBar.className = `progress-fill ${diskPercent > 80 ? 'danger' : diskPercent > 60 ? 'warning' : ''}`;
if (diskCard) diskCard.style.display = '';
} else if (diskMax > 0) {
// Есть maxdisk но нет disk - показываем размер диска
document.getElementById('metric-disk').textContent = formatBytes(diskMax);
document.getElementById('metric-disk-detail').textContent = t('disk_size');
document.getElementById('metric-disk-bar').style.display = 'none';
if (diskCard) diskCard.style.display = '';
} else {
// Нет данных о диске - скрываем карточку
if (diskCard) diskCard.style.display = 'none';
}
// Uptime
document.getElementById('metric-uptime').textContent = formatUptime(status.uptime);
// Network
document.getElementById('metric-netin').textContent = formatBytes(status.netin || 0);
document.getElementById('metric-netout').textContent = formatBytes(status.netout || 0);
// Disk I/O
document.getElementById('metric-diskread').textContent = formatBytes(status.diskread || 0);
document.getElementById('metric-diskwrite').textContent = formatBytes(status.diskwrite || 0);
// Обновляем статус
instanceStatus = status.status;
updateStatus(status.status);
updateButtons(status.status);
} catch (error) {
console.error('Error loading status:', error);
}
}
async function loadConfig() {
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/config?node=${instanceNode}`);
if (!response.ok) return;
instanceConfig = await response.json();
if (!instanceConfig) return;
document.getElementById('config-json').textContent = JSON.stringify(instanceConfig, null, 2);
// Обновляем информацию о ядрах CPU
const cores = instanceConfig.cores || 1;
const cpuDetail = document.getElementById('metric-cpu-detail');
if (cpuDetail) {
cpuDetail.textContent = `${cores} ${cores === 1 ? 'core' : 'cores'}`;
}
} catch (error) {
console.error('Error loading config:', error);
}
// Загружаем информацию о сетевых интерфейсах асинхронно (не блокирует загрузку страницы)
loadNetworkInterfaces().catch(err => console.error('Failed to load network interfaces:', err));
}
async function loadNetworkInterfaces() {
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/interfaces?node=${instanceNode}`);
if (!response.ok) return;
const data = await response.json();
const interfaces = data.interfaces || [];
if (interfaces.length > 0) {
displayNetworkInterfaces(interfaces);
// Если интерфейсы доступны и это VM (qemu), значит guest agent работает
if (instanceType === 'qemu') {
document.getElementById('command-execution-card').style.display = 'block';
}
} else {
document.getElementById('network-interfaces-empty').style.display = 'block';
document.getElementById('network-interfaces-card').style.display = 'block';
}
} catch (error) {
console.error('Error loading network interfaces:', error);
}
}
function displayNetworkInterfaces(interfaces) {
const container = document.getElementById('network-interfaces-list');
container.innerHTML = '';
interfaces.forEach(iface => {
const ifaceDiv = document.createElement('div');
ifaceDiv.style.cssText = 'background: var(--bg-tertiary); padding: 1rem; border-radius: 8px; border-left: 3px solid var(--accent);';
let html = `
<div style="display: flex; justify-content: space-between; align-items: start; flex-wrap: wrap; gap: 0.5rem;">
<div>
<div style="font-weight: 600; color: var(--text-primary); margin-bottom: 0.5rem;">
<i class="fa-solid fa-plug"></i> ${iface.name}
</div>
`;
if (iface.hardware_address) {
html += `
<div style="color: var(--text-muted); font-size: 0.85rem; margin-bottom: 0.5rem;">
MAC: <code style="background: var(--bg-secondary); padding: 0.15rem 0.4rem; border-radius: 4px;">${iface.hardware_address}</code>
</div>
`;
}
if (iface.ips && iface.ips.length > 0) {
html += '<div style="display: flex; flex-direction: column; gap: 0.35rem;">';
iface.ips.forEach(ip => {
const icon = ip.type === 'ipv4'
? '<i class="fa-solid fa-globe" style="color: var(--success);"></i>'
: '<i class="fa-solid fa-earth-americas" style="color: var(--info);"></i>';
const badge = ip.type === 'ipv4' ?
'<span class="badge badge-success">IPv4</span>' :
'<span class="badge badge-info">IPv6</span>';
html += `
<div style="display: flex; align-items: center; gap: 0.5rem;">
${icon} ${badge}
<code style="background: var(--bg-surface-hover); padding: 0.25rem 0.6rem; border-radius: 4px; color: var(--text-primary); font-weight: 500;">${ip.address}/${ip.prefix}</code>
</div>
`;
});
html += '</div>';
}
html += `
</div>
</div>
`;
ifaceDiv.innerHTML = html;
container.appendChild(ifaceDiv);
});
document.getElementById('network-interfaces-card').style.display = 'block';
document.getElementById('network-interfaces-empty').style.display = 'none';
}
// ==================== Выполнение команд ====================
async function executeQuickCommand(command) {
await executeCommand(command);
}
async function executeCustomCommand() {
const commandInput = document.getElementById('custom-command');
const command = commandInput.value.trim();
if (!command) {
showNotification(t('enter_command'), 'error');
return;
}
await executeCommand(command);
}
async function executeBashScript() {
const scriptTextarea = document.getElementById('bash-script');
const script = scriptTextarea.value.trim();
if (!script) {
showNotification(t('enter_script'), 'error');
return;
}
const outputDiv = document.getElementById('command-output');
const outputContent = document.getElementById('command-output-content');
outputDiv.style.display = 'block';
outputContent.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> ${t('executing_script')}\n`;
try {
const timeout = 60; // 60 секунд для скрипта
// Создаём FormData для отправки скрипта
const formData = new URLSearchParams();
formData.append('script', script);
formData.append('interpreter', '/bin/bash');
const response = await fetchWithAuth(
`/proxmox/api/${serverId}/vm/${vmid}/execute-script?node=${instanceNode}&timeout=${timeout}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString()
}
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
outputContent.innerHTML = `<i class="fa-solid fa-circle-xmark"></i> ${t('error')}: ${escapeHtml(JSON.stringify(error.detail || error))}`;
showNotification(t('script_execution_error'), 'error');
return;
}
const result = await response.json();
displayCommandOutput(result);
} catch (error) {
outputContent.innerHTML = `<i class="fa-solid fa-circle-xmark"></i> ${t('error')}: ${escapeHtml(error.message)}`;
showNotification(t('script_execution_error'), 'error');
}
}
// Helper function to escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function executeCommand(command) {
const outputDiv = document.getElementById('command-output');
const outputContent = document.getElementById('command-output-content');
outputDiv.style.display = 'block';
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-spinner fa-spin"></i> Выполняется...\n`;
try {
const timeout = 30;
const response = await fetchWithAuth(
`/proxmox/api/${serverId}/vm/${vmid}/execute?node=${instanceNode}&command=${encodeURIComponent(command)}&timeout=${timeout}`,
{
method: 'POST'
}
);
if (!response.ok) {
const error = await response.text();
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-circle-xmark"></i> Ошибка: ${error}`;
showNotification('Ошибка выполнения команды', 'error');
return;
}
const result = await response.json();
displayCommandOutput(result, command);
} catch (error) {
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-circle-xmark"></i> Ошибка: ${error.message}`;
showNotification('Ошибка выполнения команды', 'error');
}
}
function displayCommandOutput(result, command = null) {
const outputContent = document.getElementById('command-output-content');
let output = '';
if (command) {
output += `$ ${command}\n\n`;
}
if (result.success) {
if (result.stdout) {
output += result.stdout;
}
if (result.stderr) {
output += '\n--- STDERR ---\n' + result.stderr;
}
output += `\n\n<i class="fa-solid fa-circle-check"></i> Exit code: ${result.exit_code}`;
showNotification(t('command_executed_successfully'), 'success');
} else {
output += `<i class="fa-solid fa-circle-xmark"></i> ${t('error')}: ${result.error || t('unknown_error')}\n`;
if (result.stdout) {
output += '\n--- STDOUT ---\n' + result.stdout;
}
if (result.stderr) {
output += '\n--- STDERR ---\n' + result.stderr;
}
if (result.exit_code !== undefined && result.exit_code !== -1) {
output += `\n\nExit code: ${result.exit_code}`;
}
showNotification('Ошибка выполнения', 'error');
}
outputContent.textContent = output;
}
async function loadRRDData(timeframe = 'hour') {
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/rrddata?node=${instanceNode}&timeframe=${timeframe}`);
if (!response.ok) return;
const rrdData = await response.json();
const data = Array.isArray(rrdData) ? rrdData : (rrdData.data || []);
if (data.length > 0) {
drawCharts(data, timeframe);
}
} catch (error) {
console.error('Error loading RRD data:', error);
}
}
function changeTimeframe(timeframe) {
currentTimeframe = timeframe;
// Обновляем кнопки
['hour', 'day', 'week', 'month'].forEach(tf => {
const btn = document.getElementById(`btn-${tf}`);
if (btn) {
if (tf === timeframe) {
btn.style.border = '2px solid var(--text-primary)';
} else {
btn.style.border = '2px solid transparent';
}
}
});
// Загружаем новые данные
loadRRDData(timeframe);
}
function drawCharts(data, timeframe = 'hour') {
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: 8 }
},
y: {
beginAtZero: true,
grid: { color: 'rgba(255,255,255,0.1)' },
ticks: { color: '#b0b0b0' }
}
}
};
const formatTime = (timestamp) => {
const date = new Date(timestamp * 1000);
if (timeframe === 'hour') {
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
} else if (timeframe === 'day') {
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
} else if (timeframe === 'week' || timeframe === 'month') {
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' });
}
return date.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit' });
};
const labels = data.map(d => formatTime(d.time));
// Получаем количество ядер из конфигурации
const cores = instanceConfig?.cores || 1;
// CPU Chart - одна линия общей загрузки CPU
const cpuCtx = document.getElementById('chart-cpu');
if (cpuCtx) {
if (cpuChart) cpuChart.destroy();
cpuChart = new Chart(cpuCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: `CPU % (${cores} ${cores === 1 ? t('core') : t('cores')})`,
data: data.map(d => ((d.cpu || 0) * 100).toFixed(2)),
borderColor: '#8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 0
}]
},
options: {
...chartConfig,
plugins: {
...chartConfig.plugins,
legend: {
display: false
},
tooltip: {
enabled: true,
mode: 'index',
intersect: false,
callbacks: {
label: function(context) {
return 'CPU: ' + context.parsed.y + '%';
}
}
}
},
scales: {
...chartConfig.scales,
y: {
...chartConfig.scales.y,
max: 100,
ticks: {
...chartConfig.scales.y.ticks,
callback: function(value) {
return value + '%';
}
}
}
}
}
});
}
// Memory Chart (в гигабайтах)
const memCtx = document.getElementById('chart-memory');
if (memCtx) {
if (memoryChart) memoryChart.destroy();
// Получаем максимальную память для отображения на графике
const maxMemGB = data.length > 0 ? ((data[0].maxmem || 1) / (1024**3)).toFixed(2) : 0;
memoryChart = new Chart(memCtx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Использовано',
data: data.map(d => {
const used = d.mem || d.memused || 0;
return (used / (1024**3)).toFixed(2);
}),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 0
},
{
label: 'Выделено',
data: data.map(d => {
const max = d.maxmem || d.memtotal || 1;
return (max / (1024**3)).toFixed(2);
}),
borderColor: '#94a3b8',
backgroundColor: 'rgba(148, 163, 184, 0.05)',
borderWidth: 1,
borderDash: [5, 5],
tension: 0.4,
fill: false,
pointRadius: 0
}
]
},
options: {
...chartConfig,
plugins: {
...chartConfig.plugins,
legend: {
display: true,
position: 'top',
labels: { color: '#b0b0b0', usePointStyle: true, font: { size: 11 } }
},
tooltip: {
enabled: true,
mode: 'index',
intersect: false,
callbacks: {
label: function(context) {
return context.dataset.label + ': ' + context.parsed.y + ' GB';
}
}
}
},
scales: {
...chartConfig.scales,
y: {
...chartConfig.scales.y,
ticks: {
color: '#b0b0b0',
callback: function(value) {
return value + ' GB';
}
}
}
}
}
});
}
// Network Chart
const netCtx = document.getElementById('chart-network');
if (netCtx) {
if (networkChart) networkChart.destroy();
networkChart = new Chart(netCtx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'In',
data: data.map(d => ((d.netin || 0) / (1024*1024)).toFixed(2)),
borderColor: '#4ade80',
backgroundColor: 'rgba(74, 222, 128, 0.1)',
borderWidth: 2,
tension: 0.4,
pointRadius: 0
},
{
label: 'Out',
data: data.map(d => ((d.netout || 0) / (1024*1024)).toFixed(2)),
borderColor: '#f87171',
backgroundColor: 'rgba(248, 113, 113, 0.1)',
borderWidth: 2,
tension: 0.4,
pointRadius: 0
}
]
},
options: {
...chartConfig,
plugins: {
...chartConfig.plugins,
legend: { display: true, position: 'top', labels: { color: '#b0b0b0', usePointStyle: true } }
}
}
});
}
// Disk I/O Chart
const diskCtx = document.getElementById('chart-disk');
if (diskCtx) {
if (diskChart) diskChart.destroy();
diskChart = new Chart(diskCtx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Read',
data: data.map(d => ((d.diskread || 0) / (1024*1024)).toFixed(2)),
borderColor: '#60a5fa',
backgroundColor: 'rgba(96, 165, 250, 0.1)',
borderWidth: 2,
tension: 0.4,
pointRadius: 0
},
{
label: 'Write',
data: data.map(d => ((d.diskwrite || 0) / (1024*1024)).toFixed(2)),
borderColor: '#fbbf24',
backgroundColor: 'rgba(251, 191, 36, 0.1)',
borderWidth: 2,
tension: 0.4,
pointRadius: 0
}
]
},
options: {
...chartConfig,
plugins: {
...chartConfig.plugins,
legend: { display: true, position: 'top', labels: { color: '#b0b0b0', usePointStyle: true } }
}
}
});
}
}
// Модальное окно подтверждения
function showConfirmModal(icon, title, message, btnColor, action) {
pendingAction = action;
document.getElementById('confirmIcon').innerHTML = icon;
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmMessage').innerHTML = message;
document.getElementById('confirmBtn').style.background = btnColor;
document.getElementById('confirmModal').style.display = 'flex';
}
function closeConfirmModal() {
document.getElementById('confirmModal').style.display = 'none';
}
async function executeConfirmedAction() {
if (pendingAction && typeof pendingAction === 'function') {
const actionToExecute = pendingAction;
pendingAction = null;
closeConfirmModal();
await actionToExecute();
}
}
async function controlInstance(action) {
const actionConfig = {
start: { icon: '<i class="fa-solid fa-play"></i>', title: t('action_start'), color: 'var(--success)' },
stop: { icon: '<i class="fa-solid fa-stop"></i>', title: t('action_stop'), color: 'var(--danger)' },
kill: { icon: '<i class="fa-solid fa-skull"></i>', title: t('action_kill'), color: '#7f1d1d' },
restart: { icon: '<i class="fa-solid fa-rotate"></i>', title: t('action_restart'), color: 'var(--warning)' }
};
const config = actionConfig[action];
const warningText = action === 'kill' ? '\n\n' + t('kill_warning') : '';
showConfirmModal(
config.icon,
config.title,
`${t('confirm_action')} "${instanceName}"?${warningText}`,
config.color,
async () => {
try {
// Для kill используем stop с force=1
const url = action === 'kill'
? `/proxmox/api/${serverId}/${apiType}/${vmid}/stop?node=${instanceNode}&force=1`
: `/proxmox/api/${serverId}/${apiType}/${vmid}/${action}?node=${instanceNode}`;
const response = await fetchWithAuth(url, {
method: 'POST'
});
if (response.ok) {
showNotification(`<i class="fa-solid fa-circle-check"></i> ${t('command_sent')}: ${config.title}`, 'success');
setTimeout(loadStatus, 3000);
} else {
const error = await response.json();
showNotification(`<i class="fa-solid fa-circle-xmark"></i> ${t('error')}: ${error.detail}`, 'error');
}
} catch (error) {
showNotification('<i class="fa-solid fa-circle-xmark"></i> ' + t('connection_error'), 'error');
}
}
);
}
async function deleteInstance() {
// First confirmation
const confirmed = await showDeleteConfirm(
`${t('delete_instance_confirm').replace('{name}', instanceName).replace('{id}', vmid)}`,
t('delete_instance')
);
if (!confirmed) return;
// Second confirmation - enter ID
const confirmId = await showPrompt(
t('enter_id_to_confirm').replace('{id}', vmid),
t('confirm_deletion') || 'Подтверждение удаления',
{
type: 'danger',
icon: '<i class="fa-solid fa-triangle-exclamation"></i>',
placeholder: vmid.toString(),
confirmText: t('delete') || 'Удалить',
confirmClass: 'danger'
}
);
if (confirmId === null) return;
if (confirmId !== String(vmid)) {
showNotification('<i class="fa-solid fa-circle-xmark"></i> ' + t('id_mismatch'), 'error');
return;
}
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}?node=${instanceNode}&force=true`, {
method: 'DELETE'
});
if (response.ok) {
showNotification('<i class="fa-solid fa-circle-check"></i> ' + t('instance_deleted'), 'success');
setTimeout(() => {
window.location.href = `/proxmox/server/${serverId}`;
}, 1500);
} else {
const error = await response.json();
showNotification(`<i class="fa-solid fa-circle-xmark"></i> ${t('error')}: ${error.detail}`, 'error');
}
} catch (error) {
showNotification('<i class="fa-solid fa-circle-xmark"></i> ' + t('connection_error'), 'error');
}
}
// Toast уведомления
function showNotification(message, type = 'info') {
const colors = {
success: 'var(--success)',
error: 'var(--danger)',
warning: 'var(--warning)',
info: 'var(--primary)'
};
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
bottom: 2rem;
right: 2rem;
background: ${colors[type]};
color: var(--text-on-primary);
padding: 1rem 1.5rem;
border-radius: 8px;
font-weight: 500;
z-index: 3000;
animation: slideIn 0.3s ease;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
`;
toast.innerHTML = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// VNC
async function openVNC() {
const modal = document.getElementById('vncModal');
const vncContent = document.getElementById('vncContent');
modal.style.display = 'flex';
document.getElementById('vncTitle').innerHTML = `<i class="fa-solid fa-desktop"></i> VNC Console: ${instanceName}`;
vncContent.innerHTML = `<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> ${t('connecting')}</div>`;
try {
// Wait for RFB to be loaded
const RFBClass = await window.waitForRFB();
if (!RFBClass) {
throw new Error(t('novnc_not_loaded'));
}
const response = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/vnc?node=${instanceNode}`);
if (!response) {
throw new Error('Not authenticated - redirecting to login');
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const vncData = await response.json();
// Очистить контейнер
vncContent.innerHTML = '';
// Создать URL для websocket подключения через наш прокси
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = window.location.host;
// Backend expects vm/container, not qemu/lxc
const resourceType = vncData.type === 'qemu' ? 'vm' : 'container';
// Подключаемся через наш WebSocket прокси (используем node из ответа API)
// Передаём vnc_password для получения его первым сообщением
const wsUrl = `${protocol}//${wsHost}/proxmox/ws/vnc/${serverId}/${vncData.node}/${resourceType}/${vmid}?port=${vncData.port}&vncticket=${encodeURIComponent(vncData.ticket)}&auth_ticket=${encodeURIComponent(vncData.auth_ticket)}&vnc_password=${encodeURIComponent(vncData.password || '')}`;
// Создаём WebSocket вручную и ждём первое сообщение с паролем
const ws = new WebSocket(wsUrl);
ws.addEventListener('message', function onFirstMessage(event) {
// Первое сообщение - это VNC пароль от бэкенда
const vncPassword = event.data;
// Убираем этот обработчик (нужен только для первого сообщения)
ws.removeEventListener('message', onFirstMessage);
// Создаём RFB с существующим WebSocket и полученным паролем
vncConnection = new RFBClass(vncContent, ws, {
credentials: { password: vncPassword }
});
vncConnection.scaleViewport = true;
vncConnection.resizeSession = true;
vncConnection.addEventListener('connect', () => {
setTimeout(() => {
if (vncConnection && vncConnection._display) {
vncConnection._display.autoscale(vncContent.clientWidth, vncContent.clientHeight);
}
}, 500);
});
vncConnection.addEventListener('credentialsrequired', (e) => {
vncConnection.sendCredentials({ password: vncPassword });
});
vncConnection.addEventListener('disconnect', (e) => {
if (e.detail.clean) {
vncContent.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary);">Соединение закрыто</div>';
} else {
vncContent.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary); padding: 2rem;">
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-triangle-exclamation"></i></div>
<div style="margin-bottom: 1rem; font-size: 1.125rem;">Соединение прервано</div>
<div style="background: var(--bg-tertiary); padding: 1.5rem; border-radius: 8px; max-width: 500px; text-align: left; margin-bottom: 1rem;">
<p style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 0.5rem;">
<strong>Возможные причины:</strong>
</p>
<ul style="color: var(--text-muted); font-size: 0.8rem; margin-left: 1rem;">
<li>Proxmox VNC требует авторизацию по паролю (не API token)</li>
<li>Добавьте пароль в настройках Proxmox сервера</li>
<li>VM/контейнер может быть выключен</li>
</ul>
</div>
<div style="display: flex; gap: 1rem;">
<button onclick="openVNC()" class="btn btn-primary"><i class="fa-solid fa-rotate"></i> Переподключиться</button>
<a href="https://${vncData.host}:8006" target="_blank" class="btn btn-secondary"><i class="fa-solid fa-globe"></i> Открыть Proxmox</a>
</div>
</div>
`;
}
});
vncConnection.addEventListener('securityfailure', (e) => {
vncContent.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-lock"></i></div>
<div class="text-danger">Ошибка безопасности VNC</div>
<p style="color: var(--text-muted); font-size: 0.875rem; margin-top: 0.5rem;">${e.detail.reason || 'Неизвестная ошибка'}</p>
</div>
`;
});
}, { once: true });
ws.addEventListener('error', (error) => {
vncContent.innerHTML = `<div style="padding: 2rem; text-align: center; color: var(--text-secondary);"><i class="fa-solid fa-circle-xmark"></i> WebSocket connection error</div>`;
});
ws.addEventListener('close', (event) => {
if (!vncConnection) {
// Закрыто до создания RFB
vncContent.innerHTML = `<div style="padding: 2rem; text-align: center; color: var(--text-secondary);"><i class="fa-solid fa-circle-xmark"></i> Connection closed: ${event.reason || 'Unknown error'}</div>`;
}
});
} catch (error) {
vncContent.innerHTML = `<div style="padding: 2rem; text-align: center; color: var(--text-secondary);"><i class="fa-solid fa-circle-xmark"></i> ${error.message}</div>`;
}
}
function closeVNCModal() {
if (vncConnection) {
vncConnection.disconnect();
vncConnection = null;
}
document.getElementById('vncModal').style.display = 'none';
}
// === TERMINAL (xterm.js) FUNCTIONS ===
let terminalInstance = null;
let terminalWebSocket = null;
let fitAddon = null;
async function openTerminal() {
const modal = document.getElementById('terminalModal');
document.getElementById('terminalTitle').innerHTML = `<i class="fa-solid fa-terminal"></i> Terminal: ${instanceName || 'Instance'} (${vmid})`;
document.getElementById('terminalStatus').textContent = t('connecting') + '...';
document.getElementById('terminalContent').innerHTML = '';
modal.style.display = 'flex';
// Small delay to ensure modal is visible
setTimeout(() => {
connectTerminal();
}, 100);
}
async function connectTerminal() {
const terminalContent = document.getElementById('terminalContent');
const statusEl = document.getElementById('terminalStatus');
try {
// Initialize xterm.js
terminalInstance = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", monospace',
theme: {
background: '#1a1a1a',
foreground: '#e0e0e0',
cursor: '#00ff00',
cursorAccent: '#1a1a1a',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#000000',
red: '#ff5555',
green: '#50fa7b',
yellow: '#f1fa8c',
blue: '#6272a4',
magenta: '#ff79c6',
cyan: '#8be9fd',
white: '#f8f8f2',
brightBlack: '#6272a4',
brightRed: '#ff6e6e',
brightGreen: '#69ff94',
brightYellow: '#ffffa5',
brightBlue: '#d6acff',
brightMagenta: '#ff92df',
brightCyan: '#a4ffff',
brightWhite: '#ffffff'
},
allowTransparency: false,
scrollback: 10000,
tabStopWidth: 4
});
// Add fit addon
fitAddon = new FitAddon.FitAddon();
terminalInstance.loadAddon(fitAddon);
// Add web links addon
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
terminalInstance.loadAddon(webLinksAddon);
// Open terminal in container
terminalInstance.open(terminalContent);
fitAddon.fit();
// Connect WebSocket для терминала
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = window.location.host;
const wsUrl = `${protocol}//${wsHost}/proxmox/ws/terminal/${serverId}/${instanceNode}/${vmid}`;
terminalWebSocket = new WebSocket(wsUrl);
terminalWebSocket.binaryType = 'arraybuffer';
// TextEncoder для правильного подсчёта байтов UTF-8
const textEncoder = new TextEncoder();
terminalWebSocket.onopen = () => {
statusEl.textContent = t('connected') || 'Connected';
statusEl.style.color = 'var(--success)';
terminalInstance.focus();
// Send initial resize
const dims = fitAddon.proposeDimensions();
if (dims) {
const resizeMsg = `1:${dims.cols}:${dims.rows}:`;
terminalWebSocket.send(resizeMsg);
}
};
terminalWebSocket.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
const data = new Uint8Array(event.data);
terminalInstance.write(data);
} else {
terminalInstance.write(event.data);
}
};
terminalWebSocket.onclose = (event) => {
statusEl.textContent = t('disconnected') || 'Disconnected';
statusEl.style.color = 'var(--danger)';
if (!event.wasClean) {
terminalInstance.write('\r\n\x1b[31m*** Connection closed ***\x1b[0m\r\n');
}
};
terminalWebSocket.onerror = (error) => {
console.error('Terminal WebSocket error:', error);
statusEl.textContent = t('error') || 'Error';
statusEl.style.color = 'var(--danger)';
};
// Send input to WebSocket
terminalInstance.onData(data => {
if (terminalWebSocket && terminalWebSocket.readyState === WebSocket.OPEN) {
// Proxmox terminal protocol: 0:BYTELENGTH:MSG
const byteLength = textEncoder.encode(data).length;
const msg = `0:${byteLength}:${data}`;
terminalWebSocket.send(msg);
}
});
// Handle resize
const handleResize = () => {
if (fitAddon && terminalInstance && terminalWebSocket && terminalWebSocket.readyState === WebSocket.OPEN) {
fitAddon.fit();
const dims = fitAddon.proposeDimensions();
if (dims) {
const resizeMsg = `1:${dims.cols}:${dims.rows}:`;
terminalWebSocket.send(resizeMsg);
}
}
};
window.addEventListener('resize', handleResize);
// Keep-alive ping
window.terminalPingInterval = setInterval(() => {
if (terminalWebSocket && terminalWebSocket.readyState === WebSocket.OPEN) {
terminalWebSocket.send('2');
}
}, 120000);
} catch (error) {
console.error('Terminal error:', error);
statusEl.textContent = t('error') || 'Error';
statusEl.style.color = 'var(--danger)';
terminalContent.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;">${t('terminal_connection_error') || 'Terminal Connection Error'}</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">${error.message}</p>
<button onclick="closeTerminalModal()" class="btn">${t('close') || 'Close'}</button>
</div>
</div>
`;
}
}
function closeTerminalModal() {
// Clear keep-alive ping interval
if (window.terminalPingInterval) {
clearInterval(window.terminalPingInterval);
window.terminalPingInterval = null;
}
// Close WebSocket connection
if (terminalWebSocket) {
terminalWebSocket.close();
terminalWebSocket = null;
}
// Dispose terminal instance
if (terminalInstance) {
terminalInstance.dispose();
terminalInstance = null;
}
fitAddon = null;
document.getElementById('terminalModal').style.display = 'none';
}
// Edit Modal
async function showEditModal() {
document.getElementById('editModal').style.display = 'flex';
const content = document.getElementById('editContent');
content.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> Загрузка...</div>';
if (!instanceConfig) await loadConfig();
renderEditForm();
}
function renderEditForm() {
const config = instanceConfig || {};
const content = document.getElementById('editContent');
const cores = config.cores || 1;
const sockets = config.sockets || 1;
const memory = config.memory || 512;
const name = config.name || '';
const onboot = config.onboot || 0;
// Найти диски (исключаем ide0/ide2 - cloud-init диски)
const disks = [];
const diskKeys = instanceType === 'qemu'
? ['scsi0', 'scsi1', 'scsi2', 'virtio0', 'virtio1', 'virtio2', 'sata0', 'sata1']
: ['rootfs', 'mp0', 'mp1', 'mp2'];
diskKeys.forEach(key => {
if (config[key]) {
// Пропускаем cloud-init диски
if (config[key].includes('cloudinit') || config[key].includes('cloud-init')) return;
let currentSize = 'N/A';
const sizeMatch = config[key].match(/size=(\d+)([GMTK]?)/i);
if (sizeMatch) currentSize = sizeMatch[1] + (sizeMatch[2] || 'G');
disks.push({ key, info: config[key], currentSize });
}
});
let html = `
<form onsubmit="saveConfig(event)">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
<div>
<label style="display: block; color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.25rem;">${t('name')}</label>
<input type="text" id="edit-name" value="${name}" style="width: 100%; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div>
<label style="display: block; color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.25rem;">${t('cpu_cores')}</label>
<input type="number" id="edit-cores" value="${cores}" min="1" max="128" style="width: 100%; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div>
<label style="display: block; color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.25rem;">${t('memory_mb')}</label>
<input type="number" id="edit-memory" value="${memory}" min="128" step="128" style="width: 100%; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div style="display: flex; align-items: center;">
<label style="display: flex; align-items: center; gap: 0.5rem; color: var(--text-secondary); font-size: 0.875rem; cursor: pointer;">
<input type="checkbox" id="edit-onboot" ${onboot ? 'checked' : ''}>
${t('autostart_on_boot')}
</label>
</div>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
<button type="button" onclick="closeEditModal()" class="btn btn-secondary">${t('cancel')}</button>
<button type="submit" class="btn btn-success"><i class="fa-solid fa-floppy-disk"></i> ${t('save')}</button>
</div>
</form>
`;
// Диски
if (disks.length > 0) {
html += `
<div style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--border-default);">
<h4 style="color: var(--text-secondary); margin-bottom: 1rem;"><i class="fa-solid fa-floppy-disk"></i> ${t('disks')}</h4>
${disks.map(disk => `
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; background: var(--bg-surface-hover); border-radius: 6px; margin-bottom: 0.5rem;">
<div style="flex: 1;">
<strong style="color: var(--text-primary);">${disk.key}</strong>
<span style="color: var(--text-muted); font-size: 0.75rem; margin-left: 0.5rem;">${t('current_size')}: ${disk.currentSize}</span>
</div>
<input type="number" id="resize-${disk.key}" placeholder="+10" min="1" style="width: 80px; padding: 0.375rem; border-radius: 4px; border: 1px solid var(--border-default); background: var(--bg-input); color: var(--text-primary); text-align: center;">
<span style="color: var(--text-muted);">GB</span>
<button type="button" onclick="resizeDisk('${disk.key}')" class="btn btn-sm btn-warning">📐 ${t('increase')}</button>
</div>
`).join('')}
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.5rem;"><i class="fa-solid fa-triangle-exclamation"></i> ${t('disk_shrink_not_supported')}</p>
</div>
`;
}
content.innerHTML = html;
}
async function saveConfig(event) {
event.preventDefault();
const config = {
name: document.getElementById('edit-name').value,
cores: parseInt(document.getElementById('edit-cores').value),
memory: parseInt(document.getElementById('edit-memory').value),
onboot: document.getElementById('edit-onboot').checked ? 1 : 0
};
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/config?node=${instanceNode}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (response.ok) {
showNotification('<i class="fa-solid fa-circle-check"></i> ' + t('config_saved'), 'success');
closeEditModal();
loadConfig();
} else {
const error = await response.json();
showNotification(`<i class="fa-solid fa-circle-xmark"></i> ${t('error')}: ${error.detail}`, 'error');
}
} catch (error) {
showNotification('<i class="fa-solid fa-circle-xmark"></i> ' + t('connection_error'), 'error');
}
}
async function resizeDisk(diskKey) {
const input = document.getElementById(`resize-${diskKey}`);
const addSize = parseInt(input.value);
if (!addSize || addSize <= 0) {
showNotification('<i class="fa-solid fa-circle-xmark"></i> ' + t('enter_positive_number_gb'), 'error');
return;
}
const confirmed = await showConfirm(
t('resize_disk_confirm').replace('{disk}', diskKey).replace('{size}', addSize),
t('resize_disk') || 'Resize Disk',
{ type: 'warning', confirmText: t('resize') || 'Resize' }
);
if (!confirmed) return;
try {
const url = `/proxmox/api/${serverId}/${apiType}/${vmid}/disk/resize?node=${instanceNode}`;
const response = await fetchWithAuth(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disk: diskKey, size: `+${addSize}G` })
});
if (response.ok) {
showNotification('<i class="fa-solid fa-circle-check"></i> ' + t('disk_resized'), 'success');
loadConfig();
renderEditForm();
} else {
const error = await response.json();
showNotification(`<i class="fa-solid fa-circle-xmark"></i> ${t('error')}: ${error.detail}`, 'error');
}
} catch (error) {
console.error('Resize disk error:', error);
showNotification('<i class="fa-solid fa-circle-xmark"></i> ' + t('connection_error'), 'error');
}
}
function closeEditModal() {
document.getElementById('editModal').style.display = 'none';
}
// Reinstall Modal
async function showReinstallModal() {
document.getElementById('reinstallModal').style.display = 'flex';
const content = document.getElementById('reinstallContent');
content.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> Загрузка шаблонов...</div>';
try {
const [groupsRes, templatesRes] = await Promise.all([
fetchWithAuth('/templates/api/groups'),
fetchWithAuth(`/templates/api/templates?server_id=${serverId}&active_only=true`)
]);
const groups = groupsRes.ok ? await groupsRes.json() : [];
const templates = templatesRes.ok ? await templatesRes.json() : [];
if (templates.length === 0) {
content.innerHTML = `
<div style="text-align: center; padding: 2rem;">
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-cube"></i></div>
<p style="color: var(--text-secondary);">Нет доступных шаблонов для этого сервера</p>
<a href="/templates" class="btn" style="margin-top: 1rem;">Добавить шаблоны</a>
</div>
`;
return;
}
// Группируем
const grouped = {};
templates.forEach(tpl => {
const gid = tpl.group_id || 'other';
if (!grouped[gid]) grouped[gid] = [];
grouped[gid].push(tpl);
});
let options = `<option value="">${t('select_template')}</option>`;
groups.forEach(g => {
if (grouped[g.id]) {
options += `<optgroup label="${g.icon || ''} ${g.name}">`;
grouped[g.id].forEach(tpl => {
options += `<option value="${tpl.id}" data-vmid="${tpl.vmid}" data-node="${tpl.node}">${tpl.name}</option>`;
});
options += '</optgroup>';
}
});
content.innerHTML = `
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--warning-light); border-radius: 8px; border-left: 4px solid var(--warning);">
<p style="color: var(--warning); margin: 0;"><i class="fa-solid fa-triangle-exclamation"></i> ${t('reinstall_warning')}</p>
</div>
<form onsubmit="executeReinstall(event)">
<div style="margin-bottom: 1rem;">
<label style="display: block; color: var(--text-secondary); margin-bottom: 0.5rem;">${t('os_template')}:</label>
<select id="reinstall-template" required style="width: 100%; padding: 0.75rem; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
${options}
</select>
</div>
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bg-tertiary); border-radius: 8px;">
<h4 style="margin: 0 0 0.75rem 0; color: var(--text-primary); font-size: 0.9rem;"><i class="fa-solid fa-lock"></i> ${t('cloud_init_settings')}</h4>
<div style="margin-bottom: 0.75rem;">
<label style="display: block; color: var(--text-secondary); margin-bottom: 0.25rem; font-size: 0.875rem;">${t('username')}:</label>
<input type="text" id="reinstall-user" placeholder="root" style="width: 100%; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary);">
</div>
<div style="margin-bottom: 0.75rem;">
<label style="display: block; color: var(--text-secondary); margin-bottom: 0.25rem; font-size: 0.875rem;">${t('password')}:</label>
<input type="password" id="reinstall-password" placeholder="${t('leave_empty_for_ssh')}" style="width: 100%; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary);">
</div>
<div style="margin-bottom: 0;">
<label style="display: block; color: var(--text-secondary); margin-bottom: 0.25rem; font-size: 0.875rem;">${t('ssh_public_key')}:</label>
<textarea id="reinstall-sshkey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ..." rows="3" style="width: 100%; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); font-family: monospace; font-size: 0.75rem; resize: vertical;"></textarea>
<small style="color: var(--text-muted); font-size: 0.75rem;">${t('multiple_keys_hint')}</small>
</div>
</div>
<div style="margin-bottom: 1.5rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; color: var(--text-secondary); cursor: pointer;">
<input type="checkbox" id="reinstall-confirm" required>
${t('confirm_data_loss')}
</label>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
<button type="button" onclick="closeReinstallModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">${t('cancel')}</button>
<button type="submit" class="btn btn-warning"><i class="fa-solid fa-compact-disc"></i> ${t('do_reinstall')}</button>
</div>
</form>
`;
} catch (error) {
content.innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ${error.message}</div>`;
}
}
async function executeReinstall(event) {
event.preventDefault();
const select = document.getElementById('reinstall-template');
const templateId = select.value;
if (!templateId) {
showNotification('<i class="fa-solid fa-circle-xmark"></i> ' + t('select_template'), 'error');
return;
}
// Получаем cloud-init параметры
const cloudInitUser = document.getElementById('reinstall-user').value.trim();
const cloudInitPassword = document.getElementById('reinstall-password').value;
const sshKeys = document.getElementById('reinstall-sshkey').value.trim();
const confirmed = await showDeleteConfirm(
t('reinstall') || 'Reinstall',
t('reinstall_confirm').replace('{name}', instanceName)
);
if (!confirmed) return;
await doReinstallInstance(templateId, cloudInitUser, cloudInitPassword, sshKeys);
}
async function doReinstallInstance(templateId, cloudInitUser, cloudInitPassword, sshKeys) {
try {
// Загружаем сохраненную конфигурацию из базы данных
showNotification('<i class="fa-solid fa-spinner fa-spin"></i> ' + t('loading_saved_config'), 'warning');
const savedConfigRes = await fetchWithAuth(`/proxmox/api/${serverId}/vm/${vmid}/saved-config`);
let savedCores = 1, savedMemory = 512, diskSizeGB = 32, savedIpAddress = null, savedGateway = null;
if (savedConfigRes.ok) {
const savedData = await savedConfigRes.json();
if (savedData.found) {
savedCores = savedData.config.cores || 1;
savedMemory = savedData.config.memory || 512;
diskSizeGB = savedData.config.disk_size || 32;
savedIpAddress = savedData.config.ip_address;
savedGateway = savedData.config.gateway;
}
}
// Если в базе нет данных, берем текущую конфигурацию
if (!savedConfigRes.ok || savedCores === 1) {
const savedConfig = { ...instanceConfig };
savedCores = savedConfig.cores || 1;
savedMemory = savedConfig.memory || 512;
const savedDisk = savedConfig.bootdisk ? (savedConfig[savedConfig.bootdisk]?.size || '32G') : '32G';
diskSizeGB = parseInt(savedDisk.replace(/[^0-9]/g, '')) || 32;
}
showNotification('<i class="fa-solid fa-spinner fa-spin"></i> ' + t('stopping_instance'), 'warning');
// Остановить если запущен
if (instanceStatus === 'running') {
const stopRes = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/stop?node=${instanceNode}`, { method: 'POST' });
if (!stopRes.ok) {
const error = await stopRes.json();
throw new Error(`Ошибка остановки: ${error.detail || stopRes.statusText}`);
}
await new Promise(r => setTimeout(r, 5000));
}
showNotification('<i class="fa-solid fa-spinner fa-spin"></i> Удаление старого инстанса...', 'warning');
// Удалить (не force, чтобы сохранилось в базу)
const deleteRes = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}?node=${instanceNode}`, { method: 'DELETE' });
if (!deleteRes.ok) {
const error = await deleteRes.json();
throw new Error(`Ошибка удаления: ${error.detail || deleteRes.statusText}`);
}
await new Promise(r => setTimeout(r, 5000));
showNotification('<i class="fa-solid fa-spinner fa-spin"></i> Развертывание из шаблона...', 'warning');
// Формируем тело запроса с сохраненной конфигурацией и cloud-init параметрами
const deployData = {
template_id: parseInt(templateId),
name: instanceName,
vmid: vmid, // Используем тот же VMID для переустановки
cores: savedCores,
memory: savedMemory,
disk: diskSizeGB,
start_after_create: true
};
// Добавляем IP адрес если был сохранен
if (savedIpAddress) {
deployData.ip_address = savedIpAddress;
if (savedGateway) {
deployData.gateway = savedGateway;
}
}
// Добавляем cloud-init параметры (приоритет у введенных вручную)
if (cloudInitUser) {
deployData.cloud_init_user = cloudInitUser;
}
if (cloudInitPassword) {
deployData.cloud_init_password = cloudInitPassword;
}
if (sshKeys) {
deployData.ssh_keys = sshKeys;
}
// Развернуть новую с сохраненной конфигурацией
const response = await fetchWithAuth('/templates/api/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(deployData)
});
if (response.ok) {
const result = await response.json();
showNotification(`<i class="fa-solid fa-circle-check"></i> Переустановка завершена! VMID: ${result.vmid}`, 'success');
closeReinstallModal();
// Перезагружаем страницу через 2 секунды (VMID остался тот же)
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
const error = await response.json();
const errorMsg = typeof error.detail === 'string'
? error.detail
: JSON.stringify(error.detail || error);
throw new Error(errorMsg);
}
} catch (error) {
console.error('Reinstall error:', error);
showNotification(`<i class="fa-solid fa-circle-xmark"></i> Ошибка: ${error.message}`, 'error');
}
}
function closeReinstallModal() {
document.getElementById('reinstallModal').style.display = 'none';
}
// ==================== High Availability (HA) Functions ====================
let isCluster = false;
let inHA = false;
async function loadHAStatus() {
const haBtn = document.getElementById('btn-ha');
const haType = instanceType === 'qemu' ? 'vm' : 'ct';
// Check permission first
if (!window.hasPermission('vms.ha')) {
haBtn.style.display = 'none';
return;
}
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/ha/${haType}/${vmid}`);
if (!response.ok) {
// Скрываем кнопку при ошибке
haBtn.style.display = 'none';
return;
}
const data = await response.json();
isCluster = data.is_cluster;
if (!isCluster) {
// Автономный сервер - показываем disabled кнопку
haBtn.style.display = 'inline-flex';
haBtn.disabled = true;
haBtn.style.opacity = '0.5';
haBtn.style.cursor = 'not-allowed';
haBtn.title = '{{ t("ha_cluster_only") or "HA доступен только для кластеров" }}';
document.getElementById('btn-ha-text').textContent = 'HA (N/A)';
return;
}
// Кластер - активная кнопка
haBtn.style.display = 'inline-flex';
haBtn.disabled = false;
haBtn.style.opacity = '1';
haBtn.style.cursor = 'pointer';
inHA = data.in_ha;
updateHAButton(inHA);
} catch (error) {
console.error('Error loading HA status:', error);
haBtn.style.display = 'none';
}
}
function updateHAButton(inHA) {
const haBtn = document.getElementById('btn-ha');
const icon = document.getElementById('btn-ha-icon');
const text = document.getElementById('btn-ha-text');
if (inHA) {
haBtn.style.background = 'var(--success)';
icon.innerHTML = '<i class="fa-solid fa-shield-halved"></i>';
text.textContent = 'HA';
haBtn.title = '{{ t("remove_from_ha") or "Удалить из HA" }}';
} else {
haBtn.style.background = 'var(--purple)';
icon.innerHTML = '<i class="fa-solid fa-shield"></i>';
text.textContent = 'HA (N/A)';
haBtn.title = '{{ t("add_to_ha") or "Добавить в HA" }}';
}
}
async function toggleHA() {
if (!isCluster) {
showNotification('<i class="fa-solid fa-triangle-exclamation"></i> {{ t("ha_cluster_only") or "HA доступен только для кластеров" }}', 'warning');
return;
}
const haType = instanceType === 'qemu' ? 'vm' : 'ct';
const typeName = instanceType === 'qemu' ? 'VM' : 'LXC';
if (inHA) {
// Удаляем из HA
const confirmed = await showConfirm(
'{{ t("remove_from_ha") or "Удалить из HA" }}',
`{{ t("confirm_remove_from_ha") or "Вы уверены, что хотите удалить" }} ${typeName} ${vmid} {{ t("from_ha") or "из HA" }}?`,
{ type: 'warning', confirmText: '{{ t("remove") or "Удалить" }}' }
);
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/ha/${haType}/${vmid}/remove`, {
method: 'DELETE'
});
if (response.ok) {
inHA = false;
updateHAButton(false);
showNotification(`<i class="fa-solid fa-circle-check"></i> ${typeName} ${vmid} {{ t("removed_from_ha") or "удалён из HA" }}`, 'success');
} else {
const error = await response.json();
showNotification(`<i class="fa-solid fa-circle-xmark"></i> ${error.detail || '{{ t("error") }}'}`, 'error');
}
} catch (error) {
console.error('Error removing from HA:', error);
showNotification('<i class="fa-solid fa-circle-xmark"></i> {{ t("error") }}', 'error');
}
} else {
// Добавляем в HA
const confirmed = await showConfirm(
'{{ t("add_to_ha") or "Добавить в HA" }}',
`{{ t("confirm_add_to_ha_message") or "Добавить" }} ${typeName} ${vmid} {{ t("to_ha_question") or "в High Availability" }}?`,
{ type: 'info', confirmText: '{{ t("add") or "Добавить" }}' }
);
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/ha/${haType}/${vmid}/add?state=started`, {
method: 'POST'
});
if (response.ok) {
inHA = true;
updateHAButton(true);
showNotification(`<i class="fa-solid fa-circle-check"></i> ${typeName} ${vmid} {{ t("added_to_ha") or "добавлен в HA" }}`, 'success');
} else {
const error = await response.json();
showNotification(`<i class="fa-solid fa-circle-xmark"></i> ${error.detail || '{{ t("error") }}'}`, 'error');
}
} catch (error) {
console.error('Error adding to HA:', error);
showNotification('<i class="fa-solid fa-circle-xmark"></i> {{ t("error") }}', 'error');
}
}
}
// Инициализация
window.addEventListener('DOMContentLoaded', () => {
// Wait for permissions to be loaded from base.html
if (window.userPermissions && Object.keys(window.userPermissions).length > 0) {
// Permissions already loaded
initPage();
} else {
// Wait for permissions loaded event
window.addEventListener('permissionsLoaded', () => {
initPage();
}, { once: true });
// Fallback timeout in case permissions event doesn't fire
setTimeout(() => {
if (!window.pageInitialized) {
initPage();
}
}, 1000);
}
});
function initPage() {
if (window.pageInitialized) return;
window.pageInitialized = true;
// Apply permissions to UI elements
if (typeof applyPermissions === 'function') {
applyPermissions();
}
loadInstanceData();
loadHAStatus();
// Автообновление каждые 2 секунды
setInterval(() => {
if (document.getElementById('instance-content').style.display !== 'none') {
loadStatus();
}
}, 2000);
// Check if we should auto-open VNC console
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('autoVnc') === '1') {
// Wait for instance data to load, then open VNC
setTimeout(() => {
if (typeof openVNC === 'function') {
openVNC();
}
}, 500);
}
}
</script>
{% endblock %}