Files
pvemanager/backend/app/templates/ipam_allocations.html
markmorado 91dbf0b81c feat: IPAM improvements - server/node subnet binding, theme color fixes
- IPAM link-allocations: filter networks by proxmox_server_id and proxmox_node
  to prevent cross-server/cross-node IP assignment
- IPAM link-allocations: add manual server+network selection modal before linking
- IPAM add-network forms: add Proxmox server and node selectors with dynamic
  cluster-info loading (ipam_dashboard.html, ipam_networks.html)
- models.py / schemas.py: add proxmox_node field to IPAMNetwork
- migrations: migration #16 adds proxmox_node column to ipam_networks table
- Theme fixes: replace all hardcoded rgba/color:#fff/color:white with CSS vars
  across base.html, virtual_machines.html, instance_detail.html,
  os_templates.html, proxmox_vms.html, ipam_network_detail.html,
  ipam_allocations.html
- i18n: add proxmox_node, node selector, link-allocations modal keys (ru/en)
- Version bump: 1.0.4 -> 1.0.5
2026-03-08 14:17:54 +01:00

352 lines
17 KiB
HTML

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