"; // Consts for depth values const DEFAULT_DEPTH = 5; const MAX_DEPTH = 10; const NO_DEPTH_LIMIT = 10000; // Config values const CONF_ENABLED = 'impact_enabled_itemtypes'; public static function getTypeName($nb = 0) { return __('Impact analysis'); } public function getTabNameForItem(CommonGLPI $item, $withtemplate = 0) { global $DB; // Class of the current item $class = get_class($item); // Only enabled for CommonDBTM if (!is_a($item, "CommonDBTM", true)) { throw new InvalidArgumentException( "Argument \$item ($class) must be a CommonDBTM." ); } $is_enabled_asset = self::isEnabled($class); $is_itil_object = is_a($item, "CommonITILObject", true); // Check if itemtype is valid if (!$is_enabled_asset && !$is_itil_object) { throw new InvalidArgumentException( "Argument \$item ($class) is not a valid target for impact analysis." ); } if (!$_SESSION['glpishow_count_on_tabs'] || !isset($item->fields['id']) || $is_itil_object ) { // Count is disabled in config OR no item loaded OR ITIL object -> no count $total = 0; } else if ($is_enabled_asset) { // If on an asset, get the number of its direct dependencies $total = count($DB->request([ 'FROM' => ImpactRelation::getTable(), 'WHERE' => [ 'OR' => [ [ // Source item is our item 'itemtype_source' => get_class($item), 'items_id_source' => $item->fields['id'], ], [ // Impacted item is our item AND source item is enabled 'itemtype_impacted' => get_class($item), 'items_id_impacted' => $item->fields['id'], 'itemtype_source' => self::getEnabledItemtypes() ] ] ] ])); } return self::createTabEntry(__("Impact analysis"), $total); } public static function displayTabContentForItem( CommonGLPI $item, $tabnum = 1, $withtemplate = 0 ) { // Impact analysis should not be available outside of central if (Session::getCurrentInterface() !== "central") { return false; } $class = get_class($item); // Only enabled for CommonDBTM if (!is_a($item, "CommonDBTM")) { throw new InvalidArgumentException( "Argument \$item ($class) must be a CommonDBTM)." ); } $ID = $item->fields['id']; // Don't show the impact analysis on new object if ($item->isNewID($ID)) { return false; } // Check READ rights $itemtype = $item->getType(); if (!$itemtype::canView()) { return false; } // For an ITIL object, load the first linked element by default if (is_a($item, "CommonITILObject")) { $linked_items = $item->getLinkedItems(); // Search for a valid linked item of this ITILObject $found = false; foreach ($linked_items as $linked_item) { $class = $linked_item['itemtype']; if (self::isEnabled($class)) { $item = new $class; $found = $item->getFromDB($linked_item['items_id']); break; } } // No valid linked item were found, tab shouldn't be visible if (!$found) { return false; } self::printAssetSelectionForm($linked_items); } // Check is the impact analysis is enabled for $class if (!self::isEnabled($class)) { return false; } // Build graph and params $graph = self::buildGraph($item); $params = self::prepareParams($item); $readonly = !$item->can($item->fields['id'], UPDATE); // Print header self::printHeader(self::makeDataForCytoscape($graph), $params, $readonly); // Displays views self::displayGraphView($item); self::displayListView($item, $graph, true); // Select view echo Html::scriptBlock(" // Select default view $(document).ready(function() { if (location.hash == '#list') { showListView(); } else { showGraphView(); } }); "); return true; } /** * Display the impact analysis as an interactive graph * * @param CommonDBTM $item starting point of the graph */ public static function displayGraphView( CommonDBTM $item ) { self::loadLibs(); echo '
'; self::prepareImpactNetwork($item); echo '
'; } /** * Display the impact analysis as a list * * @param CommonDBTM $item starting point of the graph * @param string $graph array containing the graph nodes and egdes */ public static function displayListView( CommonDBTM $item, array $graph, bool $scripts = false ) { global $CFG_GLPI; $impact_item = ImpactItem::findForItem($item); $impact_context = ImpactContext::findForImpactItem($impact_item); if (!$impact_context) { $max_depth = self::DEFAULT_DEPTH; } else { $max_depth = $impact_context->fields['max_depth']; } echo '
'; echo '
'; // One table will be printed for each direction $lists = [ __("Impact") => self::DIRECTION_FORWARD, __("Impacted by") => self::DIRECTION_BACKWARD, ]; $has_impact = false; foreach ($lists as $label => $direction) { $start_node_id = self::getNodeID($item); $data = self::buildListData($graph, $direction, $item, $max_depth); if (!count($data)) { continue; } $has_impact = true; echo ''; // Header echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ($data as $itemtype => $items) { echo ''; // Subheader echo ''; echo ''; echo ''; foreach ($items as $itemtype_item) { // Content: one row per item echo '
'; echo ''; echo ''; self::displayListNumber( $itemtype_item['node']['ITILObjects']['incidents'], Ticket::class, $itemtype_item['node']['id'] ); self::displayListNumber( $itemtype_item['node']['ITILObjects']['problems'], Problem::class, $itemtype_item['node']['id'] ); self::displayListNumber( $itemtype_item['node']['ITILObjects']['changes'], Change::class, $itemtype_item['node']['id'] ); echo ''; echo ''; } echo ''; } echo '

' . $label . ''; echo '

'._n('Item', 'Items', 1).''.__('Relation').''.Ticket::getTypeName(Session::getPluralNumber()).''.Problem::getTypeName(Session::getPluralNumber()).''.Change::getTypeName(Session::getPluralNumber()).'
'; $total = count($items); echo '' . $itemtype::getTypeName() . '' . ' (' . $total . ')'; echo ''; echo '
'; echo ''; echo '
'; $path = []; foreach ($itemtype_item['node']['path'] as $node) { if ($node['id'] == $start_node_id) { $path[] = '' . $node['label'] . ''; } else { $path[] = $node['label']; } } $separator = ''; echo implode(" $separator ", $path); echo '
'; } if (!$has_impact) { echo '

' . __("This asset doesn't have any dependencies.") . '

'; } echo '
'; $can_update = $item->can($item->fields['id'], UPDATE); // Toolbar echo '
'; if ($has_impact) { echo ''; echo ''; echo ''; } if ($can_update && $impact_context) { echo ''; } echo '
'; // Settings dialog if ($can_update && $impact_context) { $rand = mt_rand(); echo '
'; echo '
'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo '
' . Html::input("max_depth", [ 'id' => "impact_max_depth_$rand", 'value' => $max_depth >= self::MAX_DEPTH ? '' : $max_depth, ]) . '
' . Html::getCheckbox([ 'name' => 'no_limit', 'id' => "check_no_limit_$rand", 'checked' => $max_depth >= self::MAX_DEPTH, ]) . '
'; echo Html::input('id', [ 'type' => "hidden", 'value' => $impact_context->fields['id'], ]); echo Html::input('update', [ 'type' => "hidden", 'value' => "1", ]); Html::closeForm(); echo '
'; } echo '
'; // Stop here if we do not need to generate scripts if (!$scripts) { return; } // Hide / show handler echo Html::scriptBlock(' // jQuery doesn\'t allow slide animation on table elements, we need // to apply the animation to each cells content and then remove the // padding to get the desired "slide" animation function impactListUp(target) { target.removeClass("fa-caret-down"); target.addClass("fa-caret-up"); target.closest("tbody").find(\'tr:gt(0) td\').animate({padding: \'0px\'}, {duration: 400}); target.closest("tbody").find(\'tr:gt(0) div\').slideUp("400"); } function impactListDown(target) { target.addClass("fa-caret-down"); target.removeClass("fa-caret-up"); target.closest("tbody").find(\'tr:gt(0) td\').animate({padding: \'8px 5px\'}, {duration: 400}); target.closest("tbody").find(\'tr:gt(0) div\').slideDown("400"); } $(document).on("click", ".impact-toggle-subitems", function(e) { if ($(e.target).hasClass("fa-caret-up")) { impactListDown($(e.target)); } else { impactListUp($(e.target)); } }); $(document).on("click", ".impact-toggle-subitems-master", function(e) { $(e.target).closest("table").find(".impact-toggle-subitems").each(function(i, elem) { if ($(e.target).hasClass("fa-caret-up")) { impactListDown($(elem)); } else { impactListUp($(elem)); } }); $(e.target).toggleClass("fa-caret-up"); $(e.target).toggleClass("fa-caret-down"); }); $(document).on("impactUpdated", function() { $.ajax({ type: "GET", url: "' . $CFG_GLPI['root_doc'] . '/ajax/impact.php", data: { itemtype: "' . get_class($item) . '", items_id: "' . $item->fields['id'] . '", action : "load", view : "list", }, success: function(data){ $("#impact_list_view").replaceWith(data); showGraphView(); }, }); }); '); if ($can_update) { // Handle settings actions echo Html::scriptBlock(' $("#impact-list-settings").click(function() { $("#list_depth_dialog").dialog({ modal: true, buttons: { ' . __("Save") . ': function() { if ($("input[name=\'no_limit\']:checked").length > 0) { $("input[name=\'max_depth\']").val(' . self::NO_DEPTH_LIMIT . '); } $(this).find("form").submit(); }, ' . __("Cancel") . ': function() { $(this).dialog( "close" ); } }, }); }); '); } } /** * Display "number" cell in list view * The cell is empty if no itilobjets are found, else it contains the * number of iitilobjets found, use the highest priority as it's background * color and is a link to matching search result * * @param array $itil_objects * @param string $type * @param string $node_id */ private static function displayListNumber($itil_objects, $type, $node_id) { $user = new User(); $user->getFromDB(Session::getLoginUserID()); $user->computePreferences(); $count = count($itil_objects) ?: ""; $extra = ""; $node_details = explode(self::NODE_ID_DELIMITER, $node_id); if ($count) { $priority = 1; $id = "impact_list_itilcount_" . mt_rand(); $link = ""; switch ($type) { case Ticket::class: $link = Ticket::getSearchURL(); $link .= "?is_deleted=0&as_map=0&search=Search&itemtype=Ticket"; $link .= "&criteria[0][link]=AND&criteria[0][field]=13&criteria[0][searchtype]=contains&criteria[0][value]=" . $node_details[1]; $link .= "&criteria[1][link]=AND&criteria[1][field]=131&criteria[1][searchtype]=equals&criteria[1][value]=" . $node_details[0]; $link .= "&criteria[2][link]=AND&criteria[2][field]=14&criteria[2][searchtype]=equals&criteria[2][value]=1"; $link .= "&criteria[3][link]=AND&criteria[3][field]=12&criteria[3][searchtype]=equals&criteria[3][value]=notold"; break; case Problem::class: $link = Problem::getSearchURL(); $link .= "?is_deleted=0&as_map=0&search=Search&itemtype=Problem"; $link .= "&criteria[0][link]=AND&criteria[0][field]=13&criteria[0][searchtype]=contains&criteria[0][value]=" . $node_details[1]; $link .= "&criteria[1][link]=AND&criteria[1][field]=131&criteria[1][searchtype]=equals&criteria[1][value]=" . $node_details[0]; $link .= "&criteria[3][link]=AND&criteria[3][field]=12&criteria[3][searchtype]=equals&criteria[3][value]=notold"; break; case Change::class: $link = Change::getSearchURL(); $link .= "?is_deleted=0&as_map=0&search=Search&itemtype=Change"; $link .= "&criteria[0][link]=AND&criteria[0][field]=13&criteria[0][searchtype]=contains&criteria[0][value]=" . $node_details[1]; $link .= "&criteria[1][link]=AND&criteria[1][field]=131&criteria[1][searchtype]=equals&criteria[1][value]=" . $node_details[0]; $link .= "&criteria[3][link]=AND&criteria[3][field]=12&criteria[3][searchtype]=equals&criteria[3][value]=notold"; break; } // Compute max priority foreach ($itil_objects as $itil_object) { if ($priority < $itil_object['priority']) { $priority = $itil_object['priority']; } } $extra = 'id="' . $id . '" style="background-color:' . $user->fields["priority_$priority"] .'; cursor:pointer;"'; echo Html::scriptBlock(' $(document).on("click", "#' . $id . '", function(e) { window.open("' . $link . '"); }); '); } echo '
' . $count . '
'; } /** * Build the data used to represent the impact graph as a semi-flat list * * @param array $graph array containing the graph nodes and egdes * @param int $direction should the list be build for item that are * impacted by $item or that impact $item ? * @param CommonDBTM $item starting point of the graph * @param int $max_depth max depth from context * * @return array */ public static function buildListData( array $graph, int $direction, CommonDBTM $item, int $max_depth ) { $data = []; // Filter tree $sub_graph = self::filterGraph($graph, $direction); // Empty graph, no need to go further if (!count($sub_graph['nodes'])) { return $data; } // Evaluate path to each assets from the starting node $start_node_id = self::getNodeID($item); $start_node = $sub_graph['nodes'][$start_node_id]; foreach ($sub_graph['nodes'] as $key => $vertex) { if ($key !== $start_node_id) { // Set path for target node using BFS $path = self::bfs( $sub_graph, $start_node, $vertex, $direction ); // Add if path is not longer than the allowed value if (count($path) - 1 <= $max_depth) { $sub_graph['nodes'][$key]['path'] = $path; } } } // Split the items by type foreach ($sub_graph['nodes'] as $node) { $details = explode(self::NODE_ID_DELIMITER, $node['id']); $itemtype = $details[0]; $items_id = $details[1]; // Skip start node or empty path if ($node['id'] == $start_node_id || !isset($node['path'])) { continue; } // Init itemtype if empty if (!isset($data[$itemtype])) { $data[$itemtype] = []; } // Add to itemtype $itemtype_item = new $itemtype; $itemtype_item->getFromDB($items_id); $data[$itemtype][] = [ 'stored' => $itemtype_item, 'node' => $node, ]; } return $data; } /** * Return a subgraph matching the given direction * * @param array $graph array containing the graph nodes and egdes * @param int $direction direction to match * * @return array */ public static function filterGraph(array $graph, int $direction) { $new_graph = [ 'edges' => [], 'nodes' => [], ]; // For each edge in the graph foreach ($graph['edges'] as $edge) { // Filter on direction if ($edge['flag'] & $direction) { // Add the edge and its two connected nodes $source = $edge['source']; $target = $edge['target']; $new_graph['edges'][] = $edge; $new_graph['nodes'][$source] = $graph['nodes'][$source]; $new_graph['nodes'][$target] = $graph['nodes'][$target]; } } return $new_graph; } /** * Evaluate the path from one node to another using BFS algorithm * * @param array $graph array containing the graph nodes and egdes * @param array $a a node of the graph * @param array $b a node of the graph * @param int $direction direction used to travel the graph */ public static function bfs(array $graph, array $a, array $b, int $direction) { switch ($direction) { case self::DIRECTION_FORWARD: $start = $a; $target = $b; break; case self::DIRECTION_BACKWARD: $start = $b; $target = $a; break; default: throw new \InvalidArgumentException("Invalid direction : $direction"); } // Insert start node in the queue $queue = []; $queue[] = $start; $discovered = [$start['id'] => true]; // Label start as discovered $start['discovered'] = true; // For each other nodes while (count($queue) > 0) { $node = array_shift($queue); if ($node['id'] == $target['id']) { // target found, build path to node $path = [$target]; while (isset($node['dfs_parent'])) { $node = $node['dfs_parent']; array_unshift($path, $node); } return $path; } foreach ($graph['edges'] as $edge) { // Skip edge if not connected to the current node if ($edge['source'] !== $node['id']) { continue; } $nextNode = $graph['nodes'][$edge['target']]; // Skip already discovered node if (isset($discovered[$nextNode['id']])) { continue; } $nextNode['dfs_parent'] = $node; $discovered[$nextNode['id']] = true; $queue[] = $nextNode; } } } /** * Print the title and view switch * * @param string $graph The network graph (json) * @param string $params Params of the graph (json) * @param bool $readonly Is the graph editable ? */ public static function printHeader( string $graph, string $params, bool $readonly ) { echo '
'; echo "

" . __("Impact analysis") . "

"; echo "
"; echo ""; echo ""; echo "
"; echo "
"; // View selection echo Html::scriptBlock(" function showGraphView() { $('#impact_list_view').hide(); $('#impact_graph_view').show(); $('#sviewlist i').removeClass('selected'); $('#sviewgraph i').addClass('selected'); if (window.GLPIImpact !== undefined && GLPIImpact.cy === null) { GLPIImpact.buildNetwork($graph, $params, $readonly); } } function showListView() { $('#impact_graph_view').hide(); $('#impact_list_view').show(); $('#sviewgraph i').removeClass('selected'); $('#sviewlist i').addClass('selected'); $('#save_impact').removeClass('clean'); } $('#sviewgraph').click(function() { showGraphView(); }); $('#sviewlist').click(function() { showListView(); }); "); } /** * Load the cytoscape library * * @since 9.5 */ public static function loadLibs() { echo Html::css('public/lib/cytoscape.css'); echo Html::script("public/lib/cytoscape.js"); } /** * Print the asset selection form used in the impact tab of ITIL objects * * @param array $items * * @since 9.5 */ public static function printAssetSelectionForm(array $items) { global $CFG_GLPI; // Dropdown values $values = []; // Add a value in the dropdown for each items, grouped by type foreach ($items as $item) { if (self::isEnabled($item['itemtype'])) { // Add itemtype group if it doesn't exist in the dropdown yet $itemtype_label = $item['itemtype']::getTypeName(); if (!isset($values[$itemtype_label])) { $values[$itemtype_label] = []; } $key = $item['itemtype'] . "::" . $item['items_id']; $values[$itemtype_label][$key] = $item['name']; } } Dropdown::showFromArray("impact_assets_selection_dropdown", $values); echo '
'; // Form interaction: load a new graph on value change echo Html::scriptBlock(' $(function() { var selector = "select[name=impact_assets_selection_dropdown]"; $(selector).change(function(){ var values = $(selector + " option:selected").val().split("::"); $.ajax({ type: "GET", url: "'. $CFG_GLPI['root_doc'] . '/ajax/impact.php", data: { itemtype: values[0], items_id: values[1], action : "load", }, success: function(data, textStatus, jqXHR) { GLPIImpact.buildNetwork( JSON.parse(data.graph), JSON.parse(data.params), data.readonly ); } }); }); }); '); } /** * Search asset by itemtype and name * * @param string $itemtype type * @param array $used ids to exlude from the search * @param string $filter filter on name * @param int $page page offset */ public static function searchAsset( string $itemtype, array $used, string $filter, int $page = 0 ) { global $DB; // Check if this type is enabled in config if (!self::isEnabled($itemtype)) { throw new \InvalidArgumentException( "itemtype ($itemtype) must be enabled in config" ); } // Check class exist and is a child of CommonDBTM if (!is_subclass_of($itemtype, "CommonDBTM", true)) { throw new \InvalidArgumentException( "itemtype ($itemtype) must be a valid child of CommonDBTM" ); } // Return empty result if the user doesn't have READ rights if (!Session::haveRight($itemtype::$rightname, READ)) { return [ "items" => [], "total" => 0 ]; } // This array can't be empty since we will use it in the NOT IN part of the reqeust if (!count($used)) { $used[] = -1; } // Search for items $table = $itemtype::getTable(); $base_request = [ 'FROM' => $table, 'WHERE' => [ 'NOT' => [ "$table.id" => $used ], ], ]; // Add friendly name search criteria $base_request['WHERE'] = array_merge( $base_request['WHERE'], $itemtype::getFriendlyNameSearchCriteria($filter) ); if (is_subclass_of($itemtype, "ExtraVisibilityCriteria", true)) { $base_request = array_merge_recursive( $base_request, $itemtype::getVisibilityCriteria() ); } $item = new $itemtype(); if ($item->isEntityAssign()) { $base_request['WHERE'] = array_merge_recursive( $base_request['WHERE'], getEntitiesRestrictCriteria($itemtype::getTable()) ); } if ($item->mayBeDeleted()) { $base_request['WHERE']["$table.is_deleted"] = 0; } if ($item->mayBeTemplate()) { $base_request['WHERE']["$table.is_template"] = 0; } $select = [ 'SELECT' => ["$table.id", $itemtype::getFriendlyNameFields()], ]; $limit = [ 'START' => $page * 20, 'LIMIT' => "20", ]; $count = [ 'COUNT' => "total", ]; // Get items $rows = $DB->request($base_request + $select + $limit); // Get total $total = $DB->request($base_request + $count); return [ "items" => iterator_to_array($rows, false), "total" => iterator_to_array($total, false)[0]['total'], ]; } /** * Load the impact network container * * @since 9.5 */ public static function printImpactNetworkContainer() { global $CFG_GLPI; $action = $CFG_GLPI['root_doc'] . '/ajax/impact.php'; $formName = "form_impact_network"; echo ""; echo ""; echo '"; echo "
'; echo ''; echo '
'; echo ''; echo '
'; echo '
'; echo '
'; echo '

' . __('Add assets') . '

'; echo '
'; echo Html::input("impact-side-filter-itemtypes", [ 'id' => 'impact-side-filter-itemtypes', 'placeholder' => __('Filter itemtypes...'), ]); echo '
'; $itemtypes = $CFG_GLPI["impact_asset_types"]; // Sort by translated itemtypes uksort($itemtypes, function($a, $b) { return strcasecmp($a::getTypeName(), $b::getTypeName()); }); foreach ($itemtypes as $itemtype => $icon) { // Do not display this itemtype if the user doesn't have READ rights if (!Session::haveRight($itemtype::$rightname, READ)) { continue; } // Skip if not enabled if (!self::isEnabled($itemtype)) { continue; } $icon = self::checkIcon($icon); echo '
'; echo '

'; echo "" . $itemtype::getTypeName() . "

"; echo '
'; // impact-side-filter-itemtypes-item } echo '
'; // impact-side-filter-itemtypes-items echo '
'; //
echo '
"; Html::closeForm(); } /** * Build the impact graph starting from a node * * @since 9.5 * * @param CommonDBTM $item Current item * * @return array Array containing edges and nodes */ public static function buildGraph(CommonDBTM $item) { $nodes = []; $edges = []; // Explore the graph forward self::buildGraphFromNode($nodes, $edges, $item, self::DIRECTION_FORWARD); // Explore the graph backward self::buildGraphFromNode($nodes, $edges, $item, self::DIRECTION_BACKWARD); // Add current node to the graph if no impact relations were found if (count($nodes) == 0) { self::addNode($nodes, $item); } // Add special flag to start node $nodes[self::getNodeID($item)]['start'] = 1; return [ 'nodes' => $nodes, 'edges' => $edges ]; } /** * Explore dependencies of the current item, subfunction of buildGraph() * * @since 9.5 * * @param array $edges Edges of the graph * @param array $nodes Nodes of the graph * @param CommonDBTM $node Current node * @param int $direction The direction in which the graph * is being explored : DIRECTION_FORWARD * or DIRECTION_BACKWARD * @param array $explored_nodes List of nodes that have already been * explored * * @throws InvalidArgumentException */ private static function buildGraphFromNode( array &$nodes, array &$edges, CommonDBTM $node, int $direction, array $explored_nodes = [] ) { global $DB; // Source and target are determined by the direction in which we are // exploring the graph switch ($direction) { case self::DIRECTION_BACKWARD: $source = "source"; $target = "impacted"; break; case self::DIRECTION_FORWARD: $source = "impacted"; $target = "source"; break; default: throw new InvalidArgumentException( "Invalid value for argument \$direction ($direction)." ); } // Get relations of the current node $relations = $DB->request([ 'FROM' => ImpactRelation::getTable(), 'WHERE' => [ 'itemtype_' . $target => get_class($node), 'items_id_' . $target => $node->fields['id'] ] ]); // Add current code to the graph if we found at least one impact relation if (count($relations)) { self::addNode($nodes, $node); } // Iterate on each relations found foreach ($relations as $related_item) { // Do not explore disabled itemtypes if (!self::isEnabled($related_item['itemtype_' . $source])) { continue; } // Add the related node if (!($related_node = getItemForItemtype($related_item['itemtype_' . $source]))) { continue; } $related_node->getFromDB($related_item['items_id_' . $source]); self::addNode($nodes, $related_node); // Add or update the relation on the graph $edgeID = self::getEdgeID($node, $related_node, $direction); self::addEdge($edges, $edgeID, $node, $related_node, $direction); // Keep exploring from this node unless we already went through it $related_node_id = self::getNodeID($related_node); if (!isset($explored_nodes[$related_node_id])) { $explored_nodes[$related_node_id] = true; self::buildGraphFromNode( $nodes, $edges, $related_node, $direction, $explored_nodes ); } } } /** * Check if the icon path is valid, if not return a fallback path * * @param string $icon_path * @return string */ private static function checkIcon(string $icon_path): string { // Special case for images returned dynamicly if (strpos($icon_path, ".php") !== false) { return $icon_path; } // Check if icon exist on the filesystem $file_path = GLPI_ROOT . "/$icon_path"; if (file_exists($file_path) && is_file($file_path)) { return $icon_path; } // Fallback "default" icon return "pics/impact/default.png"; } /** * Add a node to the node list if missing * * @param array $nodes Nodes of the graph * @param CommonDBTM $item Node to add * * @since 9.5 * * @return bool true if the node was missing, else false */ private static function addNode(array &$nodes, CommonDBTM $item) { global $CFG_GLPI; // Check if the node already exist $key = self::getNodeID($item); if (isset($nodes[$key])) { return false; } // Get web path to the image matching the itemtype from config $image_name = $CFG_GLPI["impact_asset_types"][get_class($item)] ?? ""; $image_name = self::checkIcon($image_name); // Define basic data of the new node $new_node = [ 'id' => $key, 'label' => $item->getFriendlyName(), 'image' => $CFG_GLPI['root_doc'] . "/$image_name", 'ITILObjects' => $item->getITILTickets(true), ]; // Only set GOTO link if the user have READ rights if ($item::canView()) { $new_node['link'] = $item->getLinkURL(); } // Set incident badge if needed if (count($new_node['ITILObjects']['incidents'])) { $priority = 0; foreach ($new_node['ITILObjects']['incidents'] as $incident) { if ($priority < $incident['priority']) { $priority = $incident['priority']; } } $user = new User(); $user->getFromDB(Session::getLoginUserID()); $user->computePreferences(); $new_node['badge'] = [ 'color' => $user->fields["priority_$priority"], 'count' => count($new_node['ITILObjects']['incidents']), 'target' => Ticket::getSearchURL(), ]; } // Alter the label if we found some linked ITILObjects $itil_tickets_count = $new_node['ITILObjects']['count']; if ($itil_tickets_count > 0) { $new_node['label'] .= " ($itil_tickets_count)"; $new_node['hasITILObjects'] = 1; } // Load or create a new ImpactItem object $impact_item = ImpactItem::findForItem($item); // Load node position and parent $new_node['impactitem_id'] = $impact_item->fields['id']; $new_node['parent'] = $impact_item->fields['parent_id']; // If the node has a parent, add it to the node list aswell if (!empty($new_node['parent'])) { $compound = new ImpactCompound(); $compound->getFromDB($new_node['parent']); if (!isset($nodes[$new_node['parent']])) { $nodes[$new_node['parent']] = [ 'id' => $compound->fields['id'], 'label' => $compound->fields['name'], 'color' => $compound->fields['color'], ]; } } // Insert the node $nodes[$key] = $new_node; return true; } /** * Add an edge to the edge list if missing, else update it's direction * * @param array $edges Edges of the graph * @param string $key ID of the new edge * @param CommonDBTM $itemA One of the node connected to this edge * @param CommonDBTM $itemB The other node connected to this edge * @param int $direction Direction of the edge : A to B or B to A ? * * @since 9.5 * * @return bool true if the node was missing, else false * * @throws InvalidArgumentException */ private static function addEdge( array &$edges, string $key, CommonDBTM $itemA, CommonDBTM $itemB, int $direction ) { // Just update the flag if the edge already exist if (isset($edges[$key])) { $edges[$key]['flag'] = $edges[$key]['flag'] | $direction; return; } // Assign 'from' and 'to' according to the direction switch ($direction) { case self::DIRECTION_FORWARD: $from = self::getNodeID($itemA); $to = self::getNodeID($itemB); break; case self::DIRECTION_BACKWARD: $from = self::getNodeID($itemB); $to = self::getNodeID($itemA); break; default: throw new InvalidArgumentException( "Invalid value for argument \$direction ($direction)." ); } // Add the new edge $edges[$key] = [ 'id' => $key, 'source' => $from, 'target' => $to, 'flag' => $direction ]; } /** * Build the graph and the cytoscape object * * @since 9.5 * * @param string $graph The network graph (json) * @param string $params Params of the graph (json) * @param bool $readonly Is the graph editable ? */ public static function buildNetwork( string $graph, string $params, bool $readonly ) { echo Html::scriptBlock(" $(function() { GLPIImpact.buildNetwork($graph, $params, $readonly); }); "); } /** * Get saved graph params for the current item * * @param CommonDBTM $item * * @return string $item */ public static function prepareParams(CommonDBTM $item) { $impact_item = ImpactItem::findForItem($item); $params = array_intersect_key($impact_item->fields, [ 'parent_id' => 1, 'impactcontexts_id' => 1, 'is_slave' => 1, ]); // Load context if exist if ($params['impactcontexts_id']) { $impact_context = ImpactContext::findForImpactItem($impact_item); if ($impact_context) { $params = $params + array_intersect_key( $impact_context->fields, [ 'positions' => 1, 'zoom' => 1, 'pan_x' => 1, 'pan_y' => 1, 'impact_color' => 1, 'depends_color' => 1, 'impact_and_depends_color' => 1, 'show_depends' => 1, 'show_impact' => 1, 'max_depth' => 1, ] ); } } return json_encode($params); } /** * Convert the php array reprensenting the graph into the format required by * the Cytoscape library * * @param array $graph * * @return string json data */ public static function makeDataForCytoscape(array $graph) { $data = []; foreach ($graph['nodes'] as $node) { $data[] = [ 'group' => 'nodes', 'data' => $node, ]; } foreach ($graph['edges'] as $edge) { $data[] = [ 'group' => 'edges', 'data' => $edge, ]; } return json_encode($data); } /** * Load the "show ongoing tickets" dialog * * @since 9.5 */ public static function printShowOngoingDialog() { // This dialog will be built dynamically by the front end echo '
'; } /** * Load the "edit compound" dialog * * @since 9.5 */ public static function printEditCompoundDialog() { echo '
'; echo ""; // First row: name field echo ""; echo ""; echo ""; echo ""; // Second row: color field echo ""; echo ""; echo ""; echo ""; echo "
"; echo ""; echo ""; echo Html::input("compound_name", []); echo "
"; echo ""; echo ""; Html::showColorField("compound_color", [ 'value' => '#d2d2d2' ]); echo "
"; echo "
"; } /** * Prepare the impact network * * @since 9.5 * * @param CommonDBTM $item The specified item */ public static function prepareImpactNetwork(CommonDBTM $item) { // Load requirements self::printImpactNetworkContainer(); self::printShowOngoingDialog(); self::printEditCompoundDialog(); echo Html::script("js/impact.js"); // Load backend values $default = self::DEFAULT_COLOR; $forward = self::IMPACT_COLOR; $backward = self::DEPENDS_COLOR; $both = self::IMPACT_AND_DEPENDS_COLOR; $start_node = self::getNodeID($item); // Bind the backend values to the client and start the network echo Html::scriptBlock(" $(function() { GLPIImpact.prepareNetwork( $(\"#network_container\"), { default : '$default', forward : '$forward', backward: '$backward', both : '$both', }, '$start_node' ) }); "); } /** * Check that a given asset exist in the DB * * @param string $itemtype Class of the asset * @param string $items_id id of the asset */ public static function assetExist(string $itemtype, string $items_id) { try { // Check this asset type is enabled if (!self::isEnabled($itemtype)) { return false; } // Try to create an object matching the given item type $reflection_class = new ReflectionClass($itemtype); if (!$reflection_class->isInstantiable()) { return false; } // Look for a matching asset in the DB $asset = new $itemtype(); return $asset->getFromDB($items_id); } catch (ReflectionException $e) { // Class does not exist return false; } } /** * Create an ID for a node (itemtype::items_id) * * @param CommonDBTM $item Name of the node * * @return string */ public static function getNodeID(CommonDBTM $item) { return get_class($item) . self::NODE_ID_DELIMITER . $item->fields['id']; } /** * Create an ID for an edge (NodeID->NodeID) * * @param CommonDBTM $itemA First node of the edge * @param CommonDBTM $itemB Second node of the edge * @param int $direction Direction of the edge : A to B or B to A ? * * @return string|null * * @throws InvalidArgumentException */ public static function getEdgeID( CommonDBTM $itemA, CommonDBTM $itemB, int $direction ) { switch ($direction) { case self::DIRECTION_FORWARD: return self::getNodeID($itemA) . self::EDGE_ID_DELIMITER . self::getNodeID($itemB); case self::DIRECTION_BACKWARD: return self::getNodeID($itemB) . self::EDGE_ID_DELIMITER . self::getNodeID($itemA); default: throw new InvalidArgumentException( "Invalid value for argument \$direction ($direction)." ); } } /** * Print the form for the global impact page */ public static function printImpactForm() { global $CFG_GLPI; $rand = mt_rand(); echo ""; echo ''; // First row: header echo ""; echo ""; echo ""; // Second row: itemtype field echo ""; echo ""; echo ""; echo ""; // Third row: items_id field echo ""; echo ""; echo ""; echo ""; // Fourth row: submit echo ""; echo "
" . self::getTypeName() . "
"; Dropdown::showItemTypes( 'type', self::getEnabledItemtypes(), [ 'value' => null, 'width' => '100%', 'emptylabel' => Dropdown::EMPTY_VALUE, 'rand' => $rand ] ); echo "
"; Ajax::updateItemOnSelectEvent("dropdown_type$rand", "form_results", $CFG_GLPI["root_doc"] . "/ajax/dropdownTrackingDeviceType.php", [ 'itemtype' => '__VALUE__', 'entity_restrict' => 0, 'multiple' => 1, 'admin' => 1, 'rand' => $rand, 'myname' => "id", ] ); echo "\n"; echo "\n"; echo "
"; echo Html::submit(__("Show impact analysis")); echo "
"; echo "

"; Html::closeForm(); } /** * Clean impact records for a given item that has been purged form the db * * @param CommonDBTM $item The item being purged */ public static function clean(\CommonDBTM $item) { global $DB, $CFG_GLPI; // Skip if not a valid impact type if (!self::isEnabled($item::getType())) { return; } // Remove each relations $DB->delete(\ImpactRelation::getTable(), [ 'OR' => [ [ 'itemtype_source' => get_class($item), 'items_id_source' => $item->fields['id'] ], [ 'itemtype_impacted' => get_class($item), 'items_id_impacted' => $item->fields['id'] ], ] ]); // Remove associated ImpactItem $impact_item = ImpactItem::findForItem($item, false); if (!$impact_item) { // Stop here if no impactitem, nothing more to delete return; } $impact_item->delete($impact_item->fields); // Remove impact context if defined and not a slave, update others // contexts if they are slave to us if ($impact_item->fields['impactcontexts_id'] != 0 && $impact_item->fields['is_slave'] != 0) { $DB->update(ImpactItem::getTable(), [ 'impactcontexts_id' => 0, ], [ 'impactcontexts_id' => $impact_item->fields['impactcontexts_id'], ] ); $DB->delete(ImpactContext::getTable(), [ 'id' => $impact_item->fields['impactcontexts_id'] ]); } // Delete group if less than two children remaining if ($impact_item->fields['parent_id'] != 0) { $count = countElementsInTable(ImpactItem::getTable(), [ 'parent_id' => $impact_item->fields['parent_id'] ]); if ($count < 2) { $DB->update(ImpactItem::getTable(), [ 'parent_id' => 0, ], [ 'parent_id' => $impact_item->fields['parent_id'] ] ); $DB->delete(ImpactCompound::getTable(), [ 'id' => $impact_item->fields['parent_id'] ]); } } } /** * Check if the given itemtype is enabled in impact config * * @param string $itemtype * @return bool */ public static function isEnabled(string $itemtype): bool { return in_array($itemtype, self::getEnabledItemtypes()); } /** * Return enabled itemtypes * * @return array */ public static function getEnabledItemtypes(): array { // Get configured values $conf = Config::getConfigurationValues('core'); if (!isset($conf[self::CONF_ENABLED])) { return []; } $enabled = importArrayFromDB($conf[self::CONF_ENABLED]); // Remove any forbidden values return array_filter($enabled, function($itemtype) { global $CFG_GLPI; return isset($CFG_GLPI['impact_asset_types'][$itemtype]); }); } /** * Return default itemtypes * * @return array */ public static function getDefaultItemtypes() { global $CFG_GLPI; $values = $CFG_GLPI["default_impact_asset_types"]; return array_keys($values); } /** * Print the impact config tab */ public static function showConfigForm() { global $CFG_GLPI; // Form head $action = Toolbox::getItemTypeFormURL(Config::getType()); echo ""; // Table head echo ''; echo ''; // First row: enabled itemtypes $input_name = self::CONF_ENABLED; $values = $CFG_GLPI["impact_asset_types"]; foreach ($values as $itemtype => $icon) { $values[$itemtype]= $itemtype::getTypeName(); } echo ''; echo ''; $core_config = Config::getConfigurationValues("core"); $db_values = importArrayFromDB($core_config[self::CONF_ENABLED]); echo '"; echo ""; echo '
' . __('Impact analysis configuration') . '
'; echo "'; echo ''; Dropdown::showFromArray($input_name, $values, [ 'multiple' => true, 'values' => $db_values ]); echo "
'; // Submit button echo '
'; echo Html::submit(__('Save'), ['name' => 'update']); echo '
'; Html::closeForm(); } }