Files
itflow/functions/rack.php
johnnyq 9ba21cf352 Assets: add NAS, UPS, PDU, Modem/ONT and NVR/DVR types from a new registry in functions/asset_type.php that sets each type's icon, tone, group, fields and details; the asset form shows only the fields a type uses (fields that already hold a value stay shown) and adds a Details section per type (CPU/RAM/storage, VM host/vCPUs, switch ports/SFP/PoE/management, NAS drives/capacity/RAID, UPS capacity/output/runtime/outlets/battery dates, PDU kind/outlets/input, modem circuit ID/ISP account, printer, phone, mobile, display, camera and NVR details) stored in a new asset_specs table (database 3.4.9); new or changed port and SFP counts add the missing interfaces, continuing the asset's numbering, skipping Wi-Fi, VLAN and management ports and never deleting any; the asset page and quick look show the details, a server or NAS lists the VMs that run on it, and Assigned and Warranty show only on types that use them; the list gets Servers & NAS and Power tabs, an optional Details column, a UPS battery badge, an Assigned picker only on assigned types, and New buttons named for what they add; nightly UPS Battery Due notifications at 45, 7 and 1 days as a Support notification; copy leaves out per-unit details, Transfer to Client carries details and interface types, export adds per-detail columns, import reads detail columns after the standard twelve (VM hosts by name, resolved after every row is in) and reports values it could not keep, and the API reads and writes asset_specs; racks, client portal, client export, overview, document templates and AI lists group the new types; fix asset import carrying physical location and notes into the next row, saving 0000-00-00 for a missing purchase date and warning on an unknown contact or location, save API and import types in the list's spelling, and keep interface types on transfer; changelog updated
2026-10-27 15:59:03 -04:00

340 lines
12 KiB
PHP

<?php
/*
* Racks - rack elevation layout, occupancy and the checks shared by the rack
* handlers (agent/post/rack.php), the rack page (agent/rack.php) and the racks
* listing (agent/racks.php).
*
* Positions: an item occupies unit_start_number..unit_end_number, start being
* the LOWEST U number. That is storage only - whether U1 is drawn at the bottom
* (the usual) or at the top is the rack's rack_units_descending flag.
*
* Faces: an item is mounted on the front or the rear. A full-depth item (most
* servers, a UPS) fills both faces at its units; a half-depth one (a patch
* panel, a small switch) leaves the other face free for something else.
*/
/** Unlinked rack items - things worth documenting that are not assets. */
function rackItemTypes() {
return [
'Device' => ['icon' => 'fa-hdd', 'tone' => 'device'],
'Patch Panel' => ['icon' => 'fa-ethernet', 'tone' => 'patch'],
'Cable Management' => ['icon' => 'fa-grip-lines', 'tone' => 'cable'],
'Shelf' => ['icon' => 'fa-layer-group', 'tone' => 'shelf'],
'PDU' => ['icon' => 'fa-plug', 'tone' => 'power'],
'UPS' => ['icon' => 'fa-car-battery', 'tone' => 'power'],
'KVM / Console' => ['icon' => 'fa-keyboard', 'tone' => 'device'],
'Blanking Panel' => ['icon' => 'fa-minus', 'tone' => 'blank'],
];
}
/**
* How an item is drawn: its icon, colour tone and a type label. A linked asset
* goes by its asset type, an unlinked item by its rack item type.
*/
function rackItemStyle($asset_type, $unit_type, $has_asset) {
if ($has_asset) {
$tones = [
'Server' => 'server',
'Virtual Machine' => 'server',
'NAS' => 'server',
'Switch' => 'network',
'Access Point' => 'network',
'Modem/ONT' => 'network',
'Firewall/Router' => 'firewall',
'UPS' => 'power',
'PDU' => 'power',
];
$asset_type = (string) $asset_type;
return [
'icon' => 'fa-' . getAssetIcon($asset_type),
'tone' => $tones[$asset_type] ?? 'device',
'label' => $asset_type !== '' ? $asset_type : 'Asset',
];
}
$types = rackItemTypes();
$unit_type = isset($types[$unit_type]) ? $unit_type : 'Device';
return [
'icon' => $types[$unit_type]['icon'],
'tone' => $types[$unit_type]['tone'],
'label' => $unit_type,
];
}
/** "U7" or "U7-U8" (with an en dash) */
function rackPositionLabel($start, $end) {
$start = intval($start);
$end = intval($end);
return $start === $end ? "U$start" : "U$start&ndash;U$end";
}
/** Does an item mounted like this fill the given face? */
function rackUnitOnFace($unit_face, $unit_full_depth, $face) {
return $unit_full_depth || $unit_face === $face;
}
/**
* Units in use per face, plus the rack as a whole. A U counts as used on a face
* when anything fills that face there, and as used for the rack when it is used
* on either face - a half-depth panel at the front still takes the U.
*
* $units: rows with unit_start_number, unit_end_number, unit_face, unit_full_depth
* Returns front/rear/any (counts), free_any (count of U with nothing on either
* face) and largest_gap ['size', 'start', 'end'] - the biggest run of U that is
* free on BOTH faces, i.e. where a full-depth item could still go.
*/
function rackOccupancy(array $units, $rack_units) {
$rack_units = max(0, intval($rack_units));
$front = array_fill(1, max(1, $rack_units), false);
$rear = $front;
foreach ($units as $unit) {
$start = max(1, intval($unit['unit_start_number']));
$end = min($rack_units, intval($unit['unit_end_number']));
$face = ($unit['unit_face'] ?? 'front') === 'rear' ? 'rear' : 'front';
$full_depth = intval($unit['unit_full_depth'] ?? 1) === 1;
for ($u = $start; $u <= $end; $u++) {
if (rackUnitOnFace($face, $full_depth, 'front')) {
$front[$u] = true;
}
if (rackUnitOnFace($face, $full_depth, 'rear')) {
$rear[$u] = true;
}
}
}
$used_front = 0;
$used_rear = 0;
$used_any = 0;
$gap = ['size' => 0, 'start' => 0, 'end' => 0];
$run_start = 0;
for ($u = 1; $u <= $rack_units; $u++) {
$used_front += $front[$u] ? 1 : 0;
$used_rear += $rear[$u] ? 1 : 0;
$taken = $front[$u] || $rear[$u];
$used_any += $taken ? 1 : 0;
if (!$taken) {
if (!$run_start) {
$run_start = $u;
}
$size = $u - $run_start + 1;
if ($size > $gap['size']) {
$gap = ['size' => $size, 'start' => $run_start, 'end' => $u];
}
} else {
$run_start = 0;
}
}
return [
'front' => $used_front,
'rear' => $used_rear,
'any' => $used_any,
'free_any' => $rack_units - $used_any,
'largest_gap' => $gap,
];
}
/**
* The CSS grid row an item starts on, for a rack drawn one row per U. U1 sits
* at the bottom unless the rack numbers top-down.
*/
function rackGridRow($start, $end, $rack_units, $descending) {
return $descending ? intval($start) : intval($rack_units) - intval($end) + 1;
}
/**
* The first item that would clash with an item at $start..$end on $face, or
* null if the space is free. Two items clash when their units overlap and they
* share a face - which a full-depth item always does. $exclude_unit_id skips
* the item being moved.
*
* Returns the clashing item's display name and position, raw (escape it).
*/
function rackUnitConflict($rack_id, $start, $end, $face, $full_depth, $exclude_unit_id = 0) {
global $mysqli;
$rack_id = intval($rack_id);
$start = intval($start);
$end = intval($end);
$face = $face === 'rear' ? 'rear' : 'front';
$full_depth = $full_depth ? 1 : 0;
$exclude_unit_id = intval($exclude_unit_id);
$sql = mysqli_query($mysqli, "SELECT unit_device, unit_type, unit_start_number, unit_end_number, asset_name
FROM rack_units
LEFT JOIN assets ON asset_id = unit_asset_id
WHERE unit_rack_id = $rack_id
AND unit_id != $exclude_unit_id
AND unit_start_number <= $end AND unit_end_number >= $start
AND (unit_full_depth = 1 OR $full_depth = 1 OR unit_face = '$face')
ORDER BY unit_start_number
LIMIT 1");
$row = mysqli_fetch_assoc($sql);
if (!$row) {
return null;
}
return [
'name' => rackUnitName($row['asset_name'] ?? '', $row['unit_device'], $row['unit_type']),
'start' => intval($row['unit_start_number']),
'end' => intval($row['unit_end_number']),
];
}
/** What to call an item: its asset, else its label, else its type. Raw. */
function rackUnitName($asset_name, $unit_device, $unit_type) {
$asset_name = trim((string) $asset_name);
$unit_device = trim((string) $unit_device);
if ($asset_name !== '') {
return $asset_name;
}
if ($unit_device !== '') {
return $unit_device;
}
return trim((string) $unit_type) !== '' ? (string) $unit_type : 'Unnamed item';
}
/**
* Store an uploaded rack photo under the client's upload folder, removing the
* one it replaces. Returns the stored file name, or '' when no usable file came.
*/
function rackStorePhoto($file, $client_id, $old_photo = '') {
$client_id = intval($client_id);
$new_file_name = checkFileUpload($file, ['jpg', 'jpeg', 'gif', 'png', 'webp']);
if (!$new_file_name) {
return '';
}
$upload_dir = __DIR__ . "/../uploads/clients/$client_id";
if (!is_dir($upload_dir)) {
mkdir($upload_dir, 0755, true);
}
if (!move_uploaded_file($file['tmp_name'], "$upload_dir/$new_file_name")) {
return '';
}
rackDeletePhoto($client_id, $old_photo);
return $new_file_name;
}
/** Remove a rack photo file, if there is one. Never leaves the client's folder. */
function rackDeletePhoto($client_id, $photo) {
$photo = basename((string) $photo);
if ($photo === '' || $photo === '.' || $photo === '..') {
return;
}
$path = __DIR__ . "/../uploads/clients/" . intval($client_id) . "/$photo";
if (is_file($path)) {
unlink($path);
}
}
/**
* Every item in the given racks, keyed by rack id, with what the rack page and
* listing draw from its asset. Raw rows - escape them (rackUnitView() does).
*/
function rackLoadUnits(array $rack_ids) {
global $mysqli;
$rack_ids = array_filter(array_map('intval', $rack_ids));
$units = array_fill_keys($rack_ids, []);
if (empty($rack_ids)) {
return $units;
}
$sql = mysqli_query($mysqli, "SELECT unit_id, unit_rack_id, unit_start_number, unit_end_number, unit_device,
unit_face, unit_full_depth, unit_type, unit_power_watts, unit_notes,
asset_id, asset_name, asset_type, asset_make, asset_model, asset_serial, asset_status, asset_archived_at,
(SELECT interface_ip FROM asset_interfaces
WHERE interface_asset_id = asset_id AND interface_primary = 1 AND interface_ip IS NOT NULL AND interface_ip != ''
LIMIT 1) AS asset_primary_ip
FROM rack_units
LEFT JOIN assets ON asset_id = unit_asset_id
WHERE unit_rack_id IN (" . implode(',', $rack_ids) . ")
ORDER BY unit_start_number DESC");
while ($row = mysqli_fetch_assoc($sql)) {
$units[intval($row['unit_rack_id'])][] = $row;
}
return $units;
}
/**
* One item, ready to draw. Every string in the result is HTML-escaped; numbers
* are ints. 'name' is what to call it, 'meta' the quieter line under it.
*/
function rackUnitView(array $row) {
$has_asset = intval($row['asset_id'] ?? 0) > 0;
$style = rackItemStyle($row['asset_type'] ?? '', $row['unit_type'] ?? '', $has_asset);
$start = intval($row['unit_start_number']);
$end = max($start, intval($row['unit_end_number']));
$make_model = trim(($row['asset_make'] ?? '') . ' ' . ($row['asset_model'] ?? ''));
$label = trim((string) ($row['unit_device'] ?? ''));
$name = rackUnitName($row['asset_name'] ?? '', $label, $row['unit_type'] ?? '');
// A label that just repeats the asset name ("SRV-01 - Dell R650", how items
// used to be labelled) says nothing the asset doesn't - drop it
$asset_name = trim((string) ($row['asset_name'] ?? ''));
if ($has_asset && $label !== '' && stripos($label, $asset_name) === 0) {
$label = '';
}
// Under the name: the asset's label if it has one, else its make and model.
// An unlinked item gets its type, unless its name already says it.
if ($has_asset) {
$meta = $label !== '' ? $label : $make_model;
} elseif ($label !== '' && stripos($label, $style['label']) === false && $style['label'] !== 'Device') {
$meta = $style['label'];
} else {
$meta = '';
}
return [
'id' => intval($row['unit_id']),
'start' => $start,
'end' => $end,
'height' => $end - $start + 1,
'face' => ($row['unit_face'] ?? 'front') === 'rear' ? 'rear' : 'front',
'full_depth' => intval($row['unit_full_depth'] ?? 1) === 1,
'asset_id' => $has_asset ? intval($row['asset_id']) : 0,
'asset_archived' => $has_asset && !empty($row['asset_archived_at']),
'name' => escapeHtml($name),
'meta' => escapeHtml($meta),
'label' => escapeHtml($label),
'make_model' => escapeHtml($make_model),
'serial' => escapeHtml($row['asset_serial'] ?? ''),
'status' => escapeHtml($row['asset_status'] ?? ''),
'ip' => escapeHtml($row['asset_primary_ip'] ?? ''),
'power' => intval($row['unit_power_watts'] ?? 0),
'notes' => escapeHtml($row['unit_notes'] ?? ''),
'icon' => $style['icon'],
'tone' => $style['tone'],
'type_label' => escapeHtml($style['label']),
'position' => rackPositionLabel($start, $end),
];
}
/** Where an asset is racked: the rack and its position, or null. Raw values. */
function rackPositionForAsset($asset_id) {
global $mysqli;
$asset_id = intval($asset_id);
$sql = mysqli_query($mysqli, "SELECT rack_id, rack_name, rack_archived_at, unit_start_number, unit_end_number, unit_face
FROM rack_units
JOIN racks ON rack_id = unit_rack_id
WHERE unit_asset_id = $asset_id
LIMIT 1");
return mysqli_fetch_assoc($sql) ?: null;
}