lanscape 1.4.4__py3-none-any.whl → 2.0.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. lanscape/__init__.py +9 -4
  2. lanscape/__main__.py +1 -0
  3. lanscape/{libraries → core}/app_scope.py +22 -3
  4. lanscape/{libraries → core}/decorators.py +88 -52
  5. lanscape/{libraries → core}/device_alive.py +4 -3
  6. lanscape/{libraries → core}/errors.py +1 -0
  7. lanscape/{libraries → core}/ip_parser.py +2 -1
  8. lanscape/{libraries → core}/logger.py +1 -0
  9. lanscape/{libraries → core}/mac_lookup.py +1 -0
  10. lanscape/{libraries → core}/net_tools.py +140 -46
  11. lanscape/{libraries → core}/port_manager.py +1 -0
  12. lanscape/{libraries → core}/runtime_args.py +1 -0
  13. lanscape/{libraries → core}/scan_config.py +104 -5
  14. lanscape/core/service_scan.py +205 -0
  15. lanscape/{libraries → core}/subnet_scan.py +19 -11
  16. lanscape/{libraries → core}/version_manager.py +3 -2
  17. lanscape/{libraries → core}/web_browser.py +1 -0
  18. lanscape/resources/mac_addresses/convert_csv.py +1 -0
  19. lanscape/resources/ports/convert_csv.py +1 -0
  20. lanscape/resources/services/definitions.jsonc +576 -400
  21. lanscape/ui/app.py +5 -4
  22. lanscape/ui/blueprints/__init__.py +2 -1
  23. lanscape/ui/blueprints/api/__init__.py +1 -0
  24. lanscape/ui/blueprints/api/port.py +2 -1
  25. lanscape/ui/blueprints/api/scan.py +2 -1
  26. lanscape/ui/blueprints/api/tools.py +5 -4
  27. lanscape/ui/blueprints/web/__init__.py +1 -0
  28. lanscape/ui/blueprints/web/routes.py +30 -2
  29. lanscape/ui/main.py +5 -4
  30. lanscape/ui/shutdown_handler.py +2 -1
  31. lanscape/ui/static/css/style.css +145 -2
  32. lanscape/ui/static/js/main.js +30 -2
  33. lanscape/ui/static/js/scan-config.js +39 -0
  34. lanscape/ui/templates/scan/config.html +43 -0
  35. lanscape/ui/templates/scan/device-detail.html +111 -0
  36. lanscape/ui/templates/scan/ip-table-row.html +12 -78
  37. lanscape/ui/templates/scan/ip-table.html +1 -1
  38. {lanscape-1.4.4.dist-info → lanscape-2.0.0a1.dist-info}/METADATA +7 -2
  39. lanscape-2.0.0a1.dist-info/RECORD +76 -0
  40. lanscape-2.0.0a1.dist-info/entry_points.txt +2 -0
  41. lanscape/libraries/service_scan.py +0 -50
  42. lanscape-1.4.4.dist-info/RECORD +0 -74
  43. /lanscape/{libraries → core}/__init__.py +0 -0
  44. {lanscape-1.4.4.dist-info → lanscape-2.0.0a1.dist-info}/WHEEL +0 -0
  45. {lanscape-1.4.4.dist-info → lanscape-2.0.0a1.dist-info}/licenses/LICENSE +0 -0
  46. {lanscape-1.4.4.dist-info → lanscape-2.0.0a1.dist-info}/top_level.txt +0 -0
lanscape/ui/app.py CHANGED
@@ -8,12 +8,12 @@ import logging
8
8
  from flask import Flask, render_template
9
9
  from lanscape.ui.blueprints.web import web_bp, routes # pylint: disable=unused-import
10
10
  from lanscape.ui.blueprints.api import api_bp, tools, port, scan # pylint: disable=unused-import
11
- from lanscape.libraries.runtime_args import RuntimeArgs, parse_args
12
- from lanscape.libraries.version_manager import (
11
+ from lanscape.core.runtime_args import RuntimeArgs, parse_args
12
+ from lanscape.core.version_manager import (
13
13
  is_update_available, get_installed_version, lookup_latest_version
14
14
  )
15
- from lanscape.libraries.app_scope import is_local_run
16
- from lanscape.libraries.net_tools import is_arp_supported
15
+ from lanscape.core.app_scope import is_local_run
16
+ from lanscape.core.net_tools import is_arp_supported
17
17
  from lanscape.ui.shutdown_handler import FlaskShutdownHandler
18
18
 
19
19
  app = Flask(
@@ -122,3 +122,4 @@ def start_webserver(args: RuntimeArgs) -> int:
122
122
  'use_reloader': args.reloader
123
123
  }
124
124
  app.run(**run_args)
125
+
@@ -1,10 +1,11 @@
1
1
  """Source for all things blueprint related in LANscape UI"""
2
2
  import logging
3
3
 
4
- from lanscape.libraries.subnet_scan import ScanManager
4
+ from lanscape.core.subnet_scan import ScanManager
5
5
 
6
6
  # defining here so blueprints can access the same
7
7
  # manager instance
8
8
  scan_manager = ScanManager()
9
9
 
10
10
  log = logging.getLogger('Blueprints')
11
+
@@ -3,3 +3,4 @@
3
3
  from flask import Blueprint
4
4
 
5
5
  api_bp = Blueprint('api', __name__)
6
+
@@ -4,7 +4,7 @@ Provides CRUD operations for managing port lists used in network scans.
4
4
  """
5
5
  from flask import request, jsonify
6
6
  from lanscape.ui.blueprints.api import api_bp
7
- from lanscape.libraries.port_manager import PortManager
7
+ from lanscape.core.port_manager import PortManager
8
8
 
9
9
  # Port Manager API
10
10
  ############################################
@@ -77,3 +77,4 @@ def delete_port_list(port_list):
77
77
  JSON response indicating success or failure
78
78
  """
79
79
  return jsonify(PortManager().delete_port_list(port_list))
80
+
@@ -9,7 +9,7 @@ import traceback
9
9
  from flask import request, jsonify
10
10
 
11
11
  from lanscape.ui.blueprints.api import api_bp
12
- from lanscape.libraries.subnet_scan import ScanConfig
12
+ from lanscape.core.subnet_scan import ScanConfig
13
13
  from lanscape.ui.blueprints import scan_manager
14
14
 
15
15
  # Subnet Scanner API
@@ -121,3 +121,4 @@ def get_scan_config():
121
121
  """
122
122
  data = request.get_json()
123
123
  return ScanConfig.from_dict(data)
124
+
@@ -5,10 +5,10 @@ API endpoints for subnet testing and listing.
5
5
  import traceback
6
6
  from flask import request, jsonify
7
7
  from lanscape.ui.blueprints.api import api_bp
8
- from lanscape.libraries.net_tools import get_all_network_subnets, is_arp_supported
9
- from lanscape.libraries.ip_parser import parse_ip_input
10
- from lanscape.libraries.errors import SubnetTooLargeError
11
- from lanscape.libraries.scan_config import DEFAULT_CONFIGS
8
+ from lanscape.core.net_tools import get_all_network_subnets, is_arp_supported
9
+ from lanscape.core.ip_parser import parse_ip_input
10
+ from lanscape.core.errors import SubnetTooLargeError
11
+ from lanscape.core.scan_config import DEFAULT_CONFIGS
12
12
 
13
13
 
14
14
  @api_bp.route('/api/tools/subnet/test')
@@ -69,3 +69,4 @@ def get_default_configs():
69
69
  configs[key] = config_dict
70
70
 
71
71
  return jsonify(configs)
72
+
@@ -5,3 +5,4 @@ Blueprint for web-related routes and views.
5
5
  from flask import Blueprint
6
6
 
7
7
  web_bp = Blueprint('web', __name__)
8
+
@@ -2,9 +2,9 @@
2
2
  Web blueprint routes for the LANscape application.
3
3
  Handles UI views including the main dashboard, scan results, error display, and exports.
4
4
  """
5
- from flask import render_template, request, redirect
5
+ from flask import render_template, request, redirect, url_for
6
6
  from lanscape.ui.blueprints.web import web_bp
7
- from lanscape.libraries.net_tools import (
7
+ from lanscape.core.net_tools import (
8
8
  get_all_network_subnets,
9
9
  smart_select_primary_subnet
10
10
  )
@@ -81,6 +81,33 @@ def view_errors(scan_id):
81
81
  return redirect('/')
82
82
 
83
83
 
84
+ @web_bp.route('/device/<scan_id>/<device_ip>')
85
+ def view_device(scan_id, device_ip):
86
+ """
87
+ Display detailed information about a specific device from a scan.
88
+
89
+ Args:
90
+ scan_id: Unique identifier for the scan
91
+ device_ip: IP address of the device to view
92
+
93
+ Returns:
94
+ Rendered device detail template or redirect to home if scan not found
95
+ """
96
+ if scanner := scan_manager.get_scan(scan_id):
97
+ devices = scanner.results.devices
98
+ device_info = next(
99
+ (device for device in devices if getattr(
100
+ device, 'ip', None) == device_ip), None)
101
+
102
+ if device_info:
103
+ return render_template('scan/device-detail.html', device=device_info, scan_id=scan_id)
104
+
105
+ log.debug(f'Device {device_ip} not found in scan {scan_id}')
106
+ return redirect(url_for('render_scan', scan_id=scan_id))
107
+ log.debug(f'Redirecting, scan {scan_id} doesnt exist in memory')
108
+ return redirect('/')
109
+
110
+
84
111
  @web_bp.route('/export/<scan_id>')
85
112
  def export_scan(scan_id):
86
113
  """
@@ -123,3 +150,4 @@ def app_info():
123
150
  Rendered info template
124
151
  """
125
152
  return render_template('info.html')
153
+
lanscape/ui/main.py CHANGED
@@ -9,10 +9,10 @@ import traceback
9
9
  import os
10
10
  import requests
11
11
 
12
- from lanscape.libraries.logger import configure_logging
13
- from lanscape.libraries.runtime_args import parse_args
14
- from lanscape.libraries.web_browser import open_webapp
15
- from lanscape.libraries.version_manager import get_installed_version, is_update_available
12
+ from lanscape.core.logger import configure_logging
13
+ from lanscape.core.runtime_args import parse_args
14
+ from lanscape.core.web_browser import open_webapp
15
+ from lanscape.core.version_manager import get_installed_version, is_update_available
16
16
  from lanscape.ui.app import start_webserver_daemon, start_webserver
17
17
  # do this so any logs generated on import are displayed
18
18
  args = parse_args()
@@ -136,3 +136,4 @@ def terminate():
136
136
 
137
137
  if __name__ == "__main__":
138
138
  main()
139
+
@@ -5,7 +5,7 @@ import os
5
5
  from flask import request
6
6
 
7
7
 
8
- from lanscape.libraries.runtime_args import parse_args
8
+ from lanscape.core.runtime_args import parse_args
9
9
 
10
10
 
11
11
  log = logging.getLogger('shutdown_handler')
@@ -55,3 +55,4 @@ class FlaskShutdownHandler:
55
55
  """Exits the application if a shutdown request has been made."""
56
56
  if self._exiting:
57
57
  os._exit(0)
58
+
@@ -555,6 +555,41 @@ input[type="range"] {
555
555
  color: var(--text-color);
556
556
  }
557
557
 
558
+ /* Service Strategy Select Styles */
559
+ .service-strategy-wrapper {
560
+ position: relative;
561
+ display: inline-block;
562
+ width: 60%; /* Narrower than port list */
563
+ }
564
+
565
+ .service-strategy {
566
+ position: relative;
567
+ background-color: var(--secondary-bg);
568
+ border: 1px solid var(--border-color);
569
+ color: var(--text-color);
570
+ padding: 10px;
571
+ cursor: pointer;
572
+ width: 100%;
573
+ height: 42px;
574
+ user-select: none;
575
+ appearance: none; /* Hide default arrow */
576
+ transition: all .2s ease-in-out;
577
+ }
578
+
579
+ .service-strategy:focus {
580
+ border-color: var(--primary-accent-hover);
581
+ outline: none;
582
+ }
583
+
584
+ .service-strategy-wrapper::after {
585
+ content: '▼';
586
+ position: absolute;
587
+ top: 10px;
588
+ right: 10px;
589
+ pointer-events: none;
590
+ color: var(--text-color);
591
+ }
592
+
558
593
 
559
594
 
560
595
  .text-color {
@@ -612,6 +647,35 @@ input[type="range"] {
612
647
  background-color: var(--primary-bg-accent);
613
648
  }
614
649
 
650
+ .table tbody tr td:has(.info-icon-container),
651
+ .table thead tr th.detail-col
652
+ {
653
+ width: 30px;
654
+ /*
655
+ background-color: var(--body-bg);
656
+ border: 1px solid var(--text-almost-hidden);
657
+ */
658
+ }
659
+ .table td:has(.info-icon-container) {
660
+ width: 30px;
661
+ text-align: center; /* horizontal center */
662
+ vertical-align: middle; /* vertical center inside the cell */
663
+ padding: 0; /* optional: remove extra padding */
664
+ }
665
+ .table td .info-icon-container {
666
+ display: flex;
667
+ justify-content: center; /* horizontal center */
668
+ align-items: center; /* vertical center */
669
+ height: 100%; /* ensure it takes full cell height */
670
+ }
671
+ .table td .info-icon-container .info-icon {
672
+ font-size: 1.2em;
673
+ color: var(--text-placeholder);
674
+ cursor: pointer;
675
+ }
676
+ .table td .info-icon:hover {
677
+ color: var(--text-color);
678
+ }
615
679
 
616
680
  /* Badge Styles */
617
681
  .badge-warning {
@@ -627,7 +691,7 @@ input[type="range"] {
627
691
  }
628
692
 
629
693
  .badge-info {
630
- background-color: var(--info-accent);
694
+ background-color: var(--primary-accent);
631
695
  }
632
696
 
633
697
  .badge-secondary {
@@ -835,4 +899,83 @@ html {
835
899
 
836
900
  }
837
901
 
838
- /* END overview container */
902
+ /* END overview container */
903
+
904
+ /* Device Modal Styles */
905
+ #device-modal {
906
+ --bs-modal-width: 750px;
907
+ }
908
+ #device-modal .modal-content {
909
+ background-color: var(--primary-bg);
910
+ }
911
+ #device-modal h6 {
912
+ color: var(--primary-accent);
913
+ }
914
+
915
+ /* Key/Value grid for overview */
916
+ #device-modal .kv-grid {
917
+ display: grid;
918
+ grid-template-columns: 180px 1fr;
919
+ column-gap: 12px;
920
+ row-gap: 8px;
921
+ align-items: center;
922
+ }
923
+ #device-modal .kv-label {
924
+ color: var(--text-placeholder);
925
+ text-align: right;
926
+ }
927
+ #device-modal .kv-value {
928
+ color: var(--text-color);
929
+ }
930
+
931
+ /* Port/service chips */
932
+ #device-modal .chip-group { margin-top: 4px; }
933
+ #device-modal .chip {
934
+ display: inline-flex;
935
+ align-items: center;
936
+ gap: 4px;
937
+ padding: 2px 8px;
938
+ margin: 2px;
939
+ font-size: .85rem;
940
+ background-color: var(--primary-bg-accent);
941
+ border: 1px solid var(--border-color);
942
+ border-radius: 999px;
943
+ color: var(--text-color);
944
+ }
945
+ #device-modal .chip .material-symbols-outlined {
946
+ font-size: 16px;
947
+ }
948
+
949
+ /* Services layout */
950
+ #device-modal .service-list { width: 100%; }
951
+ #device-modal .service-row {
952
+ display: flex;
953
+ align-items: flex-start;
954
+ gap: 8px;
955
+ padding: 6px 0;
956
+ border-bottom: 1px dashed var(--border-color);
957
+ }
958
+ #device-modal .service-row:last-child {
959
+ border-bottom: none;
960
+ }
961
+ #device-modal .service-name {
962
+ min-width: 140px;
963
+ color: var(--text-accent-color);
964
+ font-weight: 600;
965
+ }
966
+ #device-modal .service-ports { flex: 1; }
967
+
968
+ /* Errors */
969
+ #device-modal .error-list li {
970
+ margin-bottom: 4px;
971
+ }
972
+
973
+ /* Responsive tweaks */
974
+ @media screen and (max-width: 576px) {
975
+ #device-modal .kv-grid {
976
+ grid-template-columns: 130px 1fr;
977
+ }
978
+ #device-modal .service-name {
979
+ min-width: 110px;
980
+ }
981
+ }
@@ -1,5 +1,3 @@
1
-
2
-
3
1
  $(document).ready(function() {
4
2
  // Load port lists into the dropdown
5
3
  const scanId = getActiveScanId();
@@ -217,6 +215,36 @@ $(window).on('resize', function() {
217
215
  resizeIframe($('#ip-table-frame')[0]);
218
216
  });
219
217
 
218
+ function openDeviceDetail(deviceIp) {
219
+ try {
220
+ const scanId = getActiveScanId();
221
+ if (!scanId || !deviceIp) return;
222
+
223
+ const safeIp = encodeURIComponent(deviceIp.trim());
224
+
225
+ // Remove any existing modal instance to avoid duplicates
226
+ $('#device-modal').remove();
227
+
228
+ $.get(`/device/${scanId}/${safeIp}`, function(html) {
229
+ // Append modal HTML to the document
230
+ $('body').append(html);
231
+
232
+ // Show the modal
233
+ const $modal = $('#device-modal');
234
+ $modal.modal('show');
235
+
236
+ // Clean up after closing
237
+ $modal.on('hidden.bs.modal', function() {
238
+ $(this).remove();
239
+ });
240
+ }).fail(function() {
241
+ console.error('Failed to load device details');
242
+ });
243
+ } catch (e) {
244
+ console.error('Error opening device detail modal:', e);
245
+ }
246
+ }
247
+
220
248
 
221
249
 
222
250
 
@@ -13,6 +13,7 @@ $(document).ready(function() {
13
13
 
14
14
  $('#t_cnt_port_scan, #t_cnt_port_test').on('input', updatePortTotals);
15
15
  $('#ping_attempts, #ping_ping_count').on('input', updatePingTotals);
16
+ $('#task_scan_port_services').on('change', updateVisibility);
16
17
 
17
18
  // Lookup type toggles
18
19
  $('.lookup-type-input').on('change', onLookupTypeChanged);
@@ -43,6 +44,30 @@ function setScanConfig(configName) {
43
44
  $('#task_scan_ports').prop('checked', config.task_scan_ports);
44
45
  $('#task_scan_port_services').prop('checked', config.task_scan_port_services);
45
46
 
47
+ // port scan config
48
+ if (config.port_scan_config) {
49
+ $('#port_scan_timeout').val(config.port_scan_config.timeout);
50
+ $('#port_scan_retries').val(config.port_scan_config.retries);
51
+ $('#port_scan_retry_delay').val(config.port_scan_config.retry_delay);
52
+ } else {
53
+ // defaults if missing
54
+ $('#port_scan_timeout').val(1.0);
55
+ $('#port_scan_retries').val(0);
56
+ $('#port_scan_retry_delay').val(0.1);
57
+ }
58
+
59
+ // service config
60
+ if (config.service_scan_config) {
61
+ $('#service_lookup_type').val(config.service_scan_config.lookup_type || 'BASIC');
62
+ $('#service_timeout').val(config.service_scan_config.timeout);
63
+ $('#service_max_concurrent_probes').val(config.service_scan_config.max_concurrent_probes);
64
+ } else {
65
+ // defaults if missing
66
+ $('#service_lookup_type').val('BASIC');
67
+ $('#service_timeout').val(5.0);
68
+ $('#service_max_concurrent_probes').val(10);
69
+ }
70
+
46
71
  // lookup type (array of enum values as strings)
47
72
  setLookupTypeUI(config.lookup_type || []);
48
73
 
@@ -99,6 +124,16 @@ function getScanConfig() {
99
124
  poke_config: {
100
125
  attempts: parseInt($('#poke_attempts').val()),
101
126
  timeout: parseFloat($('#poke_timeout').val())
127
+ },
128
+ port_scan_config: {
129
+ timeout: parseFloat($('#port_scan_timeout').val()),
130
+ retries: parseInt($('#port_scan_retries').val()),
131
+ retry_delay: parseFloat($('#port_scan_retry_delay').val())
132
+ },
133
+ service_scan_config: {
134
+ timeout: parseFloat($('#service_timeout').val()),
135
+ lookup_type: $('#service_lookup_type').val(),
136
+ max_concurrent_probes: parseInt($('#service_max_concurrent_probes').val())
102
137
  }
103
138
  };
104
139
  }
@@ -168,6 +203,10 @@ function updateVisibility() {
168
203
  // Poke section only when POKE_THEN_ARP is selected
169
204
  const showPoke = types.has('POKE_THEN_ARP');
170
205
  toggleSection('#section-poke', showPoke);
206
+
207
+ // Service scan section visible only if stage enabled
208
+ const showService = $('#task_scan_port_services').is(':checked');
209
+ toggleSection('#section-service-scan', showService);
171
210
  }
172
211
 
173
212
  function toggleSection(selector, show) {
@@ -181,6 +181,49 @@
181
181
  </div>
182
182
  </div>
183
183
  </div>
184
+ <div class="row mt-2">
185
+ <div class="col">
186
+ <label for="port_scan_timeout" class="form-label">Port Timeout (sec)</label>
187
+ <input type="number" step="0.1" id="port_scan_timeout" class="form-control">
188
+ </div>
189
+ <div class="col">
190
+ <label for="port_scan_retries" class="form-label">Retries</label>
191
+ <input type="number" id="port_scan_retries" class="form-control">
192
+ </div>
193
+ <div class="col">
194
+ <label for="port_scan_retry_delay" class="form-label">Retry Delay (sec)</label>
195
+ <input type="number" step="0.1" id="port_scan_retry_delay" class="form-control">
196
+ </div>
197
+ </div>
198
+ </div>
199
+
200
+ <div id="section-service-scan" class="form-group mt-2 config-section div-hide">
201
+ <h6>Service Scanning</h6>
202
+ <div class="row">
203
+ <div class="col">
204
+ <label for="service_lookup_type" class="form-label">Strategy</label>
205
+ <div class="service-strategy-wrapper">
206
+ <select id="service_lookup_type" class="service-strategy">
207
+ <option value="LAZY">Lazy (few probes)</option>
208
+ <option value="BASIC">Basic (common probes)</option>
209
+ <option value="AGGRESSIVE">Aggressive (all probes)</option>
210
+ </select>
211
+ </div>
212
+ </div>
213
+ <div class="col">
214
+ <label for="service_timeout" class="form-label">Timeout (sec)</label>
215
+ <input type="number" step="0.1" id="service_timeout" class="form-control">
216
+ </div>
217
+ <div class="col">
218
+ <label for="service_max_concurrent_probes" class="form-label">Max Concurrent Probes</label>
219
+ <input type="number" id="service_max_concurrent_probes" class="form-control">
220
+ </div>
221
+ </div>
222
+ <div class="row mt-1">
223
+ <div class="col">
224
+ <small class="text-secondary">Attempts service identification on open ports using selected strategy.</small>
225
+ </div>
226
+ </div>
184
227
  </div>
185
228
 
186
229
  <div class="form-group mt-2 config-section">
@@ -0,0 +1,111 @@
1
+ <div class="modal fade" id="device-modal" tabindex="-1" aria-hidden="true">
2
+ <div class="modal-dialog">
3
+ <div class="modal-content">
4
+ <div class="modal-header border-secondary">
5
+ {% set ip = device.ip|default('Unknown IP') %}
6
+ {% set hostname = device.hostname|default('') %}
7
+ {% set manufacturer = device.manufacturer|default('') %}
8
+ {% set mac = device.mac_addr|default('') %}
9
+ {% set stage = device.stage|default('unknown') %}
10
+ {% set ports = device.ports|default([]) %}
11
+ {% set services = device.services|default({}) %}
12
+ {% set errors = device.caught_errors|default([]) %}
13
+ {% set stage_lower = (stage|string|lower) %}
14
+ {% set stage_badge_class = 'badge-success' if stage_lower == 'complete' else 'badge-warning' if stage_lower in ['running', 'in_progress'] else 'badge-secondary' %}
15
+ <h5 class="modal-title" id="device-modalLabel">
16
+ Device Details
17
+ <span class="text-secondary small ms-2">IP: {{ ip }}</span>
18
+ </h5>
19
+ <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
20
+ </div>
21
+
22
+ <div class="modal-body">
23
+ <div class="config-sections">
24
+ <div class="form-group mt-2 config-section">
25
+ <h6>Overview</h6>
26
+ <div class="kv-grid mt-1">
27
+ <div class="kv-label">IP Address</div>
28
+ <div class="kv-value">{{ ip }}</div>
29
+
30
+ <div class="kv-label">Hostname</div>
31
+ <div class="kv-value">{{ hostname|trim if hostname|default('')|trim else 'Unknown Hostname' }}</div>
32
+
33
+ <div class="kv-label">MAC Address</div>
34
+ <div class="kv-value">{{ mac|trim if mac|default('')|trim else 'Unknown MAC' }}</div>
35
+
36
+ <div class="kv-label">Manufacturer</div>
37
+ <div class="kv-value">{{ manufacturer|trim if manufacturer|default('')|trim else 'Unknown Manufacturer' }}</div>
38
+
39
+ <div class="kv-label">Stage</div>
40
+ <div class="kv-value">
41
+ <span class="badge {{ stage_badge_class }}">{{ stage|default('unknown')|capitalize }}</span>
42
+ </div>
43
+ </div>
44
+ </div>
45
+
46
+ <div class="form-group mt-2 config-section">
47
+ <h6>Open Ports</h6>
48
+ {% if ports and ports|length > 0 %}
49
+ <div class="chip-group mt-1">
50
+ {% for p in ports %}
51
+ <span class="chip" title="Port {{ p }}">
52
+ <span class="material-symbols-outlined">lan</span>{{ p }}
53
+ </span>
54
+ {% endfor %}
55
+ </div>
56
+ {% else %}
57
+ <div class="text-secondary">No open ports detected.</div>
58
+ {% endif %}
59
+ </div>
60
+
61
+ <div class="form-group mt-2 config-section">
62
+ <h6>Services</h6>
63
+ {% if services and services|length > 0 %}
64
+ <div class="service-list mt-1">
65
+ {% for svc, svc_ports in services|dictsort %}
66
+ <div class="service-row">
67
+ <div class="service-name">
68
+ <span class="material-symbols-outlined align-middle me-1">dns</span>{{ svc|default('Unknown') }}
69
+ </div>
70
+ <div class="service-ports">
71
+ {% set s_ports = svc_ports|default([]) %}
72
+ {% if s_ports and s_ports|length > 0 %}
73
+ {% for sp in s_ports %}
74
+ <span class="chip" title="{{ svc }} on {{ ip }}:{{ sp }}">{{ sp }}</span>
75
+ {% endfor %}
76
+ {% else %}
77
+ <span class="text-secondary">No ports</span>
78
+ {% endif %}
79
+ </div>
80
+ </div>
81
+ {% endfor %}
82
+ </div>
83
+ {% else %}
84
+ <div class="text-secondary">No service information available.</div>
85
+ {% endif %}
86
+ </div>
87
+
88
+ <div class="form-group mt-2 config-section">
89
+ <h6>Errors</h6>
90
+ {% if errors and errors|length > 0 %}
91
+ <ul class="list-unstyled error-list mt-1">
92
+ {% for err in errors %}
93
+ <li class="text-danger">
94
+ <span class="material-symbols-outlined align-middle me-1">error</span>
95
+ {{ err }}
96
+ </li>
97
+ {% endfor %}
98
+ </ul>
99
+ {% else %}
100
+ <div class="text-secondary">No errors captured.</div>
101
+ {% endif %}
102
+ </div>
103
+ </div>
104
+ </div>
105
+
106
+ <div class="modal-footer border-secondary">
107
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
108
+ </div>
109
+ </div>
110
+ </div>
111
+ </div>