Add complete project structure and all files

This commit is contained in:
2025-12-15 13:03:29 +05:00
parent dc4c2521c7
commit f5a3903f5d
139 changed files with 72714 additions and 2 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% set page_title = t('docker_management') %}
{% block content %}
<div class="card">
<h3><i class="fa-brands fa-docker"></i> {{ t('docker_containers') }}</h3>
<p class="text-muted" style="margin-top: 1rem;">
{{ t('section_in_development') }}
</p>
<div style="margin-top: 1.5rem;">
<a href="/servers" class="btn btn-primary">← {{ t('to_servers') }}</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,270 @@
{% 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 = [];
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 || [];
if (nodes.length === 0) {
console.warn(`No nodes found for ${server.name}`);
continue;
}
// Для кластеров берём только ПЕРВУЮ ноду, т.к. все серверы кластера имеют одинаковые ноды
// Используем IP сервера + имя ноды для дедупликации
const node = nodes[0].node;
const nodeKey = `${server.ip_address}-${node}`;
if (seenNodes.has(nodeKey)) {
continue; // Уже показали эту ноду
}
seenNodes.add(nodeKey);
// Получить данные для графиков
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 %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,351 @@
{% extends "base.html" %}
{% block title %}IPAM - {{ t('ip_allocations') }}{% endblock %}
{% block content %}
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 600; margin: 0;"><i class="fa-solid fa-thumbtack"></i> {{ t('ip_allocations') }}</h1>
<p style="color: var(--text-secondary); margin-top: 0.25rem;">{{ t('all_allocated_ips') }}</p>
</div>
<div style="display: flex; gap: 0.75rem;">
<a href="/ipam" class="btn btn-secondary">← {{ t('back') }}</a>
<button onclick="showAllocateModal()" class="btn btn-success" data-permission="ipam.manage"><i class="fa-solid fa-plus"></i> {{ t('allocate_ip') }}</button>
</div>
</div>
<!-- Filters -->
<div class="card" style="margin-bottom: 1rem;">
<div style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;">
<div>
<label style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('network_label') }}:</label>
<select id="filterNetwork" onchange="loadAllocations()" class="form-select" style="padding: 0.5rem; min-width: 150px;">
<option value="">{{ t('all_networks') }}</option>
</select>
</div>
<div>
<label style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('status') }}:</label>
<select id="filterStatus" onchange="loadAllocations()" class="form-select" style="padding: 0.5rem; min-width: 150px;">
<option value="">{{ t('all_status') }}</option>
<option value="allocated">{{ t('allocated') }}</option>
<option value="reserved">{{ t('reserved') }}</option>
</select>
</div>
<div>
<label style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('search') }}:</label>
<input type="text" id="filterSearch" placeholder="{{ t('search_placeholder') }}" oninput="filterTable()" style="padding: 0.5rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<button onclick="loadAllocations()" class="btn btn-sm" style="background: var(--bg-tertiary); color: var(--text-primary); margin-left: auto;"><i class="fa-solid fa-rotate"></i> {{ t('refresh') }}</button>
</div>
</div>
<!-- Allocations List -->
<div class="card">
<div id="allocationsContainer">
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}
</div>
</div>
</div>
<!-- Allocate IP Modal -->
<div id="allocateModal" 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: 500px; width: 95%; max-height: 90vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3 style="margin: 0;"><i class="fa-solid fa-plus"></i> {{ t('allocate_ip') }}</h3>
<button onclick="closeAllocateModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<form id="allocateForm" onsubmit="allocateIP(event)">
<div style="display: grid; gap: 1rem;">
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('network_label') }} {{ t('required') }}</label>
<select id="allocNetwork" required style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
<option value="">{{ t('select_network') }}</option>
</select>
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('ip_address_auto') }}</label>
<input type="text" id="allocIP" placeholder="{{ t('auto_allocation') }}" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('hostname') }}</label>
<input type="text" id="allocHostname" placeholder="server-01" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('description') }}</label>
<input type="text" id="allocDescription" placeholder="Web server" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; color: var(--text-secondary);">
<input type="checkbox" id="allocReserved" style="width: auto;">
{{ t('reserve_checkbox') }}
</label>
</div>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-default);">
<button type="button" onclick="closeAllocateModal()" class="btn btn-secondary">{{ t('cancel') }}</button>
<button type="submit" class="btn btn-success"><i class="fa-solid fa-floppy-disk"></i> {{ t('allocate') }}</button>
</div>
</form>
</div>
</div>
<script>
let allAllocations = [];
// URL params helper functions
function getUrlParams() {
return new URLSearchParams(window.location.search);
}
function updateUrlParams(params) {
const url = new URL(window.location);
Object.entries(params).forEach(([key, value]) => {
if (value) {
url.searchParams.set(key, value);
} else {
url.searchParams.delete(key);
}
});
window.history.replaceState({}, '', url);
}
function restoreFiltersFromUrl() {
const params = getUrlParams();
const network = params.get('network') || '';
const networkSelect = document.getElementById('filterNetwork');
if (networkSelect && network) {
// Will be set after networks are loaded
networkSelect.dataset.pendingValue = network;
}
const status = params.get('status') || '';
document.getElementById('filterStatus').value = status;
const search = params.get('search') || '';
document.getElementById('filterSearch').value = search;
}
async function loadNetworks() {
try {
const response = await fetchWithAuth('/ipam/api/networks');
if (response.ok) {
const networks = await response.json();
const filterSelect = document.getElementById('filterNetwork');
const allocSelect = document.getElementById('allocNetwork');
filterSelect.innerHTML = `<option value="">${t('all_networks')}</option>` +
networks.map(n => `<option value="${n.id}">${n.name} (${n.network})</option>`).join('');
// Restore pending value from URL
if (filterSelect.dataset.pendingValue) {
filterSelect.value = filterSelect.dataset.pendingValue;
delete filterSelect.dataset.pendingValue;
}
allocSelect.innerHTML = `<option value="">${t('select_network')}</option>` +
networks.map(n => `<option value="${n.id}">${n.name} (${n.network})</option>`).join('');
}
} catch (error) {
console.error('Error loading networks:', error);
}
}
async function loadAllocations() {
try {
const networkId = document.getElementById('filterNetwork').value;
const status = document.getElementById('filterStatus').value;
// Save filters to URL
updateUrlParams({ network: networkId, status: status });
let url = '/ipam/api/allocations?';
if (networkId) url += `network_id=${networkId}&`;
if (status) url += `status=${status}&`;
const response = await fetchWithAuth(url);
const container = document.getElementById('allocationsContainer');
if (response.ok) {
allAllocations = await response.json();
// Apply search filter if exists
filterTable(false);
} else {
container.innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ${t('error_loading')}</div>`;
}
} catch (error) {
console.error('Error loading allocations:', error);
document.getElementById('allocationsContainer').innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ${t('error_loading')}</div>`;
}
}
function filterTable(updateUrl = true) {
const search = document.getElementById('filterSearch').value.toLowerCase();
if (updateUrl) {
updateUrlParams({ search: search });
}
const filtered = allAllocations.filter(a =>
a.ip_address.toLowerCase().includes(search) ||
(a.hostname || '').toLowerCase().includes(search) ||
(a.description || '').toLowerCase().includes(search) ||
(a.vmid || '').toString().includes(search)
);
renderAllocations(filtered);
}
function renderAllocations(allocations) {
const container = document.getElementById('allocationsContainer');
if (allocations.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 3rem;">
<div style="font-size: 3rem; margin-bottom: 1rem;"><i class="fa-solid fa-thumbtack"></i></div>
<h3 style="color: var(--text-primary); margin-bottom: 0.5rem;">${t('allocations_not_found')}</h3>
<p style="color: var(--text-secondary);">${t('no_allocated_ips')}</p>
</div>
`;
return;
}
container.innerHTML = `
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 1px solid var(--border-color);">
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('ip_address')}</th>
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('network_label')}</th>
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('hostname')}</th>
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('resource')}</th>
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('status')}</th>
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('created')}</th>
<th style="text-align: right; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('actions')}</th>
</tr>
</thead>
<tbody>
${allocations.map(a => `
<tr style="border-bottom: 1px solid var(--border-default);" onmouseover="this.style.background='var(--bg-surface-hover)'" onmouseout="this.style.background='transparent'">
<td style="padding: 0.75rem; font-family: monospace; font-weight: 500; color: var(--text-primary);">${a.ip_address}</td>
<td style="padding: 0.75rem; color: var(--text-secondary);">${a.network_name || '-'}</td>
<td style="padding: 0.75rem; color: var(--text-primary);">${a.hostname || '-'}</td>
<td style="padding: 0.75rem; color: var(--text-primary);">
${a.resource_type ? `<span style="background: var(--bg-surface-hover); padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; color: var(--text-primary);">${a.resource_type}</span>` : ''}
${a.vmid ? `<span style="font-family: monospace; margin-left: 0.25rem; color: var(--text-secondary);">#${a.vmid}</span>` : ''}
</td>
<td style="padding: 0.75rem;">
${a.status === 'reserved' ?
`<span class="badge badge-info" style="color: #fff;"><i class="fa-solid fa-lock"></i> ${t('reserved')}</span>` :
`<span class="badge badge-success" style="color: #fff;">✓ ${t('allocated')}</span>`}
</td>
<td style="padding: 0.75rem; color: var(--text-muted); font-size: 0.875rem;">${a.created_at ? new Date(a.created_at).toLocaleDateString('ru-RU') : '-'}</td>
<td style="padding: 0.75rem; text-align: right;">
<button onclick="releaseIP(${a.id}, '${a.ip_address}')" class="btn btn-sm btn-danger"><i class="fa-solid fa-trash"></i> ${t('release')}</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
function showAllocateModal() {
document.getElementById('allocateModal').style.display = 'flex';
}
function closeAllocateModal() {
document.getElementById('allocateModal').style.display = 'none';
document.getElementById('allocateForm').reset();
}
async function allocateIP(event) {
event.preventDefault();
const networkId = document.getElementById('allocNetwork').value;
const ip = document.getElementById('allocIP').value.trim();
const hostname = document.getElementById('allocHostname').value.trim();
const description = document.getElementById('allocDescription').value.trim();
const isReserved = document.getElementById('allocReserved').checked;
try {
let response;
if (ip) {
// Manual allocation
response = await fetchWithAuth('/ipam/api/allocations', {
method: 'POST',
body: JSON.stringify({
network_id: parseInt(networkId),
ip_address: ip,
hostname: hostname || null,
description: description || null,
status: isReserved ? 'reserved' : 'allocated'
})
});
} else {
// Auto allocation
response = await fetchWithAuth('/ipam/api/allocations/auto', {
method: 'POST',
body: JSON.stringify({
network_id: parseInt(networkId),
hostname: hostname || null,
description: description || null
})
});
}
if (response.ok) {
closeAllocateModal();
loadAllocations();
showToast('success', t('success'), t('ip_allocated_successfully') || 'IP allocated successfully');
} else {
const error = await response.json();
showToast('error', t('error'), error.detail || t('error_allocating_ip'));
}
} catch (error) {
console.error('Error allocating IP:', error);
showToast('error', t('error'), t('error_allocating_ip'));
}
}
async function releaseIP(id, ip) {
const confirmed = await showConfirm(
t('release_ip'),
`${t('release_ip_confirm')} ${ip}?`,
{ type: 'warning', confirmText: t('release') }
);
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/ipam/api/allocations/${id}`, {
method: 'DELETE'
});
if (response.ok) {
loadAllocations();
showToast('success', t('success'), t('ip_released_successfully') || 'IP released successfully');
} else {
const error = await response.json();
showToast('error', t('error'), error.detail || t('error_releasing_ip'));
}
} catch (error) {
console.error('Error releasing IP:', error);
showToast('error', t('error'), t('error_releasing_ip'));
}
}
// Initial load after DOM ready
window.addEventListener('DOMContentLoaded', () => {
restoreFiltersFromUrl();
setTimeout(() => {
loadNetworks();
loadAllocations();
}, 100);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,574 @@
{% extends "base.html" %}
{% block title %}IPAM - {{ t('ip_address_management') }}{% endblock %}
{% block content %}
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 600; margin: 0;"><i class="fa-solid fa-globe"></i> IPAM</h1>
<p style="color: var(--text-secondary); margin-top: 0.25rem;">{{ t('ip_address_management') }}</p>
</div>
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
<a href="/ipam/networks" class="btn btn-secondary"><i class="fa-solid fa-list"></i> {{ t('networks') }}</a>
<a href="/ipam/allocations" class="btn btn-secondary"><i class="fa-solid fa-thumbtack"></i> {{ t('allocations') }}</a>
<button onclick="showMaintenanceModal()" class="btn btn-warning" data-permission="ipam.manage" title="Обслуживание IPAM"><i class="fa-solid fa-wrench"></i> {{ t('maintenance') }}</button>
<button onclick="showAddNetworkModal()" class="btn btn-success" data-permission="ipam.manage"><i class="fa-solid fa-plus"></i> {{ t('add_network') }}</button>
</div>
</div>
<!-- Summary Cards -->
<div class="stats-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
<div class="card stat-card">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div>
<div style="font-size: 2rem; font-weight: 600; color: var(--text-primary);" id="networksCount">-</div>
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('networks') }}</div>
</div>
<div style="font-size: 2rem; opacity: 0.5;"><i class="fa-solid fa-globe"></i></div>
</div>
</div>
<div class="card stat-card">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div>
<div style="font-size: 2rem; font-weight: 600; color: var(--text-primary);" id="allocatedCount">-</div>
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('allocated_ips') }}</div>
</div>
<div style="font-size: 2rem; opacity: 0.5;"><i class="fa-solid fa-thumbtack"></i></div>
</div>
</div>
<div class="card stat-card">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div>
<div style="font-size: 2rem; font-weight: 600; color: var(--text-primary);" id="vmCount">-</div>
<div style="color: var(--text-secondary); font-size: 0.875rem;">VM</div>
</div>
<div style="font-size: 2rem; opacity: 0.5;"><i class="fa-solid fa-desktop"></i></div>
</div>
</div>
<div class="card stat-card">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div>
<div style="font-size: 2rem; font-weight: 600; color: var(--text-primary);" id="reservedCount">-</div>
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('reserved') }}</div>
</div>
<div style="font-size: 2rem; opacity: 0.5;"><i class="fa-solid fa-lock"></i></div>
</div>
</div>
</div>
<!-- Networks List -->
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0;"><i class="fa-solid fa-list"></i> {{ t('networks') }}</h3>
<button onclick="loadData()" class="btn btn-sm" style="background: var(--bg-tertiary); color: var(--text-primary);"><i class="fa-solid fa-rotate"></i> {{ t('refresh') }}</button>
</div>
<div id="networksContainer">
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading_ellipsis') }}
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="card">
<h3 style="margin: 0 0 1rem 0;"><i class="fa-solid fa-file-lines"></i> {{ t('recent_activity') }}</h3>
<div id="recentActivity">
<div style="text-align: center; padding: 1rem; color: var(--text-muted);">
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading_ellipsis') }}
</div>
</div>
</div>
<!-- Add Network Modal -->
<div id="addNetworkModal" 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: 90vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3 style="margin: 0;"><i class="fa-solid fa-plus"></i> {{ t('add_network') }}</h3>
<button onclick="closeAddNetworkModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<form id="addNetworkForm" onsubmit="createNetwork(event)">
<div style="display: grid; gap: 1rem;">
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('title') }} *</label>
<input type="text" id="networkName" required placeholder="Production Network" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('network_cidr') }} *</label>
<input type="text" id="networkCIDR" required placeholder="10.10.10.0/24" pattern="^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('gateway') }}</label>
<input type="text" id="networkGateway" placeholder="10.10.10.1" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('vlan_id') }}</label>
<input type="number" id="networkVLAN" min="1" max="4094" placeholder="100" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('proxmox_bridge') }}</label>
<input type="text" id="networkBridge" placeholder="vmbr0" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('proxmox_server') }}</label>
<select id="networkServer" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
<option value="">{{ t('all_servers') }} ({{ t('global_network') }})</option>
</select>
<small style="color: var(--text-muted);">{{ t('network_server_hint') }}</small>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('dns_primary') }}</label>
<input type="text" id="networkDNS1" placeholder="8.8.8.8" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('dns_secondary') }}</label>
<input type="text" id="networkDNS2" placeholder="8.8.4.4" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('dns_domain') }}</label>
<input type="text" id="networkDomain" placeholder="example.local" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('description') }}</label>
<textarea id="networkDescription" rows="2" placeholder="{{ t('network_description_placeholder') }}" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary); resize: vertical;"></textarea>
</div>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-default);">
<button type="button" onclick="closeAddNetworkModal()" class="btn btn-secondary">{{ t('cancel') }}</button>
<button type="submit" class="btn btn-success"><i class="fa-solid fa-floppy-disk"></i> {{ t('create') }}</button>
</div>
</form>
</div>
</div>
<script>
// Use global fetchWithAuth from base.html
async function loadData() {
await Promise.all([
loadSummary(),
loadNetworks()
]);
}
async function loadSummary() {
try {
const response = await fetchWithAuth('/ipam/api/summary');
if (response.ok) {
const data = await response.json();
document.getElementById('networksCount').textContent = data.networks_count || 0;
document.getElementById('allocatedCount').textContent = data.total_allocated || 0;
document.getElementById('vmCount').textContent = (data.vm_count || 0) + (data.lxc_count || 0);
document.getElementById('reservedCount').textContent = data.total_reserved || 0;
// Recent activity
const activityDiv = document.getElementById('recentActivity');
if (data.recent_allocations && data.recent_allocations.length > 0) {
activityDiv.innerHTML = data.recent_allocations.map(item => `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 0; border-bottom: 1px solid var(--border-color);">
<div>
<span style="font-family: monospace; background: var(--bg-tertiary); padding: 0.25rem 0.5rem; border-radius: 4px; color: var(--text-primary);">${item.ip}</span>
<span style="margin-left: 0.5rem; color: var(--text-secondary);">${item.resource || 'N/A'}</span>
</div>
<div style="color: var(--text-muted); font-size: 0.875rem;">
${item.by || 'system'} • ${item.at ? new Date(item.at).toLocaleString('ru-RU') : ''}
</div>
</div>
`).join('');
} else {
activityDiv.innerHTML = '<div style="text-align: center; padding: 1rem; color: var(--text-muted);">' + t('no_recent_activity') + '</div>';
}
}
} catch (error) {
console.error('Error loading summary:', error);
}
}
async function loadNetworks() {
try {
const response = await fetchWithAuth('/ipam/api/networks');
const container = document.getElementById('networksContainer');
if (response.ok) {
const networks = await response.json();
if (networks.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 3rem;">
<div style="font-size: 3rem; margin-bottom: 1rem;"><i class="fa-solid fa-globe"></i></div>
<h3 style="color: var(--text-primary); margin-bottom: 0.5rem;">${t('networks_not_found')}</h3>
<p style="color: var(--text-secondary); margin-bottom: 1.5rem;">${t('add_first_network')}</p>
<button onclick="showAddNetworkModal()" class="btn btn-success"><i class="fa-solid fa-plus"></i> ${t('add_network')}</button>
</div>
`;
return;
}
container.innerHTML = `
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 1px solid var(--border-default);">
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('network_label')}</th>
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('cidr')}</th>
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('server')}</th>
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('vlan')}</th>
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('usage')}</th>
<th style="text-align: right; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('actions')}</th>
</tr>
</thead>
<tbody>
${networks.map(net => {
const serverName = net.server_name || (net.proxmox_server_id ? `ID: ${net.proxmox_server_id}` : t('global'));
return `
<tr style="border-bottom: 1px solid var(--border-default);" onmouseover="this.style.background='var(--bg-surface-hover)'" onmouseout="this.style.background='transparent'">
<td style="padding: 0.75rem;">
<a href="/ipam/network/${net.id}" style="color: var(--primary); text-decoration: none; font-weight: 500;">
${net.name}
</a>
${net.gateway ? `<div style="font-size: 0.75rem; color: var(--text-muted);">GW: ${net.gateway}</div>` : ''}
</td>
<td style="padding: 0.75rem; font-family: monospace; color: var(--text-primary);">${net.network}</td>
<td style="padding: 0.75rem;">
${net.proxmox_server_id ?
`<span class="badge" style="background: var(--primary-bg); color: var(--primary);"><i class="fa-solid fa-server"></i> ${serverName}</span>` :
`<span style="color: var(--text-muted);">${t('global')}</span>`
}
</td>
<td style="padding: 0.75rem; color: var(--text-primary);">${net.vlan_id || '-'}</td>
<td style="padding: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<div style="flex: 1; height: 8px; background: var(--bg-tertiary); border-radius: 4px; overflow: hidden; max-width: 100px;">
<div style="height: 100%; background: ${getUtilizationColor(net.utilization_percent)}; width: ${net.utilization_percent || 0}%;"></div>
</div>
<span style="font-size: 0.875rem; color: var(--text-secondary);">${net.used_ips || 0}/${net.total_ips || 0}</span>
</div>
</td>
<td style="padding: 0.75rem; text-align: right;">
<a href="/ipam/network/${net.id}" class="btn btn-sm" style="background: var(--bg-tertiary); color: var(--text-primary);"><i class="fa-solid fa-eye"></i></a>
<button onclick="deleteNetwork(${net.id}, '${net.name}')" class="btn btn-sm btn-delete" style="margin-left: 0.25rem;"><i class="fa-solid fa-trash"></i></button>
</td>
</tr>
`}).join('')}
</tbody>
</table>
`;
} else {
container.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ' + t('error_loading_networks') + '</div>';
}
} catch (error) {
console.error('Error loading networks:', error);
document.getElementById('networksContainer').innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ' + t('error_loading') + '</div>';
}
}
function getUtilizationColor(percent) {
if (percent >= 90) return 'var(--danger)';
if (percent >= 70) return 'var(--warning)';
if (percent >= 50) return 'var(--warning-light, #eab308)';
return 'var(--success)';
}
let ipamServers = [];
async function loadServersForIPAM() {
try {
const response = await fetchWithAuth('/proxmox/api/servers');
if (response.ok) {
ipamServers = await response.json();
const select = document.getElementById('networkServer');
select.innerHTML = `<option value="">{{ t('all_servers') }} ({{ t('global_network') }})</option>`;
ipamServers.forEach(s => {
select.innerHTML += `<option value="${s.id}">${s.name} (${s.ip_address})</option>`;
});
}
} catch (e) {
console.error('Error loading servers:', e);
}
}
function showAddNetworkModal() {
document.getElementById('addNetworkModal').style.display = 'flex';
loadServersForIPAM();
}
function closeAddNetworkModal() {
document.getElementById('addNetworkModal').style.display = 'none';
document.getElementById('addNetworkForm').reset();
}
async function createNetwork(event) {
event.preventDefault();
const serverId = document.getElementById('networkServer').value;
const data = {
name: document.getElementById('networkName').value,
network: document.getElementById('networkCIDR').value,
gateway: document.getElementById('networkGateway').value || null,
vlan_id: document.getElementById('networkVLAN').value ? parseInt(document.getElementById('networkVLAN').value) : null,
proxmox_bridge: document.getElementById('networkBridge').value || null,
proxmox_server_id: serverId ? parseInt(serverId) : null,
dns_primary: document.getElementById('networkDNS1').value || null,
dns_secondary: document.getElementById('networkDNS2').value || null,
dns_domain: document.getElementById('networkDomain').value || null,
description: document.getElementById('networkDescription').value || null
};
try {
const response = await fetchWithAuth('/ipam/api/networks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
closeAddNetworkModal();
loadData();
showToast('success', t('success'), t('network_created_successfully') || 'Network created successfully');
} else {
const error = await response.json();
showToast('error', t('error'), error.detail || t('failed_to_create_network'));
}
} catch (error) {
console.error('Error creating network:', error);
showToast('error', t('error'), t('network_creation_error'));
}
}
async function deleteNetwork(id, name) {
const confirmed = await showDeleteConfirm(
t('delete_network'),
t('delete_network_confirm').replace('{name}', name)
);
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/ipam/api/networks/${id}?force=true`, {
method: 'DELETE'
});
if (response.ok) {
loadData();
showToast('success', t('success'), t('network_deleted_successfully') || 'Network deleted successfully');
} else {
const error = await response.json();
showToast('error', t('error'), error.detail || t('failed_to_delete_network'));
}
} catch (error) {
console.error('Error deleting network:', error);
showToast('error', t('error'), t('network_deletion_error'));
}
}
// ==================== Maintenance Functions ====================
function showMaintenanceModal() {
document.getElementById('maintenanceModal').style.display = 'flex';
loadMaintenanceData();
}
function closeMaintenanceModal() {
document.getElementById('maintenanceModal').style.display = 'none';
}
async function loadMaintenanceData() {
// Load orphans
try {
const orphansResp = await fetchWithAuth('/ipam/api/orphans');
if (orphansResp.ok) {
const data = await orphansResp.json();
renderOrphans(data.orphans);
document.getElementById('orphansCount').textContent = data.count;
}
} catch (e) {
console.error('Error loading orphans:', e);
}
// Load unlinked
try {
const unlinkedResp = await fetchWithAuth('/ipam/api/unlinked');
if (unlinkedResp.ok) {
const data = await unlinkedResp.json();
renderUnlinked(data.unlinked);
document.getElementById('unlinkedCount').textContent = data.count;
document.getElementById('linkableCount').textContent = data.linkable_count;
}
} catch (e) {
console.error('Error loading unlinked:', e);
}
}
function renderOrphans(orphans) {
const container = document.getElementById('orphansList');
if (!orphans.length) {
container.innerHTML = '<div style="text-align: center; color: var(--text-muted); padding: 1rem;"><i class="fa-solid fa-check-circle" style="color: var(--success);"></i> {{ t("no_orphans_found") }}</div>';
return;
}
let html = '<div style="max-height: 200px; overflow-y: auto;">';
for (const o of orphans) {
html += `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px solid var(--border-color);">
<div>
<span style="font-family: monospace; color: var(--warning);">${o.ip_address}</span>
<span style="color: var(--text-secondary); margin-left: 0.5rem;">${o.resource_name || '-'}</span>
<small style="color: var(--text-muted); margin-left: 0.5rem;">(${o.reason})</small>
</div>
<span class="badge" style="background: var(--bg-tertiary);">${o.resource_type}</span>
</div>
`;
}
html += '</div>';
container.innerHTML = html;
}
function renderUnlinked(unlinked) {
const container = document.getElementById('unlinkedList');
if (!unlinked.length) {
container.innerHTML = '<div style="text-align: center; color: var(--text-muted); padding: 1rem;"><i class="fa-solid fa-check-circle" style="color: var(--success);"></i> {{ t("all_allocations_linked") }}</div>';
return;
}
let html = '<div style="max-height: 200px; overflow-y: auto;">';
for (const u of unlinked) {
const canLink = u.can_link;
html += `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px solid var(--border-color);">
<div>
<span style="font-family: monospace;">${u.ip_address}</span>
<span style="color: var(--text-secondary); margin-left: 0.5rem;">${u.resource_name}</span>
</div>
<div>
${canLink ?
`<span class="badge" style="background: var(--success);">→ VMID ${u.suggested_vmid}</span>` :
`<span class="badge" style="background: var(--danger);">{{ t("no_match") }}</span>`
}
</div>
</div>
`;
}
html += '</div>';
container.innerHTML = html;
}
async function cleanupOrphans() {
const confirmed = await showDeleteConfirm(
'{{ t("cleanup_orphans") }}',
'{{ t("confirm_cleanup_orphans") }}'
);
if (!confirmed) return;
const btn = document.querySelector('#maintenanceModal .btn-danger');
btn.disabled = true;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> {{ t("cleaning") }}...';
try {
const response = await fetchWithAuth('/ipam/api/cleanup-orphans', { method: 'POST' });
if (response.ok) {
const data = await response.json();
showToast('success', '{{ t("success") }}', `{{ t("released") }}: ${data.released_count} IP`);
loadMaintenanceData();
loadData(); // Refresh main data
} else {
const error = await response.json();
showToast('error', '{{ t("error") }}', error.detail || '{{ t("cleanup_failed") }}');
}
} catch (e) {
console.error('Error cleaning orphans:', e);
showToast('error', '{{ t("error") }}', '{{ t("cleanup_failed") }}');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-trash"></i> {{ t("cleanup_orphans") }}';
}
}
async function linkAllocations() {
const confirmed = await showDeleteConfirm(
'{{ t("link_to_vms") }}',
'{{ t("confirm_link_allocations") }}'
);
if (!confirmed) return;
const btn = document.querySelector('#maintenanceModal .btn-primary');
btn.disabled = true;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> {{ t("linking") }}...';
try {
const response = await fetchWithAuth('/ipam/api/link-allocations', { method: 'POST' });
if (response.ok) {
const data = await response.json();
showToast('success', '{{ t("success") }}', `{{ t("linked") }}: ${data.linked_count}, {{ t("not_found") }}: ${data.not_found_count}`);
loadMaintenanceData();
loadData(); // Refresh main data
} else {
const error = await response.json();
showToast('error', '{{ t("error") }}', error.detail || '{{ t("linking_failed") }}');
}
} catch (e) {
console.error('Error linking allocations:', e);
showToast('error', '{{ t("error") }}', '{{ t("linking_failed") }}');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-link"></i> {{ t("link_to_vms") }}';
}
}
// Initial load after DOM ready
window.addEventListener('DOMContentLoaded', () => {
setTimeout(loadData, 100);
});
</script>
<!-- Maintenance Modal -->
<div id="maintenanceModal" 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: 90vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3 style="margin: 0;"><i class="fa-solid fa-wrench"></i> {{ t('ipam_maintenance') }}</h3>
<button onclick="closeMaintenanceModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<!-- Orphan Allocations -->
<div style="margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
<h4 style="margin: 0; color: var(--warning);"><i class="fa-solid fa-ghost"></i> {{ t('orphan_allocations') }} (<span id="orphansCount">0</span>)</h4>
<button onclick="cleanupOrphans()" class="btn btn-danger btn-sm"><i class="fa-solid fa-trash"></i> {{ t('cleanup_orphans') }}</button>
</div>
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem;">{{ t('orphan_allocations_desc') }}</p>
<div id="orphansList" style="background: var(--bg-tertiary); border-radius: 6px; padding: 0.5rem;">
<div style="text-align: center; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i></div>
</div>
</div>
<!-- Unlinked Allocations -->
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
<h4 style="margin: 0; color: var(--info);"><i class="fa-solid fa-unlink"></i> {{ t('unlinked_allocations') }} (<span id="unlinkedCount">0</span>, {{ t('linkable') }}: <span id="linkableCount">0</span>)</h4>
<button onclick="linkAllocations()" class="btn btn-primary btn-sm"><i class="fa-solid fa-link"></i> {{ t('link_to_vms') }}</button>
</div>
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem;">{{ t('unlinked_allocations_desc') }}</p>
<div id="unlinkedList" style="background: var(--bg-tertiary); border-radius: 6px; padding: 0.5rem;">
<div style="text-align: center; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,183 @@
{% extends "base.html" %}
{% block title %}{{ t('ipam_history') }}{% endblock %}
{% block content %}
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 600; margin: 0;"><i class="fa-solid fa-file-lines"></i> {{ t('ipam_history') }}</h1>
<p style="color: var(--text-secondary); margin-top: 0.25rem;">{{ t('all_changes_log') }}</p>
</div>
<div style="display: flex; gap: 0.75rem;">
<a href="/ipam" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">← {{ t('back') }}</a>
<button onclick="loadHistory()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);"><i class="fa-solid fa-rotate"></i> {{ t('refresh') }}</button>
</div>
</div>
<!-- Filters -->
<div class="card" style="margin-bottom: 1rem;">
<div style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;">
<div>
<label style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('action_filter') }}</label>
<select id="filterAction" onchange="loadHistory()" style="padding: 0.5rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
<option value="">{{ t('all') }}</option>
<option value="allocate">{{ t('allocation') }}</option>
<option value="release">{{ t('release') }}</option>
<option value="reserve">{{ t('reservation') }}</option>
<option value="update">{{ t('update') }}</option>
</select>
</div>
<div>
<label style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('period_filter') }}</label>
<select id="filterPeriod" onchange="loadHistory()" style="padding: 0.5rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
<option value="7">{{ t('days_7') }}</option>
<option value="30" selected>{{ t('days_30') }}</option>
<option value="90">{{ t('days_90') }}</option>
<option value="">{{ t('all_time') }}</option>
</select>
</div>
</div>
</div>
<!-- History List -->
<div class="card">
<div id="historyContainer">
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading_ellipsis') }}
</div>
</div>
</div>
<script>
// URL params helper functions
function getUrlParams() {
return new URLSearchParams(window.location.search);
}
function updateUrlParams(params) {
const url = new URL(window.location);
Object.entries(params).forEach(([key, value]) => {
if (value) {
url.searchParams.set(key, value);
} else {
url.searchParams.delete(key);
}
});
window.history.replaceState({}, '', url);
}
function restoreFiltersFromUrl() {
const params = getUrlParams();
const action = params.get('action') || '';
document.getElementById('filterAction').value = action;
const period = params.get('period') || '30';
document.getElementById('filterPeriod').value = period;
}
async function loadHistory() {
try {
const action = document.getElementById('filterAction').value;
const period = document.getElementById('filterPeriod').value;
// Save filters to URL
updateUrlParams({ action: action, period: period });
let url = '/ipam/api/history?limit=100';
if (action) url += `&action=${action}`;
if (period) {
const date = new Date();
date.setDate(date.getDate() - parseInt(period));
url += `&from_date=${date.toISOString()}`;
}
const response = await fetchWithAuth(url);
const container = document.getElementById('historyContainer');
if (response.ok) {
const history = await response.json();
renderHistory(history);
} else {
container.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ' + t('error_loading') + '</div>';
}
} catch (error) {
console.error('Error loading history:', error);
document.getElementById('historyContainer').innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ' + t('error_loading') + '</div>';
}
}
function renderHistory(history) {
const container = document.getElementById('historyContainer');
if (history.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 3rem;">
<div style="font-size: 3rem; margin-bottom: 1rem;"><i class="fa-solid fa-scroll"></i></div>
<h3 style="color: var(--text-primary); margin-bottom: 0.5rem;">${t('history_empty')}</h3>
<p style="color: var(--text-secondary);">${t('no_records_for_period')}</p>
</div>
`;
return;
}
const actionIcons = {
'allocate': '<i class="fa-solid fa-plus"></i>',
'release': '<i class="fa-solid fa-minus"></i>',
'reserve': '<i class="fa-solid fa-lock"></i>',
'update': '<i class="fa-solid fa-pen"></i>',
'create': '<i class="fa-solid fa-plus-circle"></i>',
'delete': '<i class="fa-solid fa-trash"></i>'
};
const actionColors = {
'allocate': '#22c55e',
'release': '#ef4444',
'reserve': '#4f46e5',
'update': '#3b82f6',
'create': '#22c55e',
'delete': '#ef4444'
};
container.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
${history.map(h => `
<div style="display: flex; align-items: flex-start; gap: 1rem; padding: 1rem; border-radius: 8px; background: var(--bg-secondary); border-left: 3px solid ${actionColors[h.action] || 'var(--border-color)'};">
<div style="font-size: 1.5rem;">${actionIcons[h.action] || '<i class="fa-solid fa-file-lines"></i>'}</div>
<div style="flex: 1;">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div>
<span style="font-family: monospace; background: var(--bg-tertiary); padding: 0.25rem 0.5rem; border-radius: 4px; font-weight: 500; color: var(--text-primary);">${h.ip_address}</span>
<span style="color: var(--text-secondary); margin-left: 0.5rem;">${h.network_name || ''}</span>
</div>
<span style="color: var(--text-muted); font-size: 0.875rem;">${new Date(h.created_at).toLocaleString('ru-RU')}</span>
</div>
<div style="margin-top: 0.5rem; color: var(--text-secondary);">
${h.details || getActionDescription(h.action)}
</div>
${h.performed_by ? `<div style="margin-top: 0.25rem; color: var(--text-muted); font-size: 0.75rem;"><i class="fa-solid fa-user"></i> ${h.performed_by}</div>` : ''}
</div>
</div>
`).join('')}
</div>
`;
}
function getActionDescription(action) {
const descriptions = {
'allocate': t('ip_allocated'),
'release': t('ip_released'),
'reserve': t('ip_reserved'),
'update': t('record_updated'),
'create': t('record_created'),
'delete': t('record_deleted')
};
return descriptions[action] || action;
}
// Initial load after DOM ready
window.addEventListener('DOMContentLoaded', () => {
restoreFiltersFromUrl();
setTimeout(loadHistory, 100);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,647 @@
{% extends "base.html" %}
{% block title %}IPAM - {{ t('network_detail') }}{% endblock %}
{% block content %}
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<div>
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem;">
<a href="/ipam" style="color: var(--text-muted); text-decoration: none;">IPAM</a>
<span style="color: var(--text-muted);">/</span>
<span id="networkBreadcrumb">{{ t('network_label') }}</span>
</div>
<h1 style="font-size: 1.75rem; font-weight: 600; margin: 0;" id="networkTitle"><i class="fa-solid fa-globe"></i> {{ t('loading') }}</h1>
</div>
<div style="display: flex; gap: 0.75rem;">
<button onclick="showAddPoolModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);" data-permission="ipam.manage"><i class="fa-solid fa-cube"></i> {{ t('add_pool') }}</button>
<button onclick="showAllocateModal()" class="btn" style="background: var(--success); color: var(--text-on-success);" data-permission="ipam.manage"><i class="fa-solid fa-plus"></i> {{ t('allocate_ip') }}</button>
<a href="/ipam" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">← {{ t('back') }}</a>
</div>
</div>
<!-- Network Stats -->
<div class="stats-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
<div class="card" style="padding: 1rem;">
<div style="font-size: 1.5rem; font-weight: 600; color: var(--text-primary);" id="statTotal">-</div>
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('total_ips') }}</div>
</div>
<div class="card" style="padding: 1rem;">
<div style="font-size: 1.5rem; font-weight: 600; color: var(--success);" id="statAvailable">-</div>
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('available_ips') }}</div>
</div>
<div class="card" style="padding: 1rem;">
<div style="font-size: 1.5rem; font-weight: 600; color: var(--info);" id="statAllocated">-</div>
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('allocated_ips') }}</div>
</div>
<div class="card" style="padding: 1rem;">
<div style="font-size: 1.5rem; font-weight: 600; color: var(--warning);" id="statReserved">-</div>
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('reserved_ips') }}</div>
</div>
<div class="card" style="padding: 1rem;">
<div style="font-size: 1.5rem; font-weight: 600; color: var(--text-primary);" id="statUtilization">-</div>
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('utilization') }}</div>
</div>
</div>
<!-- Pools -->
<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;"><i class="fa-solid fa-cube"></i> {{ t('ip_pools') }}</h3>
</div>
<div id="poolsContainer">
<div style="text-align: center; padding: 1rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}</div>
</div>
</div>
<!-- IP Grid -->
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0;"><i class="fa-solid fa-map"></i> {{ t('ip_map') }}</h3>
<div style="display: flex; gap: 1rem; align-items: center;">
<div style="display: flex; gap: 0.5rem; align-items: center; font-size: 0.8rem; color: var(--text-primary);">
<span style="display: inline-block; width: 16px; height: 16px; background: var(--success); border-radius: 3px;"></span> {{ t('available') }}
<span style="display: inline-block; width: 16px; height: 16px; background: var(--info); border-radius: 3px; margin-left: 0.5rem;"></span> {{ t('allocated') }}
<span style="display: inline-block; width: 16px; height: 16px; background: var(--warning); border-radius: 3px; margin-left: 0.5rem;"></span> {{ t('reserved') }}
<span style="display: inline-block; width: 16px; height: 16px; background: var(--purple); border-radius: 3px; margin-left: 0.5rem;"></span> {{ t('gateway') }}
</div>
<button onclick="loadIPMap()" class="btn btn-sm" style="background: var(--bg-tertiary); color: var(--text-primary);"><i class="fa-solid fa-rotate"></i></button>
</div>
</div>
<div id="ipGridContainer" style="max-height: 500px; overflow-y: auto;">
<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading_data') }}</div>
</div>
</div>
<!-- Allocations Table -->
<div class="card" style="margin-top: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0;"><i class="fa-solid fa-list"></i> {{ t('allocated_addresses') }}</h3>
</div>
<div id="allocationsContainer">
<div style="text-align: center; padding: 1rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}</div>
</div>
</div>
<!-- Allocate IP Modal -->
<div id="allocateModal" 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: 500px; width: 95%; max-height: 90vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3 style="margin: 0;"><i class="fa-solid fa-plus"></i> {{ t('allocate_ip') }}</h3>
<button onclick="closeAllocateModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<form id="allocateForm" onsubmit="allocateIP(event)">
<div style="display: grid; gap: 1rem;">
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('ip_address') }}</label>
<div style="display: flex; gap: 0.5rem;">
<input type="text" id="allocIP" placeholder="{{ t('auto_button') }}" style="flex: 1; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
<button type="button" onclick="getNextAvailable()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);"><i class="fa-solid fa-dice"></i> {{ t('auto_button') }}</button>
</div>
<small style="color: var(--text-muted);">{{ t('leave_empty_auto') }}</small>
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('resource_type') }}</label>
<select id="allocResourceType" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
<option value="">{{ t('not_specified') }}</option>
<option value="vm"><i class="fa-solid fa-desktop"></i> {{ t('vm') }}</option>
<option value="lxc"><i class="fa-solid fa-cube"></i> {{ t('lxc_container') }}</option>
<option value="physical"><i class="fa-solid fa-server"></i> {{ t('physical_server') }}</option>
<option value="service"><i class="fa-solid fa-gear"></i> {{ t('service') }}</option>
<option value="reserved"><i class="fa-solid fa-lock"></i> {{ t('reserved') }}</option>
</select>
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('resource_name') }}</label>
<input type="text" id="allocResourceName" placeholder="web-server-01" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('hostname') }}</label>
<input type="text" id="allocHostname" placeholder="web-server-01" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('mac_address') }}</label>
<input type="text" id="allocMAC" placeholder="AA:BB:CC:DD:EE:FF" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('notes') }}</label>
<textarea id="allocNotes" rows="2" placeholder="{{ t('description_placeholder') }}" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary); resize: vertical;"></textarea>
</div>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
<button type="button" onclick="closeAllocateModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">{{ t('cancel') }}</button>
<button type="submit" class="btn btn-success"><i class="fa-solid fa-floppy-disk"></i> {{ t('allocate') }}</button>
</div>
</form>
</div>
</div>
<!-- Add Pool Modal -->
<div id="addPoolModal" 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: 500px; width: 95%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3 style="margin: 0;"><i class="fa-solid fa-cube"></i> {{ t('add_pool') }}</h3>
<button onclick="closeAddPoolModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<form id="addPoolForm" onsubmit="createPool(event)">
<div style="display: grid; gap: 1rem;">
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('pool_name') }} {{ t('required') }}</label>
<input type="text" id="poolName" required placeholder="Static IPs" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('pool_type') }}</label>
<select id="poolType" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
<option value="static">{{ t('static') }}</option>
<option value="dhcp">{{ t('dhcp') }}</option>
<option value="reserved">{{ t('reserved') }}</option>
</select>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('range_start') }} {{ t('required') }}</label>
<input type="text" id="poolStart" required placeholder="10.10.10.100" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('range_end') }} {{ t('required') }}</label>
<input type="text" id="poolEnd" required placeholder="10.10.10.200" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; color: var(--text-secondary);">
<input type="checkbox" id="poolAutoAssign" checked style="width: auto;">
{{ t('auto_assign_pool') }}
</label>
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('description') }}</label>
<textarea id="poolDescription" rows="2" placeholder="{{ t('pool_description') }}" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary); resize: vertical;"></textarea>
</div>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
<button type="button" onclick="closeAddPoolModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">{{ t('cancel') }}</button>
<button type="submit" class="btn btn-success"><i class="fa-solid fa-floppy-disk"></i> {{ t('create') }}</button>
</div>
</form>
</div>
</div>
<!-- IP Detail Modal -->
<div id="ipDetailModal" 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: 500px; width: 95%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3 style="margin: 0;" id="ipDetailTitle"><i class="fa-solid fa-thumbtack"></i> IP Details</h3>
<button onclick="closeIPDetailModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<div id="ipDetailContent"></div>
</div>
</div>
<script>
// Use global fetchWithAuth from base.html
const networkId = {{ network_id }};
async function loadData() {
await Promise.all([
loadNetworkInfo(),
loadPools(),
loadIPMap(),
loadAllocations()
]);
}
async function loadNetworkInfo() {
try {
const response = await fetchWithAuth(`/ipam/api/networks/${networkId}/stats`);
if (response.ok) {
const stats = await response.json();
document.getElementById('networkTitle').innerHTML = `<i class="fa-solid fa-globe"></i> ${stats.network_name}`;
document.getElementById('networkBreadcrumb').textContent = stats.network_name;
document.getElementById('statTotal').textContent = stats.total_ips;
document.getElementById('statAvailable').textContent = stats.available_ips;
document.getElementById('statAllocated').textContent = stats.allocated_ips;
document.getElementById('statReserved').textContent = stats.reserved_ips;
document.getElementById('statUtilization').textContent = stats.utilization_percent + '%';
}
} catch (error) {
console.error('Error loading network info:', error);
}
}
async function loadPools() {
try {
const response = await fetchWithAuth(`/ipam/api/pools?network_id=${networkId}`);
const container = document.getElementById('poolsContainer');
if (response.ok) {
const pools = await response.json();
if (pools.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 1rem; color: var(--text-muted);">
${t('no_pools')} <button onclick="showAddPoolModal()" style="background: none; border: none; color: var(--accent); cursor: pointer; text-decoration: underline;">${t('add_pool')}</button>
</div>
`;
return;
}
container.innerHTML = `
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem;">
${pools.map(pool => `
<div style="background: var(--bg-tertiary); border-radius: 6px; padding: 1rem; border: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
<div>
<div style="font-weight: 500; color: var(--text-primary);">${pool.name}</div>
<div style="font-size: 0.8rem; color: var(--text-muted);">${pool.pool_type}</div>
</div>
<button onclick="deletePool(${pool.id}, '${pool.name}')" style="background: none; border: none; color: var(--text-muted); cursor: pointer;"><i class="fa-solid fa-trash"></i></button>
</div>
<div style="font-family: monospace; font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;">
${pool.range_start} - ${pool.range_end}
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<div style="flex: 1; height: 6px; background: var(--bg-secondary); border-radius: 3px; overflow: hidden;">
<div style="height: 100%; background: var(--info); width: ${pool.total_ips > 0 ? (pool.used_ips / pool.total_ips * 100) : 0}%;"></div>
</div>
<span style="font-size: 0.75rem; color: var(--text-muted);">${pool.used_ips}/${pool.total_ips}</span>
</div>
</div>
`).join('')}
</div>
`;
}
} catch (error) {
console.error('Error loading pools:', error);
}
}
async function loadIPMap() {
try {
const response = await fetchWithAuth(`/ipam/api/networks/${networkId}/ip-map`);
const container = document.getElementById('ipGridContainer');
if (response.ok) {
const data = await response.json();
const ipMap = data.ip_map;
if (ipMap.length === 0) {
container.innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--text-muted);">${t('no_data')}</div>`;
return;
}
// Limit to first 254 IPs for /24 or show warning for larger networks
const displayIPs = ipMap.slice(0, 254);
const hasMore = ipMap.length > 254;
container.innerHTML = `
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); gap: 4px;">
${displayIPs.map(ip => {
const lastOctet = ip.ip_address.split('.').pop();
const color = getIPColor(ip.status);
const icon = getIPIcon(ip);
return `
<div onclick="showIPDetail('${ip.ip_address}')"
style="background: ${color}; padding: 0.5rem; border-radius: 4px; text-align: center; cursor: pointer; transition: filter 0.15s;"
onmouseover="this.style.filter='brightness(1.1)'"
onmouseout="this.style.filter='brightness(1)'"
title="${ip.ip_address}${ip.resource_name ? ' - ' + ip.resource_name : ''}">
<div style="font-size: 0.75rem; font-weight: 500;">.${lastOctet}</div>
<div style="font-size: 0.7rem;">${icon}</div>
</div>
`;
}).join('')}
</div>
${hasMore ? `<div style="text-align: center; padding: 1rem; color: var(--text-muted);">${t('showing_first_ips')} ${ipMap.length}</div>` : ''}
`;
}
} catch (error) {
console.error('Error loading IP map:', error);
document.getElementById('ipGridContainer').innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ${t('error_loading_map')}</div>`;
}
}
function getIPColor(status) {
switch(status) {
case 'available': return 'rgba(34, 197, 94, 0.2)';
case 'allocated': return 'rgba(59, 130, 246, 0.3)';
case 'reserved': return 'rgba(245, 158, 11, 0.3)';
case 'gateway': return 'rgba(139, 92, 246, 0.3)';
case 'conflict': return 'rgba(239, 68, 68, 0.3)';
default: return 'var(--bg-tertiary)';
}
}
function getIPIcon(ip) {
if (ip.is_gateway) return '<i class="fa-solid fa-globe"></i>';
if (!ip.resource_type) return '';
switch(ip.resource_type) {
case 'vm': return '<i class="fa-solid fa-desktop"></i>';
case 'lxc': return '<i class="fa-solid fa-cube"></i>';
case 'physical': return '<i class="fa-solid fa-server"></i>';
case 'service': return '<i class="fa-solid fa-gear"></i>';
case 'reserved': return '<i class="fa-solid fa-lock"></i>';
default: return '<i class="fa-solid fa-thumbtack"></i>';
}
}
async function loadAllocations() {
try {
const response = await fetchWithAuth(`/ipam/api/allocations?network_id=${networkId}&limit=50`);
const container = document.getElementById('allocationsContainer');
if (response.ok) {
const allocations = await response.json();
if (allocations.length === 0) {
container.innerHTML = `<div style="text-align: center; padding: 1rem; color: var(--text-muted);">${t('no_allocated_addresses')}</div>`;
return;
}
container.innerHTML = `
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 1px solid var(--border-color);">
<th style="text-align: left; padding: 0.5rem; color: var(--text-secondary); font-weight: 500; font-size: 0.875rem;">${t('ip_address')}</th>
<th style="text-align: left; padding: 0.5rem; color: var(--text-secondary); font-weight: 500; font-size: 0.875rem;">${t('resource')}</th>
<th style="text-align: left; padding: 0.5rem; color: var(--text-secondary); font-weight: 500; font-size: 0.875rem;">${t('type')}</th>
<th style="text-align: left; padding: 0.5rem; color: var(--text-secondary); font-weight: 500; font-size: 0.875rem;">${t('hostname')}</th>
<th style="text-align: left; padding: 0.5rem; color: var(--text-secondary); font-weight: 500; font-size: 0.875rem;">${t('status')}</th>
<th style="text-align: right; padding: 0.5rem;"></th>
</tr>
</thead>
<tbody>
${allocations.map(alloc => `
<tr style="border-bottom: 1px solid var(--border-color);" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='transparent'">
<td style="padding: 0.5rem; font-family: monospace; color: var(--text-primary);">${alloc.ip_address}</td>
<td style="padding: 0.5rem; color: var(--text-primary);">${alloc.resource_name || '-'}</td>
<td style="padding: 0.5rem; color: var(--text-secondary);">${alloc.resource_type || '-'}</td>
<td style="padding: 0.5rem; color: var(--text-secondary);">${alloc.hostname || '-'}</td>
<td style="padding: 0.5rem;">
<span style="padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; background: ${getStatusColor(alloc.status)}; color: #fff;">
${alloc.status}
</span>
</td>
<td style="padding: 0.5rem; text-align: right;">
<button onclick="releaseIP(${alloc.id}, '${alloc.ip_address}')" class="btn btn-sm btn-danger"><i class="fa-solid fa-trash"></i></button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
} catch (error) {
console.error('Error loading allocations:', error);
}
}
function getStatusColor(status) {
switch(status) {
case 'allocated': return 'rgba(59, 130, 246, 0.3)';
case 'reserved': return 'rgba(245, 158, 11, 0.3)';
case 'conflict': return 'rgba(239, 68, 68, 0.3)';
default: return 'var(--bg-tertiary)';
}
}
// Modal functions
function showAllocateModal() {
document.getElementById('allocateModal').style.display = 'flex';
}
function closeAllocateModal() {
document.getElementById('allocateModal').style.display = 'none';
document.getElementById('allocateForm').reset();
}
function showAddPoolModal() {
document.getElementById('addPoolModal').style.display = 'flex';
}
function closeAddPoolModal() {
document.getElementById('addPoolModal').style.display = 'none';
document.getElementById('addPoolForm').reset();
}
function closeIPDetailModal() {
document.getElementById('ipDetailModal').style.display = 'none';
}
async function getNextAvailable() {
try {
const response = await fetchWithAuth(`/ipam/api/allocations/next-available/${networkId}`);
if (response.ok) {
const data = await response.json();
document.getElementById('allocIP').value = data.next_available_ip;
} else {
await showWarning(t('no_available_ips'), t('warning'));
}
} catch (error) {
console.error('Error getting next IP:', error);
}
}
async function allocateIP(event) {
event.preventDefault();
const ip = document.getElementById('allocIP').value.trim();
const data = {
network_id: networkId,
resource_type: document.getElementById('allocResourceType').value || null,
resource_name: document.getElementById('allocResourceName').value || null,
hostname: document.getElementById('allocHostname').value || null,
mac_address: document.getElementById('allocMAC').value || null,
notes: document.getElementById('allocNotes').value || null,
status: document.getElementById('allocResourceType').value === 'reserved' ? 'reserved' : 'allocated'
};
try {
let response;
if (ip) {
data.ip_address = ip;
response = await fetchWithAuth('/ipam/api/allocations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
response = await fetchWithAuth('/ipam/api/allocations/auto', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
if (response.ok) {
closeAllocateModal();
loadData();
} else {
const error = await response.json();
await showError(error.detail || t('error_allocating_ip'), t('error'));
}
} catch (error) {
console.error('Error allocating IP:', error);
await showError(t('error_allocating_ip'), t('error'));
}
}
async function createPool(event) {
event.preventDefault();
const data = {
network_id: networkId,
name: document.getElementById('poolName').value,
pool_type: document.getElementById('poolType').value,
range_start: document.getElementById('poolStart').value,
range_end: document.getElementById('poolEnd').value,
auto_assign: document.getElementById('poolAutoAssign').checked,
description: document.getElementById('poolDescription').value || null
};
try {
const response = await fetchWithAuth('/ipam/api/pools', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
closeAddPoolModal();
loadPools();
} else {
const error = await response.json();
await showError(error.detail || t('error_creating_pool'), t('error'));
}
} catch (error) {
console.error('Error creating pool:', error);
await showError(t('error_creating_pool'), t('error'));
}
}
async function deletePool(id, name) {
const confirmed = await showDeleteConfirm(`${t('delete_pool_confirm')} "${name}"?`);
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/ipam/api/pools/${id}`, { method: 'DELETE' });
if (response.ok) {
loadPools();
} else {
const error = await response.json();
await showError(error.detail || t('error_deleting_pool'), t('error'));
}
} catch (error) {
console.error('Error deleting pool:', error);
}
}
async function releaseIP(id, ip) {
const confirmed = await showConfirm(`${t('release_ip_confirm')} ${ip}?`, t('confirm'), { type: 'warning', icon: '<i class="fa-solid fa-lock-open"></i>' });
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/ipam/api/allocations/${id}`, { method: 'DELETE' });
if (response.ok) {
loadData();
} else {
const error = await response.json();
await showError(error.detail || t('error_releasing_ip'), t('error'));
}
} catch (error) {
console.error('Error releasing IP:', error);
}
}
async function showIPDetail(ip) {
const modal = document.getElementById('ipDetailModal');
const content = document.getElementById('ipDetailContent');
document.getElementById('ipDetailTitle').innerHTML = `<i class="fa-solid fa-thumbtack"></i> ${ip}`;
content.innerHTML = `<div style="text-align: center; padding: 1rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading')}</div>`;
modal.style.display = 'flex';
try {
// Try to find allocation for this IP
const response = await fetchWithAuth(`/ipam/api/allocations?search=${ip}&limit=1`);
if (response.ok) {
const allocations = await response.json();
const alloc = allocations.find(a => a.ip_address === ip);
if (alloc) {
content.innerHTML = `
<div style="display: grid; gap: 0.75rem;">
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 0.5rem;">
<div style="color: var(--text-muted);">${t('ip_address')}:</div>
<div style="font-family: monospace; color: var(--text-primary);">${alloc.ip_address}</div>
<div style="color: var(--text-muted);">${t('status')}:</div>
<div><span style="padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.875rem; background: ${getStatusColor(alloc.status)}; color: #fff;">${alloc.status}</span></div>
<div style="color: var(--text-muted);">${t('resource_type')}:</div>
<div style="color: var(--text-primary);">${alloc.resource_type || '-'}</div>
<div style="color: var(--text-muted);">${t('name')}:</div>
<div style="color: var(--text-primary);">${alloc.resource_name || '-'}</div>
<div style="color: var(--text-muted);">${t('hostname')}:</div>
<div style="color: var(--text-primary);">${alloc.hostname || '-'}</div>
<div style="color: var(--text-muted);">${t('mac_address')}:</div>
<div style="font-family: monospace; color: var(--text-primary);">${alloc.mac_address || '-'}</div>
<div style="color: var(--text-muted);">${t('proxmox_vmid')}:</div>
<div style="color: var(--text-primary);">${alloc.proxmox_vmid || '-'}</div>
<div style="color: var(--text-muted);">${t('allocated_by')}:</div>
<div style="color: var(--text-primary);">${alloc.allocated_by || 'system'} • ${new Date(alloc.allocated_at).toLocaleString('ru-RU')}</div>
<div style="color: var(--text-muted);">${t('notes')}:</div>
<div style="color: var(--text-primary);">${alloc.notes || '-'}</div>
</div>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
<button onclick="releaseIP(${alloc.id}, '${alloc.ip_address}'); closeIPDetailModal();" class="btn btn-danger"><i class="fa-solid fa-trash"></i> ${t('release')}</button>
<button onclick="closeIPDetailModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">${t('close')}</button>
</div>
`;
} else {
content.innerHTML = `
<div style="text-align: center; padding: 1rem;">
<div style="font-size: 2rem; margin-bottom: 0.5rem;"><i class="fa-solid fa-circle-check"></i></div>
<div style="color: var(--text-secondary);">${t('ip_available')}</div>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: center; margin-top: 1rem;">
<button onclick="document.getElementById('allocIP').value='${ip}'; closeIPDetailModal(); showAllocateModal();" class="btn btn-success"><i class="fa-solid fa-plus"></i> ${t('allocate')}</button>
<button onclick="closeIPDetailModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">${t('close')}</button>
</div>
`;
}
}
} catch (error) {
console.error('Error loading IP detail:', error);
content.innerHTML = `<div style="text-align: center; padding: 1rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ${t('error_loading')}</div>`;
}
}
// Initial load after DOM ready
window.addEventListener('DOMContentLoaded', () => {
setTimeout(loadData, 100);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,232 @@
{% extends "base.html" %}
{% block title %}IPAM - {{ t('network_label') }}{% endblock %}
{% block content %}
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 600; margin: 0;"><i class="fa-solid fa-globe"></i> {{ t('ipam_networks') }}</h1>
<p style="color: var(--text-secondary); margin-top: 0.25rem;">{{ t('ip_networks_management') }}</p>
</div>
<div style="display: flex; gap: 0.75rem;">
<a href="/ipam" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">← {{ t('back') }}</a>
<button onclick="showAddNetworkModal()" class="btn btn-success" data-permission="ipam.manage"><i class="fa-solid fa-plus"></i> {{ t('add_network') }}</button>
</div>
</div>
<!-- Networks List -->
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0;"><i class="fa-solid fa-list"></i> {{ t('all_networks') }}</h3>
<button onclick="loadNetworks()" class="btn btn-sm" style="background: var(--bg-tertiary); color: var(--text-primary);"><i class="fa-solid fa-rotate"></i> {{ t('refresh') }}</button>
</div>
<div id="networksContainer">
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}
</div>
</div>
</div>
<!-- Add Network Modal -->
<div id="addNetworkModal" 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: 90vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3 style="margin: 0;"><i class="fa-solid fa-plus"></i> {{ t('add_network') }}</h3>
<button onclick="closeAddNetworkModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<form id="addNetworkForm" onsubmit="createNetwork(event)">
<div style="display: grid; gap: 1rem;">
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('title') }} {{ t('required') }}</label>
<input type="text" id="networkName" required placeholder="Production Network" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('network_cidr') }} {{ t('required') }}</label>
<input type="text" id="networkCIDR" required placeholder="10.10.10.0/24" pattern="^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('gateway') }}</label>
<input type="text" id="networkGateway" placeholder="10.10.10.1" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('vlan_id') }}</label>
<input type="number" id="networkVLAN" min="1" max="4094" placeholder="100" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('proxmox_bridge') }}</label>
<input type="text" id="networkBridge" placeholder="vmbr0" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('description') }}</label>
<textarea id="networkDescription" rows="2" placeholder="{{ t('description_placeholder') }}" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary); resize: vertical;"></textarea>
</div>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
<button type="button" onclick="closeAddNetworkModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">{{ t('cancel') }}</button>
<button type="submit" class="btn btn-success"><i class="fa-solid fa-floppy-disk"></i> {{ t('create') }}</button>
</div>
</form>
</div>
</div>
<script>
async function loadNetworks() {
try {
const response = await fetchWithAuth('/ipam/api/networks');
const container = document.getElementById('networksContainer');
if (response.ok) {
const networks = await response.json();
if (networks.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 3rem;">
<div style="font-size: 3rem; margin-bottom: 1rem;"><i class="fa-solid fa-globe"></i></div>
<h3 style="color: var(--text-primary); margin-bottom: 0.5rem;">${t('networks_not_found')}</h3>
<p style="color: var(--text-secondary); margin-bottom: 1.5rem;">${t('add_first_network')}</p>
<button onclick="showAddNetworkModal()" class="btn btn-success"><i class="fa-solid fa-plus"></i> ${t('add_network')}</button>
</div>
`;
return;
}
container.innerHTML = `
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 1px solid var(--border-color);">
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('network_label')}</th>
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('cidr')}</th>
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('gateway')}</th>
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('vlan')}</th>
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('usage')}</th>
<th style="text-align: right; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('actions')}</th>
</tr>
</thead>
<tbody>
${networks.map(net => `
<tr style="border-bottom: 1px solid var(--border-color);" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='transparent'">
<td style="padding: 0.75rem;">
<a href="/ipam/network/${net.id}" style="color: var(--accent); text-decoration: none; font-weight: 500;">
${net.name}
</a>
${net.description ? `<div style="font-size: 0.75rem; color: var(--text-muted);">${net.description}</div>` : ''}
</td>
<td style="padding: 0.75rem; font-family: monospace; color: var(--text-primary);">${net.network}</td>
<td style="padding: 0.75rem; font-family: monospace; color: var(--text-secondary);">${net.gateway || '-'}</td>
<td style="padding: 0.75rem; color: var(--text-primary);">${net.vlan_id || '-'}</td>
<td style="padding: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<div style="flex: 1; height: 8px; background: var(--bg-tertiary); border-radius: 4px; overflow: hidden; max-width: 100px;">
<div style="height: 100%; background: ${getUtilizationColor(net.utilization_percent)}; width: ${net.utilization_percent || 0}%;"></div>
</div>
<span style="font-size: 0.875rem; color: var(--text-secondary);">${net.used_ips || 0}/${net.total_ips || 0}</span>
</div>
</td>
<td style="padding: 0.75rem; text-align: right;">
<a href="/ipam/network/${net.id}" class="btn btn-sm" style="background: var(--bg-tertiary); color: var(--text-primary);"><i class="fa-solid fa-eye"></i> ${t('open')}</a>
<button onclick="deleteNetwork(${net.id}, '${net.name}')" class="btn btn-sm btn-danger" style="margin-left: 0.25rem;"><i class="fa-solid fa-trash"></i></button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} else {
container.innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ${t('error_loading_networks')}</div>`;
}
} catch (error) {
console.error('Error loading networks:', error);
document.getElementById('networksContainer').innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ${t('error_loading')}</div>`;
}
}
function getUtilizationColor(percent) {
if (percent >= 90) return 'var(--danger)';
if (percent >= 70) return 'var(--warning)';
if (percent >= 50) return 'var(--warning)';
return 'var(--success)';
}
function showAddNetworkModal() {
document.getElementById('addNetworkModal').style.display = 'flex';
}
function closeAddNetworkModal() {
document.getElementById('addNetworkModal').style.display = 'none';
document.getElementById('addNetworkForm').reset();
}
async function createNetwork(event) {
event.preventDefault();
const data = {
name: document.getElementById('networkName').value,
network: document.getElementById('networkCIDR').value,
gateway: document.getElementById('networkGateway').value || null,
vlan_id: document.getElementById('networkVLAN').value ? parseInt(document.getElementById('networkVLAN').value) : null,
proxmox_bridge: document.getElementById('networkBridge').value || null,
description: document.getElementById('networkDescription').value || null
};
try {
const response = await fetchWithAuth('/ipam/api/networks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
closeAddNetworkModal();
loadNetworks();
showToast('success', t('success'), t('network_created_successfully') || 'Network created successfully');
} else {
const error = await response.json();
showToast('error', t('error'), error.detail || t('error_creating_network'));
}
} catch (error) {
console.error('Error creating network:', error);
showToast('error', t('error'), t('error_creating_network'));
}
}
async function deleteNetwork(id, name) {
const confirmed = await showDeleteConfirm(
t('delete_network'),
`${t('delete_network_confirm')} "${name}"? ${t('all_related_data_lost')}`
);
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/ipam/api/networks/${id}?force=true`, {
method: 'DELETE'
});
if (response.ok) {
loadNetworks();
showToast('success', t('success'), t('network_deleted_successfully') || 'Network deleted successfully');
} else {
const error = await response.json();
showToast('error', t('error'), error.detail || t('error_deleting_network'));
}
} catch (error) {
console.error('Error deleting network:', error);
showToast('error', t('error'), t('error_deleting_network'));
}
}
// Initial load after DOM ready
window.addEventListener('DOMContentLoaded', () => {
setTimeout(loadNetworks, 100);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,252 @@
<!doctype html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8">
<title>{{ t('login') }} - {{ t('app_name') }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0f172a">
<style>
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 0;
padding: 0;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: #e5e7eb;
min-height: 100vh;
min-height: -webkit-fill-available;
display: flex;
align-items: center;
justify-content: center;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
html {
height: -webkit-fill-available;
}
.login-container {
width: 100%;
max-width: 400px;
padding: 1.5rem;
}
.login-card {
background: #020617;
border-radius: 1rem;
padding: 2rem;
border: 1px solid #1e293b;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h1 {
margin: 0 0 0.5rem 0;
font-size: 1.75rem;
color: #22c55e;
}
.login-header p {
margin: 0;
color: #9ca3af;
font-size: 0.875rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #e5e7eb;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem 1rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #e5e7eb;
font-size: 16px; /* Предотвращает зум на iOS */
transition: all 0.2s;
-webkit-appearance: none;
appearance: none;
}
.form-group input:focus {
outline: none;
border-color: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.1);
}
.btn {
width: 100%;
padding: 0.875rem 1rem;
background: #22c55e;
color: #020617;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
min-height: 48px;
touch-action: manipulation;
}
.btn:hover {
background: #16a34a;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message {
background: var(--danger-light, #7f1d1d);
border: 1px solid var(--danger, #991b1b);
color: #fca5a5;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
display: none;
}
.error-message.show {
display: block;
}
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #020617;
border-radius: 50%;
border-top-color: transparent;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.footer {
text-align: center;
margin-top: 2rem;
color: #6b7280;
font-size: 0.875rem;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="login-header">
{% if custom_logo_exists %}
<img src="/settings/api/logo.png" alt="{{ panel_name }}" style="height: 128px; width: auto; object-fit: contain; margin-bottom: 0.5rem;">
{% endif %}
<p>{{ t('login_to_continue') }}</p>
</div> <div id="errorMessage" class="error-message"></div>
<form id="loginForm">
<div class="form-group">
<label for="username">{{ t('username') }}</label>
<input
type="text"
id="username"
name="username"
required
autocomplete="username"
placeholder="{{ t('enter_username') }}"
>
</div>
<div class="form-group">
<label for="password">{{ t('password') }}</label>
<input
type="password"
id="password"
name="password"
required
autocomplete="current-password"
placeholder="{{ t('enter_password') }}"
>
</div>
<button type="submit" class="btn" id="loginBtn">
{{ t('login') }}
</button>
</form>
</div>
<div class="footer">
<p>{{ panel_name }} v{{ version }} © 2025</p>
</div>
</div>
<script>
const loginForm = document.getElementById('loginForm');
const loginBtn = document.getElementById('loginBtn');
const errorMessage = document.getElementById('errorMessage');
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
// Disable button and show loading
loginBtn.disabled = true;
loginBtn.innerHTML = '<span class="loading"></span> ' + '{{ t('logging_in') }}';
errorMessage.classList.remove('show');
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
// Save token to localStorage
localStorage.setItem('access_token', data.access_token);
// Redirect to original page or dashboard
const urlParams = new URLSearchParams(window.location.search);
const returnUrl = urlParams.get('returnUrl') ? decodeURIComponent(urlParams.get('returnUrl')) : '/dashboard';
window.location.href = returnUrl;
} else {
// Show error
errorMessage.textContent = data.detail || '{{ t('login_error_check_credentials') }}';
errorMessage.classList.add('show');
loginBtn.disabled = false;
loginBtn.textContent = '{{ t('login') }}';
}
} catch (error) {
errorMessage.textContent = '{{ t('server_connection_error') }}';
errorMessage.classList.add('show');
loginBtn.disabled = false;
loginBtn.textContent = '{{ t('login') }}';
}
});
// Check if already logged in
const token = localStorage.getItem('access_token');
if (token) {
// Verify token
fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
}).then(response => {
if (response.ok) {
const urlParams = new URLSearchParams(window.location.search);
const returnUrl = urlParams.get('returnUrl') ? decodeURIComponent(urlParams.get('returnUrl')) : '/dashboard';
window.location.href = returnUrl;
}
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,948 @@
{% extends "base.html" %}
{% set page_title = t('nav_logs') %}
{% block content %}
<style>
.logs-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.filters-panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.filter-group {
display: flex;
flex-direction: column;
}
.filter-group label {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.375rem;
font-weight: 600;
}
.filter-group select,
.filter-group input {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
transition: border-color 0.2s ease;
}
.filter-group select:focus,
.filter-group input:focus {
outline: none;
border-color: var(--accent);
}
.filter-group input[type="date"] {
color-scheme: dark light;
}
:root[data-theme="light"] .filter-group input[type="date"] {
color-scheme: light;
}
:root[data-theme="dark"] .filter-group input[type="date"] {
color-scheme: dark;
}
.filter-group input::placeholder {
color: var(--text-muted);
}
.filter-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-box {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
text-align: center;
}
.stat-box-value {
font-size: 1.75rem;
font-weight: 700;
color: var(--text-primary);
}
.stat-box-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
}
.stat-box.errors .stat-box-value {
color: var(--danger);
}
.stat-box.warnings .stat-box-value {
color: var(--warning);
}
.logs-table-container {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.logs-table {
width: 100%;
border-collapse: collapse;
}
.logs-table th {
background: var(--bg-tertiary);
padding: 0.875rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border-color);
}
.logs-table td {
padding: 0.75rem;
border-bottom: 1px solid var(--border-color);
font-size: 0.875rem;
}
.logs-table tr:hover {
background: var(--bg-tertiary);
cursor: pointer;
}
.log-level {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
}
.log-level.debug { background: var(--text-muted); color: var(--text-secondary); }
.log-level.info { background: var(--info-light); color: var(--info); }
.log-level.warning { background: var(--warning-light); color: var(--warning); }
.log-level.error { background: var(--danger-light); color: var(--danger); }
.log-level.critical { background: var(--danger); color: var(--danger-text-light); }
.log-category {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.6875rem;
background: var(--bg-hover);
color: var(--text-secondary);
}
.log-message {
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-time {
font-family: 'SF Mono', monospace;
font-size: 0.8125rem;
color: var(--text-muted);
}
.log-success {
color: var(--success);
}
.log-failed {
color: var(--danger);
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: var(--bg-tertiary);
border-top: 1px solid var(--border-color);
}
.pagination button {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.pagination button:hover:not(:disabled) {
background: var(--bg-hover);
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-info {
color: var(--text-muted);
font-size: 0.875rem;
margin: 0 1rem;
}
/* Modal for log details */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.active {
display: flex;
}
.modal-content {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
width: 90%;
max-width: 700px;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-header h3 {
margin: 0;
font-size: 1.125rem;
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.5rem;
cursor: pointer;
}
.modal-body {
padding: 1.5rem;
}
.detail-row {
display: grid;
grid-template-columns: 140px 1fr;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
color: var(--text-muted);
font-weight: 500;
font-size: 0.875rem;
}
.detail-value {
color: var(--text-primary);
font-size: 0.875rem;
word-break: break-all;
}
.detail-json {
background: var(--bg-tertiary);
border-radius: 4px;
padding: 0.75rem;
font-family: 'SF Mono', monospace;
font-size: 0.8125rem;
overflow-x: auto;
white-space: pre-wrap;
}
.loading {
text-align: center;
padding: 3rem;
color: var(--text-muted);
}
.no-logs {
text-align: center;
padding: 3rem;
color: var(--text-muted);
}
/* Мобильная адаптация */
@media (max-width: 768px) {
.logs-header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.logs-header > div:last-child {
justify-content: flex-end;
}
.filters-grid {
grid-template-columns: 1fr 1fr;
}
.logs-table th,
.logs-table td {
padding: 0.5rem 0.25rem;
font-size: 0.75rem;
}
/* Скрываем менее важные колонки */
.logs-table th:nth-child(3),
.logs-table td:nth-child(3),
.logs-table th:nth-child(4),
.logs-table td:nth-child(4),
.logs-table th:nth-child(6),
.logs-table td:nth-child(6) {
display: none;
}
.log-modal-content {
padding: 1rem;
margin: 0.5rem;
max-height: 90vh;
}
.detail-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.filters-grid {
grid-template-columns: 1fr;
}
.logs-table th:nth-child(5),
.logs-table td:nth-child(5) {
display: none;
}
.pagination {
flex-wrap: wrap;
gap: 0.5rem;
}
}
</style>
<div class="logs-header">
<div>
<h3 style="margin: 0; color: var(--text-primary);">{{ t('event_log') }}</h3>
<p style="margin: 0.25rem 0 0; color: var(--text-muted); font-size: 0.875rem;">
{{ t('view_and_filter_system_logs') }}
</p>
</div>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" onclick="refreshLogs()"><i class="fa-solid fa-rotate"></i> {{ t('refresh') }}</button>
<button class="btn btn-secondary" onclick="exportLogs()"><i class="fa-solid fa-calendar"></i> {{ t('export') }}</button>
</div>
</div>
<!-- Stats Row -->
<div class="stats-row" id="statsRow">
<div class="stat-box">
<div class="stat-box-value" id="statTotal">-</div>
<div class="stat-box-label">{{ t('total_24h') }}</div>
</div>
<div class="stat-box">
<div class="stat-box-value" id="statAuth">-</div>
<div class="stat-box-label"><i class="fa-solid fa-lock"></i> {{ t('auth') }}</div>
</div>
<div class="stat-box">
<div class="stat-box-value" id="statProxmox">-</div>
<div class="stat-box-label"><i class="fa-solid fa-desktop"></i> Proxmox</div>
</div>
<div class="stat-box errors">
<div class="stat-box-value" id="statErrors">-</div>
<div class="stat-box-label"><i class="fa-solid fa-circle-xmark"></i> {{ t('errors') }}</div>
</div>
<div class="stat-box warnings">
<div class="stat-box-value" id="statWarnings">-</div>
<div class="stat-box-label"><i class="fa-solid fa-triangle-exclamation"></i> {{ t('warnings') }}</div>
</div>
<div class="stat-box">
<div class="stat-box-value" id="statFailedLogins">-</div>
<div class="stat-box-label"><i class="fa-solid fa-ban"></i> {{ t('failed_logins') }}</div>
</div>
</div>
<!-- Filters Panel -->
<div class="filters-panel">
<div class="filters-grid">
<div class="filter-group">
<label>{{ t('level') }}</label>
<select id="filterLevel">
<option value="">{{ t('all_levels') }}</option>
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="critical">Critical</option>
</select>
</div>
<div class="filter-group">
<label>{{ t('category') }}</label>
<select id="filterCategory">
<option value="">{{ t('all_categories') }}</option>
<option value="auth"><i class="fa-solid fa-lock"></i> {{ t('auth') }}</option>
<option value="proxmox"><i class="fa-solid fa-desktop"></i> Proxmox</option>
<option value="ipam"><i class="fa-solid fa-globe"></i> IPAM</option>
<option value="system"><i class="fa-solid fa-gear"></i> {{ t('system') }}</option>
<option value="api"><i class="fa-solid fa-plug"></i> API</option>
<option value="docker"><i class="fa-brands fa-docker"></i> Docker</option>
</select>
</div>
<div class="filter-group">
<label>{{ t('user') }}</label>
<input type="text" id="filterUsername" placeholder="{{ t('username') }}">
</div>
<div class="filter-group">
<label>{{ t('date_from') }}</label>
<input type="date" id="filterDateFrom">
</div>
<div class="filter-group">
<label>{{ t('date_to') }}</label>
<input type="date" id="filterDateTo">
</div>
<div class="filter-group">
<label>IP {{ t('address') }}</label>
<input type="text" id="filterIP" placeholder="192.168.1.1">
</div>
<div class="filter-group">
<label>{{ t('search') }}</label>
<input type="text" id="filterSearch" placeholder="{{ t('search_in_messages') }}">
</div>
</div>
<div class="filter-actions">
<button class="btn btn-secondary" onclick="clearFilters()">{{ t('reset') }}</button>
<button class="btn btn-primary" onclick="applyFilters()">{{ t('apply') }}</button>
</div>
</div>
<!-- Logs Table -->
<div class="logs-table-container">
<table class="logs-table">
<thead>
<tr>
<th style="width: 140px;">{{ t('time') }}</th>
<th style="width: 80px;">{{ t('level') }}</th>
<th style="width: 100px;">{{ t('category') }}</th>
<th style="width: 120px;">{{ t('action') }}</th>
<th>{{ t('message') }}</th>
<th style="width: 100px;">{{ t('user') }}</th>
<th style="width: 110px;">IP</th>
<th style="width: 60px;">{{ t('status') }}</th>
</tr>
</thead>
<tbody id="logsTableBody">
<tr>
<td colspan="7" class="loading">{{ t('loading_logs') }}</td>
</tr>
</tbody>
</table>
<div class="pagination">
<button id="btnPrevPage" onclick="prevPage()" disabled>← {{ t('back') }}</button>
<span class="pagination-info" id="paginationInfo">{{ t('page') }} 1 {{ t('of') }} 1</span>
<button id="btnNextPage" onclick="nextPage()" disabled>{{ t('forward') }} →</button>
</div>
</div>
<!-- Log Detail Modal -->
<div class="modal-overlay" id="logDetailModal">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fa-solid fa-list"></i> {{ t('log_details') }}</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body" id="logDetailBody">
<!-- Content loaded dynamically -->
</div>
</div>
</div>
<script>
// State
let currentPage = 1;
let totalPages = 1;
let currentFilters = {};
let logsData = [];
// URL params helper functions
function getUrlParams() {
return new URLSearchParams(window.location.search);
}
function updateUrlParams(params) {
const url = new URL(window.location);
Object.entries(params).forEach(([key, value]) => {
if (value) {
url.searchParams.set(key, value);
} else {
url.searchParams.delete(key);
}
});
window.history.replaceState({}, '', url);
}
function restoreFiltersFromUrl() {
const params = getUrlParams();
const level = params.get('level') || '';
document.getElementById('filterLevel').value = level;
const category = params.get('category') || '';
document.getElementById('filterCategory').value = category;
const username = params.get('username') || '';
document.getElementById('filterUsername').value = username;
const dateFrom = params.get('dateFrom') || '';
document.getElementById('filterDateFrom').value = dateFrom;
const dateTo = params.get('dateTo') || '';
document.getElementById('filterDateTo').value = dateTo;
const ip = params.get('ip') || '';
document.getElementById('filterIP').value = ip;
const search = params.get('search') || '';
document.getElementById('filterSearch').value = search;
const page = params.get('page');
if (page && parseInt(page) > 0) {
currentPage = parseInt(page);
}
currentFilters = { level, category, username, dateFrom, dateTo, ip, search };
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
restoreFiltersFromUrl();
setTimeout(() => {
loadStats();
loadLogs();
}, 100);
// Enter key on search and IP fields
document.getElementById('filterSearch').addEventListener('keypress', (e) => {
if (e.key === 'Enter') applyFilters();
});
document.getElementById('filterIP').addEventListener('keypress', (e) => {
if (e.key === 'Enter') applyFilters();
});
});
// Load statistics
async function loadStats() {
try {
const token = localStorage.getItem('access_token');
const response = await fetch('/logs/api/stats?hours=24', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const stats = await response.json();
document.getElementById('statTotal').textContent = stats.total;
document.getElementById('statAuth').textContent = stats.by_category.auth || 0;
document.getElementById('statProxmox').textContent = stats.by_category.proxmox || 0;
document.getElementById('statErrors').textContent = stats.errors_count || 0;
document.getElementById('statWarnings').textContent = stats.by_level.warning || 0;
document.getElementById('statFailedLogins').textContent = stats.failed_logins || 0;
}
} catch (error) {
console.error('Failed to load stats:', error);
}
}
// Load logs with filters
async function loadLogs() {
const tbody = document.getElementById('logsTableBody');
tbody.innerHTML = '<tr><td colspan="8" class="loading">' + t('loading_logs') + '</td></tr>';
try {
const token = localStorage.getItem('access_token');
// Build query string
const params = new URLSearchParams();
params.append('page', currentPage);
params.append('limit', 50);
if (currentFilters.level) params.append('level', currentFilters.level);
if (currentFilters.category) params.append('category', currentFilters.category);
if (currentFilters.username) params.append('username', currentFilters.username);
if (currentFilters.dateFrom) params.append('date_from', currentFilters.dateFrom);
if (currentFilters.dateTo) params.append('date_to', currentFilters.dateTo);
if (currentFilters.search) params.append('search', currentFilters.search);
const response = await fetch(`/logs/api/logs?${params.toString()}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
logsData = data.logs;
totalPages = data.pages;
renderLogs(data.logs);
updatePagination(data.page, data.pages, data.total);
} catch (error) {
console.error('Failed to load logs:', error);
tbody.innerHTML = '<tr><td colspan="8" class="no-logs">' + t('log_load_error') + '</td></tr>';
}
}
// Render logs table
function renderLogs(logs) {
const tbody = document.getElementById('logsTableBody');
if (!logs || logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="no-logs">' + t('no_logs_for_filters') + '</td></tr>';
return;
}
tbody.innerHTML = logs.map((log, index) => `
<tr onclick="showLogDetail(${index})" style="cursor: pointer;">
<td class="log-time">${formatDateTime(log.created_at)}</td>
<td><span class="log-level ${log.level}">${log.level}</span></td>
<td><span class="log-category">${getCategoryIcon(log.category)} ${log.category}</span></td>
<td>${log.action}</td>
<td class="log-message" title="${escapeHtml(log.message)}">${escapeHtml(log.message)}</td>
<td>${log.username || '-'}</td>
<td style="font-family: monospace; font-size: 0.8rem;">${log.ip_address || '-'}</td>
<td class="${log.success ? 'log-success' : 'log-failed'}">${log.success ? '✓' : '✗'}</td>
</tr>
`).join('');
}
// Update pagination
function updatePagination(page, pages, total) {
document.getElementById('paginationInfo').textContent = t('page') + ' ' + page + ' ' + t('of') + ' ' + pages + ' (' + total + ' ' + t('records') + ')';
document.getElementById('btnPrevPage').disabled = page <= 1;
document.getElementById('btnNextPage').disabled = page >= pages;
}
// Navigation
function prevPage() {
if (currentPage > 1) {
currentPage--;
updateUrlParams({ page: String(currentPage) });
loadLogs();
}
}
function nextPage() {
if (currentPage < totalPages) {
currentPage++;
updateUrlParams({ page: String(currentPage) });
loadLogs();
}
}
// Filters
function applyFilters() {
currentFilters = {
level: document.getElementById('filterLevel').value,
category: document.getElementById('filterCategory').value,
username: document.getElementById('filterUsername').value,
dateFrom: document.getElementById('filterDateFrom').value,
dateTo: document.getElementById('filterDateTo').value,
ip: document.getElementById('filterIP').value,
search: document.getElementById('filterSearch').value
};
currentPage = 1;
updateUrlParams({
...currentFilters,
page: '1'
});
loadLogs();
}
function clearFilters() {
document.getElementById('filterLevel').value = '';
document.getElementById('filterCategory').value = '';
document.getElementById('filterUsername').value = '';
document.getElementById('filterDateFrom').value = '';
document.getElementById('filterDateTo').value = '';
document.getElementById('filterIP').value = '';
document.getElementById('filterSearch').value = '';
currentFilters = {};
currentPage = 1;
// Clear all URL params
updateUrlParams({
level: '',
category: '',
username: '',
dateFrom: '',
dateTo: '',
ip: '',
search: '',
page: ''
});
loadLogs();
}
// Refresh
function refreshLogs() {
loadStats();
loadLogs();
}
// Show log detail modal
function showLogDetail(index) {
const log = logsData[index];
if (!log) return;
const body = document.getElementById('logDetailBody');
body.innerHTML = `
<div class="detail-row">
<div class="detail-label">ID</div>
<div class="detail-value">${log.id}</div>
</div>
<div class="detail-row">
<div class="detail-label">${t('time')}</div>
<div class="detail-value">${formatDateTime(log.created_at)}</div>
</div>
<div class="detail-row">
<div class="detail-label">${t('level')}</div>
<div class="detail-value"><span class="log-level ${log.level}">${log.level.toUpperCase()}</span></div>
</div>
<div class="detail-row">
<div class="detail-label">${t('category')}</div>
<div class="detail-value">${getCategoryIcon(log.category)} ${log.category}</div>
</div>
<div class="detail-row">
<div class="detail-label">${t('action')}</div>
<div class="detail-value">${log.action}</div>
</div>
<div class="detail-row">
<div class="detail-label">${t('message')}</div>
<div class="detail-value">${escapeHtml(log.message)}</div>
</div>
${log.request_id ? `
<div class="detail-row">
<div class="detail-label">Request ID</div>
<div class="detail-value"><code style="font-family: monospace; background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">${log.request_id}</code></div>
</div>
` : ''}
<div class="detail-row">
<div class="detail-label">${t('user')}</div>
<div class="detail-value">${log.username || '-'}${log.user_id ? ` (ID: ${log.user_id})` : ''}</div>
</div>
<div class="detail-row">
<div class="detail-label">IP ${t('address')}</div>
<div class="detail-value">${log.ip_address || '-'}${log.geo_location ? ` <span style="color: var(--text-muted);">(${log.geo_location})</span>` : ''}</div>
</div>
${log.user_agent ? `
<div class="detail-row">
<div class="detail-label">User Agent</div>
<div class="detail-value" style="font-size: 0.8rem; word-break: break-all;">${escapeHtml(log.user_agent)}</div>
</div>
` : ''}
${log.server_name || log.node_name ? `
<div class="detail-row">
<div class="detail-label">Proxmox Server</div>
<div class="detail-value">${log.server_name || '-'}${log.node_name ? ` / Node: ${log.node_name}` : ''}</div>
</div>
` : ''}
<div class="detail-row">
<div class="detail-label">${t('resource')}</div>
<div class="detail-value">${log.resource_type ? `${log.resource_type} #${log.resource_id}${log.resource_name ? ` (${log.resource_name})` : ''}` : '-'}</div>
</div>
${log.request_method ? `
<div class="detail-row">
<div class="detail-label">HTTP ${t('request')}</div>
<div class="detail-value">${log.request_method} ${log.request_path}${log.query_params ? `?${log.query_params}` : ''} → ${log.response_status} (${log.duration_ms}ms)</div>
</div>
` : ''}
<div class="detail-row">
<div class="detail-label">${t('status')}</div>
<div class="detail-value" class="${log.success ? 'log-success' : 'log-failed'}">${log.success ? '✓ ' + t('success') : '✗ ' + t('error')}</div>
</div>
${log.error_message ? `
<div class="detail-row">
<div class="detail-label">${t('error')}</div>
<div class="detail-value" style="color: var(--danger);">${escapeHtml(log.error_message)}</div>
</div>
` : ''}
${log.error_traceback ? `
<div class="detail-row">
<div class="detail-label">Stack Trace</div>
<div class="detail-value">
<pre style="background: var(--bg-tertiary); padding: 0.5rem; border-radius: 4px; font-size: 0.75rem; overflow-x: auto; max-height: 200px; white-space: pre-wrap;">${escapeHtml(log.error_traceback)}</pre>
</div>
</div>
` : ''}
${log.request_body ? `
<div class="detail-row">
<div class="detail-label">Request Body</div>
<div class="detail-value">
<div class="detail-json">${JSON.stringify(log.request_body, null, 2)}</div>
</div>
</div>
` : ''}
${log.response_body ? `
<div class="detail-row">
<div class="detail-label">Response Body</div>
<div class="detail-value">
<div class="detail-json">${JSON.stringify(log.response_body, null, 2)}</div>
</div>
</div>
` : ''}
${log.details ? `
<div class="detail-row">
<div class="detail-label">${t('details')} (JSON)</div>
<div class="detail-value">
<div class="detail-json">${JSON.stringify(log.details, null, 2)}</div>
</div>
</div>
` : ''}
`;
document.getElementById('logDetailModal').classList.add('active');
}
function closeModal() {
document.getElementById('logDetailModal').classList.remove('active');
}
// Close modal on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
// Close modal on overlay click
document.getElementById('logDetailModal').addEventListener('click', (e) => {
if (e.target.classList.contains('modal-overlay')) closeModal();
});
// Export logs
function exportLogs() {
if (!logsData || logsData.length === 0) {
showToast('warning', t('warning') || 'Warning', t('no_data_to_export'));
return;
}
const csv = [
['ID', t('time'), t('level'), t('category'), t('action'), t('message'), t('user'), 'IP', t('status')].join(','),
...logsData.map(log => [
log.id,
log.created_at,
log.level,
log.category,
log.action,
`"${(log.message || '').replace(/"/g, '""')}"`,
log.username || '',
log.ip_address || '',
log.success ? 'OK' : 'ERROR'
].join(','))
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `logs_${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
}
// Helpers
function formatDateTime(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
function getCategoryIcon(category) {
const icons = {
'auth': '<i class="fa-solid fa-lock"></i>',
'proxmox': '<i class="fa-solid fa-desktop"></i>',
'ipam': '<i class="fa-solid fa-globe"></i>',
'system': '<i class="fa-solid fa-gear"></i>',
'api': '<i class="fa-solid fa-plug"></i>',
'docker': '<i class="fa-brands fa-docker"></i>'
};
return icons[category] || '<i class="fa-solid fa-list"></i>';
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff