feat: custom fields by device or subnet

This commit is contained in:
2025-12-27 22:07:49 +00:00
parent 53dc19a549
commit b23cda48af
11 changed files with 1786 additions and 26 deletions
+12 -4
View File
@@ -1,12 +1,20 @@
function showTab(tabName) {
// Hide all panels
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.add('hidden'));
// Remove active class from all tabs
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
// Update all tab buttons to inactive state
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
btn.classList.add('border-transparent', 'text-gray-500');
});
// Show selected panel
document.getElementById('panel-' + tabName).classList.remove('hidden');
// Add active class to selected tab
document.getElementById('tab-' + tabName).classList.add('active');
// Update selected tab to active state
const activeTab = document.getElementById('tab-' + tabName);
activeTab.classList.remove('border-transparent', 'text-gray-500');
activeTab.classList.add('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
}
document.addEventListener('DOMContentLoaded', function() {
+1 -1
View File
@@ -1 +1 @@
function showTab(e){document.querySelectorAll(".tab-panel").forEach(e=>e.classList.add("hidden")),document.querySelectorAll(".tab-btn").forEach(e=>e.classList.remove("active")),document.getElementById("panel-"+e).classList.remove("hidden"),document.getElementById("tab-"+e).classList.add("active")}document.addEventListener("DOMContentLoaded",function(){document.getElementById("bulk-ip-select")?.addEventListener("change",function(){document.getElementById("selected-ip-count").textContent=this.selectedOptions.length}),document.getElementById("bulk-tag-device-select")?.addEventListener("change",function(){document.getElementById("selected-tag-device-count").textContent=this.selectedOptions.length}),document.getElementById("bulk-subnet-select")?.addEventListener("change",function(){let e=this.value,t=document.getElementById("bulk-ip-select");if(!e){t.innerHTML='<option value="" disabled>Select a subnet first...</option>',document.getElementById("selected-ip-count").textContent="0";return}t.innerHTML='<option value="" disabled>Loading...</option>',fetch(`/get_available_ips?subnet_id=${e}`).then(e=>e.json()).then(e=>{t.innerHTML="",0===e.available_ips.length?t.innerHTML='<option value="" disabled>No available IPs in this subnet</option>':e.available_ips.forEach(e=>{let s=document.createElement("option");s.value=e.id,s.textContent=e.ip,t.appendChild(s)}),document.getElementById("selected-ip-count").textContent="0"}).catch(()=>{t.innerHTML='<option value="" disabled>Error loading IPs</option>'})}),document.getElementById("bulk-assign-ips-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("assign-ips-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/assign_ips",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';if(e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${e.success.length} IP(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.ip}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{let s=e.ip?` (${e.ip})`:"";t+=`<li>IP ID ${e.ip_id}${s}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t,e.success.length>0){let n=document.getElementById("bulk-subnet-select");n.value&&n.dispatchEvent(new Event("change"))}}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})}),document.getElementById("bulk-create-devices-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("create-devices-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/create_devices",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully created ${e.success.length} device(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.name}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} creation(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{t+=`<li>${e.name}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t,e.success.length>0&&setTimeout(()=>window.location.reload(),2e3)}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})}),document.getElementById("bulk-assign-tags-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("assign-tags-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/assign_tags",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${e.success.length} tag(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.device_name}: ${e.tag_name}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{t+=`<li>Device ID ${e.device_id}, Tag ID ${e.tag_id}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})})});
function showTab(e){document.querySelectorAll(".tab-panel").forEach(e=>e.classList.add("hidden")),document.querySelectorAll(".tab-btn").forEach(e=>{e.classList.remove("border-gray-600","text-gray-900","dark:text-gray-100"),e.classList.add("border-transparent","text-gray-500")}),document.getElementById("panel-"+e).classList.remove("hidden");let t=document.getElementById("tab-"+e);t.classList.remove("border-transparent","text-gray-500"),t.classList.add("border-gray-600","text-gray-900","dark:text-gray-100")}document.addEventListener("DOMContentLoaded",function(){document.getElementById("bulk-ip-select")?.addEventListener("change",function(){document.getElementById("selected-ip-count").textContent=this.selectedOptions.length}),document.getElementById("bulk-tag-device-select")?.addEventListener("change",function(){document.getElementById("selected-tag-device-count").textContent=this.selectedOptions.length}),document.getElementById("bulk-subnet-select")?.addEventListener("change",function(){let e=this.value,t=document.getElementById("bulk-ip-select");if(!e){t.innerHTML='<option value="" disabled>Select a subnet first...</option>',document.getElementById("selected-ip-count").textContent="0";return}t.innerHTML='<option value="" disabled>Loading...</option>',fetch(`/get_available_ips?subnet_id=${e}`).then(e=>e.json()).then(e=>{t.innerHTML="",0===e.available_ips.length?t.innerHTML='<option value="" disabled>No available IPs in this subnet</option>':e.available_ips.forEach(e=>{let s=document.createElement("option");s.value=e.id,s.textContent=e.ip,t.appendChild(s)}),document.getElementById("selected-ip-count").textContent="0"}).catch(()=>{t.innerHTML='<option value="" disabled>Error loading IPs</option>'})}),document.getElementById("bulk-assign-ips-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("assign-ips-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/assign_ips",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';if(e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${e.success.length} IP(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.ip}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{let s=e.ip?` (${e.ip})`:"";t+=`<li>IP ID ${e.ip_id}${s}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t,e.success.length>0){let n=document.getElementById("bulk-subnet-select");n.value&&n.dispatchEvent(new Event("change"))}}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})}),document.getElementById("bulk-create-devices-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("create-devices-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/create_devices",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully created ${e.success.length} device(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.name}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} creation(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{t+=`<li>${e.name}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t,e.success.length>0&&setTimeout(()=>window.location.reload(),2e3)}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})}),document.getElementById("bulk-assign-tags-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("assign-tags-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/assign_tags",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${e.success.length} tag(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.device_name}: ${e.tag_name}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{t+=`<li>Device ID ${e.device_id}, Tag ID ${e.tag_id}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})})});
+328
View File
@@ -0,0 +1,328 @@
// Custom Fields Management JavaScript
// Get initial tab from URL parameter or default to 'device'
const urlParams = new URLSearchParams(window.location.search);
let currentTab = urlParams.get('tab') || 'device';
if (currentTab !== 'device' && currentTab !== 'subnet') {
currentTab = 'device';
}
// Switch to the correct tab on page load
if (currentTab === 'subnet') {
switchTab('subnet');
} else {
// Ensure device tab is active on load
switchTab('device');
}
// Function to get current active tab
function getCurrentTab() {
return currentTab;
}
let fieldData = {};
// Tab switching
function switchTab(entityType) {
currentTab = entityType;
// Update tab buttons
document.getElementById('tab-device').classList.remove('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('tab-device').classList.add('border-transparent', 'text-gray-500');
document.getElementById('tab-subnet').classList.remove('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('tab-subnet').classList.add('border-transparent', 'text-gray-500');
if (entityType === 'device') {
document.getElementById('tab-device').classList.remove('border-transparent', 'text-gray-500');
document.getElementById('tab-device').classList.add('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('device-fields-tab').classList.remove('hidden');
document.getElementById('subnet-fields-tab').classList.add('hidden');
} else {
document.getElementById('tab-subnet').classList.remove('border-transparent', 'text-gray-500');
document.getElementById('tab-subnet').classList.add('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('device-fields-tab').classList.add('hidden');
document.getElementById('subnet-fields-tab').classList.remove('hidden');
}
// Update URL without reloading page
const newUrl = new URL(window.location);
newUrl.searchParams.set('tab', entityType);
window.history.pushState({}, '', newUrl);
}
// Show add field modal
function showAddFieldModal(entityType) {
// Determine the target entity type - prioritize explicit parameter, then read from DOM
let targetEntityType = entityType;
if (!targetEntityType) {
// Read from active tab button - check which tab has the active styling
const deviceTab = document.getElementById('tab-device');
const subnetTab = document.getElementById('tab-subnet');
if (deviceTab && deviceTab.classList.contains('border-gray-600')) {
targetEntityType = 'device';
} else if (subnetTab && subnetTab.classList.contains('border-gray-600')) {
targetEntityType = 'subnet';
} else {
// Fallback to currentTab variable
targetEntityType = currentTab || 'device';
}
}
// Ensure targetEntityType is valid
if (targetEntityType !== 'device' && targetEntityType !== 'subnet') {
targetEntityType = 'device';
}
// Ensure we're on the correct tab
if (targetEntityType !== currentTab) {
switchTab(targetEntityType);
}
document.getElementById('modal-title').textContent = 'Add Custom Field';
document.getElementById('form-action').value = 'add_field';
document.getElementById('form-field-id').value = '';
// Always set entity_type explicitly - double check it's set
const entityTypeInput = document.getElementById('form-entity-type');
entityTypeInput.value = targetEntityType;
// Debug: log to verify
console.log('Opening modal for entity type:', targetEntityType, 'currentTab:', currentTab, 'input value:', entityTypeInput.value);
// Reset form
document.getElementById('field-name').value = '';
document.getElementById('field-key').value = '';
document.getElementById('field-type').value = 'text';
document.getElementById('field-required').checked = false;
document.getElementById('field-default-value').value = '';
document.getElementById('field-help-text').value = '';
document.getElementById('field-display-order').value = '0';
document.getElementById('field-searchable').checked = false;
// Reset validation fields
document.getElementById('field-min-length').value = '';
document.getElementById('field-max-length').value = '';
document.getElementById('field-regex-pattern').value = '';
document.getElementById('field-min-value').value = '';
document.getElementById('field-max-value').value = '';
document.getElementById('field-select-options').value = '';
updateFieldTypeOptions();
document.getElementById('field-modal').classList.remove('hidden');
}
// Close field modal
function closeFieldModal() {
document.getElementById('field-modal').classList.add('hidden');
}
// Update field type options visibility
function updateFieldTypeOptions() {
const fieldType = document.getElementById('field-type').value;
// Hide all validation sections
document.getElementById('text-validation').classList.add('hidden');
document.getElementById('number-validation').classList.add('hidden');
document.getElementById('select-validation').classList.add('hidden');
// Show relevant validation section
if (fieldType === 'text' || fieldType === 'textarea') {
document.getElementById('text-validation').classList.remove('hidden');
} else if (fieldType === 'number' || fieldType === 'decimal') {
document.getElementById('number-validation').classList.remove('hidden');
} else if (fieldType === 'select') {
document.getElementById('select-validation').classList.remove('hidden');
}
}
// Auto-generate field key from name
function generateFieldKey(name) {
return name.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
// Edit field
function editField(fieldId, entityType) {
// Get field data from embedded JSON
const fieldsDataElement = document.getElementById('fields-data');
if (!fieldsDataElement) {
console.error('Fields data not found');
return;
}
try {
const fieldsData = JSON.parse(fieldsDataElement.textContent);
const fields = fieldsData[entityType] || [];
const field = fields.find(f => f.id === fieldId);
if (field) {
populateEditForm(field, entityType);
} else {
console.error('Field not found:', fieldId, entityType);
}
} catch (error) {
console.error('Error parsing fields data:', error);
}
}
function populateEditForm(field, entityType) {
document.getElementById('modal-title').textContent = 'Edit Custom Field';
document.getElementById('form-action').value = 'edit_field';
document.getElementById('form-field-id').value = field.id;
document.getElementById('form-entity-type').value = entityType;
document.getElementById('field-name').value = field.name || '';
document.getElementById('field-key').value = field.field_key || '';
document.getElementById('field-type').value = field.field_type || 'text';
document.getElementById('field-required').checked = field.required || false;
document.getElementById('field-default-value').value = field.default_value || '';
document.getElementById('field-help-text').value = field.help_text || '';
document.getElementById('field-display-order').value = field.display_order || 0;
document.getElementById('field-searchable').checked = field.searchable || false;
// Parse validation rules
let validationRules = {};
if (field.validation_rules) {
if (typeof field.validation_rules === 'string') {
try {
validationRules = JSON.parse(field.validation_rules);
} catch (e) {
validationRules = {};
}
} else {
validationRules = field.validation_rules;
}
}
// Populate validation fields
document.getElementById('field-min-length').value = validationRules.min_length || '';
document.getElementById('field-max-length').value = validationRules.max_length || '';
document.getElementById('field-regex-pattern').value = validationRules.regex_pattern || '';
document.getElementById('field-min-value').value = validationRules.min_value || '';
document.getElementById('field-max-value').value = validationRules.max_value || '';
if (validationRules.select_options) {
document.getElementById('field-select-options').value = validationRules.select_options.join(', ');
} else {
document.getElementById('field-select-options').value = '';
}
updateFieldTypeOptions();
document.getElementById('field-modal').classList.remove('hidden');
}
// Move field up/down
function moveField(entityType, fieldId, direction) {
// Get all fields for this entity type
const tbody = document.getElementById(`${entityType}-fields-tbody`);
const rows = Array.from(tbody.querySelectorAll('tr'));
const currentIndex = rows.findIndex(row => row.dataset.fieldId == fieldId);
if (currentIndex === -1) return;
let targetIndex;
if (direction === 'up' && currentIndex > 0) {
targetIndex = currentIndex - 1;
} else if (direction === 'down' && currentIndex < rows.length - 1) {
targetIndex = currentIndex + 1;
} else {
return;
}
// Swap rows
const currentRow = rows[currentIndex];
const targetRow = rows[targetIndex];
tbody.insertBefore(currentRow, direction === 'up' ? targetRow : targetRow.nextSibling);
// Update display orders and submit
const fieldOrders = {};
Array.from(tbody.querySelectorAll('tr')).forEach((row, index) => {
fieldOrders[row.dataset.fieldId] = index;
});
// Submit reorder
const form = document.createElement('form');
form.method = 'POST';
form.action = '/custom_fields';
const actionInput = document.createElement('input');
actionInput.type = 'hidden';
actionInput.name = 'action';
actionInput.value = 'reorder';
form.appendChild(actionInput);
const entityTypeInput = document.createElement('input');
entityTypeInput.type = 'hidden';
entityTypeInput.name = 'entity_type';
entityTypeInput.value = entityType;
form.appendChild(entityTypeInput);
const ordersInput = document.createElement('input');
ordersInput.type = 'hidden';
ordersInput.name = 'field_orders';
ordersInput.value = JSON.stringify(fieldOrders);
form.appendChild(ordersInput);
document.body.appendChild(form);
form.submit();
}
// Event listeners
document.addEventListener('DOMContentLoaded', function() {
// Auto-generate field key from name
const nameInput = document.getElementById('field-name');
const keyInput = document.getElementById('field-key');
if (nameInput && keyInput) {
nameInput.addEventListener('input', function() {
// Only auto-generate if key is empty or matches previous generated value
if (!keyInput.value || keyInput.dataset.autoGenerated === 'true') {
keyInput.value = generateFieldKey(this.value);
keyInput.dataset.autoGenerated = 'true';
}
});
keyInput.addEventListener('input', function() {
// Mark as manually edited
this.dataset.autoGenerated = 'false';
});
}
// Update field type options when type changes
const fieldTypeSelect = document.getElementById('field-type');
if (fieldTypeSelect) {
fieldTypeSelect.addEventListener('change', updateFieldTypeOptions);
}
// Ensure entity_type is set correctly before form submission
const fieldForm = document.getElementById('field-form');
if (fieldForm) {
fieldForm.addEventListener('submit', function(e) {
const entityTypeInput = document.getElementById('form-entity-type');
// Always ensure entity_type is set to currentTab
// This handles cases where the modal was opened without explicitly setting it
if (!entityTypeInput.value || entityTypeInput.value.trim() === '') {
entityTypeInput.value = currentTab;
console.log('Entity type was empty, setting to:', currentTab);
}
// Double-check it's a valid value
if (entityTypeInput.value !== 'device' && entityTypeInput.value !== 'subnet') {
entityTypeInput.value = currentTab;
console.log('Entity type was invalid, setting to currentTab:', currentTab);
}
console.log('Submitting form with entity_type:', entityTypeInput.value, 'currentTab:', currentTab);
});
}
});
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('field-modal');
if (event.target === modal) {
closeFieldModal();
}
}
File diff suppressed because one or more lines are too long