{t('There is no data yet.', {ns: 'statistics'})}
+ ), + + title: title('Recordings by Project'), + subtitle: t( + 'Total Recordings: {{count}}', + { + ns: 'statistics', + count: getTotalRecordings(), + } + ), + onToggleFilters: () => setShowFiltersBreakdown((prev) => !prev), + }, + { + content: + getTotalRecordings() > 0 ? ( +{t('There is no data yet.', {ns: 'statistics'})}
+ ), + title: title('Recordings by Site'), + subtitle: t( + 'Total Recordings: {{count}}', + { + ns: 'statistics', + count: getTotalRecordings(), + } + ), + onToggleFilters: () => setShowFiltersBreakdown((prev) => !prev), + }, + { + content: + getTotalEvents() > 0 ? ( +{t('There is no data yet.', {ns: 'statistics'})}
+ ), + title: title('Events by Project'), + subtitle: t( + 'Total Events: {{count}}', + { + ns: 'statistics', + count: getTotalEvents(), + } + ), + onToggleFilters: () => setShowFiltersBreakdown((prev) => !prev), + }, + ]} + /> + > + ); +}; +Electrophysiology.propTypes = { + data: PropTypes.object, + baseURL: PropTypes.string, + updateFilters: PropTypes.func, + showChart: PropTypes.func, +}; +Electrophysiology.defaultProps = { + data: {}, +}; + +export default Electrophysiology; diff --git a/modules/statistics/jsx/widgets/helpers/chartBuilder.js b/modules/statistics/jsx/widgets/helpers/chartBuilder.js index b3b51d1f2c3..89a9e1f6c43 100644 --- a/modules/statistics/jsx/widgets/helpers/chartBuilder.js +++ b/modules/statistics/jsx/widgets/helpers/chartBuilder.js @@ -76,7 +76,7 @@ const formatBarData = (data) => { return processedData; }; -const createPieChart = (columns, id, targetModal, colours) => { +const createPieChart = (columns, id, targetModal, colours, units = null, showPieLabelRatio = true) => { let newChart = c3.generate({ bindto: targetModal ? targetModal : id, data: { @@ -92,13 +92,22 @@ const createPieChart = (columns, id, targetModal, colours) => { pie: { label: { format: function(value, ratio, id) { - return value + "("+Math.round(100*ratio)+"%)"; + if (units) { + value = `${value} ${units}`; + } + if (showPieLabelRatio) { + value = `${value} (${(ratio * 100).toFixed(0)}%)`; + } + return value; } } }, tooltip: { format: { value: function (value, ratio) { + if (units) { + value = `${value} ${units}`; + } return `${value} (${(ratio * 100).toFixed(0)}%)`; }, }, @@ -107,7 +116,7 @@ const createPieChart = (columns, id, targetModal, colours) => { return newChart; } -const createBarChart = (t, labels, columns, id, targetModal, colours, dataType) => { +const createBarChart = (labels, columns, id, targetModal, colours, dataType, yLabel) => { let newChart = c3.generate({ bindto: targetModal ? targetModal : id, data: { @@ -130,11 +139,11 @@ const createBarChart = (t, labels, columns, id, targetModal, colours, dataType) axis: { x: { type: 'category', - categories: labels, + categories: labels, }, y: { label: { - text: t('Candidates registered', { ns: 'statistics'}), + text: yLabel, position: 'inner-top' }, }, @@ -167,6 +176,7 @@ const createLineChart = (data, columns, id, label, targetModal, titlePrefix) => } } } + let newChart = c3.generate({ size: { height: targetModal && 500, @@ -226,10 +236,8 @@ const createLineChart = (data, columns, id, label, targetModal, titlePrefix) => name = nameFormat(d[i].name); value = valueFormat(d[i].value, d[i].ratio, d[i].id, d[i].index); - // Calculate percentage based on grand total of entire dataset let percentage = grandTotal > 0 ? ((d[i].value / grandTotal) * 100).toFixed(1) : 0; - bgcolor = $$.levelColor ? $$.levelColor(d[i].value) : color(d[i].id); text += "There have been no scans yet.
+{t('There have been no scans yet.', {ns: 'statistics'})}
), title: title('Site Scans'), + subtitle: t( + 'Total Scans: {{count}}', + { + ns: 'statistics', + count: json['studyprogression']['total_scans'], + } + ), onToggleFilters: () => setShowFiltersScans((prev) => !prev), }, { @@ -212,11 +238,53 @@ const StudyProgression = (props) => { {showChart('total_recruitment', 'siterecruitment_bymonth')} ) : ( -There have been no candidates registered yet.
++ {t( + 'There have been no candidates registered yet.', + {ns: 'statistics'} + )} +
), title: title('Site Recruitment'), onToggleFilters: () => showFiltersBreakdown((prev) => !prev), }, + { + content: + Object.keys(json['options']['projects']).length > 0 ? ( +{t('There is no data yet.', {ns: 'statistics'})}
+ ), + title: title('Project Dataset Sizes'), + subtitle: t( + 'Total Size: {{count}} GB', + { + ns: 'statistics', + count: json['studyprogression']['total_size'] ?? -1, + } + ), + }, ]} /> > diff --git a/modules/statistics/php/charts.class.inc b/modules/statistics/php/charts.class.inc index a9d39d04cb1..3bdd6540c62 100644 --- a/modules/statistics/php/charts.class.inc +++ b/modules/statistics/php/charts.class.inc @@ -102,6 +102,14 @@ class Charts extends \NDB_Page return $this->_handleSiteLineData($request); case 'agedistribution_line': return $this->_handleAgeDistributionByProject($request); + case 'eeg_recordings_by_site': + return $this->_handleSiteEEGRecordingsBreakdown($request); + case 'eeg_recordings_by_project': + return $this->_handleProjectEEGRecordingsBreakdown($request); + case 'eeg_events_by_project': + return $this->_handleProjectEEGEventsBreakdown($request); + case 'size_byproject': + return $this->_handleProjectSizeBreakdown(); default: return new \LORIS\Http\Response\JSON\NotFound(); } @@ -831,4 +839,229 @@ class Charts extends \NDB_Page } return $data; } + + /** + * Handle an incoming request for project size breakdown. + * + * @return ResponseInterface + */ + private function _handleProjectSizeBreakdown() + { + $DB = \NDB_Factory::singleton()->database(); + $user = \NDB_Factory::singleton()->user(); + $projects = $user->getProjects(); + + $cachedSizeData = json_decode( + html_entity_decode( + $DB->pselectOne( + "SELECT Value + FROM cached_data + JOIN cached_data_type USING (CachedDataTypeID) + WHERE Name='projects_disk_space'", + [] + ) ?? '' + ), + true + ); + + $projectData = []; + if (!is_null($cachedSizeData)) { + foreach ($projects as $project) { + $projectName = $project->getName(); + if (in_array($projectName, array_keys($cachedSizeData))) { + $projectData[] = [ + 'label' => $projectName, + ...$cachedSizeData[$projectName], + ]; + } + } + } + return (new \LORIS\Http\Response\JsonResponse($projectData)); + } + + /** + * Handle an incoming request for EEG recordings by site breakdown. + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return ResponseInterface + */ + private function _handleSiteEEGRecordingsBreakdown( + ServerRequestInterface $request + ) { + $DB = \NDB_Factory::singleton()->database(); + $user = \NDB_Factory::singleton()->user(); + $sites = $user->getSites(); + + $conditions = $this->_buildQueryConditions($request); + + $data = iterator_to_array( + $DB->pselect( + "SELECT + s.CenterID, + psc.Name, + COUNT(distinct s.ID) as count + FROM physiological_file pf + JOIN session s ON (s.ID=pf.SessionID) + JOIN candidate c ON (c.ID=s.CandidateID) + JOIN psc USING (CenterID) + {$conditions['participantStatusJoin']} + WHERE 1 = 1 + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']} + GROUP BY s.CenterID + ORDER BY s.CenterID", + $conditions['params'] + ) + ); + + $processedData = []; + foreach ($data as $row) { + $processedData[$row['CenterID']] = [ + 'CenterID' => $row['CenterID'], + 'Name' => $row['Name'], + 'count' => intval($row['count']), + ]; + } + + $siteData = []; + foreach ($sites as $siteID => $site) { + if (in_array($siteID, array_keys($processedData))) { + $siteData[] = [ + 'label' => $site->getCenterName(), + 'total' => $processedData[$siteID]['count'], + ]; + } + } + + return (new \LORIS\Http\Response\JsonResponse($siteData)); + } + + /** + * Handle an incoming request for EEG recordings by project breakdown. + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return ResponseInterface + */ + private function _handleProjectEEGRecordingsBreakdown( + ServerRequestInterface $request + ) { + $DB = \NDB_Factory::singleton()->database(); + $user = \NDB_Factory::singleton()->user(); + $projects = $user->getProjects(); + + $conditions = $this->_buildQueryConditions($request); + + $data = iterator_to_array( + $DB->pselect( + "SELECT + s.ProjectID, + p.Name, + COUNT(distinct s.ID) as count + FROM physiological_file pf + JOIN session s ON (s.ID=pf.SessionID) + JOIN Project p USING (ProjectID) + JOIN candidate c ON (c.ID=s.CandidateID) + JOIN psc ON (s.CenterID = psc.CenterID) + {$conditions['participantStatusJoin']} + WHERE 1 = 1 + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']} + GROUP BY s.CenterID + ORDER BY s.CenterID", + $conditions['params'] + ) + ); + + $processedData = []; + foreach ($data as $row) { + $processedData[$row['ProjectID']] = [ + 'ProjectID' => $row['ProjectID'], + 'Name' => $row['Name'], + 'count' => intval($row['count']), + ]; + } + + $projectData = []; + foreach ($projects as $projectID => $projectName) { + if (in_array($projectID, array_keys($processedData))) { + $projectData[] = [ + 'label' => $projectName, + 'total' => $processedData[$projectID]['count'], + ]; + } + } + + return (new \LORIS\Http\Response\JsonResponse($projectData)); + } + + /** + * Handle an incoming request for EEG events by project breakdown. + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return ResponseInterface + */ + private function _handleProjectEEGEventsBreakdown( + ServerRequestInterface $request + ) { + $DB = \NDB_Factory::singleton()->database(); + $user = \NDB_Factory::singleton()->user(); + $projects = $user->getProjects(); + + $conditions = $this->_buildQueryConditions($request); + + $data = iterator_to_array( + $DB->pselect( + "SELECT + s.ProjectID, + p.Name, + COUNT(pte.PhysiologicalTaskEventID) as count + FROM physiological_task_event pte + JOIN physiological_file pf USING (PhysiologicalFileID) + JOIN session s ON (s.ID=pf.SessionID) + JOIN Project p USING (ProjectID) + JOIN candidate c ON (c.ID=s.CandidateID) + JOIN psc ON (s.CenterID = psc.CenterID) + {$conditions['participantStatusJoin']} + WHERE 1 = 1 + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']} + GROUP BY s.CenterID + ORDER BY s.CenterID", + $conditions['params'] + ) + ); + + $processedData = []; + foreach ($data as $row) { + $processedData[$row['ProjectID']] = [ + 'ProjectID' => $row['ProjectID'], + 'Name' => $row['Name'], + 'count' => intval($row['count']), + ]; + } + + $projectData = []; + foreach ($projects as $projectID => $projectName) { + if (in_array($projectID, array_keys($processedData))) { + $projectData[] = [ + 'label' => $projectName, + 'total' => $processedData[$projectID]['count'], + ]; + } + } + + return (new \LORIS\Http\Response\JsonResponse($projectData)); + } } diff --git a/modules/statistics/php/module.class.inc b/modules/statistics/php/module.class.inc index ef2b360998b..67942806db7 100644 --- a/modules/statistics/php/module.class.inc +++ b/modules/statistics/php/module.class.inc @@ -71,11 +71,11 @@ class Module extends \Module */ public function getWidgets(string $type, \User $user, array $options) : array { + $factory = \NDB_Factory::singleton(); + $baseURL = $factory->settings()->getBaseURL(); switch ($type) { case 'dashboard': - $factory = \NDB_Factory::singleton(); - $baseURL = $factory->settings()->getBaseURL(); - $widget = new \LORIS\dashboard\Widget( + $widget = new \LORIS\dashboard\Widget( new \LORIS\dashboard\WidgetContent( '', '', @@ -100,6 +100,56 @@ class Module extends \Module $widget->setTemplateVariables(['id' => 'statistics_widgets']); return [$widget]; + case 'study-progression': + $DB = $factory->database(); + $cachedSizeData = json_decode( + html_entity_decode( + $DB->pselectOne( + "SELECT Value + FROM cached_data + JOIN cached_data_type USING (CachedDataTypeID) + WHERE Name='projects_disk_space'", + [] + ) ?? '' + ), + true + ); + + $data = []; + + $projects = $user->getProjects(); + if (!is_null($cachedSizeData)) { + foreach ($projects as $project) { + $projectName = $project->getName(); + if (!in_array($projectName, array_keys($cachedSizeData))) { + continue; + } + + $datasetSize = sprintf( + dgettext('statistics', '%s GB'), + $cachedSizeData[$projectName]['total'] + ); + $data[] = [ + 'ProjectID' => $project->getId(), + 'ProjectName' => $projectName, + 'count' => $datasetSize, + 'url' => "$baseURL/dqt", + ]; + } + } + + return [ + new \LORIS\dashboard\DataWidget( + new \LORIS\GUI\LocalizableString( + "dqt", + "Dataset Size", + "Dataset Size", + ), + $data, + "", + 'rgb(186,225,255)', + ) + ]; } return []; } diff --git a/modules/statistics/php/widgets.class.inc b/modules/statistics/php/widgets.class.inc index 56d307b0cf6..672739a4b26 100644 --- a/modules/statistics/php/widgets.class.inc +++ b/modules/statistics/php/widgets.class.inc @@ -119,6 +119,21 @@ class Widgets extends \NDB_Page implements ETagCalculator $studyWidgets ); + $totalSizeOfProjectsGB = 0; + $projectsSizes = []; + $cachedSizeData = json_decode( + html_entity_decode( + $db->pselectOne( + "SELECT Value + FROM cached_data + JOIN cached_data_type USING (CachedDataTypeID) + WHERE Name='projects_disk_space'", + [] + ) ?? '' + ), + true + ); + foreach ($projects as $pid) { // Set project recruitment data $projectInfo = $config->getProjectSettings(intval(strval($pid))); @@ -129,9 +144,10 @@ class Widgets extends \NDB_Page implements ETagCalculator 'project ID ' . intval(strval($pid)) ); } + $projectName = $projectInfo['Name']; $recruitment[intval(strval($pid))] = $this->_createProjectProgressBar( strval($pid), - $projectInfo['Name'], + $projectName, $projectInfo['recruitmentTarget'], $this->getTotalRecruitmentByProject( $recruitmentRaw, @@ -174,6 +190,14 @@ class Widgets extends \NDB_Page implements ETagCalculator $visitOptions[$visitLabel] = $visitName; } } + + if (!is_null($cachedSizeData)) { + if (in_array($projectName, array_keys($cachedSizeData))) { + $projectSize = $cachedSizeData[$projectName]['total']; + $projectsSizes[$projectName] = $projectSize; + $totalSizeOfProjectsGB += floatval($projectSize); + } + } } $siteOptions = []; @@ -183,6 +207,26 @@ class Widgets extends \NDB_Page implements ETagCalculator } } + $eeg_recordings = $db->pselectOneInt( + "SELECT COUNT(distinct s.ID) as total_recordings + FROM physiological_file pf + JOIN session s ON (s.ID=pf.SessionID) + WHERE s.ProjectID IN (" . + join(',', $user->getProjectIDs()) + . ")", + [] + ); + $eeg_events = $db->pselectOneInt( + "SELECT COUNT(pte.PhysiologicalTaskEventID) as total_events + FROM physiological_task_event pte + JOIN physiological_file pf USING (PhysiologicalFileID) + JOIN session s ON (s.ID=pf.SessionID) + WHERE s.ProjectID IN (" . + join(',', $user->getProjectIDs()) + . ")", + [] + ); + $participantStatusOptions = \Candidate::getParticipantStatusOptions(); @@ -201,10 +245,18 @@ class Widgets extends \NDB_Page implements ETagCalculator 'total_scans' => $totalScans, 'recruitment' => $recruitment, 'progressionData' => $studyProgressionProjects, + 'total_size' => $totalSizeOfProjectsGB, ]; $values['options'] = $options; $values['recruitmentcohorts'] = $recruitmentCohorts; + $values['size_byproject'] = $projectsSizes; + + $values['eeg_data'] = [ + 'total_recordings' => $eeg_recordings, + 'total_events' => $eeg_events + ]; + $this->_cache = new \LORIS\Http\Response\JsonResponse($values); return $this->_cache; @@ -599,7 +651,9 @@ class Widgets extends \NDB_Page implements ETagCalculator && isset($value['ProjectID']) && strval($value['ProjectID']) === $projectID ) { - $title = $widgetConfig['ref']->label()->getN($value['count']); + $title = $widgetConfig['ref']->label()->getN( + intval(preg_split('/\s+/', strval($value['count']))[0]) + ); $projectData[] = [ 'title' => $title, diff --git a/raisinbread/RB_files/RB_cached_data.sql b/raisinbread/RB_files/RB_cached_data.sql new file mode 100644 index 00000000000..cabb8271eed --- /dev/null +++ b/raisinbread/RB_files/RB_cached_data.sql @@ -0,0 +1,6 @@ +SET FOREIGN_KEY_CHECKS=0; +TRUNCATE TABLE `cached_data`; +LOCK TABLES `cached_data` WRITE; +INSERT INTO `cached_data` (`CachedDataID`, `CachedDataTypeID`, `Value`, `LastUpdate`) VALUES (1,1,'{"Pumpernickel":{"total":0.8}}','2025-09-10 11:12:13'); +UNLOCK TABLES; +SET FOREIGN_KEY_CHECKS=1; diff --git a/raisinbread/RB_files/RB_cached_data_type.sql b/raisinbread/RB_files/RB_cached_data_type.sql new file mode 100644 index 00000000000..e1f1ec608fb --- /dev/null +++ b/raisinbread/RB_files/RB_cached_data_type.sql @@ -0,0 +1,6 @@ +SET FOREIGN_KEY_CHECKS=0; +TRUNCATE TABLE `cached_data_type`; +LOCK TABLES `cached_data_type` WRITE; +INSERT INTO `cached_data_type` (`CachedDataTypeID`, `Name`) VALUES (1,'projects_disk_space'); +UNLOCK TABLES; +SET FOREIGN_KEY_CHECKS=1; diff --git a/tools/update_projects_disk_space.php b/tools/update_projects_disk_space.php new file mode 100644 index 00000000000..508f430d34d --- /dev/null +++ b/tools/update_projects_disk_space.php @@ -0,0 +1,95 @@ +database(); +$config = \NDB_Config::singleton(); + +$dataDir = $config->getSetting('dataDirBasepath'); +$projects = Utility::getProjectList(); + +$projectData = []; + +foreach ($projects as $pid => $project) { + $projectDir = $db->pselectOne( + "SELECT DISTINCT( + SUBSTRING(SUBSTRING_INDEX(FilePath, '/', 2), 1) + ) + FROM ( + SELECT FilePath, SessionID + FROM physiological_file pf + UNION + SELECT File, SessionID as FilePath FROM files f + ) file_table + LEFT JOIN session s ON s.ID = file_table.SessionID + WHERE FilePath LIKE 'bids_imports%' + AND s.ProjectID=:PID LIMIT 1;", + ['PID' => $pid] + ); + if (!is_null($projectDir)) { + $fullPath = $dataDir . $projectDir; + $dirSizeGB = round( + getDirSize($fullPath) / pow(10, 9), + 1 + ); + $projectData[$project]['total'] = $dirSizeGB; + } +} + +$cachedDataTypeID = $db->pselectOneInt( + "SELECT `CachedDataTypeID` + FROM `cached_data_type` + WHERE `Name` = 'projects_disk_space'", + [] +); + +$rowExists = $db->pselectOne( + "SELECT Value FROM cached_data + WHERE CachedDataTypeID = :CDTID;", + ['CDTID' => $cachedDataTypeID] +); + +if ($rowExists) { + $db->update( + 'cached_data', + ['Value' => json_encode($projectData)], + ['CachedDataTypeID' => $cachedDataTypeID] + ); +} else { + $db->insert( + 'cached_data', + [ + 'CachedDataTypeID' => $cachedDataTypeID, + 'Value' => json_encode($projectData) + ] + ); +} + +echo "cached_data:projects_disk_space updated\r\n"; + +/** + * Calculate directory size, recursively, skipping .tgz files + * + * @param string $directory Target directory + * + * @return int + */ +function getDirSize(string $directory): int +{ + $size = 0; + $files = glob($directory . '/*'); + foreach ($files as $path) { + if (!str_ends_with($path, '.tgz')) { + is_file($path) && $size += filesize($path); + is_dir($path) && $size += getDirSize($path); + } + } + return $size; +}