OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

infrastructure.js

38 KB
/**
 * Infrastruktur-Modul – Rack-Elevation: Verkabelungs-Overlay und Bedienung.
 *
 * Die Seite liefert ihre Daten als JSON-Insel `#inf-rack-data` aus; dieses
 * Skript liest sie einmal nach `window.INFRA_RACK` und arbeitet damit. Alle
 * aus dem Markup heraus aufgerufenen Funktionen hängen an `window`.
 */
(function () {
    'use strict';

    var el = document.getElementById('inf-rack-data');
    if (!el) { return; }
    try {
        window.INFRA_RACK = JSON.parse(el.textContent);
    } catch (e) {
        window.INFRA_RACK = null;
    }
})();

/**
 * Verkabelungs-Overlay der Rack-Elevation.
 *
 * Zeichnet Verbindungen als 90°-geführte Leitungen im linken Kabelkanal, damit
 * die Ports frei und anklickbar bleiben (Patchdocs-Muster). Rack-interne
 * Verbindungen laufen als Schleife von Port zu Port; Verbindungen zu anderen
 * Racks/Standorten oder externen Zielen enden an einer Austrittsmarke oben.
 * Beschriftungen erscheinen erst bei Klick auf Port, Leitung oder Marke.
 */
(function () {
    'use strict';

    var data = window.INFRA_RACK;
    if (!data || !Array.isArray(data.connections)) {
        return;
    }

    var SVGNS = 'http://www.w3.org/2000/svg';
    // Farbe je Kabeltyp kommt zentral aus CSS-Tokens (--inf-cc); hier nur die Klasse.
    var CLASSES = {
        copper: 'inf-cbl--copper', fiber: 'inf-cbl--fiber', dac: 'inf-cbl--dac',
        wan: 'inf-cbl--wan', power: 'inf-cbl--power', other: 'inf-cbl--other'
    };
    function typeClass(t) { return CLASSES[t] || CLASSES.other; }
    var TYPE_LABEL = {
        copper: 'Kupfer', fiber: 'Fiber', dac: 'DAC', wan: 'WAN/VPN', power: 'Strom', other: 'Sonstige'
    };

    function slug(s) { return String(s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); }

    function stage() { return document.getElementById('inf-stage'); }
    function svg() { return document.getElementById('inf-cables'); }
    /** Aktueller Zoom der Bühne (transform: scale) – Koordinaten müssen darauf normiert werden. */
    function zoomOf() { var st = stage(); return (st && parseFloat(st.dataset.zoom)) || 1; }

    /** Port-Element eines Endpunkts, sonst dessen Geräte-Face als Fallback. */
    function endpointEl(mountId, port) {
        if (mountId == null) { return null; }
        return document.getElementById('inf-pt-' + mountId + '-' + slug(port))
            || document.getElementById('inf-face-' + mountId);
    }

    /** Mittelpunkt/Kanten eines Elements relativ zur Bühne (auf Zoom normiert). */
    function centerOf(el) {
        var z = zoomOf();
        var s = stage().getBoundingClientRect(), r = el.getBoundingClientRect();
        return {
            x: (r.left - s.left) / z + (r.width / z) / 2,
            y: (r.top - s.top) / z + (r.height / z) / 2,
            left: (r.left - s.left) / z,
            right: (r.right - s.left) / z
        };
    }

    /** Endpunkt inkl. gapY: die Kante des Geräte-Faces (dort liegen keine Ports). */
    function endpt(mountId, port) {
        var el = endpointEl(mountId, port);
        if (!el) { return null; }
        var z = zoomOf();
        var p = centerOf(el);
        var face = el.closest('.inf-face') || el;
        var s = stage().getBoundingClientRect(), r = face.getBoundingClientRect();
        var top = (r.top - s.top) / z, bot = (r.bottom - s.top) / z;
        p.gapY = (p.y < (top + bot) / 2) ? top - 3 : bot + 3;
        return p;
    }

    function laneBounds() {
        var lane = document.querySelector('#inf-stage .inf-cable-lane');
        if (!lane) { return { left: 14, right: 90 }; }
        var z = zoomOf();
        var s = stage().getBoundingClientRect(), r = lane.getBoundingClientRect();
        return { left: (r.left - s.left) / z, right: (r.right - s.left) / z };
    }

    function dist(a, b) { return Math.hypot(a[0] - b[0], a[1] - b[1]); }

    /** Polyline mit abgerundeten 90°-Ecken. */
    function roundedPath(pts, radius) {
        if (pts.length < 3) { return 'M ' + pts[0][0] + ' ' + pts[0][1] + ' L ' + pts[1][0] + ' ' + pts[1][1]; }
        var d = 'M ' + pts[0][0] + ' ' + pts[0][1];
        for (var i = 1; i < pts.length - 1; i++) {
            var p0 = pts[i - 1], p1 = pts[i], p2 = pts[i + 1];
            var rr = Math.min(radius, dist(p0, p1) / 2, dist(p1, p2) / 2);
            var d1 = [p1[0] - p0[0], p1[1] - p0[1]], l1 = Math.hypot(d1[0], d1[1]) || 1;
            var d2 = [p2[0] - p1[0], p2[1] - p1[1]], l2 = Math.hypot(d2[0], d2[1]) || 1;
            var a = [p1[0] - d1[0] / l1 * rr, p1[1] - d1[1] / l1 * rr];
            var b = [p1[0] + d2[0] / l2 * rr, p1[1] + d2[1] / l2 * rr];
            d += ' L ' + a[0] + ' ' + a[1] + ' Q ' + p1[0] + ' ' + p1[1] + ' ' + b[0] + ' ' + b[1];
        }
        var last = pts[pts.length - 1];
        return d + ' L ' + last[0] + ' ' + last[1];
    }

    // Highlight-Register: Verbindung → beteiligte Elemente (Leitung, Punkte,
    // Ports, Austrittsmarke); Port-Element-ID → Verbindungs-IDs; ID → Verbindung.
    var byConn = {}, byPort = {}, connById = {};

    function portEl(mountId, port) {
        return mountId != null ? document.getElementById('inf-pt-' + mountId + '-' + slug(port)) : null;
    }

    function register(connId, el) {
        if (!el) { return; }
        (byConn[connId] = byConn[connId] || []).push(el);
    }

    function highlight(connId, on) {
        (byConn[connId] || []).forEach(function (el) {
            if (el.classList) { el.classList.toggle('inf-hl', on); }
        });
    }

    // von außen (Verkabelungsliste rechts) aufrufbar: Verbindung im Rack hervorheben
    window.infHighlightConnection = function (connId, on) { highlight(connId, on); };

    function stroke(d, klass, connId) {
        var s = svg();
        var hit = document.createElementNS(SVGNS, 'path'); // breite, unsichtbare Trefferfläche
        hit.setAttribute('d', d); hit.setAttribute('fill', 'none');
        hit.setAttribute('stroke', 'transparent'); hit.setAttribute('stroke-width', '14');
        hit.style.pointerEvents = 'stroke'; hit.style.cursor = 'pointer';
        s.appendChild(hit);

        var vis = document.createElementNS(SVGNS, 'path');
        vis.setAttribute('d', d); vis.setAttribute('fill', 'none');
        vis.setAttribute('class', 'inf-cbl-vis ' + klass); // Strichfarbe via CSS-Token (--inf-cc)
        vis.setAttribute('stroke-width', '2.6');
        vis.setAttribute('stroke-linecap', 'round'); vis.setAttribute('opacity', '.92');
        s.appendChild(vis);
        register(connId, vis);
        return hit;
    }

    function dot(x, y, klass, connId) {
        var c = document.createElementNS(SVGNS, 'circle');
        c.setAttribute('cx', x); c.setAttribute('cy', y); c.setAttribute('r', '3.2');
        c.setAttribute('class', 'inf-cdot ' + klass);
        svg().appendChild(c);
        register(connId, c);
    }

    function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (m) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[m]; }); }

    function tipHtml(c) {
        var type = TYPE_LABEL[c.cable_type] || c.cable_type;
        var near = esc(c.near.dev) + (c.near.port ? ' · <span class="k">' + esc(c.near.port) + '</span>' : '');
        var far;
        if (c.far.external) {
            far = '<b>' + esc(c.far.dev) + '</b>';
        } else {
            var loc = c.far.location ? '<b>📍 ' + esc(c.far.location) + '</b> · ' : '';
            far = loc + esc(c.far.rack ? c.far.rack + ' · ' : '') + esc(c.far.dev) + (c.far.port ? ' · <span class="k">' + esc(c.far.port) + '</span>' : '');
        }
        var lbl = c.label ? '<br>' + esc(c.label) : '';
        return near + '&nbsp; ↔ &nbsp;' + far + '<br><span class="k">' + esc(type) + '</span>' + lbl;
    }

    // Hover: Trio (Quellport, Zielport, Leitung) hervorheben + Tooltip; Klick: bearbeiten
    function bindConnHover(el, connId, tipPos) {
        el.addEventListener('mouseenter', function () { highlight(connId, true); showTip(tipHtml(connById[connId]), tipPos()); });
        el.addEventListener('mouseleave', function () { highlight(connId, false); hideTip(); });
        el.addEventListener('click', function (e) { e.stopPropagation(); if (window.infEditConnection) { window.infEditConnection(connId); } });
    }

    function draw() {
        var st = stage(), s = svg();
        if (!st || !s || !st.clientWidth) { return; }
        s.setAttribute('viewBox', '0 0 ' + st.clientWidth + ' ' + st.clientHeight);
        s.innerHTML = '';
        Array.prototype.forEach.call(document.querySelectorAll('.inf-exit-mark'), function (m) { m.remove(); });
        byConn = {};

        var lane = laneBounds();
        var within = [], exits = [];
        // Marker-Verbindungen (AP über Patchpanel): das Patchkabel Patchpanel↔Switch
        // wird normal gezeichnet; der Anschluss zum Gerät selbst (AP) wird separat
        // grau/dezent als kurzer Stich an der Patchpanel-Seite dargestellt.
        var markerLinks = [];
        data.connections.forEach(function (c) {
            if (c.from_marker) {
                markerLinks.push(c);
                if (c.far && !c.far.external) { (c.within ? within : exits).push(c); }
                return;
            }
            (c.within ? within : exits).push(c);
        });

        within.forEach(function (c, i) {
            var a = endpt(c.near.mount_id, c.near.port);
            var b = endpt(c.far.mount_id, c.far.port);
            if (!a || !b) { return; }
            var laneX = lane.right - 12 - i * 14;
            var klass = typeClass(c.cable_type);
            var pts = [[a.x, a.y], [a.x, a.gapY], [laneX, a.gapY], [laneX, b.gapY], [b.x, b.gapY], [b.x, b.y]];
            var hit = stroke(roundedPath(pts, 9), klass, c.id);
            dot(a.x, a.y, klass, c.id); dot(b.x, b.y, klass, c.id);
            register(c.id, portEl(c.near.mount_id, c.near.port));
            register(c.id, portEl(c.far.mount_id, c.far.port));
            bindConnHover(hit, c.id, function () { return { x: laneX, y: (a.gapY + b.gapY) / 2 }; });
        });

        exits.forEach(function (c, i) {
            var a = endpt(c.near.mount_id, c.near.port);
            if (!a) { return; }
            var laneX = lane.left + 10 + i * 14, topY = 6;
            var klass = typeClass(c.cable_type);
            var pts = [[a.x, a.y], [a.x, a.gapY], [laneX, a.gapY], [laneX, topY]];
            var hit = stroke(roundedPath(pts, 9), klass, c.id);
            dot(a.x, a.y, klass, c.id);
            register(c.id, portEl(c.near.mount_id, c.near.port));

            var mk = document.createElement('div');
            mk.className = 'inf-exit-mark ' + klass;
            mk.style.left = laneX + 'px'; mk.style.top = topY + 'px';
            mk.textContent = '↗';
            st.appendChild(mk);
            register(c.id, mk);
            bindConnHover(mk, c.id, function () { return { x: laneX, y: topY + 12 }; });
        });

        // Geräte-Anschluss (AP): läuft wie ein externes Gerät vom Patchpanel-Port
        // nach oben zu einer Austrittsmarke – aber grau und nicht anklickbar
        // (Festverkabelung, nicht patchbar). Hover zeigt das Gerät.
        markerLinks.forEach(function (c, i) {
            var a = endpt(c.near.mount_id, c.near.port);
            if (!a) { return; }
            var laneX = lane.left + 10 + (exits.length + i) * 14, topY = 6;
            var pts = [[a.x, a.y], [a.x, a.gapY], [laneX, a.gapY], [laneX, topY]];
            var d = roundedPath(pts, 9);
            var apName = c.label || (c.far && c.far.dev) || 'Gerät';
            var tip = '<b>' + esc(apName) + '</b><br><span class="k">Anschluss am Patchpanel</span>';

            var vis = document.createElementNS(SVGNS, 'path');
            vis.setAttribute('d', d); vis.setAttribute('fill', 'none');
            vis.setAttribute('class', 'inf-cbl-vis inf-cbl--muted');
            vis.setAttribute('stroke-width', '2.2'); vis.setAttribute('stroke-linecap', 'round');
            vis.setAttribute('opacity', '.5');
            svg().appendChild(vis);

            var dotc = document.createElementNS(SVGNS, 'circle');
            dotc.setAttribute('cx', a.x); dotc.setAttribute('cy', a.y); dotc.setAttribute('r', '3.2');
            dotc.setAttribute('class', 'inf-cdot inf-cbl--muted'); dotc.setAttribute('opacity', '.5');
            svg().appendChild(dotc);

            var hit = document.createElementNS(SVGNS, 'path');
            hit.setAttribute('d', d); hit.setAttribute('fill', 'none');
            hit.setAttribute('stroke', 'transparent'); hit.setAttribute('stroke-width', '12');
            hit.style.pointerEvents = 'stroke';
            svg().appendChild(hit);
            hit.addEventListener('mouseenter', function () { showTip(tip, { x: laneX, y: (a.gapY + topY) / 2 }); });
            hit.addEventListener('mouseleave', hideTip);

            var mk = document.createElement('div');
            mk.className = 'inf-exit-mark inf-cbl--muted';
            mk.style.left = laneX + 'px'; mk.style.top = topY + 'px'; mk.style.opacity = '.6';
            mk.textContent = '↗';
            st.appendChild(mk);
            mk.addEventListener('mouseenter', function () { showTip(tip, { x: laneX, y: topY + 12 }); });
            mk.addEventListener('mouseleave', hideTip);
        });
    }

    // Ports einmalig verdrahten (statisches DOM). Belegt vs. frei wird zur
    // Klickzeit entschieden (byPort ist erst nach draw() gefüllt).
    function wirePorts() {
        data.connections.forEach(function (c) {
            connById[c.id] = c;
            var kn = 'inf-pt-' + c.near.mount_id + '-' + slug(c.near.port);
            (byPort[kn] = byPort[kn] || []).push(c.id);
            if (c.within && c.far.mount_id != null) {
                var kf = 'inf-pt-' + c.far.mount_id + '-' + slug(c.far.port);
                (byPort[kf] = byPort[kf] || []).push(c.id);
            }
        });
        Array.prototype.forEach.call(document.querySelectorAll('.inf-devcol .inf-pt[id]'), function (el) {
            el.style.cursor = 'pointer';
            el.addEventListener('mouseenter', function () {
                var cids = byPort[el.id];
                if (!cids || !cids.length) { return; }
                cids.forEach(function (cid) { highlight(cid, true); });
                showTip(cids.map(function (cid) { return tipHtml(connById[cid]); }).join('<hr class="inf-tip-sep">'), centerOf(el));
            });
            el.addEventListener('mouseleave', function () {
                var cids = byPort[el.id];
                if (cids) { cids.forEach(function (cid) { highlight(cid, false); }); }
                hideTip();
            });
            el.addEventListener('click', function (e) {
                e.stopPropagation();
                var cids = byPort[el.id];
                if (cids && cids.length) {
                    if (window.infEditConnection) { window.infEditConnection(cids[0]); }
                } else if (window.infAddConnectionAt) {
                    window.infAddConnectionAt(el.getAttribute('data-mount'), el.getAttribute('data-port'));
                }
            });
        });
    }

    function showTip(html, pos) {
        var t = document.getElementById('inf-tip'), s = stage().getBoundingClientRect(), z = zoomOf();
        if (!t) { return; }
        // position:fixed → Viewport-Koordinaten; Bühnen-lokale Koordinaten mit Zoom skalieren
        t.innerHTML = html; t.style.display = 'block';
        t.style.left = (s.left + pos.x * z) + 'px';
        t.style.top = (s.top + pos.y * z) + 'px';
    }
    function hideTip() { var t = document.getElementById('inf-tip'); if (t) { t.style.display = 'none'; } }

    // Von außen (Zoom-Steuerung) aufrufbar: Kabel neu zeichnen
    window.infRedrawCables = function () { draw(); };

    function init() {
        wirePorts();
        requestAnimationFrame(function () { requestAnimationFrame(draw); });
        window.addEventListener('resize', function () { hideTip(); draw(); });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();

/**
 * Ansicht der Rack-Elevation: helle/dunkle Darstellung und Zoom.
 *
 * Beide Einstellungen gelten pro Browser (localStorage). Der Zoom passt sich
 * beim Öffnen an die verfügbare Breite an, bis der Nutzer selbst zoomt.
 */
(function () {
    'use strict';

    var stage = document.getElementById('inf-stage');
    if (!stage) { return; }

    window.infToggleRackTheme = function () {
        var light = stage.classList.toggle('inf-light');
        try { localStorage.setItem('inf-rack-theme', light ? 'light' : 'dark'); } catch (e) {}
    };

    var KEY = 'inf-rack-zoom';
    var MIN = 0.3, MAX = 2, STEP = 0.15;
    // Ziel-Zoom beim Öffnen auf breiten Schirmen: kompakte 65 %, damit das Rack
    // mit Umgebung sichtbar ist. Schmale Schirme verkleinern bei Bedarf weiter.
    var FIT = 0.65;
    var curZoom = 1;
    var userSet = false; // hat der Nutzer manuell gezoomt?

    function els() {
        var sz = document.getElementById('inf-sizer');
        return sz ? { st: stage, sz: sz } : null;
    }
    function clamp(z) { return Math.max(MIN, Math.min(MAX, z)); }

    // Skalierung, bei der das (unskalierte) Rack in seinen Scroll-Container passt.
    // Auf breiten Schirmen wird höchstens auf FIT (65 %) vergrößert; passt das Rack
    // dort nicht in die Breite, wird weiter verkleinert (schmale Schirme/Handy).
    function fitZoom(e) {
        var scroll = e.st.closest('.inf-rack-scroll');
        var avail = scroll ? scroll.clientWidth : (e.st.parentElement ? e.st.parentElement.clientWidth : 0);
        var natural = e.st.offsetWidth; // transform-unabhängig
        if (!avail || !natural) { return FIT; }
        return Math.max(MIN, Math.min(FIT, avail / natural));
    }

    function render(e) {
        var z = curZoom;
        e.st.dataset.zoom = z;
        e.st.style.transform = z === 1 ? '' : 'scale(' + z + ')';
        // offsetWidth/Height sind unskaliert → skalierten Platz reservieren
        e.sz.style.width = (e.st.offsetWidth * z) + 'px';
        e.sz.style.height = (e.st.offsetHeight * z) + 'px';
        var lvl = document.getElementById('inf-zoom-lvl');
        if (lvl) { lvl.textContent = Math.round(z * 100) + '%'; }
        if (window.infRedrawCables) { window.infRedrawCables(); }
    }

    window.infZoom = function (dir) {
        var e = els(); if (!e) { return; }
        if (dir === 0) {
            // Zurücksetzen = wieder an den Bildschirm anpassen
            userSet = false;
            try { localStorage.removeItem(KEY); } catch (err) {}
            curZoom = fitZoom(e);
        } else {
            userSet = true;
            curZoom = clamp(curZoom + dir * STEP);
            try { localStorage.setItem(KEY, curZoom); } catch (err) {}
        }
        render(e);
    };

    function boot() {
        var e = els(); if (!e) { return; }
        var stored = NaN;
        try { stored = parseFloat(localStorage.getItem(KEY)); } catch (err) {}
        if (!isNaN(stored) && stored > 0) { userSet = true; curZoom = clamp(stored); }
        else { userSet = false; curZoom = fitZoom(e); }
        render(e);
    }

    // Bei Größen-/Ausrichtungsänderung neu einpassen – solange nicht manuell gezoomt.
    var rt;
    window.addEventListener('resize', function () {
        if (userSet) { return; }
        clearTimeout(rt);
        rt = setTimeout(function () { var e = els(); if (e) { curZoom = fitZoom(e); render(e); } }, 150);
    });

    setTimeout(boot, 80);
})();

/**
 * Dialoge der Rack-Seite: Einbauen, Bearbeiten, Verbinden, Ports beschriften.
 *
 * Welche Felder ein Dialog zeigt, hängt am Gerätetyp: nur Typen mit Ports
 * bekommen Portzahlen, nur netzwerkfähige zusätzlich SFP/QSFP, und die
 * Rückseiten-Felder erscheinen erst bei voller Einbautiefe.
 */
(function () {
    'use strict';

    var D = window.INFRA_RACK;
    if (!D || !D.aPorts) { return; }

    var A_PORTS = D.aPorts, B_PORTS = D.bPorts, CONNS = D.conns;
    var PORT_LABELS = D.portLabels, MOUNTS = D.mounts;

    function byId(id) { return document.getElementById(id); }
    function show(id, on) { var e = byId(id); if (e) { e.style.display = on ? '' : 'none'; } }

    /**
     * Fuellt die Auswahllisten eines Dialogs aus den Seitendaten.
     *
     * Die langen Listen (Dokuseiten, Inventar, UniFi) stehen einmal als Daten
     * im Seitenkopf statt als Markup in jedem Dialog. Gefuellt wird beim ersten
     * Oeffnen: Ein ungeoeffneter Dialog kostet damit weder Quelltext noch
     * Auswahlfelder im Dokument. Ein bereits gesetzter Wert bleibt erhalten.
     *
     * @param {ParentNode} root Dialog oder ein anderer Bereich mit Auswahlfeldern.
     */
    function fillSelects(root) {
        var lists = D.options || {};
        Array.prototype.forEach.call(root.querySelectorAll('select[data-inf-fill]'), function (sel) {
            if (sel.dataset.infFilled) { return; }
            var rows = lists[sel.dataset.infFill];
            if (!rows) { return; }
            var vorhanden = {};
            Array.prototype.forEach.call(sel.options, function (o) { vorhanden[o.value] = true; });
            var frag = document.createDocumentFragment();
            rows.forEach(function (row) {
                if (vorhanden[String(row[0])]) { return; }
                var o = document.createElement('option');
                o.value = row[0];
                o.textContent = row[1];
                if (row[2]) { o.setAttribute('data-model', row[2]); }
                frag.appendChild(o);
            });
            sel.appendChild(frag);
            sel.dataset.infFilled = '1';
            // Das durchsuchbare Dropdown baut seine Liste erst beim Aufklappen aus
            // dem Select; hier genuegt der Abgleich der Beschriftung.
            if (window.ssSync) { window.ssSync(sel); }
        });
    }

    /** Oeffnet einen Dialog und stellt sicher, dass seine Auswahllisten gefuellt sind. */
    function openModal(id) {
        var m = byId(id);
        if (!m) { return null; }
        fillSelects(m);
        m.style.display = 'flex';
        return m;
    }
    window.infFillSelects = fillSelects;
    window.infOpenModal = openModal;

    window.infSource = function (v) {
        show('inf-f-asset', v === 'asset');
        show('inf-f-unifi', v === 'unifi');
        show('inf-f-label', v === 'passive');
    };

    /** Beschriftung der Portzahl – ein Server hat NICs, eine PDU Ausgänge. */
    function countLabel(v) {
        return v === 'server' ? 'NICs'
            : ((v === 'pdu' || v === 'ups') ? window.onitText('Ausgänge')
            : (v === 'pbx' ? window.onitText('Anschlüsse') : window.onitText('Ports')));
    }
    function hasCount(v) { return ['switch', 'firewall', 'patchpanel', 'gateway', 'pdu', 'ups', 'server', 'pbx'].indexOf(v) >= 0; }
    function hasUplink(v) { return ['switch', 'firewall', 'gateway', 'server'].indexOf(v) >= 0; }
    window.infCountLabel = countLabel;
    window.infHasCount = hasCount;
    window.infHasUplink = hasUplink;

    /** Sichtbarkeit der Portfelder eines Dialogs; `pfx` ist 'add' oder 'edit'. */
    function portKind(pfx, v) {
        var cnt = hasCount(v), up = hasUplink(v);
        var fd = (byId('inf-' + pfx + '-fulldepth') || {}).checked;
        show('inf-' + pfx + '-portcounts', cnt);
        show('inf-' + pfx + '-sfp-wrap', up);
        show('inf-' + pfx + '-qsfp-wrap', up);
        show('inf-' + pfx + '-layout-wrap', v === 'switch');
        show('inf-' + pfx + '-cap-wrap', v === 'ups' || v === 'pdu');
        show('inf-' + pfx + '-rear-wrap', cnt && fd);
        show('inf-' + pfx + '-rsfp-wrap', up);
        show('inf-' + pfx + '-rqsfp-wrap', up);
        var l = byId('inf-' + pfx + '-pc-label'); if (l) { l.textContent = countLabel(v); }
        var rl = byId('inf-' + pfx + '-rpc-label'); if (rl) { rl.textContent = countLabel(v) + ' hinten'; }
    }

    window.infPortKind = function (v) { portKind('add', v); };
    window.infEditPortKind = function (v) { portKind('edit', v); };
    window.infAddDepth = function () { var k = byId('inf-portkind'); portKind('add', k ? k.value : 'none'); };
    window.infEditDepth = function () { var k = document.querySelector('#inf-edit-form [name=port_kind]'); portKind('edit', k ? k.value : 'none'); };

    // Gerätefarbe: Farb-Input nur aktiv (und damit absendbar), wenn die Checkbox gesetzt ist.
    window.infToggleColor = function (mode) {
        var cb = byId('inf-' + mode + '-usecolor');
        var ci = byId('inf-' + mode + '-color');
        if (cb && ci) { ci.disabled = !cb.checked; }
    };

    // Kabelfarbe nur senden, wenn sie bewusst gesetzt wurde – ein deaktiviertes
    // Feld überträgt nichts, die Verbindung bleibt damit ohne Farbe.
    window.infToggleCableColor = function () {
        var cb = byId('inf-conn-usecolor');
        var inp = byId('inf-conn-color');
        if (cb && inp) { inp.disabled = !cb.checked; }
    };

    window.infBKind = function (v) {
        show('inf-b-mount-wrap', v !== 'external');
        show('inf-b-ext-wrap', v === 'external');
    };

    // nach programmatischem Setzen von <select>-Werten die durchsuchbaren Wrapper aktualisieren
    function syncSelects(root) {
        if (!window.ssSync) { return; }
        Array.prototype.forEach.call(root.querySelectorAll('select'), function (s) { window.ssSync(s); });
    }
    window.infSyncSelects = syncSelects;

    window.infOpenAdd = function () {
        var cb = byId('inf-add-usecolor'); if (cb) { cb.checked = false; }
        window.infToggleColor('add');
        openModal('inf-mount-modal');
    };

    window.infAddAt = function (u, slot) {
        byId('inf-add-start').value = u;
        var side = byId('inf-add-side');
        side.value = D.side;
        if (window.ssSync) { window.ssSync(side); }
        var sl = byId('inf-add-slot');
        if (sl) { sl.value = slot || 'full'; if (window.ssSync) { window.ssSync(sl); } }
        openModal('inf-mount-modal');
    };

    /**
     * Fuellt den Bearbeiten-Dialog mit dem Stand eines eingebauten Geraets.
     *
     * Der Stand kommt aus den Seitendaten – dieselbe Quelle, aus der auch das
     * Verschieben per Ziehen liest. So beschreibt genau eine Stelle das Geraet.
     *
     * @param {number} mountId Kennung des Einbaus.
     */
    window.infEditMount = function (mountId) {
        var m = MOUNTS[mountId]; if (!m) { return; }
        var f = byId('inf-edit-form');
        f.action = '/infrastructure/mounts/' + mountId + '/update';
        f.elements['label'].value = m.label != null ? m.label : '';
        f.elements['start_u'].value = m.start_u;
        f.elements['height_u'].value = m.height_u;
        f.elements['side'].value = m.side;
        f.elements['port_kind'].value = m.port_kind;
        f.elements['port_count'].value = m.port_count;
        f.elements['sfp_count'].value = m.sfp_count;
        f.elements['qsfp_count'].value = m.qsfp_count != null ? m.qsfp_count : 0;
        if (f.elements['port_layout']) { f.elements['port_layout'].value = m.port_layout || 'zebra'; }
        f.elements['power_watt'].value = m.power_watt != null ? m.power_watt : '';
        f.elements['weight_kg'].value = m.weight_kg != null ? m.weight_kg : '';
        if (f.elements['capacity_watt']) { f.elements['capacity_watt'].value = m.capacity_watt != null ? m.capacity_watt : ''; }
        if (f.elements['slot']) { f.elements['slot'].value = m.slot || 'full'; }
        var ecb = byId('inf-edit-usecolor'), eci = byId('inf-edit-color');
        if (ecb && eci) {
            if (m.color) { ecb.checked = true; eci.value = m.color; } else { ecb.checked = false; }
            window.infToggleColor('edit');
        }
        if (f.elements['full_depth']) { f.elements['full_depth'].checked = !!(m.full_depth === 1 || m.full_depth === true); }
        if (f.elements['port_count_rear']) { f.elements['port_count_rear'].value = m.port_count_rear != null ? m.port_count_rear : 0; }
        if (f.elements['sfp_count_rear']) { f.elements['sfp_count_rear'].value = m.sfp_count_rear != null ? m.sfp_count_rear : 0; }
        if (f.elements['qsfp_count_rear']) { f.elements['qsfp_count_rear'].value = m.qsfp_count_rear != null ? m.qsfp_count_rear : 0; }
        portKind('edit', m.port_kind);
        // Die Auswahllisten stehen erst nach dem Fuellen zur Verfuegung; der
        // hinterlegte Dokuverweis wird danach gesetzt und bleibt so erhalten.
        fillSelects(byId('inf-edit-modal'));
        if (f.elements['doc_page_id']) { f.elements['doc_page_id'].value = m.doc_page_id != null ? m.doc_page_id : ''; }
        syncSelects(f);
        openModal('inf-edit-modal');
    };

    // Bekannte UniFi-Modelle → Port-Profil (Autofill beim Einbauen); spezifische zuerst
    var UNIFI_PORTS = [
        { re: /EnterpriseCampus|Campus/i,   kind: 'switch',  pc: 0,  sfp: 48 },
        { re: /Pro.?Aggregation/i,          kind: 'switch',  pc: 0,  sfp: 28 },
        { re: /Aggregation/i,               kind: 'switch',  pc: 0,  sfp: 8 },
        { re: /(Pro.?)?48|48.?PoE/i,        kind: 'switch',  pc: 48, sfp: 4 },
        { re: /(Pro.?)?24|24.?PoE/i,        kind: 'switch',  pc: 24, sfp: 2 },
        { re: /16/i,                        kind: 'switch',  pc: 16, sfp: 2 },
        { re: /Flex|Lite.?8|USW.?8|Ultra/i, kind: 'switch',  pc: 8,  sfp: 0 },
        { re: /UDM.?(Pro|SE)|UXG/i,         kind: 'gateway', pc: 8,  sfp: 2 },
        { re: /UDM|USG/i,                   kind: 'gateway', pc: 4,  sfp: 0 }
    ];
    window.infUnifiAutofill = function (sel) {
        var opt = sel.options[sel.selectedIndex];
        var model = opt ? (opt.getAttribute('data-model') || '') : '';
        if (!model) { return; }
        for (var i = 0; i < UNIFI_PORTS.length; i++) {
            if (UNIFI_PORTS[i].re.test(model)) {
                var pk = byId('inf-portkind');
                pk.value = UNIFI_PORTS[i].kind;
                portKind('add', pk.value);
                if (window.ssSync) { window.ssSync(pk); }
                byId('inf-add-pc').value = UNIFI_PORTS[i].pc;
                byId('inf-add-sfp').value = UNIFI_PORTS[i].sfp;
                break;
            }
        }
    };

    // Port-Vorschläge (datalist) je Gerätetyp – Rückseiten-Ports mit Präfix „R"
    function portsFor(kind, pc, sfp, qsfp, pfx) {
        var out = []; var i, s, x;
        pfx = pfx || ''; pc = pc || 0; sfp = sfp || 0; qsfp = qsfp || 0;
        if (kind === 'switch' || kind === 'firewall') {
            for (i = 1; i <= pc; i++) { out.push(pfx + i); }
            for (s = 1; s <= sfp; s++) { out.push(pfx + 'SFP' + s); }
            for (x = 1; x <= qsfp; x++) { out.push(pfx + 'QSFP' + x); }
        } else if (kind === 'patchpanel' || kind === 'pbx') {
            for (i = 1; i <= pc; i++) { out.push(pfx + i); }
        } else if (kind === 'gateway') {
            out.push(pfx + 'WAN1');
            for (i = 1; i <= Math.max(1, pc); i++) { out.push(pfx + 'LAN' + i); }
            for (s = 1; s <= sfp; s++) { out.push(pfx + 'SFP' + s); }
            for (x = 1; x <= qsfp; x++) { out.push(pfx + 'QSFP' + x); }
        } else if (kind === 'server') {
            for (i = 1; i <= pc; i++) { out.push(pfx + 'NIC' + i); }
            for (s = 1; s <= sfp; s++) { out.push(pfx + 'SFP' + s); }
            for (x = 1; x <= qsfp; x++) { out.push(pfx + 'QSFP' + x); }
        } else if (kind === 'ups') {
            var un = pc > 0 ? pc : 8;
            for (i = 1; i <= un; i++) { out.push(pfx + 'OUT' + i); }
        }
        return out;
    }
    function suggestPorts(p) {
        if (!p) { return []; }
        var out = portsFor(p.kind, p.pc, p.sfp, p.qsfp || 0, '');
        if ((p.pcR || 0) + (p.sfpR || 0) + (p.qsfpR || 0) > 0) {
            out = out.concat(portsFor(p.kind, p.pcR, p.sfpR, p.qsfpR, 'R'));
        }
        return out;
    }
    function fillDatalist(dlId, profile) {
        var dl = byId(dlId); if (!dl) { return; }
        dl.innerHTML = '';
        suggestPorts(profile).forEach(function (v) {
            var o = document.createElement('option'); o.value = v; dl.appendChild(o);
        });
    }
    window.infPortsFor = portsFor;
    window.infSuggestPorts = suggestPorts;
    window.infFillDatalist = fillDatalist;
    window.infBPorts = function (id) { fillDatalist('inf-b-portlist', B_PORTS[id]); };

    // Ports beschriften: Dialog mit einem Eingabefeld je Port des Geräts
    window.infLabelPorts = function (mountId) {
        var p = A_PORTS[mountId]; if (!p) { return; }
        var refs = suggestPorts(p);
        var labels = PORT_LABELS[mountId] || {};
        var body = byId('inf-portlabel-body');
        body.innerHTML = '';
        refs.forEach(function (ref) {
            var row = document.createElement('div'); row.className = 'form-group inf-pl-row';
            var lab = document.createElement('label'); lab.textContent = 'Port ' + ref;
            var inp = document.createElement('input');
            inp.type = 'text'; inp.className = 'form-control form-control-sm';
            inp.name = 'label[' + ref + ']'; inp.value = labels[ref] || '';
            inp.placeholder = window.onitText('z. B. Büro 1.02');
            row.appendChild(lab); row.appendChild(inp); body.appendChild(row);
        });
        byId('inf-portlabel-form').action = '/infrastructure/mounts/' + mountId + '/port-labels';
        openModal('inf-portlabel-modal');
    };

    // Verbindungs-Dialog: Anlegen / Bearbeiten (ein wiederverwendetes Modal)
    function openConnAdd() {
        var f = byId('inf-conn-form');
        f.reset();
        f.action = D.connAddAction;
        byId('inf-conn-title').textContent = 'Verbindung anlegen';
        window.infBKind('mount');
        fillDatalist('inf-a-portlist', null);
        fillDatalist('inf-b-portlist', null);
        syncSelects(f);
        openModal('inf-conn-modal');
    }
    window.infOpenConnAdd = openConnAdd;

    // von der Elevation: Klick auf freien Port → neue Verbindung mit vorbelegtem Endpunkt A
    window.infAddConnectionAt = function (mountId, portRef) {
        openConnAdd();
        var f = byId('inf-conn-form');
        if (mountId != null && mountId !== '') { f.elements['a_mount_id'].value = mountId; }
        f.elements['a_port'].value = portRef || '';
        fillDatalist('inf-a-portlist', A_PORTS[mountId]);
        syncSelects(f);
    };

    // aus der Liste oder per Klick auf belegten Port/Leitung → Verbindung bearbeiten
    window.infEditConnection = function (id) {
        var c = CONNS[id]; if (!c) { return; }
        var f = byId('inf-conn-form');
        f.reset();
        f.action = '/infrastructure/connections/' + id + '/update';
        byId('inf-conn-title').textContent = 'Verbindung bearbeiten';
        f.elements['a_mount_id'].value = c.a_mount_id != null ? c.a_mount_id : '';
        f.elements['a_port'].value = c.a_port || '';
        f.elements['b_kind'].value = c.b_kind;
        window.infBKind(c.b_kind);
        f.elements['b_mount_id'].value = c.b_mount_id != null ? c.b_mount_id : '';
        f.elements['b_external'].value = c.b_external || '';
        f.elements['b_port'].value = c.b_port || '';
        f.elements['cable_type'].value = c.cable_type;
        f.elements['label'].value = c.label || '';
        fillDatalist('inf-a-portlist', A_PORTS[c.a_mount_id]);
        fillDatalist('inf-b-portlist', B_PORTS[c.b_mount_id]);
        syncSelects(f);
        openModal('inf-conn-modal');
    };

    var aSel = byId('inf-a-mount');
    if (aSel) {
        aSel.addEventListener('change', function () { fillDatalist('inf-a-portlist', A_PORTS[this.value]); });
    }

    // Drag&Drop: Gerät im Rack auf eine andere HE verschieben (Server validiert Überlappung/Höhe)
    var devcol = byId('inf-devcol');
    if (devcol) {
        var dragId = null;
        Array.prototype.forEach.call(document.querySelectorAll('.inf-face.inf-draggable'), function (f) {
            f.addEventListener('dragstart', function (e) {
                dragId = f.getAttribute('data-mount-id');
                e.dataTransfer.setData('text/plain', dragId);
                e.dataTransfer.effectAllowed = 'move';
                f.classList.add('inf-dragging');
            });
            f.addEventListener('dragend', function () {
                f.classList.remove('inf-dragging');
                devcol.classList.remove('inf-droptarget');
            });
        });
        devcol.addEventListener('dragover', function (e) {
            if (dragId !== null) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; devcol.classList.add('inf-droptarget'); }
        });
        devcol.addEventListener('dragleave', function (e) {
            if (e.target === devcol) { devcol.classList.remove('inf-droptarget'); }
        });
        devcol.addEventListener('drop', function (e) {
            e.preventDefault(); devcol.classList.remove('inf-droptarget');
            var id = e.dataTransfer.getData('text/plain') || dragId; if (!id) { return; }
            var m = MOUNTS[id]; if (!m) { return; }
            var uEl = document.querySelector('.inf-uscale .inf-u');
            var uPx = uEl ? uEl.getBoundingClientRect().height : 34;
            var rect = devcol.getBoundingClientRect();
            var idx = Math.floor((e.clientY - rect.top) / uPx);
            if (idx < 0) { idx = 0; }
            if (idx >= D.rowUs.length) { idx = D.rowUs.length - 1; }
            var physTopU = D.rowUs[idx];
            var newStart = (D.numbering === 'top_down') ? physTopU : (physTopU - m.height_u + 1);
            if (newStart < 1) { newStart = 1; }
            if (newStart === m.start_u) { return; }
            var f = document.createElement('form');
            f.method = 'POST';
            f.action = '/infrastructure/mounts/' + id + '/update';
            function add(n, v) {
                var i = document.createElement('input');
                i.type = 'hidden'; i.name = n; i.value = (v == null ? '' : v);
                f.appendChild(i);
            }
            add('_csrf', D.csrf); add('start_u', newStart); add('height_u', m.height_u); add('side', m.side);
            add('slot', m.slot); add('full_depth', m.full_depth ? 1 : '');
            add('label', m.label); add('port_kind', m.port_kind); add('port_count', m.port_count); add('sfp_count', m.sfp_count);
            add('qsfp_count', m.qsfp_count); add('port_layout', m.port_layout);
            add('port_count_rear', m.port_count_rear); add('sfp_count_rear', m.sfp_count_rear); add('qsfp_count_rear', m.qsfp_count_rear);
            add('power_watt', m.power_watt); add('weight_kg', m.weight_kg);
            add('capacity_watt', m.capacity_watt);
            add('color', m.color);
            document.body.appendChild(f); f.submit();
        });
    }

    // Escape schließt offene Dialoge
    document.addEventListener('keydown', function (e) {
        if (e.key === 'Escape') {
            document.querySelectorAll('.modal-overlay').forEach(function (m) { m.style.display = 'none'; });
        }
    });
})();