";
// 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 '| '._n('Item', 'Items', 1).' | ';
echo ''.__('Relation').' | ';
echo ''.Ticket::getTypeName(Session::getPluralNumber()).' | ';
echo ''.Problem::getTypeName(Session::getPluralNumber()).' | ';
echo ''.Change::getTypeName(Session::getPluralNumber()).' | ';
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 '';
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 ' | ';
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 '
';
}
if (!$has_impact) {
echo '
' . __("This asset doesn't have any dependencies.") . '
';
}
echo '
';
$can_update = $item->can($item->fields['id'], UPDATE);
// Toolbar
echo '
';
// Settings dialog
if ($can_update && $impact_context) {
$rand = mt_rand();
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 '";
// 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 "