Compare commits
6 Commits
f95563b233
...
232eead696
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
232eead696 | ||
|
|
f8c55e4fea | ||
|
|
55bbd45562 | ||
|
|
29fc683675 | ||
|
|
65d46d925d | ||
|
|
a0b97d403e |
@@ -30,6 +30,22 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infra-plan-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-plan-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-plan-tools {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.infra-floor-canvas {
|
.infra-floor-canvas {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
@@ -39,23 +55,35 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-floor-canvas.is-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-floor-scene {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.infra-floor-svg {
|
.infra-floor-svg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
left: 0;
|
||||||
width: 100%;
|
top: 0;
|
||||||
height: 100%;
|
object-fit: fill;
|
||||||
object-fit: contain;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.85;
|
opacity: 0.22;
|
||||||
}
|
}
|
||||||
|
|
||||||
.infra-floor-overlay {
|
.infra-floor-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
left: 0;
|
||||||
width: 100%;
|
top: 0;
|
||||||
height: 100%;
|
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,13 +91,47 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.infra-overlay-marker.patchpanel {
|
.infra-room-shape {
|
||||||
|
fill: rgba(60, 102, 164, 0.06);
|
||||||
|
stroke: rgba(60, 102, 164, 0.55);
|
||||||
|
stroke-width: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-room-label {
|
||||||
|
fill: rgba(34, 59, 95, 0.9);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: middle;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-connection-path {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(212, 97, 54, 0.8);
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-dasharray: 6 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-node {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-node-label {
|
||||||
|
font-size: 13px;
|
||||||
|
fill: #223a61;
|
||||||
|
font-weight: 600;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-node--patchpanel .infra-node-shape {
|
||||||
fill: rgba(13, 110, 253, 0.25);
|
fill: rgba(13, 110, 253, 0.25);
|
||||||
stroke: #0d6efd;
|
stroke: #0d6efd;
|
||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.infra-overlay-marker.outlet {
|
.infra-node--outlet .infra-node-shape {
|
||||||
fill: rgba(25, 135, 84, 0.25);
|
fill: rgba(25, 135, 84, 0.25);
|
||||||
stroke: #198754;
|
stroke: #198754;
|
||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
window.Dashboard = (function () {
|
window.Dashboard = (function () {
|
||||||
const modules = [
|
const modules = [
|
||||||
{ id: 'device_types', label: 'Geraetetypen', description: 'Geraetetypen und Port-Definitionen', url: '?module=device_types&action=list', icon: 'DT' },
|
{ id: 'device_types', label: 'Gerätetypen', description: 'Gerätetypen und Port-Definitionen', url: '?module=device_types&action=list', icon: 'DT' },
|
||||||
{ id: 'devices', label: 'Geraete', description: 'Physische Geraete in Racks und Raeumen', url: '?module=devices&action=list', icon: 'DV' },
|
{ id: 'devices', label: 'Geräte', description: 'Physische Geräte in Racks und Räumen', url: '?module=devices&action=list', icon: 'DV' },
|
||||||
{ id: 'connections', label: 'Verbindungen', description: 'Kabel, Ports und VLANs', url: '?module=connections&action=list', icon: 'CN' },
|
{ id: 'connections', label: 'Verbindungen', description: 'Kabel, Ports und VLANs', url: '?module=connections&action=list', icon: 'CN' },
|
||||||
{ id: 'floors', label: 'Stockwerke', description: 'Standorte, Gebaeude und Etagen', url: '?module=floors&action=list', icon: 'FL' },
|
{ id: 'floors', label: 'Stockwerke', description: 'Standorte, Gebäude und Etagen', url: '?module=floors&action=list', icon: 'FL' },
|
||||||
{ id: 'racks', label: 'Racks', description: 'Racks und Positionierung', url: '?module=racks&action=list', icon: 'RK' },
|
{ id: 'racks', label: 'Racks', description: 'Racks und Positionierung', url: '?module=racks&action=list', icon: 'RK' },
|
||||||
{ id: 'infra', label: 'Infrastruktur', description: 'Patchpanels und Wandbuchsen', url: '?module=floor_infrastructure&action=list', icon: 'IF' }
|
{ id: 'infra', label: 'Infrastruktur', description: 'Patchpanels und Wandbuchsen', url: '?module=floor_infrastructure&action=list', icon: 'IF' }
|
||||||
];
|
];
|
||||||
@@ -53,7 +53,7 @@ window.Dashboard = (function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
target.textContent = `Geraete: ${stats.devices} | Verbindungen: ${stats.connections} | Infrastruktur-Eintraege: ${stats.outlets}`;
|
target.textContent = `Geräte: ${stats.devices} | Verbindungen: ${stats.connections} | Infrastruktur-Einträge: ${stats.outlets}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showWarnings() {
|
function showWarnings() {
|
||||||
@@ -64,7 +64,7 @@ window.Dashboard = (function () {
|
|||||||
|
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
if (countRows('.device-list tbody tr') === 0) {
|
if (countRows('.device-list tbody tr') === 0) {
|
||||||
warnings.push('Noch keine Geraete vorhanden');
|
warnings.push('Noch keine Geräte vorhanden');
|
||||||
}
|
}
|
||||||
if (countRows('.connection-list tbody tr') === 0) {
|
if (countRows('.connection-list tbody tr') === 0) {
|
||||||
warnings.push('Noch keine Verbindungen vorhanden');
|
warnings.push('Noch keine Verbindungen vorhanden');
|
||||||
@@ -79,7 +79,7 @@ window.Dashboard = (function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
target.textContent = 'Letzte Aenderungen werden serverseitig noch nicht protokolliert.';
|
target.textContent = 'Letzte Änderungen werden serverseitig noch nicht protokolliert.';
|
||||||
}
|
}
|
||||||
|
|
||||||
function countRows(selector) {
|
function countRows(selector) {
|
||||||
|
|||||||
@@ -12,55 +12,520 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
const canvas = document.getElementById('infra-floor-canvas');
|
const canvas = document.getElementById('infra-floor-canvas');
|
||||||
const overlay = document.getElementById('infra-floor-overlay');
|
const overlay = document.getElementById('infra-floor-overlay');
|
||||||
|
const scene = document.getElementById('infra-floor-scene');
|
||||||
const floorSvg = canvas ? canvas.querySelector('.infra-floor-svg') : null;
|
const floorSvg = canvas ? canvas.querySelector('.infra-floor-svg') : null;
|
||||||
if (!canvas || !overlay || !floorSvg) {
|
if (!canvas || !overlay || !scene || !floorSvg) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const patchPanels = safeJsonParse(canvas.dataset.patchpanels);
|
const patchPanels = safeJsonParse(canvas.dataset.patchpanels);
|
||||||
const outlets = safeJsonParse(canvas.dataset.outlets);
|
const outlets = safeJsonParse(canvas.dataset.outlets);
|
||||||
const planSize = { ...DEFAULT_PLAN_SIZE };
|
const rooms = safeJsonParse(canvas.dataset.rooms);
|
||||||
|
const links = safeJsonParse(canvas.dataset.links);
|
||||||
|
|
||||||
const updateOverlayViewBox = () => {
|
const planSize = { ...DEFAULT_PLAN_SIZE };
|
||||||
overlay.setAttribute('viewBox', `0 0 ${planSize.width} ${planSize.height}`);
|
const visibility = {
|
||||||
|
rooms: true,
|
||||||
|
connections: true
|
||||||
};
|
};
|
||||||
|
|
||||||
const createMarker = (entry, type) => {
|
const nodes = [];
|
||||||
const x = Number(entry.x);
|
patchPanels.forEach((entry) => {
|
||||||
const y = Number(entry.y);
|
nodes.push({
|
||||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
key: `patchpanel:${Number(entry.id || 0)}`,
|
||||||
|
type: 'patchpanel',
|
||||||
|
id: Number(entry.id || 0),
|
||||||
|
label: String(entry.name || 'Patchpanel'),
|
||||||
|
x: Number(entry.x || 0),
|
||||||
|
y: Number(entry.y || 0),
|
||||||
|
width: Math.max(8, Number(entry.width || 20)),
|
||||||
|
height: Math.max(4, Number(entry.height || 5)),
|
||||||
|
tooltipLines: buildTooltipLines(entry, 'patchpanel')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
outlets.forEach((entry) => {
|
||||||
|
nodes.push({
|
||||||
|
key: `outlet:${Number(entry.id || 0)}`,
|
||||||
|
type: 'outlet',
|
||||||
|
id: Number(entry.id || 0),
|
||||||
|
label: String(entry.name || 'Wandbuchse'),
|
||||||
|
x: Number(entry.x || 0),
|
||||||
|
y: Number(entry.y || 0),
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
tooltipLines: buildTooltipLines(entry, 'outlet')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeByKey = () => {
|
||||||
|
const map = new Map();
|
||||||
|
nodes.forEach((node) => map.set(node.key, node));
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
const camera = {
|
||||||
|
scale: 1,
|
||||||
|
tx: 0,
|
||||||
|
ty: 0
|
||||||
|
};
|
||||||
|
const SCALE_MIN = 0.65;
|
||||||
|
const SCALE_MAX = 4;
|
||||||
|
const SCALE_STEP = 0.14;
|
||||||
|
const PAN_STEP = 16;
|
||||||
|
|
||||||
|
let panDrag = null;
|
||||||
|
let nodeDrag = null;
|
||||||
|
|
||||||
|
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
|
const getFit = () => {
|
||||||
|
const canvasWidth = Math.max(1, canvas.clientWidth || 1);
|
||||||
|
const canvasHeight = Math.max(1, canvas.clientHeight || 1);
|
||||||
|
const scale = Math.min(canvasWidth / planSize.width, canvasHeight / planSize.height);
|
||||||
|
const baseX = (canvasWidth - (planSize.width * scale)) / 2;
|
||||||
|
const baseY = (canvasHeight - (planSize.height * scale)) / 2;
|
||||||
|
return { scale, baseX, baseY };
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyCamera = () => {
|
||||||
|
const fit = getFit();
|
||||||
|
const totalScale = fit.scale * camera.scale;
|
||||||
|
const tx = fit.baseX + camera.tx;
|
||||||
|
const ty = fit.baseY + camera.ty;
|
||||||
|
scene.style.transform = `translate(${tx}px, ${ty}px) scale(${totalScale})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toPlanPoint = (clientX, clientY) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const fit = getFit();
|
||||||
|
const totalScale = fit.scale * camera.scale;
|
||||||
|
const x = (clientX - rect.left - fit.baseX - camera.tx) / totalScale;
|
||||||
|
const y = (clientY - rect.top - fit.baseY - camera.ty) / totalScale;
|
||||||
|
return { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomAtPoint = (clientX, clientY, factor) => {
|
||||||
|
const before = toPlanPoint(clientX, clientY);
|
||||||
|
camera.scale = clamp(camera.scale * factor, SCALE_MIN, SCALE_MAX);
|
||||||
|
applyCamera();
|
||||||
|
const after = toPlanPoint(clientX, clientY);
|
||||||
|
const fit = getFit();
|
||||||
|
const totalScale = fit.scale * camera.scale;
|
||||||
|
camera.tx += (after.x - before.x) * totalScale;
|
||||||
|
camera.ty += (after.y - before.y) * totalScale;
|
||||||
|
applyCamera();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetCamera = () => {
|
||||||
|
camera.scale = 1;
|
||||||
|
camera.tx = 0;
|
||||||
|
camera.ty = 0;
|
||||||
|
applyCamera();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearOverlay = () => {
|
||||||
|
while (overlay.firstChild) {
|
||||||
|
overlay.removeChild(overlay.firstChild);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSvg = (name) => document.createElementNS(SVG_NS, name);
|
||||||
|
|
||||||
|
const parsePolygonPoints = (raw) => {
|
||||||
|
const text = String(raw || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((pair) => pair.split(','))
|
||||||
|
.filter((pair) => pair.length === 2)
|
||||||
|
.map((pair) => ({ x: Number(pair[0]), y: Number(pair[1]) }))
|
||||||
|
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoomCenter = (room) => {
|
||||||
|
const polygon = parsePolygonPoints(room.polygon_points);
|
||||||
|
if (polygon.length >= 3) {
|
||||||
|
let minX = polygon[0].x;
|
||||||
|
let minY = polygon[0].y;
|
||||||
|
let maxX = polygon[0].x;
|
||||||
|
let maxY = polygon[0].y;
|
||||||
|
polygon.forEach((point) => {
|
||||||
|
minX = Math.min(minX, point.x);
|
||||||
|
minY = Math.min(minY, point.y);
|
||||||
|
maxX = Math.max(maxX, point.x);
|
||||||
|
maxY = Math.max(maxY, point.y);
|
||||||
|
});
|
||||||
|
return { x: minX + ((maxX - minX) / 2), y: minY + ((maxY - minY) / 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = Number(room.width || 0);
|
||||||
|
const height = Number(room.height || 0);
|
||||||
|
const x = Number(room.x || 0);
|
||||||
|
const y = Number(room.y || 0);
|
||||||
|
return { x: x + (width / 2), y: y + (height / 2) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const build45Path = (from, to, laneIndex) => {
|
||||||
|
const dx = to.x - from.x;
|
||||||
|
const dy = to.y - from.y;
|
||||||
|
const sx = dx === 0 ? 1 : Math.sign(dx);
|
||||||
|
const sy = dy === 0 ? 1 : Math.sign(dy);
|
||||||
|
const shortest = Math.max(8, Math.min(Math.abs(dx), Math.abs(dy)) / 2);
|
||||||
|
const laneOffset = laneIndex * 6;
|
||||||
|
const diag = shortest + laneOffset;
|
||||||
|
|
||||||
|
const p1 = { x: from.x + (sx * diag), y: from.y + (sy * diag) };
|
||||||
|
const p2 = { x: to.x - (sx * diag), y: to.y - (sy * diag) };
|
||||||
|
|
||||||
|
return `M ${from.x} ${from.y} L ${p1.x} ${p1.y} L ${p2.x} ${p2.y} L ${to.x} ${to.y}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawRoomsLayer = (root) => {
|
||||||
|
if (!visibility.rooms) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roomLayer = createSvg('g');
|
||||||
|
roomLayer.setAttribute('class', 'infra-room-layer');
|
||||||
|
|
||||||
|
rooms.forEach((room) => {
|
||||||
|
const polygon = parsePolygonPoints(room.polygon_points);
|
||||||
|
if (polygon.length >= 3) {
|
||||||
|
const shape = createSvg('polygon');
|
||||||
|
shape.setAttribute('points', polygon.map((point) => `${point.x},${point.y}`).join(' '));
|
||||||
|
shape.setAttribute('class', 'infra-room-shape');
|
||||||
|
roomLayer.appendChild(shape);
|
||||||
|
} else {
|
||||||
|
const width = Math.max(0, Number(room.width || 0));
|
||||||
|
const height = Math.max(0, Number(room.height || 0));
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
const rect = createSvg('rect');
|
||||||
|
rect.setAttribute('x', String(Number(room.x || 0)));
|
||||||
|
rect.setAttribute('y', String(Number(room.y || 0)));
|
||||||
|
rect.setAttribute('width', String(width));
|
||||||
|
rect.setAttribute('height', String(height));
|
||||||
|
rect.setAttribute('class', 'infra-room-shape');
|
||||||
|
roomLayer.appendChild(rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = getRoomCenter(room);
|
||||||
|
const roomLabel = String(room.number || '').trim() || String(room.name || '');
|
||||||
|
if (roomLabel !== '') {
|
||||||
|
const text = createSvg('text');
|
||||||
|
text.setAttribute('x', String(center.x));
|
||||||
|
text.setAttribute('y', String(center.y));
|
||||||
|
text.setAttribute('class', 'infra-room-label');
|
||||||
|
text.textContent = roomLabel;
|
||||||
|
roomLayer.appendChild(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
root.appendChild(roomLayer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawConnectionsLayer = (root, nodeMap) => {
|
||||||
|
if (!visibility.connections) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const layer = createSvg('g');
|
||||||
|
layer.setAttribute('class', 'infra-connection-layer');
|
||||||
|
|
||||||
|
links.forEach((link, index) => {
|
||||||
|
const fromNode = nodeMap.get(String(link.from_key || ''));
|
||||||
|
const toNode = nodeMap.get(String(link.to_key || ''));
|
||||||
|
if (!fromNode || !toNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromCenter = {
|
||||||
|
x: fromNode.x + (fromNode.width / 2),
|
||||||
|
y: fromNode.y + (fromNode.height / 2)
|
||||||
|
};
|
||||||
|
const toCenter = {
|
||||||
|
x: toNode.x + (toNode.width / 2),
|
||||||
|
y: toNode.y + (toNode.height / 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
const path = createSvg('path');
|
||||||
|
path.setAttribute('d', build45Path(fromCenter, toCenter, index % 4));
|
||||||
|
path.setAttribute('class', 'infra-connection-path');
|
||||||
|
path.setAttribute('stroke-width', String(Math.min(6, 1.5 + Number(link.count || 1))));
|
||||||
|
|
||||||
|
const title = createSvg('title');
|
||||||
|
title.textContent = `${fromNode.label} ↔ ${toNode.label} (${Number(link.count || 1)})`;
|
||||||
|
path.appendChild(title);
|
||||||
|
|
||||||
|
layer.appendChild(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
root.appendChild(layer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawNodesLayer = (root) => {
|
||||||
|
const layer = createSvg('g');
|
||||||
|
layer.setAttribute('class', 'infra-node-layer');
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
const group = createSvg('g');
|
||||||
|
group.setAttribute('class', `infra-node infra-node--${node.type}`);
|
||||||
|
group.setAttribute('data-node-key', node.key);
|
||||||
|
|
||||||
|
if (node.type === 'patchpanel') {
|
||||||
|
const rect = createSvg('rect');
|
||||||
|
rect.setAttribute('x', String(node.x));
|
||||||
|
rect.setAttribute('y', String(node.y));
|
||||||
|
rect.setAttribute('width', String(node.width));
|
||||||
|
rect.setAttribute('height', String(node.height));
|
||||||
|
rect.setAttribute('rx', '1.5');
|
||||||
|
rect.setAttribute('class', 'infra-node-shape');
|
||||||
|
group.appendChild(rect);
|
||||||
|
} else {
|
||||||
|
const rect = createSvg('rect');
|
||||||
|
rect.setAttribute('x', String(node.x));
|
||||||
|
rect.setAttribute('y', String(node.y));
|
||||||
|
rect.setAttribute('width', String(node.width));
|
||||||
|
rect.setAttribute('height', String(node.height));
|
||||||
|
rect.setAttribute('rx', '2');
|
||||||
|
rect.setAttribute('class', 'infra-node-shape');
|
||||||
|
group.appendChild(rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = createSvg('text');
|
||||||
|
label.setAttribute('x', String(node.x + node.width + 6));
|
||||||
|
label.setAttribute('y', String(node.y + Math.max(10, node.height)));
|
||||||
|
label.setAttribute('class', 'infra-node-label');
|
||||||
|
label.textContent = node.label;
|
||||||
|
group.appendChild(label);
|
||||||
|
|
||||||
|
if (node.tooltipLines.length > 0) {
|
||||||
|
const title = createSvg('title');
|
||||||
|
title.textContent = node.tooltipLines.join('\n');
|
||||||
|
group.appendChild(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.appendChild(group);
|
||||||
|
});
|
||||||
|
|
||||||
|
root.appendChild(layer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
clearOverlay();
|
||||||
|
const root = createSvg('g');
|
||||||
|
drawRoomsLayer(root);
|
||||||
|
drawConnectionsLayer(root, nodeByKey());
|
||||||
|
drawNodesLayer(root);
|
||||||
|
overlay.appendChild(root);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncToggleState = () => {
|
||||||
|
document.querySelectorAll('[data-infra-toggle]').forEach((button) => {
|
||||||
|
const type = button.getAttribute('data-infra-toggle');
|
||||||
|
const active = type === 'rooms' ? visibility.rooms : visibility.connections;
|
||||||
|
button.classList.toggle('button-primary', active);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startNodeDrag = (event, node) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const point = toPlanPoint(event.clientX, event.clientY);
|
||||||
|
nodeDrag = {
|
||||||
|
key: node.key,
|
||||||
|
offsetX: point.x - node.x,
|
||||||
|
offsetY: point.y - node.y
|
||||||
|
};
|
||||||
|
canvas.classList.add('is-dragging');
|
||||||
|
canvas.setPointerCapture(event.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Element)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const marker = document.createElementNS(SVG_NS, 'rect');
|
const nodeElement = target.closest('[data-node-key]');
|
||||||
marker.classList.add('infra-overlay-marker', type);
|
if (nodeElement) {
|
||||||
marker.setAttribute('x', String(Math.round(x)));
|
const key = String(nodeElement.getAttribute('data-node-key') || '');
|
||||||
marker.setAttribute('y', String(Math.round(y)));
|
const node = nodes.find((entry) => entry.key === key);
|
||||||
|
if (node) {
|
||||||
if (type === 'patchpanel') {
|
startNodeDrag(event, node);
|
||||||
marker.setAttribute('width', String(Math.max(1, Number(entry.width) || 20)));
|
}
|
||||||
marker.setAttribute('height', String(Math.max(1, Number(entry.height) || 5)));
|
return;
|
||||||
} else {
|
|
||||||
marker.setAttribute('width', '10');
|
|
||||||
marker.setAttribute('height', '10');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipLines = buildTooltipLines(entry, type);
|
panDrag = {
|
||||||
if (tooltipLines.length > 0) {
|
x: event.clientX,
|
||||||
const titleNode = document.createElementNS(SVG_NS, 'title');
|
y: event.clientY,
|
||||||
titleNode.textContent = tooltipLines.join('\n');
|
baseX: camera.tx,
|
||||||
marker.appendChild(titleNode);
|
baseY: camera.ty
|
||||||
}
|
};
|
||||||
|
canvas.classList.add('is-dragging');
|
||||||
overlay.appendChild(marker);
|
canvas.setPointerCapture(event.pointerId);
|
||||||
};
|
};
|
||||||
|
|
||||||
patchPanels.forEach((entry) => createMarker(entry, 'patchpanel'));
|
const onPointerMove = (event) => {
|
||||||
outlets.forEach((entry) => createMarker(entry, 'outlet'));
|
if (nodeDrag) {
|
||||||
|
const point = toPlanPoint(event.clientX, event.clientY);
|
||||||
|
const node = nodes.find((entry) => entry.key === nodeDrag.key);
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.x = clamp(point.x - nodeDrag.offsetX, 0, Math.max(0, planSize.width - node.width));
|
||||||
|
node.y = clamp(point.y - nodeDrag.offsetY, 0, Math.max(0, planSize.height - node.height));
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!panDrag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
camera.tx = panDrag.baseX + (event.clientX - panDrag.x);
|
||||||
|
camera.ty = panDrag.baseY + (event.clientY - panDrag.y);
|
||||||
|
applyCamera();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDrag = (event) => {
|
||||||
|
if (!nodeDrag && !panDrag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nodeDrag = null;
|
||||||
|
panDrag = null;
|
||||||
|
canvas.classList.remove('is-dragging');
|
||||||
|
if (event && typeof event.pointerId === 'number') {
|
||||||
|
canvas.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadAsPng = () => {
|
||||||
|
const serializer = new XMLSerializer();
|
||||||
|
const floorHref = floorSvg.getAttribute('src') || '';
|
||||||
|
const sceneSvg = [
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${planSize.width} ${planSize.height}" width="${planSize.width}" height="${planSize.height}">`,
|
||||||
|
`<image href="${escapeXml(floorHref)}" x="0" y="0" width="${planSize.width}" height="${planSize.height}" opacity="0.2" preserveAspectRatio="none"/>`,
|
||||||
|
serializer.serializeToString(overlay),
|
||||||
|
'</svg>'
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const blob = new Blob([sceneSvg], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const out = document.createElement('canvas');
|
||||||
|
out.width = Math.round(planSize.width);
|
||||||
|
out.height = Math.round(planSize.height);
|
||||||
|
const ctx = out.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = out.toDataURL('image/png');
|
||||||
|
a.download = 'stockwerks-topologie.png';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
window.alert('PNG-Export fehlgeschlagen.');
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindEvents = () => {
|
||||||
|
canvas.addEventListener('wheel', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const factor = event.deltaY < 0 ? (1 + SCALE_STEP) : (1 - SCALE_STEP);
|
||||||
|
zoomAtPoint(event.clientX, event.clientY, factor);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', onPointerDown);
|
||||||
|
canvas.addEventListener('pointermove', onPointerMove);
|
||||||
|
canvas.addEventListener('pointerup', stopDrag);
|
||||||
|
canvas.addEventListener('pointercancel', stopDrag);
|
||||||
|
canvas.addEventListener('pointerleave', stopDrag);
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-infra-zoom]').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const action = button.getAttribute('data-infra-zoom');
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const cx = rect.left + (rect.width / 2);
|
||||||
|
const cy = rect.top + (rect.height / 2);
|
||||||
|
if (action === 'in') {
|
||||||
|
zoomAtPoint(cx, cy, 1 + SCALE_STEP);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'out') {
|
||||||
|
zoomAtPoint(cx, cy, 1 - SCALE_STEP);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetCamera();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-infra-toggle]').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const type = button.getAttribute('data-infra-toggle');
|
||||||
|
if (type === 'rooms') {
|
||||||
|
visibility.rooms = !visibility.rooms;
|
||||||
|
} else if (type === 'connections') {
|
||||||
|
visibility.connections = !visibility.connections;
|
||||||
|
}
|
||||||
|
syncToggleState();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-infra-download="png"]').forEach((button) => {
|
||||||
|
button.addEventListener('click', downloadAsPng);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === '0') {
|
||||||
|
resetCamera();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key.toLowerCase() === 'r') {
|
||||||
|
visibility.rooms = !visibility.rooms;
|
||||||
|
syncToggleState();
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key.toLowerCase() === 'k') {
|
||||||
|
visibility.connections = !visibility.connections;
|
||||||
|
syncToggleState();
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
camera.tx += PAN_STEP;
|
||||||
|
applyCamera();
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
camera.tx -= PAN_STEP;
|
||||||
|
applyCamera();
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
camera.ty += PAN_STEP;
|
||||||
|
applyCamera();
|
||||||
|
} else if (event.key === 'ArrowDown') {
|
||||||
|
camera.ty -= PAN_STEP;
|
||||||
|
applyCamera();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', applyCamera);
|
||||||
|
};
|
||||||
|
|
||||||
const loadPlanDimensions = async (svgUrl) => {
|
const loadPlanDimensions = async (svgUrl) => {
|
||||||
if (!svgUrl) {
|
if (!svgUrl) {
|
||||||
updateOverlayViewBox();
|
applyCamera();
|
||||||
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -82,28 +547,39 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (parts.length === 4 && parts.every((value) => Number.isFinite(value))) {
|
if (parts.length === 4 && parts.every((value) => Number.isFinite(value))) {
|
||||||
planSize.width = Math.max(1, parts[2]);
|
planSize.width = Math.max(1, parts[2]);
|
||||||
planSize.height = Math.max(1, parts[3]);
|
planSize.height = Math.max(1, parts[3]);
|
||||||
updateOverlayViewBox();
|
}
|
||||||
return;
|
} else {
|
||||||
|
const width = Number(root.getAttribute('width'));
|
||||||
|
const height = Number(root.getAttribute('height'));
|
||||||
|
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
|
||||||
|
planSize.width = width;
|
||||||
|
planSize.height = height;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const width = Number(root.getAttribute('width'));
|
|
||||||
const height = Number(root.getAttribute('height'));
|
|
||||||
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
|
|
||||||
planSize.width = width;
|
|
||||||
planSize.height = height;
|
|
||||||
} else {
|
|
||||||
planSize.width = DEFAULT_PLAN_SIZE.width;
|
|
||||||
planSize.height = DEFAULT_PLAN_SIZE.height;
|
|
||||||
}
|
|
||||||
updateOverlayViewBox();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
planSize.width = DEFAULT_PLAN_SIZE.width;
|
planSize.width = DEFAULT_PLAN_SIZE.width;
|
||||||
planSize.height = DEFAULT_PLAN_SIZE.height;
|
planSize.height = DEFAULT_PLAN_SIZE.height;
|
||||||
updateOverlayViewBox();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
overlay.setAttribute('viewBox', `0 0 ${planSize.width} ${planSize.height}`);
|
||||||
|
scene.style.width = `${planSize.width}px`;
|
||||||
|
scene.style.height = `${planSize.height}px`;
|
||||||
|
floorSvg.style.width = `${planSize.width}px`;
|
||||||
|
floorSvg.style.height = `${planSize.height}px`;
|
||||||
|
overlay.style.width = `${planSize.width}px`;
|
||||||
|
overlay.style.height = `${planSize.height}px`;
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
node.x = clamp(node.x, 0, Math.max(0, planSize.width - node.width));
|
||||||
|
node.y = clamp(node.y, 0, Math.max(0, planSize.height - node.height));
|
||||||
|
});
|
||||||
|
|
||||||
|
applyCamera();
|
||||||
|
render();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bindEvents();
|
||||||
|
syncToggleState();
|
||||||
loadPlanDimensions(floorSvg.getAttribute('src') || '');
|
loadPlanDimensions(floorSvg.getAttribute('src') || '');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,3 +622,11 @@ function buildTooltipLines(entry, type) {
|
|||||||
}
|
}
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeXml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ $selectedLocationId = $building['location_id'] ?? $prefillLocationId;
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name">Name <span class="required">*</span></label>
|
<label for="name">Name <span class="required">*</span></label>
|
||||||
<input type="text" id="name" name="name" required
|
<input type="text" id="name" name="name" required
|
||||||
value="<?php echo htmlspecialchars($building['name'] ?? '); ?>"
|
value="<?php echo htmlspecialchars($building['name'] ?? ''); ?>"
|
||||||
placeholder="z.B. Gebäude A, Verwaltungsgebäude">
|
placeholder="z.B. Gebäude A, Verwaltungsgebäude">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ $selectedLocationId = $building['location_id'] ?? $prefillLocationId;
|
|||||||
<option value="">- Wählen -</option>
|
<option value="">- Wählen -</option>
|
||||||
<?php foreach ($locations as $location): ?>
|
<?php foreach ($locations as $location): ?>
|
||||||
<option value="<?php echo $location['id']; ?>"
|
<option value="<?php echo $location['id']; ?>"
|
||||||
<?php echo ((int)$selectedLocationId === (int)$location['id']) ? 'selected' : '; ?>>
|
<?php echo ((int)$selectedLocationId === (int)$location['id']) ? 'selected' : ''; ?>>
|
||||||
<?php echo htmlspecialchars($location['name']); ?>
|
<?php echo htmlspecialchars($location['name']); ?>
|
||||||
</option>
|
</option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -67,7 +67,7 @@ $selectedLocationId = $building['location_id'] ?? $prefillLocationId;
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="comment">Beschreibung</label>
|
<label for="comment">Beschreibung</label>
|
||||||
<textarea id="comment" name="comment" rows="3"
|
<textarea id="comment" name="comment" rows="3"
|
||||||
placeholder="Adresse, Besonderheiten, etc."><?php echo htmlspecialchars($building['comment'] ?? '); ?></textarea>
|
placeholder="Adresse, Besonderheiten, etc."><?php echo htmlspecialchars($building['comment'] ?? ''); ?></textarea>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ $selectedLocationId = $building['location_id'] ?? $prefillLocationId;
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
function confirmDelete(id) {
|
function confirmDelete(id) {
|
||||||
if (confirm('Dieses Gebaeude wirklich loeschen? Alle Stockwerke werden geloescht.')) {
|
if (confirm('Dieses Gebäude wirklich löschen? Alle Stockwerke werden gelöscht.')) {
|
||||||
fetch('?module=buildings&action=delete', {
|
fetch('?module=buildings&action=delete', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||||
@@ -184,13 +184,12 @@ function confirmDelete(id) {
|
|||||||
window.location.href = '?module=buildings&action=list';
|
window.location.href = '?module=buildings&action=list';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
alert((data && data.error) ? data.error : 'Loeschen fehlgeschlagen');
|
alert((data && data.error) ? data.error : 'Löschen fehlgeschlagen');
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
alert('Loeschen fehlgeschlagen');
|
alert('Löschen fehlgeschlagen');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* modules/dashboard/list.php
|
* modules/dashboard/list.php
|
||||||
* Dashboard / Startseite - Uebersicht ueber alle Komponenten
|
* Dashboard / Startseite - Übersicht über alle Komponenten
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$stats = [
|
$stats = [
|
||||||
@@ -245,6 +245,106 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
'sample_connection_id' => (int)($entry['sample_connection_id'] ?? 0),
|
'sample_connection_id' => (int)($entry['sample_connection_id'] ?? 0),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$deviceIdByDevicePort = [];
|
||||||
|
foreach ($sql->get(
|
||||||
|
"SELECT id, device_id
|
||||||
|
FROM device_ports",
|
||||||
|
"",
|
||||||
|
[]
|
||||||
|
) as $row) {
|
||||||
|
$portId = (int)($row['id'] ?? 0);
|
||||||
|
$deviceId = (int)($row['device_id'] ?? 0);
|
||||||
|
if ($portId <= 0 || $deviceId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$deviceIdByDevicePort[$portId] = $deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deviceIdByModulePort = [];
|
||||||
|
foreach ($sql->get(
|
||||||
|
"SELECT mp.id AS port_id, MIN(dp.device_id) AS device_id
|
||||||
|
FROM module_ports mp
|
||||||
|
JOIN modules m ON m.id = mp.module_id
|
||||||
|
JOIN device_port_modules dpm ON dpm.module_id = m.id
|
||||||
|
JOIN device_ports dp ON dp.id = dpm.device_port_id
|
||||||
|
GROUP BY mp.id",
|
||||||
|
"",
|
||||||
|
[]
|
||||||
|
) as $row) {
|
||||||
|
$portId = (int)($row['port_id'] ?? 0);
|
||||||
|
$deviceId = (int)($row['device_id'] ?? 0);
|
||||||
|
if ($portId <= 0 || $deviceId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$deviceIdByModulePort[$portId] = $deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolveDeviceId = static function (string $endpointType, int $endpointId) use ($deviceIdByDevicePort, $deviceIdByModulePort): int {
|
||||||
|
if ($endpointId <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = strtolower(trim($endpointType));
|
||||||
|
if ($type === 'device' || $type === 'device_ports') {
|
||||||
|
return (int)($deviceIdByDevicePort[$endpointId] ?? 0);
|
||||||
|
}
|
||||||
|
if ($type === 'module' || $type === 'module_ports') {
|
||||||
|
return (int)($deviceIdByModulePort[$endpointId] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
$deviceLinksByKey = [];
|
||||||
|
foreach ($sql->get(
|
||||||
|
"SELECT id, port_a_type, port_a_id, port_b_type, port_b_id
|
||||||
|
FROM connections",
|
||||||
|
"",
|
||||||
|
[]
|
||||||
|
) as $row) {
|
||||||
|
$deviceA = $resolveDeviceId((string)($row['port_a_type'] ?? ''), (int)($row['port_a_id'] ?? 0));
|
||||||
|
$deviceB = $resolveDeviceId((string)($row['port_b_type'] ?? ''), (int)($row['port_b_id'] ?? 0));
|
||||||
|
if ($deviceA <= 0 || $deviceB <= 0 || $deviceA === $deviceB) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = min($deviceA, $deviceB);
|
||||||
|
$to = max($deviceA, $deviceB);
|
||||||
|
$key = $from . ':' . $to;
|
||||||
|
if (!isset($deviceLinksByKey[$key])) {
|
||||||
|
$deviceLinksByKey[$key] = [
|
||||||
|
'from_device_id' => $from,
|
||||||
|
'to_device_id' => $to,
|
||||||
|
'count' => 0,
|
||||||
|
'sample_connection_id' => (int)($row['id'] ?? 0)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$deviceLinksByKey[$key]['count']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deviceNameById = [];
|
||||||
|
foreach ($topologyPayload as $entry) {
|
||||||
|
$deviceId = (int)($entry['device_id'] ?? 0);
|
||||||
|
if ($deviceId <= 0 || isset($deviceNameById[$deviceId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$deviceNameById[$deviceId] = (string)($entry['device_name'] ?? ('Gerät #' . $deviceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
$deviceLinkPayload = [];
|
||||||
|
foreach ($deviceLinksByKey as $entry) {
|
||||||
|
$fromId = (int)($entry['from_device_id'] ?? 0);
|
||||||
|
$toId = (int)($entry['to_device_id'] ?? 0);
|
||||||
|
$deviceLinkPayload[] = [
|
||||||
|
'from_device_id' => $fromId,
|
||||||
|
'to_device_id' => $toId,
|
||||||
|
'count' => (int)($entry['count'] ?? 0),
|
||||||
|
'from_device_name' => (string)($deviceNameById[$fromId] ?? ('Gerät #' . $fromId)),
|
||||||
|
'to_device_name' => (string)($deviceNameById[$toId] ?? ('Gerät #' . $toId)),
|
||||||
|
'sample_connection_id' => (int)($entry['sample_connection_id'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
@@ -263,7 +363,7 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
<button type="button" class="button button-small" data-topology-zoom="reset">Reset</button>
|
<button type="button" class="button button-small" data-topology-zoom="reset">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="topology-wall__hint">Hierarchie: Standort → Gebaeude → Stockwerk → Rack → Geraet. Linien zeigen Rack-Verbindungen (dicker = mehr Links).</p>
|
<p class="topology-wall__hint">Hierarchie: Standort → Gebäude → Stockwerk → Rack → Gerät. Linien zeigen Rack-Verbindungen (dicker = mehr Links).</p>
|
||||||
|
|
||||||
<svg id="dashboard-topology-svg" viewBox="0 0 2400 1400" role="img" aria-label="Topologie-Wand">
|
<svg id="dashboard-topology-svg" viewBox="0 0 2400 1400" role="img" aria-label="Topologie-Wand">
|
||||||
<rect id="dashboard-topology-bg" x="0" y="0" width="2400" height="1400" class="topology-bg" fill="#f7faff"></rect>
|
<rect id="dashboard-topology-bg" x="0" y="0" width="2400" height="1400" class="topology-bg" fill="#f7faff"></rect>
|
||||||
@@ -272,12 +372,12 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
<g id="dashboard-topology-layer"></g>
|
<g id="dashboard-topology-layer"></g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<div id="dashboard-topology-empty" class="topology-empty" hidden>Keine Geraete vorhanden. Bitte zuerst Geraete erfassen.</div>
|
<div id="dashboard-topology-empty" class="topology-empty" hidden>Keine Geräte vorhanden. Bitte zuerst Geräte erfassen.</div>
|
||||||
|
|
||||||
<aside id="dashboard-topology-overlay" class="topology-overlay" hidden>
|
<aside id="dashboard-topology-overlay" class="topology-overlay" hidden>
|
||||||
<div class="topology-overlay__header">
|
<div class="topology-overlay__header">
|
||||||
<h3 data-topology-title>Rack-Detail</h3>
|
<h3 data-topology-title>Rack-Detail</h3>
|
||||||
<button type="button" class="button button-small" data-topology-close>Schliessen</button>
|
<button type="button" class="button button-small" data-topology-close>Schließen</button>
|
||||||
</div>
|
</div>
|
||||||
<p data-topology-meta></p>
|
<p data-topology-meta></p>
|
||||||
<p data-topology-rack-link></p>
|
<p data-topology-rack-link></p>
|
||||||
@@ -295,13 +395,13 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
|
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3><?php echo (int)$stats['device_types']; ?></h3>
|
<h3><?php echo (int)$stats['device_types']; ?></h3>
|
||||||
<p>Geraetetypen</p>
|
<p>Gerätetypen</p>
|
||||||
<a href="?module=device_types&action=list">Verwalten -></a>
|
<a href="?module=device_types&action=list">Verwalten -></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3><?php echo (int)$stats['devices']; ?></h3>
|
<h3><?php echo (int)$stats['devices']; ?></h3>
|
||||||
<p>Geraete</p>
|
<p>Geräte</p>
|
||||||
<a href="?module=devices&action=list">Verwalten -></a>
|
<a href="?module=devices&action=list">Verwalten -></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -312,7 +412,7 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Zuletzt hinzugefuegt</h2>
|
<h2>Zuletzt hinzugefügt</h2>
|
||||||
<?php if (!empty($recentDevices)): ?>
|
<?php if (!empty($recentDevices)): ?>
|
||||||
<table class="recent-devices">
|
<table class="recent-devices">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -337,12 +437,13 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<p><em>Noch keine Geraete vorhanden. <a href="?module=device_types&action=list">Starten Sie mit Geraetetypen</a>.</em></p>
|
<p><em>Noch keine Geräte vorhanden. <a href="?module=device_types&action=list">Starten Sie mit Gerätetypen</a>.</em></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script id="dashboard-topology-data" type="application/json"><?php echo json_encode($topologyPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?></script>
|
<script id="dashboard-topology-data" type="application/json"><?php echo json_encode($topologyPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?></script>
|
||||||
<script id="dashboard-topology-links" type="application/json"><?php echo json_encode($rackLinkPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?></script>
|
<script id="dashboard-topology-links" type="application/json"><?php echo json_encode($rackLinkPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?></script>
|
||||||
|
<script id="dashboard-topology-device-links" type="application/json"><?php echo json_encode($deviceLinkPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const root = document.getElementById('dashboard-topology-wall');
|
const root = document.getElementById('dashboard-topology-wall');
|
||||||
@@ -366,8 +467,10 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
|
|
||||||
const dataTag = document.getElementById('dashboard-topology-data');
|
const dataTag = document.getElementById('dashboard-topology-data');
|
||||||
const linkTag = document.getElementById('dashboard-topology-links');
|
const linkTag = document.getElementById('dashboard-topology-links');
|
||||||
|
const deviceLinkTag = document.getElementById('dashboard-topology-device-links');
|
||||||
let nodes = [];
|
let nodes = [];
|
||||||
let rackLinks = [];
|
let rackLinks = [];
|
||||||
|
let deviceLinks = [];
|
||||||
try {
|
try {
|
||||||
nodes = JSON.parse((dataTag && dataTag.textContent) || '[]');
|
nodes = JSON.parse((dataTag && dataTag.textContent) || '[]');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -378,6 +481,11 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
rackLinks = [];
|
rackLinks = [];
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
deviceLinks = JSON.parse((deviceLinkTag && deviceLinkTag.textContent) || '[]');
|
||||||
|
} catch (error) {
|
||||||
|
deviceLinks = [];
|
||||||
|
}
|
||||||
|
|
||||||
const scene = { width: 2400, height: 1400 };
|
const scene = { width: 2400, height: 1400 };
|
||||||
let view = { x: 0, y: 0, width: scene.width, height: scene.height };
|
let view = { x: 0, y: 0, width: scene.width, height: scene.height };
|
||||||
@@ -482,7 +590,7 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
const locationId = Number(entry.location_id || 0);
|
const locationId = Number(entry.location_id || 0);
|
||||||
const locationName = (entry.location_name || '').trim() || 'Ohne Standort';
|
const locationName = (entry.location_name || '').trim() || 'Ohne Standort';
|
||||||
const buildingId = Number(entry.building_id || 0);
|
const buildingId = Number(entry.building_id || 0);
|
||||||
const buildingName = (entry.building_name || '').trim() || 'Ohne Gebaeude';
|
const buildingName = (entry.building_name || '').trim() || 'Ohne Gebäude';
|
||||||
const floorId = Number(entry.floor_id || 0);
|
const floorId = Number(entry.floor_id || 0);
|
||||||
const floorName = (entry.floor_name || '').trim() || 'Ohne Stockwerk';
|
const floorName = (entry.floor_name || '').trim() || 'Ohne Stockwerk';
|
||||||
const rackId = Number(entry.rack_id || 0);
|
const rackId = Number(entry.rack_id || 0);
|
||||||
@@ -513,6 +621,7 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
|
|
||||||
const positioned = [];
|
const positioned = [];
|
||||||
const rackCenters = new Map();
|
const rackCenters = new Map();
|
||||||
|
const deviceCenters = new Map();
|
||||||
let maxY = 1400;
|
let maxY = 1400;
|
||||||
let locationIndex = 0;
|
let locationIndex = 0;
|
||||||
|
|
||||||
@@ -599,7 +708,7 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
rack_id: Number(device.rack_id || 0),
|
rack_id: Number(device.rack_id || 0),
|
||||||
rack_name: device.rack_name || 'Ohne Rack',
|
rack_name: device.rack_name || 'Ohne Rack',
|
||||||
floor_name: device.floor_name || 'Ohne Stockwerk',
|
floor_name: device.floor_name || 'Ohne Stockwerk',
|
||||||
building_name: device.building_name || 'Ohne Gebaeude',
|
building_name: device.building_name || 'Ohne Gebäude',
|
||||||
location_name: device.location_name || 'Ohne Standort',
|
location_name: device.location_name || 'Ohne Standort',
|
||||||
device_id: Number(device.device_id || 0),
|
device_id: Number(device.device_id || 0),
|
||||||
device_name: device.device_name || 'Unbenannt',
|
device_name: device.device_name || 'Unbenannt',
|
||||||
@@ -607,6 +716,9 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
port_count: Number(device.port_count || 0),
|
port_count: Number(device.port_count || 0),
|
||||||
port_preview: Array.isArray(device.port_preview) ? device.port_preview : []
|
port_preview: Array.isArray(device.port_preview) ? device.port_preview : []
|
||||||
});
|
});
|
||||||
|
if (Number(device.device_id || 0) > 0) {
|
||||||
|
deviceCenters.set(Number(device.device_id || 0), { x, y });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
rackCursorY += rackHeight + 16;
|
rackCursorY += rackHeight + 16;
|
||||||
@@ -651,7 +763,7 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
|
|
||||||
const width = Math.max(2400, 220 + locationIndex * 760);
|
const width = Math.max(2400, 220 + locationIndex * 760);
|
||||||
const height = Math.max(1400, Math.ceil(maxY));
|
const height = Math.max(1400, Math.ceil(maxY));
|
||||||
return { entries: positioned, rackCenters, width, height };
|
return { entries: positioned, rackCenters, deviceCenters, width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
function showOverlay(item) {
|
function showOverlay(item) {
|
||||||
@@ -668,7 +780,7 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
overlayMeta.textContent = `${item.from_name} <-> ${item.to_name} | Anzahl: ${item.count}`;
|
overlayMeta.textContent = `${item.from_name} <-> ${item.to_name} | Anzahl: ${item.count}`;
|
||||||
if (item.sample_connection_id > 0) {
|
if (item.sample_connection_id > 0) {
|
||||||
overlayRackLink.innerHTML = `<a href="?module=connections&action=edit&id=${item.sample_connection_id}">Verbindung bearbeiten</a>`;
|
overlayRackLink.innerHTML = `<a href="?module=connections&action=edit&id=${item.sample_connection_id}">Verbindung bearbeiten</a>`;
|
||||||
overlayDeviceLink.innerHTML = `<a href="?module=connections&action=delete&id=${item.sample_connection_id}" onclick="return confirm('Diese Verbindung wirklich loeschen?');">Verbindung entfernen</a>`;
|
overlayDeviceLink.innerHTML = `<a href="?module=connections&action=delete&id=${item.sample_connection_id}" onclick="return confirm('Diese Verbindung wirklich löschen?');">Verbindung entfernen</a>`;
|
||||||
}
|
}
|
||||||
overlay.hidden = false;
|
overlay.hidden = false;
|
||||||
return;
|
return;
|
||||||
@@ -676,9 +788,9 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
|
|
||||||
if (item.kind === 'port') {
|
if (item.kind === 'port') {
|
||||||
overlayTitle.textContent = `Port: ${item.port_name}`;
|
overlayTitle.textContent = `Port: ${item.port_name}`;
|
||||||
overlayMeta.textContent = `Geraet: ${item.device_name} | Rack: ${item.rack_name} | Stockwerk: ${item.floor_name}`;
|
overlayMeta.textContent = `Gerät: ${item.device_name} | Rack: ${item.rack_name} | Stockwerk: ${item.floor_name}`;
|
||||||
if (item.device_id > 0) {
|
if (item.device_id > 0) {
|
||||||
overlayRackLink.innerHTML = `<a href="?module=devices&action=edit&id=${item.device_id}">Geraet bearbeiten</a>`;
|
overlayRackLink.innerHTML = `<a href="?module=devices&action=edit&id=${item.device_id}">Gerät bearbeiten</a>`;
|
||||||
if (item.port_id > 0 && !item.is_connected) {
|
if (item.port_id > 0 && !item.is_connected) {
|
||||||
overlayDeviceLink.innerHTML = `<a href="?module=connections&action=edit&port_a_type=device&port_a_id=${item.port_id}&port_b_type=patchpanel">Mit Patchfeld verbinden</a>`;
|
overlayDeviceLink.innerHTML = `<a href="?module=connections&action=edit&port_a_type=device&port_a_id=${item.port_id}&port_b_type=patchpanel">Mit Patchfeld verbinden</a>`;
|
||||||
} else {
|
} else {
|
||||||
@@ -686,7 +798,7 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (item.port_id > 0) {
|
if (item.port_id > 0) {
|
||||||
overlayActions.innerHTML = `<a class="button button-small" href="?module=devices&action=edit&id=${item.device_id}">Port im Geraet aendern</a>`;
|
overlayActions.innerHTML = `<a class="button button-small" href="?module=devices&action=edit&id=${item.device_id}">Port im Gerät ändern</a>`;
|
||||||
}
|
}
|
||||||
overlay.hidden = false;
|
overlay.hidden = false;
|
||||||
return;
|
return;
|
||||||
@@ -694,19 +806,19 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
|
|
||||||
overlayTitle.textContent = item.rack_name || 'Ohne Rack';
|
overlayTitle.textContent = item.rack_name || 'Ohne Rack';
|
||||||
const typeLabel = item.device_type_name ? ` (${item.device_type_name})` : '';
|
const typeLabel = item.device_type_name ? ` (${item.device_type_name})` : '';
|
||||||
overlayMeta.textContent = `Standort: ${item.location_name} | Gebaeude: ${item.building_name} | Stockwerk: ${item.floor_name} | Geraet: ${item.device_name}${typeLabel}`;
|
overlayMeta.textContent = `Standort: ${item.location_name} | Gebäude: ${item.building_name} | Stockwerk: ${item.floor_name} | Gerät: ${item.device_name}${typeLabel}`;
|
||||||
|
|
||||||
if (item.rack_id > 0) {
|
if (item.rack_id > 0) {
|
||||||
overlayRackLink.innerHTML = `<a href="?module=racks&action=edit&id=${item.rack_id}">Rack bearbeiten</a>`;
|
overlayRackLink.innerHTML = `<a href="?module=racks&action=edit&id=${item.rack_id}">Rack bearbeiten</a>`;
|
||||||
} else {
|
} else {
|
||||||
overlayRackLink.textContent = 'Kein Rack verknuepft';
|
overlayRackLink.textContent = 'Kein Rack verknüpft';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.device_id > 0) {
|
if (item.device_id > 0) {
|
||||||
overlayDeviceLink.innerHTML = `<a href="?module=devices&action=edit&id=${item.device_id}">Geraet bearbeiten</a>`;
|
overlayDeviceLink.innerHTML = `<a href="?module=devices&action=edit&id=${item.device_id}">Gerät bearbeiten</a>`;
|
||||||
overlayActions.innerHTML = `
|
overlayActions.innerHTML = `
|
||||||
<a class="button button-small" href="?module=devices&action=edit&id=${item.device_id}">Editieren</a>
|
<a class="button button-small" href="?module=devices&action=edit&id=${item.device_id}">Editieren</a>
|
||||||
<a class="button button-small button-danger" href="?module=devices&action=delete&id=${item.device_id}&force=1" onclick="return confirm('Dieses Geraet wirklich loeschen?');">Entfernen</a>
|
<a class="button button-small button-danger" href="?module=devices&action=delete&id=${item.device_id}&force=1" onclick="return confirm('Dieses Gerät wirklich löschen?');">Entfernen</a>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -898,6 +1010,30 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
deviceLinks.forEach((link) => {
|
||||||
|
const fromDeviceId = Number(link.from_device_id || 0);
|
||||||
|
const toDeviceId = Number(link.to_device_id || 0);
|
||||||
|
const count = Math.max(1, Number(link.count || 1));
|
||||||
|
const fromPoint = layout.deviceCenters.get(fromDeviceId);
|
||||||
|
const toPoint = layout.deviceCenters.get(toDeviceId);
|
||||||
|
if (!fromPoint || !toPoint) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = svgElement('line');
|
||||||
|
line.setAttribute('x1', String(fromPoint.x));
|
||||||
|
line.setAttribute('y1', String(fromPoint.y));
|
||||||
|
line.setAttribute('x2', String(toPoint.x));
|
||||||
|
line.setAttribute('y2', String(toPoint.y));
|
||||||
|
line.setAttribute('class', 'topology-device-link-line');
|
||||||
|
line.setAttribute('stroke-width', String(Math.min(5, 1 + (count * 0.4))));
|
||||||
|
|
||||||
|
const title = svgElement('title');
|
||||||
|
title.textContent = `${link.from_device_name || `Gerät ${fromDeviceId}`} <-> ${link.to_device_name || `Gerät ${toDeviceId}`}: ${count} Verbindungen`;
|
||||||
|
line.appendChild(title);
|
||||||
|
connectionLayer.appendChild(line);
|
||||||
|
});
|
||||||
|
|
||||||
rackLinks.forEach((link) => {
|
rackLinks.forEach((link) => {
|
||||||
const fromRackId = Number(link.from_rack_id || 0);
|
const fromRackId = Number(link.from_rack_id || 0);
|
||||||
const toRackId = Number(link.to_rack_id || 0);
|
const toRackId = Number(link.to_rack_id || 0);
|
||||||
@@ -1018,30 +1154,54 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.dashboard {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard > h1,
|
||||||
|
.dashboard > h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #1f2d44;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
gap: 20px;
|
gap: 14px;
|
||||||
margin: 20px 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-modules {
|
.dashboard-modules {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin: 12px 0 20px;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-tile {
|
.dashboard-tile {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 10px;
|
grid-template-columns: 34px 1fr;
|
||||||
align-items: center;
|
gap: 12px;
|
||||||
border: 1px solid #d7d7d7;
|
align-items: start;
|
||||||
border-radius: 8px;
|
border: 1px solid #dbe4f3;
|
||||||
padding: 12px;
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #222;
|
color: #1c2940;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 24px rgba(18, 43, 79, 0.08);
|
||||||
|
transition: transform 140ms ease, box-shadow 140ms ease, border-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tile:hover,
|
||||||
|
.dashboard-tile:focus-visible {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: #9bb7df;
|
||||||
|
box-shadow: 0 14px 32px rgba(18, 43, 79, 0.16);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-icon {
|
.dashboard-icon {
|
||||||
@@ -1064,67 +1224,79 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
|
|
||||||
.dashboard-content p {
|
.dashboard-content p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.9rem;
|
||||||
color: #444;
|
color: #4c5e7b;
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-inline-status {
|
.dashboard-inline-status {
|
||||||
margin: 6px 0;
|
margin: 0;
|
||||||
color: #333;
|
color: #2c3f5f;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f4f8ff;
|
||||||
|
border: 1px solid #d7e4f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #d9e4f4;
|
||||||
padding: 20px;
|
padding: 20px 18px;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: #f9f9f9;
|
background: linear-gradient(180deg, #ffffff 0%, #f5f9ff 100%);
|
||||||
|
box-shadow: 0 10px 28px rgba(17, 42, 77, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card h3 {
|
.stat-card h3 {
|
||||||
font-size: 2.5em;
|
font-size: 2.3rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #333;
|
color: #23395b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card p {
|
.stat-card p {
|
||||||
margin: 10px 0;
|
margin: 10px 0 8px;
|
||||||
color: #666;
|
color: #4a5c7a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card a {
|
.stat-card a {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 10px;
|
margin-top: 6px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background: #007bff;
|
background: #1464c9;
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-devices {
|
.recent-devices {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #dbe4f3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-devices th,
|
.recent-devices th,
|
||||||
.recent-devices td {
|
.recent-devices td {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid #ddd;
|
border-bottom: 1px solid #e6edf8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-devices th {
|
.recent-devices th {
|
||||||
background: #f0f0f0;
|
background: #f1f6fe;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topology-wall {
|
.topology-wall {
|
||||||
margin: 18px 0 26px;
|
margin: 0;
|
||||||
border: 1px solid #d6e1f0;
|
border: 1px solid #d6e1f0;
|
||||||
border-radius: 10px;
|
border-radius: 14px;
|
||||||
background: #fff;
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||||
padding: 14px;
|
padding: 16px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
box-shadow: 0 14px 34px rgba(14, 40, 74, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topology-wall__header {
|
.topology-wall__header {
|
||||||
@@ -1144,7 +1316,7 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topology-wall__hint {
|
.topology-wall__hint {
|
||||||
margin: 8px 0 10px;
|
margin: 8px 0 12px;
|
||||||
color: #445067;
|
color: #445067;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1226,6 +1398,13 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topology-device-link-line {
|
||||||
|
stroke: #56a17f;
|
||||||
|
stroke-opacity: 0.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.topology-connection-line:hover,
|
.topology-connection-line:hover,
|
||||||
.topology-connection-line:focus,
|
.topology-connection-line:focus,
|
||||||
.topology-connection-line.active {
|
.topology-connection-line.active {
|
||||||
@@ -1305,6 +1484,10 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
.dashboard {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
#dashboard-topology-svg {
|
#dashboard-topology-svg {
|
||||||
height: 360px;
|
height: 360px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* app/modules/floor_infrastructure/list.php
|
* app/modules/floor_infrastructure/list.php
|
||||||
*
|
*
|
||||||
* Uebersicht ueber Patchpanels und Netzwerkbuchsen auf Stockwerken.
|
* Übersicht über Patchpanels und Netzwerkbuchsen auf Stockwerken.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$floorId = (int)($_GET['floor_id'] ?? 0);
|
$floorId = (int)($_GET['floor_id'] ?? 0);
|
||||||
@@ -64,6 +64,8 @@ foreach ($floors as $floor) {
|
|||||||
$editorFloor = ($floorId > 0 && isset($floorMap[$floorId])) ? $floorMap[$floorId] : null;
|
$editorFloor = ($floorId > 0 && isset($floorMap[$floorId])) ? $floorMap[$floorId] : null;
|
||||||
$editorPatchPanels = [];
|
$editorPatchPanels = [];
|
||||||
$editorOutlets = [];
|
$editorOutlets = [];
|
||||||
|
$editorRooms = [];
|
||||||
|
$editorLinks = [];
|
||||||
|
|
||||||
if ($editorFloor) {
|
if ($editorFloor) {
|
||||||
foreach ($patchPanels as $panel) {
|
foreach ($patchPanels as $panel) {
|
||||||
@@ -97,6 +99,101 @@ if ($editorFloor) {
|
|||||||
'comment' => (string)($outlet['comment'] ?? '')
|
'comment' => (string)($outlet['comment'] ?? '')
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($sql->get(
|
||||||
|
"SELECT id, name, number, x, y, width, height, polygon_points
|
||||||
|
FROM rooms
|
||||||
|
WHERE floor_id = ?
|
||||||
|
ORDER BY name",
|
||||||
|
"i",
|
||||||
|
[$floorId]
|
||||||
|
) as $room) {
|
||||||
|
$editorRooms[] = [
|
||||||
|
'id' => (int)($room['id'] ?? 0),
|
||||||
|
'name' => (string)($room['name'] ?? ''),
|
||||||
|
'number' => (string)($room['number'] ?? ''),
|
||||||
|
'x' => (int)($room['x'] ?? 0),
|
||||||
|
'y' => (int)($room['y'] ?? 0),
|
||||||
|
'width' => (int)($room['width'] ?? 0),
|
||||||
|
'height' => (int)($room['height'] ?? 0),
|
||||||
|
'polygon_points' => (string)($room['polygon_points'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$outletIdByPort = [];
|
||||||
|
foreach ($sql->get(
|
||||||
|
"SELECT nop.id AS port_id, nop.outlet_id
|
||||||
|
FROM network_outlet_ports nop
|
||||||
|
JOIN network_outlets o ON o.id = nop.outlet_id
|
||||||
|
JOIN rooms r ON r.id = o.room_id
|
||||||
|
WHERE r.floor_id = ?",
|
||||||
|
"i",
|
||||||
|
[$floorId]
|
||||||
|
) as $row) {
|
||||||
|
$portId = (int)($row['port_id'] ?? 0);
|
||||||
|
$outletId = (int)($row['outlet_id'] ?? 0);
|
||||||
|
if ($portId > 0 && $outletId > 0) {
|
||||||
|
$outletIdByPort[$portId] = $outletId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$patchPanelIdByPort = [];
|
||||||
|
foreach ($sql->get(
|
||||||
|
"SELECT fpp.id AS port_id, fpp.patchpanel_id
|
||||||
|
FROM floor_patchpanel_ports fpp
|
||||||
|
JOIN floor_patchpanels fp ON fp.id = fpp.patchpanel_id
|
||||||
|
WHERE fp.floor_id = ?",
|
||||||
|
"i",
|
||||||
|
[$floorId]
|
||||||
|
) as $row) {
|
||||||
|
$portId = (int)($row['port_id'] ?? 0);
|
||||||
|
$patchPanelId = (int)($row['patchpanel_id'] ?? 0);
|
||||||
|
if ($portId > 0 && $patchPanelId > 0) {
|
||||||
|
$patchPanelIdByPort[$portId] = $patchPanelId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolveNodeKey = static function (string $endpointType, int $endpointId) use ($outletIdByPort, $patchPanelIdByPort): ?string {
|
||||||
|
if ($endpointId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$type = strtolower(trim($endpointType));
|
||||||
|
if ($type === 'outlet' || $type === 'network_outlet_ports') {
|
||||||
|
$outletId = (int)($outletIdByPort[$endpointId] ?? 0);
|
||||||
|
return $outletId > 0 ? ('outlet:' . $outletId) : null;
|
||||||
|
}
|
||||||
|
if ($type === 'patchpanel' || $type === 'floor_patchpanel_ports') {
|
||||||
|
$patchPanelId = (int)($patchPanelIdByPort[$endpointId] ?? 0);
|
||||||
|
return $patchPanelId > 0 ? ('patchpanel:' . $patchPanelId) : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
$linksByKey = [];
|
||||||
|
foreach ($sql->get(
|
||||||
|
"SELECT id, port_a_type, port_a_id, port_b_type, port_b_id
|
||||||
|
FROM connections",
|
||||||
|
"",
|
||||||
|
[]
|
||||||
|
) as $row) {
|
||||||
|
$fromKey = $resolveNodeKey((string)($row['port_a_type'] ?? ''), (int)($row['port_a_id'] ?? 0));
|
||||||
|
$toKey = $resolveNodeKey((string)($row['port_b_type'] ?? ''), (int)($row['port_b_id'] ?? 0));
|
||||||
|
if ($fromKey === null || $toKey === null || $fromKey === $toKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$edgeKey = ($fromKey < $toKey) ? ($fromKey . '|' . $toKey) : ($toKey . '|' . $fromKey);
|
||||||
|
if (!isset($linksByKey[$edgeKey])) {
|
||||||
|
$linksByKey[$edgeKey] = [
|
||||||
|
'from_key' => $fromKey,
|
||||||
|
'to_key' => $toKey,
|
||||||
|
'count' => 0,
|
||||||
|
'sample_connection_id' => (int)($row['id'] ?? 0)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$linksByKey[$edgeKey]['count']++;
|
||||||
|
}
|
||||||
|
$editorLinks = array_values($linksByKey);
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
||||||
@@ -108,10 +205,10 @@ if ($editorFloor) {
|
|||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<a href="?module=floor_infrastructure&action=edit&type=patchpanel" class="button button-primary">
|
<a href="?module=floor_infrastructure&action=edit&type=patchpanel" class="button button-primary">
|
||||||
+ Patchpanel hinzufuegen
|
+ Patchpanel hinzufügen
|
||||||
</a>
|
</a>
|
||||||
<a href="?module=floor_infrastructure&action=edit&type=outlet" class="button">
|
<a href="?module=floor_infrastructure&action=edit&type=outlet" class="button">
|
||||||
+ Wandbuchse hinzufuegen
|
+ Wandbuchse hinzufügen
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,7 +217,7 @@ if ($editorFloor) {
|
|||||||
<input type="hidden" name="action" value="list">
|
<input type="hidden" name="action" value="list">
|
||||||
|
|
||||||
<select name="floor_id" id="infra-floor-select">
|
<select name="floor_id" id="infra-floor-select">
|
||||||
<option value="">- Stockwerk waehlen -</option>
|
<option value="">- Stockwerk wählen -</option>
|
||||||
<?php foreach ($floors as $floor): ?>
|
<?php foreach ($floors as $floor): ?>
|
||||||
<option value="<?php echo (int)$floor['id']; ?>" <?php echo ((int)$floor['id'] === $floorId) ? 'selected' : ''; ?>>
|
<option value="<?php echo (int)$floor['id']; ?>" <?php echo ((int)$floor['id'] === $floorId) ? 'selected' : ''; ?>>
|
||||||
<?php echo htmlspecialchars((string)$floor['name']); ?>
|
<?php echo htmlspecialchars((string)$floor['name']); ?>
|
||||||
@@ -129,30 +226,44 @@ if ($editorFloor) {
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button class="button" type="submit">Filter</button>
|
<button class="button" type="submit">Filter</button>
|
||||||
<a href="?module=floor_infrastructure&action=list" class="button">Zuruecksetzen</a>
|
<a href="?module=floor_infrastructure&action=list" class="button">Zurücksetzen</a>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section class="infra-plan">
|
<section class="infra-plan">
|
||||||
<h2>Stockwerkskarte</h2>
|
<div class="infra-plan-header">
|
||||||
|
<h2>Stockwerkskarte</h2>
|
||||||
|
<div class="infra-plan-tools">
|
||||||
|
<button type="button" class="button button-small" data-infra-toggle="rooms">Räume</button>
|
||||||
|
<button type="button" class="button button-small" data-infra-toggle="connections">Kabel</button>
|
||||||
|
<button type="button" class="button button-small" data-infra-download="png">PNG</button>
|
||||||
|
<button type="button" class="button button-small" data-infra-zoom="in">+</button>
|
||||||
|
<button type="button" class="button button-small" data-infra-zoom="out">-</button>
|
||||||
|
<button type="button" class="button button-small" data-infra-zoom="reset">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<?php if ($floorId <= 0): ?>
|
<?php if ($floorId <= 0): ?>
|
||||||
<p class="empty-state">Bitte ein Stockwerk auswaehlen, um die Karte anzuzeigen.</p>
|
<p class="empty-state">Bitte ein Stockwerk auswählen, um die Karte anzuzeigen.</p>
|
||||||
<?php elseif (!$editorFloor): ?>
|
<?php elseif (!$editorFloor): ?>
|
||||||
<p class="empty-state">Gewaehltes Stockwerk wurde nicht gefunden.</p>
|
<p class="empty-state">Gewähltes Stockwerk wurde nicht gefunden.</p>
|
||||||
<?php elseif (($editorFloor['svg_url'] ?? '') === ''): ?>
|
<?php elseif (($editorFloor['svg_url'] ?? '') === ''): ?>
|
||||||
<p class="empty-state">Das gewaehlte Stockwerk hat keinen SVG-Plan hinterlegt.</p>
|
<p class="empty-state">Das gewählte Stockwerk hat keinen SVG-Plan hinterlegt.</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<p>
|
<p>
|
||||||
Read-only Vorschau fuer <strong><?php echo htmlspecialchars((string)$editorFloor['name']); ?></strong>.
|
Read-only Vorschau für <strong><?php echo htmlspecialchars((string)$editorFloor['name']); ?></strong>.
|
||||||
Alle Objekte werden angezeigt; Hover zeigt Details.
|
Alle Objekte werden angezeigt; Hover zeigt Details.
|
||||||
</p>
|
</p>
|
||||||
<div id="infra-floor-canvas"
|
<div id="infra-floor-canvas"
|
||||||
class="infra-floor-canvas"
|
class="infra-floor-canvas"
|
||||||
data-patchpanels="<?php echo htmlspecialchars(json_encode($editorPatchPanels, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>"
|
data-patchpanels="<?php echo htmlspecialchars(json_encode($editorPatchPanels, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>"
|
||||||
data-outlets="<?php echo htmlspecialchars(json_encode($editorOutlets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>">
|
data-outlets="<?php echo htmlspecialchars(json_encode($editorOutlets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>"
|
||||||
<img src="<?php echo htmlspecialchars((string)$editorFloor['svg_url']); ?>" class="infra-floor-svg" alt="Stockwerksplan">
|
data-rooms="<?php echo htmlspecialchars(json_encode($editorRooms, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>"
|
||||||
<svg id="infra-floor-overlay" class="infra-floor-overlay" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
|
data-links="<?php echo htmlspecialchars(json_encode($editorLinks, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
|
<div class="infra-floor-scene" id="infra-floor-scene">
|
||||||
|
<img src="<?php echo htmlspecialchars((string)$editorFloor['svg_url']); ?>" class="infra-floor-svg" alt="Stockwerksplan">
|
||||||
|
<svg id="infra-floor-overlay" class="infra-floor-overlay" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="floor-plan-hint">Blau: Patchpanel | Gruen: Wandbuchse. Hover zeigt Name, Raum und Ports (Browser-Tooltip).</p>
|
<p class="floor-plan-hint">Blau: Patchpanel | Grün: Wandbuchse. Knoten sind greifbar und verschiebbar. Räume und Kabel lassen sich ein-/ausblenden.</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -165,7 +276,7 @@ if ($editorFloor) {
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Stockwerk</th>
|
<th>Stockwerk</th>
|
||||||
<th>Position</th>
|
<th>Position</th>
|
||||||
<th>Groesse</th>
|
<th>Größe</th>
|
||||||
<th>Ports</th>
|
<th>Ports</th>
|
||||||
<th>Aktionen</th>
|
<th>Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -186,7 +297,7 @@ if ($editorFloor) {
|
|||||||
data-delete-id="<?php echo (int)$panel['id']; ?>"
|
data-delete-id="<?php echo (int)$panel['id']; ?>"
|
||||||
data-delete-type="patchpanel"
|
data-delete-type="patchpanel"
|
||||||
data-delete-label="<?php echo htmlspecialchars((string)$panel['name'], ENT_QUOTES, 'UTF-8'); ?>">
|
data-delete-label="<?php echo htmlspecialchars((string)$panel['name'], ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
Loeschen
|
Löschen
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -239,7 +350,7 @@ if ($editorFloor) {
|
|||||||
data-delete-id="<?php echo (int)$outlet['id']; ?>"
|
data-delete-id="<?php echo (int)$outlet['id']; ?>"
|
||||||
data-delete-type="outlet"
|
data-delete-type="outlet"
|
||||||
data-delete-label="<?php echo htmlspecialchars((string)$outlet['name'], ENT_QUOTES, 'UTF-8'); ?>">
|
data-delete-label="<?php echo htmlspecialchars((string)$outlet['name'], ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
Loeschen
|
Löschen
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -264,7 +375,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entityLabel = type === 'patchpanel' ? 'Patchpanel' : 'Wandbuchse';
|
const entityLabel = type === 'patchpanel' ? 'Patchpanel' : 'Wandbuchse';
|
||||||
if (!confirm(entityLabel + ' "' + label + '" wirklich loeschen?')) {
|
if (!confirm(entityLabel + ' "' + label + '" wirklich löschen?')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,9 +390,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
alert((data && data.message) ? data.message : 'Loeschen fehlgeschlagen');
|
alert((data && data.message) ? data.message : 'Löschen fehlgeschlagen');
|
||||||
})
|
})
|
||||||
.catch(() => alert('Loeschen fehlgeschlagen'));
|
.catch(() => alert('Löschen fehlgeschlagen'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user