mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
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:
@@ -22,7 +22,21 @@ export class StorageController {
|
||||
const diskSpace = await this.storageService.getDiskSpace(userId, isAdmin);
|
||||
return reply.send(diskSpace);
|
||||
} catch (error: any) {
|
||||
return reply.status(500).send({ error: error.message });
|
||||
console.error("Controller error in getDiskSpace:", error);
|
||||
|
||||
// For disk space detection issues, provide a more specific error
|
||||
if (error.message?.includes("Unable to determine actual disk space")) {
|
||||
return reply.status(503).send({
|
||||
error: "Disk space detection unavailable - system configuration issue",
|
||||
details: "Please check system permissions and available disk utilities",
|
||||
code: "DISK_SPACE_DETECTION_FAILED",
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({
|
||||
error: "Failed to retrieve disk space information",
|
||||
details: error.message || "Unknown error occurred",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -788,7 +788,15 @@
|
||||
"title": "Storage Usage",
|
||||
"ariaLabel": "Storage usage progress bar",
|
||||
"used": "used",
|
||||
"available": "available"
|
||||
"available": "available",
|
||||
"loading": "Loading...",
|
||||
"retry": "Retry",
|
||||
"errors": {
|
||||
"title": "Storage information unavailable",
|
||||
"detectionFailed": "Unable to detect disk space. This may be due to system configuration issues or insufficient permissions.",
|
||||
"serverError": "Server error occurred while retrieving storage information. Please try again later.",
|
||||
"unknown": "An unexpected error occurred while loading storage information."
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Toggle theme",
|
||||
|
@@ -1,14 +1,76 @@
|
||||
import { IconDatabaseCog } from "@tabler/icons-react";
|
||||
import { IconAlertCircle, IconDatabaseCog, IconRefresh } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import type { StorageUsageProps } from "../types";
|
||||
import { formatStorageSize } from "../utils/format-storage-size";
|
||||
|
||||
export function StorageUsage({ diskSpace }: StorageUsageProps) {
|
||||
export function StorageUsage({ diskSpace, diskSpaceError, onRetry }: StorageUsageProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const getErrorMessage = (error: string) => {
|
||||
switch (error) {
|
||||
case "disk_detection_failed":
|
||||
return t("storageUsage.errors.detectionFailed");
|
||||
case "server_error":
|
||||
return t("storageUsage.errors.serverError");
|
||||
default:
|
||||
return t("storageUsage.errors.unknown");
|
||||
}
|
||||
};
|
||||
|
||||
if (diskSpaceError) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardContent className="">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<IconDatabaseCog className="text-gray-500" size={24} />
|
||||
{t("storageUsage.title")}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3 py-4">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<IconAlertCircle size={20} />
|
||||
<span className="text-sm font-medium">{t("storageUsage.errors.title")}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{getErrorMessage(diskSpaceError)}</p>
|
||||
{onRetry && (
|
||||
<Button variant="outline" size="sm" onClick={onRetry} className="w-fit">
|
||||
<IconRefresh size={16} className="mr-2" />
|
||||
{t("storageUsage.retry")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!diskSpace) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardContent className="">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<IconDatabaseCog className="text-gray-500" size={24} />
|
||||
{t("storageUsage.title")}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{t("storageUsage.loading")}</span>
|
||||
<span>{t("storageUsage.loading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardContent className="">
|
||||
|
@@ -18,6 +18,7 @@ export function useDashboard() {
|
||||
diskAvailableGB: number;
|
||||
uploadAllowed: boolean;
|
||||
} | null>(null);
|
||||
const [diskSpaceError, setDiskSpaceError] = useState<string | null>(null);
|
||||
const [recentFiles, setRecentFiles] = useState<any[]>([]);
|
||||
const [recentShares, setRecentShares] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -34,24 +35,48 @@ export function useDashboard() {
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
const [diskSpaceRes, filesRes, sharesRes] = await Promise.all([getDiskSpace(), listFiles(), listUserShares()]);
|
||||
// Load disk space separately to not block other data
|
||||
const loadDiskSpace = async () => {
|
||||
try {
|
||||
const diskSpaceRes = await getDiskSpace();
|
||||
setDiskSpace(diskSpaceRes.data);
|
||||
setDiskSpaceError(null);
|
||||
} catch (error: any) {
|
||||
console.warn("Failed to load disk space:", error);
|
||||
setDiskSpace(null);
|
||||
|
||||
setDiskSpace(diskSpaceRes.data);
|
||||
// Check for specific disk space detection error
|
||||
if (error.response?.status === 503 && error.response?.data?.code === "DISK_SPACE_DETECTION_FAILED") {
|
||||
setDiskSpaceError("disk_detection_failed");
|
||||
} else if (error.response?.status >= 500) {
|
||||
setDiskSpaceError("server_error");
|
||||
} else {
|
||||
setDiskSpaceError("unknown_error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const allFiles = filesRes.data.files || [];
|
||||
const sortedFiles = [...allFiles].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
// Load files and shares (these should work even if disk space fails)
|
||||
const loadFilesAndShares = async () => {
|
||||
const [filesRes, sharesRes] = await Promise.all([listFiles(), listUserShares()]);
|
||||
|
||||
setRecentFiles(sortedFiles.slice(0, 5));
|
||||
const allFiles = filesRes.data.files || [];
|
||||
const sortedFiles = [...allFiles].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
setRecentFiles(sortedFiles.slice(0, 5));
|
||||
|
||||
const allShares = sharesRes.data.shares || [];
|
||||
const sortedShares = [...allShares].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
const allShares = sharesRes.data.shares || [];
|
||||
const sortedShares = [...allShares].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
setRecentShares(sortedShares.slice(0, 5));
|
||||
};
|
||||
|
||||
setRecentShares(sortedShares.slice(0, 5));
|
||||
// Load everything in parallel but handle errors separately
|
||||
await Promise.allSettled([loadDiskSpace(), loadFilesAndShares()]);
|
||||
} catch (error) {
|
||||
console.error("Critical dashboard error:", error);
|
||||
toast.error(t("dashboard.loadError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -76,6 +101,7 @@ export function useDashboard() {
|
||||
return {
|
||||
isLoading,
|
||||
diskSpace,
|
||||
diskSpaceError,
|
||||
recentFiles,
|
||||
recentShares,
|
||||
modals: {
|
||||
|
@@ -19,6 +19,7 @@ export default function DashboardPage() {
|
||||
const {
|
||||
isLoading,
|
||||
diskSpace,
|
||||
diskSpaceError,
|
||||
recentFiles,
|
||||
recentShares,
|
||||
modals,
|
||||
@@ -32,6 +33,10 @@ export default function DashboardPage() {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
const handleRetryDiskSpace = async () => {
|
||||
await loadDashboardData();
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<FileManagerLayout
|
||||
@@ -40,7 +45,7 @@ export default function DashboardPage() {
|
||||
showBreadcrumb={false}
|
||||
title={t("dashboard.pageTitle")}
|
||||
>
|
||||
<StorageUsage diskSpace={diskSpace} />
|
||||
<StorageUsage diskSpace={diskSpace} diskSpaceError={diskSpaceError} onRetry={handleRetryDiskSpace} />
|
||||
<QuickAccessCards />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
|
@@ -24,6 +24,8 @@ export interface StorageUsageProps {
|
||||
diskAvailableGB: number;
|
||||
uploadAllowed: boolean;
|
||||
} | null;
|
||||
diskSpaceError?: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export interface DashboardModalsProps {
|
||||
|
@@ -23,7 +23,7 @@ docker buildx build \
|
||||
--no-cache \
|
||||
-t kyantech/palmr:latest \
|
||||
-t kyantech/palmr:$TAG \
|
||||
--load \
|
||||
--push \
|
||||
.
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
|
Reference in New Issue
Block a user