Quelltext des aktuellen Standes
<?php
$this->layout('layouts/main', [
'page_title' => $page_title,
'current_route' => $current_route,
'full_width' => true,
'extra_css' => ['/assets/css/infrastructure.css?v=' . filemtime(dirname(__DIR__, 2) . '/public/assets/css/infrastructure.css')],
'extra_js' => ['/assets/js/infrastructure.js?v=' . filemtime(dirname(__DIR__, 2) . '/public/assets/js/infrastructure.js')],
]);
$rack = $detail['rack'];
$height = (int)$rack['height_u'];
$numbering = $rack['numbering'] ?? 'bottom_up';
$mounts = $detail['mounts'];
// Gerätefarbe: erzeugt aus einer Hex-Farbe eine hellere/dunklere Variante für den
// dezenten Verlauf; $pct>0 mischt zu Weiß, $pct<0 zu Schwarz.
$infShade = static function (string $hex, float $pct): string {
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) { $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2]; }
if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) { return '#' . $hex; }
$mix = static function (int $c) use ($pct): int {
return (int)round($pct >= 0 ? $c + (255 - $c) * $pct : $c * (1 + $pct));
};
return sprintf('#%02x%02x%02x', $mix(hexdec(substr($hex,0,2))), $mix(hexdec(substr($hex,2,2))), $mix(hexdec(substr($hex,4,2))));
};
// Lesbare Vordergrundfarbe (hell/dunkel) je nach Helligkeit der Gerätefarbe.
$infReadableFg = static function (string $hex): string {
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) { $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2]; }
if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) { return '#f3f4f6'; }
$lum = 0.2126*hexdec(substr($hex,0,2)) + 0.7152*hexdec(substr($hex,2,2)) + 0.0722*hexdec(substr($hex,4,2));
return $lum > 150 ? '#111827' : '#f3f4f6';
};
$conns = $detail['connections'];
$hasRear = (bool)array_filter($mounts, static fn($m) => $m['side'] === 'rear');
// Geräte der gewählten Seite – plus Geräte voller Tiefe (die auf beiden Seiten stehen)
$sideMounts = array_values(array_filter($mounts, static fn($m) => $m['side'] === $side || !empty($m['full_depth'])));
// Physische Zeilenreihenfolge (oben → unten) je Nummerierung
$rowUs = $numbering === 'top_down' ? range(1, $height) : range($height, 1);
// Zeilen-Index (0 = oberste physische Zeile) für die absolute Positionierung
$rowIndex = [];
foreach (array_values($rowUs) as $i => $u) { $rowIndex[$u] = $i; }
// Breiten-Belegung je HE (für die Frei-Flächen): 'left'/'right' getrennt
$occ = [];
foreach ($rowUs as $u) { $occ[$u] = ['left' => false, 'right' => false]; }
foreach ($sideMounts as $m) {
$lowU = (int)$m['start_u'];
$highU = $lowU + (int)$m['height_u'] - 1;
$slot = $m['slot'] ?? 'full';
for ($u = $lowU; $u <= $highU; $u++) {
if (!isset($occ[$u])) { continue; }
if ($slot === 'left') { $occ[$u]['left'] = true; }
elseif ($slot === 'right') { $occ[$u]['right'] = true; }
else { $occ[$u]['left'] = true; $occ[$u]['right'] = true; }
}
}
$slug = static fn($s) => preg_replace('/[^a-z0-9]/', '', strtolower((string)$s));
// Belegte Ports je Einbau (nur Verbindungen der aktuellen Seite werden gezeichnet)
$usedByMount = [];
foreach ($conns as $c) {
$usedByMount[$c['near']['mount_id']][$slug($c['near']['port'])] = true;
if ($c['within'] && $c['far']['mount_id']) {
$usedByMount[$c['far']['mount_id']][$slug($c['far']['port'])] = true;
}
}
// Port-Beschriftungen je Einbau (port_ref => label)
$portLabels = $detail['portLabels'] ?? [];
$port = static function (int $mid, string $ref, string $cls, array $used, string $label = '') use ($slug): string {
$u = isset($used[$slug($ref)]) ? ' inf-used' : '';
$lc = $label !== '' ? ' inf-labeled' : '';
$e = htmlspecialchars($ref);
$title = 'Port ' . $e . ($label !== '' ? ' — ' . htmlspecialchars($label) : '');
return '<div class="inf-pt ' . $cls . $u . $lc . '" id="inf-pt-' . $mid . '-' . $slug($ref) . '"'
. ' data-mount="' . $mid . '" data-port="' . $e . '" title="' . $title . '"></div>';
};
$blank = static fn(string $cls = '') => '<div class="inf-pt ' . $cls . '" style="visibility:hidden"></div>';
$portsHtml = static function (array $m, bool $rear = false) use ($port, $blank, $usedByMount, $portLabels): string {
$mid = (int)$m['id'];
$pfx = $rear ? 'R' : '';
$pc = $rear ? (int)($m['port_count_rear'] ?? 0) : (int)$m['port_count'];
$sfp = $rear ? (int)($m['sfp_count_rear'] ?? 0) : (int)$m['sfp_count'];
$qsfp = $rear ? (int)($m['qsfp_count_rear'] ?? 0) : (int)$m['qsfp_count'];
$used = $usedByMount[$mid] ?? [];
$lbl = $portLabels[$mid] ?? [];
// Uplink-Ports: SFP paarweise (2 Reihen), QSFP einzeln und breiter
$uplinks = static function () use ($mid, $pfx, $sfp, $qsfp, $port, $blank, $used, $lbl): string {
$s = '';
if ($sfp > 0) {
$s .= '<div class="inf-grp-gap"></div>';
for ($j = 1; $j <= $sfp; $j += 2) {
$a = $j; $b = $j + 1;
$s .= '<div class="inf-pcol"><span class="inf-pnum">S' . $a . '</span>' . $port($mid, $pfx . 'SFP' . $a, 'inf-sfp', $used, $lbl[$pfx . 'SFP' . $a] ?? '');
$s .= $b <= $sfp ? $port($mid, $pfx . 'SFP' . $b, 'inf-sfp', $used, $lbl[$pfx . 'SFP' . $b] ?? '') : $blank('inf-sfp');
$s .= '<span class="inf-pnum">' . ($b <= $sfp ? 'S' . $b : '') . '</span></div>';
}
}
if ($qsfp > 0) {
$s .= '<div class="inf-grp-gap"></div>';
for ($q = 1; $q <= $qsfp; $q++) {
$s .= '<div class="inf-pcol"><span class="inf-pnum">Q' . $q . '</span>' . $port($mid, $pfx . 'QSFP' . $q, 'inf-qsfp', $used, $lbl[$pfx . 'QSFP' . $q] ?? '') . '<span class="inf-pnum"></span></div>';
}
}
return $s;
};
switch ($m['port_kind']) {
case 'switch':
$cols = (int)ceil($pc / 2);
$rows = ($m['port_layout'] ?? 'zebra') === 'rows';
$s = '<div class="inf-pcols">';
for ($col = 1; $col <= $cols; $col++) {
// zebra: 1 oben-links, 2 darunter, 3 oben … | rows: obere Reihe 1..cols, untere cols+1..N
$top = $rows ? $col : ($col * 2 - 1);
$bot = $rows ? ($cols + $col) : ($col * 2);
$s .= '<div class="inf-pcol"><span class="inf-pnum">' . ($top <= $pc ? sprintf('%02d', $top) : '') . '</span>';
$s .= $top <= $pc ? $port($mid, $pfx . $top, 'inf-rj', $used, $lbl[$pfx . $top] ?? '') : $blank('inf-rj');
$s .= $bot <= $pc ? $port($mid, $pfx . $bot, 'inf-rj', $used, $lbl[$pfx . $bot] ?? '') : $blank('inf-rj');
$s .= '<span class="inf-pnum">' . ($bot <= $pc ? sprintf('%02d', $bot) : '') . '</span></div>';
if ($col % 6 === 0 && $col < $cols) { $s .= '<div class="inf-grp-gap"></div>'; }
}
return $s . $uplinks() . '</div>';
case 'patchpanel':
$s = '<div class="inf-pcols">';
for ($i = 1; $i <= $pc; $i++) {
$s .= '<div class="inf-pcol"><span class="inf-pnum">' . sprintf('%02d', $i) . '</span>' . $port($mid, $pfx . $i, '', $used, $lbl[$pfx . $i] ?? '') . '</div>';
if ($i % 8 === 0 && $i < $pc) { $s .= '<div class="inf-grp-gap"></div>'; }
}
return $s . '</div>';
case 'gateway':
$s = '<div class="inf-screen"></div><div class="inf-pcols">';
$s .= '<div class="inf-pcol"><span class="inf-pnum">WAN</span>' . $port($mid, $pfx . 'WAN1', 'inf-rj', $used, $lbl[$pfx . 'WAN1'] ?? '') . '</div>';
$lan = max(1, $pc);
for ($i = 1; $i <= $lan; $i++) {
$s .= '<div class="inf-pcol"><span class="inf-pnum">L' . $i . '</span>' . $port($mid, $pfx . 'LAN' . $i, 'inf-rj', $used, $lbl[$pfx . 'LAN' . $i] ?? '') . '</div>';
}
return $s . $uplinks() . '</div>';
case 'server':
$s = '<div class="inf-drives">';
for ($i = 0; $i < 8; $i++) { $s .= '<div class="inf-drive"></div>'; }
$s .= '</div>';
if ($pc > 0 || $sfp > 0 || $qsfp > 0) {
$s .= '<div class="inf-pcols">';
for ($i = 1; $i <= $pc; $i++) {
$s .= '<div class="inf-pcol"><span class="inf-pnum">N' . $i . '</span>' . $port($mid, $pfx . 'NIC' . $i, 'inf-rj', $used, $lbl[$pfx . 'NIC' . $i] ?? '') . '</div>';
}
$s .= $uplinks() . '</div>';
}
return $s;
case 'firewall':
$s = '<div class="inf-screen"></div><div class="inf-pcols">';
for ($i = 1; $i <= $pc; $i++) {
$s .= '<div class="inf-pcol"><span class="inf-pnum">' . sprintf('%02d', $i) . '</span>' . $port($mid, $pfx . $i, 'inf-rj', $used, $lbl[$pfx . $i] ?? '') . '</div>';
if ($i % 6 === 0 && $i < $pc) { $s .= '<div class="inf-grp-gap"></div>'; }
}
return $s . $uplinks() . '</div>';
case 'pbx':
$s = '<div class="inf-screen"></div><div class="inf-pcols">';
for ($i = 1; $i <= $pc; $i++) {
$s .= '<div class="inf-pcol"><span class="inf-pnum">' . sprintf('%02d', $i) . '</span>' . $port($mid, $pfx . $i, 'inf-rj', $used, $lbl[$pfx . $i] ?? '') . '</div>';
if ($i % 8 === 0 && $i < $pc) { $s .= '<div class="inf-grp-gap"></div>'; }
}
return $s . '</div>';
case 'pdu':
$n = $pc > 0 ? min($pc, 24) : 8;
$s = '<div class="inf-outlets">';
for ($i = 0; $i < $n; $i++) { $s .= '<div class="inf-outlet"></div>'; }
return $s . '</div>';
case 'ups':
$n = $pc > 0 ? min($pc, 24) : 8;
$s = '<div class="inf-screen"></div><div class="inf-outlets">';
for ($i = 1; $i <= $n; $i++) { $s .= $port($mid, $pfx . 'OUT' . $i, 'inf-out', $used, $lbl[$pfx . 'OUT' . $i] ?? ''); }
return $s . '</div>';
default:
return '';
}
};
$typeClass = ['asset' => 'inf-face--asset', 'unifi' => 'inf-face--unifi', 'passive' => 'inf-face--passive'];
$cableTagClass = ['copper' => 'inf-t-copper', 'fiber' => 'inf-t-fiber', 'dac' => 'inf-t-dac', 'wan' => 'inf-t-wan', 'power' => 'inf-t-power', 'other' => 'inf-t-other'];
$cableTagLabel = ['copper' => 'Kupfer', 'fiber' => 'Fiber', 'dac' => 'DAC', 'wan' => 'WAN/VPN', 'power' => 'Strom', 'other' => 'Sonstige'];
$srcLabel = ['asset' => 'Inventar', 'unifi' => 'UniFi', 'passive' => 'Passiv'];
// Marker-Verbindungen ohne Switch-Endpunkt (Gerät direkt am Patchpanel) werden
// ausschließlich als graue Festverkabelungs-Zeile geführt, damit die Liste dem
// Rack-Diagramm folgt (dort nur grauer Ausgang, keine normale Leitung).
$isMarkerNoSwitch = static fn($c) => !empty($c['from_marker']) && !empty($c['far']['external']);
$connExit = array_values(array_filter($conns, static fn($c) => !$c['within'] && !$isMarkerNoSwitch($c)));
$connWithin = array_values(array_filter($conns, static fn($c) => $c['within'] && !$isMarkerNoSwitch($c)));
// Marker-Verbindungen (Gerät über Patchpanel) bekommen eine eigene, graue
// „Gerät ↔ Patchpanel"-Zeile (Festverkabelung, nicht anklickbar).
$connMarkerAp = array_values(array_filter($conns, static fn($c) => !empty($c['from_marker'])));
$weightStr = (float)$detail['weightKg'] > 0 ? number_format((float)$detail['weightKg'], 1, ',', '.') . ' kg' : null;
// Kapazitäts-/Grenzwerte (Prozent begrenzt auf 100 für die Balkenbreite)
// Audit-Aktionen in Klartext – der Verlauf soll ohne Fachbegriffe lesbar sein.
$aktionLabel = static function (string $a): string {
return [
'rack_created' => 'Rack angelegt',
'rack_updated' => 'Rack geändert',
'mount_added' => 'Gerät eingebaut',
'mount_updated' => 'Einbau geändert',
'mount_removed' => 'Einbau entfernt',
'port_labels_updated' => 'Port-Beschriftungen geändert',
][$a] ?? $a;
};
$maxLoadKg = ($rack['max_load_kg'] ?? null) !== null ? (float)$rack['max_load_kg'] : null;
$maxPowerW = ($rack['max_power_watt'] ?? null) !== null ? (int)$rack['max_power_watt'] : null;
$weightKgVal = (float)$detail['weightKg'];
$powerWVal = (int)$detail['powerW'];
$hePct = $height > 0 ? min(100, (int)round((int)$detail['usedU'] / $height * 100)) : 0;
$loadPct = $maxLoadKg > 0 ? (int)round($weightKgVal / $maxLoadKg * 100) : null;
$powerPct = $maxPowerW > 0 ? (int)round($powerWVal / $maxPowerW * 100) : null;
$issues = $detail['issues'] ?? [];
/** Kapazitäts-Balken: Klasse nach Auslastung (>100 % kritisch, ≥85 % Warnung). */
$capClass = static fn(?int $pct): string => $pct === null ? '' : ($pct > 100 ? ' inf-cap-fill--over' : ($pct >= 85 ? ' inf-cap-fill--warn' : ''));
// Port-Profile für die datalist-Vorschläge (A = Geräte dieses Racks, B = alle Ziele)
$aPortMap = [];
foreach ($mounts as $m) {
$aPortMap[(int)$m['id']] = ['kind' => $m['port_kind'], 'pc' => (int)$m['port_count'], 'sfp' => (int)$m['sfp_count'], 'qsfp' => (int)$m['qsfp_count'], 'pcR' => (int)($m['port_count_rear'] ?? 0), 'sfpR' => (int)($m['sfp_count_rear'] ?? 0), 'qsfpR' => (int)($m['qsfp_count_rear'] ?? 0)];
}
$bPortMap = [];
foreach ($targets as $g) {
foreach ($g['mounts'] as $tm) {
$bPortMap[(int)$tm['id']] = ['kind' => $tm['port_kind'], 'pc' => (int)$tm['port_count'], 'sfp' => (int)$tm['sfp_count'], 'qsfp' => (int)($tm['qsfp_count'] ?? 0), 'pcR' => (int)($tm['port_count_rear'] ?? 0), 'sfpR' => (int)($tm['sfp_count_rear'] ?? 0), 'qsfpR' => (int)($tm['qsfp_count_rear'] ?? 0)];
}
}
// Einbauten je ID für Drag&Drop-Verschieben (Felder wie im Bearbeiten-Dialog)
$mountEdit = [];
foreach ($mounts as $m) {
$mountEdit[(int)$m['id']] = [
'start_u' => (int)$m['start_u'],
'height_u' => (int)$m['height_u'],
'side' => $m['side'],
'slot' => $m['slot'] ?? 'full',
'full_depth' => !empty($m['full_depth']) ? 1 : 0,
'label' => $m['label_raw'],
'port_kind' => $m['port_kind'],
'port_count' => (int)$m['port_count'],
'sfp_count' => (int)$m['sfp_count'],
'qsfp_count' => (int)$m['qsfp_count'],
'port_count_rear' => (int)($m['port_count_rear'] ?? 0),
'sfp_count_rear' => (int)($m['sfp_count_rear'] ?? 0),
'qsfp_count_rear' => (int)($m['qsfp_count_rear'] ?? 0),
'port_layout' => $m['port_layout'],
'power_watt' => $m['power_watt'],
'weight_kg' => $m['weight_kg'],
'capacity_watt' => $m['capacity_watt'],
'color' => $m['color'] ?? '',
'doc_page_id' => (int)($m['doc_page_id'] ?? 0) ?: '',
];
}
// Auswahllisten der Dialoge: einmal als Daten, statt je Dialog als Markup.
// Die Dokuseiten stehen in drei Dialogen zur Wahl und wuerden sonst dreifach
// im Quelltext stehen; gefuellt wird erst beim Oeffnen.
$docOptions = array_map(static fn (array $dp): array => [(int)$dp['id'], (string)$dp['title']], $docPages ?? []);
$assetOptions = array_map(
static fn (array $a): array => [
(int)$a['id'],
($a['name'] ?: $a['inventory_number']) . ($a['inventory_number'] ? ' (' . $a['inventory_number'] . ')' : ''),
],
$candidates['assets']
);
$unifiOptions = array_map(
static fn (array $u): array => [
(string)$u['mac'],
($u['name'] ?: $u['model'] ?: $u['mac']) . ' · ' . $u['mac'],
(string)($u['model'] ?? ''),
],
$candidates['unifi']
);
// Verbindungen für den Bearbeiten-Dialog (Endpunkt A = lokal/„nah", B = „fern")
$connEdit = [];
foreach ($conns as $c) {
$connEdit[$c['id']] = [
'a_mount_id' => $c['near']['mount_id'],
'a_port' => $c['near']['port'],
'b_kind' => $c['far']['external'] ? 'external' : 'mount',
'b_mount_id' => $c['far']['mount_id'],
'b_external' => $c['far']['external'] ? $c['far']['dev'] : '',
'b_port' => $c['far']['port'],
'cable_type' => $c['cable_type'],
'label' => $c['label'],
];
}
?>
<div class="content-header">
<div>
<h1><?= $this->te('Rack {name}', ['name' => $rack['name']]) ?></h1>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/infrastructure"><?= $this->te('Infrastruktur') ?></a></li>
<?php foreach ($detail['breadcrumb'] as $b): ?>
<li class="breadcrumb-item"><a href="/infrastructure/locations/<?= (int)$b['id'] ?>"><?= $this->e($b['name']) ?></a></li>
<?php endforeach; ?>
<li class="breadcrumb-item active"><?= $this->e($rack['name']) ?></li>
</ol>
</div>
<div class="content-header-actions">
<a href="/infrastructure/racks/<?= (int)$rack['id'] ?>/datasheet" target="_blank" class="btn btn-default btn-sm">
<i class="fas fa-file-pdf"></i> <?= $this->te('Datenblatt') ?>
</a>
<a href="/infrastructure/racks/<?= (int)$rack['id'] ?>/patchplan" target="_blank" class="btn btn-default btn-sm">
<i class="fas fa-file-pdf"></i> <?= $this->te('Patchplan') ?>
</a>
<a href="/infrastructure/racks/<?= (int)$rack['id'] ?>/cables.csv" class="btn btn-default btn-sm">
<i class="fas fa-file-csv"></i> <?= $this->te('Kabel-CSV') ?>
</a>
<?php if ($can_admin): ?>
<button type="button" class="btn btn-default btn-sm" onclick="infOpenModal('inf-rack-modal')">
<i class="fas fa-pencil-alt"></i> <?= $this->te('Rack') ?>
</button>
<button type="button" class="btn btn-primary btn-sm" onclick="infOpenAdd()">
<i class="fas fa-plus"></i> <?= $this->te('Gerät einbauen') ?>
</button>
<button type="button" class="btn btn-default btn-sm" onclick="infOpenConnAdd()">
<i class="fas fa-network-wired"></i> <?= $this->te('Verbindung') ?>
</button>
<button type="button" class="btn btn-default btn-sm" onclick="document.getElementById('inf-import-modal').style.display='flex'">
<i class="fas fa-file-import"></i> <?= $this->te('CSV-Import') ?>
</button>
<form method="POST" action="/infrastructure/racks/<?= (int)$rack['id'] ?>/delete" style="display:inline"
onsubmit="return confirm('Rack „<?= $this->e($rack['name']) ?>“ inkl. Belegung und Verkabelung löschen?');">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button aria-label="<?= $this->te('Löschen') ?>" type="submit" class="btn btn-outline-danger btn-sm"><i class="fas fa-trash"></i></button>
</form>
<?php endif; ?>
</div>
</div>
<div class="page-body">
<?php if ($hasRear || $side === 'rear'): ?>
<div class="subnav">
<a class="subnav-tab <?= $side === 'front' ? 'active' : '' ?>" href="/infrastructure/racks/<?= (int)$rack['id'] ?>?side=front"><?= $this->te('Front') ?></a>
<a class="subnav-tab <?= $side === 'rear' ? 'active' : '' ?>" href="/infrastructure/racks/<?= (int)$rack['id'] ?>?side=rear"><?= $this->te('Rückseite') ?></a>
</div>
<?php endif; ?>
<div class="inf-detail-grid">
<!-- Elevation mit Ports & Verkabelung -->
<div>
<div class="inf-elev-toolbar">
<span class="inf-zoom">
<button type="button" class="btn btn-xs btn-default" onclick="infZoom(-1)" title="<?= $this->te('Verkleinern') ?>"><i class="fas fa-search-minus"></i></button>
<span class="inf-zoom-lvl" id="inf-zoom-lvl">100%</span>
<button type="button" class="btn btn-xs btn-default" onclick="infZoom(1)" title="<?= $this->te('Vergrößern') ?>"><i class="fas fa-search-plus"></i></button>
<button type="button" class="btn btn-xs btn-default" onclick="infZoom(0)" title="<?= $this->te('An Bildschirm anpassen') ?>"><i class="fas fa-compress"></i></button>
</span>
<button type="button" class="btn btn-xs btn-default" onclick="infToggleRackTheme()" title="<?= $this->te('Rack hell/dunkel') ?>"><i class="fas fa-lightbulb"></i> <?= $this->te('Hell/Dunkel') ?></button>
</div>
<div class="inf-rack-scroll">
<div class="inf-rack-sizer" id="inf-sizer">
<div class="inf-rack-stage" id="inf-stage">
<div class="inf-rack-frame">
<div class="inf-rack-title"><?= $this->e($rack['name']) ?> · <?= $height ?> HE · <?= $this->te($side === 'rear' ? 'Rückansicht' : 'Frontansicht') ?></div>
<div class="inf-elev-body">
<div class="inf-uscale">
<?php foreach ($rowUs as $u): ?><div class="inf-u"><?= $u ?></div><?php endforeach; ?>
</div>
<div class="inf-cable-lane"></div>
<div class="inf-mount">
<div class="inf-frame-bar"><b></b><b></b></div>
<div class="inf-rack-inner">
<div class="inf-rail"></div>
<div class="inf-devcol" id="inf-devcol" style="position:relative;height:calc(<?= $height ?> * var(--inf-u))">
<?php
// Frei-Flächen je Breiten-Lane (both/left/right) zu Blöcken zusammenfassen
$segs = [];
$cur = null;
foreach (array_values($rowUs) as $i => $u) {
$L = $occ[$u]['left']; $R = $occ[$u]['right'];
$cl = (!$L && !$R) ? 'both' : (!$L ? 'left' : (!$R ? 'right' : 'none'));
if ($cl === 'none') { if ($cur) { $segs[] = $cur; $cur = null; } continue; }
if ($cur && $cur['class'] === $cl) { $cur['len']++; $cur['minU'] = min($cur['minU'], $u); }
else { if ($cur) { $segs[] = $cur; } $cur = ['class' => $cl, 'startIdx' => $i, 'len' => 1, 'minU' => $u]; }
}
if ($cur) { $segs[] = $cur; }
foreach ($segs as $sg):
$laneCls = $sg['class'] === 'left' ? ' inf-free--left' : ($sg['class'] === 'right' ? ' inf-free--right' : '');
$slotParam = $sg['class'] === 'both' ? 'full' : $sg['class'];
$attr = $can_admin ? ' onclick="infAddAt(' . $sg['minU'] . ',\'' . $slotParam . '\')" title="Gerät ab HE ' . $sg['minU'] . ' einbauen"' : '';
echo '<div class="inf-free' . $laneCls . '"' . $attr . ' style="position:absolute;top:calc(' . $sg['startIdx'] . ' * var(--inf-u));height:calc(' . $sg['len'] . ' * var(--inf-u))">'
. '<span>' . $sg['len'] . ' HE' . ($sg['class'] === 'both' ? ' frei' : ' ½') . ($can_admin ? ' · +' : '') . '</span></div>';
endforeach;
// Geräte absolut positionieren (Halbbreite nebeneinander, volle Tiefe beidseitig)
foreach ($sideMounts as $m):
$lowU = (int)$m['start_u'];
$highU = $lowU + (int)$m['height_u'] - 1;
$physTop = $numbering === 'top_down' ? $lowU : $highU;
$idx = $rowIndex[$physTop] ?? 0;
$slot = $m['slot'] ?? 'full';
$slotCls = $slot === 'left' ? ' inf-face--left' : ($slot === 'right' ? ' inf-face--right' : '');
$rearView = ($m['side'] !== $side); // nur bei voller Tiefe: Gegenseite → Rückseiten-Ports
$cls = $typeClass[$m['source_type']] ?? 'inf-face--passive';
// Optionale Gerätefarbe: überschreibt Hell/Dunkel, behält aber den Verlauf.
$mColor = !empty($m['color']) ? $m['color'] : null;
$faceStyle = 'position:absolute;top:calc(' . $idx . ' * var(--inf-u));height:calc(' . (int)$m['height_u'] . ' * var(--inf-u))';
$mFg = null;
if ($mColor !== null) {
$mFg = $infReadableFg($mColor);
$faceStyle .= ';background:linear-gradient(180deg,' . $infShade($mColor, 0.14) . ',' . $infShade($mColor, -0.12) . ');color:' . $mFg . ';--inf-fg:' . $mFg;
} ?>
<div class="inf-face <?= $cls . $slotCls ?><?= $can_admin ? ' inf-draggable' : '' ?><?= !empty($m['full_depth']) ? ' inf-face--depth' : '' ?><?= $mColor ? ' inf-face--custom' : '' ?>" id="inf-face-<?= (int)$m['id'] ?>"
<?php if ($can_admin): ?>draggable="true" data-mount-id="<?= (int)$m['id'] ?>" data-h="<?= (int)$m['height_u'] ?>"<?php endif; ?>
style="<?= $faceStyle ?>">
<span class="inf-face-nm"<?= $mFg ? ' style="color:' . $mFg . '"' : '' ?>><?= $this->e($m['name']) ?><?php if (!empty($m['full_depth'])): ?> <i class="fas fa-arrows-alt-h inf-depth-ic" title="<?= $this->te('volle Tiefe') ?>"></i><?php endif; ?></span>
<?php if ($m['source_type'] === 'asset' && !empty($m['link'])): ?><a class="inf-face-open" href="<?= $this->e($m['link']) ?>" title="<?= $this->te('Zum Inventar-Gerät') ?>" draggable="false" onclick="event.stopPropagation()"><i class="fas fa-external-link-alt"></i></a><?php endif; ?>
<?= $portsHtml($m, $rearView) ?>
<span class="inf-face-meta"<?= $mFg ? ' style="color:' . $mFg . ';opacity:.75"' : '' ?>><?= (int)$m['start_u'] ?><?= (int)$m['height_u'] > 1 ? '–' . ((int)$m['start_u'] + (int)$m['height_u'] - 1) : '' ?><?= $rearView ? ' · hinten' : '' ?></span>
<?php if ($m['live'] !== null): ?>
<span class="inf-ldot" style="background:<?= $m['live'] ? 'var(--green-500)' : 'var(--red-500)' ?>"></span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<div class="inf-rail"></div>
</div>
<div class="inf-frame-bar"><b></b><b></b></div>
</div>
</div>
</div>
<svg class="inf-cables" id="inf-cables"></svg>
</div>
</div>
</div>
<div class="inf-legend">
<span><i class="inf-sw inf-cbl--copper"></i><?= $this->te('Kupfer') ?></span>
<span><i class="inf-sw inf-cbl--fiber"></i><?= $this->te('DAC/Fiber') ?></span>
<span><i class="inf-sw inf-cbl--wan"></i>WAN/VPN</span>
<span class="inf-tiphint"><i class="fas fa-lightbulb"></i> <?= $this->te('Kabel laufen links am Rack vorbei – die Ports bleiben frei. Klick auf Port, Leitung oder Austrittsmarke zeigt die Verbindung.') ?></span>
</div>
</div>
<!-- Rechte Spalte: Kennzahlen + Verkabelung + Belegung -->
<div>
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title"><?= $this->te('Rack-Kennzahlen') ?></span>
</div>
<div class="detail-card-body">
<div class="inf-cap">
<div class="inf-cap-top"><span><i class="fas fa-server"></i> <?= $this->te('Höheneinheiten') ?></span><span><?= $this->te('{belegt} / {gesamt} HE · {frei} frei', ['belegt' => (int)$detail['usedU'], 'gesamt' => $height, 'frei' => (int)$detail['freeU']]) ?></span></div>
<div class="inf-cap-bar"><div class="inf-cap-fill" style="width:<?= $hePct ?>%"></div></div>
</div>
<div class="inf-cap">
<div class="inf-cap-top">
<span><i class="fas fa-weight-hanging"></i> <?= $this->te('Gewicht') ?></span>
<span><?= $weightStr ?? '–' ?><?php if ($maxLoadKg > 0): ?> / <?= $this->e(number_format($maxLoadKg, 1, ',', '.')) ?> kg<?php if ($loadPct > 100): ?> <span class="status-pill status-pill--danger"><?= $this->te('Überlast') ?></span><?php endif; ?><?php endif; ?></span>
</div>
<?php if ($maxLoadKg > 0): ?><div class="inf-cap-bar"><div class="inf-cap-fill<?= $capClass($loadPct) ?>" style="width:<?= min(100, (int)$loadPct) ?>%"></div></div><?php endif; ?>
</div>
<div class="inf-cap">
<div class="inf-cap-top">
<span><i class="fas fa-bolt"></i> <?= $this->te('Leistung') ?></span>
<span><?= $powerWVal > 0 ? $powerWVal . ' W' : '–' ?><?php if ($maxPowerW > 0): ?> / <?= (int)$maxPowerW ?> W<?php if ($powerPct > 100): ?> <span class="status-pill status-pill--danger"><?= $this->te('über Limit') ?></span><?php endif; ?><?php endif; ?></span>
</div>
<?php if ($maxPowerW > 0): ?><div class="inf-cap-bar"><div class="inf-cap-fill<?= $capClass($powerPct) ?>" style="width:<?= min(100, (int)$powerPct) ?>%"></div></div><?php endif; ?>
</div>
<?php $feeds = $detail['feeds'] ?? null; ?>
<?php if ($feeds && ($feeds['a'] + $feeds['b'] + $feeds['both']) > 0): ?>
<div class="inf-cap">
<div class="inf-cap-top">
<span><i class="fas fa-plug"></i> <?= $this->te('Einspeisungen') ?></span>
<span><?= $this->te('A {a} W · B {b} W · beide {beide} W', ['a' => (int)$feeds['a'], 'b' => (int)$feeds['b'], 'beide' => (int)$feeds['both']]) ?></span>
</div>
<div class="inf-cap-top" style="font-size:0.78rem;color:var(--gray-500);">
<span><?= $this->te('Bei Ausfall einer Seite: {watt} W', ['watt' => (int)$feeds['worst_case']]) ?></span>
<span>
<?php if ((int)$feeds['single_feed'] > 0): ?>
<span class="status-pill status-pill--warning"><?= $this->te('{anzahl} Gerät(e) ohne Redundanz', ['anzahl' => (int)$feeds['single_feed']]) ?></span>
<?php else: ?>
<span class="status-pill status-pill--success"><span class="status-dot status-dot--success"></span> <?= $this->te('alle zweiseitig versorgt') ?></span>
<?php endif; ?>
</span>
</div>
<?php if ($maxPowerW > 0 && (int)$feeds['worst_case'] > $maxPowerW): ?>
<div style="font-size:0.78rem;color:var(--color-danger);margin-top:0.25rem;">
<?= $this->te('Beim Ausfall einer Einspeisung läge die Last über dem Rack-Grenzwert.') ?>
</div>
<?php endif; ?>
<?php if ((int)$feeds['unassigned'] > 0): ?>
<div style="font-size:0.78rem;color:var(--gray-500);margin-top:0.25rem;">
<?= $this->te('{anzahl} Gerät(e) mit Leistungsangabe, aber ohne zugeordnete Einspeisung.', ['anzahl' => (int)$feeds['unassigned']]) ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="inf-cap" style="margin-bottom:0">
<div class="inf-cap-top">
<span><i class="fas fa-clipboard-check"></i> <?= $this->te('Datenqualität') ?></span>
<span>
<?php if (empty($issues)): ?><span class="status-pill status-pill--success"><span class="status-dot status-dot--success"></span> <?= $this->te('keine Auffälligkeiten') ?></span>
<?php else: ?><span class="status-pill status-pill--warning"><?= $this->te(count($issues) === 1 ? '{anzahl} Hinweis' : '{anzahl} Hinweise', ['anzahl' => count($issues)]) ?></span><?php endif; ?>
</span>
</div>
<?php if (!empty($issues)): ?>
<ul class="inf-issues">
<?php foreach ($issues as $it): ?>
<li class="inf-issue inf-issue--<?= $it['level'] === 'danger' ? 'danger' : 'warn' ?>"><i class="fas fa-exclamation-triangle"></i> <?= $this->e($it['text']) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
</div>
<?php if (!empty($history)): ?>
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title"><?= $this->te('Letzte Änderungen') ?></span>
</div>
<div class="detail-card-body">
<ul style="list-style:none;margin:0;padding:0;">
<?php foreach ($history as $h): ?>
<li style="display:flex;justify-content:space-between;gap:0.75rem;padding:0.35rem 0;border-bottom:1px solid var(--gray-100);font-size:0.82rem;">
<span>
<?= $this->e($aktionLabel($h['action_type'] ?? '')) ?>
<?php if (!empty($h['object_name'])): ?>
<span style="color:var(--gray-500);">· <?= $this->e($h['object_name']) ?></span>
<?php endif; ?>
</span>
<span style="color:var(--gray-500);white-space:nowrap;">
<?= $this->e($h['display_name'] ?? 'System') ?> ·
<?= $this->e(date('d.m.Y H:i', strtotime((string)$h['event_timestamp']))) ?>
</span>
</li>
<?php endforeach; ?>
</ul>
</div>
</div>
<?php endif; ?>
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title"><?= $this->te('Verkabelung') ?></span>
<span class="inf-hint"><?= $this->te(count($conns) === 1 ? '{anzahl} Verbindung' : '{anzahl} Verbindungen', ['anzahl' => count($conns)]) ?> · <a href="/infrastructure/cabling" class="table-link"><?= $this->te('Gesamtübersicht') ?></a></span>
</div>
<div class="detail-card-body">
<?php if (empty($conns)): ?>
<p class="text-muted"><?= $this->te('Noch keine Verkabelung erfasst.') ?></p>
<?php else: ?>
<?php
$renderConn = function (array $c) use ($cableTagClass, $cableTagLabel, $can_admin) {
$tag = $cableTagClass[$c['cable_type']] ?? 'inf-t-other';
$tagL = $cableTagLabel[$c['cable_type']] ?? $c['cable_type'];
?>
<div class="inf-conn" data-conn-id="<?= (int)$c['id'] ?>"
onmouseenter="window.infHighlightConnection && infHighlightConnection(<?= (int)$c['id'] ?>, true)"
onmouseleave="window.infHighlightConnection && infHighlightConnection(<?= (int)$c['id'] ?>, false)">
<div class="inf-ep">
<span class="inf-port"><?= $this->e($c['near']['port'] ?: '–') ?></span>
<span class="inf-dev-nm"><?= $this->e($c['near']['dev']) ?></span>
</div>
<div class="inf-cbl-mid">
<div class="inf-line"></div>
<span class="inf-cbl-tag <?= $tag ?>"><?= $this->e($tagL) ?></span>
</div>
<div class="inf-ep inf-r">
<span class="inf-port"><?= $this->e($c['far']['port'] ?: ($c['far']['external'] ? 'extern' : '–')) ?></span>
<span class="inf-dev-nm inf-dev-nm--target"><strong><?= $this->e($c['far']['dev']) ?></strong></span>
<?php if (!$c['far']['external'] && ($c['far']['location'] || $c['far']['rack'])): ?>
<span class="inf-far-loc"><i class="fas fa-map-marker-alt"></i>
<?= $this->e(trim(($c['far']['location'] ? $c['far']['location'] . ' · ' : '') . $c['far']['rack'])) ?>
</span>
<?php elseif ($c['far']['external']): ?>
<span class="inf-far-loc"><?= $this->te('extern') ?></span>
<?php endif; ?>
</div>
<?php if ($can_admin): ?>
<div class="inf-conn-actions">
<button type="button" class="btn btn-xs btn-default" title="<?= $this->te('Bearbeiten') ?>" onclick="infEditConnection(<?= (int)$c['id'] ?>)"><i class="fas fa-pencil-alt"></i></button>
<form method="POST" action="/infrastructure/connections/<?= (int)$c['id'] ?>/delete" style="display:inline" onsubmit="return confirm('Verbindung entfernen?');">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button aria-label="<?= $this->te('Schließen') ?>" type="submit" class="btn btn-xs btn-outline-danger"><i class="fas fa-times"></i></button>
</form>
</div>
<?php endif; ?>
</div>
<?php
};
// Graue, nicht anklickbare Zeile für ein Gerät, das über das
// Patchpanel fest verkabelt ist (Marker-Verbindung). Nah = Patchpanel-Port,
// fern = Gerätename (extern). Kein Bearbeiten/Löschen (kein eigener DB-Datensatz).
$renderApRow = function (array $c) {
$apName = $c['label'] ?: ($c['far']['dev'] ?? 'Gerät');
?>
<div class="inf-conn inf-conn--muted" title="<?= $this->te('Festverkabelung über Patchpanel – nicht anklickbar') ?>">
<div class="inf-ep">
<span class="inf-port"><?= $this->e($c['near']['port'] ?: '–') ?></span>
<span class="inf-dev-nm"><?= $this->e($c['near']['dev']) ?></span>
</div>
<div class="inf-cbl-mid">
<div class="inf-line"></div>
<span class="inf-cbl-tag inf-t-other"><?= $this->te('Fest') ?></span>
</div>
<div class="inf-ep inf-r">
<span class="inf-port"><?= $this->te('extern') ?></span>
<span class="inf-dev-nm inf-dev-nm--target"><strong><?= $this->e($apName) ?></strong></span>
<span class="inf-far-loc"><?= $this->te('über Patchpanel') ?></span>
</div>
</div>
<?php
};
?>
<?php if (!empty($connExit)): ?>
<div class="inf-cbl-group">
<p class="inf-gh"><i class="fas fa-link"></i> <?= $this->te('Uplinks / Rack-zu-Rack & extern') ?></p>
<?php foreach ($connExit as $c) { $renderConn($c); } ?>
</div>
<?php endif; ?>
<?php if (!empty($connWithin)): ?>
<div class="inf-cbl-group">
<p class="inf-gh"><i class="fas fa-plug"></i> <?= $this->te('Innerhalb des Racks') ?></p>
<?php foreach ($connWithin as $c) { $renderConn($c); } ?>
</div>
<?php endif; ?>
<?php if (!empty($connMarkerAp)): ?>
<div class="inf-cbl-group">
<p class="inf-gh"><i class="fas fa-wifi"></i> <?= $this->te('Geräte über Patchpanel (fest)') ?></p>
<?php foreach ($connMarkerAp as $c) { $renderApRow($c); } ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title"><?= $this->te('Belegung') ?></span>
<span class="inf-hint">
<?= $this->te('{belegt} / {gesamt} HE · {frei} frei', ['belegt' => (int)$detail['usedU'], 'gesamt' => $height, 'frei' => (int)$detail['freeU']]) ?>
<?php if ((int)$detail['powerW'] > 0): ?> · <?= (int)$detail['powerW'] ?> W<?php endif; ?>
<?php if ($weightStr !== null): ?> · <?= $this->e($weightStr) ?><?php endif; ?>
</span>
</div>
<div class="detail-card-body">
<?php if (empty($mounts)): ?>
<p class="text-muted"><?= $this->te('Noch keine Geräte eingebaut.') ?></p>
<?php else: ?>
<div class="table-wrapper">
<table class="data-table">
<thead><tr><th scope="col">HE</th><th scope="col"><?= $this->te('Gerät') ?></th><th scope="col"><?= $this->te('Seite') ?></th><th scope="col"><?= $this->te('Ports') ?></th><th scope="col"><?= $this->te('Status') ?></th><th scope="col"></th></tr></thead>
<tbody>
<?php foreach ($mounts as $m):
?>
<tr>
<td><?= (int)$m['start_u'] ?><?= (int)$m['height_u'] > 1 ? '–' . ((int)$m['start_u'] + (int)$m['height_u'] - 1) : '' ?></td>
<td>
<?php if (!empty($m['link'])): ?>
<a href="<?= $this->e($m['link']) ?>" class="table-link"><strong><?= $this->e($m['name']) ?></strong></a>
<?php else: ?>
<strong><?= $this->e($m['name']) ?></strong>
<?php endif; ?>
<?php if ($m['sub'] !== ''): ?><br><span class="text-muted"><?= $this->e($m['sub']) ?></span><?php endif; ?>
</td>
<td><span class="badge badge-secondary"><?= $m['side'] === 'rear' ? 'Rück' : 'Front' ?></span></td>
<?php $labelable = in_array($m['port_kind'], ['switch', 'firewall', 'patchpanel', 'gateway', 'server', 'pbx', 'ups'], true); ?>
<td>
<?php if (in_array($m['port_kind'], ['ups', 'pdu'], true)): ?>
<?php $load = (int)($m['feed_load_w'] ?? 0); $cap = $m['capacity_watt'] !== null ? (int)$m['capacity_watt'] : null;
$over = $cap !== null && $cap > 0 && $load > $cap; ?>
<span class="text-muted"><i class="fas fa-bolt"></i> <?= $load ?> W<?php if ($cap !== null && $cap > 0): ?> / <?= $cap ?> W<?php endif; ?></span>
<?php if ($over): ?> <span class="status-pill status-pill--danger"><?= $this->te('Überlast') ?></span><?php endif; ?>
<?php elseif ($m['port_kind'] !== 'none'): ?>
<span class="text-muted"><?= (int)$m['port_count'] ?><?= (int)$m['sfp_count'] > 0 ? ' + ' . (int)$m['sfp_count'] . '× SFP' : '' ?><?= (int)$m['qsfp_count'] > 0 ? ' + ' . (int)$m['qsfp_count'] . '× QSFP' : '' ?></span>
<?php else: ?><span class="text-muted">–</span><?php endif; ?>
</td>
<td>
<?php if ($m['live'] === true): ?><span class="status-pill status-pill--success"><?= $this->te('online') ?></span>
<?php elseif ($m['live'] === false): ?><span class="status-pill status-pill--danger"><?= $this->te('offline') ?></span>
<?php else: ?><span class="text-muted">–</span><?php endif; ?>
</td>
<td class="text-right" style="white-space:nowrap">
<?php if ($labelable): ?>
<a href="/infrastructure/mounts/<?= (int)$m['id'] ?>/patchplan" target="_blank" class="btn btn-xs btn-default" title="<?= $this->te('Patchplan (PDF)') ?>"><i class="fas fa-file-pdf"></i></a>
<?php endif; ?>
<?php if (!empty($m['doc_page_id'])): ?>
<a href="/pages/<?= (int)$m['doc_page_id'] ?>" class="btn btn-xs btn-default" title="<?= $this->te('Verknüpfte Dokumentation') ?>"><i class="fas fa-book"></i></a>
<?php endif; ?>
<?php if (!empty($m['asset_id']) && ($modules['tickets'] ?? false)): ?>
<a href="/tickets/create?asset_id=<?= (int)$m['asset_id'] ?>" class="btn btn-xs btn-default" title="<?= $this->te('Ticket zu diesem Gerät anlegen') ?>"><i class="fas fa-life-ring"></i></a>
<?php endif; ?>
<?php if ($can_admin): ?>
<?php if ($labelable): ?>
<button type="button" class="btn btn-xs btn-default" title="<?= $this->te('Ports beschriften') ?>" onclick="infLabelPorts(<?= (int)$m['id'] ?>)"><i class="fas fa-tags"></i></button>
<?php endif; ?>
<button type="button" class="btn btn-xs btn-default" title="<?= $this->te('Bearbeiten') ?>" onclick="infEditMount(<?= (int)$m['id'] ?>)"><i class="fas fa-pencil-alt"></i></button>
<form method="POST" action="/infrastructure/mounts/<?= (int)$m['id'] ?>/delete" style="display:inline" onsubmit="return confirm('Einbau entfernen?');">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button aria-label="<?= $this->te('Löschen') ?>" type="submit" class="btn btn-xs btn-outline-danger"><i class="fas fa-trash"></i></button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
<div class="inf-tip" id="inf-tip"></div>
<script id="inf-rack-data" type="application/json"><?= json_encode([
'connections' => $conns,
'aPorts' => (object)$aPortMap,
'bPorts' => (object)$bPortMap,
'conns' => (object)$connEdit,
'portLabels' => (object)$portLabels,
'mounts' => (object)$mountEdit,
'rowUs' => array_values($rowUs),
'numbering' => $numbering,
'side' => $side,
'options' => ['docPages' => $docOptions, 'assets' => $assetOptions, 'unifi' => $unifiOptions],
'csrf' => $this->csrf(),
'connAddAction' => '/infrastructure/racks/' . (int)$rack['id'] . '/connections',
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?></script>
<script>
// Die helle Ansicht steht vor dem ersten Zeichnen fest, damit sie nicht erst
// nach dem Laden der Modul-Skripte umschlaegt.
try { if (localStorage.getItem('inf-rack-theme') === 'light') { document.getElementById('inf-stage').classList.add('inf-light'); } } catch (e) {}
</script>
<?php if ($can_admin): ?>
<?= $this->partial('infrastructure-rack-dialogs', [
'rack' => $rack,
'height' => $height,
'numbering' => $numbering,
'side' => $side,
'mounts' => $mounts,
'targets' => $targets,
'docOptions' => $docOptions,
'maxLoadKg' => $maxLoadKg,
'maxPowerW' => $maxPowerW,
]) ?>
<?php endif; ?>