diff --git a/README.md b/README.md index bd3b1faf..4fc88e20 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,13 @@ https:///ocs/v2.php/apps/serverinfo/api/v1/info - Use the url parameter `skipApps=true` to omit app updates (including available app updates will send an external request to the app store). - Use the url parameter `skipOs=false` to include the operating system status. +The values that come from database queries (`nextcloud.storage`, +`nextcloud.shares`, `server.database` and `activeUsers`) are cached for five +minutes by default, so they can be up to that old; see +[Caching of the database statistics](#caching-of-the-database-statistics). +CPU, memory, PHP, PHP-FPM and the web server are read on every request; app +and server updates and the operating system status are not part of this cache. + The operating system status in `server.os` holds counts and flags only, never package names, package or kernel versions, or advisories, so a leaked token does not reveal what is unpatched. Values that could not be determined are @@ -293,6 +300,22 @@ php occ serverinfo:update-storage-statistics -v --output=json_pretty } ``` +##### Caching of the database statistics + +The storage and user counts, share counts, active users and the database type, +version and size take about 25 database queries, on MySQL including +`SHOW TABLE STATUS` and on PostgreSQL `pg_database_size()`. The API and the +admin page share one copy of them in the distributed memory cache, so a +monitoring system that polls every few seconds runs those queries at most once +per cache period. The values can therefore be up to that period old. + +The period is 300 seconds by default and at most 3600; 0 turns the cache off: + +``php occ config:app:set serverinfo statistics_cache_ttl --type=integer --value=60`` + +Without a memory cache (`memcache.local` or `memcache.distributed` in +`config.php`) nothing is cached. + ##### Restricted mode (>= Nextcloud 28) To obtain information about your server, the serverinfo app reads files outside the application directory (e.g. /proc on Linux) or executes shell commands (e.g. df on Linux). diff --git a/lib/Config/ConfigLexicon.php b/lib/Config/ConfigLexicon.php index 2a181d1c..be2dfc5c 100644 --- a/lib/Config/ConfigLexicon.php +++ b/lib/Config/ConfigLexicon.php @@ -28,6 +28,8 @@ class ConfigLexicon implements ILexicon { public const CACHED_DB_CHECK = 'cached_db_check'; + public const STATISTICS_CACHE_TTL = 'statistics_cache_ttl'; + public const CACHED_OS_FIRST_SEEN = 'cached_os_first_seen'; public const CACHED_OS_PHP_PROCESS = 'cached_os_php_process'; public const CACHED_OS_PHP_OUTDATED_SINCE = 'cached_os_php_outdated_since'; @@ -89,6 +91,12 @@ public function getAppConfigs(): array { 'Outcome of the last database check run, read by the setup check on Settings > Overview', lazy: true, ), + new Entry( + self::STATISTICS_CACHE_TTL, + ValueType::INT, + 300, + 'How long the database statistics of the monitoring API and the admin page are cached, in seconds; 0 turns the cache off, at most 3600', + ), new Entry( self::CACHED_OS_FIRST_SEEN, ValueType::ARRAY, diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 568b6de5..9d22c94e 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -9,14 +9,11 @@ namespace OCA\ServerInfo\Controller; -use OCA\ServerInfo\DatabaseStatistics; use OCA\ServerInfo\FpmStatistics; use OCA\ServerInfo\Os; use OCA\ServerInfo\OsStatus\OsStatusService; use OCA\ServerInfo\PhpStatistics; -use OCA\ServerInfo\SessionStatistics; -use OCA\ServerInfo\ShareStatistics; -use OCA\ServerInfo\StorageStatistics; +use OCA\ServerInfo\StatisticsCache; use OCA\ServerInfo\SystemStatistics; use OCA\ServerInfo\UptimeFormatter; use OCP\AppFramework\Http; @@ -36,12 +33,9 @@ public function __construct( private ?IUserSession $userSession, private Os $os, private SystemStatistics $systemStatistics, - private StorageStatistics $storageStatistics, + private StatisticsCache $statisticsCache, private PhpStatistics $phpStatistics, private FpmStatistics $fpmStatistics, - private DatabaseStatistics $databaseStatistics, - private ShareStatistics $shareStatistics, - private SessionStatistics $sessionStatistics, private UptimeFormatter $uptimeFormatter, private OsStatusService $osStatus, ) { @@ -88,19 +82,20 @@ public function info(bool $skipApps = true, bool $skipUpdate = true, bool $skipO $response->setStatus(Http::STATUS_UNAUTHORIZED); return $response; } + $statistics = $this->statisticsCache->get(); $data = [ 'nextcloud' => [ 'system' => $this->systemStatistics->getSystemStatistics($skipApps, $skipUpdate), - 'storage' => $this->storageStatistics->getStorageStatistics(), - 'shares' => $this->shareStatistics->getShareStatistics() + 'storage' => $statistics['storage'], + 'shares' => $statistics['shares'] ], 'server' => [ 'webserver' => $this->getWebserver(), 'php' => $this->phpStatistics->getPhpStatistics(), 'fpm' => $this->fpmStatistics->getFpmStatistics(), - 'database' => $this->databaseStatistics->getDatabaseStatistics() + 'database' => $statistics['database'] ], - 'activeUsers' => $this->sessionStatistics->getSessionStatistics() + 'activeUsers' => $statistics['activeUsers'] ]; if (!$skipOs) { $data['server']['os'] = $this->osStatus->monitoringSummary(); diff --git a/lib/StaticData.php b/lib/StaticData.php index 6e869b72..2aba405d 100644 --- a/lib/StaticData.php +++ b/lib/StaticData.php @@ -19,13 +19,10 @@ class StaticData { public function __construct( private Os $os, private IURLGenerator $urlGenerator, - private StorageStatistics $storageStatistics, + private StatisticsCache $statisticsCache, private Php $php, private Opcache $opcache, private FpmStatistics $fpmStatistics, - private DatabaseStatistics $databaseStatistics, - private ShareStatistics $shareStatistics, - private SessionStatistics $sessionStatistics, private SystemStatistics $systemStatistics, private IAppConfig $appConfig, private OsStatusService $osStatus, @@ -36,6 +33,7 @@ public function getData(): array { $diskinfo = $this->os->getDiskInfo(); $interfaces = $this->os->getNetworkInterfaces(); $memory = $this->os->getMemory(); + $statistics = $this->statisticsCache->get(); return [ 'hostname' => $this->os->getHostname(), @@ -62,13 +60,13 @@ public function getData(): array { 'loopback' => $i->isLoopback(), ], $interfaces), 'ocs' => $this->urlGenerator->getAbsoluteURL('ocs/v2.php/apps/serverinfo/api/v1/info'), - 'storage' => $this->storageStatistics->getStorageStatistics(), - 'shares' => $this->shareStatistics->getShareStatistics(), + 'storage' => $statistics['storage'], + 'shares' => $statistics['shares'], 'php' => $this->php->getData(), 'opcache' => $this->opcache->getData(), 'fpm' => $this->fpmStatistics->getFpmStatistics(), - 'database' => $this->databaseStatistics->getDatabaseStatistics(), - 'activeUsers' => $this->sessionStatistics->getSessionStatistics(), + 'database' => $statistics['database'], + 'activeUsers' => $statistics['activeUsers'], 'freeSpace' => $this->systemStatistics->getFreeSpace(), 'memTotal' => $memory->getMemTotal(), 'phpinfo' => $this->appConfig->getAppValueBool('phpinfo', false), diff --git a/lib/StatisticsCache.php b/lib/StatisticsCache.php new file mode 100644 index 00000000..7c38af35 --- /dev/null +++ b/lib/StatisticsCache.php @@ -0,0 +1,117 @@ +cache = $cacheFactory->createDistributed('serverinfo'); + } + + /** + * The statistics, at most ttl() seconds old. + * + * @return StatisticsBlock + */ + public function get(): array { + $ttl = $this->ttl(); + if ($ttl === 0) { + return $this->compute(); + } + + $cached = $this->decode($this->cache->get(self::CACHE_KEY)); + if ($cached !== null) { + return $cached; + } + + $statistics = $this->compute(); + try { + // Encoded here so that floats keep their type on every cache backend + $json = json_encode($statistics, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); + $this->cache->set(self::CACHE_KEY, $json, $ttl); + } catch (\JsonException) { + // Served uncached: invalid UTF-8, for example in a database version string + } + return $statistics; + } + + /** + * How long the statistics are cached, in seconds; 0 when caching is off. + */ + public function ttl(): int { + $ttl = $this->appConfig->getAppValueInt(ConfigLexicon::STATISTICS_CACHE_TTL); + return max(0, min(self::MAX_TTL, $ttl)); + } + + /** + * @return StatisticsBlock + */ + private function compute(): array { + return [ + 'storage' => $this->storageStatistics->getStorageStatistics(), + 'shares' => $this->shareStatistics->getShareStatistics(), + 'database' => $this->databaseStatistics->getDatabaseStatistics(), + 'activeUsers' => $this->sessionStatistics->getSessionStatistics(), + ]; + } + + /** + * @return StatisticsBlock|null + */ + private function decode(mixed $json): ?array { + if (!is_string($json)) { + return null; + } + try { + $cached = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + if (!is_array($cached)) { + return null; + } + foreach (self::BLOCKS as $block) { + if (!isset($cached[$block]) || !is_array($cached[$block])) { + return null; + } + } + /** @var StatisticsBlock $cached */ + return $cached; + } +} diff --git a/tests/lib/ApiControllerTest.php b/tests/lib/ApiControllerTest.php index 7635d2ae..f4b78b8d 100644 --- a/tests/lib/ApiControllerTest.php +++ b/tests/lib/ApiControllerTest.php @@ -10,14 +10,11 @@ namespace OCA\ServerInfo\Tests; use OCA\ServerInfo\Controller\ApiController; -use OCA\ServerInfo\DatabaseStatistics; use OCA\ServerInfo\FpmStatistics; use OCA\ServerInfo\Os; use OCA\ServerInfo\OsStatus\OsStatusService; use OCA\ServerInfo\PhpStatistics; -use OCA\ServerInfo\SessionStatistics; -use OCA\ServerInfo\ShareStatistics; -use OCA\ServerInfo\StorageStatistics; +use OCA\ServerInfo\StatisticsCache; use OCA\ServerInfo\SystemStatistics; use OCA\ServerInfo\UptimeFormatter; use OCP\AppFramework\Http; @@ -34,12 +31,9 @@ class ApiControllerTest extends \Test\TestCase { private IConfig&MockObject $config; private IGroupManager&MockObject $groupManager; private SystemStatistics&MockObject $systemStatistics; - private StorageStatistics&MockObject $storageStatistics; + private StatisticsCache&MockObject $statisticsCache; private PhpStatistics&MockObject $phpStatistics; private FpmStatistics&MockObject $fpmStatistics; - private DatabaseStatistics&MockObject $databaseStatistics; - private ShareStatistics&MockObject $shareStatistics; - private SessionStatistics&MockObject $sessionStatistics; private UptimeFormatter&MockObject $uptimeFormatter; private OsStatusService&MockObject $osStatus; @@ -51,12 +45,15 @@ protected function setUp(): void { $this->groupManager = $this->createMock(IGroupManager::class); $this->os = $this->createMock(Os::class); $this->systemStatistics = $this->createMock(SystemStatistics::class); - $this->storageStatistics = $this->createMock(StorageStatistics::class); + $this->statisticsCache = $this->createMock(StatisticsCache::class); + $this->statisticsCache->method('get')->willReturn([ + 'storage' => [], + 'shares' => [], + 'database' => [], + 'activeUsers' => [], + ]); $this->phpStatistics = $this->createMock(PhpStatistics::class); $this->fpmStatistics = $this->createMock(FpmStatistics::class); - $this->databaseStatistics = $this->createMock(DatabaseStatistics::class); - $this->shareStatistics = $this->createMock(ShareStatistics::class); - $this->sessionStatistics = $this->createMock(SessionStatistics::class); $this->uptimeFormatter = $this->createMock(UptimeFormatter::class); $this->osStatus = $this->createMock(OsStatusService::class); } @@ -70,18 +67,17 @@ private function getController($userSession) { $userSession, $this->os, $this->systemStatistics, - $this->storageStatistics, + $this->statisticsCache, $this->phpStatistics, $this->fpmStatistics, - $this->databaseStatistics, - $this->shareStatistics, - $this->sessionStatistics, $this->uptimeFormatter, $this->osStatus, ); } public function testAuthFailureNoSession() { + $this->statisticsCache->expects($this->never())->method('get'); + $response = $this->getController(null)->info(); $this->assertEquals(['message' => 'Unauthorized'], $response->getData()); @@ -188,24 +184,21 @@ public function testInfo() { $this->systemStatistics->expects($this->once()) ->method('getSystemStatistics') ->willReturn(['some_system' => 'some_value']); - $this->storageStatistics->expects($this->once()) - ->method('getStorageStatistics') - ->willReturn(['some_storage' => 'some_value']); - $this->shareStatistics->expects($this->once()) - ->method('getShareStatistics') - ->willReturn(['some_shares' => 'some_value']); $this->phpStatistics->expects($this->once()) ->method('getPhpStatistics') ->willReturn(['some_php' => 'some_value']); $this->fpmStatistics->expects($this->once()) ->method('getFpmStatistics') ->willReturn(['some_fpm' => 'some_value']); - $this->databaseStatistics->expects($this->once()) - ->method('getDatabaseStatistics') - ->willReturn(['some_database' => 'some_value']); - $this->sessionStatistics->expects($this->once()) - ->method('getSessionStatistics') - ->willReturn(['some_user' => 'some_value']); + $this->statisticsCache = $this->createMock(StatisticsCache::class); + $this->statisticsCache->expects($this->once()) + ->method('get') + ->willReturn([ + 'storage' => ['some_storage' => 'some_value'], + 'shares' => ['some_shares' => 'some_value'], + 'database' => ['some_database' => 'some_value'], + 'activeUsers' => ['some_user' => 'some_value'], + ]); $this->osStatus->expects($this->never())->method('monitoringSummary'); $response = $this->getController($userSession)->info(); diff --git a/tests/lib/StatisticsCacheDatabaseTest.php b/tests/lib/StatisticsCacheDatabaseTest.php new file mode 100644 index 00000000..9926ade5 --- /dev/null +++ b/tests/lib/StatisticsCacheDatabaseTest.php @@ -0,0 +1,71 @@ +createMock(ICache::class); + $cache->method('get') + ->willReturnCallback(function (string $key) use (&$stored) { + return isset($stored[$key]) ? json_decode($stored[$key], true) : null; + }); + $cache->expects($this->once()) + ->method('set') + ->willReturnCallback(function (string $key, mixed $value) use (&$stored): bool { + $stored[$key] = json_encode($value, JSON_THROW_ON_ERROR); + return true; + }); + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn($cache); + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getAppValueInt')->willReturn(300); + + $statisticsCache = new StatisticsCache( + $storageStatistics, + $shareStatistics, + $sessionStatistics, + $databaseStatistics, + $appConfig, + $cacheFactory, + ); + + $computed = $statisticsCache->get(); + $cached = $statisticsCache->get(); + + $this->assertSame([ + 'storage' => $storageStatistics->getStorageStatistics(), + 'shares' => $shareStatistics->getShareStatistics(), + 'database' => $databaseStatistics->getDatabaseStatistics(), + 'activeUsers' => $sessionStatistics->getSessionStatistics(), + ], $computed); + $this->assertSame($computed, $cached); + } +} diff --git a/tests/lib/StatisticsCacheTest.php b/tests/lib/StatisticsCacheTest.php new file mode 100644 index 00000000..a1fc2a29 --- /dev/null +++ b/tests/lib/StatisticsCacheTest.php @@ -0,0 +1,246 @@ + 7, 'num_files' => 708860, 'size_appdata_storage' => 1024.0]; + private const SHARES = ['num_shares' => 1, 'permissions_3_1' => '1']; + private const DATABASE = ['type' => 'pgsql', 'version' => '16.4', 'size' => 331382784]; + private const ACTIVE_USERS = ['last5minutes' => 2, 'last1hour' => 3]; + + private StorageStatistics&MockObject $storageStatistics; + private ShareStatistics&MockObject $shareStatistics; + private SessionStatistics&MockObject $sessionStatistics; + private DatabaseStatistics&MockObject $databaseStatistics; + private IAppConfig&MockObject $appConfig; + private ICache&MockObject $cache; + private ICacheFactory&MockObject $cacheFactory; + /** @var array */ + private array $stored = []; + /** @var list */ + private array $storedTtls = []; + + protected function setUp(): void { + parent::setUp(); + + $this->storageStatistics = $this->createMock(StorageStatistics::class); + $this->storageStatistics->method('getStorageStatistics')->willReturn(self::STORAGE); + $this->shareStatistics = $this->createMock(ShareStatistics::class); + $this->shareStatistics->method('getShareStatistics')->willReturn(self::SHARES); + $this->sessionStatistics = $this->createMock(SessionStatistics::class); + $this->sessionStatistics->method('getSessionStatistics')->willReturn(self::ACTIVE_USERS); + $this->databaseStatistics = $this->createMock(DatabaseStatistics::class); + $this->databaseStatistics->method('getDatabaseStatistics')->willReturn(self::DATABASE); + $this->appConfig = $this->createMock(IAppConfig::class); + + // Stores values as JSON, the way the Redis cache does. + $this->cache = $this->createMock(ICache::class); + $this->cache->method('get') + ->willReturnCallback(fn (string $key) => isset($this->stored[$key]) ? json_decode($this->stored[$key], true) : null); + $this->cache->method('set') + ->willReturnCallback(function (string $key, mixed $value, int $ttl = 0): bool { + $this->stored[$key] = json_encode($value, JSON_THROW_ON_ERROR); + $this->storedTtls[] = $ttl; + return true; + }); + $this->cacheFactory = $this->createMock(ICacheFactory::class); + $this->cacheFactory->method('createDistributed')->with('serverinfo')->willReturn($this->cache); + } + + private function statisticsCache(int $ttl = 300): StatisticsCache { + $this->appConfig->method('getAppValueInt') + ->with(ConfigLexicon::STATISTICS_CACHE_TTL) + ->willReturn($ttl); + + return new StatisticsCache( + $this->storageStatistics, + $this->shareStatistics, + $this->sessionStatistics, + $this->databaseStatistics, + $this->appConfig, + $this->cacheFactory, + ); + } + + private function expected(): array { + return [ + 'storage' => self::STORAGE, + 'shares' => self::SHARES, + 'database' => self::DATABASE, + 'activeUsers' => self::ACTIVE_USERS, + ]; + } + + public function testMissComputesAndStoresForTheTtl(): void { + $this->storageStatistics->expects($this->once())->method('getStorageStatistics'); + $this->shareStatistics->expects($this->once())->method('getShareStatistics'); + $this->sessionStatistics->expects($this->once())->method('getSessionStatistics'); + $this->databaseStatistics->expects($this->once())->method('getDatabaseStatistics'); + + $this->assertSame($this->expected(), $this->statisticsCache(120)->get()); + $this->assertSame([120], $this->storedTtls); + } + + public function testHitServesTheSameValuesWithoutQuerying(): void { + $this->storageStatistics->expects($this->once())->method('getStorageStatistics'); + $this->shareStatistics->expects($this->once())->method('getShareStatistics'); + $this->sessionStatistics->expects($this->once())->method('getSessionStatistics'); + $this->databaseStatistics->expects($this->once())->method('getDatabaseStatistics'); + $statisticsCache = $this->statisticsCache(); + + $first = $statisticsCache->get(); + $second = $statisticsCache->get(); + + $this->assertSame($first, $second); + $this->assertSame($this->expected(), $second); + } + + public function testZeroTtlTurnsTheCacheOff(): void { + $this->storageStatistics->expects($this->exactly(2))->method('getStorageStatistics'); + $this->databaseStatistics->expects($this->exactly(2))->method('getDatabaseStatistics'); + $this->cache->expects($this->never())->method('get'); + $this->cache->expects($this->never())->method('set'); + $statisticsCache = $this->statisticsCache(0); + + $this->assertSame($this->expected(), $statisticsCache->get()); + $this->assertSame($this->expected(), $statisticsCache->get()); + } + + public static function dataTtl(): array { + return [ + 'default' => [300, 300], + 'off' => [0, 0], + 'negative' => [-5, 0], + 'longest' => [StatisticsCache::MAX_TTL, StatisticsCache::MAX_TTL], + 'too long' => [86400, StatisticsCache::MAX_TTL], + ]; + } + + #[DataProvider('dataTtl')] + public function testTtlIsClamped(int $configured, int $expected): void { + $this->assertSame($expected, $this->statisticsCache($configured)->ttl()); + } + + public function testNegativeTtlTurnsTheCacheOff(): void { + $this->cache->expects($this->never())->method('set'); + + $this->assertSame($this->expected(), $this->statisticsCache(-1)->get()); + } + + public static function dataInvalidEntry(): array { + $complete = ['storage' => [], 'shares' => [], 'database' => [], 'activeUsers' => []]; + return [ + 'not a string' => [$complete], + 'not JSON' => ['{"storage":'], + 'not an object' => ['"stale"'], + 'block missing' => [json_encode(['storage' => [], 'shares' => [], 'database' => []])], + 'block not an array' => [json_encode(['activeUsers' => 3] + $complete)], + ]; + } + + #[DataProvider('dataInvalidEntry')] + public function testInvalidEntryIsRecomputed(mixed $entry): void { + $this->stored['statistics'] = json_encode($entry, JSON_THROW_ON_ERROR); + $this->storageStatistics->expects($this->once())->method('getStorageStatistics'); + + $this->assertSame($this->expected(), $this->statisticsCache()->get()); + $this->assertSame($this->expected(), json_decode(json_decode($this->stored['statistics'], true), true)); + } + + public function testUnencodableStatisticsAreServedUncached(): void { + $database = ['type' => 'mysql', 'version' => "\xB1\x31", 'size' => 1]; + $this->databaseStatistics = $this->createMock(DatabaseStatistics::class); + $this->databaseStatistics->expects($this->exactly(2)) + ->method('getDatabaseStatistics') + ->willReturn($database); + $this->cache->expects($this->never())->method('set'); + $statisticsCache = $this->statisticsCache(); + + $this->assertSame($database, $statisticsCache->get()['database']); + $this->assertSame($database, $statisticsCache->get()['database']); + } + + public function testApiAndPageShareOneEntry(): void { + $this->storageStatistics->expects($this->once())->method('getStorageStatistics'); + $this->shareStatistics->expects($this->once())->method('getShareStatistics'); + $this->sessionStatistics->expects($this->once())->method('getSessionStatistics'); + $this->databaseStatistics->expects($this->once())->method('getDatabaseStatistics'); + $statisticsCache = $this->statisticsCache(); + + $request = $this->createMock(IRequest::class); + $request->method('getHeader')->with('NC-Token')->willReturn('token'); + $config = $this->createMock(IConfig::class); + $config->method('getAppValue')->with('serverinfo', 'token', '')->willReturn('token'); + $api = new ApiController( + 'serverinfo', + $request, + $config, + $this->createMock(IGroupManager::class), + null, + $this->createMock(Os::class), + $this->createMock(SystemStatistics::class), + $statisticsCache, + $this->createMock(PhpStatistics::class), + $this->createMock(FpmStatistics::class), + $this->createMock(UptimeFormatter::class), + $this->createMock(OsStatusService::class), + ); + $page = new StaticData( + $this->createMock(Os::class), + $this->createMock(IURLGenerator::class), + $statisticsCache, + $this->createMock(Php::class), + $this->createMock(Opcache::class), + $this->createMock(FpmStatistics::class), + $this->createMock(SystemStatistics::class), + $this->createMock(IAppConfig::class), + $this->createMock(OsStatusService::class), + ); + + $info = $api->info()->getData(); + $data = $page->getData(); + + $this->assertSame(self::STORAGE, $info['nextcloud']['storage']); + $this->assertSame(self::SHARES, $info['nextcloud']['shares']); + $this->assertSame(self::DATABASE, $info['server']['database']); + $this->assertSame(self::ACTIVE_USERS, $info['activeUsers']); + $this->assertSame(self::STORAGE, $data['storage']); + $this->assertSame(self::SHARES, $data['shares']); + $this->assertSame(self::DATABASE, $data['database']); + $this->assertSame(self::ACTIVE_USERS, $data['activeUsers']); + } +}