723 lines
25 KiB
PHP
723 lines
25 KiB
PHP
<?php
|
|
// src/models/DiskUsageModel.php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once PROJECT_ROOT . '/config/config.php';
|
|
require_once PROJECT_ROOT . '/src/lib/FS.php';
|
|
|
|
/**
|
|
* DiskUsageModel
|
|
*
|
|
* Builds and reads a cached snapshot of disk usage under UPLOAD_DIR.
|
|
* Snapshot is stored as JSON under META_DIR . '/disk_usage.json'.
|
|
*
|
|
* Folder keys mirror the rest of FileRise:
|
|
* - "root" is the upload root
|
|
* - "foo/bar" are subfolders under UPLOAD_DIR
|
|
*
|
|
* We intentionally skip:
|
|
* - trash subtree
|
|
* - profile_pics subtree
|
|
* - dot-prefixed names
|
|
* - FS::IGNORE() entries like @eaDir, .DS_Store, etc.
|
|
*/
|
|
class DiskUsageModel
|
|
{
|
|
/** Where we persist the snapshot JSON. */
|
|
public const SNAPSHOT_BASENAME = 'disk_usage.json';
|
|
|
|
/** Maximum number of per-file records to keep (for Top N view). */
|
|
private const TOP_FILE_LIMIT = 1000;
|
|
|
|
/**
|
|
* Absolute path to the snapshot JSON file.
|
|
*/
|
|
public static function snapshotPath(): string
|
|
{
|
|
$meta = rtrim((string)META_DIR, '/\\');
|
|
return $meta . DIRECTORY_SEPARATOR . self::SNAPSHOT_BASENAME;
|
|
}
|
|
|
|
/**
|
|
* Build a fresh snapshot of disk usage under UPLOAD_DIR and write it to disk.
|
|
*
|
|
* Returns the structured snapshot array (same shape as stored JSON).
|
|
*
|
|
* @throws RuntimeException on configuration or IO errors.
|
|
*/
|
|
public static function buildSnapshot(): array
|
|
{
|
|
$start = microtime(true);
|
|
|
|
$root = realpath(UPLOAD_DIR);
|
|
if ($root === false || !is_dir($root)) {
|
|
throw new RuntimeException('Uploads directory is not configured correctly.');
|
|
}
|
|
$root = rtrim($root, DIRECTORY_SEPARATOR);
|
|
|
|
$IGNORE = FS::IGNORE();
|
|
$SKIP = FS::SKIP();
|
|
|
|
// Folder map: key => [
|
|
// 'key' => string,
|
|
// 'parent' => string|null,
|
|
// 'name' => string,
|
|
// 'bytes' => int,
|
|
// 'files' => int,
|
|
// 'dirs' => int,
|
|
// 'latest_mtime' => int
|
|
// ]
|
|
$folders = [];
|
|
|
|
// Root entry
|
|
$folders['root'] = [
|
|
'key' => 'root',
|
|
'parent' => null,
|
|
'name' => 'root',
|
|
'bytes' => 0,
|
|
'files' => 0,
|
|
'dirs' => 0,
|
|
'latest_mtime' => 0,
|
|
];
|
|
|
|
// File records (we may trim to TOP_FILE_LIMIT later)
|
|
// Each item: [
|
|
// 'folder' => folderKey,
|
|
// 'name' => file name,
|
|
// 'path' => "folder/name" or just name if root,
|
|
// 'bytes' => int,
|
|
// 'mtime' => int
|
|
// ]
|
|
$files = [];
|
|
|
|
$rootLen = strlen($root);
|
|
|
|
$it = new RecursiveIteratorIterator(
|
|
new RecursiveDirectoryIterator(
|
|
$root,
|
|
FilesystemIterator::SKIP_DOTS
|
|
| FilesystemIterator::FOLLOW_SYMLINKS
|
|
),
|
|
RecursiveIteratorIterator::SELF_FIRST
|
|
);
|
|
|
|
foreach ($it as $path => $info) {
|
|
/** @var SplFileInfo $info */
|
|
$name = $info->getFilename();
|
|
|
|
// Skip dotfiles / dotdirs
|
|
if ($name === '.' || $name === '..') {
|
|
continue;
|
|
}
|
|
if ($name[0] === '.') {
|
|
continue;
|
|
}
|
|
|
|
// Skip system/ignored entries
|
|
if (in_array($name, $IGNORE, true)) {
|
|
continue;
|
|
}
|
|
|
|
// Relative path under UPLOAD_DIR, normalized with '/'
|
|
$rel = substr($path, $rootLen);
|
|
$rel = str_replace('\\', '/', $rel);
|
|
$rel = ltrim($rel, '/');
|
|
|
|
// Should only happen for the root itself, which we seeded
|
|
if ($rel === '') {
|
|
continue;
|
|
}
|
|
|
|
$isDir = $info->isDir();
|
|
|
|
if ($isDir) {
|
|
$folderKey = $rel;
|
|
$lowerRel = strtolower($folderKey);
|
|
|
|
// Skip trash/profile_pics subtrees entirely
|
|
if ($lowerRel === 'trash' || strpos($lowerRel, 'trash/') === 0) {
|
|
$it->next();
|
|
continue;
|
|
}
|
|
if ($lowerRel === 'profile_pics' || strpos($lowerRel, 'profile_pics/') === 0) {
|
|
$it->next();
|
|
continue;
|
|
}
|
|
|
|
// Skip SKIP entries at any level
|
|
$baseLower = strtolower(basename($folderKey));
|
|
if (in_array($baseLower, $SKIP, true)) {
|
|
$it->next();
|
|
continue;
|
|
}
|
|
|
|
// Register folder
|
|
if (!isset($folders[$folderKey])) {
|
|
$parent = self::parentKeyOf($folderKey);
|
|
if (!isset($folders[$parent])) {
|
|
// Ensure parent exists (important for aggregation step later)
|
|
$folders[$parent] = [
|
|
'key' => $parent,
|
|
'parent' => self::parentKeyOf($parent),
|
|
'name' => self::basenameKey($parent),
|
|
'bytes' => 0,
|
|
'files' => 0,
|
|
'dirs' => 0,
|
|
'latest_mtime' => 0,
|
|
];
|
|
}
|
|
|
|
$folders[$folderKey] = [
|
|
'key' => $folderKey,
|
|
'parent' => $parent,
|
|
'name' => self::basenameKey($folderKey),
|
|
'bytes' => 0,
|
|
'files' => 0,
|
|
'dirs' => 0,
|
|
'latest_mtime' => 0,
|
|
];
|
|
// Increment dir count on parent
|
|
if ($parent !== null && isset($folders[$parent])) {
|
|
$folders[$parent]['dirs']++;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// File entry
|
|
// Determine folder key where this file resides
|
|
$relDir = str_replace('\\', '/', dirname($rel));
|
|
if ($relDir === '.' || $relDir === '') {
|
|
$folderKey = 'root';
|
|
} else {
|
|
$folderKey = $relDir;
|
|
}
|
|
|
|
$lowerFolder = strtolower($folderKey);
|
|
if ($lowerFolder === 'trash' || strpos($lowerFolder, 'trash/') === 0) {
|
|
continue;
|
|
}
|
|
if ($lowerFolder === 'profile_pics' || strpos($lowerFolder, 'profile_pics/') === 0) {
|
|
continue;
|
|
}
|
|
|
|
// Skip SKIP entries for files inside unwanted app-specific dirs
|
|
$baseLower = strtolower(basename($folderKey));
|
|
if (in_array($baseLower, $SKIP, true)) {
|
|
continue;
|
|
}
|
|
|
|
// Ensure folder exists in map
|
|
if (!isset($folders[$folderKey])) {
|
|
$parent = self::parentKeyOf($folderKey);
|
|
if (!isset($folders[$parent])) {
|
|
$folders[$parent] = [
|
|
'key' => $parent,
|
|
'parent' => self::parentKeyOf($parent),
|
|
'name' => self::basenameKey($parent),
|
|
'bytes' => 0,
|
|
'files' => 0,
|
|
'dirs' => 0,
|
|
'latest_mtime' => 0,
|
|
];
|
|
}
|
|
|
|
$folders[$folderKey] = [
|
|
'key' => $folderKey,
|
|
'parent' => $parent,
|
|
'name' => self::basenameKey($folderKey),
|
|
'bytes' => 0,
|
|
'files' => 0,
|
|
'dirs' => 0,
|
|
'latest_mtime' => 0,
|
|
];
|
|
if ($parent !== null && isset($folders[$parent])) {
|
|
$folders[$parent]['dirs']++;
|
|
}
|
|
}
|
|
|
|
$bytes = (int)$info->getSize();
|
|
$mtime = (int)$info->getMTime();
|
|
|
|
// Update folder leaf stats
|
|
$folders[$folderKey]['bytes'] += $bytes;
|
|
$folders[$folderKey]['files']++;
|
|
if ($mtime > $folders[$folderKey]['latest_mtime']) {
|
|
$folders[$folderKey]['latest_mtime'] = $mtime;
|
|
}
|
|
|
|
// Remember file record (we may trim later)
|
|
$filePath = ($folderKey === 'root')
|
|
? $name
|
|
: ($folderKey . '/' . $name);
|
|
|
|
$files[] = [
|
|
'folder' => $folderKey,
|
|
'name' => $name,
|
|
'path' => $filePath,
|
|
'bytes' => $bytes,
|
|
'mtime' => $mtime,
|
|
];
|
|
}
|
|
|
|
// Aggregate folder bytes up the tree so each folder includes its descendants.
|
|
// Process folders from deepest to shallowest.
|
|
$keys = array_keys($folders);
|
|
usort($keys, function (string $a, string $b): int {
|
|
return self::depthOf($b) <=> self::depthOf($a);
|
|
});
|
|
|
|
foreach ($keys as $key) {
|
|
$parent = $folders[$key]['parent'];
|
|
if ($parent !== null && isset($folders[$parent])) {
|
|
$folders[$parent]['bytes'] += $folders[$key]['bytes'];
|
|
$folders[$parent]['files'] += $folders[$key]['files'];
|
|
$folders[$parent]['dirs'] += $folders[$key]['dirs'];
|
|
$parentLatest = $folders[$parent]['latest_mtime'];
|
|
if ($folders[$key]['latest_mtime'] > $parentLatest) {
|
|
$folders[$parent]['latest_mtime'] = $folders[$key]['latest_mtime'];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Root aggregate
|
|
$rootBytes = isset($folders['root']) ? (int)$folders['root']['bytes'] : 0;
|
|
$rootFiles = isset($folders['root']) ? (int)$folders['root']['files'] : 0;
|
|
|
|
// Count of folders under the upload root (excluding "root" itself)
|
|
$rootFolders = 0;
|
|
if (!empty($folders)) {
|
|
$rootFolders = max(0, count($folders) - 1);
|
|
}
|
|
|
|
// Trim top files list
|
|
usort($files, function (array $a, array $b): int {
|
|
// descending by bytes, then by path
|
|
if ($a['bytes'] === $b['bytes']) {
|
|
return strcmp($a['path'], $b['path']);
|
|
}
|
|
return ($a['bytes'] < $b['bytes']) ? 1 : -1;
|
|
});
|
|
if (count($files) > self::TOP_FILE_LIMIT) {
|
|
$files = array_slice($files, 0, self::TOP_FILE_LIMIT);
|
|
}
|
|
|
|
$snapshot = [
|
|
'version' => 1,
|
|
'generated_at' => time(),
|
|
'scan_seconds' => microtime(true) - $start,
|
|
'root_bytes' => $rootBytes,
|
|
'root_files' => $rootFiles,
|
|
'root_folders' => $rootFolders,
|
|
// Store folders as numerically-indexed array
|
|
'folders' => array_values($folders),
|
|
'files' => $files,
|
|
];
|
|
|
|
$path = self::snapshotPath();
|
|
$dir = dirname($path);
|
|
if (!is_dir($dir)) {
|
|
@mkdir($dir, 0775, true);
|
|
}
|
|
|
|
$json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
if ($json === false) {
|
|
throw new RuntimeException('Failed to encode disk usage snapshot.');
|
|
}
|
|
|
|
if (@file_put_contents($path, $json) === false) {
|
|
throw new RuntimeException('Failed to write disk usage snapshot to ' . $path);
|
|
}
|
|
|
|
return $snapshot;
|
|
}
|
|
|
|
/**
|
|
* Load the snapshot from disk, or return null if missing or invalid.
|
|
*/
|
|
public static function loadSnapshot(): ?array
|
|
{
|
|
$path = self::snapshotPath();
|
|
if (!is_file($path)) {
|
|
return null;
|
|
}
|
|
$raw = @file_get_contents($path);
|
|
if ($raw === false || $raw === '') {
|
|
return null;
|
|
}
|
|
$data = json_decode($raw, true);
|
|
if (!is_array($data)) {
|
|
return null;
|
|
}
|
|
if (!isset($data['version']) || (int)$data['version'] !== 1) {
|
|
return null;
|
|
}
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Compute a lightweight summary for the Admin panel.
|
|
*
|
|
* @param int $maxTopFolders How many top folders to include.
|
|
* @param int $maxTopFilesPreview Optional number of top files to include as preview.
|
|
* @return array
|
|
*/
|
|
public static function getSummary(int $maxTopFolders = 5, int $maxTopFilesPreview = 0): array
|
|
{
|
|
$snapshot = self::loadSnapshot();
|
|
if ($snapshot === null) {
|
|
return [
|
|
'ok' => false,
|
|
'error' => 'no_snapshot',
|
|
'message' => 'No disk usage snapshot found. Run the disk usage scan to generate one.',
|
|
'generatedAt' => null,
|
|
];
|
|
}
|
|
|
|
$rootBytes = (int)($snapshot['root_bytes'] ?? 0);
|
|
$folders = is_array($snapshot['folders'] ?? null) ? $snapshot['folders'] : [];
|
|
|
|
// --- Build "volumes" across core FileRise dirs (UPLOAD/USERS/META) ---
|
|
$volumeRoots = [
|
|
'uploads' => defined('UPLOAD_DIR') ? (string)UPLOAD_DIR : null,
|
|
'users' => defined('USERS_DIR') ? (string)USERS_DIR : null,
|
|
'meta' => defined('META_DIR') ? (string)META_DIR : null,
|
|
];
|
|
|
|
$volumesMap = [];
|
|
$uploadReal = null;
|
|
|
|
if (defined('UPLOAD_DIR')) {
|
|
$tmp = realpath(UPLOAD_DIR);
|
|
if ($tmp !== false && is_dir($tmp)) {
|
|
$uploadReal = $tmp;
|
|
}
|
|
}
|
|
|
|
foreach ($volumeRoots as $kind => $dir) {
|
|
if ($dir === null || $dir === '') {
|
|
continue;
|
|
}
|
|
$real = realpath($dir);
|
|
if ($real === false || !is_dir($real)) {
|
|
continue;
|
|
}
|
|
|
|
$total = @disk_total_space($real);
|
|
$free = @disk_free_space($real);
|
|
if ($total === false || $free === false || $total <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$total = (int)$total;
|
|
$free = (int)$free;
|
|
$used = $total - $free;
|
|
if ($used < 0) {
|
|
$used = 0;
|
|
}
|
|
$usedPct = ($used * 100.0) / $total;
|
|
|
|
// Group by same total+free => assume same underlying volume
|
|
$bucketKey = $total . ':' . $free;
|
|
if (!isset($volumesMap[$bucketKey])) {
|
|
$volumesMap[$bucketKey] = [
|
|
'totalBytes' => $total,
|
|
'freeBytes' => $free,
|
|
'usedBytes' => $used,
|
|
'usedPercent' => $usedPct,
|
|
'roots' => [],
|
|
];
|
|
}
|
|
|
|
$volumesMap[$bucketKey]['roots'][] = [
|
|
'kind' => $kind, // "uploads" | "users" | "meta"
|
|
'path' => $real,
|
|
];
|
|
}
|
|
|
|
$volumes = array_values($volumesMap);
|
|
// Sort by usedPercent desc (heaviest first)
|
|
usort($volumes, function (array $a, array $b): int {
|
|
$pa = (float)($a['usedPercent'] ?? 0.0);
|
|
$pb = (float)($b['usedPercent'] ?? 0.0);
|
|
if ($pa === $pb) {
|
|
return 0;
|
|
}
|
|
return ($pa < $pb) ? 1 : -1;
|
|
});
|
|
|
|
// Backwards-compat: root filesystem metrics based on the volume
|
|
// that contains UPLOAD_DIR (if we can detect it).
|
|
$fsTotalBytes = null;
|
|
$fsFreeBytes = null;
|
|
$fsUsedBytes = null;
|
|
$fsUsedPct = null;
|
|
|
|
if ($uploadReal && !empty($volumes)) {
|
|
foreach ($volumes as $vol) {
|
|
foreach ($vol['roots'] as $root) {
|
|
if (!isset($root['path'])) continue;
|
|
if ((string)$root['path'] === (string)$uploadReal) {
|
|
$fsTotalBytes = (int)$vol['totalBytes'];
|
|
$fsFreeBytes = (int)$vol['freeBytes'];
|
|
$fsUsedBytes = (int)$vol['usedBytes'];
|
|
$fsUsedPct = (float)$vol['usedPercent'];
|
|
break 2;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Top N non-root folders by bytes (from snapshot)
|
|
$candidates = array_filter($folders, function (array $f): bool {
|
|
return isset($f['key']) && $f['key'] !== 'root';
|
|
});
|
|
|
|
usort($candidates, function (array $a, array $b): int {
|
|
$ba = (int)($a['bytes'] ?? 0);
|
|
$bb = (int)($b['bytes'] ?? 0);
|
|
if ($ba === $bb) {
|
|
return strcmp((string)$a['key'], (string)$b['key']);
|
|
}
|
|
return ($ba < $bb) ? 1 : -1;
|
|
});
|
|
|
|
if ($maxTopFolders > 0 && count($candidates) > $maxTopFolders) {
|
|
$candidates = array_slice($candidates, 0, $maxTopFolders);
|
|
}
|
|
|
|
$topFolders = [];
|
|
foreach ($candidates as $f) {
|
|
$bytes = (int)($f['bytes'] ?? 0);
|
|
$pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0;
|
|
$topFolders[] = [
|
|
'folder' => (string)$f['key'],
|
|
'name' => (string)$f['name'],
|
|
'bytes' => $bytes,
|
|
'files' => (int)($f['files'] ?? 0),
|
|
'dirs' => (int)($f['dirs'] ?? 0),
|
|
'latest_mtime' => (int)($f['latest_mtime'] ?? 0),
|
|
'percentOfTotal' => $pct,
|
|
];
|
|
}
|
|
|
|
// totalFolders: prefer snapshot["root_folders"], but fall back to counting
|
|
$totalFolders = isset($snapshot['root_folders'])
|
|
? (int)$snapshot['root_folders']
|
|
: max(0, count($folders) - 1);
|
|
|
|
$out = [
|
|
'ok' => true,
|
|
'generatedAt' => (int)($snapshot['generated_at'] ?? 0),
|
|
'scanSeconds' => (float)($snapshot['scan_seconds'] ?? 0.0),
|
|
'totalBytes' => $rootBytes,
|
|
'totalFiles' => (int)($snapshot['root_files'] ?? 0),
|
|
'totalFolders' => $totalFolders,
|
|
'topFolders' => $topFolders,
|
|
// original fields (for single-root view)
|
|
'uploadRoot' => $uploadReal,
|
|
'fsTotalBytes' => $fsTotalBytes,
|
|
'fsFreeBytes' => $fsFreeBytes,
|
|
'fsUsedBytes' => $fsUsedBytes,
|
|
'fsUsedPercent' => $fsUsedPct,
|
|
// new grouped volumes: each with total/free/used and roots[]
|
|
'volumes' => $volumes,
|
|
];
|
|
|
|
if ($maxTopFilesPreview > 0) {
|
|
$files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : [];
|
|
if (count($files) > $maxTopFilesPreview) {
|
|
$files = array_slice($files, 0, $maxTopFilesPreview);
|
|
}
|
|
$out['topFiles'] = $files;
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Return direct children (folders + files) of a given folder key.
|
|
*
|
|
* @param string $folderKey
|
|
* @return array
|
|
*/
|
|
public static function getChildren(string $folderKey): array
|
|
{
|
|
$folderKey = ($folderKey === '' || $folderKey === '/') ? 'root' : $folderKey;
|
|
|
|
$snapshot = self::loadSnapshot();
|
|
if ($snapshot === null) {
|
|
return [
|
|
'ok' => false,
|
|
'error' => 'no_snapshot',
|
|
];
|
|
}
|
|
|
|
$rootBytes = (int)($snapshot['root_bytes'] ?? 0);
|
|
$folders = is_array($snapshot['folders'] ?? null) ? $snapshot['folders'] : [];
|
|
$files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : [];
|
|
|
|
// Index folders by key
|
|
$folderByKey = [];
|
|
foreach ($folders as $f) {
|
|
if (!isset($f['key'])) continue;
|
|
$folderByKey[(string)$f['key']] = $f;
|
|
}
|
|
if (!isset($folderByKey[$folderKey])) {
|
|
return [
|
|
'ok' => false,
|
|
'error' => 'folder_not_found',
|
|
];
|
|
}
|
|
|
|
$childrenFolders = [];
|
|
foreach ($folders as $f) {
|
|
if (!isset($f['parent']) || !isset($f['key'])) continue;
|
|
if ((string)$f['parent'] === $folderKey) {
|
|
$bytes = (int)($f['bytes'] ?? 0);
|
|
$pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0;
|
|
$childrenFolders[] = [
|
|
'type' => 'folder',
|
|
'folder' => (string)$f['key'],
|
|
'name' => (string)$f['name'],
|
|
'bytes' => $bytes,
|
|
'files' => (int)($f['files'] ?? 0),
|
|
'dirs' => (int)($f['dirs'] ?? 0),
|
|
'latest_mtime' => (int)($f['latest_mtime'] ?? 0),
|
|
'percentOfTotal' => $pct,
|
|
];
|
|
}
|
|
}
|
|
|
|
$childrenFiles = [];
|
|
foreach ($files as $file) {
|
|
if (!isset($file['folder']) || !isset($file['name'])) continue;
|
|
if ((string)$file['folder'] !== $folderKey) continue;
|
|
|
|
$bytes = (int)($file['bytes'] ?? 0);
|
|
$pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0;
|
|
$childrenFiles[] = [
|
|
'type' => 'file',
|
|
'folder' => (string)$file['folder'],
|
|
'name' => (string)$file['name'],
|
|
'path' => (string)($file['path'] ?? $file['name']),
|
|
'bytes' => $bytes,
|
|
'mtime' => (int)($file['mtime'] ?? 0),
|
|
'percentOfTotal' => $pct,
|
|
];
|
|
}
|
|
|
|
// Sort children: folders first (by bytes desc), then files (by bytes desc)
|
|
usort($childrenFolders, function (array $a, array $b): int {
|
|
$ba = (int)($a['bytes'] ?? 0);
|
|
$bb = (int)($b['bytes'] ?? 0);
|
|
if ($ba === $bb) {
|
|
return strcmp((string)$a['name'], (string)$b['name']);
|
|
}
|
|
return ($ba < $bb) ? 1 : -1;
|
|
});
|
|
|
|
usort($childrenFiles, function (array $a, array $b): int {
|
|
$ba = (int)($a['bytes'] ?? 0);
|
|
$bb = (int)($b['bytes'] ?? 0);
|
|
if ($ba === $bb) {
|
|
return strcmp((string)$a['name'], (string)$b['name']);
|
|
}
|
|
return ($ba < $bb) ? 1 : -1;
|
|
});
|
|
|
|
return [
|
|
'ok' => true,
|
|
'folder' => $folderKey,
|
|
'folders' => $childrenFolders,
|
|
'files' => $childrenFiles,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Return the global Top N files by size from the snapshot.
|
|
*
|
|
* @param int $limit
|
|
* @return array
|
|
*/
|
|
public static function getTopFiles(int $limit = 100): array
|
|
{
|
|
$snapshot = self::loadSnapshot();
|
|
if ($snapshot === null) {
|
|
return [
|
|
'ok' => false,
|
|
'error' => 'no_snapshot',
|
|
];
|
|
}
|
|
|
|
$rootBytes = (int)($snapshot['root_bytes'] ?? 0);
|
|
$files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : [];
|
|
|
|
if ($limit > 0 && count($files) > $limit) {
|
|
$files = array_slice($files, 0, $limit);
|
|
}
|
|
|
|
$out = [];
|
|
foreach ($files as $file) {
|
|
$bytes = (int)($file['bytes'] ?? 0);
|
|
$pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0;
|
|
$out[] = [
|
|
'folder' => (string)($file['folder'] ?? 'root'),
|
|
'name' => (string)($file['name'] ?? ''),
|
|
'path' => (string)($file['path'] ?? ($file['name'] ?? '')),
|
|
'bytes' => $bytes,
|
|
'mtime' => (int)($file['mtime'] ?? 0),
|
|
'percentOfTotal' => $pct,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'ok' => true,
|
|
'files' => $out,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Helper: derive the parent folder key ("root" -> null, "foo/bar" -> "foo").
|
|
*/
|
|
private static function parentKeyOf(string $key): ?string
|
|
{
|
|
if ($key === 'root' || $key === '') {
|
|
return null;
|
|
}
|
|
$key = trim($key, '/');
|
|
if ($key === '') return null;
|
|
$pos = strrpos($key, '/');
|
|
if ($pos === false) {
|
|
return 'root';
|
|
}
|
|
$parent = substr($key, 0, $pos);
|
|
return ($parent === '' ? 'root' : $parent);
|
|
}
|
|
|
|
/**
|
|
* Helper: basename of a folder key. "root" -> "root", "foo/bar" -> "bar".
|
|
*/
|
|
private static function basenameKey(?string $key): string
|
|
{
|
|
if ($key === null || $key === '' || $key === 'root') {
|
|
return 'root';
|
|
}
|
|
$key = trim($key, '/');
|
|
$pos = strrpos($key, '/');
|
|
if ($pos === false) {
|
|
return $key;
|
|
}
|
|
return substr($key, $pos + 1);
|
|
}
|
|
|
|
/**
|
|
* Helper: approximate depth of a folder key (root->0, "foo"->1, "foo/bar"->2, etc.)
|
|
*/
|
|
private static function depthOf(string $key): int
|
|
{
|
|
if ($key === '' || $key === 'root') return 0;
|
|
return substr_count(trim($key, '/'), '/') + 1;
|
|
}
|
|
} |