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 = ? '
. '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) {
$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, '
. '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', '');
$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