diff --git a/lib/CachingInfo.php b/lib/CachingInfo.php new file mode 100644 index 00000000..1cf93dc3 --- /dev/null +++ b/lib/CachingInfo.php @@ -0,0 +1,116 @@ + $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, + '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, + '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; + } +} diff --git a/lib/DbHealth.php b/lib/DbHealth.php new file mode 100644 index 00000000..e8e1f325 --- /dev/null +++ b/lib/DbHealth.php @@ -0,0 +1,99 @@ +, + * 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 + */ + 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; + $conn = $this->db; + $stmt = $conn->prepare($sql); + $stmt->bindValue(1, $dbName); + $result = $stmt->executeQuery(); + $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 + */ + 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; + $result = $this->db->prepare($sql)->executeQuery(); + $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; + } +} diff --git a/lib/DiskGrowth.php b/lib/DiskGrowth.php new file mode 100644 index 00000000..3f29bed3 --- /dev/null +++ b/lib/DiskGrowth.php @@ -0,0 +1,128 @@ +, + * 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 + */ + 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 + } + } +} diff --git a/lib/ExternalStoragesInfo.php b/lib/ExternalStoragesInfo.php new file mode 100644 index 00000000..661973d3 --- /dev/null +++ b/lib/ExternalStoragesInfo.php @@ -0,0 +1,66 @@ + + * } + */ + public function getExternalStorages(): array { + if (!$this->appManager->isInstalled('files_external')) { + return ['installed' => false, 'count' => 0, 'mounts' => []]; + } + + try { + $qb = $this->db->getQueryBuilder(); + $qb->select('mount_id', 'mount_point', 'storage_backend', 'auth_backend', 'type') + ->from('external_mounts') + ->setMaxResults(50); + $result = $qb->executeQuery(); + $mounts = []; + while (($row = $result->fetch()) !== false) { + $mounts[] = [ + 'name' => $this->prettyMountPoint((string)($row['mount_point'] ?? '')), + 'backend' => $this->shortBackend((string)($row['storage_backend'] ?? '')), + 'scope' => (int)($row['type'] ?? 1) === 2 ? 'user' : 'admin', + ]; + } + $result->closeCursor(); + return ['installed' => true, 'count' => count($mounts), 'mounts' => $mounts]; + } catch (\Throwable) { + return ['installed' => true, 'count' => 0, 'mounts' => []]; + } + } + + private function prettyMountPoint(string $mp): string { + $mp = trim($mp, '/'); + return $mp === '' ? '/' : $mp; + } + + private function shortBackend(string $backend): string { + // Strip leading provider prefix, e.g. "smb" or "amazons3" + $parts = explode('::', $backend); + $tail = end($parts) ?: $backend; + return ucwords(str_replace(['_', '-'], ' ', $tail)); + } +} diff --git a/lib/TopUsersByQuota.php b/lib/TopUsersByQuota.php new file mode 100644 index 00000000..1adc52d3 --- /dev/null +++ b/lib/TopUsersByQuota.php @@ -0,0 +1,55 @@ + + */ + public function getTopUsers(int $limit = 10): array { + try { + $qb = $this->db->getQueryBuilder(); + $qb->select('s.id', 'fc.size') + ->from('storages', 's') + ->innerJoin('s', 'filecache', 'fc', $qb->expr()->andX( + $qb->expr()->eq('fc.storage', 's.numeric_id'), + $qb->expr()->eq('fc.path', $qb->createNamedParameter('files')) + )) + ->where($qb->expr()->like('s.id', $qb->createNamedParameter('home::%'))) + ->orderBy('fc.size', 'DESC') + ->setMaxResults($limit); + $result = $qb->executeQuery(); + $out = []; + while (($row = $result->fetch()) !== false) { + $id = (string)($row['id'] ?? ''); + $user = str_starts_with($id, 'home::') ? substr($id, 6) : $id; + $out[] = [ + 'user' => $user, + 'sizeBytes' => max(0, (int)($row['size'] ?? 0)), + ]; + } + $result->closeCursor(); + return $out; + } catch (\Throwable) { + return []; + } + } +}