feat(locations): add cross-fields overview page and integrate UISP device data

This commit is contained in:
Christian 2026-07-23 21:32:56 +02:00
parent be68148448
commit 428ad21132
5 changed files with 172 additions and 2 deletions

View File

@ -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)
]

View File

@ -0,0 +1,41 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Krydsfelter - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-4">
<div>
<div class="small text-uppercase text-muted fw-semibold mb-1">Lokaliteter</div>
<h1 class="h3 mb-1"><i class="bi bi-diagram-3 me-2"></i>Krydsfelter</h1>
<p class="text-muted mb-0">Én række pr. X-felt med samlet kapacitet og tilknyttede lokationer.</p>
</div>
<a href="/app/locations" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Lokaliteter</a>
</div>
<div class="card border-0 shadow-sm mb-4"><div class="card-body">
<form class="row g-3 align-items-end" method="get">
<div class="col-md-9"><label class="form-label" for="cross-field-search">Søg</label><input id="cross-field-search" class="form-control" name="q" value="{{ query }}" placeholder="X-felt, underfelt eller lokation"></div>
<div class="col-md-3 d-grid"><button class="btn btn-primary"><i class="bi bi-search me-1"></i>Søg</button></div>
</form>
</div></div>
<div class="card border-0 shadow-sm"><div class="table-responsive"><table class="table align-middle mb-0">
<thead><tr><th>X-felt</th><th>Lokation</th><th>Felter</th><th>Porte</th><th>Ledige</th><th></th></tr></thead>
<tbody>
{% for field in cross_fields %}
<tr>
<td><strong>{{ field.location_name }}</strong></td>
<td><a href="/app/locations/{{ field.location_id }}?tab=cross-field" class="text-decoration-none">{{ field.hierarchy_path or field.location_name }}</a></td>
<td>{{ field.field_count }}<div class="small text-muted text-truncate" style="max-width: 280px;">{{ field.field_names }}</div></td>
<td><strong>{{ field.assigned_ports }}</strong> / {{ field.total_ports }}{% if field.port_label_format == 'paired' %}<div class="small text-muted">A/B-par</div>{% endif %}</td>
<td><span class="badge {% if field.total_ports - field.assigned_ports > 0 %}bg-success{% else %}bg-secondary{% endif %}">{{ field.total_ports - field.assigned_ports }}</span></td>
<td class="text-end"><a href="/app/locations/{{ field.location_id }}?tab=cross-field" class="btn btn-outline-primary btn-sm"><i class="bi bi-pencil-square me-1"></i>Åbn og rediger</a></td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-5">Ingen X-felter matcher søgningen.</td></tr>
{% endfor %}
</tbody>
</table></div></div>
</div>
{% endblock %}

View File

@ -889,8 +889,27 @@
<div><div class="fw-600"><a href="/hardware/{{ hw.id }}" class="text-decoration-none">{{ hw.brand }} {{ hw.model }}</a></div><div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div></div>
<div class="d-flex align-items-center gap-2"><div class="input-group input-group-sm" style="width: 175px;"><span class="input-group-text">Rækkefølge</span><input type="number" min="1" class="form-control hardware-display-order" data-hardware-id="{{ hw.id }}" value="{{ hw.location_display_order or loop.index }}"><button type="button" class="btn btn-outline-primary save-hardware-order-btn" data-hardware-id="{{ hw.id }}">Gem</button></div><span class="badge bg-secondary">{{ hw.status }}</span></div>
</div>
{% if hw.uisp_device %}
{% set uisp = hw.uisp_device %}
{% set overview = uisp.overview or {} %}
<div class="mt-3 p-3 rounded border bg-light">
<div class="d-flex justify-content-between align-items-center mb-2"><div class="small fw-semibold text-primary"><i class="bi bi-broadcast-pin me-1"></i>UISP live-data</div>{% if uisp.device_link %}<a href="{{ uisp.device_link }}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn i UISP</a>{% endif %}</div>
<div class="row g-2 small">
<div class="col-sm-3"><span class="text-muted d-block">Status</span><strong>{{ uisp.status or 'Ukendt' }}</strong></div>
<div class="col-sm-3"><span class="text-muted d-block">IP-adresser</span><strong>{{ (uisp.ip_addresses or []) | join(', ') or '—' }}</strong></div>
<div class="col-sm-3"><span class="text-muted d-block">MAC</span><strong>{{ uisp.mac_address or '—' }}</strong></div>
<div class="col-sm-3"><span class="text-muted d-block">Senest set</span><strong>{{ uisp.last_seen or '—' }}</strong></div>
<div class="col-sm-3"><span class="text-muted d-block">Firmware</span><strong>{{ uisp.firmware.version or uisp.firmware.name or '—' }}</strong></div>
<div class="col-sm-3"><span class="text-muted d-block">Platform / rolle</span><strong>{{ uisp.platform or '—' }}{% if uisp.device_role %} · {{ uisp.device_role }}{% endif %}</strong></div>
<div class="col-sm-2"><span class="text-muted d-block">Uptime</span><strong>{{ overview.uptime or overview.serviceUptime or '—' }}</strong></div>
<div class="col-sm-2"><span class="text-muted d-block">CPU / RAM</span><strong>{{ overview.cpu or '—' }} / {{ overview.ram or '—' }}</strong></div>
<div class="col-sm-2"><span class="text-muted d-block">Temperatur</span><strong>{{ overview.temperature or '—' }}</strong></div>
<div class="col-sm-2"><span class="text-muted d-block">Synkroniseret</span><strong>{{ uisp.synced_at or '—' }}</strong></div>
</div>
</div>
{% endif %}
{% if hw.switch_ports %}
<details class="mt-3" open><summary class="small fw-semibold mb-2">Switch-porte ({{ hw.switch_ports | length }})</summary><div class="patch-panel"><div class="patch-panel-grid">{% for port in hw.switch_ports %}<a href="/hardware/{{ hw.id }}" class="patch-port text-decoration-none {% if port.hardware_link %}hardware-linked{% elif port.outlet %}assigned{% endif %}" title="{% if port.hardware_link %}Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %}{% elif port.outlet %}{{ port.outlet.outlet_number or 'Ikke navngivet' }}{% else %}Ledig port — åbn switch for at tilknytte{% endif %}"><span>{{ port.port_number }}</span>{% if port.hardware_link %}<span class="patch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>{% elif port.outlet %}<span class="patch-port-outlet">{{ port.outlet.outlet_number or 'Tilknyttet' }}</span>{% else %}<span class="patch-port-outlet">Ledig</span>{% endif %}</a>{% endfor %}</div></div><div class="form-text mt-2">Lilla porte er forbundet med hardware; grønne porte går til et vægstik.</div></details>
<details class="mt-3" open><summary class="small fw-semibold mb-2">Switch-porte ({{ hw.switch_ports | length }})</summary><div class="patch-panel"><div class="patch-panel-grid">{% for port in hw.switch_ports %}<a href="/hardware/{{ hw.id }}" class="patch-port text-decoration-none {% if port.hardware_link %}hardware-linked{% elif port.outlet %}assigned{% endif %}{% if port.live %} {{ 'border border-success' if port.live.plugged else 'border border-danger' }}{% endif %}" title="{% if port.live %}Live: {{ port.live.status or ('forbundet' if port.live.plugged else 'ikke forbundet') }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}. {% endif %}{% if port.hardware_link %}Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %}{% elif port.outlet %}{{ port.outlet.outlet_number or 'Ikke navngivet' }}{% else %}Ledig port — åbn switch for at tilknytte{% endif %}"><span>{{ port.port_number }}</span>{% if port.live %}<span class="patch-port-outlet">{{ 'LIVE' if port.live.plugged else 'INTET LINK' }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}</span>{% elif port.hardware_link %}<span class="patch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>{% elif port.outlet %}<span class="patch-port-outlet">{{ port.outlet.outlet_number or 'Tilknyttet' }}</span>{% else %}<span class="patch-port-outlet">Ledig</span>{% endif %}</a>{% endfor %}</div></div><div class="form-text mt-2">Grøn/rød kant viser live linkstatus fra UISP.</div></details>
{% endif %}
</div>
{% endfor %}
@ -1120,6 +1139,10 @@
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (new URLSearchParams(window.location.search).get('tab') === 'cross-field') {
const crossFieldTab = document.getElementById('crossFieldTab');
if (crossFieldTab) bootstrap.Tab.getOrCreateInstance(crossFieldTab).show();
}
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
const locationId = '{{ location.id }}';
const locationHardware = {{ location.hardware | tojson }};

View File

@ -306,6 +306,9 @@
<a href="/app/locations/outlets" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-ethernet me-2"></i>Vægstik
</a>
<a href="/app/locations/cross-fields" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-diagram-3 me-2"></i>Krydsfelter
</a>
<span class="shortcut-hint">Tip: Tryk / for søgning</span>
</div>
</div>

View File

@ -937,6 +937,7 @@
<li><h6 class="dropdown-header">Struktur</h6></li>
<li data-menu-key="menu-crm-links"><a class="dropdown-item py-2" href="/links">Links</a></li>
<li data-menu-key="menu-crm-locations"><a class="dropdown-item py-2" href="/app/locations">Lokaliteter</a></li>
<li data-menu-key="menu-crm-cross-fields"><a class="dropdown-item py-2" href="/app/locations/cross-fields">Krydsfelter</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Pipeline</h6></li>
<li data-menu-key="menu-crm-opportunities"><a class="dropdown-item py-2" href="/opportunities"><i class="bi bi-briefcase me-2"></i>Muligheder</a></li>