icon, tone, group, fields * (from ASSET_FIELDS), os (what the OS field is called on it: 'os' or * 'firmware'), specs (keys of ASSET_SPECS) and example (what the form's * name, description, make, model and OS fields suggest for it). */ const ASSET_TYPES = [ 'Laptop' => ['icon' => 'fa-laptop', 'tone' => 'workstation', 'group' => 'workstation', 'os' => 'os', 'fields' => ['contact', 'os', 'network', 'links', 'serial', 'purchase', 'warranty'], 'specs' => ['cpu', 'ram_gb', 'storage'], 'example' => ['name' => 'RDG-LT-04', 'description' => 'Front desk laptop', 'make' => 'Dell', 'model' => 'Latitude 5450', 'os' => 'Windows 11 Pro 24H2']], 'Desktop' => ['icon' => 'fa-desktop', 'tone' => 'workstation', 'group' => 'workstation', 'os' => 'os', 'fields' => ['contact', 'os', 'network', 'links', 'serial', 'purchase', 'warranty'], 'specs' => ['cpu', 'ram_gb', 'storage'], 'example' => ['name' => 'RDG-WS-12', 'description' => 'Reception PC', 'make' => 'HP', 'model' => 'EliteDesk 800 G9', 'os' => 'Windows 11 Pro 24H2']], 'Server' => ['icon' => 'fa-server', 'tone' => 'server', 'group' => 'server', 'os' => 'os', 'fields' => ['os', 'network', 'nat', 'links', 'serial', 'purchase', 'warranty'], 'specs' => ['cpu', 'ram_gb', 'storage'], 'example' => ['name' => 'SRV-DC01', 'description' => 'Domain controller for HQ', 'make' => 'Dell', 'model' => 'PowerEdge R650', 'os' => 'Windows Server 2022']], 'NAS' => ['icon' => 'fa-hdd', 'tone' => 'storage', 'group' => 'server', 'os' => 'os', 'fields' => ['os', 'network', 'links', 'serial', 'purchase', 'warranty'], 'specs' => ['drive_bays', 'drives', 'raw_tb', 'usable_tb', 'raid', 'ports'], 'example' => ['name' => 'NAS-01', 'description' => 'File shares and backups', 'make' => 'Synology', 'model' => 'DS923+', 'os' => 'DSM 7.2']], 'Virtual Machine' => ['icon' => 'fa-cloud', 'tone' => 'virtual', 'group' => 'virtual', 'os' => 'os', 'fields' => ['os', 'network', 'nat', 'links'], 'specs' => ['vm_host', 'vcpus', 'ram_gb', 'storage'], 'example' => ['name' => 'VM-APP-01', 'description' => 'Line of business app', 'make' => 'Microsoft', 'model' => 'Hyper-V', 'os' => 'Ubuntu 24.04']], 'Firewall/Router' => ['icon' => 'fa-fire-alt', 'tone' => 'firewall', 'group' => 'network', 'os' => 'firmware', 'fields' => ['os', 'network', 'nat', 'links', 'serial', 'purchase', 'warranty'], 'specs' => ['ports', 'sfp_ports'], 'example' => ['name' => 'FW-01', 'description' => 'Edge firewall', 'make' => 'Netgate', 'model' => '6100', 'os' => 'pfSense Plus 24.11']], 'Switch' => ['icon' => 'fa-network-wired', 'tone' => 'network', 'group' => 'network', 'os' => 'firmware', 'fields' => ['os', 'network', 'links', 'serial', 'purchase', 'warranty'], 'specs' => ['ports', 'sfp_ports', 'poe_watts', 'managed'], 'example' => ['name' => 'SW-CORE-01', 'description' => 'Core switch, comms closet', 'make' => 'Cisco', 'model' => 'Catalyst 9200L-24P', 'os' => 'IOS-XE 17.9']], 'Access Point' => ['icon' => 'fa-wifi', 'tone' => 'wireless', 'group' => 'network', 'os' => 'firmware', 'fields' => ['os', 'network', 'links', 'serial', 'purchase', 'warranty'], 'specs' => ['wifi'], 'example' => ['name' => 'AP-LOBBY', 'description' => 'Lobby ceiling', 'make' => 'Ubiquiti', 'model' => 'U7 Pro', 'os' => 'UniFi 7.0']], 'Modem/ONT' => ['icon' => 'fa-broadcast-tower', 'tone' => 'network', 'group' => 'network', 'os' => 'firmware', 'fields' => ['os', 'network', 'nat', 'links', 'serial', 'purchase', 'warranty'], 'specs' => ['circuit_id', 'isp_account', 'ports'], 'example' => ['name' => 'ONT-01', 'description' => 'Fiber handoff', 'make' => 'Nokia', 'model' => 'G-010G-A', 'os' => '3FE49362']], 'UPS' => ['icon' => 'fa-car-battery', 'tone' => 'power', 'group' => 'power', 'os' => 'firmware', 'fields' => ['os', 'network', 'links', 'serial', 'purchase', 'warranty'], 'specs' => ['va', 'watts', 'runtime_min', 'outlets', 'battery_installed', 'battery_replace'], 'example' => ['name' => 'UPS-RACK-01', 'description' => 'Rack UPS, comms closet', 'make' => 'APC', 'model' => 'SMT1500RM2UC', 'os' => 'NMC 2.6']], 'PDU' => ['icon' => 'fa-plug', 'tone' => 'power', 'group' => 'power', 'os' => 'firmware', 'fields' => ['os', 'network', 'links', 'serial', 'purchase', 'warranty'], 'specs' => ['pdu_type', 'outlets', 'power_input'], 'example' => ['name' => 'PDU-RACK-01', 'description' => 'Rack PDU, A side', 'make' => 'APC', 'model' => 'AP7900B', 'os' => 'RPDU 7.1']], 'Printer' => ['icon' => 'fa-print', 'tone' => 'peripheral', 'group' => 'printer', 'os' => 'firmware', 'fields' => ['os', 'network', 'links', 'serial', 'purchase', 'warranty'], 'specs' => ['color', 'supplies'], 'example' => ['name' => 'PRN-FRONT', 'description' => 'Front desk printer', 'make' => 'HP', 'model' => 'LaserJet Pro M404dn', 'os' => 'FutureSmart 5.7']], 'Phone' => ['icon' => 'fa-phone', 'tone' => 'phone', 'group' => 'mobile', 'os' => 'firmware', 'fields' => ['contact', 'os', 'network', 'serial', 'purchase', 'warranty'], 'specs' => ['extension'], 'example' => ['name' => 'PH-204', 'description' => 'Front desk phone', 'make' => 'Yealink', 'model' => 'T54W', 'os' => '96.86.0.55']], 'Mobile Phone' => ['icon' => 'fa-mobile-alt', 'tone' => 'phone', 'group' => 'mobile', 'os' => 'os', 'fields' => ['contact', 'os', 'serial', 'purchase', 'warranty'], 'specs' => ['phone_number', 'carrier', 'imei'], 'example' => ['name' => 'MOB-CEO', 'description' => "Owner's phone", 'make' => 'Apple', 'model' => 'iPhone 15', 'os' => 'iOS 18']], 'Tablet' => ['icon' => 'fa-tablet-alt', 'tone' => 'phone', 'group' => 'mobile', 'os' => 'os', 'fields' => ['contact', 'os', 'serial', 'purchase', 'warranty'], 'specs' => ['phone_number', 'carrier', 'imei'], 'example' => ['name' => 'TAB-CHECKIN', 'description' => 'Patient check-in tablet', 'make' => 'Apple', 'model' => 'iPad (10th gen)', 'os' => 'iPadOS 18']], 'Display' => ['icon' => 'fa-tv', 'tone' => 'peripheral', 'group' => 'peripheral', 'os' => 'os', 'fields' => ['contact', 'serial', 'purchase', 'warranty'], 'specs' => ['size_in', 'resolution'], 'example' => ['name' => 'MON-RECEPTION', 'description' => 'Reception monitor', 'make' => 'Dell', 'model' => 'P2723D', 'os' => '']], 'Camera' => ['icon' => 'fa-video', 'tone' => 'peripheral', 'group' => 'peripheral', 'os' => 'firmware', 'fields' => ['os', 'network', 'links', 'serial', 'purchase', 'warranty'], 'specs' => ['resolution'], 'example' => ['name' => 'CAM-LOBBY', 'description' => 'Lobby entrance', 'make' => 'Hikvision', 'model' => 'DS-2CD2347G2', 'os' => 'V5.7.12']], 'NVR/DVR' => ['icon' => 'fa-photo-video', 'tone' => 'storage', 'group' => 'peripheral', 'os' => 'firmware', 'fields' => ['os', 'network', 'links', 'serial', 'purchase', 'warranty'], 'specs' => ['channels', 'storage', 'ports'], 'example' => ['name' => 'NVR-01', 'description' => 'Camera recorder', 'make' => 'Hikvision', 'model' => 'DS-7616NI-K2', 'os' => 'V4.62']], 'Other' => ['icon' => 'fa-tag', 'tone' => 'other', 'group' => 'other', 'os' => 'os', 'fields' => ASSET_FIELDS, 'specs' => [], 'example' => ['name' => 'ASSET-01', 'description' => 'What it is for', 'make' => 'Make', 'model' => 'Model', 'os' => '']], ]; /** * The details, by key - label, input (text, number, select, date or asset), * icon and, by input: unit, min, max, step, options (select), suggest (a * text field's suggestions), asset_types (an asset pick, of the same * client), placeholder, ports ('copper' or 'sfp' - a count of interfaces) and * flag (a date that is flagged as it nears, like a warranty); unit_only, one * that belongs to the one unit (an IMEI, a circuit) and isn't copied with it; * alone, one whose value says what it is (Managed, Wi-Fi 6), so a short line * of an asset's details leaves its label off. */ const ASSET_SPECS = [ 'cpu' => ['label' => 'CPU', 'input' => 'text', 'icon' => 'fa-microchip', 'placeholder' => 'Intel Core i7-1365U'], 'ram_gb' => ['label' => 'RAM', 'input' => 'number', 'icon' => 'fa-memory', 'unit' => 'GB', 'min' => 0, 'max' => 1048576, 'step' => 'any', 'placeholder' => '16'], 'storage' => ['label' => 'Storage', 'input' => 'text', 'icon' => 'fa-hdd', 'placeholder' => '512 GB NVMe SSD'], 'vm_host' => ['label' => 'Host', 'input' => 'asset', 'icon' => 'fa-server', 'asset_types' => ['Server', 'NAS']], 'vcpus' => ['label' => 'vCPUs', 'input' => 'number', 'icon' => 'fa-microchip', 'min' => 1, 'max' => 4096, 'step' => 1, 'placeholder' => '4'], 'ports' => ['label' => 'Ports', 'input' => 'number', 'icon' => 'fa-ethernet', 'min' => 0, 'max' => 128, 'step' => 1, 'placeholder' => '24', 'ports' => 'copper'], 'sfp_ports' => ['label' => 'SFP ports', 'input' => 'number', 'icon' => 'fa-ethernet', 'min' => 0, 'max' => 64, 'step' => 1, 'placeholder' => '4', 'ports' => 'sfp'], 'poe_watts' => ['label' => 'PoE budget', 'input' => 'number', 'icon' => 'fa-bolt', 'unit' => 'W', 'min' => 0, 'max' => 100000, 'step' => 'any', 'placeholder' => '370'], 'managed' => ['label' => 'Management', 'input' => 'select', 'icon' => 'fa-sliders-h', 'options' => ['Managed', 'Smart', 'Unmanaged'], 'alone' => true], 'wifi' => ['label' => 'Wi-Fi', 'input' => 'select', 'icon' => 'fa-wifi', 'alone' => true, 'options' => ['Wi-Fi 4 (802.11n)', 'Wi-Fi 5 (802.11ac)', 'Wi-Fi 6 (802.11ax)', 'Wi-Fi 6E', 'Wi-Fi 7 (802.11be)']], 'drive_bays' => ['label' => 'Drive bays', 'input' => 'number', 'icon' => 'fa-th-large', 'min' => 0, 'max' => 1000, 'step' => 1, 'placeholder' => '4'], 'drives' => ['label' => 'Drives', 'input' => 'text', 'icon' => 'fa-hdd', 'placeholder' => '4 x 8 TB WD Red Plus'], 'raw_tb' => ['label' => 'Raw capacity', 'input' => 'number', 'icon' => 'fa-database', 'unit' => 'TB', 'min' => 0, 'max' => 1000000, 'step' => 'any', 'placeholder' => '32'], 'usable_tb' => ['label' => 'Usable capacity', 'input' => 'number', 'icon' => 'fa-database', 'unit' => 'TB', 'min' => 0, 'max' => 1000000, 'step' => 'any', 'placeholder' => '24'], 'raid' => ['label' => 'RAID', 'input' => 'text', 'icon' => 'fa-layer-group', 'placeholder' => 'RAID 5, SHR-2, RAIDZ2', 'suggest' => ['RAID 1', 'RAID 5', 'RAID 6', 'RAID 10', 'SHR', 'SHR-2', 'RAIDZ1', 'RAIDZ2', 'RAIDZ3', 'JBOD']], 'va' => ['label' => 'Capacity', 'input' => 'number', 'icon' => 'fa-bolt', 'unit' => 'VA', 'min' => 0, 'max' => 10000000, 'step' => 'any', 'placeholder' => '1500'], 'watts' => ['label' => 'Output', 'input' => 'number', 'icon' => 'fa-plug', 'unit' => 'W', 'min' => 0, 'max' => 10000000, 'step' => 'any', 'placeholder' => '1000'], 'runtime_min' => ['label' => 'Runtime', 'input' => 'number', 'icon' => 'fa-hourglass-half', 'unit' => 'min', 'min' => 0, 'max' => 100000, 'step' => 'any', 'placeholder' => '12'], 'outlets' => ['label' => 'Outlets', 'input' => 'number', 'icon' => 'fa-plug', 'min' => 0, 'max' => 1000, 'step' => 1, 'placeholder' => '8'], 'battery_installed' => ['label' => 'Battery installed', 'input' => 'date', 'icon' => 'fa-battery-full'], 'battery_replace' => ['label' => 'Replace battery by', 'input' => 'date', 'icon' => 'fa-battery-quarter', 'flag' => true], 'pdu_type' => ['label' => 'Kind', 'input' => 'select', 'icon' => 'fa-plug', 'options' => ['Basic', 'Metered', 'Monitored', 'Switched'], 'alone' => true], 'power_input' => ['label' => 'Input', 'input' => 'text', 'icon' => 'fa-bolt', 'placeholder' => 'NEMA L5-30P, 30 A'], 'circuit_id' => ['label' => 'Circuit ID', 'input' => 'text', 'icon' => 'fa-fingerprint', 'placeholder' => '12.KXFN.012345..ATI', 'unit_only' => true], 'isp_account' => ['label' => 'ISP account', 'input' => 'text', 'icon' => 'fa-hashtag', 'placeholder' => '8301 2245 0019 4410', 'unit_only' => true], 'channels' => ['label' => 'Channels', 'input' => 'number', 'icon' => 'fa-video', 'min' => 0, 'max' => 1024, 'step' => 1, 'placeholder' => '16'], 'color' => ['label' => 'Prints in', 'input' => 'select', 'icon' => 'fa-palette', 'options' => ['Color', 'Black and white'], 'alone' => true], 'supplies' => ['label' => 'Supplies', 'input' => 'text', 'icon' => 'fa-fill-drip', 'placeholder' => 'HP 58A toner'], 'extension' => ['label' => 'Extension', 'input' => 'text', 'icon' => 'fa-phone', 'placeholder' => '204', 'unit_only' => true], 'phone_number' => ['label' => 'Phone number', 'input' => 'text', 'icon' => 'fa-phone', 'placeholder' => '412-555-0134', 'unit_only' => true], 'carrier' => ['label' => 'Carrier', 'input' => 'text', 'icon' => 'fa-signal', 'placeholder' => 'Verizon'], 'imei' => ['label' => 'IMEI', 'input' => 'text', 'icon' => 'fa-sim-card', 'placeholder' => '356938035643809', 'mono' => true, 'unit_only' => true], 'size_in' => ['label' => 'Size', 'input' => 'number', 'icon' => 'fa-expand', 'unit' => 'in', 'min' => 0, 'max' => 1000, 'step' => 'any', 'placeholder' => '27'], 'resolution' => ['label' => 'Resolution', 'input' => 'text', 'icon' => 'fa-th', 'placeholder' => '2560 x 1440, 4 MP', 'suggest' => ['1920 x 1080', '2560 x 1440', '3840 x 2160', '2 MP', '4 MP', '8 MP']], ]; /** The types that go in a rack - offered first when racking an asset, and on a rack page as kit not in one yet. */ const ASSET_RACK_TYPES = ['Firewall/Router', 'Switch', 'Server', 'NAS', 'UPS', 'PDU', 'NVR/DVR', 'Modem/ONT']; /** The groups types fall into - for the assets list's tabs, the portal and the client overview. */ const ASSET_TYPE_GROUPS = [ 'workstation' => ['label' => 'Workstations', 'icon' => 'fa-desktop'], 'server' => ['label' => 'Servers & storage', 'icon' => 'fa-server'], 'virtual' => ['label' => 'Virtual', 'icon' => 'fa-cloud'], 'network' => ['label' => 'Network', 'icon' => 'fa-network-wired'], 'power' => ['label' => 'Power', 'icon' => 'fa-car-battery'], 'printer' => ['label' => 'Printers', 'icon' => 'fa-print'], 'mobile' => ['label' => 'Phones & tablets', 'icon' => 'fa-mobile-alt'], 'peripheral' => ['label' => 'Displays & cameras', 'icon' => 'fa-tv'], 'other' => ['label' => 'Other', 'icon' => 'fa-tag'], ]; /** A type's entry - Other's for a type that isn't one ITFlow knows, with its name. */ function assetTypeInfo($asset_type) { $asset_type = (string) $asset_type; if (!isset(ASSET_TYPES[$asset_type])) { // Spelt another way - 'switch', as the database matches it too - or not one at all $asset_type = assetTypeName($asset_type) ?: 'Other'; } return ASSET_TYPES[$asset_type] + ['name' => $asset_type]; } /** A type as the list spells it, matched without case - '' when it isn't one. */ function assetTypeName($value) { $value = trim((string) $value); foreach (array_keys(ASSET_TYPES) as $asset_type) { if (strcasecmp($asset_type, $value) === 0) { return $asset_type; } } return ''; } /** Type => icon class (fa-laptop), in order - what $asset_types_array has always been. */ function assetTypeIcons() { return array_map(function ($type) { return $type['icon']; }, ASSET_TYPES); } /** The types in one or more groups. */ function assetTypesInGroup(...$groups) { return array_keys(array_filter(ASSET_TYPES, function ($type) use ($groups) { return in_array($type['group'], $groups, true); })); } /** * SQL that matches a group's types on $column - 'other' is also anything * that isn't a type ITFlow knows (an old or hand-made one). */ function assetTypeGroupSql($group, $column = 'asset_type') { $in = function (array $types) { return "'" . implode("', '", array_map('escapeSql', $types)) . "'"; }; if ($group === 'other') { $known = array_diff(array_keys(ASSET_TYPES), assetTypesInGroup('other')); return "$column NOT IN (" . $in($known) . ")"; } $types = assetTypesInGroup($group); return $types ? "$column IN (" . $in($types) . ")" : '1 = 0'; } /** * The assets list's tabs - key => label, icon, what New calls one, and the * SQL on asset_type. Other is every type the rest don't take, and any type * ITFlow doesn't know. Shared with the export, which takes the same filter. */ function assetTypeTabs($column = 'asset_type') { $tabs = [ 'workstation' => ['label' => 'Workstations', 'new' => 'Workstation', 'groups' => ['workstation']], 'server' => ['label' => 'Servers & NAS', 'new' => 'Server', 'groups' => ['server']], 'virtual' => ['label' => 'Virtual', 'new' => 'Virtual Machine', 'groups' => ['virtual']], 'network' => ['label' => 'Network', 'new' => 'Network Device', 'groups' => ['network']], 'power' => ['label' => 'Power', 'new' => 'UPS', 'groups' => ['power']], 'printer' => ['label' => 'Printers', 'new' => 'Printer', 'groups' => ['printer']], ]; $taken = []; foreach ($tabs as $key => $tab) { $types = assetTypesInGroup(...$tab['groups']); $taken = array_merge($taken, $types); $tabs[$key]['icon'] = ASSET_TYPE_GROUPS[$tab['groups'][0]]['icon']; $tabs[$key]['query'] = "$column IN ('" . implode("', '", array_map('escapeSql', $types)) . "')"; } $tabs['other'] = ['label' => 'Other', 'new' => 'Asset', 'groups' => ['mobile', 'peripheral', 'other'], 'icon' => 'fa-tag', 'query' => "$column NOT IN ('" . implode("', '", array_map('escapeSql', $taken)) . "')"]; return $tabs; } /** Does this type show the field (from ASSET_FIELDS)? */ function assetTypeHasField($asset_type, $field) { return in_array($field, assetTypeInfo($asset_type)['fields'], true); } /** What the OS field is called on this type - "Operating system" or "Firmware". */ function assetTypeOsLabel($asset_type) { return assetTypeInfo($asset_type)['os'] === 'firmware' ? 'Firmware' : 'Operating system'; } /** A type's details - key => ASSET_SPECS entry, in its order. */ function assetTypeSpecs($asset_type) { $specs = []; foreach (assetTypeInfo($asset_type)['specs'] as $key) { $specs[$key] = ASSET_SPECS[$key]; } return $specs; } /** * Is the asset_specs table there yet (database update 3.4.9)? Until it is, * no asset has details - none are read, kept or flagged. */ function assetSpecsReady() { global $mysqli; static $ready = null; if ($ready === null) { $sql = mysqli_query($mysqli, "SHOW TABLES LIKE 'asset_specs'"); $ready = $sql && mysqli_num_rows($sql) > 0; } return $ready; } /** Each asset's details - [asset_id => [key => value]], raw. */ function assetSpecsFor(array $asset_ids) { global $mysqli; $asset_ids = array_values(array_unique(array_filter(array_map('intval', $asset_ids)))); $specs = array_fill_keys($asset_ids, []); if (!$asset_ids || !assetSpecsReady()) { return $specs; } $sql = mysqli_query($mysqli, "SELECT asset_spec_asset_id, asset_spec_key, asset_spec_value FROM asset_specs WHERE asset_spec_asset_id IN (" . implode(',', $asset_ids) . ")"); while ($sql && $row = mysqli_fetch_assoc($sql)) { $specs[intval($row['asset_spec_asset_id'])][$row['asset_spec_key']] = $row['asset_spec_value']; } return $specs; } /** One asset's details, raw - [key => value]. */ function assetSpecs($asset_id) { return assetSpecsFor([$asset_id])[intval($asset_id)] ?? []; } /** A number as it was meant - 16, not 16.00; 1.5 as typed. */ function assetSpecNumber($value) { $value = (float) $value; return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); } /** * What was given for a type's details, checked - [key => value] of the ones * with a value, as they are to be kept. $problems gets a line for each one * that isn't a value its field takes, which is left out. An asset pick has * to be one of $client_id's own of the types the detail allows, and not the * asset itself. */ function assetSpecsFromInput($asset_type, $input, $client_id, $asset_id = 0, &$problems = []) { global $mysqli; $problems = []; $input = is_array($input) ? $input : []; $kept = []; foreach (assetTypeSpecs($asset_type) as $key => $spec) { if (!array_key_exists($key, $input) || is_array($input[$key])) { continue; } $value = trim((string) $input[$key]); if ($value === '') { continue; } switch ($spec['input']) { case 'number': $number = str_replace(',', '', $value); if (!is_numeric($number) || (float) $number < ($spec['min'] ?? 0) || (float) $number > ($spec['max'] ?? PHP_INT_MAX) || (($spec['step'] ?? 'any') === 1 && floor((float) $number) != (float) $number)) { $problems[] = $spec['label'] . ': ' . (($spec['step'] ?? 'any') === 1 ? 'a whole number' : 'a number') . ' from ' . ($spec['min'] ?? 0) . ' to ' . ($spec['max'] ?? '') . (isset($spec['unit']) ? ' ' . $spec['unit'] : ''); continue 2; } $value = assetSpecNumber($number); break; case 'date': $date = DateTime::createFromFormat('!Y-m-d', $value); if (!$date || $date->format('Y-m-d') !== $value) { $problems[] = $spec['label'] . ': a date'; continue 2; } break; case 'select': $match = ''; foreach ($spec['options'] as $option) { if (strcasecmp($option, $value) === 0) { $match = $option; } } if ($match === '') { $problems[] = $spec['label'] . ': one of ' . implode(', ', $spec['options']); continue 2; } $value = $match; break; case 'asset': // By id, or by name - as an import or the export has it $types = "'" . implode("', '", array_map('escapeSql', $spec['asset_types'])) . "'"; $pick = ctype_digit($value) ? intval($value) : 0; if (!$pick) { $named = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT asset_id FROM assets WHERE asset_name = '" . escapeSql($value) . "' AND asset_client_id = " . intval($client_id) . " AND asset_type IN ($types) AND asset_archived_at IS NULL ORDER BY asset_id LIMIT 1")); $pick = intval($named['asset_id'] ?? 0); } if (!$pick || $pick === intval($asset_id) || !mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM assets WHERE asset_id = $pick AND asset_client_id = " . intval($client_id) . " AND asset_type IN ($types) LIMIT 1"))) { $problems[] = $spec['label'] . ': one of the client\'s ' . implode(' or ', array_map(function ($type) { return strtoupper($type) === $type ? $type : strtolower($type) . 's'; }, $spec['asset_types'])); continue 2; } $value = (string) $pick; break; default: $value = mb_substr(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value), 0, 255); } $kept[$key] = $value; } return $kept; } /** * Keep an asset's details - what its type has, as given; the rest go (a * detail its type no longer has, or one emptied). What changed, in words. */ function assetSpecsSave($asset_id, $asset_type, array $specs) { global $mysqli; $asset_id = intval($asset_id); if (!assetSpecsReady()) { return []; } $before = assetSpecs($asset_id); $allowed = array_keys(assetTypeSpecs($asset_type)); $changes = []; foreach ($before as $key => $value) { if (!in_array($key, $allowed, true) || !array_key_exists($key, $specs)) { mysqli_query($mysqli, "DELETE FROM asset_specs WHERE asset_spec_asset_id = $asset_id AND asset_spec_key = '" . escapeSql($key) . "'"); if (in_array($key, $allowed, true)) { $changes[] = (ASSET_SPECS[$key]['label'] ?? $key) . ' cleared'; } } } foreach ($specs as $key => $value) { if (!in_array($key, $allowed, true) || ($before[$key] ?? null) === (string) $value) { continue; } mysqli_query($mysqli, "INSERT INTO asset_specs SET asset_spec_asset_id = $asset_id, asset_spec_key = '" . escapeSql($key) . "', asset_spec_value = '" . escapeSql($value) . "' ON DUPLICATE KEY UPDATE asset_spec_value = VALUES(asset_spec_value)"); $changes[] = ASSET_SPECS[$key]['label'] . ' ' . assetSpecText($key, $value, ['link' => false]); } return $changes; } /** * An asset's ports from its details - the interfaces its ports and SFP ports * count that it doesn't have yet are made (interfacePortsEnsure()). How many, * by kind - copper and sfp. */ function assetPortsFromSpecs($asset_id, $asset_type, array $specs) { $type_specs = assetTypeSpecs($asset_type); if (!isset($type_specs['ports']) && !isset($type_specs['sfp_ports'])) { return ['copper' => 0, 'sfp' => 0]; } return interfacePortsEnsure($asset_id, $asset_type, intval($specs['ports'] ?? 0), intval($specs['sfp_ports'] ?? 0)); } /** "23 ports and 4 SFP ports" - what assetPortsFromSpecs() made, or ''. */ function assetPortsMadeText(array $made) { $parts = []; if ($made['copper']) { $parts[] = $made['copper'] . ' port' . ($made['copper'] === 1 ? '' : 's'); } if ($made['sfp']) { $parts[] = $made['sfp'] . ' SFP port' . ($made['sfp'] === 1 ? '' : 's'); } return implode(' and ', $parts); } /** * The asset form's details (spec[key]) for an asset just saved: checked, * kept, and its ports made from their counts - when a count is new or * changed, so an edit of something else doesn't put back a port someone * took off. [what to add to the message - ", with 23 ports added" or '', * the values not kept and why]. */ function assetSpecsFromPost($asset_id, $asset_type, $input, $client_id) { $asset_type = assetTypeName($asset_type) ?: trim((string) $asset_type); $before = assetSpecs($asset_id); $specs = assetSpecsFromInput($asset_type, $input, $client_id, $asset_id, $problems); assetSpecsSave($asset_id, $asset_type, $specs); $made = ''; if (($before['ports'] ?? '') !== ($specs['ports'] ?? '') || ($before['sfp_ports'] ?? '') !== ($specs['sfp_ports'] ?? '')) { $made = assetPortsMadeText(assetPortsFromSpecs($asset_id, $asset_type, $specs)); } return [$made !== '' ? ", with $made added" : '', $problems]; } /** * A detail as it reads - "16 GB", "Wi-Fi 6 (802.11ax)", a date. A host is its * name ($opts['hosts'] => [id => name] saves a lookup each; $opts['link'] * false gives plain text, the default HTML is escaped either way). Raw text. */ function assetSpecText($key, $value, array $opts = []) { $spec = ASSET_SPECS[$key] ?? ['input' => 'text']; $value = (string) $value; if ($value === '') { return ''; } switch ($spec['input']) { case 'number': return assetSpecNumber($value) . (isset($spec['unit']) ? ' ' . $spec['unit'] : ''); case 'asset': $hosts = $opts['hosts'] ?? null; $name = $hosts !== null ? ($hosts[intval($value)] ?? '') : (string) getFieldById('assets', intval($value), 'asset_name'); return $name !== '' ? $name : 'An asset that is gone'; default: return $value; } } /** * The details a page shows for an asset - [key => [label, icon, text, html, * flag]] in its type's order, the ones with a value. html is ready to echo: a * host links to its page (the same client's only), a flagged date carries its * badge as assetWarranty() reads it. Ports read "24 + 4 SFP". */ function assetSpecRows($asset_type, array $specs, $client_id = 0) { $rows = []; $type_specs = assetTypeSpecs($asset_type); foreach ($type_specs as $key => $spec) { $value = (string) ($specs[$key] ?? ''); if ($value === '' || ($key === 'sfp_ports' && isset($type_specs['ports']) && ($specs['ports'] ?? '') !== '')) { continue; } $text = assetSpecText($key, $value); $html = escapeHtml($text); $flag = null; if ($key === 'ports' && ($specs['sfp_ports'] ?? '') !== '') { $text = assetSpecNumber($value) . ' + ' . assetSpecNumber($specs['sfp_ports']) . ' SFP'; $html = escapeHtml($text); } elseif ($spec['input'] === 'asset') { $host_id = intval($value); $host = $host_id ? mysqli_fetch_assoc(mysqli_query($GLOBALS['mysqli'], "SELECT asset_name, asset_client_id FROM assets WHERE asset_id = $host_id LIMIT 1")) : null; if (!$host || ($client_id && intval($host['asset_client_id']) !== intval($client_id))) { continue; } $text = $host['asset_name']; $html = '' . escapeHtml($text) . ''; } elseif (!empty($spec['flag'])) { $flag = assetWarranty($value); if ($flag) { $flag['short'] = $flag['tone'] === 'success' ? 'Good' : ($flag['tone'] === 'danger' ? 'Overdue' : $flag['short']); $flag['text'] = $flag['tone'] === 'danger' ? 'Due ' . timeAgo($flag['date']) : ($flag['tone'] === 'warning' ? ($flag['days'] === 0 ? 'Due today' : 'Due in ' . $flag['days'] . ' day' . ($flag['days'] === 1 ? '' : 's')) : 'By ' . $flag['date']); } } elseif (!empty($spec['mono'])) { $html = '' . $html . ''; } // The short line - the list's Details, the quick look: the label, unless the value says what it is, and no age $label = $spec['label']; $alone = !empty($spec['alone']) || stripos($text, $label) === 0; $brief = ($alone ? '' : '' . escapeHtml($label) . ' ') . $html; if ($spec['input'] === 'date' && !$flag && !in_array($age = assetAge($value), ['', 'new'], true)) { $html .= '
' . escapeHtml($age) . ' ago
'; } $rows[$key] = ['label' => $label, 'icon' => $spec['icon'], 'text' => $text, 'html' => $html, 'flag' => $flag, 'alone' => $alone, 'brief' => $brief]; } return $rows; } /** The assets a detail can pick (a VM's host) - the client's own of its types, bar this one: [id => name]. */ function assetSpecAssetOptions($key, $client_id, $asset_id = 0, $keep_id = 0) { global $mysqli; $spec = ASSET_SPECS[$key] ?? null; if (!$spec || $spec['input'] !== 'asset' || !$client_id) { return []; } $types = "'" . implode("', '", array_map('escapeSql', $spec['asset_types'])) . "'"; $options = []; $sql = mysqli_query($mysqli, "SELECT asset_id, asset_name, asset_type FROM assets WHERE asset_client_id = " . intval($client_id) . " AND asset_type IN ($types) AND asset_id <> " . intval($asset_id) . " AND (asset_archived_at IS NULL OR asset_id = " . intval($keep_id) . ") ORDER BY asset_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { $options[intval($row['asset_id'])] = $row; } return $options; } /** The virtual machines that run on an asset (their Host) - the same client's, not archived: [id => name]. */ function assetGuests($asset_id, $client_id) { global $mysqli; $guests = []; if (!assetSpecsReady()) { return $guests; } $sql = mysqli_query($mysqli, "SELECT asset_id, asset_name FROM asset_specs JOIN assets ON asset_id = asset_spec_asset_id WHERE asset_spec_key = 'vm_host' AND asset_spec_value = '" . intval($asset_id) . "' AND asset_client_id = " . intval($client_id) . " AND asset_type = 'Virtual Machine' AND asset_archived_at IS NULL ORDER BY asset_name ASC"); while ($sql && $row = mysqli_fetch_assoc($sql)) { $guests[intval($row['asset_id'])] = $row['asset_name']; } return $guests; } /** * Rows for an export, each with its details as spec_ - its type's, as * written in the form (a host by its name); '' for the rest. */ function assetSpecsExportAttach(array $rows) { global $mysqli; $specs = assetSpecsFor(array_column($rows, 'asset_id')); $hosts = []; foreach ($specs as $asset_specs) { if (!empty($asset_specs['vm_host'])) { $hosts[intval($asset_specs['vm_host'])] = ''; } } if ($hosts) { $sql = mysqli_query($mysqli, "SELECT asset_id, asset_name FROM assets WHERE asset_id IN (" . implode(',', array_keys($hosts)) . ")"); while ($row = mysqli_fetch_assoc($sql)) { $hosts[intval($row['asset_id'])] = $row['asset_name']; } } foreach ($rows as $i => $row) { $type_specs = assetTypeSpecs($row['asset_type'] ?? ''); foreach (array_keys(ASSET_SPECS) as $key) { $value = isset($type_specs[$key]) ? (string) ($specs[intval($row['asset_id'] ?? 0)][$key] ?? '') : ''; $rows[$i]['spec_' . $key] = $key === 'vm_host' && $value !== '' ? ($hosts[intval($value)] ?? '') : $value; } } return $rows; } /** The API's assets, each with its details as asset_specs - {key: value}, its type's only. */ function assetSpecsApiAttach(array $rows) { $specs = assetSpecsFor(array_column($rows, 'asset_id')); foreach ($rows as $i => $row) { $kept = array_intersect_key($specs[intval($row['asset_id'] ?? 0)] ?? [], assetTypeSpecs($row['asset_type'] ?? '')); $rows[$i]['asset_specs'] = (object) $kept; } return $rows; }