Add complete project structure and all files
This commit is contained in:
1298
backend/app/templates/base.html
Normal file
1298
backend/app/templates/base.html
Normal file
File diff suppressed because it is too large
Load Diff
13
backend/app/templates/containers.html
Normal file
13
backend/app/templates/containers.html
Normal 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 %}
|
||||
270
backend/app/templates/dashboard.html
Normal file
270
backend/app/templates/dashboard.html
Normal 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 %}
|
||||
2152
backend/app/templates/instance_detail.html
Normal file
2152
backend/app/templates/instance_detail.html
Normal file
File diff suppressed because it is too large
Load Diff
351
backend/app/templates/ipam_allocations.html
Normal file
351
backend/app/templates/ipam_allocations.html
Normal 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;">×</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 %}
|
||||
574
backend/app/templates/ipam_dashboard.html
Normal file
574
backend/app/templates/ipam_dashboard.html
Normal 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;">×</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;">×</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 %}
|
||||
183
backend/app/templates/ipam_history.html
Normal file
183
backend/app/templates/ipam_history.html
Normal 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 %}
|
||||
647
backend/app/templates/ipam_network_detail.html
Normal file
647
backend/app/templates/ipam_network_detail.html
Normal 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;">×</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;">×</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;">×</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 %}
|
||||
232
backend/app/templates/ipam_networks.html
Normal file
232
backend/app/templates/ipam_networks.html
Normal 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;">×</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 %}
|
||||
252
backend/app/templates/login.html
Normal file
252
backend/app/templates/login.html
Normal 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>
|
||||
948
backend/app/templates/logs.html
Normal file
948
backend/app/templates/logs.html
Normal 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()">×</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 %}
|
||||
1477
backend/app/templates/os_templates.html
Normal file
1477
backend/app/templates/os_templates.html
Normal file
File diff suppressed because it is too large
Load Diff
2246
backend/app/templates/proxmox_server_detail.html
Normal file
2246
backend/app/templates/proxmox_server_detail.html
Normal file
File diff suppressed because it is too large
Load Diff
2141
backend/app/templates/proxmox_vms.html
Normal file
2141
backend/app/templates/proxmox_vms.html
Normal file
File diff suppressed because it is too large
Load Diff
1943
backend/app/templates/settings.html
Normal file
1943
backend/app/templates/settings.html
Normal file
File diff suppressed because it is too large
Load Diff
1259
backend/app/templates/users.html
Normal file
1259
backend/app/templates/users.html
Normal file
File diff suppressed because it is too large
Load Diff
4337
backend/app/templates/virtual_machines.html
Normal file
4337
backend/app/templates/virtual_machines.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user