$row['currentsession'])); if ($lecture !== false) { $row['currentsession'] = $lecture['displayname']; $row['lectureid'] = $lecture['lectureid']; } $row['session'] = $row['currentsession']; return; } } $res = Database::simpleQuery('SELECT dateline, username, data FROM statistic' . " WHERE clientip = :ip AND typeid = '.vmchooser-session-name'" . ' AND dateline BETWEEN :start AND :end', array( 'ip' => $row['clientip'], 'start' => $row['logintime'] - 60, 'end' => $row['logintime'] + 300, )); $session = false; foreach ($res as $r) { if ($session === false || abs($session['dateline'] - $row['logintime']) > abs($r['dateline'] - $row['logintime'])) { $session = $r; } } if ($session !== false) { $row['session'] = $session['data']; if (empty($row['currentuser'])) { $row['username'] = $session['username']; } } } private static function showMachine($uuid) { $client = Database::queryFirst('SELECT machineuuid, locationid, macaddr, clientip, firstseen, lastseen, logintime, lastboot, state, mbram, live_tmpsize, live_tmpfree, live_id45size, live_id45free, live_swapsize, live_swapfree, live_memsize, live_memfree, live_cpuload, live_cputemp, Length(position) AS hasroomplan, kvmstate, cpumodel, id44mb, id45mb, data, hostname, currentuser, currentsession, notes FROM machine WHERE machineuuid = :uuid', array('uuid' => $uuid)); if ($client === false) { Message::addError('unknown-machine', $uuid); return; } if (Module::isAvailable('locations') && !Location::isLeaf($client['locationid'])) { $client['hasroomplan'] = false; } User::assertPermission('machine.view-details', (int)$client['locationid']); // Hack: Get raw collected data if (Request::get('raw', false)) { Header('Content-Type: text/plain; charset=utf-8'); die($client['data']); } // Parse data $hdds = array(); if ($client['data'][0] === '{') { $json = json_decode($client['data'], true); if (is_array($json)) { $client += self::parseJson($uuid, $json); $hdds['hdds'] = self::queryHddData($uuid); } } else { self::parseLegacy($client, $hdds); } unset($client['data']); // Get rid of configured speed, if equal to maximum speed foreach ($client['ram'] as &$item) { if (isset($item['Configured Memory Speed']) && $item['Configured Memory Speed'] === $item['Speed']) { unset($item['Configured Memory Speed']); } } unset($item); $client['lspci1'] = $client['lspci2'] = []; foreach ($client['lspci'] as $item) { $item['vendor_s'] = PciId::getPciId(PciId::VENDOR, $item['vendor']); $item['device_s'] = PciId::getPciId(PciId::DEVICE, $item['vendor'] . $item['device']); if ($item['vendor_s'] === false) { $pciLookup[$item['vendor']] = true; } if ($item['device_s'] === false) { $pciLookup[$item['vendor'] . ':' . $item['device']] = true; } $class = $item['class']; if ($class === '0300' || $class === '0200' || $class === '0403') { $dst =& $client['lspci1']; } else { $dst =& $client['lspci2']; } if (!isset($dst[$class])) { $dst[$class] = [ 'class' => $class, 'class_s' => PciId::getPciId(PciId::DEVCLASS, $class, true), 'entries' => [], ]; } $dst[$class]['entries'][] = $item; } unset($dst, $client['lspci']); ksort($client['lspci1']); ksort($client['lspci2']); $client['lspci1'] = array_values($client['lspci1']); $client['lspci2'] = array_values($client['lspci2']); // Runmode if (Module::isAvailable('runmode')) { $data = RunMode::getRunMode($uuid, RunMode::DATA_STRINGS); if ($data !== false) { $client += $data; } } // Rebootcontrol if (Module::get('rebootcontrol') !== false) { $client['canReboot'] = (User::hasPermission('.rebootcontrol.action.reboot', (int)$client['locationid'])); $client['canShutdown'] = (User::hasPermission('.rebootcontrol.action.shutdown', (int)$client['locationid'])); $client['canWol'] = (User::hasPermission('.rebootcontrol.action.wol', (int)$client['locationid'])); $client['canExec'] = (User::hasPermission('.rebootcontrol.action.exec', (int)$client['locationid'])); $client['rebootcontrol'] = $client['canReboot'] || $client['canShutdown'] || $client['canWol'] || $client['canExec']; } // Baseconfig if (Module::get('baseconfig') !== false && User::hasPermission('.baseconfig.view', (int)$client['locationid'])) { $cvs = Database::queryFirst('SELECT Count(*) AS cnt FROM setting_machine WHERE machineuuid = :uuid', ['uuid' => $uuid]); $client['overriddenVars'] = is_array($cvs) ? $cvs['cnt'] : 0; $client['hasBaseconfig'] = true; } if (!isset($client['isclient'])) { $client['isclient'] = true; } // Mangle fields $NOW = time(); if (!$client['isclient']) { if ($client['state'] === 'IDLE') { $client['state'] = 'OCCUPIED'; } } else { if ($client['state'] === 'OCCUPIED') { self::fillSessionInfo($client); } } $client['state_' . $client['state']] = true; $client['firstseen_s'] = date('d.m.Y H:i', $client['firstseen']); $client['lastseen_s'] = date('d.m.Y H:i', $client['lastseen']); $client['logintime_s'] = date('d.m.Y H:i', $client['logintime']); if ($client['lastboot'] == 0) { $client['lastboot_s'] = '-'; } else { $uptime = $NOW - $client['lastboot']; $client['lastboot_s'] = date('d.m.Y H:i', $client['lastboot']); if ($client['state'] === 'IDLE' || $client['state'] === 'OCCUPIED') { $client['lastboot_s'] .= ' (Up ' . floor($uptime / 86400) . 'd ' . gmdate('H:i', $uptime) . ')'; } } $client['gbram'] = round(ceil($client['mbram'] / 512) / 2, 1); $client['gbtmp'] = round($client['id44mb'] / 1024); $client['gbid45'] = round($client['id45mb'] / 1024); foreach (['tmp', 'id45', 'swap', 'mem'] as $item) { if ($client['live_' . $item . 'size'] == 0) continue; $client['live_' . $item . 'percent'] = round(($client['live_' . $item . 'free'] / $client['live_' . $item . 'size']) * 100, 2); $client['live_' . $item . 'free_s'] = Util::readableFileSize($client['live_' . $item . 'free'], -1, 2); } if ($client['live_cpuload'] <= 100) { $client['live_cpuload_s'] = $client['live_cpuload'] . "\xe2\x80\x89%"; $client['live_cpuidle'] = 100 - $client['live_cpuload']; } $client['live_cputemppercent'] = max(0, min(100, 110 - $client['live_cputemp'])); $client['ramclass'] = StatisticsStyling::ramColorClass($client['mbram']); $client['kvmclass'] = StatisticsStyling::kvmColorClass($client['kvmstate']); $client['hddclass'] = StatisticsStyling::hddColorClass($client['gbtmp']); // Format HDD data to strings foreach ($hdds['hdds'] as &$hdd) { $hdd['smart_status_failed'] = !($client['smart_status//passed'] ?? 1); self::mangleHdd($hdd); } // BIOS update check if (!empty($client['bios']['BIOS Revision']) || !empty($client['bios']['Release Date'])) { if (preg_match('#^(\d{1,2})/(\d{1,2})/(\d{4})#', $client['bios']['Release Date'], $out)) { $client['bios']['Release Date'] = $out[2] . '.' . $out[1] . '.' . $out[3]; } $mainboard = $client['mainboard']['Manufacturer'] . '##' . $client['mainboard']['Product Name']; $system = $client['system']['Manufacturer'] . '##' . $client['system']['Product Name']; $ret = self::checkBios($mainboard, $system, $client['bios']['Release Date'], $client['bios']['BIOS Revision']); if ($ret === false) { // Not loaded, use AJAX $params = [ 'mainboard' => $mainboard, 'system' => $system, 'date' => $client['bios']['Release Date'], 'revision' => $client['bios']['BIOS Revision'], ]; $client['biosurl'] = '?do=statistics&action=bios&' . http_build_query($params); } elseif (!isset($ret['status']) || $ret['status'] !== 0) { $client['bioshtml'] = Render::parse('machine-bios-update', $ret); } } // Get locations if (Module::isAvailable('locations')) { $locs = Location::getLocationsAssoc(); $next = (int)$client['locationid']; $output = array(); while (isset($locs[$next])) { array_unshift($output, $locs[$next]); $next = $locs[$next]['parentlocationid']; } $client['locations'] = $output; } // Screens TODO Move everything else to hw table instead of blob parsing above // `devicetype`, `devicename`, `subid`, `machineuuid` $res = Database::simpleQuery("SELECT m.hwid, h.hwname, m.devpath AS connector, m.disconnecttime," . " p.value AS resolution, q.prop AS projector FROM machine_x_hw m" . " INNER JOIN statistic_hw h ON (m.hwid = h.hwid AND h.hwtype = :screen)" . " LEFT JOIN machine_x_hw_prop p ON (m.machinehwid = p.machinehwid AND p.prop = 'resolution')" . " LEFT JOIN statistic_hw_prop q ON (m.hwid = q.hwid AND q.prop = 'projector')" . " WHERE m.machineuuid = :uuid", array('screen' => DeviceType::SCREEN, 'uuid' => $uuid)); $client['screens'] = array(); $ports = array(); foreach ($res as $row) { if ($row['disconnecttime'] != 0) continue; $ports[] = $row['connector']; $client['screens'][] = $row; } array_multisort($ports, SORT_ASC, $client['screens']); Permission::addGlobalTags($client['perms'], null, ['hardware.projectors.edit', 'hardware.projectors.view']); // Throw output at user Render::addTemplate('machine-main', $client); if (!empty($pciLookup)) { Render::addTemplate('js-pciquery', ['missing_ids' => json_encode(array_keys($pciLookup))]); } // Sessions $NOW = time(); $cutoff = $NOW - 86400 * 7; //if ($cutoff < $client['firstseen']) $cutoff = $client['firstseen']; $scale = 100 / ($NOW - $cutoff); $res = Database::simpleQuery('SELECT dateline, typeid, data FROM statistic' . " WHERE dateline > :cutoff AND typeid IN (:sessionLength, :offlineLength) AND machineuuid = :uuid ORDER BY dateline ASC", array( 'cutoff' => $cutoff - 86400 * 14, 'uuid' => $uuid, 'sessionLength' => Statistics::SESSION_LENGTH, 'offlineLength' => Statistics::OFFLINE_LENGTH, )); $spans['rows'] = array(); $spans['graph'] = ''; $last = false; $first = true; foreach ($res as $row) { if (!$client['isclient'] && $row['typeid'] === Statistics::SESSION_LENGTH) continue; // Don't differentiate between session and idle for non-clients if ($first && $row['dateline'] > $cutoff && $client['lastboot'] > $cutoff) { // Special case: offline before $spans['graph'] .= '
 
'; } $first = false; if ($row['dateline'] + $row['data'] < $cutoff || $row['data'] > 864000) { continue; } if ($last !== false && abs($last['dateline'] - $row['dateline']) < 30 && abs($last['data'] - $row['data']) < 30 ) { continue; } if ($last !== false && $last['dateline'] + $last['data'] > $row['dateline']) { $point = $last['dateline'] + $last['data']; $row['data'] -= ($point - $row['dateline']); $row['dateline'] = $point; } if ($row['dateline'] < $cutoff) { $row['data'] -= ($cutoff - $row['dateline']); $row['dateline'] = $cutoff; } $row['from'] = Util::prettyTime($row['dateline']); $row['duration'] = floor($row['data'] / 86400) . 'd ' . gmdate('H:i', $row['data']); if ($row['typeid'] === Statistics::OFFLINE_LENGTH) { $row['glyph'] = 'off'; $color = '#444'; } elseif ($row['typeid'] === Statistics::SUSPEND_LENGTH) { $row['glyph'] = 'pause'; $color = '#686'; } else { $row['glyph'] = 'user'; $color = '#e77'; } $spans['graph'] .= '
 
'; if ($client['isclient']) { $spans['rows'][] = $row; } $last = $row; } if ($first && $client['lastboot'] > $cutoff) { // Special case: offline before $spans['graph'] .= '
 
'; } elseif ($first) { // Not seen in last two weeks $spans['graph'] .= '
 
'; } if ($client['state'] === 'OCCUPIED') { $spans['graph'] .= '
 
'; $spans['rows'][] = [ 'from' => Util::prettyTime($client['logintime']), 'duration' => '-', 'glyph' => 'user', ]; } elseif ($client['state'] === 'OFFLINE') { $spans['graph'] .= '
 
'; $spans['rows'][] = [ 'from' => Util::prettyTime($client['lastseen']), 'duration' => '-', 'glyph' => 'off', ]; } elseif ($client['state'] === 'STANDBY') { $spans['graph'] .= '
 
'; $spans['rows'][] = [ 'from' => Util::prettyTime($client['lastseen']), 'duration' => '-', 'glyph' => 'pause', ]; } $t = explode('-', date('Y-n-j-G', $cutoff)); if ($t[3] >= 8 && $t[3] <= 22) { $start = mktime(22, 0, 0, $t[1], $t[2], $t[0]); } else { $start = mktime(22, 0, 0, $t[1], $t[2] - 1, $t[0]); } for ($i = $start; $i < $NOW; $i += 86400) { $spans['graph'] .= '
 
'; } if (count($spans['rows']) > 10) { $spans['hasrows2'] = true; $spans['rows2'] = array_slice($spans['rows'], ceil(count($spans['rows']) / 2)); $spans['rows'] = array_slice($spans['rows'], 0, ceil(count($spans['rows']) / 2)); } $spans['isclient'] = $client['isclient']; Render::addTemplate('machine-usage', $spans); // Any hdds? if (!empty($hdds['hdds'])) { Render::addTemplate('machine-hdds', $hdds); } // Client log if (Module::get('syslog') !== false) { $lres = Database::simpleQuery('SELECT logid, dateline, logtypeid, clientip, description, extra FROM clientlog' . ' WHERE machineuuid = :uuid ORDER BY logid DESC LIMIT 25', array('uuid' => $client['machineuuid'])); $count = 0; $log = array(); foreach ($lres as $row) { if (substr($row['description'], -5) === 'on :0' && strpos($row['description'], 'root logged') === false) { continue; } $row['date'] = Util::prettyTime($row['dateline']); $row['icon'] = self::eventToIconName($row['logtypeid']); $log[] = $row; if (++$count === 10) { break; } } Render::addTemplate('syslog', array( 'machineuuid' => $client['machineuuid'], 'list' => $log, )); } // Notes if (User::hasPermission('machine.note.*', (int)$client['locationid'])) { Permission::addGlobalTags($client['perms'], (int)$client['locationid'], ['machine.note.edit']); Render::addTemplate('machine-notes', $client); } } private static function parseLegacy(array &$client, array &$hdds) { // Parse the giant blob of data if (strpos($client['data'], "\r") !== false) { $client['data'] = str_replace("\r", "\n", $client['data']); } if (preg_match_all('/##### ([^#]+) #+$(.*?)^#####/ims', $client['data'] . '########', $out, PREG_SET_ORDER)) { foreach ($out as $section) { if ($section[1] === 'CPU') { HardwareParserLegacy::parseCpu($client, $section[2]); } if ($section[1] === 'dmidecode') { HardwareParserLegacy::parseDmiDecode($client, $section[2]); } if ($section[1] === 'Partition tables') { HardwareParserLegacy::parseHdd($hdds, $section[2]); } if ($section[1] === 'PCI ID') { $client['lspci'] = HardwareParserLegacy::parsePci($section[2]); } if (isset($hdds['hdds']) && $section[1] === 'smartctl') { // This currently requires that the partition table section comes first... HardwareParserLegacy::parseSmartctl($hdds['hdds'], $section[2]); } } } } private static function parseJson(string $uuid, array $json): array { $return = [ 'cpu' => $json['cpu'] ?? [], 'lspci' => $json['lspci'] ?? [], 'ram' => array_map(function($item) { return HardwareParser::prepareDmiProperties($item); }, HardwareParser::getDmiHandles($json, 17)), ]; foreach ($return['ram'] as $ram) { if (!empty($ram['Form Factor']) && !empty($ram['Type'])) { $return['ramtype'] = $ram['Type'] . '-' . $ram['Form Factor']; break; } } $need = [ 'bios' => 0, 'system' => 1, 'mainboard' => 2, ]; foreach ($need as $name => $id) { $return[$name] = HardwareParser::prepareDmiProperties( HardwareParser::getDmiHandles($json, $id)[0] ?? []); } $q = new HardwareQuery(HardwareInfo::MAINBOARD, $uuid); $q->addGlobalColumn('Memory Maximum Capacity'); $q->addGlobalColumn('Memory Slot Count'); $res = $q->query()->fetch(); if (is_array($res)) { $return += $res; } return $return; } private static function queryHddData(string $uuid): array { $hdds = []; $ret = Database::simpleQuery("SELECT mp.`machinehwid`, mp.`prop`, mp.`value`, mp.`numeric` FROM machine_x_hw_prop mp INNER JOIN machine_x_hw mxhw ON (mp.machinehwid = mxhw.machinehwid AND mxhw.machineuuid = :uuid AND mxhw.disconnecttime = 0) INNER JOIN statistic_hw sh ON (mxhw.hwid = sh.hwid AND sh.hwtype = :type) UNION SELECT mxhw.`machinehwid`, hwp.`prop`, hwp.`value`, hwp.`numeric` FROM statistic_hw_prop hwp INNER JOIN machine_x_hw mxhw ON (hwp.hwid = mxhw.hwid AND mxhw.machineuuid = :uuid AND mxhw.disconnecttime = 0) INNER JOIN statistic_hw sh ON (mxhw.hwid = sh.hwid AND sh.hwtype = :type) ", ['type' => HardwareInfo::HDD, 'uuid' => $uuid]); foreach ($ret as $row) { if (!isset($hdds[$row['machinehwid']])) { $hdds[$row['machinehwid']] = ['partitions' => []]; } $hdd =& $hdds[$row['machinehwid']]; if (preg_match('/^(attr_[0-9]+)_(.*)$/', $row['prop'], $out)) { // SMART attributes if (!isset($hdd[$out[1]])) { $hdd[$out[1]] = []; } $hdd[$out[1]][$out[2]] = $row['numeric'] ?? $row['value']; } elseif (preg_match('/^part_([0-9]+)_(.*)$/', $row['prop'], $out)) { // Partitions if (!isset($hdd['partitions'][$out[1]])) { $hdd['partitions'][$out[1]] = ['id' => 'dev-' . count($hdds) . '-' . $out[1], 'index' => $out[1] + 1]; } $hdd['partitions'][$out[1]][$out[2]] = $row['numeric'] ?? $row['value']; } else { $hdd[$row['prop']] = $row['numeric'] ?? $row['value']; } } foreach ($hdds as $k => &$hdd) { $hdd['devid'] = 'k' . $k; $hdd['partitions'] = array_values($hdd['partitions']); } return array_values($hdds); } private static function mangleHdd(array &$hdd) { $hours = $hdd['power_on_time//hours'] ?? $hdd['attr_9']['raw'] ?? $hdd['power_on_hours'] ?? $hdd['power_on_time']['hours'] ?? null; if ($hours !== null) { $hdd['PowerOnTime'] = ''; $val = (int)str_replace('.', '', $hours); if ($val > 8760) { $hdd['PowerOnTime'] .= floor($val / 8760) . 'Y, '; $val %= 8760; } if ($val > 720) { $hdd['PowerOnTime'] .= floor($val / 720) . 'M, '; $val %= 720; } if ($val > 24) { $hdd['PowerOnTime'] .= floor($val / 24) . 'd, '; $val %= 24; } $hdd['PowerOnTime'] .= $val . 'h'; } // Sort by start for building pie-chart $xx = array_column($hdd['partitions'], 'start'); array_multisort($xx, SORT_ASC, SORT_NUMERIC, $hdd['partitions']); $used = 0; $json = []; $lastEnd = 0; $minDisplaySize = $hdd['size'] / 150; foreach ($hdd['partitions'] as &$part) { $dist = $part['start'] - $lastEnd; if ($dist > $minDisplaySize) { error_log('Dist: ' . Util::readableFileSize($dist)); $json[] = ['value' => $dist, 'color' => '#aaa']; } if ($part['size'] > $minDisplaySize) { $json[] = ['value' => $part['size'], 'color' => self::typeToColor($part), 'label' => $part['id']]; } $part['size_s'] = Util::readableFileSize($part['size']); $used += $part['size']; $lastEnd = $part['start'] + $part['size']; if (!isset($part['name'])) { $part['name'] = self::partTypeToName($part['slxtype'] ?? $part['type']); } } $dist = $hdd['size'] - $lastEnd; if ($dist > $minDisplaySize) { $json[] = ['value' => $dist, 'color' => '#aaa']; } $hdd['json'] = json_encode($json); $hdd['size_s'] = Util::readableFileSize($hdd['size']); if ($hdd['size'] - $used > 1000000000) { $hdd['unused_s'] = Util::readableFileSize($hdd['size'] - $used); } // Finally sort by index for table display array_multisort(array_column($hdd['partitions'], 'index'), SORT_ASC, $hdd['partitions']); } private static function typeToColor(array $part): string { switch ($part['slxtype'] ?? $part['type']) { case 44: return '#5c1'; case 45: return '#0d7'; case 82: return '#48f'; } return '#e55'; } private static function eventToIconName($event): string { switch ($event) { case 'session-open': return 'glyphicon-log-in'; case 'session-close': return 'glyphicon-log-out'; case 'partition-swap': return 'glyphicon-info-sign'; case 'partition-temp': case 'smartctl-realloc': return 'glyphicon-exclamation-sign'; default: return 'glyphicon-minus'; } } const BIOS_CACHE = '/tmp/bwlp-bios.json'; public static function ajaxCheckBios() { $mainboard = Request::any('mainboard', false, 'string'); $system = Request::any('system', false, 'string'); $date = Request::any('date', false, 'string'); $revision = Request::any('revision', false, 'string'); $reply = self::checkBios($mainboard, $system, $date, $revision); if ($reply === false) { $data = Download::asString(CONFIG_BIOS_URL, 3, $err); if ($err < 200 || $err >= 300) { $reply = ['error' => 'HTTP: ' . $err]; } else { file_put_contents(self::BIOS_CACHE, $data); $data = json_decode($data, true); $reply = self::checkBios($mainboard, $system, $date, $revision, $data); } } if ($reply === false) { $reply = ['error' => 'Internal Error']; } if (isset($reply['status']) && $reply['status'] === 0) exit; // Show nothing, 0 means OK die(Render::parse('machine-bios-update', $reply)); } private static function checkBios($mainboard, $system, $date, $revision, $json = null) { if ($json === null) { if (!file_exists(self::BIOS_CACHE) || filemtime(self::BIOS_CACHE) + 3600 < time()) return false; $json = json_decode(file_get_contents(self::BIOS_CACHE), true); } if (!is_array($json) || !isset($json['system'])) return ['error' => 'Malformed JSON, no system key']; if (isset($json['system'][$system]) && isset($json['system'][$system]['fixes']) && isset($json['system'][$system]['match'])) { $match =& $json['system'][$system]; } elseif (isset($json['mainboard'][$mainboard]) && isset($json['mainboard'][$mainboard]['fixes']) && isset($json['mainboard'][$mainboard]['match'])) { $match =& $json['mainboard'][$mainboard]; } else { return ['status' => 0]; } $key = $match['match']; if ($key === 'revision') { $cmp = function ($item) { $s = explode('.', $item); return $s[0] * 0x10000 + $s[1]; }; $reference = $cmp($revision); } elseif ($key === 'date') { $cmp = function ($item) { $s = explode('.', $item); return $s[2] * 10000 + $s[1] * 100 + $s[0]; }; $reference = $cmp($date); } else { return ['error' => 'Invalid comparison key: ' . $key]; } $retval = ['fixes' => []]; $level = 0; foreach ($match['fixes'] as $fix) { if ($cmp($fix[$key]) > $reference) { class_exists('Dictionary'); // Trigger setup of lang stuff $lang = isset($fix['text'][LANG]) ? LANG : 'en'; $fix['text'] = $fix['text'][$lang]; $retval['fixes'][] = $fix; $level = max($level, $fix['level']); } } $retval['url'] = $match['url']; $retval['status'] = $level; if ($level > 5) { $retval['class'] = 'danger'; } elseif ($level > 3) { $retval['class'] = 'warning'; } else { $retval['class'] = 'info'; } return $retval; } /** * @param string $type MBR-type or GPT UUID * @return string Name of partition type if known, otherwise, $type is returned */ private static function partTypeToName(string $type): string { switch ($type) { case '44': case '45': return 'OpenSLX-ID' . $type; case '82': return 'Linux Swap'; case '83': return 'Linux'; case '7': return 'NTFS/Windows'; case 'ef': return 'EFI'; } return HardwareInfo::GPT[$type] ?? $type; } }