enhance: improve disk space detection and error handling in storage module

- Refactored disk space retrieval logic to support multiple commands based on the operating system.
- Added detailed error handling for disk space detection failures, including specific messages for system configuration issues.
- Updated API responses to provide clearer error messages to the frontend.
- Enhanced the dashboard UI to display loading states and error messages related to disk space retrieval, with retry functionality.
- Improved type definitions to accommodate new error handling in the dashboard components.
This commit is contained in:
Daniel Luiz Alves
2025-06-20 09:48:00 -03:00
parent 3c084a6686
commit 9dfb034c2e
8 changed files with 294 additions and 73 deletions

View File

@@ -1,35 +1,142 @@
import { IS_RUNNING_IN_CONTAINER } from "../../utils/container-detection";
import { ConfigService } from "../config/service";
import { PrismaClient } from "@prisma/client";
import { exec } from "child_process";
import fs from "node:fs";
import { promisify } from "util";
import fs from 'node:fs';
const execAsync = promisify(exec);
const prisma = new PrismaClient();
export class StorageService {
private configService = new ConfigService();
private isDockerCached = undefined;
private _hasDockerEnv() {
private _ensureNumber(value: number, fallback: number = 0): number {
if (isNaN(value) || !isFinite(value)) {
return fallback;
}
return value;
}
private _safeParseInt(value: string): number {
const parsed = parseInt(value);
return this._ensureNumber(parsed, 0);
}
private async _tryDiskSpaceCommand(
command: string,
pathToCheck: string
): Promise<{ total: number; available: number } | null> {
try {
fs.statSync('/.dockerenv');
return true;
} catch {
return false;
console.log(`Trying disk space command: ${command}`);
const { stdout, stderr } = await execAsync(command);
if (stderr) {
console.warn(`Command stderr: ${stderr}`);
}
console.log(`Command stdout: ${stdout}`);
let total = 0;
let available = 0;
if (process.platform === "win32") {
const lines = stdout.trim().split("\n").slice(1);
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 3) {
const [, size, freespace] = parts;
total += this._safeParseInt(size);
available += this._safeParseInt(freespace);
}
}
} else if (process.platform === "darwin") {
const lines = stdout.trim().split("\n");
if (lines.length >= 2) {
const parts = lines[1].trim().split(/\s+/);
if (parts.length >= 4) {
const [, size, , avail] = parts;
total = this._safeParseInt(size) * 1024; // df -k returns KB, convert to bytes
available = this._safeParseInt(avail) * 1024;
}
}
} else {
// Linux
const lines = stdout.trim().split("\n");
if (lines.length >= 2) {
const parts = lines[1].trim().split(/\s+/);
if (parts.length >= 4) {
const [, size, , avail] = parts;
// Check if command used -B1 (bytes) or default (1K blocks)
if (command.includes("-B1")) {
total = this._safeParseInt(size);
available = this._safeParseInt(avail);
} else {
// Default df returns 1K blocks
total = this._safeParseInt(size) * 1024;
available = this._safeParseInt(avail) * 1024;
}
}
}
}
if (total > 0 && available >= 0) {
console.log(`Successfully parsed disk space: ${total} bytes total, ${available} bytes available`);
return { total, available };
} else {
console.warn(`Invalid values parsed: total=${total}, available=${available}`);
return null;
}
} catch (error) {
console.warn(`Command failed: ${command}`, error);
return null;
}
}
private _hasDockerCGroup() {
try {
return fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker');
} catch {
return false;
}
}
private async _getDiskSpaceMultiplePaths(): Promise<{ total: number; available: number } | null> {
const pathsToTry = IS_RUNNING_IN_CONTAINER
? ["/app/server/uploads", "/app/server", "/app", "/"]
: [".", "./uploads", process.cwd()];
private _isDocker() {
return this.isDockerCached ?? (this._hasDockerEnv() || this._hasDockerCGroup());
for (const pathToCheck of pathsToTry) {
console.log(`Trying path: ${pathToCheck}`);
// Ensure the path exists if it's our uploads directory
if (pathToCheck.includes("uploads")) {
try {
if (!fs.existsSync(pathToCheck)) {
fs.mkdirSync(pathToCheck, { recursive: true });
console.log(`Created directory: ${pathToCheck}`);
}
} catch (err) {
console.warn(`Could not create path ${pathToCheck}:`, err);
continue;
}
}
// Check if path exists
if (!fs.existsSync(pathToCheck)) {
console.warn(`Path does not exist: ${pathToCheck}`);
continue;
}
const commandsToTry =
process.platform === "win32"
? ["wmic logicaldisk get size,freespace,caption"]
: process.platform === "darwin"
? [`df -k "${pathToCheck}"`, `df "${pathToCheck}"`]
: [`df -B1 "${pathToCheck}"`, `df -k "${pathToCheck}"`, `df "${pathToCheck}"`];
for (const command of commandsToTry) {
const result = await this._tryDiskSpaceCommand(command, pathToCheck);
if (result) {
console.log(`✅ Successfully got disk space for path: ${pathToCheck}`);
return result;
}
}
}
return null;
}
async getDiskSpace(
@@ -43,49 +150,41 @@ export class StorageService {
}> {
try {
if (isAdmin) {
const isDocker = this._isDocker();
const pathToCheck = isDocker ? "/app/server/uploads" : ".";
console.log(`Running in container: ${IS_RUNNING_IN_CONTAINER}`);
const command = process.platform === "win32"
? "wmic logicaldisk get size,freespace,caption"
: process.platform === "darwin"
? `df -k ${pathToCheck}`
: `df -B1 ${pathToCheck}`;
const diskInfo = await this._getDiskSpaceMultiplePaths();
const { stdout } = await execAsync(command);
let total = 0;
let available = 0;
if (!diskInfo) {
console.error("❌ CRITICAL: Could not determine disk space using any method!");
console.error("This indicates a serious system issue. Please check:");
console.error("1. File system permissions");
console.error("2. Available disk utilities (df, wmic)");
console.error("3. Container/system configuration");
if (process.platform === "win32") {
const lines = stdout.trim().split("\n").slice(1);
for (const line of lines) {
const [, size, freespace] = line.trim().split(/\s+/);
total += parseInt(size) || 0;
available += parseInt(freespace) || 0;
}
} else if (process.platform === "darwin") {
const lines = stdout.trim().split("\n");
const [, size, , avail] = lines[1].trim().split(/\s+/);
total = parseInt(size) * 1024;
available = parseInt(avail) * 1024;
} else {
const lines = stdout.trim().split("\n");
const [, size, , avail] = lines[1].trim().split(/\s+/);
total = parseInt(size);
available = parseInt(avail);
// Only now use fallback, but make it very clear this is an error state
throw new Error("Unable to determine actual disk space - system configuration issue");
}
const { total, available } = diskInfo;
const used = total - available;
const diskSizeGB = this._ensureNumber(total / (1024 * 1024 * 1024), 0);
const diskUsedGB = this._ensureNumber(used / (1024 * 1024 * 1024), 0);
const diskAvailableGB = this._ensureNumber(available / (1024 * 1024 * 1024), 0);
console.log(
`✅ Real disk space: ${diskSizeGB.toFixed(2)}GB total, ${diskUsedGB.toFixed(2)}GB used, ${diskAvailableGB.toFixed(2)}GB available`
);
return {
diskSizeGB: Number((total / (1024 * 1024 * 1024)).toFixed(2)),
diskUsedGB: Number((used / (1024 * 1024 * 1024)).toFixed(2)),
diskAvailableGB: Number((available / (1024 * 1024 * 1024)).toFixed(2)),
uploadAllowed: true,
diskSizeGB: Number(diskSizeGB.toFixed(2)),
diskUsedGB: Number(diskUsedGB.toFixed(2)),
diskAvailableGB: Number(diskAvailableGB.toFixed(2)),
uploadAllowed: diskAvailableGB > 0.1, // At least 100MB free
};
} else if (userId) {
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
const maxStorageGB = Number(maxTotalStorage) / (1024 * 1024 * 1024);
const maxStorageGB = this._ensureNumber(Number(maxTotalStorage) / (1024 * 1024 * 1024), 10);
const userFiles = await prisma.file.findMany({
where: { userId },
@@ -94,21 +193,26 @@ export class StorageService {
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = Number(totalUsedStorage) / (1024 * 1024 * 1024);
const availableStorageGB = maxStorageGB - usedStorageGB;
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(maxStorageGB - usedStorageGB, 0);
return {
diskSizeGB: maxStorageGB,
diskUsedGB: usedStorageGB,
diskAvailableGB: availableStorageGB,
diskSizeGB: Number(maxStorageGB.toFixed(2)),
diskUsedGB: Number(usedStorageGB.toFixed(2)),
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
uploadAllowed: availableStorageGB > 0,
};
}
throw new Error("User ID is required for non-admin users");
} catch (error) {
console.error("Error getting disk space:", error);
throw new Error("Failed to get disk space information");
console.error("Error getting disk space:", error);
// Re-throw the error instead of returning fallback values
// This way the API will return a proper error and the frontend can handle it
throw new Error(
`Failed to get disk space information: ${error instanceof Error ? error.message : String(error)}`
);
}
}