Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ https://<nextcloud-fqdn>/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
Expand Down Expand Up @@ -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).
Expand Down
8 changes: 8 additions & 0 deletions lib/Config/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 7 additions & 12 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
) {
Expand Down Expand Up @@ -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();
Expand Down
14 changes: 6 additions & 8 deletions lib/StaticData.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
Expand All @@ -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),
Expand Down
117 changes: 117 additions & 0 deletions lib/StatisticsCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\ServerInfo;

use OCA\ServerInfo\Config\ConfigLexicon;
use OCP\AppFramework\Services\IAppConfig;
use OCP\ICache;
use OCP\ICacheFactory;

/**
* The statistics that come from database queries: storage and user counts,
* shares, active users and the database itself. They change on a scale of
* minutes but cost about 25 queries, so the monitoring API and the admin page
* share one entry in the distributed cache instead of each running them on
* every request.
*
* @psalm-type StatisticsBlock = array{storage: array, shares: array, database: array, activeUsers: array}
* @psalm-api
*/
class StatisticsCache {
/** Longest time the statistics may be served from the cache, in seconds */
public const MAX_TTL = 3600;

private const CACHE_KEY = 'statistics';
private const BLOCKS = ['storage', 'shares', 'database', 'activeUsers'];

private ICache $cache;

public function __construct(
private StorageStatistics $storageStatistics,
private ShareStatistics $shareStatistics,
private SessionStatistics $sessionStatistics,
private DatabaseStatistics $databaseStatistics,
private IAppConfig $appConfig,
ICacheFactory $cacheFactory,
) {
$this->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;
}
}
49 changes: 21 additions & 28 deletions tests/lib/ApiControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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);
}
Expand All @@ -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());
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading