A core/OverviewStatistics extension adds a tile to the statistics dashboards an administrator sees under ACP → Statistics → Key Statistics. There are two such dashboards — "User Activity" (app=core&module=stats&controller=overview) and "Content Activity" (app=core&module=activitystats&controller=overview) — and a third page, "Saved Reports" (app=core&module=overview&controller=mycharts), where an admin can pin individual tiles into a named report and download the whole report as CSV. The tile is a small card with a title, an optional description, a body of HTML you generate, an optional per-tile filter form, and an optional auto-refresh. The date range at the top of the page is chosen by the admin and handed to you; you decide what the number means.
Every tile on those pages is one of these extensions. The registration count, the online-users chart, the reputation tables, the per-content-type activity counts and the forums "% solved" tile are all OverviewStatistics implementations, so there is no privileged core path a third-party app cannot reach.
The contract
The abstract is system/Extensions/OverviewStatisticsAbstract.php. Three methods are abstract; the rest have working defaults you may override.
namespace IPS\Extensions;
abstract class OverviewStatisticsAbstract
{
/* Which of the two dashboards the tile appears on. The ONLY values core
tests for are 'user' and 'activity'. Anything else renders nowhere. */
public string $page = 'user';
/* REQUIRED. The sub-block keys this one class provides. One class may
produce many tiles; core loops this and calls the other methods once
per entry. Returning an empty array means the class contributes
nothing and is skipped. */
abstract public function getBlocks(): array;
/* REQUIRED. Metadata for one tile. Return an EMPTY ARRAY to hide it. */
abstract public function getBlockDetails( ?string $subBlock = NULL ): array;
/* REQUIRED. The tile body, as a string of HTML. Loaded over AJAX, not
on the initial page render. */
abstract public function getBlock( array|string|null $dateRange = NULL, ?string $subBlock = NULL ): string;
/* OPTIONAL but see below - the default return of NULL puts an error
message in the CSV export instead of your numbers. */
public function getBlockNumbers( array|string $dateRange = null, string $subBlock = null ) : array|null
/* OPTIONAL. Add fields to the tile's filter dropdown. Only reached when
getBlockDetails() returned 'form' => TRUE. */
protected function _blockForm( Form &$form, ?string $subBlock = null, array $values=[] ) : void
/* Helper. Maps '7', '30', '90', '180', '365' to a DateInterval,
anything else to NULL. */
protected static function getInterval( ?int $dateRange ) : ?DateInterval
/* Provided by the abstract. Do not override unless you know why. */
public function getSavedBlockFilters() : array
public function getBlockForm( string $subBlock = NULL ): string|null
}
Signature lines above are copied from system/Extensions/OverviewStatisticsAbstract.php lines 42, 50, 58, 67, 75, 106, 118, 129 and 177. Note that core's own implementations declare the softer string $subBlock = NULL rather than ?string $subBlock = NULL — for example applications/core/extensions/core/OverviewStatistics/Registrations.php:55. Both satisfy PHP's variance rules, so either form works.
What getBlockDetails() must return
An associative array. The keys core actually reads are:
title— a language key, not a title. Both dashboard templates run it throughaddToStack()before display.description— a language key orNULL.overviewStatisticBlock.phtmlonly renders the description block when this is truthy, soNULLmeans "no subtitle".app— the directory name of an installed application. It becomesdata-appon the tile and, on the Content Activity page, drives the per-app show/hide checkboxes.refresh— an integer.ips.stats.overviewBlock.js:28reads it asdata-refreshand line 177 uses it assetInterval(..., this.refreshInterval * 1000), so the unit is seconds.form—TRUEto give the tile a filter dropdown, which then calls your_blockForm(). Omitted orFALSEmeans no filter icon. Use the boolean, not a truthy stand-in:overviewStatisticBlock.phtmltests$details['form'] === TRUEstrictly, whilegetBlockForm()tests it loosely (@$details['form']), so1or'yes'gives you a reachable endpoint with no icon to reach it from.
Returning an empty array is the supported way to hide a tile conditionally. applications/core/extensions/core/OverviewStatistics/Tags.php:61 does exactly this when the tags system is switched off:
return Settings::i()->tags_enabled ? array( 'app' => 'core', 'title' => 'stats_overview_toptags', 'description' => null, 'refresh' => 60 ) : [];
What $dateRange contains
The docblock on the abstract describes three shapes, and all three really occur:
NULL— all time. This is what you get when the admin picks "All Time", or when the request carries norangeat all. It is not the default: thepredateselect is created with a default of'7'(applications/core/modules/admin/stats/overview.php:91), and the JS sends that value verbatim, so a fresh page load gives you the string'7', notNULL.- A string of days:
'7','30','90','180'or'365', taken straight fromRequest::i()->range. Pass it tostatic::getInterval()to get aDateInterval. The "All Time" option in the form submitsrange=0(ips.stats.liveDateFilter.js:111copies thepredateselect's value), and because PHP evaluates the string'0'as falsy, theif( Request::i()->range )gate inloadBlock()and ingetBlockForm()skips it — so "All Time" reaches you asNULL, not as'0'. You should still guard against aNULLinterval if you ever callgetInterval()with a value you did not get from that form, since it returnsNULLfor anything outside the five listed keys. - An array with
startandend, bothIPS\DateTimeobjects, built inapplications/core/modules/admin/stats/overview.php:143-146from the custom date picker.
The CSV export path builds the array form only. mycharts::_getDateRange() always returns [ 'start' => DateTime, 'end' => DateTime ], so if your code only handles the string form your numbers will silently be wrong in the download.
Who calls it
Four consumers, all in the AdminCP. All of them resolve extensions through Application, which keys every extension as {appDirectory}_{ExtensionName} (system/Application/Application.php:419). The two dashboards and the two AJAX endpoints pass the sort arguments — Application::allExtensions( 'core', 'OverviewStatistics', TRUE, 'core', 'Registrations' ) — but Chart::getSavedBlocks() calls Application::allExtensions( 'core', 'OverviewStatistics', TRUE ) with no sort, and mycharts::saveBlock() uses the per-app $application->extensions( 'core', 'OverviewStatistics', true ) instead. Do not rely on a fixed ordering.
applications/core/modules/admin/stats/overview.php:105(manage()) renders the User Activity page shell. The templateapplications/core/dev/html/admin/stats/overview.phtml:10-12filters on$block->page == 'user', then callsgetBlocks()andgetBlockDetails()for each sub-block. It never callsgetBlock().applications/core/modules/admin/activitystats/overview.php:60does the same for$block->page == 'activity'. That class isclass overview extends StatsOverview(line 35), so it inherits everything below. Its template additionally calls\IPS\Application::load( $details['app'] )to build the app filter checkboxes.applications/core/modules/admin/stats/overview.php:116(loadBlock()) is the AJAX endpoint that actually callsgetBlock( $dateFilters, Request::i()->subblock )at line 155 and writes the return value straight to the response withOutput::i()->sendOutput(). The User Activity page and the Saved Reports page point atmodule=stats&controller=overview&do=loadBlock; Content Activity points atmodule=activitystats&controller=overview&do=loadBlock, which is the same inherited method.loadBlockForm()at line 163 is the sibling endpoint for the filter dropdown; it callsgetBlockForm()at line 173, which in turn calls your_blockForm(). The filter icon always links to themodule=statscopy of that route.applications/core/sources/Statistics/Chart.php:296-303(Chart::getSavedBlocks()) reads thecore_saved_overview_statstable, re-resolves each saved row to an extension, checks the saved sub-block is still ingetBlocks(), and callsgetBlockDetails(). Note that it then overwrites$details['title']with the admin's savedstat_title, so on Saved Reports your language key is not used.mycharts::_compileBlocks()at line 785 then callsgetBlockNumbers()(line 804) for the CSV download.
Registration is a file plus an entry in your app's data/extensions.json, under the core owner key. The forums app's entry is one line:
{
"core": {
"OverviewStatistics": {
"Solved": "IPS\\forums\\extensions\\core\\OverviewStatistics\\Solved"
}
}
}
Failure modes
Most of the ways this extension point goes wrong are silent. A few are fatal for the whole page, not just your tile.
The tile simply is not there, and nothing is logged
This is by far the most common outcome, and it has at least five separate causes that all look identical.
The class name in extensions.json does not resolve. Application::extensions() at system/Application/Application.php:926 reads:
if( !is_string( $classname ) or !class_exists( $classname ) )
{
/* Switching between branches confuses extensions */
continue;
}
A single-backslash namespace in the JSON, a filename that does not match the class, or a parse error in your file all end here. No exception, no log entry.
Your constructor threw. Application::constructExtensionClass() at system/Application/Application.php:478-499 wraps new $classToUse( ... ) in catch( RuntimeException | OutOfRangeException $e ){} and returns null. If your constructor does something like Application::load( 'someapp' ) or Settings::i()->whatever in a way that raises either of those, your extension is dropped from the array with no trace. Any other exception type from the constructor is not caught and will take the whole page down instead — see below.
$page is not exactly 'user' or 'activity'. The two templates test for those literals. A typo, or leaving the Developer Center default unchanged when you meant the other page, produces a tile that renders on neither dashboard. It will still appear on Saved Reports if an admin ever managed to pin it, because savedStatBlocks.phtml does not filter on page at all.
getBlockDetails() returned an empty array. Intentional for conditional tiles, but easy to hit by accident: Solved.php:67-80 uses a switch on $subBlock and falls through to return []; at line 80. If getBlocks() and getBlockDetails() ever disagree about the sub-block keys, the tile disappears.
The datastore is stale. Application::allExtensions() caches the entire extension list in Store::i()->extensions, keyed by extension type, and only rebuilds when the key is absent (system/Application/Application.php:359-443). Installing or upgrading an application calls Store::i()->clearAll(), so a normal install is fine; dropping files in by hand during development is not. The Developer Center's extensions screen clears it (applications/core/modules/admin/developer/extensions.php:313), as does clearing the system cache.
A related and less obvious consequence of that cache: the per-member access filter is applied while the list is being built and the filtered result is what gets stored. Lines 406-442 skip any application for which $application->canAccess( ... ) is false, then write the survivors to Store::i()->extensions. canAccess() (system/Application/Application.php:5299) returns TRUE early in the AdminCP only for admins holding the core/applications/app_manage restriction; otherwise it falls through to the app's front-end disabled_groups. So an app that is hidden from the admin's own member group can be excluded from the cached list. We have read this in the source but have not reproduced the cross-request effect on a live install, so treat the practical impact as unverified; the code path itself is not in doubt.
A missing 'title' key takes down the entire statistics page
The dashboard templates do this, unguarded:
{{$details['title'] = member.language()->addToStack( $details['title'] );}}
Lang::addToStack() is declared public function addToStack( string $key, ?bool $vle=TRUE, array $options=array() ): string (system/Lang/Lang.php:875). The parameter is a non-nullable string, so passing the NULL that an undefined array key evaluates to raises a TypeError. Nothing catches it. The admin sees a fatal error on the whole User Activity or Content Activity page, and because the message names core's template rather than your class, the natural first assumption is that core is broken.
The same shape of problem applies to app on the Content Activity page: activitystats/overview.phtml:13-14 reads {{if $details['app'] && !isset( $apps[ $details['app'] ] )}} and then calls \IPS\Application::load( $details['app'] ) with no try. The guard is a truthiness test only — it saves you if the key is missing or empty, but not if it is a non-empty string. So an app value naming an application that is not installed raises an uncaught OutOfRangeException and kills the page. Returning your own app's directory is safe; returning something you guessed is not.
Missing 'app' or 'refresh' fails quietly instead
Warnings do not become exceptions in IPS 5. IPS::errorHandler() at init.php:797-800 returns immediately for E_WARNING, E_NOTICE, E_STRICT and E_DEPRECATED. So an undefined refresh key renders as data-refresh='', the JavaScript at ips.stats.overviewBlock.js:28 sets refreshInterval to false, and the tile just never auto-updates. An undefined app key renders data-app='', which means the Content Activity page's app checkboxes cannot show or hide your tile — it stays visible when the admin unticks everything. Neither produces an error message anywhere.
Not implementing getBlockNumbers() prints an accusation in the CSV
The abstract's default implementation returns NULL (OverviewStatisticsAbstract.php:106-109). mycharts::_compileBlocks() handles that at line 801:
if ( is_callable( array( $block['extension'], 'getBlockNumbers' ) ) )
{
// Allow extensions to define their own numbers
$numbersFound = $block['extension']->getBlockNumbers( $dateRange, $block['subblock'] );
if ( !is_array( $numbersFound ) )
{
$numbersFound = [[ Member::loggedIn()->language()->get( "statsreports_legacy_block" ) ]];
}
}
Because the abstract always declares the method, is_callable() is always true and the else branch — which scrapes data-number attributes out of your rendered HTML — is unreachable for anything extending OverviewStatisticsAbstract. The language string it substitutes is defined in applications/core/dev/lang/stats.php:248:
"statsreports_legacy_block" => "This block cannot be downloaded. Contact the third party author for more information.",
So an admin who adds your tile to a report and downloads the CSV gets a column containing that sentence, with your tile's title above it. There is no error and no log entry; the first you hear of it is the support ticket. Implement getBlockNumbers() and return an array, even a trivial one.
The array shape is loose. Core convention is [ 'statsreports_current_count' => n, 'statsreports_previous_count' => n ], where the keys are language keys used as CSV column suffixes. String keys become named columns; integer keys are numbered "Title - (1)", "Title - (2)" and so on. Tags.php:85 returns a plain tag => count map instead, which also works.
Anything getBlock() throws is returned as the tile body
loadBlock() ends with Output::i()->sendOutput( $block->getBlock( $dateFilters, Request::i()->subblock ) ) and has no try. An exception from your getBlock() is handled by the global exception handler, which in the AdminCP shows the message. Because the tile is an AJAX fragment, the error HTML is injected into the small card, often clipped or unreadable. If your tile shows a truncated error box while the rest of the page is fine, look at uncaught_exception in the system log rather than at the page.
Also note that $subBlock arrives from Request::i()->subblock — it is user input on that route, not something core validates against your getBlocks(). Core's own implementations defend themselves; Solved.php:93 and ContentActivity.php:103 both begin with an in_array( $subBlock, ... ) check and return '' if it fails. Do the same, particularly if you interpolate the sub-block key into a query or use it as a class name.
A complete working example
applications/core/extensions/core/OverviewStatistics/Registrations.php is the smallest real implementation in the suite and the one core sorts first on the two dashboards (they pass it as the $firstExtensionKey argument; Saved Reports does not sort). It is reproduced here in full, minus the licence header.
<?php
namespace IPS\core\extensions\core\OverviewStatistics;
use IPS\DateTime;
use IPS\Db;
use IPS\Extensions\OverviewStatisticsAbstract;
use IPS\Theme;
use function defined;
use function is_array;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Registrations extends OverviewStatisticsAbstract
{
public string $page = 'user';
public function getBlocks(): array
{
return array( 'registrations' );
}
public function getBlockDetails( string $subBlock = NULL ): array
{
/* Description can be null and will not be shown if so */
return array( 'app' => 'core', 'title' => 'stats_overview_registrations', 'description' => null, 'refresh' => 30 );
}
public function getBlock( array|string $dateRange = NULL, string $subBlock = NULL ): string
{
$numbers = $this->getBlockNumbers( $dateRange, $subBlock );
return Theme::i()->getTemplate( 'stats' )->overviewComparisonCount( $numbers['statsreports_current_count'], $numbers['statsreports_previous_count'] );
}
public function getBlockNumbers( array|string $dateRange = NULL, string $subBlock=NULL ) : array
{
$where = NULL;
$previousCount = NULL;
if( $dateRange !== NULL )
{
if( is_array( $dateRange ) )
{
$where = array(
array( 'joined > ?', $dateRange['start']->getTimestamp() ),
array( 'joined < ?', $dateRange['end']->getTimestamp() ),
);
}
else
{
$currentDate = new DateTime;
$interval = static::getInterval( $dateRange );
$initialTimestamp = $currentDate->sub( $interval )->getTimestamp();
$where = array( array( 'joined > ?', $initialTimestamp ) );
$previousCount = Db::i()->select( 'COUNT(*)', 'core_members', array( array( 'joined BETWEEN ? AND ?', $currentDate->sub( $interval )->getTimestamp(), $initialTimestamp ) ) )->first();
}
}
$count = Db::i()->select( 'COUNT(*)', 'core_members', $where )->first();
return [
'statsreports_current_count' => $count,
'statsreports_previous_count' => $previousCount,
];
}
}
Two details worth copying. First, getBlock() delegates to getBlockNumbers() rather than duplicating the query, which is what keeps the CSV and the tile in agreement — Solved.php does duplicate it, and its own docblock at line 184 apologises for that. Second, overviewComparisonCount is a core template (applications/core/dev/html/admin/stats/overviewComparisonCount.phtml, parameters $count, $previousCount=NULL, $chart=FALSE) that draws the large number with an up/down delta. overviewCount takes a single $count; overviewTable takes $values, $previousValues=array() and draws a two-column table. From a third-party app, fetch them explicitly: Theme::i()->getTemplate( 'stats', 'core', 'admin' ), which is what the Developer Center stub (applications/core/data/defaults/extensions/OverviewStatistics.txt) generates. The bare getTemplate( 'stats' ) in the example above works because Theme::getTemplate() (system/Theme/Theme.php:871) defaults $app to Dispatcher::i()->application->directory and $location to Dispatcher::i()->controllerLocation — not from the calling class. Since every route that reaches this extension point is app=core in the admin location, a bare call from your app would also land on core's admin stats group; the argument for being explicit is that it is the only way to reach your own templates, and the only form that does not silently depend on the dispatcher.
Your title and description keys must exist in your app's dev/lang.php. If they do not, Lang::replaceWords() falls back to printing the key itself, so the tile heading reads stats_overview_myapp_thing in plain text.
Adding a filter dropdown
Set 'form' => TRUE in getBlockDetails() and implement _blockForm(). Core builds the Form object, submits it back to do=loadBlockForm, and on submission calls your getBlock() again (OverviewStatisticsAbstract.php:257) with the filter values still present in Request::i(). The extra "save these filters as the default" button is only added when Request::i()->saved_block_id is set, i.e. on Saved Reports — on the two dashboards there is no saved row, so getSavedBlockFilters() returns [] and the $values argument you receive is empty. You never read the form values from $form->values() yourself; you read them from the request inside getBlock(). Solved.php:314 is the whole implementation:
protected function _blockForm( Form &$form, ?string $subBlock = null, array $values=[] ) : void
{
/* Make sure someone isn't trying to manipulate the request or do something weird */
if( !in_array( $subBlock, $this->getBlocks() ) )
{
throw new DomainException;
}
$default = @$values[Forum::$nodeTitle] ? explode( ',', $values[Forum::$nodeTitle] ) : NULL;
$form->add( new Node( Forum::$nodeTitle, $default, false, array( 'class' => '\IPS\forums\Forum', 'multiple' => TRUE, 'clubs' => FALSE ) ) );
}
Throwing DomainException is the documented way to refuse: getBlockForm() catches it and returns null, which means the dropdown opens empty. The abstract's own comment at line 192 is explicit that any other exception is deliberately left uncaught — "there is an intentional possible uncaught ex. to adhere to fail fast; it's an implementation issue in that case". The $values array holds raw request parameters previously saved to core_saved_overview_stats.stat_filters, not the formatted values $form->values() would give you, which is why Solved has to explode() a comma-separated string back into an array.
Verified against
Read from Invision Community 5.0.19 source (applications/core/data/versions.json, last entry 5001908 => "5.0.19"). Files inspected: system/Extensions/OverviewStatisticsAbstract.php; all fifteen core implementations under applications/core/extensions/core/OverviewStatistics/; applications/forums/extensions/core/OverviewStatistics/Solved.php, applications/blog/extensions/core/OverviewStatistics/Blogs.php, applications/downloads/extensions/core/OverviewStatistics/Downloads.php, applications/nexus/extensions/core/OverviewStatistics/Products.php and Subscriptions.php; the consumers applications/core/modules/admin/stats/overview.php, applications/core/modules/admin/activitystats/overview.php, applications/core/modules/admin/overview/mycharts.php and applications/core/sources/Statistics/Chart.php; Application::allExtensions(), Application::extensions(), Application::constructExtensionClass() and Application::canAccess() in system/Application/Application.php; IPS::errorHandler() in init.php; system/Lang/Lang.php; the templates stats/overview.phtml, activitystats/overview.phtml, reports/overviewStatisticBlock.phtml and reports/savedStatBlocks.phtml; the controller ips.stats.overviewBlock.js; the stub at applications/core/data/defaults/extensions/OverviewStatistics.txt; and applications/core/data/acpmenu.json. cms, calendar and gallery ship no OverviewStatistics extension.
Recommended Comments