1318 lines
44 KiB
PHP
1318 lines
44 KiB
PHP
<?php
|
|
/**
|
|
* modules/dashboard/list.php
|
|
* Dashboard / Startseite - Uebersicht ueber alle Komponenten
|
|
*/
|
|
|
|
$stats = [
|
|
'devices' => $sql->single("SELECT COUNT(*) as cnt FROM devices", "", [])['cnt'] ?? 0,
|
|
'device_types' => $sql->single("SELECT COUNT(*) as cnt FROM device_types", "", [])['cnt'] ?? 0,
|
|
'racks' => $sql->single("SELECT COUNT(*) as cnt FROM racks", "", [])['cnt'] ?? 0,
|
|
'floors' => $sql->single("SELECT COUNT(*) as cnt FROM floors", "", [])['cnt'] ?? 0,
|
|
'locations' => $sql->single("SELECT COUNT(*) as cnt FROM locations", "", [])['cnt'] ?? 0,
|
|
];
|
|
|
|
$recentDevices = $sql->get(
|
|
"SELECT d.id, d.name, dt.name as type_name, r.name as rack_name, f.name as floor_name
|
|
FROM devices d
|
|
LEFT JOIN device_types dt ON d.device_type_id = dt.id
|
|
LEFT JOIN racks r ON d.rack_id = r.id
|
|
LEFT JOIN floors f ON r.floor_id = f.id
|
|
ORDER BY d.id DESC LIMIT 5",
|
|
"",
|
|
[]
|
|
);
|
|
|
|
$topologyDevices = $sql->get(
|
|
"SELECT
|
|
d.id AS device_id,
|
|
d.name AS device_name,
|
|
dt.name AS device_type_name,
|
|
r.id AS rack_id,
|
|
r.name AS rack_name,
|
|
f.id AS floor_id,
|
|
f.name AS floor_name,
|
|
b.id AS building_id,
|
|
b.name AS building_name,
|
|
l.id AS location_id,
|
|
l.name AS location_name
|
|
FROM devices d
|
|
LEFT JOIN device_types dt ON dt.id = d.device_type_id
|
|
LEFT JOIN racks r ON r.id = d.rack_id
|
|
LEFT JOIN floors f ON f.id = r.floor_id
|
|
LEFT JOIN buildings b ON b.id = f.building_id
|
|
LEFT JOIN locations l ON l.id = b.location_id
|
|
ORDER BY location_name, building_name, floor_name, rack_name, device_name",
|
|
"",
|
|
[]
|
|
);
|
|
|
|
$topologyPayload = array_map(static function (array $row): array {
|
|
return [
|
|
'device_id' => (int)($row['device_id'] ?? 0),
|
|
'device_name' => (string)($row['device_name'] ?? ''),
|
|
'device_type_name' => (string)($row['device_type_name'] ?? ''),
|
|
'rack_id' => (int)($row['rack_id'] ?? 0),
|
|
'rack_name' => (string)($row['rack_name'] ?? ''),
|
|
'floor_id' => (int)($row['floor_id'] ?? 0),
|
|
'floor_name' => (string)($row['floor_name'] ?? ''),
|
|
'building_id' => (int)($row['building_id'] ?? 0),
|
|
'building_name' => (string)($row['building_name'] ?? ''),
|
|
'location_id' => (int)($row['location_id'] ?? 0),
|
|
'location_name' => (string)($row['location_name'] ?? ''),
|
|
];
|
|
}, $topologyDevices);
|
|
|
|
$devicePortCountByDevice = [];
|
|
foreach ($sql->get(
|
|
"SELECT device_id, COUNT(*) AS cnt
|
|
FROM device_ports
|
|
GROUP BY device_id",
|
|
"",
|
|
[]
|
|
) as $row) {
|
|
$deviceId = (int)($row['device_id'] ?? 0);
|
|
if ($deviceId <= 0) {
|
|
continue;
|
|
}
|
|
$devicePortCountByDevice[$deviceId] = (int)($row['cnt'] ?? 0);
|
|
}
|
|
|
|
$devicePortPreviewByDevice = [];
|
|
$connectedDevicePorts = [];
|
|
foreach ($sql->get(
|
|
"SELECT port_a_type, port_a_id, port_b_type, port_b_id
|
|
FROM connections",
|
|
"",
|
|
[]
|
|
) as $row) {
|
|
$portAType = strtolower(trim((string)($row['port_a_type'] ?? '')));
|
|
$portBType = strtolower(trim((string)($row['port_b_type'] ?? '')));
|
|
$portAId = (int)($row['port_a_id'] ?? 0);
|
|
$portBId = (int)($row['port_b_id'] ?? 0);
|
|
|
|
if (($portAType === 'device' || $portAType === 'device_ports') && $portAId > 0) {
|
|
$connectedDevicePorts[$portAId] = true;
|
|
}
|
|
if (($portBType === 'device' || $portBType === 'device_ports') && $portBId > 0) {
|
|
$connectedDevicePorts[$portBId] = true;
|
|
}
|
|
}
|
|
|
|
foreach ($sql->get(
|
|
"SELECT id, device_id, name
|
|
FROM device_ports
|
|
ORDER BY device_id, id",
|
|
"",
|
|
[]
|
|
) as $row) {
|
|
$deviceId = (int)($row['device_id'] ?? 0);
|
|
if ($deviceId <= 0) {
|
|
continue;
|
|
}
|
|
if (!isset($devicePortPreviewByDevice[$deviceId])) {
|
|
$devicePortPreviewByDevice[$deviceId] = [];
|
|
}
|
|
if (count($devicePortPreviewByDevice[$deviceId]) >= 4) {
|
|
continue;
|
|
}
|
|
$devicePortPreviewByDevice[$deviceId][] = [
|
|
'id' => (int)($row['id'] ?? 0),
|
|
'name' => (string)($row['name'] ?? ''),
|
|
'is_connected' => isset($connectedDevicePorts[(int)($row['id'] ?? 0)]),
|
|
];
|
|
}
|
|
|
|
foreach ($topologyPayload as $idx => $entry) {
|
|
$deviceId = (int)($entry['device_id'] ?? 0);
|
|
$topologyPayload[$idx]['port_count'] = (int)($devicePortCountByDevice[$deviceId] ?? 0);
|
|
$topologyPayload[$idx]['port_preview'] = $devicePortPreviewByDevice[$deviceId] ?? [];
|
|
}
|
|
|
|
$rackInfoRows = $sql->get(
|
|
"SELECT
|
|
r.id AS rack_id,
|
|
r.name AS rack_name,
|
|
f.id AS floor_id,
|
|
f.name AS floor_name,
|
|
b.id AS building_id,
|
|
b.name AS building_name,
|
|
l.id AS location_id,
|
|
l.name AS location_name
|
|
FROM racks r
|
|
LEFT JOIN floors f ON f.id = r.floor_id
|
|
LEFT JOIN buildings b ON b.id = f.building_id
|
|
LEFT JOIN locations l ON l.id = b.location_id",
|
|
"",
|
|
[]
|
|
);
|
|
|
|
$rackInfoById = [];
|
|
foreach ($rackInfoRows as $row) {
|
|
$rackId = (int)($row['rack_id'] ?? 0);
|
|
if ($rackId <= 0) {
|
|
continue;
|
|
}
|
|
$rackInfoById[$rackId] = [
|
|
'rack_id' => $rackId,
|
|
'rack_name' => (string)($row['rack_name'] ?? ''),
|
|
'floor_id' => (int)($row['floor_id'] ?? 0),
|
|
'floor_name' => (string)($row['floor_name'] ?? ''),
|
|
'building_id' => (int)($row['building_id'] ?? 0),
|
|
'building_name' => (string)($row['building_name'] ?? ''),
|
|
'location_id' => (int)($row['location_id'] ?? 0),
|
|
'location_name' => (string)($row['location_name'] ?? ''),
|
|
];
|
|
}
|
|
|
|
$devicePortRacks = [];
|
|
foreach ($sql->get(
|
|
"SELECT dp.id AS port_id, d.rack_id
|
|
FROM device_ports dp
|
|
JOIN devices d ON d.id = dp.device_id
|
|
WHERE d.rack_id IS NOT NULL",
|
|
"",
|
|
[]
|
|
) as $row) {
|
|
$devicePortRacks[(int)$row['port_id']] = (int)$row['rack_id'];
|
|
}
|
|
|
|
$modulePortRacks = [];
|
|
foreach ($sql->get(
|
|
"SELECT mp.id AS port_id, d.rack_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
|
|
JOIN devices d ON d.id = dp.device_id
|
|
WHERE d.rack_id IS NOT NULL",
|
|
"",
|
|
[]
|
|
) as $row) {
|
|
$modulePortRacks[(int)$row['port_id']] = (int)$row['rack_id'];
|
|
}
|
|
|
|
$resolveRackId = static function (string $endpointType, int $endpointId) use ($devicePortRacks, $modulePortRacks): int {
|
|
if ($endpointType === 'device') {
|
|
return (int)($devicePortRacks[$endpointId] ?? 0);
|
|
}
|
|
if ($endpointType === 'module') {
|
|
return (int)($modulePortRacks[$endpointId] ?? 0);
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
$rackLinksByKey = [];
|
|
foreach ($sql->get(
|
|
"SELECT id, port_a_type, port_a_id, port_b_type, port_b_id
|
|
FROM connections",
|
|
"",
|
|
[]
|
|
) as $row) {
|
|
$rackA = $resolveRackId((string)($row['port_a_type'] ?? ''), (int)($row['port_a_id'] ?? 0));
|
|
$rackB = $resolveRackId((string)($row['port_b_type'] ?? ''), (int)($row['port_b_id'] ?? 0));
|
|
if ($rackA <= 0 || $rackB <= 0 || $rackA === $rackB) {
|
|
continue;
|
|
}
|
|
|
|
$from = min($rackA, $rackB);
|
|
$to = max($rackA, $rackB);
|
|
$key = $from . ':' . $to;
|
|
if (!isset($rackLinksByKey[$key])) {
|
|
$rackLinksByKey[$key] = [
|
|
'from_rack_id' => $from,
|
|
'to_rack_id' => $to,
|
|
'count' => 0,
|
|
'sample_connection_id' => (int)($row['id'] ?? 0)
|
|
];
|
|
}
|
|
$rackLinksByKey[$key]['count']++;
|
|
}
|
|
|
|
$rackLinkPayload = [];
|
|
foreach ($rackLinksByKey as $entry) {
|
|
$fromId = (int)$entry['from_rack_id'];
|
|
$toId = (int)$entry['to_rack_id'];
|
|
$fromMeta = $rackInfoById[$fromId] ?? ['rack_name' => 'Rack #' . $fromId];
|
|
$toMeta = $rackInfoById[$toId] ?? ['rack_name' => 'Rack #' . $toId];
|
|
|
|
$rackLinkPayload[] = [
|
|
'from_rack_id' => $fromId,
|
|
'to_rack_id' => $toId,
|
|
'count' => (int)$entry['count'],
|
|
'from_rack_name' => (string)($fromMeta['rack_name'] ?? ('Rack #' . $fromId)),
|
|
'to_rack_name' => (string)($toMeta['rack_name'] ?? ('Rack #' . $toId)),
|
|
'sample_connection_id' => (int)($entry['sample_connection_id'] ?? 0),
|
|
];
|
|
}
|
|
?>
|
|
|
|
<div class="dashboard">
|
|
<h1>Dashboard</h1>
|
|
<div id="dashboard-modules" class="dashboard-modules"></div>
|
|
<p data-dashboard-stats class="dashboard-inline-status"></p>
|
|
<p data-dashboard-warnings class="dashboard-inline-status"></p>
|
|
<p data-dashboard-recent class="dashboard-inline-status"></p>
|
|
|
|
<section class="topology-wall" id="dashboard-topology-wall">
|
|
<div class="topology-wall__header">
|
|
<h2>Gesamt-Topologie-Wand</h2>
|
|
<div class="topology-wall__tools">
|
|
<button type="button" class="button button-small" data-topology-zoom="in">+</button>
|
|
<button type="button" class="button button-small" data-topology-zoom="out">-</button>
|
|
<button type="button" class="button button-small" data-topology-zoom="reset">Reset</button>
|
|
</div>
|
|
</div>
|
|
<p class="topology-wall__hint">Hierarchie: Standort → Gebaeude → Stockwerk → Rack → Geraet. Linien zeigen Rack-Verbindungen (dicker = mehr Links).</p>
|
|
|
|
<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>
|
|
<g id="dashboard-topology-grid"></g>
|
|
<g id="dashboard-topology-connections"></g>
|
|
<g id="dashboard-topology-layer"></g>
|
|
</svg>
|
|
|
|
<div id="dashboard-topology-empty" class="topology-empty" hidden>Keine Geraete vorhanden. Bitte zuerst Geraete erfassen.</div>
|
|
|
|
<aside id="dashboard-topology-overlay" class="topology-overlay" hidden>
|
|
<div class="topology-overlay__header">
|
|
<h3 data-topology-title>Rack-Detail</h3>
|
|
<button type="button" class="button button-small" data-topology-close>Schliessen</button>
|
|
</div>
|
|
<p data-topology-meta></p>
|
|
<p data-topology-rack-link></p>
|
|
<p data-topology-device-link></p>
|
|
<div class="topology-overlay__actions" data-topology-actions></div>
|
|
</aside>
|
|
</section>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<h3><?php echo (int)$stats['locations']; ?></h3>
|
|
<p>Standorte</p>
|
|
<a href="?module=floors&action=list">Verwalten -></a>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<h3><?php echo (int)$stats['device_types']; ?></h3>
|
|
<p>Geraetetypen</p>
|
|
<a href="?module=device_types&action=list">Verwalten -></a>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<h3><?php echo (int)$stats['devices']; ?></h3>
|
|
<p>Geraete</p>
|
|
<a href="?module=devices&action=list">Verwalten -></a>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<h3><?php echo (int)$stats['racks']; ?></h3>
|
|
<p>Racks</p>
|
|
<a href="?module=racks&action=list">Verwalten -></a>
|
|
</div>
|
|
</div>
|
|
|
|
<h2>Zuletzt hinzugefuegt</h2>
|
|
<?php if (!empty($recentDevices)): ?>
|
|
<table class="recent-devices">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Typ</th>
|
|
<th>Rack</th>
|
|
<th>Stockwerk</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ($recentDevices as $device): ?>
|
|
<tr>
|
|
<td><?php echo htmlspecialchars((string)$device['name']); ?></td>
|
|
<td><?php echo htmlspecialchars((string)($device['type_name'] ?? '-')); ?></td>
|
|
<td><?php echo htmlspecialchars((string)($device['rack_name'] ?? '-')); ?></td>
|
|
<td><?php echo htmlspecialchars((string)($device['floor_name'] ?? '-')); ?></td>
|
|
<td><a href="?module=devices&action=edit&id=<?php echo (int)$device['id']; ?>">Bearbeiten</a></td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
<?php else: ?>
|
|
<p><em>Noch keine Geraete vorhanden. <a href="?module=device_types&action=list">Starten Sie mit Geraetetypen</a>.</em></p>
|
|
<?php endif; ?>
|
|
</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-links" type="application/json"><?php echo json_encode($rackLinkPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?></script>
|
|
<script>
|
|
(function () {
|
|
const root = document.getElementById('dashboard-topology-wall');
|
|
if (!root) {
|
|
return;
|
|
}
|
|
|
|
const svg = document.getElementById('dashboard-topology-svg');
|
|
const bgNode = document.getElementById('dashboard-topology-bg');
|
|
const gridLayer = document.getElementById('dashboard-topology-grid');
|
|
const connectionLayer = document.getElementById('dashboard-topology-connections');
|
|
const nodeLayer = document.getElementById('dashboard-topology-layer');
|
|
const emptyNode = document.getElementById('dashboard-topology-empty');
|
|
const overlay = document.getElementById('dashboard-topology-overlay');
|
|
const overlayTitle = overlay ? overlay.querySelector('[data-topology-title]') : null;
|
|
const overlayMeta = overlay ? overlay.querySelector('[data-topology-meta]') : null;
|
|
const overlayRackLink = overlay ? overlay.querySelector('[data-topology-rack-link]') : null;
|
|
const overlayDeviceLink = overlay ? overlay.querySelector('[data-topology-device-link]') : null;
|
|
const overlayActions = overlay ? overlay.querySelector('[data-topology-actions]') : null;
|
|
const closeButton = overlay ? overlay.querySelector('[data-topology-close]') : null;
|
|
|
|
const dataTag = document.getElementById('dashboard-topology-data');
|
|
const linkTag = document.getElementById('dashboard-topology-links');
|
|
let nodes = [];
|
|
let rackLinks = [];
|
|
try {
|
|
nodes = JSON.parse((dataTag && dataTag.textContent) || '[]');
|
|
} catch (error) {
|
|
nodes = [];
|
|
}
|
|
try {
|
|
rackLinks = JSON.parse((linkTag && linkTag.textContent) || '[]');
|
|
} catch (error) {
|
|
rackLinks = [];
|
|
}
|
|
|
|
const scene = { width: 2400, height: 1400 };
|
|
let view = { x: 0, y: 0, width: scene.width, height: scene.height };
|
|
let drag = null;
|
|
|
|
function clearActiveSelections() {
|
|
nodeLayer.querySelectorAll('.topology-node.active, .topology-port-node.active').forEach((node) => {
|
|
node.classList.remove('active');
|
|
});
|
|
connectionLayer.querySelectorAll('.topology-connection-line.active').forEach((line) => {
|
|
line.classList.remove('active');
|
|
});
|
|
}
|
|
|
|
function clampView() {
|
|
view.width = Math.max(220, Math.min(scene.width, view.width));
|
|
view.height = Math.max(180, Math.min(scene.height, view.height));
|
|
view.x = Math.max(0, Math.min(scene.width - view.width, view.x));
|
|
view.y = Math.max(0, Math.min(scene.height - view.height, view.y));
|
|
}
|
|
|
|
function applyView() {
|
|
clampView();
|
|
svg.setAttribute('viewBox', `${view.x} ${view.y} ${view.width} ${view.height}`);
|
|
}
|
|
|
|
function toSvgPoint(clientX, clientY) {
|
|
const rect = svg.getBoundingClientRect();
|
|
const x = view.x + ((clientX - rect.left) / rect.width) * view.width;
|
|
const y = view.y + ((clientY - rect.top) / rect.height) * view.height;
|
|
return { x, y };
|
|
}
|
|
|
|
function zoomAt(anchorX, anchorY, factor) {
|
|
const nextWidth = view.width * factor;
|
|
const nextHeight = view.height * factor;
|
|
|
|
const ratioX = (anchorX - view.x) / view.width;
|
|
const ratioY = (anchorY - view.y) / view.height;
|
|
|
|
view = {
|
|
x: anchorX - ratioX * nextWidth,
|
|
y: anchorY - ratioY * nextHeight,
|
|
width: nextWidth,
|
|
height: nextHeight
|
|
};
|
|
applyView();
|
|
}
|
|
|
|
function resetView() {
|
|
view = { x: 0, y: 0, width: scene.width, height: scene.height };
|
|
applyView();
|
|
}
|
|
|
|
function zoomToNode(x, y) {
|
|
view = {
|
|
x: x - 250,
|
|
y: y - 170,
|
|
width: 500,
|
|
height: 340
|
|
};
|
|
applyView();
|
|
}
|
|
|
|
function clearLayer(layer) {
|
|
while (layer.firstChild) {
|
|
layer.removeChild(layer.firstChild);
|
|
}
|
|
}
|
|
|
|
function svgElement(name) {
|
|
return document.createElementNS('http://www.w3.org/2000/svg', name);
|
|
}
|
|
|
|
function drawGrid() {
|
|
clearLayer(gridLayer);
|
|
for (let x = 0; x <= scene.width; x += 100) {
|
|
const line = svgElement('line');
|
|
line.setAttribute('x1', String(x));
|
|
line.setAttribute('y1', '0');
|
|
line.setAttribute('x2', String(x));
|
|
line.setAttribute('y2', String(scene.height));
|
|
line.setAttribute('class', 'topology-grid-line');
|
|
gridLayer.appendChild(line);
|
|
}
|
|
|
|
for (let y = 0; y <= scene.height; y += 100) {
|
|
const line = svgElement('line');
|
|
line.setAttribute('x1', '0');
|
|
line.setAttribute('y1', String(y));
|
|
line.setAttribute('x2', String(scene.width));
|
|
line.setAttribute('y2', String(y));
|
|
line.setAttribute('class', 'topology-grid-line');
|
|
gridLayer.appendChild(line);
|
|
}
|
|
}
|
|
|
|
function buildLayout(data) {
|
|
const hierarchy = new Map();
|
|
|
|
data.forEach((entry) => {
|
|
const locationId = Number(entry.location_id || 0);
|
|
const locationName = (entry.location_name || '').trim() || 'Ohne Standort';
|
|
const buildingId = Number(entry.building_id || 0);
|
|
const buildingName = (entry.building_name || '').trim() || 'Ohne Gebaeude';
|
|
const floorId = Number(entry.floor_id || 0);
|
|
const floorName = (entry.floor_name || '').trim() || 'Ohne Stockwerk';
|
|
const rackId = Number(entry.rack_id || 0);
|
|
const rackName = (entry.rack_name || '').trim() || 'Ohne Rack';
|
|
|
|
const locationKey = `${locationId}:${locationName}`;
|
|
const buildingKey = `${buildingId}:${buildingName}`;
|
|
const floorKey = `${floorId}:${floorName}`;
|
|
const rackKey = `${rackId}:${rackName}`;
|
|
|
|
if (!hierarchy.has(locationKey)) {
|
|
hierarchy.set(locationKey, new Map());
|
|
}
|
|
const buildings = hierarchy.get(locationKey);
|
|
if (!buildings.has(buildingKey)) {
|
|
buildings.set(buildingKey, new Map());
|
|
}
|
|
const floors = buildings.get(buildingKey);
|
|
if (!floors.has(floorKey)) {
|
|
floors.set(floorKey, new Map());
|
|
}
|
|
const racks = floors.get(floorKey);
|
|
if (!racks.has(rackKey)) {
|
|
racks.set(rackKey, []);
|
|
}
|
|
racks.get(rackKey).push(entry);
|
|
});
|
|
|
|
const positioned = [];
|
|
const rackCenters = new Map();
|
|
let maxY = 1400;
|
|
let locationIndex = 0;
|
|
|
|
const locationKeys = Array.from(hierarchy.keys()).sort((a, b) => a.localeCompare(b));
|
|
locationKeys.forEach((locationKey) => {
|
|
const locationName = locationKey.split(':').slice(1).join(':');
|
|
const locationX = 120 + locationIndex * 760;
|
|
const buildings = hierarchy.get(locationKey);
|
|
const buildingKeys = Array.from(buildings.keys()).sort((a, b) => a.localeCompare(b));
|
|
let currentY = 110;
|
|
const locationTop = currentY - 46;
|
|
|
|
positioned.push({
|
|
type: 'location-label',
|
|
x: locationX,
|
|
y: 70,
|
|
label: locationName
|
|
});
|
|
|
|
buildingKeys.forEach((buildingKey) => {
|
|
const buildingName = buildingKey.split(':').slice(1).join(':');
|
|
const floors = buildings.get(buildingKey);
|
|
const floorKeys = Array.from(floors.keys()).sort((a, b) => a.localeCompare(b));
|
|
const buildingTop = currentY;
|
|
|
|
positioned.push({
|
|
type: 'building-label',
|
|
x: locationX + 18,
|
|
y: buildingTop + 24,
|
|
label: buildingName
|
|
});
|
|
|
|
let floorCursorY = buildingTop + 32;
|
|
floorKeys.forEach((floorKey) => {
|
|
const floorName = floorKey.split(':').slice(1).join(':');
|
|
const racks = floors.get(floorKey);
|
|
const rackKeys = Array.from(racks.keys()).sort((a, b) => a.localeCompare(b));
|
|
const floorTop = floorCursorY;
|
|
let rackCursorY = floorTop + 36;
|
|
|
|
positioned.push({
|
|
type: 'floor-label',
|
|
x: locationX + 28,
|
|
y: floorTop + 22,
|
|
label: floorName
|
|
});
|
|
|
|
rackKeys.forEach((rackKey) => {
|
|
const devices = racks.get(rackKey);
|
|
const [rackIdPart, ...rackNameParts] = rackKey.split(':');
|
|
const rackId = Number(rackIdPart || 0);
|
|
const rackName = rackNameParts.join(':') || 'Ohne Rack';
|
|
const rowCount = Math.ceil(Math.max(1, devices.length) / 6);
|
|
const rackHeight = Math.max(78, 28 + rowCount * 30);
|
|
const rackX = locationX + 40;
|
|
|
|
positioned.push({
|
|
type: 'rack-box',
|
|
x: rackX,
|
|
y: rackCursorY,
|
|
width: 640,
|
|
height: rackHeight,
|
|
label: rackName,
|
|
rack_id: rackId
|
|
});
|
|
|
|
if (rackId > 0) {
|
|
rackCenters.set(rackId, {
|
|
x: rackX + 320,
|
|
y: rackCursorY + rackHeight / 2
|
|
});
|
|
}
|
|
|
|
devices.forEach((device, deviceIndex) => {
|
|
const col = deviceIndex % 6;
|
|
const row = Math.floor(deviceIndex / 6);
|
|
const x = rackX + 24 + col * 96;
|
|
const y = rackCursorY + 28 + row * 30;
|
|
|
|
positioned.push({
|
|
type: 'node',
|
|
x,
|
|
y,
|
|
rack_id: Number(device.rack_id || 0),
|
|
rack_name: device.rack_name || 'Ohne Rack',
|
|
floor_name: device.floor_name || 'Ohne Stockwerk',
|
|
building_name: device.building_name || 'Ohne Gebaeude',
|
|
location_name: device.location_name || 'Ohne Standort',
|
|
device_id: Number(device.device_id || 0),
|
|
device_name: device.device_name || 'Unbenannt',
|
|
device_type_name: device.device_type_name || '',
|
|
port_count: Number(device.port_count || 0),
|
|
port_preview: Array.isArray(device.port_preview) ? device.port_preview : []
|
|
});
|
|
});
|
|
|
|
rackCursorY += rackHeight + 16;
|
|
});
|
|
|
|
const floorHeight = Math.max(84, rackCursorY - floorTop + 8);
|
|
positioned.push({
|
|
type: 'floor-box',
|
|
x: locationX + 20,
|
|
y: floorTop,
|
|
width: 680,
|
|
height: floorHeight
|
|
});
|
|
|
|
floorCursorY = floorTop + floorHeight + 18;
|
|
});
|
|
|
|
const buildingHeight = Math.max(130, floorCursorY - buildingTop + 8);
|
|
positioned.push({
|
|
type: 'building-box',
|
|
x: locationX + 8,
|
|
y: buildingTop,
|
|
width: 708,
|
|
height: buildingHeight
|
|
});
|
|
|
|
currentY = buildingTop + buildingHeight + 26;
|
|
});
|
|
|
|
const locationHeight = Math.max(220, currentY - locationTop + 16);
|
|
positioned.push({
|
|
type: 'location-box',
|
|
x: locationX - 8,
|
|
y: locationTop,
|
|
width: 736,
|
|
height: locationHeight
|
|
});
|
|
|
|
maxY = Math.max(maxY, locationTop + locationHeight + 40);
|
|
locationIndex++;
|
|
});
|
|
|
|
const width = Math.max(2400, 220 + locationIndex * 760);
|
|
const height = Math.max(1400, Math.ceil(maxY));
|
|
return { entries: positioned, rackCenters, width, height };
|
|
}
|
|
|
|
function showOverlay(item) {
|
|
if (!overlay || !overlayTitle || !overlayMeta || !overlayRackLink || !overlayDeviceLink || !overlayActions) {
|
|
return;
|
|
}
|
|
|
|
overlayRackLink.textContent = '';
|
|
overlayDeviceLink.textContent = '';
|
|
overlayActions.innerHTML = '';
|
|
|
|
if (item.kind === 'connection') {
|
|
overlayTitle.textContent = 'Verbindung';
|
|
overlayMeta.textContent = `${item.from_name} <-> ${item.to_name} | Anzahl: ${item.count}`;
|
|
if (item.sample_connection_id > 0) {
|
|
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>`;
|
|
}
|
|
overlay.hidden = false;
|
|
return;
|
|
}
|
|
|
|
if (item.kind === 'port') {
|
|
overlayTitle.textContent = `Port: ${item.port_name}`;
|
|
overlayMeta.textContent = `Geraet: ${item.device_name} | Rack: ${item.rack_name} | Stockwerk: ${item.floor_name}`;
|
|
if (item.device_id > 0) {
|
|
overlayRackLink.innerHTML = `<a href="?module=devices&action=edit&id=${item.device_id}">Geraet bearbeiten</a>`;
|
|
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>`;
|
|
} else {
|
|
overlayDeviceLink.textContent = 'Port ist bereits verbunden';
|
|
}
|
|
}
|
|
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>`;
|
|
}
|
|
overlay.hidden = false;
|
|
return;
|
|
}
|
|
|
|
overlayTitle.textContent = item.rack_name || 'Ohne Rack';
|
|
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}`;
|
|
|
|
if (item.rack_id > 0) {
|
|
overlayRackLink.innerHTML = `<a href="?module=racks&action=edit&id=${item.rack_id}">Rack bearbeiten</a>`;
|
|
} else {
|
|
overlayRackLink.textContent = 'Kein Rack verknuepft';
|
|
}
|
|
|
|
if (item.device_id > 0) {
|
|
overlayDeviceLink.innerHTML = `<a href="?module=devices&action=edit&id=${item.device_id}">Geraet bearbeiten</a>`;
|
|
overlayActions.innerHTML = `
|
|
<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>
|
|
`;
|
|
}
|
|
|
|
overlay.hidden = false;
|
|
}
|
|
|
|
function renderTopology() {
|
|
if (!nodes.length) {
|
|
drawGrid();
|
|
clearLayer(connectionLayer);
|
|
clearLayer(nodeLayer);
|
|
if (emptyNode) {
|
|
emptyNode.hidden = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (emptyNode) {
|
|
emptyNode.hidden = true;
|
|
}
|
|
|
|
const layout = buildLayout(nodes);
|
|
const entries = layout.entries;
|
|
scene.width = layout.width;
|
|
scene.height = layout.height;
|
|
drawGrid();
|
|
clearLayer(connectionLayer);
|
|
clearLayer(nodeLayer);
|
|
if (bgNode) {
|
|
bgNode.setAttribute('width', String(scene.width));
|
|
bgNode.setAttribute('height', String(scene.height));
|
|
}
|
|
|
|
entries.forEach((entry) => {
|
|
if (entry.type === 'location-box') {
|
|
const rect = svgElement('rect');
|
|
rect.setAttribute('x', String(entry.x));
|
|
rect.setAttribute('y', String(entry.y));
|
|
rect.setAttribute('width', String(entry.width));
|
|
rect.setAttribute('height', String(entry.height));
|
|
rect.setAttribute('rx', '12');
|
|
rect.setAttribute('class', 'topology-location-box');
|
|
nodeLayer.appendChild(rect);
|
|
return;
|
|
}
|
|
|
|
if (entry.type === 'building-box') {
|
|
const rect = svgElement('rect');
|
|
rect.setAttribute('x', String(entry.x));
|
|
rect.setAttribute('y', String(entry.y));
|
|
rect.setAttribute('width', String(entry.width));
|
|
rect.setAttribute('height', String(entry.height));
|
|
rect.setAttribute('rx', '10');
|
|
rect.setAttribute('class', 'topology-building-box');
|
|
nodeLayer.appendChild(rect);
|
|
return;
|
|
}
|
|
|
|
if (entry.type === 'floor-box') {
|
|
const rect = svgElement('rect');
|
|
rect.setAttribute('x', String(entry.x));
|
|
rect.setAttribute('y', String(entry.y));
|
|
rect.setAttribute('width', String(entry.width));
|
|
rect.setAttribute('height', String(entry.height));
|
|
rect.setAttribute('rx', '8');
|
|
rect.setAttribute('class', 'topology-floor-box');
|
|
nodeLayer.appendChild(rect);
|
|
return;
|
|
}
|
|
|
|
if (entry.type === 'location-label') {
|
|
const text = svgElement('text');
|
|
text.setAttribute('x', String(entry.x));
|
|
text.setAttribute('y', String(entry.y));
|
|
text.setAttribute('class', 'topology-location-label');
|
|
text.textContent = entry.label;
|
|
nodeLayer.appendChild(text);
|
|
return;
|
|
}
|
|
|
|
if (entry.type === 'building-label') {
|
|
const text = svgElement('text');
|
|
text.setAttribute('x', String(entry.x));
|
|
text.setAttribute('y', String(entry.y));
|
|
text.setAttribute('class', 'topology-building-label');
|
|
text.textContent = entry.label;
|
|
nodeLayer.appendChild(text);
|
|
return;
|
|
}
|
|
|
|
if (entry.type === 'floor-label') {
|
|
const text = svgElement('text');
|
|
text.setAttribute('x', String(entry.x));
|
|
text.setAttribute('y', String(entry.y));
|
|
text.setAttribute('class', 'topology-floor-label');
|
|
text.textContent = entry.label;
|
|
nodeLayer.appendChild(text);
|
|
return;
|
|
}
|
|
|
|
if (entry.type === 'rack-box') {
|
|
const rect = svgElement('rect');
|
|
rect.setAttribute('x', String(entry.x));
|
|
rect.setAttribute('y', String(entry.y));
|
|
rect.setAttribute('width', String(entry.width));
|
|
rect.setAttribute('height', String(entry.height));
|
|
rect.setAttribute('rx', '10');
|
|
rect.setAttribute('class', 'topology-rack-box');
|
|
nodeLayer.appendChild(rect);
|
|
|
|
const label = svgElement('text');
|
|
label.setAttribute('x', String(entry.x + 12));
|
|
label.setAttribute('y', String(entry.y + 22));
|
|
label.setAttribute('class', 'topology-rack-label');
|
|
label.textContent = entry.label;
|
|
nodeLayer.appendChild(label);
|
|
return;
|
|
}
|
|
|
|
const circle = svgElement('circle');
|
|
circle.setAttribute('cx', String(entry.x));
|
|
circle.setAttribute('cy', String(entry.y));
|
|
circle.setAttribute('r', '10');
|
|
circle.setAttribute('tabindex', '0');
|
|
circle.setAttribute('class', 'topology-node');
|
|
circle.setAttribute('data-device-id', String(entry.device_id));
|
|
circle.setAttribute('data-rack-id', String(entry.rack_id));
|
|
|
|
const title = svgElement('title');
|
|
title.textContent = `${entry.device_name} (${entry.floor_name} / ${entry.rack_name})`;
|
|
circle.appendChild(title);
|
|
|
|
const activate = () => {
|
|
clearActiveSelections();
|
|
circle.classList.add('active');
|
|
zoomToNode(entry.x, entry.y);
|
|
showOverlay({ ...entry, kind: 'device' });
|
|
};
|
|
|
|
circle.addEventListener('click', activate);
|
|
circle.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
activate();
|
|
}
|
|
});
|
|
|
|
nodeLayer.appendChild(circle);
|
|
|
|
const portPreview = Array.isArray(entry.port_preview) ? entry.port_preview : [];
|
|
portPreview.forEach((port, portIndex) => {
|
|
const portNode = svgElement('circle');
|
|
portNode.setAttribute('cx', String(entry.x + 16 + (portIndex * 10)));
|
|
portNode.setAttribute('cy', String(entry.y - 16));
|
|
portNode.setAttribute('r', '5');
|
|
portNode.setAttribute('tabindex', '0');
|
|
portNode.setAttribute('class', 'topology-port-node');
|
|
portNode.setAttribute('data-port-id', String(Number(port.id || 0)));
|
|
portNode.setAttribute('data-device-id', String(entry.device_id));
|
|
|
|
const portTitle = svgElement('title');
|
|
portTitle.textContent = `${port.name || 'Port'} (${entry.device_name})`;
|
|
portNode.appendChild(portTitle);
|
|
|
|
const activatePort = () => {
|
|
clearActiveSelections();
|
|
portNode.classList.add('active');
|
|
showOverlay({
|
|
kind: 'port',
|
|
port_id: Number(port.id || 0),
|
|
port_name: port.name || `Port ${portIndex + 1}`,
|
|
is_connected: !!port.is_connected,
|
|
device_id: entry.device_id,
|
|
device_name: entry.device_name,
|
|
rack_name: entry.rack_name,
|
|
floor_name: entry.floor_name
|
|
});
|
|
};
|
|
|
|
portNode.addEventListener('click', activatePort);
|
|
portNode.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
activatePort();
|
|
}
|
|
});
|
|
|
|
nodeLayer.appendChild(portNode);
|
|
});
|
|
});
|
|
|
|
rackLinks.forEach((link) => {
|
|
const fromRackId = Number(link.from_rack_id || 0);
|
|
const toRackId = Number(link.to_rack_id || 0);
|
|
const count = Math.max(1, Number(link.count || 1));
|
|
const fromPoint = layout.rackCenters.get(fromRackId);
|
|
const toPoint = layout.rackCenters.get(toRackId);
|
|
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-connection-line');
|
|
line.setAttribute('stroke-width', String(Math.min(8, 1 + count)));
|
|
line.setAttribute('tabindex', '0');
|
|
|
|
const title = svgElement('title');
|
|
title.textContent = `${link.from_rack_name} <-> ${link.to_rack_name}: ${count} Verbindungen`;
|
|
line.appendChild(title);
|
|
|
|
const activateConnection = () => {
|
|
clearActiveSelections();
|
|
line.classList.add('active');
|
|
showOverlay({
|
|
kind: 'connection',
|
|
from_name: link.from_rack_name || `Rack ${fromRackId}`,
|
|
to_name: link.to_rack_name || `Rack ${toRackId}`,
|
|
count,
|
|
sample_connection_id: Number(link.sample_connection_id || 0)
|
|
});
|
|
};
|
|
|
|
line.addEventListener('click', activateConnection);
|
|
line.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
activateConnection();
|
|
}
|
|
});
|
|
connectionLayer.appendChild(line);
|
|
});
|
|
}
|
|
|
|
svg.addEventListener('wheel', (event) => {
|
|
event.preventDefault();
|
|
const point = toSvgPoint(event.clientX, event.clientY);
|
|
const factor = event.deltaY < 0 ? 0.9 : 1.1;
|
|
zoomAt(point.x, point.y, factor);
|
|
}, { passive: false });
|
|
|
|
svg.addEventListener('pointerdown', (event) => {
|
|
const target = event.target;
|
|
if (target instanceof Element && (target.closest('.topology-node') || target.closest('.topology-port-node') || target.closest('.topology-connection-line'))) {
|
|
drag = null;
|
|
return;
|
|
}
|
|
drag = {
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
baseX: view.x,
|
|
baseY: view.y
|
|
};
|
|
svg.setPointerCapture(event.pointerId);
|
|
});
|
|
|
|
svg.addEventListener('pointermove', (event) => {
|
|
if (!drag) {
|
|
return;
|
|
}
|
|
const rect = svg.getBoundingClientRect();
|
|
const scaleX = view.width / rect.width;
|
|
const scaleY = view.height / rect.height;
|
|
const dx = (event.clientX - drag.startX) * scaleX;
|
|
const dy = (event.clientY - drag.startY) * scaleY;
|
|
view.x = drag.baseX - dx;
|
|
view.y = drag.baseY - dy;
|
|
applyView();
|
|
});
|
|
|
|
svg.addEventListener('pointerup', (event) => {
|
|
drag = null;
|
|
svg.releasePointerCapture(event.pointerId);
|
|
});
|
|
|
|
svg.addEventListener('pointercancel', (event) => {
|
|
drag = null;
|
|
svg.releasePointerCapture(event.pointerId);
|
|
});
|
|
|
|
root.querySelectorAll('[data-topology-zoom]').forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
const action = button.getAttribute('data-topology-zoom');
|
|
if (action === 'in') {
|
|
zoomAt(view.x + view.width / 2, view.y + view.height / 2, 0.85);
|
|
return;
|
|
}
|
|
if (action === 'out') {
|
|
zoomAt(view.x + view.width / 2, view.y + view.height / 2, 1.15);
|
|
return;
|
|
}
|
|
resetView();
|
|
});
|
|
});
|
|
|
|
if (closeButton && overlay) {
|
|
closeButton.addEventListener('click', () => {
|
|
overlay.hidden = true;
|
|
clearActiveSelections();
|
|
});
|
|
}
|
|
|
|
renderTopology();
|
|
applyView();
|
|
})();
|
|
</script>
|
|
|
|
<style>
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
margin: 20px 0;
|
|
}
|
|
|
|
.dashboard-modules {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 12px;
|
|
margin: 12px 0 20px;
|
|
}
|
|
|
|
.dashboard-tile {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
border: 1px solid #d7d7d7;
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
text-decoration: none;
|
|
color: #222;
|
|
background: #fff;
|
|
}
|
|
|
|
.dashboard-icon {
|
|
width: 34px;
|
|
height: 34px;
|
|
border-radius: 999px;
|
|
background: #0c4da2;
|
|
color: #fff;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 0.75rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.dashboard-content h3 {
|
|
margin: 0 0 4px;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.dashboard-content p {
|
|
margin: 0;
|
|
font-size: 0.85rem;
|
|
color: #444;
|
|
}
|
|
|
|
.dashboard-inline-status {
|
|
margin: 6px 0;
|
|
color: #333;
|
|
}
|
|
|
|
.stat-card {
|
|
border: 1px solid #ddd;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
background: #f9f9f9;
|
|
}
|
|
|
|
.stat-card h3 {
|
|
font-size: 2.5em;
|
|
margin: 0;
|
|
color: #333;
|
|
}
|
|
|
|
.stat-card p {
|
|
margin: 10px 0;
|
|
color: #666;
|
|
}
|
|
|
|
.stat-card a {
|
|
display: inline-block;
|
|
margin-top: 10px;
|
|
padding: 8px 12px;
|
|
background: #007bff;
|
|
color: white;
|
|
text-decoration: none;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.recent-devices {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.recent-devices th,
|
|
.recent-devices td {
|
|
padding: 10px;
|
|
text-align: left;
|
|
border-bottom: 1px solid #ddd;
|
|
}
|
|
|
|
.recent-devices th {
|
|
background: #f0f0f0;
|
|
}
|
|
|
|
.topology-wall {
|
|
margin: 18px 0 26px;
|
|
border: 1px solid #d6e1f0;
|
|
border-radius: 10px;
|
|
background: #fff;
|
|
padding: 14px;
|
|
position: relative;
|
|
}
|
|
|
|
.topology-wall__header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.topology-wall__header h2 {
|
|
margin: 0;
|
|
}
|
|
|
|
.topology-wall__tools {
|
|
display: flex;
|
|
gap: 6px;
|
|
}
|
|
|
|
.topology-wall__hint {
|
|
margin: 8px 0 10px;
|
|
color: #445067;
|
|
}
|
|
|
|
#dashboard-topology-svg {
|
|
width: 100%;
|
|
height: 460px;
|
|
border: 1px solid #d7dee9;
|
|
border-radius: 8px;
|
|
background: #f8fbff;
|
|
cursor: grab;
|
|
touch-action: none;
|
|
}
|
|
|
|
#dashboard-topology-svg:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.topology-bg {
|
|
fill: #f7faff;
|
|
}
|
|
|
|
.topology-grid-line {
|
|
stroke: #e4ebf5;
|
|
stroke-width: 1;
|
|
}
|
|
|
|
.topology-location-box {
|
|
fill: none;
|
|
stroke: #a9c0de;
|
|
stroke-width: 2;
|
|
}
|
|
|
|
.topology-building-box {
|
|
fill: none;
|
|
stroke: #c3d3e8;
|
|
stroke-width: 1.5;
|
|
}
|
|
|
|
.topology-floor-box {
|
|
fill: none;
|
|
stroke: #d0deef;
|
|
stroke-width: 1.2;
|
|
}
|
|
|
|
.topology-location-label {
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
fill: #23304a;
|
|
}
|
|
|
|
.topology-building-label {
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
fill: #2a3d5c;
|
|
}
|
|
|
|
.topology-floor-label {
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
fill: #3f5679;
|
|
}
|
|
|
|
.topology-rack-box {
|
|
fill: #ffffff;
|
|
stroke: #bfd0e6;
|
|
stroke-width: 2;
|
|
}
|
|
|
|
.topology-rack-label {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
fill: #2a3d5c;
|
|
}
|
|
|
|
.topology-connection-line {
|
|
stroke: #426da4;
|
|
stroke-opacity: 0.55;
|
|
stroke-linecap: round;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.topology-connection-line:hover,
|
|
.topology-connection-line:focus,
|
|
.topology-connection-line.active {
|
|
stroke: #d2612a;
|
|
stroke-opacity: 0.85;
|
|
outline: none;
|
|
}
|
|
|
|
.topology-node {
|
|
fill: #1f73c9;
|
|
stroke: #ffffff;
|
|
stroke-width: 3;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.topology-node:hover,
|
|
.topology-node:focus,
|
|
.topology-node.active {
|
|
fill: #e5572e;
|
|
outline: none;
|
|
}
|
|
|
|
.topology-port-node {
|
|
fill: #3d9640;
|
|
stroke: #ffffff;
|
|
stroke-width: 2;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.topology-port-node:hover,
|
|
.topology-port-node:focus,
|
|
.topology-port-node.active {
|
|
fill: #f18d32;
|
|
outline: none;
|
|
}
|
|
|
|
.topology-empty {
|
|
margin-top: 12px;
|
|
padding: 10px;
|
|
background: #f4f7fb;
|
|
border: 1px dashed #b8c7dd;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.topology-overlay {
|
|
position: absolute;
|
|
right: 16px;
|
|
bottom: 16px;
|
|
width: min(360px, calc(100% - 32px));
|
|
border: 1px solid #cad8ea;
|
|
border-radius: 10px;
|
|
background: #ffffff;
|
|
box-shadow: 0 12px 34px rgba(12, 42, 84, 0.2);
|
|
padding: 12px;
|
|
}
|
|
|
|
.topology-overlay__header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.topology-overlay__header h3 {
|
|
margin: 0;
|
|
}
|
|
|
|
.topology-overlay p {
|
|
margin: 8px 0;
|
|
}
|
|
|
|
.topology-overlay__actions {
|
|
margin-top: 10px;
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
#dashboard-topology-svg {
|
|
height: 360px;
|
|
}
|
|
|
|
.topology-wall__header {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
}
|
|
</style>
|