Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

37 armis import sort function #45

Merged
merged 3 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/helper/armis.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,29 @@ def _remove_existing_devices(deviceList):

# flake8: noqa: E231
@armiscloud
def get_devices(acloud, site):
def get_devices(acloud, sites):
vlan_bl = ""
vlan_blacklist = armis_config['armis-server'].get('vlan_blacklist', '')
vlan_bl = f"!networkInterface:(vlans:{vlan_blacklist})" if vlan_blacklist else ""
sites = ','.join(f'"{site}"' for site in sites)
deviceList = acloud.get_devices(
asq=f'in:devices site:"{site.get("name")}" timeFrame:"7 Days" {vlan_bl}',
asq=f'in:devices site:{sites} timeFrame:"7 Days" {vlan_bl}',
fields_wanted=['id', 'ipAddress', 'macAddress', 'name', 'boundaries']
)
return _remove_existing_devices(deviceList)
# flake8: qa


def get_boundaries(deviceList):
unique_boundaries = set()
for device in deviceList:
boundaries = [b.strip() for b in device['boundaries'].split(',')]
unique_boundaries.update(boundaries)

return sorted(list(unique_boundaries))

def get_tenant_url():
return 'https://{}'.format(armis_config['armis-server']['tenant_hostname'])

def map_ids_to_names(selectedSiteIds, armisServerSites):
return [armisServerSites[id]['name'] for id in selectedSiteIds if id in armisServerSites]
14 changes: 8 additions & 6 deletions src/nac/subviews/armis.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from django.core.cache import cache
from django.shortcuts import render

from helper.armis import get_armis_sites, get_devices, get_tenant_url
from helper.armis import get_armis_sites, get_devices, get_tenant_url, get_boundaries, map_ids_to_names


class ArmisView(View):
Expand All @@ -35,14 +35,16 @@ def _get_context(self): # sets the site-context for armis_import.html, uses cac

def get(self, request, *args, **kwargs): # rendering the html base with site-context
context = self._get_context()
context['display'] = True
return render(request, self.template_name, context)

def post(self, request, *args, **kwargs): # gets site-id chosen in html-dropdown, gets Devices based on site-id, shows them via device-context
context = self._get_context()

selected_site = request.POST.get('site-id')
context['selected_site'] = selected_site if selected_site else ''
if selected_site:
context['devices'] = get_devices(context['armis_sites'][selected_site])
selected_sites = request.POST.getlist('site-ids[]')
context['display'] = False if selected_sites else True
context['selected_sites'] = selected_sites if selected_sites else ''
if selected_sites:
context['devices'] = get_devices(map_ids_to_names(selected_sites, context['armis_sites']))
context['boundaries'] = get_boundaries(context['devices'])

return render(request, self.template_name, context)
22 changes: 22 additions & 0 deletions src/static/default.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,26 @@ td {
margin-top: 5px;
white-space: nowrap;
z-index: 2000;
}
.checkbox-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); /* displays checkbox in 100pt width columns, as many as possible based on window size*/
gap: 10px 20px;
padding: 10px;
}

.checkbox-item {
display: flex;
align-items: center;
}

.checkbox-label {
margin-left: 8px;
cursor: pointer;
}

.custom-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
}
181 changes: 151 additions & 30 deletions src/templates/armis_import.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,24 @@
{% load crispy_forms_tags %}
{% block content %}
<div class="container">
<h1>Armis Import</h1>
<form method="POST" action="{% url 'armis_import' %}">
{% csrf_token %}
<select name="site-id">
<option value="" disabled {% if not selected_site %}selected{% endif %}>Choose site...</option>
{% for site_id, site_info in armis_sites.items %}
<option value="{{ site_id }}" {% if site_id == selected_site %}selected{% endif %}>
{{ site_info.name }}
</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-secondary btn-sm">select</button>
</form>
<h1>Armis Import</h1>
<button id="toggleSiteForm" class="btn btn-secondary btn-sm">
{% if display %}Hide Sites{% else %}Show Sites{% endif %}
</button>
<div id="formContainer" style="display: {% if display %}block{% else %}none{% endif %}">
<form id="siteSubmitForm" method="POST" action="{% url 'armis_import' %}">
{% csrf_token %}
<div class="checkbox-container">
{% for site_id, site_info in armis_sites.items %}
<div class="checkbox-item">
<input type="checkbox" id="site-{{ site_id }}" name="site-ids[]" value="{{ site_id }}" class="custom-checkbox">
<label for="site-{{ site_id }}" class="checkbox-label">{{ site_info.name }}</label>
</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-secondary btn-sm">Select</button>
</form>
</div>
{%if devices%}
<div id="pagination">
<button id="prevPage" class="link-button">
Expand Down Expand Up @@ -64,18 +69,38 @@ <h1>Armis Import</h1>
color: blue;
cursor: pointer;
}
.sort-icon {
cursor: pointer;
user-select: none;
}
</style>
<tr>
<th scope="col">Device Name</th>
<th scope="col">MAC Address</th>
<th scope="col">IP Address</th>
<th scope="col">Boundaries</th>
<th scope="col">
<input type="text" id="searchInput" placeholder="Search Name, IP, or MAC">
</th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<th scope="col">
<select id="boundaryFilter">
<option value="">Filter Boundaries / All</option>
{% for boundary in boundaries %}
<option value="{{ boundary }}">{{ boundary }}</option>
{% endfor %}
</select>
</th>
<th scope="col"></th>
</tr>
<tr>
<th scope="col">Device Name <span class="sort-icon" data-column="name">▲</span></th>
<th scope="col">MAC Address <span class="sort-icon" data-column="macAddress">▲</span></th>
<th scope="col">IP Address <span class="sort-icon" data-column="ipAddress">▲</span></th>
<th scope="col">Boundaries</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>

<div id="detailModal" class="modal">
<div class="modal-content">
Expand All @@ -101,13 +126,112 @@ <h2 id="modalTitle"></h2>
},
{% endfor %}
];
let filteredData = [...data]; // copies array to prevent changes on original data
let sortOrder = 'asc';
let searchTerm = '';

document.getElementById('toggleSiteForm').addEventListener('click', function() {
var formContainer = document.getElementById('formContainer');
if (formContainer.style.display === 'none') {
formContainer.style.display = 'block';
this.textContent = 'Hide Sites';
} else {
formContainer.style.display = 'none';
this.textContent = 'Show Sites';
}
});
function setupFilters() {
const boundaryFilter = document.getElementById("boundaryFilter");
const searchInput = document.getElementById("searchInput");

boundaryFilter.addEventListener("change", applyFilters); // calls applyFilter function on select
searchInput.addEventListener("input", applyFilters); // calls applyFilter function on every input
}

function setupSorting() {
const sortIcons = document.querySelectorAll('.sort-icon'); //Get all html-elements that are sort-elements
sortIcons.forEach(icon => {
icon.addEventListener('click', () => {
const column = icon.getAttribute('data-column'); //determines which column to order
sortData(column);
});
});
}

function sortData(column) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; //switches asc to desc or desc to asc for every click so user can switch between order

filteredData.sort((a, b) => { // sorts data-array by providing a function that returns <0, 0, >0 for specific comparisons
const valueA = a[column];
const valueB = b[column];

if (valueA === "None" && valueB === "None") return 0;
if (valueA === "None") return sortOrder === 'asc' ? -1 : 1; // short for if (sortOrder === 'asc') { return -1;} else {return 1;}
if (valueB === "None") return sortOrder === 'asc' ? 1 : -1;

if (column === 'macAddress' || column === 'ipAddress') {
return compareAddresses(valueA, valueB);
}

return sortOrder === 'asc' ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA); // returns 0,1,-1 for string comparison
});

currentPage = 1;
displayTable(currentPage);
updateSortIcons(column);
}

function compareAddresses(addrA, addrB) {
const partsA = addrA.split(/[.:]/).map(part => parseInt(part, 16) || 0); //splits mac or ip Adress into integer blocks
const partsB = addrB.split(/[.:]/).map(part => parseInt(part, 16) || 0);

for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
if (partsA[i] === undefined) return sortOrder === 'asc' ? -1 : 1;
if (partsB[i] === undefined) return sortOrder === 'asc' ? 1 : -1;
if (partsA[i] !== partsB[i]) {
return sortOrder === 'asc'
? partsA[i] - partsB[i] //return <0 if block of A is smaller than block of B -> A comes before B
: partsB[i] - partsA[i];
}
}
return 0; //mac or IPAddress are equal
}

function updateSortIcons(sortedColumn) { // switches icon so user knows if order is asc oder desc
const sortIcons = document.querySelectorAll('.sort-icon');
sortIcons.forEach(icon => {
const column = icon.getAttribute('data-column');
if (column === sortedColumn) {
icon.textContent = sortOrder === 'asc' ? '▲' : '▼';
} else {
icon.textContent = '▲';
}
});
}
function applyFilters() {
const selectedBoundary = document.getElementById("boundaryFilter").value;
searchTerm = document.getElementById("searchInput").value.toLowerCase();

filteredData = data.filter(function(device) { // filters for devices where the function returns true
const matchesBoundary = !selectedBoundary || device.boundaries.split(',').map(b => b.trim()).includes(selectedBoundary); // sets selectedBoundary to True if boundary isnt set,
const matchesSearch = device.name.toLowerCase().startsWith(searchTerm) || device.name.toLowerCase().includes(searchTerm) ||
device.ipAddress.toLowerCase().replace(/[^0-9]/g, '').startsWith(searchTerm.replace(/[^a-z0-9]/g, '') || false) || // replace everything that is not in 0-9 range
device.macAddress.toLowerCase().replace(/[^a-f0-9]/g, '').startsWith(searchTerm.replace(/[^a-z0-9]/g, '') || false); // replace everything that is not in hex (a-f,0-9) range
return matchesBoundary && matchesSearch; // and-Operator so devices can get filtered by boundary AND searchTerm, otherwise it would filter for either one
});

currentPage = 1;
displayTable(currentPage);
}

function displayTable(page) {

const tableBody = document.querySelector("#deviceTable tbody");
tableBody.innerHTML = "";

const start = (page - 1) * itemsPerPage;
const end = start + itemsPerPage;
const pageData = data.slice(start, end);
const pageData = filteredData.slice(start, end);

pageData.forEach((device, index) => {
const row = tableBody.insertRow();
Expand Down Expand Up @@ -148,7 +272,7 @@ <h2 id="modalTitle"></h2>
});

document.getElementById("currentPage").textContent = page;
document.getElementById("totalPages").textContent = Math.ceil(data.length / itemsPerPage);
document.getElementById("totalPages").textContent = Math.ceil(filteredData.length / itemsPerPage);
}

function showDetails(title, addresses) {
Expand Down Expand Up @@ -205,10 +329,7 @@ <h2 id="modalTitle"></h2>
displayTable(currentPage);
setupPagination();
setupModal();
setupFilters();
setupSorting();
</script>
{% endblock content %}




#Tests schreiben, prüfen wegen unique
{% endblock content %}
7 changes: 1 addition & 6 deletions src/tests/test_helper/test_armis.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ def mock_config():
}


@pytest.fixture
def mock_armis_cloud():
return MagicMock()


@patch('helper.armis.armis_config', new_callable=MagicMock)
@patch('helper.armis.ArmisCloud')
def test_armiscloud_decorator(mock_armis_cloud, mock_armis_config, mock_config):
Expand Down Expand Up @@ -94,7 +89,7 @@ def test_get_devices(mock_remove_existing_devices, mock_config):

# manually call original function without decorator to prevent argument error
with patch('helper.armis.armis_config', mock_config):
result = get_devices.__wrapped__(mock_armis_cloud, {'name': 'TestSite'})
result = get_devices.__wrapped__(mock_armis_cloud, {'TestSite'})

assert result == mock_devices
mock_armis_cloud.get_devices.assert_called_once_with(
Expand Down
15 changes: 9 additions & 6 deletions src/tests/test_views/test_armis_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,21 @@ def test_get(self, mock_render, mock_get_context, armis_view, rf):
def test_post_with_site(self, mock_render, mock_get_devices, mock_get_context, armis_view, rf):
mock_context = {'armis_sites': {'1': {'name': 'Site1'}}}
mock_get_context.return_value = mock_context
mock_devices = [{'name': 'Device1'}, {'name': 'Device2'}]
mock_devices = [{'name': 'Device1', 'boundaries': 'boundary1'}, {'name': 'Device2', 'boundaries': 'boundary2'}]
mock_get_devices.return_value = mock_devices

request = rf.post('/armis/', {'site-id': '1'})
request = rf.post('/armis/', {'site-ids[]': '1'})
armis_view.post(request)

expected_context = {
'armis_sites': {'1': {'name': 'Site1'}},
'selected_site': '1',
'devices': mock_devices
'display': False,
'selected_sites': ['1'],
'devices': mock_devices,
'boundaries': ['boundary1', 'boundary2']
}
mock_render.assert_called_once_with(request, armis_view.template_name, expected_context)
mock_get_devices.assert_called_once_with({'name': 'Site1'})
mock_get_devices.assert_called_once_with(['Site1'])

@patch('nac.subviews.armis.ArmisView._get_context')
@patch('nac.subviews.armis.render')
Expand All @@ -75,6 +77,7 @@ def test_post_without_site(self, mock_render, mock_get_context, armis_view, rf):

expected_context = {
'armis_sites': {'1': {'name': 'Site1'}},
'selected_site': ''
'display': True,
'selected_sites': ''
}
mock_render.assert_called_once_with(request, armis_view.template_name, expected_context)