diff --git a/app/modules/locations/frontend/views.py b/app/modules/locations/frontend/views.py index 811d1dc..8dadf0e 100644 --- a/app/modules/locations/frontend/views.py +++ b/app/modules/locations/frontend/views.py @@ -488,7 +488,59 @@ def wall_outlets_view(q: Optional[str] = Query(None), status: Optional[str] = Qu # ============================================================================ -# 4. GET /app/locations/{id} - Detail view (HTML) +# 4. GET /app/locations/cross-fields - Cross-field overview +# ============================================================================ + +@router.get("/app/locations/cross-fields", response_class=HTMLResponse) +def cross_fields_view(q: Optional[str] = Query(None)): + """Show every active cross-field with capacity and location context.""" + try: + params = [] + where = ["cf.deleted_at IS NULL", "cf.is_active = TRUE", "l.deleted_at IS NULL"] + if q and q.strip(): + where.append("(l.name ILIKE %s OR tree.hierarchy_path ILIKE %s OR cf.name ILIKE %s)") + params.extend([f"%{q.strip()}%"] * 3) + cross_fields = execute_query(f""" + WITH RECURSIVE tree AS ( + SELECT id, name, parent_location_id, name::text AS hierarchy_path + FROM locations_locations WHERE parent_location_id IS NULL AND deleted_at IS NULL + UNION ALL + SELECT l.id, l.name, l.parent_location_id, tree.hierarchy_path || ' > ' || l.name + FROM locations_locations l JOIN tree ON l.parent_location_id = tree.id + WHERE l.deleted_at IS NULL + ), port_usage AS ( + SELECT p.cross_field_id, + COUNT(*) FILTER (WHERE p.is_active = TRUE) AS total_ports, + COUNT(o.id) FILTER (WHERE p.is_active = TRUE AND o.deleted_at IS NULL) AS assigned_ports + FROM locations_cross_field_ports p + LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id + GROUP BY p.cross_field_id + ) + SELECT l.id AS location_id, l.name AS location_name, tree.hierarchy_path, + COUNT(cf.id) AS field_count, + STRING_AGG(cf.name, ', ' ORDER BY cf.display_order, cf.name) AS field_names, + COALESCE(SUM(port_usage.total_ports), 0) AS total_ports, + COALESCE(SUM(port_usage.assigned_ports), 0) AS assigned_ports + FROM locations_cross_fields cf + JOIN locations_locations l ON l.id = cf.location_id + LEFT JOIN tree ON tree.id = l.id + LEFT JOIN port_usage ON port_usage.cross_field_id = cf.id + WHERE {' AND '.join(where)} + GROUP BY l.id, l.name, tree.hierarchy_path + ORDER BY tree.hierarchy_path + """, tuple(params)) or [] + return HTMLResponse(render_template( + "modules/locations/templates/cross_fields.html", + cross_fields=cross_fields, + query=q or '', + )) + except Exception as exc: + logger.error("Error rendering cross-field overview: %s", exc) + raise HTTPException(status_code=500, detail="Kunne ikke vise krydsfelter") + + +# ============================================================================ +# 5. GET /app/locations/{id} - Detail view (HTML) # ============================================================================ @router.get("/app/locations/{id}", response_class=HTMLResponse) @@ -618,7 +670,55 @@ def detail_location_view(id: int = Path(..., gt=0)): # Render the same physical port map directly under each switch on the location page. hardware_link_map = {} hardware_ids = [hw['id'] for hw in (hardware or [])] + uisp_by_hardware_id = {} if hardware_ids: + uisp_rows = execute_query( + """SELECT link.hardware_id, d.id, d.external_id, d.name, d.display_name, + d.hostname, d.mac_address, d.serial_number, d.vendor, d.model, + d.platform, d.device_type, d.device_role, d.ip_addresses, d.status, + d.last_seen, d.device_link, d.raw_json, d.synced_at + FROM hardware_uisp_links link + JOIN uisp_devices d ON d.id = link.uisp_device_id + WHERE link.hardware_id = ANY(%s)""", + (hardware_ids,), + ) or [] + for uisp_device in uisp_rows: + raw = uisp_device.get('raw_json') or {} + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (TypeError, ValueError): + raw = {} + uisp_device['overview'] = raw.get('overview') if isinstance(raw, dict) else {} + uisp_device['firmware'] = ( + (raw.get('firmware') or {}) + if isinstance(raw, dict) and isinstance(raw.get('firmware'), dict) + else {} + ) + # This object is also included in the page's JavaScript hardware list. + # psycopg returns timestamps as datetime objects, which JSON cannot encode. + for timestamp_key in ('last_seen', 'synced_at'): + timestamp = uisp_device.get(timestamp_key) + if hasattr(timestamp, 'isoformat'): + uisp_device[timestamp_key] = timestamp.isoformat() + uisp_device['live_ports'] = {} + for interface in (raw.get('interfaces') or []) if isinstance(raw, dict) else []: + if not isinstance(interface, dict): + continue + identification = interface.get('identification') or {} + status = interface.get('status') or {} + name = str(identification.get('name') or '').lower() + if not name.startswith(('port', 'eth')): + continue + port_number = name.removeprefix('port').removeprefix('eth') + if not port_number.isdigit(): + continue + uisp_device['live_ports'][str(int(port_number))] = { + 'plugged': bool(status.get('plugged')), + 'status': status.get('status'), + 'speed': status.get('currentSpeed') or status.get('speed'), + } + uisp_by_hardware_id[uisp_device['hardware_id']] = uisp_device hardware_link_rows = execute_query( """SELECT l.source_hardware_id, l.source_port, l.target_hardware_id, l.target_port, target.brand AS target_brand, target.model AS target_model, target.serial_number AS target_serial, @@ -645,6 +745,7 @@ def detail_location_view(id: int = Path(..., gt=0)): hardware_link_map[(row['target_hardware_id'], str(row['target_port']))] = reverse_row for hw in hardware or []: hw['switch_ports'] = [] + hw['uisp_device'] = uisp_by_hardware_id.get(hw['id']) if str(hw.get('asset_type') or '').lower() != 'netværk': continue specs = hw.get('hardware_specs') or {} @@ -664,6 +765,7 @@ def detail_location_view(id: int = Path(..., gt=0)): 'port_number': str(port), 'outlet': linked.get(str(port)), 'hardware_link': hardware_link_map.get((hw['id'], str(port))), + 'live': (hw['uisp_device'] or {}).get('live_ports', {}).get(str(port)), } for port in range(1, port_count + 1) ] diff --git a/app/modules/locations/templates/cross_fields.html b/app/modules/locations/templates/cross_fields.html new file mode 100644 index 0000000..66b4f43 --- /dev/null +++ b/app/modules/locations/templates/cross_fields.html @@ -0,0 +1,41 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Krydsfelter - BMC Hub{% endblock %} + +{% block content %} +
Én række pr. X-felt med samlet kapacitet og tilknyttede lokationer.
+| X-felt | Lokation | Felter | Porte | Ledige | |
|---|---|---|---|---|---|
| {{ field.location_name }} | +{{ field.hierarchy_path or field.location_name }} | +{{ field.field_count }} {{ field.field_names }} |
+ {{ field.assigned_ports }} / {{ field.total_ports }}{% if field.port_label_format == 'paired' %} A/B-par {% endif %} |
+ {{ field.total_ports - field.assigned_ports }} | +Åbn og rediger | +
| Ingen X-felter matcher søgningen. | |||||