diff --git a/admin/inertia/components/StorageProjectionBar.tsx b/admin/inertia/components/StorageProjectionBar.tsx new file mode 100644 index 0000000..36bb214 --- /dev/null +++ b/admin/inertia/components/StorageProjectionBar.tsx @@ -0,0 +1,122 @@ +import classNames from '~/lib/classNames' +import { formatBytes } from '~/lib/util' +import { IconAlertTriangle, IconServer } from '@tabler/icons-react' + +interface StorageProjectionBarProps { + totalSize: number // Total disk size in bytes + currentUsed: number // Currently used space in bytes + projectedAddition: number // Additional space that will be used in bytes +} + +export default function StorageProjectionBar({ + totalSize, + currentUsed, + projectedAddition, +}: StorageProjectionBarProps) { + const projectedTotal = currentUsed + projectedAddition + const currentPercent = (currentUsed / totalSize) * 100 + const projectedPercent = (projectedAddition / totalSize) * 100 + const projectedTotalPercent = (projectedTotal / totalSize) * 100 + const remainingAfter = totalSize - projectedTotal + const willExceed = projectedTotal > totalSize + + // Determine warning level based on projected total + const getProjectedColor = () => { + if (willExceed) return 'bg-desert-red' + if (projectedTotalPercent >= 90) return 'bg-desert-orange' + if (projectedTotalPercent >= 75) return 'bg-desert-tan' + return 'bg-desert-olive' + } + + const getProjectedGlow = () => { + if (willExceed) return 'shadow-desert-red/50' + if (projectedTotalPercent >= 90) return 'shadow-desert-orange/50' + if (projectedTotalPercent >= 75) return 'shadow-desert-tan/50' + return 'shadow-desert-olive/50' + } + + return ( +
+
+
+ + Storage +
+
+ {formatBytes(projectedTotal, 1)} / {formatBytes(totalSize, 1)} + {projectedAddition > 0 && ( + + (+{formatBytes(projectedAddition, 1)} selected) + + )} +
+
+ + {/* Progress bar */} +
+
+ {/* Current usage - darker/subdued */} +
+ {/* Projected addition - highlighted */} + {projectedAddition > 0 && ( +
+ )} +
+ + {/* Percentage label */} +
15 + ? 'left-3 text-desert-white drop-shadow-md' + : 'right-3 text-desert-green' + )} + > + {Math.round(projectedTotalPercent)}% +
+
+ + {/* Legend and warnings */} +
+
+
+
+ Current ({formatBytes(currentUsed, 1)}) +
+ {projectedAddition > 0 && ( +
+
+ + Selected (+{formatBytes(projectedAddition, 1)}) + +
+ )} +
+ + {willExceed ? ( +
+ + Exceeds available space by {formatBytes(projectedTotal - totalSize, 1)} +
+ ) : ( +
+ {formatBytes(remainingAfter, 1)} will remain free +
+ )} +
+
+ ) +} diff --git a/admin/inertia/pages/easy-setup/index.tsx b/admin/inertia/pages/easy-setup/index.tsx index 31c5d0d..f775398 100644 --- a/admin/inertia/pages/easy-setup/index.tsx +++ b/admin/inertia/pages/easy-setup/index.tsx @@ -1,6 +1,6 @@ import { Head, router } from '@inertiajs/react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } from 'react' import AppLayout from '~/layouts/AppLayout' import StyledButton from '~/components/StyledButton' import api from '~/lib/api' @@ -10,9 +10,11 @@ import CategoryCard from '~/components/CategoryCard' import TierSelectionModal from '~/components/TierSelectionModal' import LoadingSpinner from '~/components/LoadingSpinner' import Alert from '~/components/Alert' +import StorageProjectionBar from '~/components/StorageProjectionBar' import { IconCheck } from '@tabler/icons-react' import { useNotifications } from '~/context/NotificationContext' import useInternetStatus from '~/hooks/useInternetStatus' +import { useSystemInfo } from '~/hooks/useSystemInfo' import classNames from 'classnames' import { CuratedCategory, CategoryTier, CategoryResource } from '../../../types/downloads' @@ -51,6 +53,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim const { addNotification } = useNotifications() const { isOnline } = useInternetStatus() const queryClient = useQueryClient() + const { data: systemInfo } = useSystemInfo({ enabled: true }) const anySelectionMade = selectedServices.length > 0 || @@ -144,6 +147,47 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim return resources } + // Calculate total projected storage from all selections + const projectedStorageBytes = useMemo(() => { + let totalBytes = 0 + + // Add tier resources + const tierResources = getSelectedTierResources() + totalBytes += tierResources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0) + + // Add map collections + if (mapCollections) { + selectedMapCollections.forEach((slug) => { + const collection = mapCollections.find((c) => c.slug === slug) + if (collection) { + totalBytes += collection.resources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0) + } + }) + } + + // Add ZIM collections + if (zimCollections) { + selectedZimCollections.forEach((slug) => { + const collection = zimCollections.find((c) => c.slug === slug) + if (collection) { + totalBytes += collection.resources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0) + } + }) + } + + return totalBytes + }, [selectedTiers, selectedMapCollections, selectedZimCollections, categories, mapCollections, zimCollections]) + + // Get primary disk/filesystem info for storage projection + // Try disk array first (Linux/production), fall back to fsSize (Windows/dev) + const primaryDisk = systemInfo?.disk?.[0] + const primaryFs = systemInfo?.fsSize?.[0] + const storageInfo = primaryDisk + ? { totalSize: primaryDisk.totalSize, totalUsed: primaryDisk.totalUsed } + : primaryFs + ? { totalSize: primaryFs.size, totalUsed: primaryFs.used } + : null + const canProceedToNextStep = () => { if (!isOnline) return false // Must be online to proceed if (currentStep === 1) return true // Can skip app installation @@ -657,6 +701,15 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{renderStepIndicator()} + {storageInfo && ( +
+ +
+ )}
{currentStep === 1 && renderStep1()} {currentStep === 2 && renderStep2()}