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
116 changes: 116 additions & 0 deletions lib/CachingInfo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?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 OCP\IConfig;

class CachingInfo {

Check failure on line 14 in lib/CachingInfo.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UnusedClass

lib/CachingInfo.php:14:7: UnusedClass: Class OCA\ServerInfo\CachingInfo is never used (see https://psalm.dev/075)
public function __construct(
private IConfig $config,
) {
}

/**
* @return array{
* opcache: array{enabled: bool, hits: int, misses: int, hitRate: float, memoryUsedMB: float, memoryFreeMB: float, cachedScripts: int},
* apcu: array{enabled: bool, hits: int, misses: int, hitRate: float, memoryUsedMB: float, memoryFreeMB: float},
* redis: array{configured: bool, distributed: string, locking: string},
* memcache: array{local: string, distributed: string, locking: string}
* }
*/
public function getCachingInfo(): array {
return [
'opcache' => $this->opcacheInfo(),
'apcu' => $this->apcuInfo(),
'redis' => $this->redisInfo(),
'memcache' => [
'local' => $this->shortClassName($this->config->getSystemValue('memcache.local', '')),
'distributed' => $this->shortClassName($this->config->getSystemValue('memcache.distributed', '')),
'locking' => $this->shortClassName($this->config->getSystemValue('memcache.locking', '')),
],
];
}

/**
* @return array{enabled: bool, hits: int, misses: int, hitRate: float, memoryUsedMB: float, memoryFreeMB: float, cachedScripts: int}
*/
private function opcacheInfo(): array {
if (!extension_loaded('Zend OPcache') || !function_exists('opcache_get_status')) {
return ['enabled' => false, 'hits' => 0, 'misses' => 0, 'hitRate' => 0.0, 'memoryUsedMB' => 0.0, 'memoryFreeMB' => 0.0, 'cachedScripts' => 0];
}
$status = @opcache_get_status(false);
if (!is_array($status)) {
return ['enabled' => false, 'hits' => 0, 'misses' => 0, 'hitRate' => 0.0, 'memoryUsedMB' => 0.0, 'memoryFreeMB' => 0.0, 'cachedScripts' => 0];
}
$stats = $status['opcache_statistics'] ?? [];
$mem = $status['memory_usage'] ?? [];
$hits = (int)($stats['hits'] ?? 0);
$misses = (int)($stats['misses'] ?? 0);
$total = $hits + $misses;
return [
'enabled' => (bool)($status['opcache_enabled'] ?? false),
'hits' => $hits,
'misses' => $misses,
'hitRate' => $total > 0 ? ($hits / $total) * 100 : 0.0,

Check failure on line 61 in lib/CachingInfo.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

InvalidOperand

lib/CachingInfo.php:61:30: InvalidOperand: Cannot process ints and floats in strict binary operands mode, please cast explicitly (see https://psalm.dev/058)
'memoryUsedMB' => isset($mem['used_memory']) ? round($mem['used_memory'] / (1024 * 1024), 2) : 0.0,
'memoryFreeMB' => isset($mem['free_memory']) ? round($mem['free_memory'] / (1024 * 1024), 2) : 0.0,
'cachedScripts' => (int)($stats['num_cached_scripts'] ?? 0),
];
}

/**
* @return array{enabled: bool, hits: int, misses: int, hitRate: float, memoryUsedMB: float, memoryFreeMB: float}
*/
private function apcuInfo(): array {
if (!extension_loaded('apcu') || !function_exists('apcu_cache_info')) {
return ['enabled' => false, 'hits' => 0, 'misses' => 0, 'hitRate' => 0.0, 'memoryUsedMB' => 0.0, 'memoryFreeMB' => 0.0];
}
$cache = @apcu_cache_info(true);
$sma = function_exists('apcu_sma_info') ? @apcu_sma_info(true) : false;
if (!is_array($cache)) {
return ['enabled' => false, 'hits' => 0, 'misses' => 0, 'hitRate' => 0.0, 'memoryUsedMB' => 0.0, 'memoryFreeMB' => 0.0];
}
$hits = (int)($cache['num_hits'] ?? 0);
$misses = (int)($cache['num_misses'] ?? 0);
$total = $hits + $misses;
$used = is_array($sma) && isset($sma['seg_size'], $sma['avail_mem']) ? (int)$sma['seg_size'] - (int)$sma['avail_mem'] : 0;
$free = is_array($sma) && isset($sma['avail_mem']) ? (int)$sma['avail_mem'] : 0;
return [
'enabled' => true,
'hits' => $hits,
'misses' => $misses,
'hitRate' => $total > 0 ? ($hits / $total) * 100 : 0.0,

Check failure on line 89 in lib/CachingInfo.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

InvalidOperand

lib/CachingInfo.php:89:30: InvalidOperand: Cannot process ints and floats in strict binary operands mode, please cast explicitly (see https://psalm.dev/058)
'memoryUsedMB' => round($used / (1024 * 1024), 2),
'memoryFreeMB' => round($free / (1024 * 1024), 2),
];
}

/**
* @return array{configured: bool, distributed: string, locking: string}
*/
private function redisInfo(): array {
$distributed = (string)$this->config->getSystemValue('memcache.distributed', '');
$locking = (string)$this->config->getSystemValue('memcache.locking', '');
$usingRedis = stripos($distributed, 'Redis') !== false || stripos($locking, 'Redis') !== false;
return [
'configured' => $usingRedis,
'distributed' => $this->shortClassName($distributed),
'locking' => $this->shortClassName($locking),
];
}

private function shortClassName(string $cls): string {
if ($cls === '') {
return '';
}
$parts = explode('\\', $cls);
return end($parts) ?: $cls;
}
}
99 changes: 99 additions & 0 deletions lib/DbHealth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?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 OCP\IConfig;
use OCP\IDBConnection;

class DbHealth {

Check failure on line 15 in lib/DbHealth.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UnusedClass

lib/DbHealth.php:15:7: UnusedClass: Class OCA\ServerInfo\DbHealth is never used (see https://psalm.dev/075)
public function __construct(
private IDBConnection $db,
private IConfig $config,
) {
}

/**
* @return array{
* driver: string,
* largestTables: list<array{name: string, rows: int, sizeBytes: int}>,
* available: bool
* }
*/
public function getDbHealth(): array {
$driver = (string)$this->config->getSystemValue('dbtype', 'sqlite');

try {
$tables = match ($driver) {
'mysql', 'mariadb' => $this->mysqlTables(),
'pgsql' => $this->pgTables(),
default => [],
};
} catch (\Throwable) {
$tables = [];
}

return [
'driver' => $driver,
'largestTables' => $tables,
'available' => $tables !== [] || $driver === 'sqlite',
];
}

/**
* @return list<array{name: string, rows: int, sizeBytes: int}>
*/
private function mysqlTables(int $limit = 8): array {
$dbName = (string)$this->config->getSystemValue('dbname', '');
if ($dbName === '') {
return [];
}
$sql = 'SELECT table_name AS name, table_rows AS rows, '
. '(data_length + index_length) AS size_bytes '
. 'FROM information_schema.TABLES WHERE table_schema = ? '
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This definitely needs error handler because by default Nextcloud drops admin privilege by creating a dedicated user during setup.

Checking options …

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should work when mariadb allows seeing the objects the user has access to. The only remaining issue is that the information_schema access is notorious for rebuilding database metadata.

Accepting the risk for now to get this merged.

. 'ORDER BY size_bytes DESC LIMIT ' . (int)$limit;

Check failure on line 60 in lib/DbHealth.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

RedundantCast

lib/DbHealth.php:60:42: RedundantCast: Redundant cast to int (see https://psalm.dev/262)
$conn = $this->db;
$stmt = $conn->prepare($sql);
$stmt->bindValue(1, $dbName);
$result = $stmt->executeQuery();

Check failure on line 64 in lib/DbHealth.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedInterfaceMethod

lib/DbHealth.php:64:20: UndefinedInterfaceMethod: Method OCP\DB\IPreparedStatement::executeQuery does not exist (see https://psalm.dev/181)
$out = [];
while (($row = $result->fetch()) !== false) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer using fetchAssociative/fetchNumeric/fetchOne or iterateAssociate/iterateNumeric instead.

$out[] = [
'name' => (string)($row['name'] ?? ''),
'rows' => (int)($row['rows'] ?? 0),
'sizeBytes' => (int)($row['size_bytes'] ?? 0),
];
}
$result->closeCursor();
return $out;
}

/**
* @return list<array{name: string, rows: int, sizeBytes: int}>
*/
private function pgTables(int $limit = 8): array {
$sql = 'SELECT relname AS name, n_live_tup AS rows, '
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither pg_class or pg_namespace have a column "n_live_tup".

I'm going to check ...

Copy link
Copy Markdown
Collaborator

@kesselb kesselb May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SELECT
    relname as name,
    n_live_tup as rows,
    pg_total_relation_size(relid) AS size_bytes
FROM pg_stat_user_tables
ORDER BY size_bytes DESC
LIMIT 8;
Image

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pg_stat_user_tables

Should list all tables the current user has access too

. 'pg_total_relation_size(C.oid) AS size_bytes '
. 'FROM pg_class C '
. 'LEFT JOIN pg_namespace N ON N.oid = C.relnamespace '
. "WHERE relkind = 'r' AND nspname NOT IN ('pg_catalog', 'information_schema') "
. 'ORDER BY size_bytes DESC LIMIT ' . (int)$limit;

Check failure on line 86 in lib/DbHealth.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

RedundantCast

lib/DbHealth.php:86:42: RedundantCast: Redundant cast to int (see https://psalm.dev/262)
$result = $this->db->prepare($sql)->executeQuery();

Check failure on line 87 in lib/DbHealth.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedInterfaceMethod

lib/DbHealth.php:87:39: UndefinedInterfaceMethod: Method OCP\DB\IPreparedStatement::executeQuery does not exist (see https://psalm.dev/181)
$out = [];
while (($row = $result->fetch()) !== false) {
$out[] = [
'name' => (string)($row['name'] ?? ''),
'rows' => (int)($row['rows'] ?? 0),
'sizeBytes' => (int)($row['size_bytes'] ?? 0),
];
}
$result->closeCursor();
return $out;
}
}
128 changes: 128 additions & 0 deletions lib/DiskGrowth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?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 OCP\IAppConfig;
use OCP\IConfig;

/**
* Persists daily snapshots of (free space + file count) and uses a
* simple linear regression over the last samples to predict when
* the system disk will fill up.
*
* Snapshots are stored in app config as JSON: list of {ts, free, files}.
*/
class DiskGrowth {

Check failure on line 22 in lib/DiskGrowth.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UnusedClass

lib/DiskGrowth.php:22:7: UnusedClass: Class OCA\ServerInfo\DiskGrowth is never used (see https://psalm.dev/075)
private const HISTORY_KEY = 'storage_history';
private const MAX_SAMPLES = 60;
private const SAMPLE_INTERVAL = 12 * 3600;

public function __construct(
private IAppConfig $appConfig,
private IConfig $config,
private StorageStatistics $storageStatistics,
) {
}

/**
* Returns growth and prediction. Reads stored history; appends a
* new sample if enough time has passed since the last one.
*
* @return array{
* samples: list<array{ts: int, freeBytes: int, files: int}>,
* daysUntilFull: int,
* bytesPerDay: int,
* filesPerDay: int,
* freeBytes: int,
* hasEnoughData: bool
* }
*/
public function getGrowthInfo(): array {
$history = $this->loadHistory();
$now = time();
$shouldSample = $history === [] || ($now - $history[count($history) - 1]['ts']) >= self::SAMPLE_INTERVAL;

if ($shouldSample) {
$dataDir = (string)$this->config->getSystemValue('datadirectory', '');
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the sampling should go to

public function updateStorageCounts(): void {
(which is called by background job every 3 hours)

$free = $dataDir !== '' ? @disk_free_space($dataDir) : false;
$files = (int)($this->storageStatistics->getStorageStatistics()['num_files'] ?? 0);
if ($free !== false && $free > 0) {
$history[] = ['ts' => $now, 'freeBytes' => (int)$free, 'files' => $files];
$history = array_slice($history, -self::MAX_SAMPLES);
$this->saveHistory($history);
}
}

$count = count($history);
$current = $count > 0 ? $history[$count - 1] : ['freeBytes' => 0, 'files' => 0, 'ts' => $now];

$bytesPerDay = 0;
$filesPerDay = 0;
$daysUntilFull = -1;
$hasEnough = false;

if ($count >= 2) {
$first = $history[0];
$last = $history[$count - 1];
$dt = max(1, $last['ts'] - $first['ts']);
$dayFactor = 86400 / $dt;
// Negative bytesPerDay means free space is shrinking (i.e. usage growing).
$bytesPerDay = (int)round(($last['freeBytes'] - $first['freeBytes']) * $dayFactor);
$filesPerDay = (int)round(($last['files'] - $first['files']) * $dayFactor);
$hasEnough = true;

if ($bytesPerDay < 0) {
$daysUntilFull = (int)round($last['freeBytes'] / abs($bytesPerDay));
}
}

return [
'samples' => $history,
'daysUntilFull' => $daysUntilFull,
'bytesPerDay' => $bytesPerDay,
'filesPerDay' => $filesPerDay,
'freeBytes' => (int)($current['freeBytes'] ?? 0),
'hasEnoughData' => $hasEnough,
];
}

/**
* @return list<array{ts: int, freeBytes: int, files: int}>
*/
private function loadHistory(): array {
$raw = $this->appConfig->getValueString('serverinfo', self::HISTORY_KEY, '[]');
try {
$parsed = json_decode($raw, true, 4, JSON_THROW_ON_ERROR);
} catch (\Throwable) {
return [];
}
if (!is_array($parsed)) {
return [];
}
$out = [];
foreach ($parsed as $entry) {
if (!is_array($entry)) continue;
$out[] = [
'ts' => (int)($entry['ts'] ?? 0),
'freeBytes' => (int)($entry['freeBytes'] ?? 0),
'files' => (int)($entry['files'] ?? 0),
];
}
return $out;
}

private function saveHistory(array $history): void {
try {
$this->appConfig->setValueString('serverinfo', self::HISTORY_KEY, json_encode($history, JSON_THROW_ON_ERROR));
} catch (\Throwable) {
// best-effort; storage unavailable
}
}
}
Loading
Loading