ui: replace native browser dialogs with custom panel modals

- settings.html: confirm() → showConfirm(), prompt() → showPrompt() for update, SMTP test, Telegram test
- backups.html: confirm() → showDeleteConfirm() for backup/storage/job deletion
This commit is contained in:
markmorado
2026-03-08 20:42:08 +01:00
parent d788ac78c3
commit a0a502f82a
3 changed files with 303 additions and 6 deletions

View File

@@ -690,7 +690,8 @@ function formatSize(bytes) {
}
async function deleteBackup(volid, node, storage) {
if (!confirm('{{ t("backup_delete_confirm") }}')) return;
const confirmed = await showDeleteConfirm('{{ t("backup_delete_confirm") }}');
if (!confirmed) return;
try {
const r = await (await fetchWithAuth(`/proxmox/api/backups/${currentServerId}/backup`, {
method: 'DELETE',
@@ -825,7 +826,8 @@ async function loadStorages() {
}
async function deleteStorage(storageId) {
if (!confirm('{{ t("backup_storage_delete_confirm") }}')) return;
const confirmed = await showDeleteConfirm('{{ t("backup_storage_delete_confirm") }}');
if (!confirmed) return;
try {
const r = await (await fetchWithAuth(`/proxmox/api/backups/storages/${currentServerId}/${storageId}`, { method: 'DELETE' })).json();
if (r.success) { showToast('{{ t("success") }}', '{{ t("backup_storage_deleted") }}', 'success'); loadStorages(); }
@@ -1147,7 +1149,8 @@ async function runJobNow(jobId) {
}
async function deleteJob(jobId) {
if (!confirm('{{ t("backup_job_delete_confirm") }}')) return;
const confirmed = await showDeleteConfirm('{{ t("backup_job_delete_confirm") }}');
if (!confirmed) return;
try {
const r = await (await fetchWithAuth(`/proxmox/api/backups/jobs/${jobId}`, { method: 'DELETE' })).json();
if (r.success) { showToast('{{ t("success") }}', '{{ t("deleted") }}', 'success'); loadJobs(); }

View File

@@ -0,0 +1,281 @@
{% extends "base.html" %}
{% set page_title = t('nav_dashboard') %}
{% block content %}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">{{ t('proxmox_servers') }}</div>
<div class="stat-value">{{ stats.total_servers }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('online') }}</div>
<div class="stat-value text-success">{{ stats.online_servers }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('offline') }}</div>
<div class="stat-value text-danger">{{ stats.offline_servers }}</div>
</div>
</div>
<!-- Графики Proxmox нод -->
<div class="card" id="proxmox-charts-section" style="display: none;">
<h3><i class="fa-solid fa-chart-line"></i> {{ t('proxmox_nodes_load') }}</h3>
<div class="dashboard-charts-grid" id="nodes-charts-grid"></div>
</div>
<style>
.dashboard-charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
@media (max-width: 768px) {
.dashboard-charts-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr !important;
}
.stat-card {
padding: 1rem;
}
.stat-value {
font-size: 1.75rem;
}
}
@media (max-width: 480px) {
.dashboard-charts-grid .card h4 {
font-size: 0.9rem;
}
}
</style>
<script>
// Загрузка статистики и графиков Proxmox нод
async function loadProxmoxNodesStats() {
try {
const response = await fetchWithAuth('/proxmox/api/servers');
if (!response) return;
const servers = await response.json();
const onlineServers = servers.filter(s => s.is_online);
if (onlineServers.length === 0) return;
const chartsSection = document.getElementById('proxmox-charts-section');
const chartsGrid = document.getElementById('nodes-charts-grid');
chartsGrid.innerHTML = '';
// Собрать все данные сначала, потом отрисовать
const chartsToRender = [];
// Дедупликация по ключу "cluster_id::node".
// Члены одного кластера возвращают одинаковый cluster_id (имя кластера),
// поэтому общие ноды показываются один раз.
// Независимые серверы с одинаковым именем ноды получают разные cluster_id
// ("standalone-{server_id}"), поэтому отображаются отдельно.
const seenNodes = new Set();
// Загрузить данные для каждого сервера
for (const server of onlineServers) {
try {
// Получить список нод
const nodesResponse = await fetchWithAuth(`/proxmox/api/${server.id}/nodes`);
if (!nodesResponse || !nodesResponse.ok) {
console.warn(`Failed to load nodes for ${server.name}`);
continue;
}
const nodesData = await nodesResponse.json();
const nodes = nodesData.nodes || [];
const clusterId = nodesData.cluster_id || `standalone-${server.id}`;
if (nodes.length === 0) {
console.warn(`No nodes found for ${server.name}`);
continue;
}
// Обработать ВСЕ ноды этого сервера
for (const nodeInfo of nodes) {
const node = nodeInfo.node;
// Дедупликация по кластеру: члены одного кластера имеют
// одинаковый clusterId и одинаковые имена нод → пропускаем дубли.
// Независимые серверы с одинаковым именем ноды имеют разные
// clusterId → отображаются отдельно.
const dedupeKey = `${clusterId}::${node}`;
if (seenNodes.has(dedupeKey)) {
continue;
}
seenNodes.add(dedupeKey);
// Получить данные для графиков
const rrdResponse = await fetchWithAuth(`/proxmox/api/${server.id}/node/rrddata?node=${node}&timeframe=hour`);
if (!rrdResponse || !rrdResponse.ok) {
console.warn(`Failed to load RRD data for ${server.name}/${node}, skipping chart`);
continue;
}
const rrdData = await rrdResponse.json();
const data = rrdData.data || [];
if (data.length === 0) {
console.warn(`No RRD data for ${server.name}/${node}`);
continue;
}
// Сохранить данные для отрисовки
chartsToRender.push({
canvasId: `chart-${server.id}-${node}`,
serverName: server.name,
nodeName: node,
data: data
});
}
} catch (error) {
console.error(`Error loading stats for server ${server.id}:`, error);
}
}
// Теперь создать все canvas элементы
if (chartsToRender.length > 0) {
let html = '';
for (const chart of chartsToRender) {
// Извлекаем базовое имя сервера (убираем " - nodeX" если есть)
let baseServerName = chart.serverName.trim();
// Если имя сервера заканчивается на " - <nodeName>", убираем это
const nodePattern = new RegExp(`\\s*-\\s*(pve\\d+|node\\d+|${chart.nodeName})$`, 'i');
baseServerName = baseServerName.replace(nodePattern, '').trim();
// Формируем отображаемое имя
// Если базовое имя пустое или равно имени ноды — просто показываем имя ноды
let displayName;
if (!baseServerName || baseServerName.toLowerCase() === chart.nodeName.toLowerCase()) {
displayName = chart.nodeName;
} else {
displayName = `${baseServerName} - ${chart.nodeName}`;
}
html += `
<div class="card">
<h4 style="margin: 0 0 1rem 0;">${displayName}</h4>
<canvas id="${chart.canvasId}" style="max-height: 250px;"></canvas>
</div>
`;
}
chartsGrid.innerHTML = html;
chartsSection.style.display = 'block';
// Отрисовать все графики после того как DOM обновился
requestAnimationFrame(() => {
for (const chart of chartsToRender) {
drawNodeChart(chart.canvasId, chart.data);
}
});
}
} catch (error) {
console.error('Error loading Proxmox stats:', error);
}
}
// Отрисовка графика ноды
function drawNodeChart(canvasId, data) {
if (typeof Chart === 'undefined') {
console.error('Chart.js not loaded');
return;
}
const ctx = document.getElementById(canvasId);
if (!ctx) {
console.error('Canvas not found:', canvasId);
return;
}
const locale = "{{ 'ru-RU' if lang == 'ru' else 'en-US' }}";
const formatTime = (timestamp) => {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
};
new Chart(ctx, {
type: 'line',
data: {
labels: data.map(d => formatTime(d.time)),
datasets: [
{
label: '{{ t('cpu') }} %',
data: data.map(d => ((d.cpu || 0) * 100).toFixed(2)),
borderColor: '#60a5fa',
backgroundColor: 'rgba(96,165,250,0.1)',
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 0,
yAxisID: 'y'
},
{
label: '{{ t('memory') }} {{ t('gb') }}',
data: data.map(d => ((d.memused || 0) / (1024*1024*1024)).toFixed(2)),
borderColor: '#f59e0b',
backgroundColor: 'rgba(245,158,11,0.1)',
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 0,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: true,
position: 'top',
labels: { color: '#9ca3af', usePointStyle: true, padding: 10 }
},
tooltip: { enabled: true, mode: 'index', intersect: false }
},
scales: {
x: {
type: 'category',
grid: { color: 'rgba(255,255,255,0.1)' },
ticks: { color: '#9ca3af', maxTicksLimit: 10 }
},
y: {
type: 'linear',
display: true,
position: 'left',
beginAtZero: true,
grid: { color: 'rgba(255,255,255,0.1)' },
ticks: { color: '#9ca3af' },
title: { display: true, text: '{{ t('cpu') }} %', color: '#60a5fa' }
},
y1: {
type: 'linear',
display: true,
position: 'right',
beginAtZero: true,
grid: { drawOnChartArea: false },
ticks: { color: '#9ca3af' },
title: { display: true, text: '{{ t('memory') }} {{ t('gb') }}', color: '#f59e0b' }
}
}
}
});
}
// Инициализация при полной загрузке DOM
document.addEventListener('DOMContentLoaded', function() {
// Загрузить графики нод
loadProxmoxNodesStats();
});
</script>
{% endblock %}

View File

@@ -1773,7 +1773,11 @@ document.addEventListener('DOMContentLoaded', function() {
// Test SMTP settings
document.getElementById('test-smtp-settings-btn').addEventListener('click', async function() {
const testEmail = prompt(t('enter_test_email') || 'Введите email для тестового письма:', currentUser?.email || '');
const testEmail = await showPrompt(
t('enter_test_email') || 'Введите email для тестового письма',
null,
{ inputType: 'email', defaultValue: currentUser?.email || '', placeholder: 'test@example.com', confirmText: t('send') || 'Отправить' }
);
if (!testEmail) return;
const btn = this;
@@ -1803,7 +1807,11 @@ document.addEventListener('DOMContentLoaded', function() {
// Test Telegram settings
document.getElementById('test-telegram-settings-btn').addEventListener('click', async function() {
const chatId = prompt(t('enter_chat_id_for_test') || 'Введите Chat ID для тестового сообщения:');
const chatId = await showPrompt(
t('enter_chat_id_for_test') || 'Введите Chat ID для тестового сообщения',
null,
{ placeholder: '123456789', confirmText: t('send') || 'Отправить' }
);
if (!chatId) return;
const btn = this;
@@ -2023,7 +2031,12 @@ async function checkForUpdates() {
async function performUpdate() {
// Confirm update
if (!confirm((t('update_warning') || 'Панель будет недоступна во время обновления') + '\n\nПродолжить?')) {
const confirmed = await showConfirm(
t('update_warning') || 'Панель будет недоступна во время обновления. Продолжить?',
t('update_now') || 'Обновить сейчас',
{ type: 'warning', icon: '<i class="fas fa-download"></i>', confirmText: t('yes') || 'Да', confirmClass: 'warning' }
);
if (!confirmed) {
return;
}