- 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
2155 lines
100 KiB
HTML
2155 lines
100 KiB
HTML
{% 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 && systemctl status || service --status-all 2>/dev/null || rc-status 2>/dev/null || echo "Service management not available"')" 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 echo 'Hello from VM' # Ваш скрипт здесь..."
|
||
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;">×</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;">×</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;">×</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;">×</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 %}
|