diff --git a/src/applications/conduit/controller/PhabricatorConduitLogController.php b/src/applications/conduit/controller/PhabricatorConduitLogController.php index 2effa1815b..62f3a24905 100644 --- a/src/applications/conduit/controller/PhabricatorConduitLogController.php +++ b/src/applications/conduit/controller/PhabricatorConduitLogController.php @@ -1,131 +1,131 @@ getRequest(); $viewer = $request->getUser(); $conn_table = new PhabricatorConduitConnectionLog(); $call_table = new PhabricatorConduitMethodCallLog(); $conn_r = $call_table->establishConnection('r'); $pager = new AphrontCursorPagerView(); $pager->readFromRequest($request); $pager->setPageSize(500); $query = id(new PhabricatorConduitLogQuery()) ->setViewer($viewer); $methods = $request->getStrList('methods'); if ($methods) { $query->withMethods($methods); } $calls = $query->executeWithCursorPager($pager); $conn_ids = array_filter(mpull($calls, 'getConnectionID')); $conns = array(); if ($conn_ids) { $conns = $conn_table->loadAllWhere( 'id IN (%Ld)', $conn_ids); } $table = $this->renderCallTable($calls, $conns); $box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Call Logs')) ->appendChild($table); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Call Logs')); return $this->buildApplicationPage( array( $crumbs, $box, $pager, ), array( 'title' => pht('Conduit Logs'), )); } private function renderCallTable(array $calls, array $conns) { assert_instances_of($calls, 'PhabricatorConduitMethodCallLog'); assert_instances_of($conns, 'PhabricatorConduitConnectionLog'); $viewer = $this->getRequest()->getUser(); $methods = id(new PhabricatorConduitMethodQuery()) ->setViewer($viewer) ->execute(); $methods = mpull($methods, null, 'getAPIMethodName'); $rows = array(); foreach ($calls as $call) { $conn = idx($conns, $call->getConnectionID()); if ($conn) { $name = $conn->getUserName(); $client = ' '.pht('(via %s)', $conn->getClient()); } else { $name = null; $client = null; } $method = idx($methods, $call->getMethod()); if ($method) { switch ($method->getMethodStatus()) { case ConduitAPIMethod::METHOD_STATUS_STABLE: $status = null; break; case ConduitAPIMethod::METHOD_STATUS_UNSTABLE: $status = pht('Unstable'); break; case ConduitAPIMethod::METHOD_STATUS_DEPRECATED: $status = pht('Deprecated'); break; } } else { $status = pht('Unknown'); } $rows[] = array( $call->getConnectionID(), $name, array($call->getMethod(), $client), $status, $call->getError(), - pht('%d us', number_format($call->getDuration())), + pht('%s us', new PhutilNumber($call->getDuration())), phabricator_datetime($call->getDateCreated(), $viewer), ); } $table = id(new AphrontTableView($rows)); $table->setHeaders( array( pht('Connection'), pht('User'), pht('Method'), pht('Status'), pht('Error'), pht('Duration'), pht('Date'), )); $table->setColumnClasses( array( '', '', 'wide', '', '', 'n', 'right', )); return $table; } } diff --git a/src/applications/console/plugin/DarkConsoleServicesPlugin.php b/src/applications/console/plugin/DarkConsoleServicesPlugin.php index 6a59c7e129..055315d641 100644 --- a/src/applications/console/plugin/DarkConsoleServicesPlugin.php +++ b/src/applications/console/plugin/DarkConsoleServicesPlugin.php @@ -1,292 +1,294 @@ getServiceCallLog(); foreach ($log as $key => $entry) { $config = idx($entry, 'config', array()); unset($log[$key]['config']); if (!$should_analyze) { $log[$key]['explain'] = array( 'sev' => 7, 'size' => null, 'reason' => pht('Disabled'), ); // Query analysis is disabled for this request, so don't do any of it. continue; } if ($entry['type'] != 'query') { continue; } // For each SELECT query, go issue an EXPLAIN on it so we can flag stuff // causing table scans, etc. if (preg_match('/^\s*SELECT\b/i', $entry['query'])) { $conn = PhabricatorEnv::newObjectFromConfig( 'mysql.implementation', array($entry['config'])); try { $explain = queryfx_all( $conn, 'EXPLAIN %Q', $entry['query']); $badness = 0; $size = 1; $reason = null; foreach ($explain as $table) { $size *= (int)$table['rows']; switch ($table['type']) { case 'index': $cur_badness = 1; $cur_reason = 'Index'; break; case 'const': $cur_badness = 1; $cur_reason = 'Const'; break; case 'eq_ref'; $cur_badness = 2; $cur_reason = 'EqRef'; break; case 'range': $cur_badness = 3; $cur_reason = 'Range'; break; case 'ref': $cur_badness = 3; $cur_reason = 'Ref'; break; case 'fulltext': $cur_badness = 3; $cur_reason = 'Fulltext'; break; case 'ALL': if (preg_match('/Using where/', $table['Extra'])) { if ($table['rows'] < 256 && !empty($table['possible_keys'])) { $cur_badness = 2; $cur_reason = pht('Small Table Scan'); } else { $cur_badness = 6; $cur_reason = pht('TABLE SCAN!'); } } else { $cur_badness = 3; $cur_reason = pht('Whole Table'); } break; default: if (preg_match('/No tables used/i', $table['Extra'])) { $cur_badness = 1; $cur_reason = pht('No Tables'); } else if (preg_match('/Impossible/i', $table['Extra'])) { $cur_badness = 1; $cur_reason = pht('Empty'); } else { $cur_badness = 4; $cur_reason = pht("Can't Analyze"); } break; } if ($cur_badness > $badness) { $badness = $cur_badness; $reason = $cur_reason; } } $log[$key]['explain'] = array( 'sev' => $badness, 'size' => $size, 'reason' => $reason, ); } catch (Exception $ex) { $log[$key]['explain'] = array( 'sev' => 5, 'size' => null, 'reason' => $ex->getMessage(), ); } } } return array( 'start' => PhabricatorStartup::getStartTime(), 'end' => microtime(true), 'log' => $log, 'analyzeURI' => (string)$this ->getRequestURI() ->alter('__analyze__', true), 'didAnalyze' => $should_analyze, ); } public function renderPanel() { $data = $this->getData(); $log = $data['log']; $results = array(); $results[] = phutil_tag( 'div', array('class' => 'dark-console-panel-header'), array( phutil_tag( 'a', array( 'href' => $data['analyzeURI'], 'class' => $data['didAnalyze'] ? 'disabled button' : 'green button', ), pht('Analyze Query Plans')), phutil_tag('h1', array(), pht('Calls to External Services')), phutil_tag('div', array('style' => 'clear: both;')), )); $page_total = $data['end'] - $data['start']; $totals = array(); $counts = array(); foreach ($log as $row) { $totals[$row['type']] = idx($totals, $row['type'], 0) + $row['duration']; $counts[$row['type']] = idx($counts, $row['type'], 0) + 1; } $totals['All Services'] = array_sum($totals); $counts['All Services'] = array_sum($counts); $totals['Entire Page'] = $page_total; $counts['Entire Page'] = 0; $summary = array(); foreach ($totals as $type => $total) { $summary[] = array( $type, number_format($counts[$type]), - pht('%d us', number_format((int)(1000000 * $totals[$type]))), + pht('%s us', new PhutilNumber((int)(1000000 * $totals[$type]))), sprintf('%.1f%%', 100 * $totals[$type] / $page_total), ); } $summary_table = new AphrontTableView($summary); $summary_table->setColumnClasses( array( '', 'n', 'n', 'wide', )); $summary_table->setHeaders( array( pht('Type'), pht('Count'), pht('Total Cost'), pht('Page Weight'), )); $results[] = $summary_table->render(); $rows = array(); foreach ($log as $row) { $analysis = null; switch ($row['type']) { case 'query': $info = $row['query']; $info = wordwrap($info, 128, "\n", true); if (!empty($row['explain'])) { $analysis = phutil_tag( 'span', array( 'class' => 'explain-sev-'.$row['explain']['sev'], ), $row['explain']['reason']); } break; case 'connect': $info = $row['host'].':'.$row['database']; break; case 'exec': $info = $row['command']; break; case 's3': case 'conduit': $info = $row['method']; break; case 'http': $info = $row['uri']; break; default: $info = '-'; break; } + $offset = ($row['begin'] - $data['start']); + $rows[] = array( $row['type'], - pht('+%d ms', number_format(1000 * ($row['begin'] - $data['start']))), - pht('%d us', number_format(1000000 * $row['duration'])), + pht('+%s ms', new PhutilNumber(1000 * $offset)), + pht('%s us', new PhutilNumber(1000000 * $row['duration'])), $info, $analysis, ); } $table = new AphrontTableView($rows); $table->setColumnClasses( array( null, 'n', 'n', 'wide', '', )); $table->setHeaders( array( pht('Event'), pht('Start'), pht('Duration'), pht('Details'), pht('Analysis'), )); $results[] = $table->render(); return phutil_implode_html("\n", $results); } } diff --git a/src/applications/daemon/controller/PhabricatorDaemonConsoleController.php b/src/applications/daemon/controller/PhabricatorDaemonConsoleController.php index 76d98bda5c..921921942c 100644 --- a/src/applications/daemon/controller/PhabricatorDaemonConsoleController.php +++ b/src/applications/daemon/controller/PhabricatorDaemonConsoleController.php @@ -1,272 +1,272 @@ getViewer(); $window_start = (time() - (60 * 15)); // Assume daemons spend about 250ms second in overhead per task acquiring // leases and doing other bookkeeping. This is probably an over-estimation, // but we'd rather show that utilization is too high than too low. $lease_overhead = 0.250; $completed = id(new PhabricatorWorkerArchiveTaskQuery()) ->withDateModifiedSince($window_start) ->execute(); $failed = id(new PhabricatorWorkerActiveTask())->loadAllWhere( 'failureTime > %d', $window_start); $usage_total = 0; $usage_start = PHP_INT_MAX; $completed_info = array(); foreach ($completed as $completed_task) { $class = $completed_task->getTaskClass(); if (empty($completed_info[$class])) { $completed_info[$class] = array( 'n' => 0, 'duration' => 0, ); } $completed_info[$class]['n']++; $duration = $completed_task->getDuration(); $completed_info[$class]['duration'] += $duration; // NOTE: Duration is in microseconds, but we're just using seconds to // compute utilization. $usage_total += $lease_overhead + ($duration / 1000000); $usage_start = min($usage_start, $completed_task->getDateModified()); } $completed_info = isort($completed_info, 'n'); $rows = array(); foreach ($completed_info as $class => $info) { $rows[] = array( $class, number_format($info['n']), - pht('%d us', number_format((int)($info['duration'] / $info['n']))), + pht('%s us', new PhutilNumber((int)($info['duration'] / $info['n']))), ); } if ($failed) { // Add the time it takes to restart the daemons. This includes a guess // about other overhead of 2X. $restart_delay = PhutilDaemonHandle::getWaitBeforeRestart(); $usage_total += $restart_delay * count($failed) * 2; foreach ($failed as $failed_task) { $usage_start = min($usage_start, $failed_task->getFailureTime()); } $rows[] = array( phutil_tag('em', array(), pht('Temporary Failures')), count($failed), null, ); } $logs = id(new PhabricatorDaemonLogQuery()) ->setViewer($viewer) ->withStatus(PhabricatorDaemonLogQuery::STATUS_ALIVE) ->setAllowStatusWrites(true) ->execute(); $taskmasters = 0; foreach ($logs as $log) { if ($log->getDaemon() == 'PhabricatorTaskmasterDaemon') { $taskmasters++; } } if ($taskmasters && $usage_total) { // Total number of wall-time seconds the daemons have been running since // the oldest event. For very short times round up to 15s so we don't // render any ridiculous numbers if you reload the page immediately after // restarting the daemons. $available_time = $taskmasters * max(15, (time() - $usage_start)); // Percentage of those wall-time seconds we can account for, which the // daemons spent doing work: $used_time = ($usage_total / $available_time); $rows[] = array( phutil_tag('em', array(), pht('Queue Utilization (Approximate)')), sprintf('%.1f%%', 100 * $used_time), null, ); } $completed_table = new AphrontTableView($rows); $completed_table->setNoDataString( pht('No tasks have completed in the last 15 minutes.')); $completed_table->setHeaders( array( pht('Class'), pht('Count'), pht('Avg'), )); $completed_table->setColumnClasses( array( 'wide', 'n', 'n', )); $completed_panel = new PHUIObjectBoxView(); $completed_panel->setHeaderText( pht('Recently Completed Tasks (Last 15m)')); $completed_panel->appendChild($completed_table); $daemon_table = new PhabricatorDaemonLogListView(); $daemon_table->setUser($viewer); $daemon_table->setDaemonLogs($logs); $daemon_panel = new PHUIObjectBoxView(); $daemon_panel->setHeaderText(pht('Active Daemons')); $daemon_panel->appendChild($daemon_table); $tasks = id(new PhabricatorWorkerLeaseQuery()) ->setSkipLease(true) ->withLeasedTasks(true) ->setLimit(100) ->execute(); $tasks_table = id(new PhabricatorDaemonTasksTableView()) ->setTasks($tasks) ->setNoDataString(pht('No tasks are leased by workers.')); $leased_panel = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Leased Tasks')) ->appendChild($tasks_table); $task_table = new PhabricatorWorkerActiveTask(); $queued = queryfx_all( $task_table->establishConnection('r'), 'SELECT taskClass, count(*) N FROM %T GROUP BY taskClass ORDER BY N DESC', $task_table->getTableName()); $rows = array(); foreach ($queued as $row) { $rows[] = array( $row['taskClass'], number_format($row['N']), ); } $queued_table = new AphrontTableView($rows); $queued_table->setHeaders( array( pht('Class'), pht('Count'), )); $queued_table->setColumnClasses( array( 'wide', 'n', )); $queued_table->setNoDataString(pht('Task queue is empty.')); $queued_panel = new PHUIObjectBoxView(); $queued_panel->setHeaderText(pht('Queued Tasks')); $queued_panel->appendChild($queued_table); $upcoming = id(new PhabricatorWorkerLeaseQuery()) ->setLimit(10) ->setSkipLease(true) ->execute(); $upcoming_panel = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Next In Queue')) ->appendChild( id(new PhabricatorDaemonTasksTableView()) ->setTasks($upcoming) ->setNoDataString(pht('Task queue is empty.'))); $triggers = id(new PhabricatorWorkerTriggerQuery()) ->setViewer($viewer) ->setOrder(PhabricatorWorkerTriggerQuery::ORDER_EXECUTION) ->needEvents(true) ->setLimit(10) ->execute(); $triggers_table = $this->buildTriggersTable($triggers); $triggers_panel = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Upcoming Triggers')) ->appendChild($triggers_table); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Console')); $nav = $this->buildSideNavView(); $nav->selectFilter('/'); $nav->appendChild( array( $crumbs, $completed_panel, $daemon_panel, $queued_panel, $leased_panel, $upcoming_panel, $triggers_panel, )); return $this->buildApplicationPage( $nav, array( 'title' => pht('Console'), 'device' => false, )); } private function buildTriggersTable(array $triggers) { $viewer = $this->getViewer(); $rows = array(); foreach ($triggers as $trigger) { $event = $trigger->getEvent(); if ($event) { $last_epoch = $event->getLastEventEpoch(); $next_epoch = $event->getNextEventEpoch(); } else { $last_epoch = null; $next_epoch = null; } $rows[] = array( $trigger->getID(), $trigger->getClockClass(), $trigger->getActionClass(), $last_epoch ? phabricator_datetime($last_epoch, $viewer) : null, $next_epoch ? phabricator_datetime($next_epoch, $viewer) : null, ); } return id(new AphrontTableView($rows)) ->setNoDataString(pht('There are no upcoming event triggers.')) ->setHeaders( array( pht('ID'), pht('Clock'), pht('Action'), pht('Last'), pht('Next'), )) ->setColumnClasses( array( '', '', 'wide', 'date', 'date', )); } } diff --git a/src/applications/daemon/controller/PhabricatorWorkerTaskDetailController.php b/src/applications/daemon/controller/PhabricatorWorkerTaskDetailController.php index 57951ccb91..66925ffd36 100644 --- a/src/applications/daemon/controller/PhabricatorWorkerTaskDetailController.php +++ b/src/applications/daemon/controller/PhabricatorWorkerTaskDetailController.php @@ -1,224 +1,224 @@ id = $data['id']; } public function processRequest() { $request = $this->getRequest(); $user = $request->getUser(); $task = id(new PhabricatorWorkerActiveTask())->load($this->id); if (!$task) { $tasks = id(new PhabricatorWorkerArchiveTaskQuery()) ->withIDs(array($this->id)) ->execute(); $task = reset($tasks); } if (!$task) { $title = pht('Task Does Not Exist'); $error_view = new PHUIInfoView(); $error_view->setTitle(pht('No Such Task')); $error_view->appendChild(phutil_tag( 'p', array(), pht('This task may have recently been garbage collected.'))); $error_view->setSeverity(PHUIInfoView::SEVERITY_NODATA); $content = $error_view; } else { $title = pht('Task %d', $task->getID()); $header = id(new PHUIHeaderView()) ->setHeader(pht('Task %d (%s)', $task->getID(), $task->getTaskClass())); $properties = $this->buildPropertyListView($task); $object_box = id(new PHUIObjectBoxView()) ->setHeader($header) ->addPropertyList($properties); $retry_head = id(new PHUIHeaderView()) ->setHeader(pht('Retries')); $retry_info = $this->buildRetryListView($task); $retry_box = id(new PHUIObjectBoxView()) ->setHeader($retry_head) ->addPropertyList($retry_info); $content = array( $object_box, $retry_box, ); } $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb($title); return $this->buildApplicationPage( array( $crumbs, $content, ), array( 'title' => $title, )); } private function buildPropertyListView( PhabricatorWorkerTask $task) { $viewer = $this->getRequest()->getUser(); $view = new PHUIPropertyListView(); if ($task->isArchived()) { switch ($task->getResult()) { case PhabricatorWorkerArchiveTask::RESULT_SUCCESS: $status = pht('Complete'); break; case PhabricatorWorkerArchiveTask::RESULT_FAILURE: $status = pht('Failed'); break; case PhabricatorWorkerArchiveTask::RESULT_CANCELLED: $status = pht('Cancelled'); break; default: throw new Exception(pht('Unknown task status!')); } } else { $status = pht('Queued'); } $view->addProperty( pht('Task Status'), $status); $view->addProperty( pht('Task Class'), $task->getTaskClass()); if ($task->getLeaseExpires()) { if ($task->getLeaseExpires() > time()) { $lease_status = pht('Leased'); } else { $lease_status = pht('Lease Expired'); } } else { $lease_status = phutil_tag('em', array(), pht('Not Leased')); } $view->addProperty( pht('Lease Status'), $lease_status); $view->addProperty( pht('Lease Owner'), $task->getLeaseOwner() ? $task->getLeaseOwner() : phutil_tag('em', array(), pht('None'))); if ($task->getLeaseExpires() && $task->getLeaseOwner()) { $expires = ($task->getLeaseExpires() - time()); $expires = phutil_format_relative_time_detailed($expires); } else { $expires = phutil_tag('em', array(), pht('None')); } $view->addProperty( pht('Lease Expires'), $expires); if ($task->isArchived()) { - $duration = pht('%d us', number_format($task->getDuration())); + $duration = pht('%s us', new PhutilNumber($task->getDuration())); } else { $duration = phutil_tag('em', array(), pht('Not Completed')); } $view->addProperty( pht('Duration'), $duration); $data = id(new PhabricatorWorkerTaskData())->load($task->getDataID()); $task->setData($data->getData()); $worker = $task->getWorkerInstance(); $data = $worker->renderForDisplay($viewer); $view->addProperty( pht('Data'), $data); return $view; } private function buildRetryListView(PhabricatorWorkerTask $task) { $view = new PHUIPropertyListView(); $data = id(new PhabricatorWorkerTaskData())->load($task->getDataID()); $task->setData($data->getData()); $worker = $task->getWorkerInstance(); $view->addProperty( pht('Failure Count'), $task->getFailureCount()); $retry_count = $worker->getMaximumRetryCount(); if ($retry_count === null) { $max_retries = phutil_tag('em', array(), pht('Retries Forever')); $retry_count = INF; } else { $max_retries = $retry_count; } $view->addProperty( pht('Maximum Retries'), $max_retries); $projection = clone $task; $projection->makeEphemeral(); $next = array(); for ($ii = $task->getFailureCount(); $ii < $retry_count; $ii++) { $projection->setFailureCount($ii); $next[] = $worker->getWaitBeforeRetry($projection); if (count($next) > 10) { break; } } if ($next) { $cumulative = 0; foreach ($next as $key => $duration) { if ($duration === null) { $duration = 60; } $cumulative += $duration; $next[$key] = phutil_format_relative_time($cumulative); } if ($ii != $retry_count) { $next[] = '...'; } $retries_in = implode(', ', $next); } else { $retries_in = pht('No More Retries'); } $view->addProperty( pht('Retries After'), $retries_in); return $view; } } diff --git a/src/applications/herald/query/HeraldTranscriptSearchEngine.php b/src/applications/herald/query/HeraldTranscriptSearchEngine.php index 1fd01c3e0d..f9433e0c02 100644 --- a/src/applications/herald/query/HeraldTranscriptSearchEngine.php +++ b/src/applications/herald/query/HeraldTranscriptSearchEngine.php @@ -1,139 +1,139 @@ getStrList('objectMonograms'); $saved->setParameter('objectMonograms', $object_monograms); $ids = $request->getStrList('ids'); foreach ($ids as $key => $id) { if (!$id || !is_numeric($id)) { unset($ids[$key]); } else { $ids[$key] = $id; } } $saved->setParameter('ids', $ids); return $saved; } public function buildQueryFromSavedQuery(PhabricatorSavedQuery $saved) { $query = id(new HeraldTranscriptQuery()); $object_monograms = $saved->getParameter('objectMonograms'); if ($object_monograms) { $objects = id(new PhabricatorObjectQuery()) ->setViewer($this->requireViewer()) ->withNames($object_monograms) ->execute(); $query->withObjectPHIDs(mpull($objects, 'getPHID')); } $ids = $saved->getParameter('ids'); if ($ids) { $query->withIDs($ids); } return $query; } public function buildSearchForm( AphrontFormView $form, PhabricatorSavedQuery $saved) { $object_monograms = $saved->getParameter('objectMonograms', array()); $ids = $saved->getParameter('ids', array()); $form ->appendChild( id(new AphrontFormTextControl()) ->setName('objectMonograms') ->setLabel(pht('Object Monograms')) ->setValue(implode(', ', $object_monograms))) ->appendChild( id(new AphrontFormTextControl()) ->setName('ids') ->setLabel(pht('Transcript IDs')) ->setValue(implode(', ', $ids))); } protected function getURI($path) { return '/herald/transcript/'.$path; } protected function getBuiltinQueryNames() { return array( 'all' => pht('All'), ); } public function buildSavedQueryFromBuiltin($query_key) { $query = $this->newSavedQuery(); $query->setQueryKey($query_key); $viewer_phid = $this->requireViewer()->getPHID(); switch ($query_key) { case 'all': return $query; } return parent::buildSavedQueryFromBuiltin($query_key); } protected function getRequiredHandlePHIDsForResultList( array $transcripts, PhabricatorSavedQuery $query) { return mpull($transcripts, 'getObjectPHID'); } protected function renderResultList( array $transcripts, PhabricatorSavedQuery $query, array $handles) { assert_instances_of($transcripts, 'HeraldTranscript'); $viewer = $this->requireViewer(); $list = new PHUIObjectItemListView(); foreach ($transcripts as $xscript) { $view_href = phutil_tag( 'a', array( 'href' => '/herald/transcript/'.$xscript->getID().'/', ), pht('View Full Transcript')); $item = new PHUIObjectItemView(); $item->setObjectName($xscript->getID()); $item->setHeader($view_href); if ($xscript->getDryRun()) { $item->addAttribute(pht('Dry Run')); } $item->addAttribute($handles[$xscript->getObjectPHID()]->renderLink()); $item->addAttribute( - pht('%d ms', number_format((int)(1000 * $xscript->getDuration())))); + pht('%s ms', new PhutilNumber((int)(1000 * $xscript->getDuration())))); $item->addIcon( 'none', phabricator_datetime($xscript->getTime(), $viewer)); $list->addItem($item); } return $list; } }