lanscape 1.3.8a1__py3-none-any.whl → 2.4.0a2__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.

Potentially problematic release.


This version of lanscape might be problematic. Click here for more details.

Files changed (58) hide show
  1. lanscape/__init__.py +8 -4
  2. lanscape/{libraries → core}/app_scope.py +21 -3
  3. lanscape/core/decorators.py +231 -0
  4. lanscape/{libraries → core}/device_alive.py +83 -16
  5. lanscape/{libraries → core}/ip_parser.py +2 -26
  6. lanscape/{libraries → core}/net_tools.py +209 -66
  7. lanscape/{libraries → core}/runtime_args.py +6 -0
  8. lanscape/{libraries → core}/scan_config.py +103 -5
  9. lanscape/core/service_scan.py +222 -0
  10. lanscape/{libraries → core}/subnet_scan.py +30 -14
  11. lanscape/{libraries → core}/version_manager.py +15 -17
  12. lanscape/resources/ports/test_port_list_scan.json +4 -0
  13. lanscape/resources/services/definitions.jsonc +576 -400
  14. lanscape/ui/app.py +17 -5
  15. lanscape/ui/blueprints/__init__.py +1 -1
  16. lanscape/ui/blueprints/api/port.py +15 -1
  17. lanscape/ui/blueprints/api/scan.py +1 -1
  18. lanscape/ui/blueprints/api/tools.py +4 -4
  19. lanscape/ui/blueprints/web/routes.py +29 -2
  20. lanscape/ui/main.py +46 -19
  21. lanscape/ui/shutdown_handler.py +2 -2
  22. lanscape/ui/static/css/style.css +186 -20
  23. lanscape/ui/static/js/core.js +14 -0
  24. lanscape/ui/static/js/main.js +30 -2
  25. lanscape/ui/static/js/quietReload.js +3 -0
  26. lanscape/ui/static/js/scan-config.js +56 -6
  27. lanscape/ui/templates/base.html +6 -8
  28. lanscape/ui/templates/core/head.html +1 -1
  29. lanscape/ui/templates/info.html +20 -5
  30. lanscape/ui/templates/main.html +33 -36
  31. lanscape/ui/templates/scan/config.html +214 -176
  32. lanscape/ui/templates/scan/device-detail.html +111 -0
  33. lanscape/ui/templates/scan/ip-table-row.html +17 -83
  34. lanscape/ui/templates/scan/ip-table.html +5 -5
  35. lanscape/ui/ws/__init__.py +31 -0
  36. lanscape/ui/ws/delta.py +170 -0
  37. lanscape/ui/ws/handlers/__init__.py +20 -0
  38. lanscape/ui/ws/handlers/base.py +145 -0
  39. lanscape/ui/ws/handlers/port.py +184 -0
  40. lanscape/ui/ws/handlers/scan.py +352 -0
  41. lanscape/ui/ws/handlers/tools.py +145 -0
  42. lanscape/ui/ws/protocol.py +86 -0
  43. lanscape/ui/ws/server.py +375 -0
  44. {lanscape-1.3.8a1.dist-info → lanscape-2.4.0a2.dist-info}/METADATA +18 -3
  45. lanscape-2.4.0a2.dist-info/RECORD +85 -0
  46. {lanscape-1.3.8a1.dist-info → lanscape-2.4.0a2.dist-info}/WHEEL +1 -1
  47. lanscape-2.4.0a2.dist-info/entry_points.txt +2 -0
  48. lanscape/libraries/decorators.py +0 -170
  49. lanscape/libraries/service_scan.py +0 -50
  50. lanscape/libraries/web_browser.py +0 -210
  51. lanscape-1.3.8a1.dist-info/RECORD +0 -74
  52. /lanscape/{libraries → core}/__init__.py +0 -0
  53. /lanscape/{libraries → core}/errors.py +0 -0
  54. /lanscape/{libraries → core}/logger.py +0 -0
  55. /lanscape/{libraries → core}/mac_lookup.py +0 -0
  56. /lanscape/{libraries → core}/port_manager.py +0 -0
  57. {lanscape-1.3.8a1.dist-info → lanscape-2.4.0a2.dist-info}/licenses/LICENSE +0 -0
  58. {lanscape-1.3.8a1.dist-info → lanscape-2.4.0a2.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(
@@ -51,6 +51,18 @@ app.jinja_env.filters['is_substring_in_values'] = is_substring_in_values
51
51
  ################################
52
52
 
53
53
 
54
+ def get_runtime_args_safe():
55
+ """
56
+ Safely get runtime args, returning empty dict if parsing fails.
57
+ This prevents conflicts when the module is imported during testing.
58
+ """
59
+ try:
60
+ return vars(parse_args())
61
+ except SystemExit:
62
+ # This happens when pytest tries to import the module
63
+ return {}
64
+
65
+
54
66
  def set_global_safe(key: str, value):
55
67
  """ Safely set global vars without worrying about an exception """
56
68
  app_globals = app.jinja_env.globals
@@ -73,7 +85,7 @@ def set_global_safe(key: str, value):
73
85
  set_global_safe('app_version', get_installed_version)
74
86
  set_global_safe('update_available', is_update_available)
75
87
  set_global_safe('latest_version', lookup_latest_version)
76
- set_global_safe('runtime_args', vars(parse_args()))
88
+ set_global_safe('runtime_args', get_runtime_args_safe)
77
89
  set_global_safe('is_local', is_local_run)
78
90
  set_global_safe('is_arp_supported', is_arp_supported)
79
91
 
@@ -1,7 +1,7 @@
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
@@ -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
  ############################################
@@ -21,6 +21,20 @@ def get_port_lists():
21
21
  return jsonify(PortManager().get_port_lists())
22
22
 
23
23
 
24
+ @api_bp.route('/api/port/list/summary', methods=['GET'])
25
+ def get_port_lists_summary():
26
+ """Get port list names with their port counts."""
27
+ manager = PortManager()
28
+ summaries = []
29
+ for name in manager.get_port_lists():
30
+ ports = manager.get_port_list(name) or {}
31
+ summaries.append({
32
+ 'name': name,
33
+ 'count': len(ports)
34
+ })
35
+ return jsonify(summaries)
36
+
37
+
24
38
  @api_bp.route('/api/port/list/<port_list>', methods=['GET'])
25
39
  def get_port_list(port_list):
26
40
  """
@@ -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
@@ -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')
@@ -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
  """
lanscape/ui/main.py CHANGED
@@ -1,19 +1,22 @@
1
1
  """Main entry point for the LANscape application when running as a module."""
2
2
  import socket
3
3
 
4
-
5
- import threading
6
4
  import time
7
5
  import logging
8
6
  import traceback
9
7
  import os
8
+ from subprocess import Popen
9
+ import webbrowser
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 pwa_launcher import open_pwa, ChromiumNotFoundError
13
+
14
+
15
+ from lanscape.core.logger import configure_logging
16
+ from lanscape.core.runtime_args import parse_args
17
+ from lanscape.core.version_manager import get_installed_version, is_update_available
16
18
  from lanscape.ui.app import start_webserver_daemon, start_webserver
19
+ from lanscape.ui.ws.server import run_server
17
20
  # do this so any logs generated on import are displayed
18
21
  args = parse_args()
19
22
  configure_logging(args.loglevel, args.logfile, args.flask_logging)
@@ -46,6 +49,11 @@ def _main():
46
49
  else:
47
50
  log.info('Flask reloaded app.')
48
51
 
52
+ # Check if WebSocket server mode is requested
53
+ if args.ws_server:
54
+ start_websocket_server()
55
+ return
56
+
49
57
  args.port = get_valid_port(args.port)
50
58
 
51
59
  try:
@@ -70,7 +78,23 @@ def try_check_update():
70
78
  log.warning('Unable to check for updates.')
71
79
 
72
80
 
73
- def open_browser(url: str, wait=2) -> bool:
81
+ def start_websocket_server():
82
+ """Start the WebSocket server."""
83
+ args.ws_port = get_valid_port(args.ws_port)
84
+ log.info(f'Starting WebSocket server on port {args.ws_port}')
85
+ log.info(f'React UI should connect to ws://localhost:{args.ws_port}')
86
+
87
+ try:
88
+ run_server(host='0.0.0.0', port=args.ws_port)
89
+ except KeyboardInterrupt:
90
+ log.info('WebSocket server stopped by user')
91
+ except Exception as e:
92
+ log.critical(f'WebSocket server failed: {e}')
93
+ log.debug(traceback.format_exc())
94
+ raise
95
+
96
+
97
+ def open_browser(url: str, wait=2) -> Popen | None:
74
98
  """
75
99
  Open a browser window to the specified
76
100
  url after waiting for the server to start
@@ -78,12 +102,18 @@ def open_browser(url: str, wait=2) -> bool:
78
102
  try:
79
103
  time.sleep(wait)
80
104
  log.info(f'Starting UI - http://127.0.0.1:{args.port}')
81
- return open_webapp(url)
82
-
105
+ return open_pwa(url)
106
+
107
+ except ChromiumNotFoundError:
108
+ success = webbrowser.open(url)
109
+ if success:
110
+ log.warning("Chromium browser not found. Falling back to default web browser.")
111
+ else:
112
+ log.warning(f"Cannot find any web browser. LANScape UI running on {url}")
83
113
  except BaseException:
84
114
  log.debug(traceback.format_exc())
85
115
  log.info(f'Unable to open web browser, server running on {url}')
86
- return False
116
+ return None
87
117
 
88
118
 
89
119
  def start_webserver_ui():
@@ -97,19 +127,16 @@ def start_webserver_ui():
97
127
  # if it was, dont open the browser again
98
128
  log.info('Opening UI as daemon')
99
129
  if not IS_FLASK_RELOAD:
100
- threading.Thread(
101
- target=open_browser,
102
- args=(uri,),
103
- daemon=True
104
- ).start()
130
+ open_browser(uri)
105
131
  start_webserver(args)
106
132
  else:
107
133
  flask_thread = start_webserver_daemon(args)
108
- app_closed = open_browser(uri)
134
+ proc = open_browser(uri)
135
+ if proc:
136
+ app_closed = proc.wait()
137
+ else:
138
+ app_closed = False
109
139
 
110
- # depending on env, open_browser may or
111
- # may not be coupled with the closure of UI
112
- # (if in browser tab, it's uncoupled)
113
140
  if not app_closed or args.persistent:
114
141
  # not doing a direct join so i can still
115
142
  # terminate the app with ctrl+c
@@ -5,10 +5,10 @@ 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
- log = logging.getLogger('shutdown_handler')
11
+ log = logging.getLogger('ShutdownHandler')
12
12
 
13
13
 
14
14
  class FlaskShutdownHandler:
@@ -34,6 +34,9 @@
34
34
  --danger-border-color: #922; /* Bold red for danger borders */
35
35
 
36
36
  --footer-height: 25px;
37
+
38
+ --fa-primary-color: var(--text-accent-color);
39
+ --fa-secondary-color: var(--text-placeholder);
37
40
  }
38
41
 
39
42
  body {
@@ -63,13 +66,16 @@ body:has(.submodule) footer {
63
66
 
64
67
  #header {
65
68
  background-color: var(--primary-bg);
66
- padding: 8px 20px;
69
+ padding: 8px 2%;
67
70
  margin: 0;
68
71
  display: block;
69
72
  position: relative;
70
73
  box-shadow: 0 0 10px var(--box-shadow);
71
74
  width: 100vw;
72
75
  }
76
+ #header .title {
77
+ font-size: 36px;
78
+ }
73
79
 
74
80
  footer {
75
81
  position: sticky;
@@ -115,6 +121,14 @@ hr {
115
121
  border-color: var(--border-color);
116
122
  margin: 20px 0;
117
123
  }
124
+
125
+ .config-sections > .config-section {
126
+ border-top: 1px solid var(--border-color);
127
+ margin-top: 20px;
128
+ padding-top: 20px;
129
+ padding-bottom: 20px;
130
+ }
131
+
118
132
  h1.title{
119
133
  cursor: pointer;
120
134
  margin: 0;
@@ -152,7 +166,12 @@ details {
152
166
 
153
167
 
154
168
  #scan-form {
155
- width: 500px;
169
+ width: 60vw;
170
+ margin: 0;
171
+ min-width: 300px;
172
+ max-width: 700px;
173
+ }
174
+ #scan-form .form-group {
156
175
  margin: 0;
157
176
  }
158
177
  #scan-form label {
@@ -236,7 +255,7 @@ details {
236
255
  transition: all .5s ease-in-out
237
256
  }
238
257
 
239
- #app-actions a .material-symbols-outlined {
258
+ #app-actions a .fa-solid {
240
259
  font-size: inherit
241
260
  }
242
261
  #app-actions {
@@ -249,9 +268,12 @@ details {
249
268
  color: var(--text-color);
250
269
  text-decoration: none;
251
270
  cursor: pointer;
271
+ --fa-primary-color: var(--text-accent-color);
272
+ --fa-secondary-color: var(--text-placeholder);
252
273
  }
253
- #app-actions a:hover {
254
- color: var(--text-placeholder)
274
+ #app-actions a i:hover {
275
+ --fa-primary-color: var(--text-color);
276
+ --fa-secondary-color: var(--text-accent-color);
255
277
  }
256
278
 
257
279
  #power-button {
@@ -266,10 +288,10 @@ details {
266
288
  display: flex;
267
289
  justify-content: space-around;
268
290
  align-items: center;
269
- a {
270
- text-decoration: none;
271
- margin: 0 3px;
272
- }
291
+ }
292
+
293
+ #scan-actions i {
294
+ font-size: 20px;
273
295
  }
274
296
 
275
297
  #advanced-modal {
@@ -290,6 +312,9 @@ details {
290
312
  }
291
313
  #advanced-modal label {
292
314
  font-size: 12px;
315
+ text-overflow: ellipsis;
316
+ overflow: hidden;
317
+ white-space: nowrap;
293
318
  }
294
319
  #advanced-modal .form-check {
295
320
  width: fit-content;
@@ -433,8 +458,10 @@ button {
433
458
 
434
459
  #scan-form #scan-submit {
435
460
  border: none;
436
- padding: 10px 20px;
437
- margin-top: 15px;
461
+ padding: 10px 3%;
462
+ margin: 0 15px;
463
+ min-width: 55px;
464
+ height: 42px;
438
465
  }
439
466
 
440
467
  /* Button Styling */
@@ -547,6 +574,41 @@ input[type="range"] {
547
574
  color: var(--text-color);
548
575
  }
549
576
 
577
+ /* Service Strategy Select Styles */
578
+ .service-strategy-wrapper {
579
+ position: relative;
580
+ display: block;
581
+ width: 100%;
582
+ }
583
+
584
+ .service-strategy {
585
+ position: relative;
586
+ background-color: var(--secondary-bg);
587
+ border: 1px solid var(--border-color);
588
+ color: var(--text-color);
589
+ padding: 10px;
590
+ cursor: pointer;
591
+ width: 100%;
592
+ height: 42px;
593
+ user-select: none;
594
+ appearance: none; /* Hide default arrow */
595
+ transition: all .2s ease-in-out;
596
+ }
597
+
598
+ .service-strategy:focus {
599
+ border-color: var(--primary-accent-hover);
600
+ outline: none;
601
+ }
602
+
603
+ .service-strategy-wrapper::after {
604
+ content: '▼';
605
+ position: absolute;
606
+ top: 10px;
607
+ right: 10px;
608
+ pointer-events: none;
609
+ color: var(--text-color);
610
+ }
611
+
550
612
 
551
613
 
552
614
  .text-color {
@@ -604,6 +666,31 @@ input[type="range"] {
604
666
  background-color: var(--primary-bg-accent);
605
667
  }
606
668
 
669
+ .table tbody tr td:has(.info-icon-container),
670
+ .table thead tr th.detail-col
671
+ {
672
+ width: 30px;
673
+ }
674
+ .table td:has(.info-icon-container) {
675
+ width: 30px;
676
+ text-align: center; /* horizontal center */
677
+ vertical-align: middle; /* vertical center inside the cell */
678
+ padding: 0; /* optional: remove extra padding */
679
+ }
680
+ .table td .info-icon-container {
681
+ display: flex;
682
+ justify-content: center; /* horizontal center */
683
+ align-items: center; /* vertical center */
684
+ height: 100%; /* ensure it takes full cell height */
685
+ }
686
+ .table td .info-icon-container .info-icon {
687
+ font-size: 1.2em;
688
+ color: var(--text-placeholder);
689
+ cursor: pointer;
690
+ }
691
+ .table td .info-icon:hover {
692
+ color: var(--text-color);
693
+ }
607
694
 
608
695
  /* Badge Styles */
609
696
  .badge-warning {
@@ -619,7 +706,7 @@ input[type="range"] {
619
706
  }
620
707
 
621
708
  .badge-info {
622
- background-color: var(--info-accent);
709
+ background-color: var(--primary-accent);
623
710
  }
624
711
 
625
712
  .badge-secondary {
@@ -631,6 +718,12 @@ input[type="range"] {
631
718
  span.alt {
632
719
  color: var(--text-accent-color);
633
720
  }
721
+ span.no-wrap {
722
+ white-space: nowrap;
723
+ overflow: hidden;
724
+ text-overflow: ellipsis;
725
+ display: block;
726
+ }
634
727
  .colorful-buttons a{
635
728
  margin:2px;
636
729
  color: var(--text-color);
@@ -724,13 +817,7 @@ html {
724
817
  }
725
818
 
726
819
 
727
- .material-symbols-outlined {
728
- font-variation-settings:
729
- 'FILL' 0,
730
- 'wght' 400,
731
- 'GRAD' 0,
732
- 'opsz' 24
733
- }
820
+ /* FontAwesome Solid Icon Styling */
734
821
 
735
822
  #shutdown-sub-sub {
736
823
  color: var(--text-almost-hidden);
@@ -827,4 +914,83 @@ html {
827
914
 
828
915
  }
829
916
 
830
- /* END overview container */
917
+ /* END overview container */
918
+
919
+ /* Device Modal Styles */
920
+ #device-modal {
921
+ --bs-modal-width: 750px;
922
+ }
923
+ #device-modal .modal-content {
924
+ background-color: var(--primary-bg);
925
+ }
926
+ #device-modal h6 {
927
+ color: var(--primary-accent);
928
+ }
929
+
930
+ /* Key/Value grid for overview */
931
+ #device-modal .kv-grid {
932
+ display: grid;
933
+ grid-template-columns: 180px 1fr;
934
+ column-gap: 12px;
935
+ row-gap: 8px;
936
+ align-items: center;
937
+ }
938
+ #device-modal .kv-label {
939
+ color: var(--text-placeholder);
940
+ text-align: right;
941
+ }
942
+ #device-modal .kv-value {
943
+ color: var(--text-color);
944
+ }
945
+
946
+ /* Port/service chips */
947
+ #device-modal .chip-group { margin-top: 4px; }
948
+ #device-modal .chip {
949
+ display: inline-flex;
950
+ align-items: center;
951
+ gap: 4px;
952
+ padding: 2px 8px;
953
+ margin: 2px;
954
+ font-size: .85rem;
955
+ background-color: var(--primary-bg-accent);
956
+ border: 1px solid var(--border-color);
957
+ border-radius: 999px;
958
+ color: var(--text-color);
959
+ }
960
+ #device-modal .chip .fa-solid {
961
+ font-size: 16px;
962
+ }
963
+
964
+ /* Services layout */
965
+ #device-modal .service-list { width: 100%; }
966
+ #device-modal .service-row {
967
+ display: flex;
968
+ align-items: flex-start;
969
+ gap: 8px;
970
+ padding: 6px 0;
971
+ border-bottom: 1px dashed var(--border-color);
972
+ }
973
+ #device-modal .service-row:last-child {
974
+ border-bottom: none;
975
+ }
976
+ #device-modal .service-name {
977
+ min-width: 140px;
978
+ color: var(--text-accent-color);
979
+ font-weight: 600;
980
+ }
981
+ #device-modal .service-ports { flex: 1; }
982
+
983
+ /* Errors */
984
+ #device-modal .error-list li {
985
+ margin-bottom: 4px;
986
+ }
987
+
988
+ /* Responsive tweaks */
989
+ @media screen and (max-width: 576px) {
990
+ #device-modal .kv-grid {
991
+ grid-template-columns: 130px 1fr;
992
+ }
993
+ #device-modal .service-name {
994
+ min-width: 110px;
995
+ }
996
+ }
@@ -1,10 +1,12 @@
1
1
  $(document).ready(function() {
2
2
  rightSizeDocLayout(0,showFooter);
3
3
  initTooltips();
4
+ adjustNoWrap();
4
5
  })
5
6
 
6
7
  $(window).on('resize', function() {
7
8
  rightSizeDocLayout();
9
+ adjustNoWrap();
8
10
  });
9
11
 
10
12
 
@@ -36,4 +38,16 @@ function initTooltips() {
36
38
  tooltipTriggerList.map(function (tooltipTriggerEl) {
37
39
  return new bootstrap.Tooltip(tooltipTriggerEl)
38
40
  })
41
+ }
42
+
43
+ /*
44
+ An imperfect approach to adjusting
45
+ text field width within a table
46
+ */
47
+ function adjustNoWrap() {
48
+ $('.no-wrap').width(0);
49
+ $('.no-wrap').each(function() {
50
+ var parentWidth = $(this).parent().width();
51
+ $(this).width(parseInt(parentWidth));
52
+ });
39
53
  }
@@ -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
 
@@ -8,6 +8,9 @@ function quietReload() {
8
8
  var newDoc = new DOMParser().parseFromString(data, 'text/html');
9
9
  // replace current body with the new body content
10
10
  $('body').html($(newDoc.body).html());
11
+ if (typeof adjustNoWrap === 'function') {
12
+ adjustNoWrap();
13
+ }
11
14
  });
12
15
  }
13
16
  setTimeout(function() {